diff --git a/README.md b/README.md index 4109f46..e584d5a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

An offline-first branching dialogue workstation for Unity and Pixel Crushers.
- Write, localize, review, playtest, version, and export a story from macOS or an Android tablet. + Write, localize, review, playtest, version, and export a story from macOS, Windows, or an Android tablet.

@@ -49,32 +49,32 @@ The screenshots use the disposable demo project created by [`scripts/create-demo - A visual graph for line, choice, junction, and cross-conversation flow, with direct connector dragging and create-on-drop. - Permanent Line IDs, stable Pixel Crushers numeric mappings, conditions, Lua, sequences, tags, variables, cast metadata, and voice context. -- Side-by-side languages, translation coverage, local validation, read view, text-only playtest, beat map, review snapshots, and search. +- Side-by-side languages, a 47-language game catalog with BCP 47 custom tags, translation coverage, local validation, read view, text-only playtest, beat map, review snapshots, and search. - Autosave with a visible state, SQLite/WAL history, rolling and named backups, deterministic JSON, and reversible or confirmed destructive actions. - Voice-actor PDFs, studio XLSX/CSV, screenplay Markdown, word/minute budgets, audio manifests, and Pixel Crushers JSON. - Optional local Git checkpoints, branches, GitHub sync, and pull-request review from inside Reed. - A local stdio MCP server and CLI backed by the same versioned core contracts as the editor. -## macOS and Android +## Desktop and Android -Reed has two native application shells over equivalent project rules. +Reed has native desktop and Android application shells over equivalent project rules. -| | macOS | Android / Daylight DC-1 | -|---|---|---| -| Interface | Electron, React, and React Flow | Kotlin and Jetpack Compose | -| Current target | Apple silicon | Android 13+; tested on Daylight DC-1 | -| Storage | `@reed/core`, SQLite in WAL mode | Android `ReedCore`, SQLite in WAL mode | -| Input | Keyboard, mouse, and trackpad | Touch, hardware keyboard, mouse, trackpad, and Wacom EMR pen | -| Canvas | Marquee select, live node drag, connector drop-to-create, trackpad pan, focal zoom | Responsive panes, pen hover, side-button marquee, connector drag, two-finger pan, focal pinch zoom | -| Git | Bundled/system `git` and GitHub CLI credential store | Embedded JGit and optional Android Keystore token | -| External agents | Local stdio MCP or CLI | Explicit foreground Remote Agent Access session | -| Offline default | Yes | Yes | +| | macOS | Windows | Android / Daylight DC-1 | +|---|---|---|---| +| Interface | Electron, React, and React Flow | Electron, React, and React Flow | Kotlin and Jetpack Compose | +| Current target | Apple silicon | Windows 10/11 x64 | Android 13+; tested on Daylight DC-1 | +| Storage | `@reed/core`, SQLite in WAL mode | `@reed/core`, SQLite in WAL mode | Android `ReedCore`, SQLite in WAL mode | +| Input | Keyboard, mouse, and trackpad | Keyboard, mouse, and precision touchpad | Touch, hardware keyboard, mouse, trackpad, and Wacom EMR pen | +| Canvas | Marquee select, live node drag, connector drop-to-create, trackpad pan, focal zoom | Same desktop graph interactions | Responsive panes, pen hover, side-button marquee, connector drag, two-finger pan, focal pinch zoom | +| Git | System `git`; bundled or system GitHub CLI | Git for Windows and GitHub CLI | Embedded JGit and optional Android Keystore token | +| External agents | Local stdio MCP or CLI | Local stdio MCP or CLI | Explicit foreground Remote Agent Access session | +| Offline default | Yes | Yes | Yes | -### macOS +### macOS and Windows Download the latest Apple-silicon build from [GitHub Releases](https://github.com/is2b007/Reed/releases/latest), or build from source below. Published binary releases currently trail the active source tree, so contributors testing the newest Android, GitHub, and remote-agent work should build from `main`. -The current development app is locally signed but not Apple-notarized. If macOS blocks it, Control-click **Reed.app**, choose **Open**, and confirm once. Intel macOS, Windows, and Linux packages are not currently produced. +The macOS development app is locally signed but not Apple-notarized. If macOS blocks it, Control-click **Reed.app**, choose **Open**, and confirm once. The Windows ZIP is unsigned, so Windows may show a SmartScreen warning. Optional desktop GitHub collaboration uses the locally installed Git and GitHub CLI when a compatible CLI is not bundled. Build a local application bundle: @@ -83,10 +83,11 @@ pnpm install --frozen-lockfile CI=true pnpm typecheck CI=true pnpm exec vitest run --no-file-parallelism --maxWorkers=1 CI=true pnpm test:offline -CI=true pnpm --filter @reed/editor package +CI=true pnpm desktop:make:mac +CI=true pnpm desktop:make:windows ``` -The application is written to `apps/editor/out/Reed-darwin-arm64/Reed.app`. +The macOS DMG/ZIP and Windows x64 ZIP are written beneath `apps/editor/out/make/`. Building Windows from macOS downloads the target Electron runtime and target-native `better-sqlite3` prebuild; the resulting archive runs without a package-install step. ### Android and Daylight DC-1 @@ -110,7 +111,7 @@ Read [Android and Daylight DC-1 setup](docs/ANDROID_DC1.md) and the [Android arc ```mermaid flowchart LR - UI["macOS or Android editor"] --> Core["Reed Core contracts"] + UI["macOS, Windows, or Android editor"] --> Core["Reed Core contracts"] CLI["Local CLI"] --> Core MCP["Local stdio MCP"] --> Core Core --> DB["SQLite + WAL"] @@ -225,6 +226,7 @@ The platform-level view is in [ARCHITECTURE.md](ARCHITECTURE.md). | [AI MCP playbook](docs/AI_MCP_PLAYBOOK.md) | Safe agent operating sequence | | [Project history](docs/PROJECT_HISTORY.md) | Backups, local Git, optional GitHub, and pull requests | | [Voice production](docs/VOICE_PRODUCTION.md) | Actor and studio export workflow | +| [Languages and game exports](docs/LOCALIZATION_EXPORTS.md) | BCP 47 catalog plus Unity, Godot, Unreal, Yarn, PO, XLIFF, JSON, and CSV handoff | | [Offline contract](docs/OFFLINE_CONTRACT.md) | Allowed networking and release acceptance | | [Android / DC-1](docs/ANDROID_DC1.md) | Build, install, input, Git, and device verification | | [Remote Android MCP](docs/REMOTE_MCP_ANDROID.md) | Claude connector model and relay deployment | diff --git a/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt b/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt index 00afa68..2964078 100644 --- a/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt +++ b/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt @@ -77,6 +77,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Source import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Translate @@ -166,6 +167,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.reed.core.DialogueNodeRecord +import com.reed.core.GameLanguages import com.reed.core.NodeKind import com.reed.core.ProjectOverview import com.reed.core.TextStatus @@ -433,6 +435,7 @@ private fun ProjectScreen( Workspace.VARIABLES -> VariableWorkspace(state, viewModel, Modifier.weight(1f)) Workspace.GIT -> GitWorkspace(state, viewModel, onCheckpoint, Modifier.weight(1f)) Workspace.AGENT -> AgentAccessWorkspace(state, viewModel, Modifier.weight(1f)) + Workspace.SETTINGS -> ProjectSettingsWorkspace(state, viewModel, Modifier.weight(1f)) } } } @@ -519,6 +522,7 @@ private fun WorkspaceRail(selected: Workspace, viewModel: ReedViewModel) { Workspace.VARIABLES -> Icons.Outlined.DataObject Workspace.GIT -> Icons.Outlined.Source Workspace.AGENT -> Icons.Outlined.Hub + Workspace.SETTINGS -> Icons.Outlined.Settings } val isSelected = workspace == selected Column( @@ -2103,6 +2107,129 @@ private fun StatusBar(state: ReedUiState) { } } +@Composable +private fun ProjectSettingsWorkspace(state: ReedUiState, viewModel: ReedViewModel, modifier: Modifier = Modifier) { + val project = state.activeProject ?: return + var languages by remember(project.id, project.updatedAt) { mutableStateOf(project.languages.toSet()) } + var defaultLanguage by remember(project.id, project.updatedAt) { mutableStateOf(project.defaultLanguage) } + var query by remember { mutableStateOf("") } + var customTag by remember { mutableStateOf("") } + var defaultMenuOpen by remember { mutableStateOf(false) } + val filtered = remember(query) { + val term = query.trim().lowercase() + if (term.isBlank()) GameLanguages.catalog else GameLanguages.catalog.filter { + it.code.lowercase().contains(term) || it.englishName.lowercase().contains(term) || it.nativeName.lowercase().contains(term) + } + } + val canonicalCustom = GameLanguages.canonicalize(customTag) + val customValid = GameLanguages.isValidTag(canonicalCustom) && languages.none { it.equals(canonicalCustom, true) } + val changed = languages.toList() != project.languages || defaultLanguage != project.defaultLanguage + + fun setLanguages(next: Set) { + languages = next + if (next.isNotEmpty() && defaultLanguage !in next) defaultLanguage = next.first() + } + + LazyColumn( + modifier.fillMaxSize().padding(horizontal = 18.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 18.dp), + ) { + item { + Text("Project settings", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text("Configure the language contract stored with this project. Every change is versioned and remains local.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(18.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Languages", fontWeight = FontWeight.SemiBold) + Text("IETF BCP 47 tags · ${languages.size} enabled", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton(onClick = { setLanguages(GameLanguages.gameMarketPreset().toSet()) }) { Text("Game preset") } + } + Spacer(Modifier.height(8.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + languages.forEach { code -> + OutlinedButton(onClick = { setLanguages(languages - code) }, shape = RoundedCornerShape(4.dp)) { + Text(GameLanguages.displayName(code), maxLines = 1) + Spacer(Modifier.width(6.dp)) + Text(code, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + Spacer(Modifier.width(6.dp)) + Text("×") + } + } + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Find a language") }, + placeholder = { Text("Name or tag") }, + leadingIcon = { Icon(Icons.Outlined.Search, null) }, + singleLine = true, + ) + Spacer(Modifier.height(6.dp)) + } + items(filtered, key = { it.code }) { language -> + LanguageCheckbox( + code = language.code, + checked = language.code in languages, + modifier = Modifier.fillMaxWidth(), + onCheckedChange = { checked -> setLanguages(if (checked) languages + language.code else languages - language.code) }, + ) + } + item { + if (filtered.isEmpty()) Text("No catalog result. Add a custom tag below.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = customTag, + onValueChange = { customTag = it }, + modifier = Modifier.weight(1f), + label = { Text("Custom BCP 47 tag") }, + placeholder = { Text("ga or x-fictional") }, + isError = customTag.isNotBlank() && !customValid, + singleLine = true, + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = customValid, + onClick = { setLanguages(languages + canonicalCustom); customTag = "" }, + ) { Text("Add") } + } + Spacer(Modifier.height(14.dp)) + Text("Default writing language", fontWeight = FontWeight.SemiBold) + Text("The source language writers author first. Translation can use any configured pair.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + Box { + OutlinedButton( + onClick = { defaultMenuOpen = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + enabled = languages.isNotEmpty(), + ) { + Icon(Icons.Outlined.Translate, null) + Spacer(Modifier.width(8.dp)) + Text(if (languages.isEmpty()) "Choose a language" else GameLanguages.displayName(defaultLanguage), Modifier.weight(1f)) + Text(defaultLanguage, fontFamily = FontFamily.Monospace) + } + DropdownMenu(defaultMenuOpen, { defaultMenuOpen = false }) { + languages.forEach { code -> DropdownMenuItem( + text = { Text("${GameLanguages.displayName(code)} · $code") }, + onClick = { defaultLanguage = code; defaultMenuOpen = false }, + ) } + } + } + Spacer(Modifier.height(18.dp)) + Button( + onClick = { viewModel.updateProjectLanguages(languages.toList(), defaultLanguage) }, + enabled = changed && languages.isNotEmpty() && defaultLanguage in languages && state.busyLabel == null, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { Text(if (state.busyLabel == "Saving language settings") "Saving…" else "Save language settings") } + Spacer(Modifier.height(8.dp)) + Text("Removing a language from the active set does not erase its authored text. Re-enable the tag to restore it.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + @Composable private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String, List, String) -> Unit) { var name by remember { mutableStateOf("") } @@ -2114,7 +2241,12 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String val nameFocus = remember { FocusRequester() } val slugValid = slug.matches(Regex("^[a-z0-9]+(?:-[a-z0-9]+)*$")) && slug.length >= 2 val canCreate = name.isNotBlank() && slugValid && languages.isNotEmpty() && sourceLanguage in languages - val languageOptions = listOf("en", "el", "it", "de", "griko") + var languageSearch by remember { mutableStateOf("") } + val languageOptions = GameLanguages.catalog.map { it.code }.filter { code -> + val definition = GameLanguages.definition(code) + val term = languageSearch.trim().lowercase() + term.isBlank() || code.lowercase().contains(term) || definition?.englishName?.lowercase()?.contains(term) == true || definition?.nativeName?.lowercase()?.contains(term) == true + } LaunchedEffect(Unit) { nameFocus.requestFocus() } Dialog( @@ -2192,6 +2324,22 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String Text("Project languages", fontWeight = FontWeight.SemiBold) Text("Choose every language this story will contain.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(6.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = languageSearch, + onValueChange = { languageSearch = it }, + modifier = Modifier.weight(1f), + label = { Text("Find language") }, + leadingIcon = { Icon(Icons.Outlined.Search, null) }, + singleLine = true, + ) + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = { + languages = GameLanguages.gameMarketPreset().toSet() + if (sourceLanguage !in languages) sourceLanguage = "en" + }) { Text("Game preset") } + } + Spacer(Modifier.height(6.dp)) if (useTwoLanguageColumns) { languageOptions.chunked(2).forEach { rowLanguages -> Row(Modifier.fillMaxWidth()) { @@ -2239,7 +2387,7 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String Text(sourceLanguage, fontFamily = FontFamily.Monospace) } DropdownMenu(sourceMenuOpen, { sourceMenuOpen = false }) { - languageOptions.filter { it in languages }.forEach { code -> + GameLanguages.catalog.map { it.code }.filter { it in languages }.forEach { code -> DropdownMenuItem( text = { Text("${languageName(code)} · $code") }, onClick = { sourceLanguage = code; sourceMenuOpen = false }, @@ -2516,6 +2664,7 @@ private fun CommandPalette(state: ReedUiState, viewModel: ReedViewModel) { "Open Variables" to { viewModel.setWorkspace(Workspace.VARIABLES) }, "Open Git" to { viewModel.setWorkspace(Workspace.GIT) }, "Open Agent Access" to { viewModel.setWorkspace(Workspace.AGENT) }, + "Open Project Settings" to { viewModel.setWorkspace(Workspace.SETTINGS) }, "Sync Git" to { viewModel.sync() }, "Back to Projects" to { viewModel.closeProject() }, ).filter { it.first.contains(query, ignoreCase = true) } @@ -2575,6 +2724,7 @@ private val Workspace.displayName: String get() = when (this) { Workspace.VARIABLES -> "Variables" Workspace.GIT -> "Git versioning" Workspace.AGENT -> "Agent access" + Workspace.SETTINGS -> "Project settings" } private val Workspace.shortLabel: String get() = when (this) { @@ -2584,6 +2734,7 @@ private val Workspace.shortLabel: String get() = when (this) { Workspace.VARIABLES -> "Variables" Workspace.GIT -> "Git" Workspace.AGENT -> "Agent" + Workspace.SETTINGS -> "Settings" } private val TextStatus.displayName: String @@ -2601,11 +2752,4 @@ private fun speakerAccentColor(characterId: String?): Color { return accents[(characterId.hashCode() and Int.MAX_VALUE) % accents.size] } -private fun languageName(code: String) = when (code) { - "en" -> "English" - "el" -> "Greek" - "it" -> "Italian" - "de" -> "German" - "griko" -> "Griko" - else -> code -} +private fun languageName(code: String) = GameLanguages.displayName(code) diff --git a/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt b/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt index 95db603..f2f5ba6 100644 --- a/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt +++ b/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt @@ -26,7 +26,7 @@ import kotlinx.serialization.json.JsonPrimitive import java.io.File import java.security.MessageDigest -enum class Workspace { GRAPH, CHARACTERS, VARIABLES, TRANSLATION, GIT, AGENT } +enum class Workspace { GRAPH, CHARACTERS, VARIABLES, TRANSLATION, GIT, AGENT, SETTINGS } data class RepositoryConnection( val directory: File, @@ -217,6 +217,17 @@ class ReedViewModel(application: Application) : AndroidViewModel(application) { fun selectLanguage(language: String) = mutableState.update { it.copy(selectedLanguage = language) } + fun updateProjectLanguages(languages: List, defaultLanguage: String) { + val project = state.value.activeProject ?: return + io("Saving language settings") { + val result = core.updateProjectLanguages(project.id, languages, defaultLanguage, project.version) + reloadActive("Language settings saved") + mutableState.update { current -> + current.copy(selectedLanguage = current.selectedLanguage.takeIf { it in result.value.languages } ?: result.value.defaultLanguage) + } + } + } + fun setWorkspace(workspace: Workspace) = mutableState.update { it.copy(workspace = workspace) } fun updateAgentRelayUrl(value: String) = mutableState.update { it.copy(agentRelayUrl = value) } diff --git a/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt b/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt index 3b5e462..d387e9f 100644 --- a/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt +++ b/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt @@ -1,6 +1,7 @@ package com.reed.editor.agent import com.reed.core.NodeKind +import com.reed.core.GameLanguages import com.reed.core.ProjectSnapshot import com.reed.core.ReedCore import com.reed.core.TextStatus @@ -107,6 +108,16 @@ class AndroidMcpDispatcher( "list_variables" -> json.encodeToJsonElement(snapshot.variables) "find_lines" -> findLines(snapshot, arguments) "get_validation_report" -> validationReport(snapshot) + "update_project" -> { + val result = core.updateProjectLanguages( + projectId = projectId, + languages = arguments.optionalStringArray("languages") ?: snapshot.project.languages, + defaultLanguage = arguments.optionalString("defaultLanguage") ?: snapshot.project.defaultLanguage, + expectedVersion = arguments.long("expectedVersion"), + source = "agent", + ) + mutation(json.encodeToJsonElement(result.value), result.version, result.changeSequence) + } "create_scene" -> { val result = core.createScene( projectId = projectId, @@ -320,6 +331,13 @@ class AndroidMcpDispatcher( put("textStatuses", JsonArray(TextStatus.entries.map { JsonPrimitive(it.name.lowercase()) })) put("agentMaximumTextStatus", "polished") put("languages", json.encodeToJsonElement(core.projectOverview(projectId).project.languages)) + put("languageTagStandard", "IETF BCP 47") + put("gameLanguageCatalog", buildJsonArray { + GameLanguages.catalog.forEach { language -> add(buildJsonObject { + put("code", language.code); put("englishName", language.englishName); put("nativeName", language.nativeName) + put("direction", if (language.rightToLeft) "rtl" else "ltr") + }) } + }) put("transport", "remote-streamable-http-via-user-authorized-tablet-bridge") put("gitAvailable", false) } @@ -359,6 +377,7 @@ class AndroidMcpDispatcher( tool("list_variables", "List declared dialogue variables."), tool("find_lines", "Search local dialogue text.", optional = mapOf("query" to stringSchema(), "characterId" to stringSchema(), "limit" to integerSchema())), tool("get_validation_report", "Validate the active project."), + tool("update_project", "Update the active project's versioned language list and default writing language.", required = mapOf("expectedVersion" to integerSchema()), optional = mapOf("languages" to arraySchema(), "defaultLanguage" to stringSchema())), tool("create_scene", "Create an agent-sourced scene.", required = mapOf("name" to stringSchema(), "slug" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("beatRef" to stringSchema(), "sortOrder" to integerSchema())), tool("create_conversation", "Create a conversation with a permanent START node.", required = mapOf("sceneId" to stringSchema(), "title" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("description" to stringSchema())), tool("add_dialogue_node", "Add a Draft line, choice, or group/junction node.", required = mapOf("conversationId" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("kind" to enumSchema("line", "choice", "group"), "speakerCharacterId" to stringSchema(), "language" to stringSchema(), "text" to stringSchema(), "menuText" to stringSchema(), "x" to numberSchema(), "y" to numberSchema())), @@ -430,7 +449,7 @@ class AndroidMcpDispatcher( companion object { private val SUPPORTED_PROTOCOLS = setOf("2025-06-18", "2025-03-26", "2024-11-05") private val MUTATING_TOOLS = setOf( - "create_scene", "create_conversation", "add_dialogue_node", "update_node_text", "set_line_text", + "update_project", "create_scene", "create_conversation", "add_dialogue_node", "update_node_text", "set_line_text", "set_node_menu_text", "set_node_speaker", "update_node", "set_node_conditions", "set_node_sequence", "add_tags", "set_line_status", "link_nodes", "unlink_nodes", "delete_node", "upsert_character", "declare_variable", ) diff --git a/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt b/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt new file mode 100644 index 0000000..e95403d --- /dev/null +++ b/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt @@ -0,0 +1,30 @@ +package com.reed.editor + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +class LanguageSettingsRegressionTest { + @Test + fun androidExposesVersionedCatalogSettingsWithoutNetwork() { + val activity = projectFile("app/src/main/java/com/reed/editor/MainActivity.kt").readText() + val viewModel = projectFile("app/src/main/java/com/reed/editor/ReedViewModel.kt").readText() + + assertTrue(activity.contains("Workspace.SETTINGS -> ProjectSettingsWorkspace")) + assertTrue(activity.contains("GameLanguages.gameMarketPreset()")) + assertTrue(activity.contains("Custom BCP 47 tag")) + assertTrue(activity.contains("Save language settings")) + assertTrue(viewModel.contains("fun updateProjectLanguages(languages: List, defaultLanguage: String)")) + assertTrue(viewModel.contains("core.updateProjectLanguages(project.id, languages, defaultLanguage, project.version)")) + } + + private fun projectFile(relativePath: String): File { + var current = File(requireNotNull(System.getProperty("user.dir"))) + repeat(6) { + val candidate = current.resolve(relativePath) + if (candidate.isFile) return candidate + current = current.parentFile ?: current + } + error("Project file not found: $relativePath") + } +} diff --git a/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt b/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt index 868a3ae..f558fe2 100644 --- a/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt +++ b/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt @@ -39,6 +39,7 @@ class AndroidMcpDispatcherTest { val tools = dispatcher.dispatch(request(1, "tools/list", "{}"))!!.jsonObject .getValue("result").jsonObject.getValue("tools").jsonArray assertTrue(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content == "add_dialogue_node" }) + assertTrue(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content == "update_project" }) assertFalse(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content?.contains("git") == true }) val snapshot = core.exportSnapshot(core.listProjects().single().id) @@ -52,6 +53,18 @@ class AndroidMcpDispatcherTest { assertEquals("Καλημέρα — buongiorno", refreshed.nodes.last().texts.first { it.language == "en" }.text) } + @Test + fun agentCanConfigureCanonicalProjectLanguagesThroughCore() { + val project = core.exportSnapshot(core.listProjects().single().id).project + val response = toolCall(4, "update_project", """{ + "languages":["en","el","pt_br"],"defaultLanguage":"pt-br","expectedVersion":${project.version} + }""") + assertFalse(toolPayload(response).getValue("isError").jsonPrimitive.content.toBoolean()) + val updated = core.projectOverview(project.id).project + assertEquals(listOf("en", "el", "pt-BR"), updated.languages) + assertEquals("pt-BR", updated.defaultLanguage) + } + @Test fun agentCannotPromoteTextToFinal() { val snapshot = core.exportSnapshot(core.listProjects().single().id) diff --git a/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt b/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt new file mode 100644 index 0000000..6c7b078 --- /dev/null +++ b/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt @@ -0,0 +1,82 @@ +package com.reed.core + +import java.util.Locale + +data class GameLanguageDefinition( + val code: String, + val englishName: String, + val nativeName: String, + val rightToLeft: Boolean = false, + val steamCode: String? = null, + val legacy: Boolean = false, +) + +object GameLanguages { + val catalog = listOf( + GameLanguageDefinition("ar", "Arabic", "العربية", true, "arabic"), + GameLanguageDefinition("bg", "Bulgarian", "Български", steamCode = "bulgarian"), + GameLanguageDefinition("ca", "Catalan", "Català"), + GameLanguageDefinition("zh-Hans", "Chinese (Simplified)", "简体中文", steamCode = "schinese"), + GameLanguageDefinition("zh-Hant", "Chinese (Traditional)", "繁體中文", steamCode = "tchinese"), + GameLanguageDefinition("hr", "Croatian", "Hrvatski"), + GameLanguageDefinition("cs", "Czech", "Čeština", steamCode = "czech"), + GameLanguageDefinition("da", "Danish", "Dansk", steamCode = "danish"), + GameLanguageDefinition("nl", "Dutch", "Nederlands", steamCode = "dutch"), + GameLanguageDefinition("en", "English", "English", steamCode = "english"), + GameLanguageDefinition("en-AU", "English (Australia)", "English (Australia)"), + GameLanguageDefinition("en-GB", "English (United Kingdom)", "English (United Kingdom)"), + GameLanguageDefinition("en-US", "English (United States)", "English (United States)"), + GameLanguageDefinition("fil", "Filipino", "Filipino"), + GameLanguageDefinition("fi", "Finnish", "Suomi", steamCode = "finnish"), + GameLanguageDefinition("fr", "French", "Français", steamCode = "french"), + GameLanguageDefinition("fr-CA", "French (Canada)", "Français (Canada)"), + GameLanguageDefinition("de", "German", "Deutsch", steamCode = "german"), + GameLanguageDefinition("el", "Greek", "Ελληνικά", steamCode = "greek"), + GameLanguageDefinition("he", "Hebrew", "עברית", true), + GameLanguageDefinition("hi", "Hindi", "हिन्दी"), + GameLanguageDefinition("hu", "Hungarian", "Magyar", steamCode = "hungarian"), + GameLanguageDefinition("id", "Indonesian", "Bahasa Indonesia", steamCode = "indonesian"), + GameLanguageDefinition("it", "Italian", "Italiano", steamCode = "italian"), + GameLanguageDefinition("ja", "Japanese", "日本語", steamCode = "japanese"), + GameLanguageDefinition("ko", "Korean", "한국어", steamCode = "koreana"), + GameLanguageDefinition("ms", "Malay", "Bahasa Melayu", steamCode = "malay"), + GameLanguageDefinition("no", "Norwegian", "Norsk", steamCode = "norwegian"), + GameLanguageDefinition("fa", "Persian", "فارسی", true), + GameLanguageDefinition("pl", "Polish", "Polski", steamCode = "polish"), + GameLanguageDefinition("pt-PT", "Portuguese (Portugal)", "Português (Portugal)", steamCode = "portuguese"), + GameLanguageDefinition("pt-BR", "Portuguese (Brazil)", "Português (Brasil)", steamCode = "brazilian"), + GameLanguageDefinition("ro", "Romanian", "Română", steamCode = "romanian"), + GameLanguageDefinition("ru", "Russian", "Русский", steamCode = "russian"), + GameLanguageDefinition("sr-Cyrl", "Serbian (Cyrillic)", "Српски"), + GameLanguageDefinition("sr-Latn", "Serbian (Latin)", "Srpski"), + GameLanguageDefinition("sk", "Slovak", "Slovenčina"), + GameLanguageDefinition("sl", "Slovenian", "Slovenščina"), + GameLanguageDefinition("es-ES", "Spanish (Spain)", "Español (España)", steamCode = "spanish"), + GameLanguageDefinition("es-419", "Spanish (Latin America)", "Español (Latinoamérica)", steamCode = "latam"), + GameLanguageDefinition("sv", "Swedish", "Svenska", steamCode = "swedish"), + GameLanguageDefinition("th", "Thai", "ไทย", steamCode = "thai"), + GameLanguageDefinition("tr", "Turkish", "Türkçe", steamCode = "turkish"), + GameLanguageDefinition("uk", "Ukrainian", "Українська", steamCode = "ukrainian"), + GameLanguageDefinition("vi", "Vietnamese", "Tiếng Việt", steamCode = "vietnamese"), + GameLanguageDefinition("griko", "Griko", "Griko", legacy = true), + GameLanguageDefinition("x-griko", "Griko (private-use tag)", "Griko"), + ) + + private val byCode = catalog.associateBy { it.code.lowercase(Locale.ROOT) } + private val tagPattern = Regex("^(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-[A-Za-z0-9]{5,8}|-[0-9][A-Za-z0-9]{3})*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-x(?:-[A-Za-z0-9]{1,8})+)?|x(?:-[A-Za-z0-9]{1,8})+)$") + + fun isValidTag(value: String): Boolean = value.length <= 63 && tagPattern.matches(value) + + fun canonicalize(value: String): String { + val normalized = value.trim().replace('_', '-') + if (normalized.equals("griko", ignoreCase = true)) return "griko" + if (!isValidTag(normalized)) return normalized + return Locale.forLanguageTag(normalized).toLanguageTag().takeUnless { it == "und" } ?: normalized + } + + fun definition(code: String): GameLanguageDefinition? = byCode[canonicalize(code).lowercase(Locale.ROOT)] + + fun displayName(code: String): String = definition(code)?.englishName ?: code + + fun gameMarketPreset(): List = catalog.filter { it.steamCode != null }.map { it.code } +} diff --git a/apps/android/core/src/main/java/com/reed/core/ReedCore.kt b/apps/android/core/src/main/java/com/reed/core/ReedCore.kt index b2ff8e7..3f9056b 100644 --- a/apps/android/core/src/main/java/com/reed/core/ReedCore.kt +++ b/apps/android/core/src/main/java/com/reed/core/ReedCore.kt @@ -68,8 +68,11 @@ class ReedCore( ): ProjectSnapshot { require(name.isNotBlank()) { "Project name is required" } require(slug.matches(Regex("^[a-z0-9][a-z0-9-]{1,62}$"))) { "Invalid project slug" } - require(languages.isNotEmpty() && languages.distinct().size == languages.size) { "Languages must be unique" } - require(defaultLanguage in languages) { "Default language must be configured" } + val normalizedLanguages = languages.map(GameLanguages::canonicalize) + val normalizedDefaultLanguage = GameLanguages.canonicalize(defaultLanguage) + require(normalizedLanguages.all(GameLanguages::isValidTag)) { "Languages must use valid BCP 47 tags" } + require(normalizedLanguages.isNotEmpty() && normalizedLanguages.map(String::lowercase).distinct().size == normalizedLanguages.size) { "Languages must be unique" } + require(normalizedDefaultLanguage in normalizedLanguages) { "Default language must be configured" } val now = Instant.now().toString() val projectId = uuid() val levelId = uuid() @@ -84,8 +87,8 @@ class ReedCore( id = projectId, name = name.trim(), slug = slug, - languages = languages, - defaultLanguage = defaultLanguage, + languages = normalizedLanguages, + defaultLanguage = normalizedDefaultLanguage, version = 1, createdAt = now, updatedAt = now, @@ -115,14 +118,14 @@ class ReedCore( lineId = "${prefix}_start", entryNumericId = 0, kind = NodeKind.START, - spokenLanguage = defaultLanguage, + spokenLanguage = normalizedDefaultLanguage, lineStatus = TextStatus.PLACEHOLDER, voiceStatus = VoiceStatus.NONE, x = 48.0, y = 96.0, createdAt = now, updatedAt = now, - texts = languages.map { NodeTextRecord(it, "", null, VerificationStatus.UNVERIFIED, now, now) }, + texts = normalizedLanguages.map { NodeTextRecord(it, "", null, VerificationStatus.UNVERIFIED, now, now) }, ) ) ) @@ -133,6 +136,50 @@ class ReedCore( return snapshot } + fun updateProjectLanguages( + projectId: String, + languages: List, + defaultLanguage: String, + expectedVersion: Long, + source: String = "human", + ): MutationResult = transaction { db -> + validateSource(source) + val normalizedLanguages = languages.map(GameLanguages::canonicalize) + val normalizedDefaultLanguage = GameLanguages.canonicalize(defaultLanguage) + require(normalizedLanguages.all(GameLanguages::isValidTag)) { "Languages must use valid BCP 47 tags" } + require(normalizedLanguages.isNotEmpty()) { "Select at least one project language" } + require(normalizedLanguages.map(String::lowercase).distinct().size == normalizedLanguages.size) { "Languages must be unique" } + require(normalizedDefaultLanguage in normalizedLanguages) { "Default language must be configured" } + val before = readProject(db, projectId) + checkVersion(before.version, expectedVersion) + val now = Instant.now().toString() + db.execSQL( + "UPDATE projects SET languages_json = ?, default_language = ? WHERE id = ?", + arrayOf(json.encodeToString(normalizedLanguages), normalizedDefaultLanguage, projectId), + ) + val nodeIds = db.rawQuery("SELECT id FROM nodes WHERE project_id = ?", arrayOf(projectId)).use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } + } + for (nodeId in nodeIds) for (language in normalizedLanguages) { + db.execSQL( + "INSERT OR IGNORE INTO node_texts (node_id,language,text_value,menu_text,verification_status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", + arrayOf(nodeId, language, "", null, VerificationStatus.UNVERIFIED.wire, now, now), + ) + } + val version = incrementVersion(db, projectId, expectedVersion, now) + val after = before.copy( + languages = normalizedLanguages, + defaultLanguage = normalizedDefaultLanguage, + version = version, + updatedAt = now, + ) + val sequence = recordMutation( + db, projectId, version, "update_project_languages", "project", projectId, + json.encodeToString(before), json.encodeToString(after), source, + ) + MutationResult(after, version, sequence) + } + fun importProjectFile(file: File, source: String = "system"): ProjectSnapshot { require(file.isFile) { "Project export not found: ${file.path}" } return importSnapshot(file.readText(Charsets.UTF_8), source) diff --git a/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt b/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt new file mode 100644 index 0000000..ce0e79a --- /dev/null +++ b/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt @@ -0,0 +1,18 @@ +package com.reed.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GameLanguagesTest { + @Test + fun catalogCoversCommonGameLocalesAndCanonicalizesTags() { + assertTrue(GameLanguages.catalog.size >= 40) + assertTrue(GameLanguages.catalog.count { it.steamCode != null } >= 29) + assertEquals(GameLanguages.catalog.size, GameLanguages.catalog.map { it.code.lowercase() }.distinct().size) + assertEquals("pt-BR", GameLanguages.canonicalize("pt_br")) + assertEquals("zh-Hans", GameLanguages.canonicalize("zh-hans")) + assertEquals("griko", GameLanguages.canonicalize("GRIKO")) + assertTrue(GameLanguages.isValidTag("x-studio-dialect")) + } +} diff --git a/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt b/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt index 991aa86..9987b3d 100644 --- a/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt +++ b/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt @@ -80,6 +80,23 @@ class ReedCoreTest { assertEquals(names, restored.displayNames) } + @Test + fun projectLanguageSettingsAreVersionedCanonicalAndCreateMissingTextRows() { + val created = core.createProject("Language Test", "language-test", listOf("en", "el"), "en") + val updated = core.updateProjectLanguages( + created.project.id, + listOf("en", "pt_br", "zh-hans"), + "pt-br", + created.project.version, + ) + + assertEquals(listOf("en", "pt-BR", "zh-Hans"), updated.value.languages) + assertEquals("pt-BR", updated.value.defaultLanguage) + assertEquals(created.project.version + 1, updated.version) + val languages = core.conversationGraph(created.conversations.single().id).nodes.single().texts.map { it.language } + assertTrue(languages.containsAll(listOf("en", "el", "pt-BR", "zh-Hans"))) + } + @Test fun mutationsEnforceOptimisticVersionAndRecordedTextReturnsToRework() { val created = core.createProject("Voice Test", "voice-test") diff --git a/apps/editor/assets/Reed.ico b/apps/editor/assets/Reed.ico new file mode 100644 index 0000000..1f3c442 Binary files /dev/null and b/apps/editor/assets/Reed.ico differ diff --git a/apps/editor/forge.config.ts b/apps/editor/forge.config.ts index 66c1401..73cfe0b 100644 --- a/apps/editor/forge.config.ts +++ b/apps/editor/forge.config.ts @@ -17,8 +17,16 @@ const configDirectory = path.dirname(fileURLToPath(import.meta.url)); const hoistedModules = path.resolve(configDirectory, "../../node_modules"); const dmgStagingDirectory = path.join(tmpdir(), `reed-dmg-${process.pid}`); const dmgStagingApp = path.join(dmgStagingDirectory, "Reed.app"); -const githubCliSource = [process.env.REED_GH_PATH, "/opt/homebrew/bin/gh", "/usr/local/bin/gh"] +const signatureValidationDirectory = path.join(tmpdir(), `reed-signature-${process.pid}`); +const signatureValidationApp = path.join(signatureValidationDirectory, "Reed.app"); +const platformFlag = process.argv.indexOf("--platform"); +const targetPlatform = (platformFlag >= 0 ? process.argv[platformFlag + 1] : process.platform) as NodeJS.Platform; +const githubCliCandidates = targetPlatform === "win32" + ? [process.env.REED_GH_WINDOWS_PATH] + : [process.env.REED_GH_PATH, "/opt/homebrew/bin/gh", "/usr/local/bin/gh"]; +const githubCliSource = githubCliCandidates .find((candidate): candidate is string => Boolean(candidate && existsSync(candidate))); +const prebuildInstallScript = path.join(hoistedModules, "prebuild-install", "bin.js"); const includeNativeRuntime: NonNullable["afterPrune"] = [ (buildPath, _electronVersion, _platform, _arch, callback) => { @@ -34,13 +42,25 @@ const includeNativeRuntime: NonNullable["afterPru const stagedPackage = JSON.parse(await readFile(packagePath, "utf8")) as Record; stagedPackage.type = "commonjs"; await writeFile(packagePath, `${JSON.stringify(stagedPackage, null, 2)}\n`, "utf8"); - await rebuild({ - buildPath, - electronVersion: _electronVersion, - arch: _arch, - onlyModules: ["better-sqlite3"], - force: true, - }); + if (_platform !== process.platform) { + execFileSync(process.execPath, [ + prebuildInstallScript, + "--runtime", "electron", + "--target", _electronVersion, + "--platform", _platform, + "--arch", _arch, + "--verbose", + ], { cwd: path.join(destination, "better-sqlite3"), stdio: "inherit" }); + } else { + await rebuild({ + buildPath, + electronVersion: _electronVersion, + platform: _platform as NodeJS.Platform, + arch: _arch, + onlyModules: ["better-sqlite3"], + force: true, + }); + } })().then(() => callback(), (error: Error) => callback(error)); }, ]; @@ -51,7 +71,7 @@ const config: ForgeConfig = { appBundleId: "com.reed.dialogue-editor", appCategoryType: "public.app-category.developer-tools", name: "Reed", - icon: path.join(configDirectory, "assets", "Reed.icns"), + icon: path.join(configDirectory, "assets", targetPlatform === "win32" ? "Reed.ico" : "Reed.icns"), extraResource: githubCliSource ? [realpathSync(githubCliSource)] : [], osxSign: { identity: "-", @@ -63,17 +83,34 @@ const config: ForgeConfig = { afterPrune: includeNativeRuntime, }, hooks: { + postPackage: async (_forgeConfig, packageResult) => { + if (packageResult.platform !== "darwin") return; + for (const outputPath of packageResult.outputPaths) { + const appPath = path.join(outputPath, "Reed.app"); + execFileSync("/bin/chmod", ["-R", "u+w", appPath]); + execFileSync("/usr/bin/xattr", ["-cr", appPath]); + execFileSync("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], { stdio: "inherit" }); + await rm(signatureValidationDirectory, { recursive: true, force: true }); + await mkdir(signatureValidationDirectory, { recursive: true }); + execFileSync("/usr/bin/ditto", ["--noextattr", "--noqtn", appPath, signatureValidationApp]); + execFileSync("/bin/chmod", ["-R", "u+w", signatureValidationApp]); + execFileSync("/usr/bin/xattr", ["-cr", signatureValidationApp]); + execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", signatureValidationApp], { stdio: "inherit" }); + await rm(signatureValidationDirectory, { recursive: true, force: true }); + } + }, postMake: async (_forgeConfig, makeResults) => { await rm(dmgStagingDirectory, { recursive: true, force: true }); return makeResults; }, }, rebuildConfig: {}, - makers: [new MakerZIP({}, ["darwin"]), new MakerDMG({ + makers: [new MakerZIP({}, ["darwin", "win32"]), new MakerDMG({ format: "ULFO", contents: (options) => { mkdirSync(dmgStagingDirectory, { recursive: true }); execFileSync("/usr/bin/ditto", ["--noextattr", "--noqtn", options.appPath, dmgStagingApp]); + execFileSync("/bin/chmod", ["-R", "u+w", dmgStagingApp]); execFileSync("/usr/bin/xattr", ["-cr", dmgStagingApp]); execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", dmgStagingApp], { stdio: "inherit" }); return [ diff --git a/apps/editor/package.json b/apps/editor/package.json index d1c990b..0c31914 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -2,7 +2,7 @@ "name": "@reed/editor", "productName": "Reed", "version": "0.1.9", - "description": "Offline branching dialogue editor for Unity and Pixel Crushers", + "description": "Offline branching dialogue and localization workspace for game development", "private": true, "type": "module", "main": ".webpack/main", @@ -10,6 +10,9 @@ "start": "electron-forge start", "package": "electron-forge package", "make": "electron-forge make", + "make:mac": "electron-forge make --platform=darwin --arch=arm64", + "make:windows": "electron-forge make --platform=win32 --arch=x64", + "make:desktop": "pnpm make:mac && pnpm make:windows", "build": "electron-forge package", "typecheck": "tsc --noEmit" }, @@ -18,8 +21,9 @@ "@fontsource/ibm-plex-sans": "^5.2.6", "@fontsource/source-serif-4": "^5.2.6", "@reed/core": "workspace:*", + "@reed/schema": "workspace:*", "@xyflow/react": "^12.11.2", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "12.10.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -34,7 +38,7 @@ "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "css-loader": "^7.1.2", - "electron": "43.1.0", + "electron": "41.10.2", "node-loader": "^2.1.0", "style-loader": "^4.0.0", "ts-loader": "^9.5.2", diff --git a/apps/editor/src/app.tsx b/apps/editor/src/app.tsx index c98b669..1961490 100644 --- a/apps/editor/src/app.tsx +++ b/apps/editor/src/app.tsx @@ -8,6 +8,7 @@ import type { CharacterRecord, ConversationGraph, DialogueNodeRecord, MutationRecord, ProjectSnapshot, ProjectGitHubPullRequest, ProjectGitHubStatus, ProjectGitStatus, ProjectRecord, SceneRecord, SearchResult, ValidationIssue, ValidationReport, VariableRecord, } from "@reed/core"; +import { GAME_LANGUAGE_CATALOG, GAME_LANGUAGE_PRESET, canonicalizeLanguageCode, getLanguageDefinition, isLanguageCode } from "@reed/schema"; import type { AppearancePreference, AppearanceState, EditorBootstrap, ExportKind, MutationName, SaveStateEvent, WorkspaceId } from "./contract.js"; import type { RecentProject } from "./recent-projects.js"; import { conditionVariableNames, createPickerPosition, evaluatePlaytestCondition, globalShortcut, nextAppearancePreference, nextMenuIndex, nodeKindOptions, nodePositionForConnectionDrop, parseLanguageList, promptValueIsValid, speakerAccent, variableInitialValueLabel, type AddableNodeKind } from "./ui-logic.js"; @@ -294,6 +295,58 @@ function usePrompt(): PromptRequest { return request; } +function languageOptionLabel(code: string): string { + const language = getLanguageDefinition(code); + if (!language) return code; + return language.nativeName === language.englishName + ? `${language.englishName} · ${code}` + : `${language.englishName} · ${language.nativeName} · ${code}`; +} + +function LanguageConfigurator({ languages, defaultLanguage, onLanguages, onDefaultLanguage, compact = false }: { + languages: string[]; + defaultLanguage: string; + onLanguages(languages: string[]): void; + onDefaultLanguage(language: string): void; + compact?: boolean; +}) { + const [query, setQuery] = useState(""); + const [customCode, setCustomCode] = useState(""); + const selected = new Set(languages.map((language) => language.toLowerCase())); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const visible = normalizedQuery + ? GAME_LANGUAGE_CATALOG.filter((language) => [language.code, language.englishName, language.nativeName].some((value) => value.toLocaleLowerCase().includes(normalizedQuery))) + : GAME_LANGUAGE_CATALOG; + const set = (next: string[]) => { + onLanguages(next); + if (next.length > 0 && !next.includes(defaultLanguage)) onDefaultLanguage(next[0] ?? defaultLanguage); + }; + const toggle = (code: string) => { + if (selected.has(code.toLowerCase())) set(languages.filter((language) => language.toLowerCase() !== code.toLowerCase())); + else set([...languages, code]); + }; + const customCanonical = canonicalizeLanguageCode(customCode); + const customValid = isLanguageCode(customCanonical) && !selected.has(customCanonical.toLowerCase()); + const addCustom = () => { + if (!customValid) return; + set([...languages, customCanonical]); + setCustomCode(""); + }; + const applyGamePreset = () => set([...GAME_LANGUAGE_PRESET]); + return

+

Project languages

Use standard BCP 47 tags. The catalog starts with the major game-market languages, and custom tags remain available for dialects and constructed languages.

{languages.length} enabled
+ {languages.length > 0 &&
{languages.map((code) => )}
} +
+
{visible.map((language) => { + const checked = selected.has(language.code.toLowerCase()); + return ; + })}
+ {visible.length === 0 &&

No catalog matches. Add a valid custom tag below.

} +
+ +
; +} + function ProjectHome({ current, recentProjects, mode, appearance, onMode, onReady, onContinue, onRecentProjects, onAppearance }: { current: EditorBootstrap | null; recentProjects: RecentProject[]; mode: "idle" | "new"; appearance: AppearanceState; onMode(mode: "idle" | "new"): void; onReady(value: EditorBootstrap): void; onContinue(): void; onRecentProjects(projects: RecentProject[]): void; onAppearance(preference: AppearancePreference): Promise }) { const [name, setName] = useState("Untitled dialogue"); const [slug, setSlug] = useState("untitled-dialogue"); @@ -328,7 +381,7 @@ function ProjectHome({ current, recentProjects, mode, appearance, onMode, onRead
ReedOffline dialogue production

Project home

Keep every branch under control.

Move between local projects without closing Reed. Every edit, backup, and export stays on this Mac.

{current &&

Open now

{current.snapshot.project.name}

{current.snapshot.project.languages.map((language) => language.toUpperCase()).join(" · ")} · v{current.snapshot.project.version}

{current.root}
} - {mode === "new" &&

New local project

Project identity

Used for local export filenames. Permanent Line IDs will not change later.
} + {mode === "new" &&

New local project

Project identity

Used for local export filenames. Permanent Line IDs will not change later. setLanguageList(next.join(", "))} onDefaultLanguage={setDefaultLanguage} />
}

On this Mac

Recent projects

{recentProjects.length > 0 && }
{visibleRecent.length > 0 ?
{visibleRecent.map((project) =>
)}
:

No other recent projects.

Open a Reed folder and it will stay here for next time.
}
{error &&
{error}
}
@@ -357,7 +410,7 @@ function ProjectSettingsDialog({ state, onClose, onSaved }: { state: EditorBoots await onSaved(); onClose(); } catch (value) { setError(message(value)); } finally { setSaving(false); } }; - return
void save(event)}>

Project settings

Identity, languages & collaboration

Identity & languages

The slug controls local export filenames. Existing Line IDs and Pixel Crushers IDs remain permanent.

Comma-separated language codes, for example en, el, it. Greek, Griko, Italian, German, and English remain fully local.

GitHub collaboration

When on, Reed can sign in, create or connect repositories, fetch, push, review, and merge from the History workspace. Nothing runs in the background, and Reed never stores your GitHub token.

Use the branch contributors should propose changes into, usually main. Remote PR merges do not silently replace the open SQLite story.

Schema {project.schemaVersion}Project version {project.version}
{error &&
{error}
}
; + return
void save(event)}>

Project settings

Identity, languages & collaboration

Identity

The slug controls local export filenames. Existing Line IDs and Pixel Crushers IDs remain permanent.

setLanguageList(next.join(", "))} onDefaultLanguage={setDefaultLanguage} />

GitHub collaboration

When on, Reed can sign in, create or connect repositories, fetch, push, review, and merge from the History workspace. Nothing runs in the background, and Reed never stores your GitHub token.

Use the branch contributors should propose changes into, usually main. Remote PR merges do not silently replace the open SQLite story.

Schema {project.schemaVersion}Project version {project.version}
{error &&
{error}
}
; } function PreferencesDialog({ appearance, onAppearance, onClose }: { appearance: AppearanceState; onAppearance(preference: AppearancePreference): Promise; onClose(): void }) { @@ -919,7 +972,11 @@ function Production({ snapshot, validation, request, onRequestHandled }: { snaps return stats; }, [snapshot.characters, voiceLanguage, voiceSelection.selected]); const run = useCallback(async (kind: ExportKind, options?: Record) => { - const labels: Record = { project: "Project JSON", "pixel-single": "Unity database", "pixel-partitioned": "Partitioned Unity databases", voice: "Voice package", screenplay: "Screenplay" }; + const labels: Record = { + project: "Project JSON", "pixel-single": "Unity database", "pixel-partitioned": "Partitioned Unity databases", voice: "Voice package", screenplay: "Screenplay", + "game-json": "Localization JSON", "game-csv": "Localization CSV", "unity-localization": "Unity Localization table", godot: "Godot localization table", + "unreal-string-table": "Unreal String Tables", "yarn-spinner": "Yarn Spinner strings", gettext: "gettext catalogs", "xliff-2.0": "XLIFF package", + }; setFeedback({ status: "working", title: `Exporting ${labels[kind]}…`, detail: "Writing files on this Mac.", paths: [] }); try { const result = await window.reed.export(kind, options ?? (kind === "voice" ? { @@ -937,17 +994,19 @@ function Production({ snapshot, validation, request, onRequestHandled }: { snaps useEffect(() => { if (request && handledRequestRef.current !== request.id) { handledRequestRef.current = request.id; void run(request.kind).finally(onRequestHandled); } }, [onRequestHandled, request, run]); const exportRow = (kind: ExportKind, title: string, format: string, description: string, primaryAction = false) => { const blocked = kind.startsWith("pixel-") && !validation.valid; - return

{title}

{description}

{format} · saved under exports/{blocked && Blocked by {validation.errors} validation {validation.errors === 1 ? "error" : "errors"}}
; + const marks: Partial> = { voice: "VO", screenplay: "MD", "game-csv": "CSV", "unity-localization": "U", godot: "G", "unreal-string-table": "UE", "yarn-spinner": "YS", gettext: "PO", "xliff-2.0": "XL" }; + return

{title}

{description}

{format} · saved under exports/{blocked && Blocked by {validation.errors} validation {validation.errors === 1 ? "error" : "errors"}}
; }; - return

Local handoff

Export

Create Unity, voice, script, and portable project files. Reed writes only inside this project and never uploads an export.

+ return

Local handoff

Export

Create engine-ready localization, voice, script, and portable project files. Reed writes only inside this project and never uploads an export.

Game integration

Unity / Pixel Crushers

{validation.valid ? "Ready" : `${validation.errors} errors`}

Unity exports require a validation-clear story so permanent IDs and links remain safe.

{exportRow("pixel-single", "Single dialogue database", "Pixel Crushers JSON + manifest", "One database containing every conversation. Best for most Unity projects.", true)}{exportRow("pixel-partitioned", "Partitioned dialogue databases", "Pixel Crushers JSON files + manifest", "One database per configured partition for larger production pipelines.")}
+

Interchange

Game engines & localization tools

{snapshot.project.languages.length} languages

These tables use permanent Line IDs and include choice menu text as separate .menu keys. They carry localized strings and production context; Reed remains the source of branching graph logic.

{exportRow("unity-localization", "Unity Localization", "String Table Collection CSV", "A wide UTF-8 table with stable keys, numeric IDs, locale columns, and shared comments.")}{exportRow("godot", "Godot", "Localization CSV", "A Godot-ready keys-and-locales table with ignored context columns for speakers and source Line IDs.")}{exportRow("unreal-string-table", "Unreal Engine", "String Table CSV per culture", "One Key / SourceString table per enabled culture, with context preserved as metadata columns.")}{exportRow("yarn-spinner", "Yarn Spinner", "Strings CSV per language", "Line-ID-based strings files with source locations, deterministic locks, and translator comments.")}{exportRow("gettext", "gettext", "PO per language", "Version-control-friendly catalogs for Godot and standard localization tooling.")}{exportRow("xliff-2.0", "XLIFF 2.0", "XLIFF per target language", "Standards-based bilingual exchange for CAT tools and localization vendors.")}

Recording

Voice production

{voiceSelection.missing.length ? `${voiceSelection.missing.length} missing` : "Script ready"}

Voice actor package

A studio master, a focused PDF for each actor, an editable session tracker, and CSV interchange data. Dialogue is exported only in the selected language; every recording uses its permanent LineID.wav filename.

{voiceSelection.selected.length}lines
{voiceSelection.words}words
{(voiceSelection.words / 150).toFixed(1)}est. minutes
{voicePreset === "ready" ? "Polished-or-later text with no recording or a required rework." : voicePreset === "pickups" ? "Polished-or-later text marked for recording rework." : "Every authored line, including drafts."}
{voiceSelection.missing.length > 0 &&
Add {voiceLanguage.toUpperCase()} dialogue to {voiceSelection.missing.length} selected {voiceSelection.missing.length === 1 ? "line" : "lines"} before exporting. Reed will not substitute another language in an actor script.
}

Selected cast estimate

{snapshot.characters.map((character) => { if (voiceCharacter !== "all" && character.id !== voiceCharacter) return null; const stats = productionStats.get(character.id) ?? { lines: 0, words: 0 }; return ; })}
CharacterLinesWordsEst. minutes
{character.name}{stats.lines}{stats.words}{(stats.words / 150).toFixed(1)}
-

Portable files

Story source & reading

{exportRow("project", "Deterministic project JSON", "Reed JSON", "A stable plain-text snapshot for local Git history, inspection, and archival.")}{exportRow("screenplay", "Linear screenplay", "Markdown", "A readable golden-path script in the project’s default language.")}
+

Portable files

Plain data, story source & reading

{exportRow("game-json", "Plain localization JSON", "Structured JSON dictionary", "Permanent Line IDs mapped to every configured language plus speaker, scene, context, tags, and menu text.")}{exportRow("game-csv", "Plain localization CSV", "Long-form UTF-8 CSV", "One row per Line ID and language for spreadsheets, scripts, databases, and custom engines.")}{exportRow("project", "Deterministic project JSON", "Reed JSON", "A complete graph snapshot for local Git history, inspection, archival, and Reed interchange.")}{exportRow("screenplay", "Linear screenplay", "Markdown", "A readable golden-path script in the project’s default language.")}
{feedback &&
{feedback.title}{feedback.detail}{feedback.paths.map((filePath) => {filePath})}
{feedback.paths[0] && }
}
; } diff --git a/apps/editor/src/collaboration-appearance.regression-12.test.ts b/apps/editor/src/collaboration-appearance.regression-12.test.ts index ac4daa1..23e3ba5 100644 --- a/apps/editor/src/collaboration-appearance.regression-12.test.ts +++ b/apps/editor/src/collaboration-appearance.regression-12.test.ts @@ -19,7 +19,8 @@ describe("collaboration and appearance regressions", () => { expect(git).toContain("GH_PROMPT_DISABLED"); expect(git).toContain("GH_NO_UPDATE_NOTIFIER"); expect(forge).toContain("extraResource: githubCliSource"); - expect(main).not.toContain("shell.openExternal"); + expect(main).toContain("shell.openExternal(githubDeviceUrl)"); + expect(main).not.toContain('execFileAsync("/usr/bin/open"'); }); it("provides persisted Automatic, Light, and Dark themes in native and in-app settings", async () => { diff --git a/apps/editor/src/contract.ts b/apps/editor/src/contract.ts index 1777231..1ce2307 100644 --- a/apps/editor/src/contract.ts +++ b/apps/editor/src/contract.ts @@ -12,7 +12,10 @@ import type { import type { RecentProject } from "./recent-projects.js"; export type WorkspaceId = "graph" | "beats" | "playtest" | "translation" | "characters" | "variables" | "validation" | "review" | "production" | "history" | "read"; -export type ExportKind = "project" | "pixel-single" | "pixel-partitioned" | "voice" | "screenplay"; +export type ExportKind = + | "project" | "pixel-single" | "pixel-partitioned" | "voice" | "screenplay" + | "game-json" | "game-csv" | "unity-localization" | "godot" + | "unreal-string-table" | "yarn-spinner" | "gettext" | "xliff-2.0"; export type SaveStateStatus = "saved" | "dirty" | "saving" | "error"; export interface SaveStateEvent { status: SaveStateStatus; at?: string; message?: string; } export type AppearancePreference = "system" | "light" | "dark"; diff --git a/apps/editor/src/export-history-autosave.regression-11.test.ts b/apps/editor/src/export-history-autosave.regression-11.test.ts index d03438a..7283342 100644 --- a/apps/editor/src/export-history-autosave.regression-11.test.ts +++ b/apps/editor/src/export-history-autosave.regression-11.test.ts @@ -8,7 +8,11 @@ describe("export, history, and autosave regressions", () => { expect(production).toContain("Unity / Pixel Crushers"); expect(production).toContain("Voice production"); - expect(production).toContain("Story source & reading"); + expect(production).toContain("Plain data, story source & reading"); + expect(production).toContain("Game engines & localization tools"); + expect(production).toContain("Unity Localization"); + expect(production).toContain("Unreal Engine"); + expect(production).toContain("XLIFF 2.0"); expect(production).toContain("Show in Finder"); expect(production).toContain('role={feedback.status === "error" ? "alert" : "status"}'); expect(production).toContain('disabled={blocked || feedback?.status === "working"}'); diff --git a/apps/editor/src/main.ts b/apps/editor/src/main.ts index 07c0e32..687ad06 100644 --- a/apps/editor/src/main.ts +++ b/apps/editor/src/main.ts @@ -1,8 +1,6 @@ import path from "node:path"; -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { promisify } from "node:util"; import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, session, shell, type MenuItemConstructorOptions } from "electron"; import { ReedProject, @@ -16,6 +14,7 @@ import { createProjectSnapshot, disabledProjectGitHubStatus, exportPixelCrushers, + exportGameData, exportProjectJson, exportScreenplay, exportVoicePackage, @@ -39,7 +38,6 @@ let activeWorkspace: WorkspaceId = "graph"; let appearancePreference: AppearancePreference = "system"; let githubConnection: ReturnType | null = null; -const execFileAsync = promisify(execFile); const githubDeviceUrl = "https://github.com/login/device"; const workspaceMenuItems: Array<{ id: WorkspaceId; label: string; accelerator?: string }> = [ @@ -66,9 +64,9 @@ function appSettingsFile(): string { function configureBundledTools(): void { if (!app.isPackaged) return; - const bundledGitHubCli = path.join(process.resourcesPath, "gh"); + const bundledGitHubCli = path.join(process.resourcesPath, process.platform === "win32" ? "gh.exe" : "gh"); if (existsSync(bundledGitHubCli)) process.env.REED_GH_PATH = bundledGitHubCli; - process.env.REED_GH_BROWSER = "/usr/bin/true"; + if (process.platform === "darwin") process.env.REED_GH_BROWSER = "/usr/bin/true"; } function appearanceState(): AppearanceState { @@ -148,11 +146,11 @@ function requestedScreenshotDimension(flag: "--screenshot-width" | "--screenshot return Number.isFinite(parsed) ? Math.max(minimum, parsed) : fallback; } -function requestedScreenshotAction(): "save" | "github-status" | null { +function requestedScreenshotAction(): "save" | "github-status" | "project-settings" | null { if (!requestedScreenshotPath()) return null; const index = process.argv.indexOf("--screenshot-action"); const requested = index >= 0 ? process.argv[index + 1] : undefined; - return requested === "save" || requested === "github-status" ? requested : null; + return requested === "save" || requested === "github-status" || requested === "project-settings" ? requested : null; } function applicationMenuTemplate(): MenuItemConstructorOptions[] { @@ -170,12 +168,18 @@ function applicationMenuTemplate(): MenuItemConstructorOptions[] { { type: "separator" }, { label: "Clear Recent Projects", enabled: recentProjects.length > 0, click: () => { void clearRecentProjects(); } }, ); - const exportItems: Array<{ label: string; kind: ExportKind }> = [ - { label: "Project JSON", kind: "project" }, - { label: "Pixel Crushers — Single Database", kind: "pixel-single" }, - { label: "Pixel Crushers — Partitioned", kind: "pixel-partitioned" }, - { label: "Voice Package", kind: "voice" }, - { label: "Screenplay", kind: "screenplay" }, + const exportAction = (label: string, kind: ExportKind): MenuItemConstructorOptions => ({ label, click: () => sendMenuCommand({ type: "export", kind }) }); + const exportSubmenu: MenuItemConstructorOptions[] = [ + { label: "Pixel Crushers", submenu: [exportAction("Single Database", "pixel-single"), exportAction("Partitioned Databases", "pixel-partitioned")] }, + { label: "Game Engines & Localization", submenu: [ + exportAction("Unity Localization CSV", "unity-localization"), exportAction("Godot Localization CSV", "godot"), + exportAction("Unreal String Tables", "unreal-string-table"), exportAction("Yarn Spinner Strings", "yarn-spinner"), + exportAction("gettext PO", "gettext"), exportAction("XLIFF 2.0", "xliff-2.0"), + ] }, + { label: "Plain Data", submenu: [exportAction("Localization JSON", "game-json"), exportAction("Localization CSV", "game-csv"), exportAction("Complete Reed Project JSON", "project")] }, + { type: "separator" }, + exportAction("Voice Package", "voice"), + exportAction("Screenplay", "screenplay"), ]; const template: MenuItemConstructorOptions[] = []; if (process.platform === "darwin") template.push({ @@ -204,7 +208,7 @@ function applicationMenuTemplate(): MenuItemConstructorOptions[] { { label: "Close Project", accelerator: "CommandOrControl+Alt+W", enabled: hasProject, click: () => { void closeCurrentProject(true); } }, { type: "separator" }, { label: "Create Backup…", accelerator: "CommandOrControl+Shift+S", enabled: hasProject, click: () => sendMenuCommand({ type: "create-backup" }) }, - { label: "Export", enabled: hasProject, submenu: exportItems.map((item) => ({ label: item.label, click: () => sendMenuCommand({ type: "export", kind: item.kind }) })) }, + { label: "Export", enabled: hasProject, submenu: exportSubmenu }, { type: "separator" }, { role: "close" }, ], @@ -317,7 +321,7 @@ async function connectGitHubFromEditor() { if (githubConnection) return githubConnection; const projectRoot = requireGitHubProject().paths.root; githubConnection = (async () => { - try { await execFileAsync("/usr/bin/open", [githubDeviceUrl], { timeout: 10_000 }); } + try { await shell.openExternal(githubDeviceUrl); } catch { throw new Error(`Reed could not open GitHub in the default browser. Open ${githubDeviceUrl} manually, then try again.`); } return connectProjectGitHub(projectRoot); })(); @@ -423,6 +427,21 @@ function registerIpc(): void { lineStatuses: options.lineStatuses as string[] | undefined, }); if (kind === "screenplay") return exportScreenplay(project, options.language as string | undefined, options.referenceLanguage as string | undefined); + const gameFormats = { + "game-json": "json", + "game-csv": "csv", + "unity-localization": "unity-localization", + godot: "godot", + "unreal-string-table": "unreal-string-table", + "yarn-spinner": "yarn-spinner", + gettext: "gettext", + "xliff-2.0": "xliff-2.0", + } as const; + if (kind in gameFormats) return exportGameData(project, { + format: gameFormats[kind as keyof typeof gameFormats], + outputDirectory: options.output as string | undefined, + languages: options.languages as string[] | undefined, + }); throw new Error(`Unsupported export: ${kind}`); }); ipcMain.handle("reed:git-status", async () => { @@ -531,7 +550,11 @@ async function createWindow(): Promise { await new Promise((resolve) => setTimeout(resolve, 3_000)); } if (screenshotAction) { - await createdWindow.webContents.executeJavaScript(`(() => { + await createdWindow.webContents.executeJavaScript(screenshotAction === "project-settings" ? `(() => { + const button = document.querySelector("button.project-name"); + if (!(button instanceof HTMLElement)) throw new Error("Project settings control is unavailable"); + button.click(); + })()` : `(() => { const button = document.querySelector("button.save-indicator"); if (!(button instanceof HTMLElement)) throw new Error("Saved control is unavailable"); button.click(); diff --git a/apps/editor/src/project-home-menu.regression-10.test.ts b/apps/editor/src/project-home-menu.regression-10.test.ts index 74b1123..ef476cd 100644 --- a/apps/editor/src/project-home-menu.regression-10.test.ts +++ b/apps/editor/src/project-home-menu.regression-10.test.ts @@ -49,8 +49,10 @@ describe("project home and native menu regressions", () => { expect(settings).toContain('mutate("updateProject"'); expect(settings).toContain("expectedVersion: project.version"); - expect(settings).toContain("Enabled languages"); - expect(settings).toContain("Default writing language"); + expect(settings).toContain("LanguageConfigurator"); + expect(source).toContain("Use game-market preset"); + expect(source).toContain("Custom language tag"); + expect(source).toContain("Default writing language"); expect(settings).toContain("Project location"); }); diff --git a/apps/editor/src/styles.css b/apps/editor/src/styles.css index d91f6f8..9c6b188 100644 --- a/apps/editor/src/styles.css +++ b/apps/editor/src/styles.css @@ -151,6 +151,11 @@ button.tree-heading:hover, .tree-disclosure:hover { background:var(--raised); co .project-settings { width:min(720px,calc(100vw - 32px)); max-height:calc(100vh - 32px); display:grid; gap:14px; padding:20px; overflow:auto; border:1px solid var(--line2); border-radius:6px; background:var(--panel); box-shadow:0 28px 80px var(--shadow); } .project-settings>header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }.project-settings h2 { margin:0; font-size:22px; font-weight:500; text-wrap:balance; }.project-settings>header>button { width:40px; height:40px; padding:0; border:0; background:transparent; font-size:20px; }.project-settings label { display:grid; gap:5px; color:var(--muted); font-size:10px; letter-spacing:.05em; text-transform:uppercase; }.project-settings input,.project-settings select { width:100%; color:var(--text); font-size:12px; letter-spacing:0; text-transform:none; }.form-help { margin:-7px 0 0; color:var(--muted); font-size:10px; line-height:1.5; text-wrap:pretty; }.project-settings-meta { display:flex; gap:14px; color:var(--muted); font:9px "IBM Plex Mono"; font-variant-numeric:tabular-nums; } .settings-section { display:grid; gap:12px; padding:14px; border:1px solid var(--line); background:var(--subtle); }.settings-section h3 { margin:0; color:var(--text); font-size:13px; font-weight:600; }.settings-section .form-help { margin:-5px 0 0; } +.language-configurator { display:grid; gap:10px; padding:14px; border:1px solid var(--line); background:var(--subtle); }.language-config-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }.language-config-heading h4 { margin:0; color:var(--text); font-size:13px; }.language-config-heading p { max-width:590px; margin:4px 0 0; color:var(--muted); font-size:10px; line-height:1.45; }.language-config-heading>span { flex:none; padding:4px 7px; border:1px solid var(--line2); color:var(--reed2); font:9px "IBM Plex Mono"; text-transform:uppercase; } +.selected-languages { display:flex; flex-wrap:wrap; gap:5px; }.selected-languages button { min-height:30px; display:flex; align-items:center; gap:6px; padding:4px 7px; border-color:var(--line2); background:var(--panel); color:var(--text); }.selected-languages button:hover { border-color:var(--bad); }.selected-languages code { color:var(--muted); font-size:9px; }.selected-languages b { color:var(--muted); font-size:12px; font-weight:400; } +.language-config-actions { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:7px; align-items:end; }.language-config-actions label,.language-custom label { display:grid; gap:4px; color:var(--muted); font-size:9px; letter-spacing:.05em; text-transform:uppercase; }.language-config-actions input,.language-custom input { min-height:36px; color:var(--text); font-size:11px; letter-spacing:0; text-transform:none; }.language-config-actions button { min-height:36px; white-space:nowrap; } +.language-catalog { max-height:238px; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; overflow:auto; border:1px solid var(--line); background:var(--line); }.language-catalog label { min-width:0; min-height:46px; display:grid; grid-template-columns:18px minmax(0,1fr) auto; gap:8px; align-items:center; padding:6px 8px; background:var(--panel); color:var(--text); cursor:pointer; letter-spacing:0; text-transform:none; }.language-catalog label:hover { background:var(--hover); }.language-catalog label.selected { background:var(--selected); }.language-catalog input { width:16px; height:16px; margin:0; accent-color:var(--reed); }.language-catalog span,.language-catalog strong,.language-catalog small { min-width:0; display:block; }.language-catalog strong { overflow:hidden; font-size:11px; font-weight:500; text-overflow:ellipsis; white-space:nowrap; }.language-catalog small { overflow:hidden; margin-top:1px; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.language-catalog code { color:var(--muted); font-size:9px; direction:ltr; }.language-empty { margin:0; padding:14px; border:1px dashed var(--line2); color:var(--muted); font-size:10px; text-align:center; } +.language-custom { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:7px; align-items:end; }.language-custom button { min-height:36px; }.language-configurator>.default-language { display:grid; gap:5px; color:var(--muted); font-size:9px; letter-spacing:.05em; text-transform:uppercase; }.language-configurator>.default-language select { min-height:38px; color:var(--text); font-size:11px; letter-spacing:0; text-transform:none; }.new-project .language-configurator { margin-top:12px; }.new-project .language-config-heading p { max-width:720px; } .preferences-dialog { width:min(560px,calc(100vw - 32px)); display:grid; gap:16px; padding:20px; border:1px solid var(--line2); border-radius:6px; background:var(--panel); box-shadow:0 28px 80px var(--shadow); }.preferences-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }.preferences-dialog h2 { margin:0; font-size:22px; font-weight:500; }.preferences-dialog>header>button { width:40px; height:40px; padding:0; border:0; background:transparent; font-size:20px; }.preferences-dialog fieldset { display:grid; gap:6px; margin:0; padding:0; border:0; }.preferences-dialog legend { margin-bottom:8px; color:var(--muted); font-size:10px; letter-spacing:.05em; text-transform:uppercase; }.preferences-dialog fieldset>label { min-height:60px; display:flex; align-items:center; gap:12px; padding:10px 12px; border:1px solid var(--line); background:var(--subtle); cursor:pointer; }.preferences-dialog fieldset>label.selected { border-color:var(--reed); background:var(--selected); box-shadow:inset 2px 0 var(--reed); }.preferences-dialog input { width:16px; height:16px; margin:0; accent-color:var(--reed); }.preferences-dialog strong,.preferences-dialog small { display:block; }.preferences-dialog strong { font-size:12px; }.preferences-dialog small { margin-top:4px; color:var(--muted); font-size:10px; }.preferences-dialog .metadata-actions { justify-content:flex-end; } .status-indicator { display:flex; align-items:center; gap:5px; margin-left:auto; color:var(--status,var(--muted)); font:9px "IBM Plex Mono",monospace; text-transform:uppercase; } .dot { width:7px; height:7px; border-radius:50%; background:var(--status,var(--muted)); } @@ -359,6 +364,7 @@ button.tree-heading:hover, .tree-disclosure:hover { background:var(--raised); co .home-intro { margin-bottom:24px; } .current-project { grid-template-columns:46px minmax(0,1fr); gap:11px; }.current-project-mark { width:46px; height:46px; }.current-project>button { grid-column:1/-1; width:100%; } .new-project .metadata-grid,.project-settings .metadata-grid { grid-template-columns:1fr; } + .language-config-actions { grid-template-columns:1fr 1fr; }.language-config-actions label { grid-column:1/-1; }.language-catalog { grid-template-columns:1fr; max-height:220px; }.language-custom { grid-template-columns:1fr; }.language-custom button { justify-self:start; } .preferences-dialog,.project-settings { width:calc(100vw - 20px); max-height:calc(100vh - 20px); padding:16px; }.settings-section { padding:12px; } .new-project>.primary { width:100%; } .recent-project-list>article { grid-template-columns:minmax(0,1fr) 40px; } diff --git a/apps/editor/src/ui-logic.test.ts b/apps/editor/src/ui-logic.test.ts index 5fb6bbc..13b3711 100644 --- a/apps/editor/src/ui-logic.test.ts +++ b/apps/editor/src/ui-logic.test.ts @@ -28,7 +28,7 @@ describe("project and production display helpers", () => { }); it("normalizes language settings and gives every permanent actor ID a stable accent", () => { - expect(parseLanguageList("EN, el it,el")).toEqual(["en", "el", "it"]); + expect(parseLanguageList("EN, el it,el pt-br zh-hans")).toEqual(["en", "el", "it", "pt-BR", "zh-Hans"]); expect(speakerAccent(1)).toBe(speakerAccent(1)); expect(speakerAccent(1)).not.toBe(speakerAccent(2)); }); diff --git a/apps/editor/src/ui-logic.ts b/apps/editor/src/ui-logic.ts index ea97154..72482d2 100644 --- a/apps/editor/src/ui-logic.ts +++ b/apps/editor/src/ui-logic.ts @@ -46,7 +46,16 @@ export function speakerAccent(numericId: number): string { } export function parseLanguageList(value: string): string[] { - return [...new Set(value.toLowerCase().split(/[\s,]+/u).map((language) => language.trim()).filter(Boolean))]; + const seen = new Set(); + const languages: string[] = []; + for (const raw of value.split(/[\s,]+/u).map((language) => language.trim()).filter(Boolean)) { + const language = canonicalizeLanguageCode(raw); + const identity = language.toLowerCase(); + if (seen.has(identity)) continue; + seen.add(identity); + languages.push(language); + } + return languages; } export function nextAppearancePreference(value: AppearanceCyclePreference): AppearanceCyclePreference { @@ -214,3 +223,4 @@ export function evaluatePlaytestCondition(source: string, variables: Record { expect(saveControl).toContain('if (next.state === "connected") setNotice(success)'); expect(saveControl).not.toMatch(/setInterval|XMLHttpRequest|WebSocket/); expect(main).toContain("throw new Error(ipcErrorMessage(error))"); - expect(main).toContain('execFileAsync("/usr/bin/open", [githubDeviceUrl]'); + expect(main).toContain("shell.openExternal(githubDeviceUrl)"); + expect(main).not.toContain('execFileAsync("/usr/bin/open"'); expect(main).toContain("if (githubConnection) return githubConnection"); expect(main).toContain('process.env.REED_GH_BROWSER = "/usr/bin/true"'); expect(main).toContain("return await connectGitHubFromEditor()"); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index d84acf4..84de503 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -25,6 +25,7 @@ import { import { ReedError, ReedProject, + exportGameData, exportPixelCrushers, exportVoicePackage, getSchemaDescriptor, @@ -99,6 +100,11 @@ register("import_conversation_json", "Bulk-create and deterministically auto-lay register("export_pixelcrushers", "Validate and export local Pixel Crushers DialogueDatabase JSON.", z.object({ mode: z.enum(["single", "partitioned"]).default("single"), outputDirectory: z.string().optional(), allowInvalid: z.boolean().default(false), }), (args) => exportPixelCrushers(project, args)); +register("export_game_data", "Export local engine and localization interchange files using permanent Reed Line IDs.", z.object({ + format: z.enum(["json", "csv", "unity-localization", "godot", "unreal-string-table", "yarn-spinner", "gettext", "xliff-2.0"]), + outputDirectory: z.string().optional(), + languages: z.array(z.string()).optional(), +}), (args) => exportGameData(project, args)); register("export_voice", "Export local voice-actor PDF and studio CSV/XLSX files.", z.object({ outputDirectory: z.string().optional(), language: z.string().optional(), characterIds: z.array(z.string().uuid()).optional(), voiceStatuses: z.array(z.string()).optional(), lineStatuses: z.array(z.string()).optional(), }), (args) => exportVoicePackage(project, args)); diff --git a/docs/ANDROID_DC1.md b/docs/ANDROID_DC1.md index 4acf2c7..23558b3 100644 --- a/docs/ANDROID_DC1.md +++ b/docs/ANDROID_DC1.md @@ -2,6 +2,8 @@ Reed Android is designed for the Daylight DC-1's 10.5-inch, 1200×1600 grayscale LivePaper display and Android 13-based Sol:OS. It supports portrait, landscape, split-screen, touch, Wacom stylus selection, Bluetooth/USB keyboards, mice, and trackpads. Local projects, editing, SQLite storage, history, and exports work with Wi-Fi off. +Project Settings is available from the labelled workspace rail. It provides the same offline 47-language game catalog as desktop, canonical BCP 47 tags, the game-market preset, search, custom tags, and a versioned default writing language. Removing a language from the active set preserves its stored text so re-enabling it is reversible. See [Languages and game localization exports](LOCALIZATION_EXPORTS.md). + ## Build from source Requirements: diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 6145a66..eaffe6d 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -5,7 +5,7 @@ - Electron + Electron Forge for the desktop editor; React runs in a sandboxed renderer and receives a narrow typed API through `contextBridge`. - pnpm workspace with TypeScript packages. No web server is required at runtime. - `better-sqlite3` in WAL mode behind `@reed/core`; Forge rebuilds the native module for packaged Electron targets. -- macOS arm64 and x64 are the first signed distribution targets. Windows follows after the same offline acceptance suite passes there. +- macOS arm64 and Windows x64 are desktop distribution targets. Both package the same sandboxed renderer and rebuild `better-sqlite3` for the target runtime. - MCP uses stdio only and the stable v1 TypeScript SDK until MCP v2 leaves beta. - Pixel Crushers export is fixture-gated. No exporter is called compatible until a Unity-authored database has passed import, export, and two-database collision tests. @@ -38,7 +38,7 @@ The renderer, MCP server, and CLI never issue SQL. All mutation rules, status gu 3. **Complete — Local MCP:** stable v1 SDK over stdio, read/mutation/import/export tools, schema resource, structured errors, stale-write handling, and agent guardrails. 4. **Complete — Editor foundation:** packaged Electron shell, runtime egress denial, project picker, navigator, React Flow editing, inspector, cast/variable registry, and beat map. 5. **Complete for the production vertical slice:** playtest, search/jump, usage counts, validation/jump, review approval, translation coverage/editing, verification states, dual language, and external-change reload. Advanced multi-line bulk selection remains a future efficiency enhancement, not a data-model dependency. -6. **Complete except external credentials — Handoff and distribution:** screenplay Markdown/read view, VO budget, Unicode PDF/CSV/XLSX, unsigned arm64 macOS app bundle, native-module packaging checks, and offline acceptance. Apple signing/notarization requires the distributor's certificate and account; x64/Windows builds require those target runners. +6. **Complete except external signing credentials — Handoff and distribution:** screenplay Markdown/read view, VO budget, Unicode PDF/CSV/XLSX, locally signed arm64 macOS DMG/ZIP, unsigned Windows x64 ZIP, target-native module packaging checks, and offline acceptance. Apple notarization and Windows code signing require distributor certificates. ## Quality gates @@ -52,6 +52,6 @@ The renderer, MCP server, and CLI never issue SQL. All mutation rules, status gu ## Deliberate boundaries - Pixel Crushers version certification is blocked on a real Unity-authored sample, not on exporter implementation. -- Windows/Linux distribution, Apple signing, and notarization require target runners or owner credentials. +- Windows code signing, Apple distribution signing/notarization, and Linux distribution require target runners or owner credentials. - Optional local push into a Unity project is not required; local JSON import remains the supported one-way path. - LLM prompts, credentials, model SDKs, accounts, telemetry, and hosted services are permanently out of scope. diff --git a/docs/CLI.md b/docs/CLI.md index efdbeab..10561f0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -52,9 +52,12 @@ Mutation commands accept `--expected-version` where relevant. Final content requ | `export-voice` | Studio master PDF, one PDF per actor, session XLSX, and CSV. Filter with `--language`, `--character`, `--status`, and `--line-status`. | | `voice-budget` | Per-character lines, words, and estimated minutes. | | `export-screenplay` | Golden-path Markdown in one or two languages. | +| `export-game-data --format ` | Local game-engine and localization interchange: JSON, CSV, Unity Localization, Godot, Unreal String Tables, Yarn Spinner, gettext PO, or XLIFF 2.0. Optional `--languages` limits output files/columns. | Use `--help` on any command for its exact flags and defaults. Voice export always requires real dialogue in the requested language; Reed does not silently substitute another language in an actor script. `--character` accepts comma-separated character UUIDs, while `--status` and `--line-status` accept comma-separated production states. See [Voice production handoff](VOICE_PRODUCTION.md). +Language settings accept canonical IETF BCP 47 tags such as `pt-BR`, `es-419`, `zh-Hans`, and private-use tags such as `x-griko`. See [Languages and game localization exports](LOCALIZATION_EXPORTS.md). + The CLI remains an offline automation surface: its GitHub handoff command only prepares inert commands and never authenticates, fetches, pushes, reviews, or merges. People can perform those optional remote actions without Terminal in the editor's **History** workspace after enabling GitHub collaboration for that project. See [Local history and optional GitHub collaboration](PROJECT_HISTORY.md). diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 4060f17..5b59e67 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -4,7 +4,7 @@ `Project → Level? → Scene → Conversation → DialogueNode` -- A project owns languages, cast, declared variables, database partitions, review baselines, and global version history. +- A project owns an ordered set of IETF BCP 47 language tags, a default writing language, cast, declared variables, database partitions, review baselines, and global version history. Reed ships an offline game-language catalog but permits custom and private-use tags. - A level is an optional organizational container. - A scene freezes its slug-derived Line-ID prefix and stores an external `beatRef`. - A conversation maps one-to-one to a Pixel Crushers conversation and is an optimistic-concurrency aggregate. diff --git a/docs/LOCALIZATION_EXPORTS.md b/docs/LOCALIZATION_EXPORTS.md new file mode 100644 index 0000000..3f28d7e --- /dev/null +++ b/docs/LOCALIZATION_EXPORTS.md @@ -0,0 +1,59 @@ +# Languages and game localization exports + +Reed stores language identity as IETF BCP 47 tags and keeps every export local. A project can enable any structurally valid BCP 47 tag, not only the built-in catalog. Project settings provide 47 ready-to-use language presets, a game-market preset based on Steam's supported language set, a default writing language, search, and custom-tag entry. + +The catalog includes Arabic, Bulgarian, Catalan, Simplified and Traditional Chinese, Croatian, Czech, Danish, Dutch, English and its AU/GB/US variants, Filipino, Finnish, French and French Canadian, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Malay, Norwegian, Persian, Polish, European and Brazilian Portuguese, Romanian, Russian, Serbian in both scripts, Slovak, Slovenian, European and Latin American Spanish, Swedish, Thai, Turkish, Ukrainian, Vietnamese, Griko, and the private-use `x-griko` tag. + +## Language identity + +- Reed follows [IETF BCP 47 / RFC 5646](https://www.rfc-editor.org/info/rfc5646/) and canonicalizes case, so `pt-br` becomes `pt-BR` and `zh-hans` becomes `zh-Hans`. +- The game-market preset follows the languages exposed by [Steamworks](https://partner.steamgames.com/doc/store/localization/languages?language=english), while the larger Reed catalog adds common regional and localization-team requests. +- The existing `griko` project code remains supported so old Reed projects never lose identity. New projects that need a strictly private BCP 47 tag may use `x-griko`. +- Export adapters map identity only where an engine requires it: for example, `pt-BR` becomes `pt_BR` in a Godot CSV header and `zh-Hans` maps to Godot's `zh_CN`. Reed's stored project code does not change. +- Arabic, Hebrew, and Persian are marked right-to-left in the catalog. Direction is shown structurally in the selector and is available to future UI/export adapters; color is not used to encode it. + +## Export formats + +All outputs use permanent Reed Line IDs as keys. If a choice has separate menu text, the menu string is emitted as `.menu`. This keeps spoken dialogue and player-facing choice text independently localizable. + +| Reed export | Output | Intended use | +|---|---|---| +| Pixel Crushers | Dialogue Database JSON plus manifest | Full branching dialogue, conditions, scripts, actors, variables, and localization for Unity Dialogue System projects. | +| Unity Localization | One String Table Collection-style CSV | `Key`, stable numeric `Id`, shared comments, and one column per locale. Unity documents the same CSV row model in its [Localization package](https://docs.unity3d.com/Packages/com.unity.localization@1.4/manual/CSV.html). | +| Godot | One UTF-8 localization CSV | `keys` plus locale columns. Context columns start with `_`, so Godot ignores them while humans retain speaker and Line ID context. This follows [Godot's spreadsheet localization format](https://docs.godotengine.org/en/stable/tutorials/i18n/localization_using_spreadsheets.html). | +| Unreal String Table | One CSV per culture | `Key` and `SourceString`, with context as additional metadata columns. Unreal documents those two mandatory columns in [String Tables](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-string-tables-for-text-in-unreal-engine). | +| Yarn Spinner | One strings CSV per language | `language,id,text,file,node,lineNumber,lock,comment`, following [Yarn Spinner's built-in localization layout](https://docs.yarnspinner.dev/yarn-spinner-for-unity/assets-and-localization/inbuilt-localisation). | +| gettext | One `.po` catalog per language | Version-control-friendly handoff for gettext tooling and engines such as Godot, which [supports PO files](https://docs.godotengine.org/en/latest/tutorials/i18n/localization_using_gettext.html). | +| XLIFF 2.0 | One bilingual `.xliff` per target | CAT-tool and localization-vendor interchange with source, target, context, and Reed Line ID notes, using the [OASIS XLIFF 2.0 vocabulary](https://www.oasis-open.org/standard/xliffv2-0/). | +| Plain JSON | One deterministic localization dictionary | Custom engines, scripts, build pipelines, and inspection. Includes speaker, listener, scene, conversation, context, tags, text, and menu text. | +| Plain CSV | One long-form UTF-8 table | Spreadsheets, databases, and custom pipelines. One row per Line ID and language. | + +Except for Pixel Crushers, these are localization/string interchange exports, not conversions of Reed's branching runtime logic into another engine's scripting language. Reed's deterministic project JSON remains the complete portable graph snapshot. + +## Desktop workflow + +1. Open the project name in the navigator or choose **File → Project Settings**. +2. Search the catalog, apply the game-market preset, or add a custom BCP 47 tag. +3. Choose the default writing language and save. The project change is versioned like every other mutation. +4. Open **Export**. Engine and tool adapters are grouped under **Game engines & localization tools**; plain formats are under **Portable files**. +5. Reed writes under `exports/integrations//`. Nothing is uploaded. + +## CLI and MCP + +```sh +reed export-game-data --project /path/to/project --format godot +reed export-game-data --project /path/to/project --format xliff-2.0 --languages el,it,pt-BR +reed export-game-data --project /path/to/project --format unreal-string-table --output /path/to/game/Content/Localization/Reed +``` + +Allowed formats are `json`, `csv`, `unity-localization`, `godot`, `unreal-string-table`, `yarn-spinner`, `gettext`, and `xliff-2.0`. + +Local agents can call `export_game_data` with `format`, optional `languages`, and an optional local `outputDirectory`. `get_schema` returns the catalog and states the BCP 47 language contract. The tool is local stdio on desktop and never makes a network request. + +## Integration checks before shipping + +- Import the generated table into the exact engine/plugin version used by the game. +- Confirm locale identifiers match the project's engine configuration and fallback rules. +- Confirm `.menu` keys are bound where choices use separate menu text. +- Treat the generated files as outputs; make narrative changes in Reed and re-export. +- Keep a fixture from the real game in its integration test suite. A successful CSV or XML parse is not a claim of engine-version certification. diff --git a/docs/MCP.md b/docs/MCP.md index 0d8cd57..11393e5 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -23,7 +23,7 @@ The `reed://schema` resource exposes the same schema descriptor for clients that - Content: `update_node_text`, `set_line_text`, `set_node_menu_text`, `update_node`, `set_node_conditions`, `set_node_sequence`, `add_tags`, `set_line_status`. - Registries: `upsert_character`, `declare_variable`. - Bulk: `import_conversation_json`, including deterministic breadth-first auto-layout. -- Handoff: `export_pixelcrushers`, `export_voice`. Voice export accepts `language`, `characterIds`, `voiceStatuses`, and `lineStatuses`, and writes a studio master PDF, individual actor PDFs, an XLSX session tracker, and CSV interchange data. +- Handoff: `export_pixelcrushers`, `export_voice`, `export_game_data`. Voice export accepts `language`, `characterIds`, `voiceStatuses`, and `lineStatuses`, and writes a studio master PDF, individual actor PDFs, an XLSX session tracker, and CSV interchange data. Game-data export accepts a common engine/interchange `format`, optional enabled `languages`, and an optional local output directory. Writes are recorded with source `agent`. Inputs accept `expectedVersion` where applicable. Stale versions return a structured conflict and change list. Agent-created lines default to Draft; an agent cannot set Edited/Final, finalize voice, or modify Final text even by passing force. Destructive deletion requires an exact Line-ID confirmation. @@ -31,4 +31,6 @@ The server intentionally contains no prompt, provider key, model selection, HTTP For voice work, agents can store pronunciation in `update_node.pronunciation`, story circumstances in `contextComment`, and playable intention in `voiceComment`. Keep those concerns separate so an actor packet remains scannable. Export fails when a selected line lacks exact text in the requested language; see [Voice production handoff](VOICE_PRODUCTION.md). +`get_schema` exposes Reed's offline language catalog and BCP 47 contract. See [Languages and game localization exports](LOCALIZATION_EXPORTS.md) for stable Line ID behavior and the exact Unity, Godot, Unreal, Yarn Spinner, gettext, XLIFF, JSON, and CSV outputs. + Git branches and remote GitHub actions intentionally remain editor-owned workflows rather than MCP mutations. An agent can update the project’s PR preference, but the local stdio MCP server never gets a hidden route to authenticate, fetch, push, review, or merge. A human must start every remote action from Reed's History workspace. diff --git a/docs/REMOTE_MCP_ANDROID.md b/docs/REMOTE_MCP_ANDROID.md index 80ba16f..c3d5ca0 100644 --- a/docs/REMOTE_MCP_ANDROID.md +++ b/docs/REMOTE_MCP_ANDROID.md @@ -148,6 +148,8 @@ The connector URL is stable for that local project, so it can be reused across l Remote tools can read project structure and create or edit scenes, conversations, characters, variables, text, nodes, links, conditions, sequences, tags, and layout through `com.reed.core.ReedCore`. +`get_schema` also returns Reed's bundled game-language catalog and the IETF BCP 47 language-tag contract. `update_project` can change the active project's enabled languages and default writing language with `expectedVersion`; newly enabled languages receive blank text rows, while disabling a language preserves its existing text for later re-enabling. This is the same versioned operation used by Android's Project Settings workspace. + They cannot: - execute Git, create commits, fetch, push, or open pull requests; diff --git a/package.json b/package.json index d5d2b4f..0855b75 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "reed", "version": "0.1.9", - "description": "Offline-first branching dialogue editor for Unity and Pixel Crushers on macOS and Android", + "description": "Offline-first branching dialogue and game-localization editor for macOS, Windows, and Android", "private": true, "type": "module", "homepage": "https://github.com/is2b007/Reed#readme", @@ -34,6 +34,9 @@ "android:build": "cd apps/android && ./gradlew assembleDebug", "android:test": "cd apps/android && ./gradlew testDebugUnitTest", "android:lint": "cd apps/android && ./gradlew lintDebug", + "desktop:make:mac": "pnpm --filter @reed/editor make:mac", + "desktop:make:windows": "pnpm --filter @reed/editor make:windows", + "desktop:make": "pnpm --filter @reed/editor make:desktop", "relay:deploy:aws": "./scripts/deploy-relay-lightsail.sh", "relay:verify": "node scripts/verify-relay.mjs", "test:watch": "vitest", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 404516b..79ae767 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ import { checkpointProjectGit, createProjectGitBranch, createProjectPullRequestHandoff, + exportGameData, exportProjectJson, exportPixelCrushers, exportScreenplay, @@ -19,6 +20,8 @@ import { inspectProjectGit, setProjectGitRemote, validateProject, + GAME_EXPORT_FORMATS, + type GameExportFormat, type MutationSource, } from "@reed/core"; @@ -435,6 +438,20 @@ program.command("export-pixelcrushers") print(await useProject(command, (project) => exportPixelCrushers(project, { mode: options.mode, outputDirectory: options.output, allowInvalid: options.allowInvalid })), command); }); +program.command("export-game-data") + .description("export localization data for common game engines and interchange tools") + .requiredOption("--format ", `format: ${GAME_EXPORT_FORMATS.join(", ")}`) + .option("--languages ", "comma-separated enabled languages; defaults to all") + .option("-o, --output ") + .action(async (options: { format: GameExportFormat; languages?: string; output?: string }, command: Command) => { + if (!GAME_EXPORT_FORMATS.includes(options.format)) throw new Error(`Unsupported format: ${options.format}`); + print(await useProject(command, (project) => exportGameData(project, { + format: options.format, + languages: options.languages?.split(",").map((value) => value.trim()).filter(Boolean), + outputDirectory: options.output, + })), command); + }); + program.command("export-voice") .description("export actor PDF and editable CSV/XLSX studio package") .option("--language ") diff --git a/packages/core/package.json b/packages/core/package.json index 0294c8c..70ce0e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,7 +17,7 @@ "@reed/schema": "workspace:*", "@fontsource/noto-sans": "^5.2.6", "@pdf-lib/fontkit": "^1.1.1", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "12.10.0", "exceljs": "^4.4.0", "pdf-lib": "^1.17.1" }, diff --git a/packages/core/src/game-export.ts b/packages/core/src/game-export.ts new file mode 100644 index 0000000..d340575 --- /dev/null +++ b/packages/core/src/game-export.ts @@ -0,0 +1,341 @@ +import { createHash } from "node:crypto"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + canonicalizeLanguageCode, + languageDisplayName, + localeForGodot, + localeForUnity, + localeForUnreal, +} from "@reed/schema"; +import { ConflictError } from "./errors.js"; +import type { CharacterRecord, DialogueNodeRecord } from "./models.js"; +import type { ReedProject } from "./project.js"; + +export const GAME_EXPORT_FORMATS = [ + "json", + "csv", + "unity-localization", + "godot", + "unreal-string-table", + "yarn-spinner", + "gettext", + "xliff-2.0", +] as const; + +export type GameExportFormat = typeof GAME_EXPORT_FORMATS[number]; + +export interface GameExportOptions { + format: GameExportFormat; + outputDirectory?: string; + /** Defaults to every enabled project language, in project order. */ + languages?: string[]; +} + +export interface GameExportResult { + format: GameExportFormat; + outputDirectory: string; + filePaths: string[]; + languages: string[]; + entryCount: number; +} + +interface LocalizationEntry { + lineId: string; + numericId: string; + kind: "line" | "choice"; + speaker: string; + listener: string; + scene: string; + sceneSlug: string; + conversation: string; + conversationNumericId: number; + entryNumericId: number; + context: string; + developerComment: string; + tags: string[]; + lineStatus: string; + voiceStatus: string; + spokenLanguage: string; + text: Record; + menuText: Record; +} + +interface StringResource { + key: string; + lineId: string; + field: "dialogue" | "menu"; + entry: LocalizationEntry; + values: Record; +} + +function safeFilePart(value: string): string { + return value.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/^-+|-+$/gu, "") || "dialogue"; +} + +function csvCell(value: string | number): string { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function csvRow(values: Array): string { + return values.map(csvCell).join(","); +} + +function xml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} + +function po(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\t", "\\t").replaceAll("\r", "\\r").replaceAll("\n", "\\n"); +} + +function unreal(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("\t", "\\t").replaceAll("\r", "\\r").replaceAll("\n", "\\n"); +} + +function stableNumericId(value: string): string { + // 63-bit FNV-1a: deterministic, positive, and accepted as a decimal CSV ID. + let hash = 0xcbf29ce484222325n; + for (const byte of new TextEncoder().encode(value)) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + const positive = hash & 0x7fffffffffffffffn; + return (positive === 0n ? 1n : positive).toString(); +} + +function yarnLock(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 8); +} + +function enabledLanguages(reed: ReedProject, requested?: string[]): string[] { + const project = reed.getProject(); + if (!requested?.length) return [...project.languages]; + const enabled = new Map(project.languages.map((code) => [canonicalizeLanguageCode(code).toLowerCase(), code])); + const result: string[] = []; + for (const requestedCode of requested) { + const code = enabled.get(canonicalizeLanguageCode(requestedCode).toLowerCase()); + if (!code) throw new ConflictError("Export language is not enabled for this project", { language: requestedCode, languages: project.languages }); + if (!result.includes(code)) result.push(code); + } + return result; +} + +function selectedText(node: DialogueNodeRecord, language: string, field: "text" | "menuText"): string { + const value = node.texts.find((candidate) => candidate.language === language); + return field === "text" ? value?.text ?? "" : value?.menuText ?? ""; +} + +function collectEntries(reed: ReedProject, languages: string[]): LocalizationEntry[] { + const characters = new Map(reed.listCharacters().map((character) => [character.id, character])); + const scenes = new Map(reed.listScenes().map((scene) => [scene.id, scene])); + const entries: LocalizationEntry[] = []; + const conversations = [...reed.listConversations()].sort((left, right) => { + const leftScene = scenes.get(left.sceneId); + const rightScene = scenes.get(right.sceneId); + return (leftScene?.sortOrder ?? 0) - (rightScene?.sortOrder ?? 0) + || left.numericId - right.numericId + || left.id.localeCompare(right.id); + }); + for (const conversation of conversations) { + const scene = scenes.get(conversation.sceneId); + const graph = reed.getConversation(conversation.id); + for (const node of [...graph.nodes].sort((left, right) => left.entryNumericId - right.entryNumericId || left.id.localeCompare(right.id))) { + if (node.kind !== "line" && node.kind !== "choice") continue; + const characterName = (id: string | null): string => id ? characters.get(id)?.name ?? "Unknown" : ""; + entries.push({ + lineId: node.lineId, + numericId: stableNumericId(node.lineId), + kind: node.kind, + speaker: characterName(node.speakerCharacterId), + listener: characterName(node.listenerCharacterId), + scene: scene?.name ?? "", + sceneSlug: scene?.slug ?? "scene", + conversation: conversation.title, + conversationNumericId: conversation.numericId, + entryNumericId: node.entryNumericId, + context: node.contextComment, + developerComment: node.voiceComment, + tags: [...node.tags], + lineStatus: node.lineStatus, + voiceStatus: node.voiceStatus, + spokenLanguage: node.spokenLanguage, + text: Object.fromEntries(languages.map((language) => [language, selectedText(node, language, "text")])), + menuText: Object.fromEntries(languages.map((language) => [language, selectedText(node, language, "menuText")])), + }); + } + } + const numericIds = new Set(); + for (const entry of entries) { + if (numericIds.has(entry.numericId)) throw new ConflictError("A stable localization ID collision was detected", { lineId: entry.lineId, numericId: entry.numericId }); + numericIds.add(entry.numericId); + } + return entries; +} + +function resources(entries: LocalizationEntry[]): StringResource[] { + return entries.flatMap((entry) => { + const result: StringResource[] = [{ key: entry.lineId, lineId: entry.lineId, field: "dialogue", entry, values: entry.text }]; + if (Object.values(entry.menuText).some(Boolean)) result.push({ key: `${entry.lineId}.menu`, lineId: entry.lineId, field: "menu", entry, values: entry.menuText }); + return result; + }); +} + +async function atomicWrite(filePath: string, contents: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.tmp`; + await writeFile(temporary, contents, "utf8"); + await rename(temporary, filePath); + return filePath; +} + +function resourceComment(resource: StringResource): string { + return [ + resource.field === "menu" ? "Choice menu text" : resource.entry.kind === "choice" ? "Choice dialogue" : "Dialogue", + resource.entry.speaker ? `Speaker: ${resource.entry.speaker}` : "", + resource.entry.context, + resource.entry.developerComment, + ].filter(Boolean).join(" · "); +} + +async function exportJson(directory: string, slug: string, projectName: string, defaultLanguage: string, languages: string[], entries: LocalizationEntry[]): Promise { + const filePath = path.join(directory, `${slug}.localization.json`); + const payload = { + format: "reed-localization", + formatVersion: 1, + project: { name: projectName, slug, defaultLanguage, languages }, + entries: Object.fromEntries(entries.map((entry) => [entry.lineId, { + type: entry.kind, + speaker: entry.speaker || null, + listener: entry.listener || null, + scene: entry.scene, + conversation: entry.conversation, + context: entry.context, + tags: entry.tags, + spokenLanguage: entry.spokenLanguage, + text: Object.fromEntries(languages.map((language) => [language, entry.text[language] ?? ""])), + menuText: Object.fromEntries(languages.map((language) => [language, entry.menuText[language] ?? ""])), + }])), + }; + return [await atomicWrite(filePath, `${JSON.stringify(payload, null, 2)}\n`)]; +} + +async function exportCsv(directory: string, slug: string, languages: string[], entries: LocalizationEntry[]): Promise { + const rows = [csvRow(["Line ID", "Language", "Text", "Menu Text", "Type", "Speaker", "Listener", "Scene", "Conversation", "Context", "Tags", "Text Status", "Voice Status", "Spoken Language"])]; + for (const entry of entries) for (const language of languages) rows.push(csvRow([ + entry.lineId, language, entry.text[language] ?? "", entry.menuText[language] ?? "", entry.kind, entry.speaker, entry.listener, + entry.scene, entry.conversation, entry.context, entry.tags.join(" | "), entry.lineStatus, entry.voiceStatus, entry.spokenLanguage, + ])); + return [await atomicWrite(path.join(directory, `${slug}.localization.csv`), `${rows.join("\n")}\n`)]; +} + +async function exportUnity(directory: string, slug: string, languages: string[], values: StringResource[]): Promise { + const headers = ["Key", "Id", "Shared Comments", ...languages.map((language) => `${languageDisplayName(language)}(${localeForUnity(language)})`)]; + const rows = [csvRow(headers)]; + for (const resource of values) rows.push(csvRow([ + resource.key, stableNumericId(resource.key), resourceComment(resource), ...languages.map((language) => resource.values[language] ?? ""), + ])); + return [await atomicWrite(path.join(directory, `${slug}.unity-localization.csv`), `${rows.join("\n")}\n`)]; +} + +async function exportGodot(directory: string, slug: string, languages: string[], values: StringResource[]): Promise { + const rows = [csvRow(["keys", ...languages.map(localeForGodot), "_speaker", "_context", "_reed_line_id", "_field"])]; + for (const resource of values) rows.push(csvRow([ + resource.key, ...languages.map((language) => resource.values[language] ?? ""), resource.entry.speaker, + resource.entry.context, resource.lineId, resource.field, + ])); + return [await atomicWrite(path.join(directory, `${slug}.godot.csv`), `${rows.join("\n")}\n`)]; +} + +async function exportUnreal(directory: string, slug: string, languages: string[], values: StringResource[]): Promise { + return Promise.all(languages.map(async (language) => { + const rows = [csvRow(["Key", "SourceString", "Context", "Speaker", "LineID", "Field"])]; + for (const resource of values) rows.push(csvRow([ + resource.key, unreal(resource.values[language] ?? ""), unreal(resourceComment(resource)), resource.entry.speaker, resource.lineId, resource.field, + ])); + return atomicWrite(path.join(directory, localeForUnreal(language), `${slug}.csv`), `${rows.join("\n")}\n`); + })); +} + +async function exportYarn(directory: string, slug: string, defaultLanguage: string, languages: string[], values: StringResource[]): Promise { + return Promise.all(languages.map(async (language) => { + const rows = [csvRow(["language", "id", "text", "file", "node", "lineNumber", "lock", "comment"])]; + for (const resource of values) { + const source = resource.values[defaultLanguage] ?? ""; + rows.push(csvRow([ + language, `line:${resource.key}`, resource.values[language] ?? "", `${resource.entry.sceneSlug}.yarn`, resource.entry.conversation, + resource.entry.entryNumericId, yarnLock(source), resourceComment(resource), + ])); + } + return atomicWrite(path.join(directory, `${slug}.${language}.strings.csv`), `${rows.join("\n")}\n`); + })); +} + +function poHeader(language: string, projectName: string): string { + return [ + 'msgid ""', 'msgstr ""', + `"Project-Id-Version: ${po(projectName)}\\n"`, + `"Language: ${po(language)}\\n"`, + '"MIME-Version: 1.0\\n"', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Content-Transfer-Encoding: 8bit\\n"', + ].join("\n"); +} + +async function exportGettext(directory: string, slug: string, projectName: string, defaultLanguage: string, languages: string[], values: StringResource[]): Promise { + return Promise.all(languages.map(async (language) => { + const blocks = [poHeader(language, projectName)]; + for (const resource of values) blocks.push([ + `#. ${po(resourceComment(resource))}`, + `#: ${po(resource.entry.sceneSlug)}:${resource.entry.entryNumericId}`, + `msgctxt "${po(resource.key)}"`, + `msgid "${po(resource.values[defaultLanguage] ?? "")}"`, + `msgstr "${po(language === defaultLanguage ? resource.values[defaultLanguage] ?? "" : resource.values[language] ?? "")}"`, + ].join("\n")); + return atomicWrite(path.join(directory, `${slug}.${language}.po`), `${blocks.join("\n\n")}\n`); + })); +} + +async function exportXliff(directory: string, slug: string, projectName: string, defaultLanguage: string, languages: string[], values: StringResource[]): Promise { + const targets = languages.filter((language) => language !== defaultLanguage); + const outputLanguages = targets.length ? targets : [defaultLanguage]; + return Promise.all(outputLanguages.map(async (language) => { + const units = values.map((resource) => [ + ` `, + ` ${xml(resourceComment(resource))}${xml(resource.lineId)}`, + " ", + ` ${xml(resource.values[defaultLanguage] ?? "")}`, + ...(language === defaultLanguage ? [] : [` ${xml(resource.values[language] ?? "")}`]), + " ", + " ", + ].join("\n")).join("\n"); + const targetAttribute = language === defaultLanguage ? "" : ` trgLang="${xml(language)}"`; + const document = `\n\n \n${units}\n \n\n`; + return atomicWrite(path.join(directory, `${slug}.${language}.xliff`), document); + })); +} + +export async function exportGameData(reed: ReedProject, options: GameExportOptions): Promise { + if (!GAME_EXPORT_FORMATS.includes(options.format)) throw new ConflictError("Unsupported game export format", { format: options.format, formats: GAME_EXPORT_FORMATS }); + const project = reed.getProject(); + const languages = enabledLanguages(reed, options.languages); + // Keep the source language available for bilingual formats even when callers + // request only target-language output files. + const entries = collectEntries(reed, project.languages); + const stringResources = resources(entries); + const directory = path.resolve(options.outputDirectory ?? path.join(reed.paths.exports, "integrations", options.format)); + await mkdir(directory, { recursive: true }); + const args = [directory, safeFilePart(project.slug)] as const; + let filePaths: string[]; + switch (options.format) { + case "json": filePaths = await exportJson(...args, project.name, project.defaultLanguage, languages, entries); break; + case "csv": filePaths = await exportCsv(...args, languages, entries); break; + case "unity-localization": filePaths = await exportUnity(...args, languages, stringResources); break; + case "godot": filePaths = await exportGodot(...args, languages, stringResources); break; + case "unreal-string-table": filePaths = await exportUnreal(...args, languages, stringResources); break; + case "yarn-spinner": filePaths = await exportYarn(...args, project.defaultLanguage, languages, stringResources); break; + case "gettext": filePaths = await exportGettext(...args, project.name, project.defaultLanguage, languages, stringResources); break; + case "xliff-2.0": filePaths = await exportXliff(...args, project.name, project.defaultLanguage, languages, stringResources); break; + } + return { format: options.format, outputDirectory: directory, filePaths, languages, entryCount: entries.length }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42a1908..7433c6b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "@reed/schema"; export * from "./errors.js"; export * from "./export.js"; +export * from "./game-export.js"; export * from "./models.js"; export * from "./paths.js"; export * from "./pixel-crushers.js"; diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 85c57d2..199a4dc 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -275,6 +275,12 @@ export class ReedProject { const saveSetting = this.db.prepare(`INSERT INTO project_settings(project_id, key, value_json, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(project_id, key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`); const timestamp = now(); + const addLanguageRows = this.db.prepare(`INSERT OR IGNORE INTO node_texts + (node_id, language_code, dialogue_text, menu_text, verification_status, created_at, updated_at) + SELECT node.id, ?, '', NULL, 'unverified', ?, ? + FROM dialogue_nodes node JOIN conversations conversation ON conversation.id = node.conversation_id + WHERE conversation.project_id = ?`); + for (const language of next.languages) addLanguageRows.run(language, timestamp, timestamp, project.id); saveSetting.run(project.id, "pull_request_provider", JSON.stringify(next.pullRequestProvider), timestamp); saveSetting.run(project.id, "pull_request_base_branch", JSON.stringify(next.pullRequestBaseBranch), timestamp); return this.commitProjectMutation(project, "project.update", input.source, next, input.expectedVersion); diff --git a/packages/core/test/game-export.test.ts b/packages/core/test/game-export.test.ts new file mode 100644 index 0000000..e133c01 --- /dev/null +++ b/packages/core/test/game-export.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { GAME_EXPORT_FORMATS, ReedProject, exportGameData } from "../src/index.js"; + +const projects: ReedProject[] = []; + +afterEach(() => { + while (projects.length) projects.pop()?.close(); +}); + +async function fixture(): Promise<{ root: string; project: ReedProject; lineId: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), "reed-game-export-")); + const project = await ReedProject.create(root, { + name: "Λιμάνι", + slug: "harbour", + languages: ["en", "el", "ja", "pt-BR"], + defaultLanguage: "en", + }); + projects.push(project); + const character = (await project.upsertCharacter({ name: "Eleni", displayNames: {}, description: "", actorNotes: "", voiceNotes: "" })).value; + const scene = (await project.createScene({ name: "Old Harbour", slug: "old-harbour" })).value; + const conversation = (await project.createConversation({ sceneId: scene.id, title: "Arrival" })).value; + const line = (await project.addNode({ + conversationId: conversation.id, + kind: "choice", + speakerCharacterId: character.id, + text: "Enter the gate?", + menuText: "Enter", + contextComment: "A guarded invitation", + tags: ["arrival"], + isTerminal: true, + })).value; + await project.setNodeText({ lineId: line.lineId, language: "el", text: "Να περάσω την πύλη;", menuText: "Πέρασε" }); + await project.setNodeText({ lineId: line.lineId, language: "ja", text: "門に入りますか?", menuText: "入る" }); + await project.setNodeText({ lineId: line.lineId, language: "pt-BR", text: "Entrar pelo portão?", menuText: "Entrar" }); + return { root, project, lineId: line.lineId }; +} + +describe("game localization exports", () => { + it("writes every supported format with deterministic permanent keys", async () => { + const { project, lineId } = await fixture(); + const outputs = []; + for (const format of GAME_EXPORT_FORMATS) outputs.push(await exportGameData(project, { format })); + + expect(outputs.every((output) => output.entryCount === 1 && output.filePaths.length > 0)).toBe(true); + const unity = outputs.find((output) => output.format === "unity-localization"); + const unityCsv = await readFile(unity?.filePaths[0] ?? "", "utf8"); + expect(unityCsv).toContain('"Key","Id","Shared Comments"'); + expect(unityCsv).toContain(`"${lineId}.menu"`); + expect(unityCsv).toContain("Να περάσω την πύλη;"); + + const godot = outputs.find((output) => output.format === "godot"); + const godotCsv = await readFile(godot?.filePaths[0] ?? "", "utf8"); + expect(godotCsv).toContain('"keys","en","el","ja","pt_BR"'); + + const unreal = outputs.find((output) => output.format === "unreal-string-table"); + expect(unreal?.filePaths).toHaveLength(4); + expect(await readFile(unreal?.filePaths.find((file) => file.includes("pt-BR")) ?? "", "utf8")).toContain("Entrar pelo portão?"); + + const yarn = outputs.find((output) => output.format === "yarn-spinner"); + expect(await readFile(yarn?.filePaths.find((file) => file.includes(".ja.")) ?? "", "utf8")).toContain(`"line:${lineId}"`); + + const gettext = outputs.find((output) => output.format === "gettext"); + expect(await readFile(gettext?.filePaths.find((file) => file.includes(".el.")) ?? "", "utf8")).toContain('msgstr "Να περάσω την πύλη;"'); + + const xliff = outputs.find((output) => output.format === "xliff-2.0"); + expect(await readFile(xliff?.filePaths.find((file) => file.includes(".ja.")) ?? "", "utf8")).toContain("門に入りますか?"); + }); + + it("supports target-only file selection while retaining the source text", async () => { + const { project } = await fixture(); + const output = await exportGameData(project, { format: "xliff-2.0", languages: ["el"] }); + expect(output.languages).toEqual(["el"]); + expect(output.filePaths).toHaveLength(1); + const contents = await readFile(output.filePaths[0] ?? "", "utf8"); + expect(contents).toContain('srcLang="en" trgLang="el"'); + expect(contents).toContain("Enter the gate?"); + }); +}); diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 31bda93..b28b481 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -58,6 +58,21 @@ describe("ReedProject", () => { .rejects.toMatchObject({ code: "STALE_VERSION" }); }); + it("canonicalizes added languages and preserves disabled language text", async () => { + const { project } = await createProject(["en", "el"]); + const conversation = await createConversation(project); + const line = (await project.addNode({ conversationId: conversation.id, text: "Source" })).value; + await project.setNodeText({ lineId: line.lineId, language: "el", text: "Πηγή" }); + const current = project.getProject(); + const changed = await project.updateProject({ languages: ["en", "pt-br"], defaultLanguage: "pt-br", expectedVersion: current.version }); + + expect(changed.value.languages).toEqual(["en", "pt-BR"]); + expect(changed.value.defaultLanguage).toBe("pt-BR"); + const texts = project.getNodeByLineId(line.lineId).texts; + expect(texts).toContainEqual(expect.objectContaining({ language: "el", text: "Πηγή" })); + expect(texts).toContainEqual(expect.objectContaining({ language: "pt-BR", text: "" })); + }); + it("allocates stable Line IDs and persistent numeric IDs", async () => { const { root, project } = await createProject(); const level = (await project.createLevel({ name: "Chapter One" })).value; diff --git a/packages/schema/package.json b/packages/schema/package.json index f11abab..ca6e3fe 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -6,7 +6,9 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "browser": "./dist/index.js", + "default": "./dist/index.js" } }, "scripts": { diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 745ee28..e870e26 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,4 +1,7 @@ import { z } from "zod"; +import { GAME_LANGUAGE_CATALOG, canonicalizeLanguageCode, isLanguageCode } from "./languages.js"; + +export * from "./languages.js"; export const SCHEMA_VERSION = 1 as const; @@ -6,11 +9,11 @@ export const DEFAULT_LANGUAGES = ["en", "el", "it", "de", "griko"] as const; export const IdSchema = z.string().uuid(); export const LineIdSchema = z.string().regex(/^[a-z0-9][a-z0-9_-]{3,63}$/); -export const LanguageCodeSchema = z - .string() - .min(2) - .max(24) - .regex(/^[a-z][a-z0-9-]*$/); +export const LanguageCodeSchema = z.preprocess( + (value) => typeof value === "string" ? canonicalizeLanguageCode(value) : value, + z.string().min(2).max(63) + .refine(isLanguageCode, "Use a valid BCP 47 language tag, for example en, pt-BR, zh-Hans, or x-griko"), +); export const NodeKindSchema = z.enum(["start", "line", "choice", "group"]); export const TextStatusSchema = z.enum([ @@ -340,5 +343,7 @@ export function getSchemaDescriptor() { mutationSources: MutationSourceSchema.options, variableTypes: VariableTypeSchema.options, defaultLanguages: DEFAULT_LANGUAGES, + languageTagStandard: "IETF BCP 47", + gameLanguageCatalog: GAME_LANGUAGE_CATALOG, } as const; } diff --git a/packages/schema/src/languages.ts b/packages/schema/src/languages.ts new file mode 100644 index 0000000..e1f4201 --- /dev/null +++ b/packages/schema/src/languages.ts @@ -0,0 +1,139 @@ +export type LanguageDirection = "ltr" | "rtl"; + +export interface GameLanguageDefinition { + /** Canonical project identifier. BCP 47 unless `legacy` is true. */ + code: string; + englishName: string; + nativeName: string; + direction: LanguageDirection; + /** Steamworks API name where Steam exposes one for this language. */ + steamCode?: string; + /** True for Reed's original Griko identifier, retained for existing projects. */ + legacy?: boolean; +} + +/** + * A curated, offline catalog for game projects. It includes Steam's platform + * languages plus regional variants and additional locales commonly requested + * by game-localization teams. The project can still use any valid BCP 47 tag. + */ +export const GAME_LANGUAGE_CATALOG: readonly GameLanguageDefinition[] = [ + { code: "ar", englishName: "Arabic", nativeName: "العربية", direction: "rtl", steamCode: "arabic" }, + { code: "bg", englishName: "Bulgarian", nativeName: "Български", direction: "ltr", steamCode: "bulgarian" }, + { code: "ca", englishName: "Catalan", nativeName: "Català", direction: "ltr" }, + { code: "zh-Hans", englishName: "Chinese (Simplified)", nativeName: "简体中文", direction: "ltr", steamCode: "schinese" }, + { code: "zh-Hant", englishName: "Chinese (Traditional)", nativeName: "繁體中文", direction: "ltr", steamCode: "tchinese" }, + { code: "hr", englishName: "Croatian", nativeName: "Hrvatski", direction: "ltr" }, + { code: "cs", englishName: "Czech", nativeName: "Čeština", direction: "ltr", steamCode: "czech" }, + { code: "da", englishName: "Danish", nativeName: "Dansk", direction: "ltr", steamCode: "danish" }, + { code: "nl", englishName: "Dutch", nativeName: "Nederlands", direction: "ltr", steamCode: "dutch" }, + { code: "en", englishName: "English", nativeName: "English", direction: "ltr", steamCode: "english" }, + { code: "en-AU", englishName: "English (Australia)", nativeName: "English (Australia)", direction: "ltr" }, + { code: "en-GB", englishName: "English (United Kingdom)", nativeName: "English (United Kingdom)", direction: "ltr" }, + { code: "en-US", englishName: "English (United States)", nativeName: "English (United States)", direction: "ltr" }, + { code: "fil", englishName: "Filipino", nativeName: "Filipino", direction: "ltr" }, + { code: "fi", englishName: "Finnish", nativeName: "Suomi", direction: "ltr", steamCode: "finnish" }, + { code: "fr", englishName: "French", nativeName: "Français", direction: "ltr", steamCode: "french" }, + { code: "fr-CA", englishName: "French (Canada)", nativeName: "Français (Canada)", direction: "ltr" }, + { code: "de", englishName: "German", nativeName: "Deutsch", direction: "ltr", steamCode: "german" }, + { code: "el", englishName: "Greek", nativeName: "Ελληνικά", direction: "ltr", steamCode: "greek" }, + { code: "he", englishName: "Hebrew", nativeName: "עברית", direction: "rtl" }, + { code: "hi", englishName: "Hindi", nativeName: "हिन्दी", direction: "ltr" }, + { code: "hu", englishName: "Hungarian", nativeName: "Magyar", direction: "ltr", steamCode: "hungarian" }, + { code: "id", englishName: "Indonesian", nativeName: "Bahasa Indonesia", direction: "ltr", steamCode: "indonesian" }, + { code: "it", englishName: "Italian", nativeName: "Italiano", direction: "ltr", steamCode: "italian" }, + { code: "ja", englishName: "Japanese", nativeName: "日本語", direction: "ltr", steamCode: "japanese" }, + { code: "ko", englishName: "Korean", nativeName: "한국어", direction: "ltr", steamCode: "koreana" }, + { code: "ms", englishName: "Malay", nativeName: "Bahasa Melayu", direction: "ltr", steamCode: "malay" }, + { code: "no", englishName: "Norwegian", nativeName: "Norsk", direction: "ltr", steamCode: "norwegian" }, + { code: "fa", englishName: "Persian", nativeName: "فارسی", direction: "rtl" }, + { code: "pl", englishName: "Polish", nativeName: "Polski", direction: "ltr", steamCode: "polish" }, + { code: "pt-PT", englishName: "Portuguese (Portugal)", nativeName: "Português (Portugal)", direction: "ltr", steamCode: "portuguese" }, + { code: "pt-BR", englishName: "Portuguese (Brazil)", nativeName: "Português (Brasil)", direction: "ltr", steamCode: "brazilian" }, + { code: "ro", englishName: "Romanian", nativeName: "Română", direction: "ltr", steamCode: "romanian" }, + { code: "ru", englishName: "Russian", nativeName: "Русский", direction: "ltr", steamCode: "russian" }, + { code: "sr-Cyrl", englishName: "Serbian (Cyrillic)", nativeName: "Српски", direction: "ltr" }, + { code: "sr-Latn", englishName: "Serbian (Latin)", nativeName: "Srpski", direction: "ltr" }, + { code: "sk", englishName: "Slovak", nativeName: "Slovenčina", direction: "ltr" }, + { code: "sl", englishName: "Slovenian", nativeName: "Slovenščina", direction: "ltr" }, + { code: "es-ES", englishName: "Spanish (Spain)", nativeName: "Español (España)", direction: "ltr", steamCode: "spanish" }, + { code: "es-419", englishName: "Spanish (Latin America)", nativeName: "Español (Latinoamérica)", direction: "ltr", steamCode: "latam" }, + { code: "sv", englishName: "Swedish", nativeName: "Svenska", direction: "ltr", steamCode: "swedish" }, + { code: "th", englishName: "Thai", nativeName: "ไทย", direction: "ltr", steamCode: "thai" }, + { code: "tr", englishName: "Turkish", nativeName: "Türkçe", direction: "ltr", steamCode: "turkish" }, + { code: "uk", englishName: "Ukrainian", nativeName: "Українська", direction: "ltr", steamCode: "ukrainian" }, + { code: "vi", englishName: "Vietnamese", nativeName: "Tiếng Việt", direction: "ltr", steamCode: "vietnamese" }, + { code: "griko", englishName: "Griko", nativeName: "Griko", direction: "ltr", legacy: true }, + { code: "x-griko", englishName: "Griko (private-use tag)", nativeName: "Griko", direction: "ltr" }, +] as const; + +const gameLanguagePreset: string[] = []; +for (const language of GAME_LANGUAGE_CATALOG) { + if (language.steamCode) gameLanguagePreset.push(language.code); +} + +/** Languages exposed by Steam as supported game languages, in catalog order. */ +export const GAME_LANGUAGE_PRESET: readonly string[] = gameLanguagePreset; + +const catalogByCode = new Map(GAME_LANGUAGE_CATALOG.map((language) => [language.code.toLowerCase(), language])); + +// RFC 5646-compatible structural validation. Registry membership is not +// required so private-use and studio-specific registered subtags remain usable. +const LANGUAGE_TAG_PATTERN = /^(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-[A-Za-z0-9]{5,8}|-[0-9][A-Za-z0-9]{3})*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-x(?:-[A-Za-z0-9]{1,8})+)?|x(?:-[A-Za-z0-9]{1,8})+)$/; + +export function isLanguageCode(value: string): boolean { + return value.length <= 63 && LANGUAGE_TAG_PATTERN.test(value); +} + +export function canonicalizeLanguageCode(value: string): string { + const normalized = value.trim().replaceAll("_", "-"); + if (!isLanguageCode(normalized)) return normalized; + if (normalized.toLowerCase() === "griko") return "griko"; + try { + return Intl.getCanonicalLocales(normalized)[0] ?? normalized; + } catch { + const [language = "", ...subtags] = normalized.split("-"); + return [language.toLowerCase(), ...subtags.map((subtag) => { + if (/^[A-Za-z]{4}$/.test(subtag)) return `${subtag[0]?.toUpperCase() ?? ""}${subtag.slice(1).toLowerCase()}`; + if (/^[A-Za-z]{2}$/.test(subtag) || /^[0-9]{3}$/.test(subtag)) return subtag.toUpperCase(); + return subtag.toLowerCase(); + })].join("-"); + } +} + +export function getLanguageDefinition(code: string): GameLanguageDefinition | undefined { + return catalogByCode.get(canonicalizeLanguageCode(code).toLowerCase()); +} + +export function languageDisplayName(code: string): string { + return getLanguageDefinition(code)?.englishName ?? code; +} + +export function languageNativeName(code: string): string { + return getLanguageDefinition(code)?.nativeName ?? code; +} + +export function languageDirection(code: string): LanguageDirection { + return getLanguageDefinition(code)?.direction ?? "ltr"; +} + +export function localeForGodot(code: string): string { + const canonical = canonicalizeLanguageCode(code); + if (canonical === "zh-Hans") return "zh_CN"; + if (canonical === "zh-Hant") return "zh_TW"; + return canonical.replaceAll("-", "_"); +} + +export function localeForUnity(code: string): string { + const canonical = canonicalizeLanguageCode(code); + if (canonical === "griko") return "x-griko"; + return canonical; +} + +export function localeForUnreal(code: string): string { + const canonical = canonicalizeLanguageCode(code); + if (canonical === "zh-Hans") return "zh-Hans"; + if (canonical === "zh-Hant") return "zh-Hant"; + if (canonical === "griko") return "x-griko"; + return canonical; +} diff --git a/packages/schema/test/languages.test.ts b/packages/schema/test/languages.test.ts new file mode 100644 index 0000000..b362822 --- /dev/null +++ b/packages/schema/test/languages.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + GAME_LANGUAGE_CATALOG, + GAME_LANGUAGE_PRESET, + LanguageCodeSchema, + canonicalizeLanguageCode, + getLanguageDefinition, + localeForGodot, + localeForUnity, +} from "../src/index.js"; + +describe("game language catalog", () => { + it("covers the common game market with unique language tags", () => { + expect(GAME_LANGUAGE_CATALOG.length).toBeGreaterThanOrEqual(40); + expect(new Set(GAME_LANGUAGE_CATALOG.map((language) => language.code.toLowerCase())).size).toBe(GAME_LANGUAGE_CATALOG.length); + expect(GAME_LANGUAGE_CATALOG.filter((language) => language.steamCode).length).toBeGreaterThanOrEqual(29); + expect(GAME_LANGUAGE_PRESET.length).toBe(GAME_LANGUAGE_CATALOG.filter((language) => language.steamCode).length); + for (const language of GAME_LANGUAGE_CATALOG) expect(LanguageCodeSchema.parse(language.code)).toBe(language.code); + }); + + it("accepts and canonicalizes BCP 47 tags while retaining legacy Griko", () => { + expect(LanguageCodeSchema.parse("pt-br")).toBe("pt-BR"); + expect(LanguageCodeSchema.parse("pt_br")).toBe("pt-BR"); + expect(LanguageCodeSchema.parse("zh-hans")).toBe("zh-Hans"); + expect(LanguageCodeSchema.parse("x-studio-dialect")).toBe("x-studio-dialect"); + expect(LanguageCodeSchema.parse("griko")).toBe("griko"); + expect(() => LanguageCodeSchema.parse("not a locale")).toThrow(); + }); + + it("provides engine mappings without changing stored project identity", () => { + expect(canonicalizeLanguageCode("es-419")).toBe("es-419"); + expect(localeForGodot("pt-BR")).toBe("pt_BR"); + expect(localeForGodot("zh-Hans")).toBe("zh_CN"); + expect(localeForUnity("griko")).toBe("x-griko"); + expect(getLanguageDefinition("EL")?.englishName).toBe("Greek"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 405c4ef..9d75fa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,15 @@ importers: '@reed/core': specifier: workspace:* version: link:../../packages/core + '@reed/schema': + specifier: workspace:* + version: link:../../packages/schema '@xyflow/react': specifier: ^12.11.2 version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) better-sqlite3: - specifier: ^12.2.0 - version: 12.11.1 + specifier: 12.10.0 + version: 12.10.0 react: specifier: ^19.1.1 version: 19.2.7 @@ -85,8 +88,8 @@ importers: specifier: ^7.1.2 version: 7.1.4(webpack@5.108.4) electron: - specifier: 43.1.0 - version: 43.1.0 + specifier: 41.10.2 + version: 41.10.2 node-loader: specifier: ^2.1.0 version: 2.1.0(webpack@5.108.4) @@ -148,8 +151,8 @@ importers: specifier: workspace:* version: link:../schema better-sqlite3: - specifier: ^12.2.0 - version: 12.11.1 + specifier: 12.10.0 + version: 12.10.0 exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -1222,8 +1225,8 @@ packages: batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} - better-sqlite3@12.11.1: - resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} big-integer@1.6.52: @@ -1712,8 +1715,8 @@ packages: electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} - electron@43.1.0: - resolution: {integrity: sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==} + electron@41.10.2: + resolution: {integrity: sha512-vzSetbn05LPfg0gW0p9txpqmkkFyTXzdSHvKIXbtmsK362hkgUQJu+x19GSSS9v/VXeFi5UJI5TYJ1a+Os3CpA==} engines: {node: '>= 22.12.0'} hasBin: true @@ -5173,7 +5176,7 @@ snapshots: batch@0.6.1: {} - better-sqlite3@12.11.1: + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 @@ -5676,7 +5679,7 @@ snapshots: electron-to-chromium@1.5.389: {} - electron@43.1.0: + electron@41.10.2: dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0 diff --git a/scripts/offline-smoke.mjs b/scripts/offline-smoke.mjs index 16bb273..0515093 100644 --- a/scripts/offline-smoke.mjs +++ b/scripts/offline-smoke.mjs @@ -46,6 +46,9 @@ try { run(cli, ["--project", projectRoot, "--json", "export-pixelcrushers"]); run(cli, ["--project", projectRoot, "--json", "export-voice", "--language", "el"]); run(cli, ["--project", projectRoot, "--json", "export-screenplay", "--language", "el", "--reference-language", "en"]); + for (const format of ["json", "csv", "unity-localization", "godot", "unreal-string-table", "yarn-spinner", "gettext", "xliff-2.0"]) { + run(cli, ["--project", projectRoot, "--json", "export-game-data", "--format", format]); + } const client = new Client({ name: "reed-offline-smoke", version: "0.1.0" }, { capabilities: {} }); const transport = new StdioClientTransport({ @@ -56,7 +59,7 @@ try { }); await client.connect(transport); const tools = await client.listTools(); - const requiredTools = ["import_conversation_json", "export_pixelcrushers", "update_project", "update_scene", "update_conversation", "declare_variable", "list_variables"]; + const requiredTools = ["import_conversation_json", "export_pixelcrushers", "export_game_data", "update_project", "update_scene", "update_conversation", "declare_variable", "list_variables"]; if (requiredTools.some((name) => !tools.tools.some((tool) => tool.name === name))) throw new Error("MCP tool surface is incomplete"); const mcpScenes = await client.callTool({ name: "list_scenes", arguments: {} }); const mcpScenePayload = JSON.parse(mcpScenes.content.find((item) => item.type === "text")?.text ?? "{}"); @@ -76,6 +79,8 @@ try { if (!mcpVariablePayload.data?.some((variable) => variable.name === "mcp_ready")) throw new Error("MCP variable registry integration failed offline"); const validation = await client.callTool({ name: "get_validation_report", arguments: {} }); if (validation.isError) throw new Error("MCP validation failed offline"); + const gameExport = await client.callTool({ name: "export_game_data", arguments: { format: "godot", languages: ["en", "el"] } }); + if (gameExport.isError) throw new Error("MCP game-data export failed offline"); await client.close(); const gitInit = JSON.parse(run(cli, ["--project", projectRoot, "--json", "git-init"])).data; @@ -99,6 +104,8 @@ try { if (!projectJson.includes("The Salt Gate")) throw new Error("Deterministic project export is missing"); const voicePdf = await readFile(path.join(projectRoot, "exports", "voice", "voice-actor-handoff.pdf")); if (voicePdf.subarray(0, 4).toString() !== "%PDF") throw new Error("Voice PDF was not produced offline"); + const godotCsv = await readFile(path.join(projectRoot, "exports", "integrations", "godot", "the-salt-gate.godot.csv"), "utf8"); + if (!godotCsv.includes('"keys","en","el"')) throw new Error("Godot localization export was not produced offline"); const forbidden = /\bfetch\s*\(|new\s+WebSocket\b|EventSource\b|sendBeacon\b|electron-updater|autoUpdater|@sentry|posthog|amplitude|url\(\s*["']?https?:|<(?:script|link|img)[^>]+(?:src|href)=["']https?:/iu; for (const area of [path.join(repository, "apps"), path.join(repository, "packages")]) {