From 1dabf0a71765a81bfaf2dabc53e9f4f16c9c76b8 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Wed, 21 Oct 2026 12:31:50 +0300 Subject: [PATCH 01/25] Extract narrative features into an addon Move dialogue runtime, UI, `.story` tooling, resources, and tests into a standalone Narrative addon. Add extension points for IDE script templates and camera poses, and rebuild scripting environments when addon contributions change. --- .../common/ScriptingEnvironmentFactory.kt | 43 +++++++++- addons/narrative/build.gradle.kts | 14 ++++ .../addons/narrative/HollowNarrativeAddon.kt | 62 ++++++++++++++ .../narrative/client}/DialogueScreenUi.kt | 36 ++++----- .../narrative/client/NarrativeClient.kt | 33 ++++++++ .../narrative/client}/StoryScreenEffects.kt | 15 ++-- .../narrative}/dialogue/DialogueCharacter.kt | 2 +- .../narrative/dialogue/DialogueCommands.kt | 38 +++++++++ .../narrative}/dialogue/DialogueController.kt | 4 +- .../addons/narrative}/dialogue/DialogueDsl.kt | 5 +- .../narrative}/dialogue/DialogueEvents.kt | 2 +- .../narrative}/dialogue/DialogueInput.kt | 2 +- .../narrative}/dialogue/DialoguePresenter.kt | 2 +- .../narrative}/dialogue/DialogueSession.kt | 2 +- .../narrative}/dialogue/DialogueUiContract.kt | 6 +- .../dialogue/ScriptRegistryStorySource.kt | 2 +- .../narrative}/dialogue/StoryArguments.kt | 6 +- .../dialogue/StoryBuiltinFunctions.kt | 6 +- .../narrative}/dialogue/StoryCheckpoint.kt | 4 +- .../dialogue/StoryEffectFunctions.kt | 24 +++--- .../narrative}/dialogue/StoryEffectPackets.kt | 6 +- .../narrative}/dialogue/StoryFunctions.kt | 10 +-- .../narrative}/dialogue/StoryLibrary.kt | 10 +-- .../narrative}/dialogue/StoryNpcFunctions.kt | 4 +- .../addons/narrative}/dialogue/StoryValue.kt | 2 +- .../dialogue/UiDialoguePresentation.kt | 4 +- .../narrative}/dialogue/lang/StoryCompiler.kt | 4 +- .../dialogue/lang/StoryCompletionContext.kt | 2 +- .../narrative}/dialogue/lang/StoryCst.kt | 2 +- .../dialogue/lang/StoryDiagnostic.kt | 2 +- .../dialogue/lang/StoryExpressions.kt | 4 +- .../dialogue/lang/StoryFunctionSignature.kt | 4 +- .../narrative}/dialogue/lang/StoryParser.kt | 6 +- .../narrative}/dialogue/lang/StoryProgram.kt | 2 +- .../addons/narrative/ide}/StoryHighlighter.kt | 12 +-- .../narrative/ide}/StoryScriptingAnalyzer.kt | 16 ++-- .../main/resources/META-INF/plugin.properties | 7 ++ .../hollowengine-narrative/lang/en_us.json | 5 ++ .../hollowengine-narrative/lang/ru_ru.json | 5 ++ .../textures/gui/dialogues/button.png | Bin .../textures/gui/dialogues/character_name.png | Bin .../textures/gui/dialogues/cursor.png | Bin .../textures/gui/dialogues/desc.png | Bin .../textures/gui/dialogues/dialogue_box.png | Bin .../ui/examples/dialogue.ui.kts | 8 +- .../ui/examples/dialogue_overlay.hss | 0 .../ui/styles/dialogue.hss | 6 +- .../templates/dialogue.story.template | 0 .../client/DialogueSurfaceScriptTest.kt | 76 ++++++++++++++++++ .../narrative}/dialogue/DemoStoryTest.kt | 6 +- .../dialogue/DialogueCheckpointTest.kt | 4 +- .../dialogue/DialogueRuntimeTest.kt | 8 +- .../narrative}/dialogue/StoryCompilerTest.kt | 4 +- .../dialogue/StoryCompletionContextTest.kt | 6 +- .../narrative}/dialogue/StoryParserTest.kt | 12 +-- .../narrative/ide/CompletionTestSupport.kt | 18 +++++ .../ide}/StoryScriptingAnalyzerTest.kt | 3 +- .../src/test/resources/dialogue/demo.story | 0 build.gradle.kts | 3 + .../hollowengine/client/ui/FormattedText.kt | 8 +- .../client/ui/ide/HollowIdeExtensions.kt | 7 ++ .../ui/ide/HollowIdeProjectContextMenu.kt | 6 +- .../ui/ide/HollowIdeProjectController.kt | 6 +- .../client/ui/ide/HollowIdeScriptTemplates.kt | 74 +++++++++++------ .../ui/ide/files/EditorLanguageService.kt | 7 -- .../timeline/cutscene/CameraPoseSources.kt | 25 ++++++ .../timeline/cutscene/CutsceneCameraSystem.kt | 12 ++- .../client/ui/script/UiScriptLoader.kt | 2 - .../common/addons/HollowAddonRuntime.kt | 6 +- .../common/addons/HollowAddonsChangedEvent.kt | 13 +++ .../common/commands/HollowEngineCommands.kt | 23 ------ .../scripting/DefaultScriptDefinitions.kt | 9 --- .../scripting/reload/ServerReloadScripts.kt | 5 +- .../reload/ServerReloadScriptsEvent.kt | 20 +++++ .../common/ui/UiDefinitionRegistry.kt | 11 ++- .../{dialogue => ui}/text/FormattedText.kt | 2 +- .../lang/en_us/hollowengine/commands.json | 4 +- .../lang/en_us/hollowengine/dialogue.json | 3 - .../lang/ru_ru/hollowengine/commands.json | 4 +- .../lang/ru_ru/hollowengine/dialogue.json | 3 - .../common/ui/UiScriptContentTest.kt | 55 ------------- .../text}/FormattedTextParserTest.kt | 8 +- 82 files changed, 586 insertions(+), 296 deletions(-) create mode 100644 addons/narrative/build.gradle.kts create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client}/DialogueScreenUi.kt (87%) create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client}/StoryScreenEffects.kt (90%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueCharacter.kt (92%) create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCommands.kt rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueController.kt (99%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueDsl.kt (93%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueEvents.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueInput.kt (95%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialoguePresenter.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueSession.kt (96%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueUiContract.kt (94%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/ScriptRegistryStorySource.kt (95%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryArguments.kt (96%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryBuiltinFunctions.kt (91%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryCheckpoint.kt (97%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryEffectFunctions.kt (92%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryEffectPackets.kt (88%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryFunctions.kt (94%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryLibrary.kt (89%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryNpcFunctions.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryValue.kt (96%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/UiDialoguePresentation.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryCompiler.kt (99%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryCompletionContext.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryCst.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryDiagnostic.kt (93%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryExpressions.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryFunctionSignature.kt (96%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryParser.kt (99%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/lang/StoryProgram.kt (98%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide}/StoryHighlighter.kt (96%) rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide}/StoryScriptingAnalyzer.kt (97%) create mode 100644 addons/narrative/src/main/resources/META-INF/plugin.properties create mode 100644 addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json create mode 100644 addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/textures/gui/dialogues/button.png (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/textures/gui/dialogues/character_name.png (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/textures/gui/dialogues/cursor.png (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/textures/gui/dialogues/desc.png (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/textures/gui/dialogues/dialogue_box.png (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/ui/examples/dialogue.ui.kts (92%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/ui/examples/dialogue_overlay.hss (100%) rename {runtime/src/main/resources/assets/hollowengine => addons/narrative/src/main/resources/assets/hollowengine-narrative}/ui/styles/dialogue.hss (94%) rename {runtime => addons/narrative}/src/main/resources/internal/templates/dialogue.story.template (100%) create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueSurfaceScriptTest.kt rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DemoStoryTest.kt (91%) rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueCheckpointTest.kt (98%) rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/DialogueRuntimeTest.kt (98%) rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryCompilerTest.kt (98%) rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryCompletionContextTest.kt (92%) rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/dialogue/StoryParserTest.kt (94%) create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/CompletionTestSupport.kt rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide}/StoryScriptingAnalyzerTest.kt (98%) rename {runtime => addons/narrative}/src/test/resources/dialogue/demo.story (100%) create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt rename runtime/src/main/java/ru/hollowhorizon/hollowengine/common/{dialogue => ui}/text/FormattedText.kt (99%) delete mode 100644 runtime/src/main/lang/en_us/hollowengine/dialogue.json delete mode 100644 runtime/src/main/lang/ru_ru/hollowengine/dialogue.json rename runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/{dialogue => ui/text}/FormattedTextParserTest.kt (91%) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt index ded423347..7829594de 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt @@ -5,10 +5,12 @@ import kotlinx.coroutines.* import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonsChangedEvent import ru.hollowhorizon.hollowengine.common.addons.publish import ru.hollowhorizon.hollowengine.common.compiler.ScriptingCompilerImpl import ru.hollowhorizon.hollowengine.common.compiler.configuration.HollowScriptConfiguration import ru.hollowhorizon.hollowengine.common.compiler.configuration.clientSideImplicitReceivers +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.ide.session.AnalysisEnvironment import ru.hollowhorizon.hollowengine.common.ide.session.EmptyLogger import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions @@ -37,6 +39,9 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol @Volatile private var loaded: LoadedAddon? = null + @Volatile + private var built: BuiltEnvironment? = null + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { loaded = LoadedAddon(context.addonFile, scope) setUp(context.addonFile, scope) @@ -45,6 +50,7 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol override suspend fun unload(context: HollowAddonContext) { loaded = null + built = null ScriptingEnvironment.clear() } @@ -56,20 +62,51 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol setUp(addon.file, addon.scope) } + /** + * Addons loaded after this one may add script types and imports. Once they are all in, the + * environment is recreated with them on the same classpath, so nothing is remapped again. + */ + @SubscribeEvent + @Synchronized + fun onAddonsChanged(event: HollowAddonsChangedEvent) { + val addon = loaded ?: return + val current = built ?: return + val scriptTypes = DefaultScriptDefinitions.providers() + if (scriptTypes == current.scriptTypes) return + ScriptingEnvironment.clear() + start(addon, current.mappings, current.classpath, scriptTypes) + } + private fun setUp(addonFile: File, scope: CoroutineScope) { val (mappings, classpath) = CommonEnvironment.setup(addonFile) + start(LoadedAddon(addonFile, scope), mappings, classpath, DefaultScriptDefinitions.providers()) + } + + private fun start( + addon: LoadedAddon, + mappings: Mappings, + classpath: List, + scriptTypes: List, + ) { val environment = initialize( javaHome = File(System.getProperty("java.home")), classpath = classpath, - hostClasspath = classpath + addonFile, - scriptTypes = DefaultScriptDefinitions.providers(), + hostClasspath = classpath + addon.file, + scriptTypes = scriptTypes, mappings = mappings, ) - environment.warmUpAnalysis(scope) + built = BuiltEnvironment(mappings, classpath, scriptTypes) + environment.warmUpAnalysis(addon.scope) } private class LoadedAddon(val file: File, val scope: CoroutineScope) + private class BuiltEnvironment( + val mappings: Mappings, + val classpath: List, + val scriptTypes: List, + ) + override fun initialize( javaHome: File, classpath: List, diff --git a/addons/narrative/build.gradle.kts b/addons/narrative/build.gradle.kts new file mode 100644 index 000000000..76908319d --- /dev/null +++ b/addons/narrative/build.gradle.kts @@ -0,0 +1,14 @@ +base { + archivesName.set("HollowEngineNarrative") +} + +dependencies { + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + val serializationVersion = rootProject.property("serializationVersion") as String + add("testRuntimeOnly", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") +} + +tasks.withType().configureEach { useJUnitPlatform() } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt new file mode 100644 index 000000000..2f95253e0 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt @@ -0,0 +1,62 @@ +package ru.hollowhorizon.hollowengine.addons.narrative + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.addons.narrative.client.NarrativeClient +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCommands +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.ScriptRegistryStorySource +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryEngine +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.addons.extensions +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import ru.hollowhorizon.hollowengine.common.scripting.NODE_SCRIPT_EXTENSION +import ru.hollowhorizon.hollowengine.common.scripting.RELOAD_SCRIPT_EXTENSION +import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefaultImportsEvent +import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadScriptsEvent +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +/** + * Dialogues and the `.story` language. NPCs, their action channels and cutscenes stay in the engine; + * this addon builds stories on top of them. + */ +class HollowNarrativeAddon : HollowAddonEntrypoint { + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { + ScriptRegistry.addListener(ScriptRegistryStorySource) + context.extensions.onUnload { ScriptRegistry.removeListener(ScriptRegistryStorySource) } + if (isPhysicalClient) NarrativeClient.register(context.extensions) + } + + @SubscribeEvent + fun addDefaultImports(event: ScriptDefaultImportsEvent) { + when (event.suffix) { + RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS + NODE_SCRIPT_EXTENSION -> event.defaultImports += DIALOGUE_PACKAGE + } + } + + /** Engine-owned story functions are reinstalled before reload scripts register theirs. */ + @SubscribeEvent + fun beforeReloadScripts(event: ServerReloadScriptsEvent.Pre) = StoryEngine.prepareReload() + + @SubscribeEvent + fun afterReloadScripts(event: ServerReloadScriptsEvent.Post) = StoryEngine.completeReload() + + @SubscribeEvent(-1) + fun registerCommands(event: RegisterCommandsEvent) { + val root = requireNotNull(event.dispatcher.root.getChild("hollowengine")) { + "The HollowEngine root command must be registered before addon commands" + } + DialogueCommands.register(root) + } + + private companion object { + const val DIALOGUE_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.*" + + /** Reload scripts register story functions, so they also get the signature helpers. */ + val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE) + + listOf("actor", "any", "boolean", "list", "number", "signature", "string") + .map { "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.$it" } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueScreenUi.kt similarity index 87% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueScreenUi.kt index c16b746de..84aaed64a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueScreenUi.kt @@ -1,10 +1,10 @@ -package ru.hollowhorizon.hollowengine.client.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.client import androidx.compose.runtime.* import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState -import ru.hollowhorizon.hollowengine.common.dialogue.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.* import ru.hollowhorizon.hollowengine.common.ui.UiGuiScale import ru.hollowhorizon.hollowengine.common.ui.UiScope import ru.hollowhorizon.hollowengine.common.ui.UiScreenDefinition @@ -14,28 +14,24 @@ import ru.hollowhorizon.hollowengine.common.ui.send * The dialogue window the engine ships with. */ object DialogueScreenUi { - const val STYLESHEET = "hollowengine:ui/styles/dialogue.hss" + const val STYLESHEET = "hollowengine-narrative:ui/styles/dialogue.hss" const val EXIT_DURATION_MILLIS = 760L - /** Registers the built-in screen. Scripts declaring the same id take over from it. */ - fun register(registry: (UiScreenDefinition) -> Unit) { - registry( - UiScreenDefinition( - id = UiDialoguePresentation.DEFAULT_SCREEN, - title = "Dialogue", - closeOnEscape = false, - pausesGame = false, - rebuildEveryFrame = false, - guiScale = UiGuiScale.Auto, - exitDuration = EXIT_DURATION_MILLIS, - content = { DialogueScreen() }, - ), - ) - } + /** The built-in screen. Scripts declaring the same id take over from it. */ + val definition = UiScreenDefinition( + id = UiDialoguePresentation.DEFAULT_SCREEN, + title = "Dialogue", + closeOnEscape = false, + pausesGame = false, + rebuildEveryFrame = false, + guiScale = UiGuiScale.Auto, + exitDuration = EXIT_DURATION_MILLIS, + content = { DialogueScreen() }, + ) } -private const val CURSOR_TEXTURE = "hollowengine:textures/gui/dialogues/cursor.png" -private const val DESC_TEXTURE = "hollowengine:textures/gui/dialogues/desc.png" +private const val CURSOR_TEXTURE = "hollowengine-narrative:textures/gui/dialogues/cursor.png" +private const val DESC_TEXTURE = "hollowengine-narrative:textures/gui/dialogues/desc.png" /** `icon="none"` on a `@choice` drops the icon that otherwise sits left of the button. */ private const val NO_ICON = "none" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt new file mode 100644 index 000000000..ddf7085c0 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt @@ -0,0 +1,33 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.client + +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryScriptingAnalyzer +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeScriptTemplate +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeLanguage +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeScriptTemplate +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPoseSources +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensions +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry + +/** What the addon adds to the client: the dialogue window and camera, and `.story` support in the IDE. */ +@ClientOnly +internal object NarrativeClient { + fun register(extensions: HollowAddonExtensions) { + extensions.register(UiDefinitionRegistry.builtInScreens, "dialogue", DialogueScreenUi.definition) + extensions.register(CameraPoseSources.point, "story", StoryCameraSystem) + extensions.registerIdeLanguage( + HollowIdeLanguageService.extensions("story", listOf("story")) { StoryScriptingAnalyzer }, + ) + extensions.registerIdeScriptTemplate( + HollowIdeScriptTemplate.fromResource( + id = "dialogue", + label = "Dialogue", + extension = ".story", + defaultName = "dialogue", + resource = "dialogue.story", + owner = NarrativeClient::class.java, + ), + ) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt similarity index 90% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt index e4e4c34c7..1d8b51a80 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt @@ -1,11 +1,13 @@ -package ru.hollowhorizon.hollowengine.client.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.client import net.minecraft.client.Minecraft import net.minecraft.client.renderer.RenderType import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPoseSource import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.client.render.RenderHudEvent +import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f /** @@ -62,14 +64,14 @@ object StoryFadeOverlay { * A camera pose a story sets directly, interpolated from wherever the camera is now. */ @ClientOnly -object StoryCameraSystem { +object StoryCameraSystem : CameraPoseSource { private var from: CameraPose? = null private var target: CameraPose? = null private var startedAt = 0L private var durationMillis = 0L /** Where the story wants the camera, or null when it is leaving the camera alone. */ - val currentPose: CameraPose? + override val currentPose: CameraPose? get() { val destination = target ?: return null val origin = from ?: return destination @@ -107,10 +109,11 @@ object StoryCameraSystem { private var releaseAfter = 0L /** - * Called every client tick: finishes a release once its slide back is over, and hands the camera - * back the moment the player is no longer alive to look through it. + * Finishes a release once its slide back is over, and hands the camera back the moment the player + * is no longer alive to look through it. */ - fun update() { + @SubscribeEvent + fun onClientTick(event: TickEvent.Client) { if (releaseAfter != 0L && System.currentTimeMillis() >= releaseAfter) clear() if (target == null) return val player = Minecraft.getInstance().player diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCharacter.kt similarity index 92% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCharacter.kt index 04ea206f2..a59ae4746 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCharacter.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.world.entity.LivingEntity diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCommands.kt new file mode 100644 index 000000000..31d0f4f37 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCommands.kt @@ -0,0 +1,38 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue + +import com.mojang.brigadier.arguments.IntegerArgumentType +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.tree.CommandNode +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import ru.hollowhorizon.hollowengine.common.utils.mcTranslate + +/** + * `/he dialogue advance` and `/he dialogue choose `: how a player plays a dialogue from chat, and + * what a presenter's own buttons can run. + */ +internal object DialogueCommands { + fun register(root: CommandNode) { + val command = Commands.literal("dialogue") + .then(Commands.literal("advance").executes(::advance)) + .then( + Commands.literal("choose").then( + Commands.argument("option", IntegerArgumentType.integer(0)).executes(::choose), + ), + ) + root.addChild(command.build()) + } + + private fun advance(context: CommandContext): Int { + if (DialogueInput.advance(context.source.playerOrException)) return 1 + context.source.sendFailure("hollowengine.commands.dialogue_not_in_dialogue".mcTranslate) + return 0 + } + + private fun choose(context: CommandContext): Int { + val option = IntegerArgumentType.getInteger(context, "option") + if (DialogueInput.choose(context.source.playerOrException, option)) return 1 + context.source.sendFailure("hollowengine.commands.dialogue_no_such_option".mcTranslate(option)) + return 0 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueController.kt similarity index 99% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueController.kt index f163eb0e4..fa800a630 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueController.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -6,7 +6,7 @@ import kotlinx.coroutines.selects.select import net.minecraft.nbt.CompoundTag import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* import kotlin.time.Duration.Companion.milliseconds /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueDsl.kt similarity index 93% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueDsl.kt index c14ccc1a3..64f1698ae 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueDsl.kt @@ -1,6 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue /** * Builds a [DialogueController]: @@ -47,7 +45,6 @@ object StoryEngine { postRegistrationEvent() // Stories live alongside scripts, so every script namespace is a story source too. library.addProvider(ScriptRegistryStorySource) - ScriptRegistry.addListener(ScriptRegistryStorySource) } /** Registers a source of story text (a directory, an addon jar, an in-memory map). */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueEvents.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueEvents.kt index a12e4a820..b55e11565 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueEvents.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.server.level.ServerPlayer import ru.hollowhorizon.hollowengine.common.events.Event diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueInput.kt similarity index 95% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueInput.kt index ac95f5fc7..23bbf5657 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueInput.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.server.level.ServerPlayer import java.util.concurrent.CopyOnWriteArrayList diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialoguePresenter.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialoguePresenter.kt index d6463dbf0..39e3c5552 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialoguePresenter.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.server.level.ServerPlayer diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueSession.kt similarity index 96% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueSession.kt index a208c91ef..cfbc2f530 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueSession.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueUiContract.kt similarity index 94% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueUiContract.kt index 159f19c9b..74d206260 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueUiContract.kt @@ -1,9 +1,9 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.serialization.Serializable import ru.hollowhorizon.hollowengine.common.data.dataKey -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys.ChooseAction -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys.Index +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueUiKeys.ChooseAction +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueUiKeys.Index /** * What [UiDialoguePresentation] writes into the UI session, and what the screen reads back. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/ScriptRegistryStorySource.kt similarity index 95% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/ScriptRegistryStorySource.kt index b35f34b8b..ef9c6cf00 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/ScriptRegistryStorySource.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryArguments.kt similarity index 96% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryArguments.kt index fc51197bf..05830cbc1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryArguments.kt @@ -1,9 +1,9 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.world.entity.LivingEntity import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionSignature -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParam +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryFunctionSignature +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryParam /** * Evaluated arguments of one call, already matched against the chosen overload. Positional and named diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryBuiltinFunctions.kt similarity index 91% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryBuiltinFunctions.kt index bb1cee215..2cbbe6f67 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryBuiltinFunctions.kt @@ -1,8 +1,8 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.delay -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playSound import kotlin.time.Duration.Companion.milliseconds diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCheckpoint.kt similarity index 97% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCheckpoint.kt index c8f5032ca..119e8c054 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCheckpoint.kt @@ -1,9 +1,9 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.ListTag import net.minecraft.nbt.Tag -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryAnchor /** * Everything needed to continue a dialogue after a restart. Written after every statement, so the diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt similarity index 92% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt index e92d68879..ee2718d9c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt @@ -1,12 +1,13 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.delay import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.any -import ru.hollowhorizon.hollowengine.common.dialogue.lang.list -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.any +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.list +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.entity.LivingEntityDeathEvent import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.playCutscene import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.stopCutscene @@ -47,7 +48,6 @@ internal object StoryEffectFunctions { registry.hud() registry.camera() registry.cutscenes() - listenForEndings() } private fun StoryFunctionRegistry.fades() { @@ -155,10 +155,12 @@ internal object StoryEffectFunctions { * Every ending goes through here, which is the point: a story that faded out and never faded back * in, or that was cut off by a server stop, still gives the screen back. */ - private fun listenForEndings() { - if (!listening.compareAndSet(false, true)) return - DialogueEvent.Ended.register { event -> restoreAll(event.session) } - LivingEntityDeathEvent.register { event -> (event.entity as? ServerPlayer)?.let(::restoreFor) } + @SubscribeEvent + fun onDialogueEnded(event: DialogueEvent.Ended) = restoreAll(event.session) + + @SubscribeEvent + fun onPlayerDeath(event: LivingEntityDeathEvent) { + (event.entity as? ServerPlayer)?.let(::restoreFor) } /** Hands one player's visuals back without touching the dialogue the others are still in. */ @@ -177,8 +179,6 @@ internal object StoryEffectFunctions { } } - private val listening = java.util.concurrent.atomic.AtomicBoolean(false) - private fun restoreAll(session: DialogueSession) { val state = borrowed.remove(session) ?: return if (state.fade) session.eachPlayer { StoryClearFadePacket.send(it) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt similarity index 88% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt index cfb590795..f2f71b49d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt @@ -1,9 +1,9 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.serialization.Serializable import net.minecraft.world.entity.player.Player -import ru.hollowhorizon.hollowengine.client.dialogue.StoryCameraSystem -import ru.hollowhorizon.hollowengine.client.dialogue.StoryFadeOverlay +import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryCameraSystem +import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryFadeOverlay import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose import ru.hollowhorizon.hollowengine.common.network.HollowPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryFunctions.kt similarity index 94% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryFunctions.kt index 9bc26125d..93711414b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryFunctions.kt @@ -1,12 +1,12 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import net.minecraft.nbt.CompoundTag import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionCatalog -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionSignature -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParam -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryType +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryFunctionCatalog +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryFunctionSignature +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryParam +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryType /** * What a story function can see while it runs. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryLibrary.kt similarity index 89% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryLibrary.kt index 96d7b0c3b..122c2f763 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryLibrary.kt @@ -1,9 +1,9 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompileResult -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompiler -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionCatalog -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryProgram +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryCompileResult +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryCompiler +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryFunctionCatalog +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryProgram import java.util.concurrent.ConcurrentHashMap /** Where story text comes from. Files, addon jars, an in-memory string, or a future node graph. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt index b52ba614e..fa4cab456 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt @@ -1,10 +1,10 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.withTimeoutOrNull import net.minecraft.world.entity.LivingEntity import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.HollowEngine -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* import ru.hollowhorizon.hollowengine.common.entities.NpcEntity import ru.hollowhorizon.hollowengine.common.models.AnimationPlayMode import ru.hollowhorizon.hollowengine.common.npcs.navigation.Facing diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryValue.kt similarity index 96% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryValue.kt index d2377cad7..232f8a92f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryValue.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue /** * A runtime value of the `.story` language. Variables are dynamically typed and limited to diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/UiDialoguePresentation.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/UiDialoguePresentation.kt index 45b04fd9c..de4cfd826 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/UiDialoguePresentation.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull @@ -6,7 +6,7 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import ru.hollowhorizon.hollowengine.common.data.write -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser import ru.hollowhorizon.hollowengine.common.ui.net.UiSession import ru.hollowhorizon.hollowengine.common.ui.net.UiSessionManager import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompiler.kt similarity index 99% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompiler.kt index 6e9135277..f3ebfbec8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompiler.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang import java.security.MessageDigest @@ -421,7 +421,7 @@ class StoryCompiler( } private fun literalString(arg: StoryArg): String? = - (arg.expr.constant as? ru.hollowhorizon.hollowengine.common.dialogue.StoryString)?.value + (arg.expr.constant as? ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryString)?.value private fun validateTemplate(template: TextTemplate) { for (part in template.parts) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompletionContext.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompletionContext.kt index d626813b4..b5c3ba058 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCompletionContext.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang /** * What the caret is sitting on, worked out by scanning the line-up to it. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCst.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCst.kt index 2f960875b..d738735fc 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryCst.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang /** * Lossless syntax tree of one `.story` file. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryDiagnostic.kt similarity index 93% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryDiagnostic.kt index ad835021b..a0d8c0591 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryDiagnostic.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang /** Half-open character range inside a story source, plus the 0-based line it starts on. */ data class StorySpan(val start: Int, val end: Int, val line: Int) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryExpressions.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryExpressions.kt index a1fcc8450..0f6bd35b2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryExpressions.kt @@ -1,6 +1,6 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang -import ru.hollowhorizon.hollowengine.common.dialogue.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.* import ru.hollowhorizon.hollowengine.common.utils.expressions.* /** Where a name in an expression comes from: the session's variables, then its actors. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryFunctionSignature.kt similarity index 96% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryFunctionSignature.kt index 5301a13c7..b8f3fa30b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryFunctionSignature.kt @@ -1,6 +1,6 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang -import ru.hollowhorizon.hollowengine.common.dialogue.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.* enum class StoryType { STRING, NUMBER, BOOL, LIST, ACTOR, ANY; diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryParser.kt similarity index 99% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryParser.kt index cbe28d682..2c26f63fe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryParser.kt @@ -1,7 +1,7 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang -import ru.hollowhorizon.hollowengine.common.dialogue.StoryBool -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryBool +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryString data class StoryParseResult( val cst: StoryFileCst, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryProgram.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryProgram.kt index d135f40bf..a1a4378e8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/lang/StoryProgram.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang /** * A compiled story: a flat instruction list plus the label index. Nothing is reparsed while a diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryHighlighter.kt similarity index 96% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryHighlighter.kt index da09d6b8a..0cf46d1af 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryHighlighter.kt @@ -1,10 +1,10 @@ -package ru.hollowhorizon.hollowengine.common.scripting.ide.story +package ru.hollowhorizon.hollowengine.addons.narrative.ide -import ru.hollowhorizon.hollowengine.common.dialogue.StoryBool -import ru.hollowhorizon.hollowengine.common.dialogue.StoryNumber -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString -import ru.hollowhorizon.hollowengine.common.dialogue.StoryValue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryBool +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryNumber +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryString +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryValue +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType import ru.hollowhorizon.hollowengine.common.scripting.ide.ui.TextSpan import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzer.kt similarity index 97% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzer.kt index dc58a0d1c..f67697528 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzer.kt @@ -1,11 +1,11 @@ -package ru.hollowhorizon.hollowengine.common.scripting.ide.story - -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController -import ru.hollowhorizon.hollowengine.common.dialogue.StoryEngine -import ru.hollowhorizon.hollowengine.common.dialogue.StoryFunctionRegistry -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueController +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryEngine +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryFunctionRegistry +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryString +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser import ru.hollowhorizon.hollowengine.common.scripting.ide.* import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast diff --git a/addons/narrative/src/main/resources/META-INF/plugin.properties b/addons/narrative/src/main/resources/META-INF/plugin.properties new file mode 100644 index 000000000..0d94b0bd9 --- /dev/null +++ b/addons/narrative/src/main/resources/META-INF/plugin.properties @@ -0,0 +1,7 @@ +id=hollowengine-narrative +name=HollowEngine Narrative +version=${version} +entry=ru.hollowhorizon.hollowengine.addons.narrative.HollowNarrativeAddon +environment=common +description=Dialogues, the .story language with its editor support, and story functions for NPCs +authors=HollowHorizon diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json new file mode 100644 index 000000000..dd6c023da --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json @@ -0,0 +1,5 @@ +{ + "hollowengine.commands.dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.", + "hollowengine.commands.dialogue_no_such_option": "There is no option %s in the dialogue you are in.", + "hollowengine.dialogue.advance": "[ continue ▶ ]" +} diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json new file mode 100644 index 000000000..200a5a9cd --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json @@ -0,0 +1,5 @@ +{ + "hollowengine.commands.dialogue_not_in_dialogue": "Сейчас вы не участвуете в диалоге.", + "hollowengine.commands.dialogue_no_such_option": "В вашем диалоге нет варианта %s.", + "hollowengine.dialogue.advance": "[ дальше ▶ ]" +} diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/button.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/button.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/button.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/button.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/character_name.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/character_name.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/character_name.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/character_name.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/cursor.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/cursor.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/cursor.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/cursor.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/desc.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/desc.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/desc.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/desc.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/dialogue_box.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/dialogue_box.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/dialogue_box.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/dialogue_box.png diff --git a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/examples/dialogue.ui.kts similarity index 92% rename from runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/examples/dialogue.ui.kts index e962e385a..ab3b92233 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/examples/dialogue.ui.kts @@ -1,10 +1,10 @@ package assets.hollowengine.ui.examples -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoicesView -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueLineView -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueChoicesView +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueLineView +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueUiKeys -val STYLE = "hollowengine:ui/examples/dialogue_overlay.hss" +val STYLE = "hollowengine-narrative:ui/examples/dialogue_overlay.hss" surface("project:dialogue") { title = "Dialogue" diff --git a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue_overlay.hss b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/examples/dialogue_overlay.hss similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue_overlay.hss rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/examples/dialogue_overlay.hss diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue.hss similarity index 94% rename from runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue.hss index c7b4a2c20..7545397fe 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue.hss @@ -37,7 +37,7 @@ align: center end; align-items: start start; - background: image("hollowengine:textures/gui/dialogues/dialogue_box.png"); + background: image("hollowengine-narrative:textures/gui/dialogues/dialogue_box.png"); image-fit: 9-slice 30px 8px 30px 8px; padding: 14px 12px 8px 12px; @@ -68,7 +68,7 @@ padding: 0px 10px 0px 10px; align-items: center center; - background: image("hollowengine:textures/gui/dialogues/character_name.png"); + background: image("hollowengine-narrative:textures/gui/dialogues/character_name.png"); image-fit: 9-slice 7px; perspective: 400px; @@ -178,7 +178,7 @@ padding: 0px 14px 0px 14px; align-items: center center; - background: image("hollowengine:textures/gui/dialogues/button.png"); + background: image("hollowengine-narrative:textures/gui/dialogues/button.png"); image-fit: 9-slice 12px; cursor: pointer; } diff --git a/runtime/src/main/resources/internal/templates/dialogue.story.template b/addons/narrative/src/main/resources/internal/templates/dialogue.story.template similarity index 100% rename from runtime/src/main/resources/internal/templates/dialogue.story.template rename to addons/narrative/src/main/resources/internal/templates/dialogue.story.template diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueSurfaceScriptTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueSurfaceScriptTest.kt new file mode 100644 index 000000000..7eb65cf23 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueSurfaceScriptTest.kt @@ -0,0 +1,76 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.client + +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueChoiceView +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueChoicesView +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueUiKeys +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript +import ru.hollowhorizon.hollowengine.common.ui.UiData +import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry +import ru.hollowhorizon.hollowengine.common.ui.UiGuiScale +import ru.hollowhorizon.hollowengine.common.ui.send +import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind +import ru.hollowhorizon.hollowengine.common.utils.rl +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Compiles a `.ui.kts` dialogue surface against the dialogue UI contract, so a change to the keys the + * presenter writes fails here rather than in a project's own dialogue window. + */ +class DialogueSurfaceScriptTest { + @AfterTest + fun tearDown() = UiDefinitionRegistry.clear() + + @Test + fun `a surface switches between overlay and screen from its own document`() { + object : UiScript() { + init { + surface("mypack:dialogue") { + anchor = "hotbar" + interactive() + closeOnEscape = false + guiScale = UiGuiScale.Auto + mode { document -> + if (document[DialogueUiKeys.Choices].options.isEmpty()) UiSurfaceKind.OVERLAY + else UiSurfaceKind.SCREEN + } + content { + val line = data[DialogueUiKeys.Line] + val choices = data[DialogueUiKeys.Choices] + Box(mode = UiBoxMode.FREE, modifier = Modifier.size(UiLength.Fill, UiLength.Fill)) { + var catcher: Modifier = Modifier + if (line.awaiting) { + catcher = catcher.input(clickable = true).onClick { event -> + if (event.isRightClick()) { + send { putString(DialogueUiKeys.Action, DialogueUiKeys.AdvanceAction) } + } + } + } + Box(modifier = catcher) + Box(mode = UiBoxMode.FREE) { + Row { + if (line.speakerEntity >= 0) Entity(line.speakerEntity) + Text(line.text) + } + Box(modifier = if (line.awaiting) Modifier.state(UiState.of("ready")) else null) { + Image("hollowengine:textures/gui/icons/mouse_right.png") + } + } + choices.options.forEach { option -> Text(option.text) } + } + } + } + } + } + + val surface = assertNotNull(UiDefinitionRegistry.surface("mypack:dialogue".rl)) + assertEquals(UiSurfaceKind.OVERLAY, surface.mode(UiData()), "a line reads over the world") + val menu = UiData().apply { + this[DialogueUiKeys.Choices] = DialogueChoicesView(listOf(DialogueChoiceView(0, "Yes"))) + } + assertEquals(UiSurfaceKind.SCREEN, surface.mode(menu), "a menu needs the cursor") + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DemoStoryTest.kt similarity index 91% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DemoStoryTest.kt index 8ebea56b1..ae88e6134 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DemoStoryTest.kt @@ -1,7 +1,7 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompiler -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryInstruction +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryCompiler +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryInstruction import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCheckpointTest.kt similarity index 98% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCheckpointTest.kt index c4f477fb6..3f46e9445 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueCheckpointTest.kt @@ -1,11 +1,11 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import net.minecraft.nbt.CompoundTag -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryAnchor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueRuntimeTest.kt similarity index 98% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueRuntimeTest.kt index c891498e0..e482f6d58 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/DialogueRuntimeTest.kt @@ -1,13 +1,13 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor -import ru.hollowhorizon.hollowengine.common.dialogue.lang.actor -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryAnchor +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.actor +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompilerTest.kt similarity index 98% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompilerTest.kt index d4886f41a..df0cb903b 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompilerTest.kt @@ -1,6 +1,6 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* import kotlin.test.* class StoryCompilerTest { diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompletionContextTest.kt similarity index 92% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompletionContextTest.kt index ff05b0564..7af6f505a 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryCompletionContextTest.kt @@ -1,7 +1,7 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompletionContext -import ru.hollowhorizon.hollowengine.common.dialogue.lang.storyCompletionContext +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryCompletionContext +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.storyCompletionContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryParserTest.kt similarity index 94% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryParserTest.kt index 5a978b5c9..55e86c960 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryParserTest.kt @@ -1,11 +1,11 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.dialogue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryExpression -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryLineKind -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParser -import ru.hollowhorizon.hollowengine.common.dialogue.lang.TextPart +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryExpression +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryLineKind +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.StoryParser +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.TextPart import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType -import ru.hollowhorizon.hollowengine.common.scripting.ide.story.StoryScriptingAnalyzer +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryScriptingAnalyzer import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast import kotlin.test.* diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/CompletionTestSupport.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/CompletionTestSupport.kt new file mode 100644 index 000000000..d17eb02b8 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/CompletionTestSupport.kt @@ -0,0 +1,18 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptingAnalyzer + +/** + * Drains an analyzer's completion stream into a list. The editor consumes the batches as they + * arrive; assertions want the whole set, so tests collect it here instead of the analyzer keeping + * a second entry point alive for them. + */ +fun ScriptingAnalyzer.collectCompletions(name: String, text: String, offset: Int): List { + val items = ArrayList() + completions(name, text, offset) { batch -> + items += batch + true + } + return items +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzerTest.kt similarity index 98% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzerTest.kt index 774ec2682..60740a5bf 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryScriptingAnalyzerTest.kt @@ -1,9 +1,8 @@ -package ru.hollowhorizon.hollowengine.common.scripting.ide.story +package ru.hollowhorizon.hollowengine.addons.narrative.ide import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType -import ru.hollowhorizon.hollowengine.common.scripting.ide.collectCompletions import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull diff --git a/runtime/src/test/resources/dialogue/demo.story b/addons/narrative/src/test/resources/dialogue/demo.story similarity index 100% rename from runtime/src/test/resources/dialogue/demo.story rename to addons/narrative/src/test/resources/dialogue/demo.story diff --git a/build.gradle.kts b/build.gradle.kts index d9590bca1..0f53c33c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -102,6 +102,7 @@ fun Project.configureHollowAddon() { add("modCompileOnly", "net.fabricmc:fabric-loader:$fabricLoaderVersion") add("compileOnly", project(path = ":runtime", configuration = "namedElements")) add("testImplementation", project(path = ":runtime", configuration = "namedElements")) + add("testImplementation", project(path = ":bridge", configuration = "namedElements")) add("compileOnly", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") add("compileOnly", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") add("compileOnly", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") @@ -282,9 +283,11 @@ tasks.named("buildAndCollect") { val compilerJar = project(":addons:compiler").tasks.named("addonJar") val mcpJar = project(":addons:mcp").tasks.named("addonJar") + val narrativeJar = project(":addons:narrative").tasks.named("addonJar") from(compilerJar.flatMap { it.archiveFile }) from(mcpJar.flatMap { it.archiveFile }) + from(narrativeJar.flatMap { it.archiveFile }) } val buildAddons = tasks.register("buildAddons") { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt index 661c6e023..531b1ad66 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt @@ -3,10 +3,10 @@ package ru.hollowhorizon.hollowengine.client.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import ru.hollowhorizon.hollowengine.client.ui.text.* -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextAnimation -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextDocument -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextStyle +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextAnimation +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextDocument +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextStyle import ru.hollowhorizon.hollowengine.client.ui.text.Gradient as UiGradient /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt index ae19a2718..e1804ebe1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt @@ -19,6 +19,7 @@ object HollowIdeExtensionPoints { val FILE_ACTIONS = ExtensionPoints.create("hollowengine:ide/file-actions".rl, HollowIdeFileActionProvider::class) val PROJECT_ACTIONS = ExtensionPoints.create("hollowengine:ide/project-actions".rl, HollowIdeProjectActionProvider::class) val LANGUAGES = ExtensionPoints.create("hollowengine:ide/languages".rl, HollowIdeLanguageService::class) + val SCRIPT_TEMPLATES = ExtensionPoints.create("hollowengine:ide/script-templates".rl, HollowIdeScriptTemplate::class) } /** Operations a contributed panel or menu action may request from the IDE. */ @@ -157,3 +158,9 @@ fun HollowAddonExtensions.registerIdeLanguage( language: HollowIdeLanguageService, priority: Int = 0, ): HollowAddonRegistration = register(HollowIdeExtensionPoints.LANGUAGES, language.id, language, priority) + +/** Adds a kind of script to the "New script" menu of the project tree. */ +fun HollowAddonExtensions.registerIdeScriptTemplate( + template: HollowIdeScriptTemplate, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.SCRIPT_TEMPLATES, template.id, template, priority) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt index 63e306185..8c43880c7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt @@ -13,7 +13,7 @@ internal fun HollowIdeProjectContextMenu( menu: ProjectContextMenu?, onCreateFile: (String) -> Unit, onCreateFolder: (String) -> Unit, - onCreateScript: (String, ScriptTemplate) -> Unit, + onCreateScript: (String, HollowIdeScriptTemplate) -> Unit, onCreateSoundEvents: (String) -> Unit, onRename: (String) -> Unit, onCopy: (String) -> Unit, @@ -73,7 +73,7 @@ internal fun HollowIdeProjectContextMenu( tags = listOf("dropdown-popup", "project-context-menu"), onDismiss = onDismiss, ) { - ScriptTemplate.entries.forEach { template -> + scriptTemplates().forEach { template -> ProjectMenuItem(template.label, template.extension, template.icon) { onCreateScript(menu.path, template) } } } @@ -173,7 +173,7 @@ internal data class ProjectNameDialog( val name: String, val x: Float, val y: Float, - val template: ScriptTemplate? = null, + val template: HollowIdeScriptTemplate? = null, ) { val title: String get() = when (action) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt index 5ec11d1e8..47d96b7bf 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt @@ -78,7 +78,7 @@ internal class HollowIdeProjectController( fun openCreateFileDialog(path: String) = openNameDialog(ProjectNameAction.CreateFile, path) - fun openCreateScriptDialog(path: String, template: ScriptTemplate) = + fun openCreateScriptDialog(path: String, template: HollowIdeScriptTemplate) = openNameDialog(ProjectNameAction.CreateFile, path, template) fun openCreateFolderDialog(path: String) = openNameDialog(ProjectNameAction.CreateFolder, path) @@ -102,7 +102,7 @@ internal class HollowIdeProjectController( if (result == HollowIdeFileOperationResult.Success) nameDialog = null } - private fun createScript(dialog: ProjectNameDialog, template: ScriptTemplate): HollowIdeFileOperationResult { + private fun createScript(dialog: ProjectNameDialog, template: HollowIdeScriptTemplate): HollowIdeFileOperationResult { val name = template.fileName(dialog.name) val directory = if (dialog.path.fromReadablePath().isDirectory) dialog.path else dialog.path.substringBeforeLast('/', "") val path = if (directory.isBlank()) name else "$directory/$name" @@ -250,7 +250,7 @@ internal class HollowIdeProjectController( if (result != HollowIdeFileOperationResult.Success) HollowNotifications.error(result.statusText()) } - private fun openNameDialog(action: ProjectNameAction, path: String, template: ScriptTemplate? = null) { + private fun openNameDialog(action: ProjectNameAction, path: String, template: HollowIdeScriptTemplate? = null) { val defaultName = when (action) { ProjectNameAction.Rename -> path.substringAfterLast('/') ProjectNameAction.CreateFile -> template?.suggestedFileName ?: "new_file.kts" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt index 348ec0864..846b4d496 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt @@ -5,25 +5,25 @@ import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry /** - * The kinds of script the project tree can create, each with a starter file taken from - * `internal/templates`. A template may use `{{name}}` (the file name without its extension), `{{id}}` (the - * same, made a valid resource path), `{{constant}}` (the id as a Kotlin constant name), `{{namespace}}` (the - * sandbox namespace) and `{{path}}` (new file's project path). + * A kind of script the project tree can create, with its starter text. The text may use `{{name}}` (the + * file name without its extension), `{{id}}` (the same, made a valid resource path), `{{constant}}` (the id + * as a Kotlin constant name), `{{namespace}}` (the sandbox namespace) and `{{path}}` (new file's project path). + * + * @param label shown in the "New script" menu and in the title of the name dialog. + * @param extension appended to the typed name when it lacks it, such as `.node.kts`. + * @param text reads the starter text; called each time a file is created. */ -internal enum class ScriptTemplate( +class HollowIdeScriptTemplate( + val id: String, val label: String, val extension: String, private val defaultName: String, - private val resource: String, + private val text: () -> String, ) { - Plain("Script", ".kts", "script", "plain.kts"), - ServerReload("Server Reload Script", ".reload.kts", "server", "server.reload.kts"), - ClientReload("Client Reload Script", ".reload.kts", "client", "client.reload.kts"), - Startup("Startup Script", ".startup.kts", "ruby", "startup.kts"), - Node("Node Script", ".node.kts", "node", "node.kts"), - Ui("UI Script", ".ui.kts", "menu", "ui.kts"), - Mixin("Mixin Script", ".mixin.kts", "mixins", "mixin.kts"), - Dialogue("Dialogue", ".story", "dialogue", "dialogue.story"); + init { + require(id.isNotBlank()) { "Script template ID cannot be blank" } + require(extension.startsWith('.')) { "Script template extension must start with a dot: $extension" } + } val suggestedFileName: String get() = defaultName + extension @@ -42,11 +42,7 @@ internal enum class ScriptTemplate( val name = path.substringAfterLast('/').removeSuffix(extension) val id = name.lowercase().replace(INVALID_ID_CHARS, "_").ifEmpty { "script" } val constant = id.uppercase().replace(INVALID_CONSTANT_CHARS, "_").let { if (it.first().isDigit()) "_$it" else it } - val text = ScriptTemplate::class.java.classLoader - .getResourceAsStream("internal/templates/$resource.template") - ?.use { it.readBytes().toString(Charsets.UTF_8) } - .orEmpty() - return text + return text() .replace("{{name}}", name) .replace("{{id}}", id) .replace("{{constant}}", constant) @@ -54,13 +50,45 @@ internal enum class ScriptTemplate( .replace("{{path}}", path) } - private companion object { - const val KOTLIN_SCRIPT = ".kts" - val INVALID_ID_CHARS = Regex("[^a-z0-9_.-]") - val INVALID_CONSTANT_CHARS = Regex("[^A-Z0-9_]") + companion object { + private const val KOTLIN_SCRIPT = ".kts" + private val INVALID_ID_CHARS = Regex("[^a-z0-9_.-]") + private val INVALID_CONSTANT_CHARS = Regex("[^A-Z0-9_]") + + /** A template whose text is the `internal/templates/.template` file of [owner]'s jar. */ + fun fromResource( + id: String, + label: String, + extension: String, + defaultName: String, + resource: String, + owner: Class<*>, + ) = HollowIdeScriptTemplate(id, label, extension, defaultName) { + owner.classLoader.getResourceAsStream("internal/templates/$resource.template") + ?.use { it.readBytes().toString(Charsets.UTF_8) } + .orEmpty() + } } } +private fun builtIn(id: String, label: String, extension: String, defaultName: String, resource: String) = + HollowIdeScriptTemplate.fromResource(id, label, extension, defaultName, resource, HollowIdeScriptTemplate::class.java) + +/** The engine's own templates; addons add theirs through [HollowIdeExtensionPoints.SCRIPT_TEMPLATES]. */ +private val BuiltInScriptTemplates = listOf( + builtIn("plain", "Script", ".kts", "script", "plain.kts"), + builtIn("server_reload", "Server Reload Script", ".reload.kts", "server", "server.reload.kts"), + builtIn("client_reload", "Client Reload Script", ".reload.kts", "client", "client.reload.kts"), + builtIn("startup", "Startup Script", ".startup.kts", "ruby", "startup.kts"), + builtIn("node", "Node Script", ".node.kts", "node", "node.kts"), + builtIn("ui", "UI Script", ".ui.kts", "menu", "ui.kts"), + builtIn("mixin", "Mixin Script", ".mixin.kts", "mixins", "mixin.kts"), +) + +/** What the "New script" menu offers: the engine's templates, then those of loaded addons. */ +internal fun scriptTemplates(): List = + BuiltInScriptTemplates + HollowIdeExtensionPoints.SCRIPT_TEMPLATES.extensions + /** Scripts are only picked up from the sandbox's `scripts` folder, so that is the only place to offer them. */ internal fun isInsideScripts(path: String): Boolean = path == SandboxScriptSource.SCRIPTS_DIRECTORY || path.startsWith(SandboxScriptSource.SCRIPTS_DIRECTORY + "/") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt index e375d3968..967d14d81 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment import ru.hollowhorizon.hollowengine.common.scripting.ide.* -import ru.hollowhorizon.hollowengine.common.scripting.ide.story.StoryScriptingAnalyzer import ru.hollowhorizon.hollowengine.common.scripting.ide.ui.HssScriptingAnalyzer interface EditorLanguageService { @@ -15,7 +14,6 @@ fun EditorLanguageService(extension: String): EditorLanguageService { "java" -> JavaEditorLanguageService "json" -> JsonEditorLanguageService "hss" -> HssEditorLanguageService - "story" -> StoryEditorLanguageService else -> error("Unsupported language: $extension") } } @@ -45,11 +43,6 @@ object HssEditorLanguageService : EditorLanguageService { get() = HssScriptingAnalyzer } -object StoryEditorLanguageService : EditorLanguageService { - override val analyzer: ScriptingAnalyzer - get() = StoryScriptingAnalyzer -} - /** * A language an addon contributes to the editor through `registerIdeLanguage`. It takes precedence * over the built-in languages for the paths it [matches]. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt new file mode 100644 index 000000000..fc26ad22b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt @@ -0,0 +1,25 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene + +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.utils.rl + +/** + * Something outside the cutscene system that wants to point the camera, such as a dialogue's own camera. + */ +interface CameraPoseSource { + /** Where the camera should be, or null while this source leaves it alone. */ + val currentPose: CameraPose? +} + +/** + * Camera sources addons contribute. They are asked only when no cutscene plays and no pose is held, + * so a cutscene always wins over them; among themselves the first one with a pose wins. + */ +object CameraPoseSources { + val point = ExtensionPoints.create("hollowengine:camera/pose-sources".rl, CameraPoseSource::class) + + fun register(id: String, source: CameraPoseSource): ExtensionHandle = point.register(id.rl, source) + + val currentPose: CameraPose? get() = point.extensions.firstNotNullOfOrNull { it.currentPose } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt index 60f1c7d89..ece01deb3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene import net.minecraft.client.Minecraft import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.client.dialogue.StoryCameraSystem import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.client.render.RenderItemInHandEvent @@ -19,19 +18,19 @@ object CutsceneCameraSystem { get() = controller /** - * A pose held from outside any cutscene or story, by a tool that needs a frame seen from a given - * point. It wins over both while it is set and hides the hand like a cutscene does. + * A pose held by a tool that needs a frame seen from a given point. It wins over cutscenes and + * [CameraPoseSources] while it is set and hides the hand like a cutscene does. */ @Volatile var heldPose: CameraPose? = null /** * The pose the camera should take. A held pose wins over everything; a cutscene being played wins - * over a story's own `@camera`, because it is the more specific instruction. The render bridge reads - * this one property, so all of them arrive through the same path. + * over the poses of [CameraPoseSources], because it is the more specific instruction. The render + * bridge reads this one property, so all of them arrive through the same path. */ val currentPose: CameraPose? - get() = heldPose ?: controller?.currentPose ?: StoryCameraSystem.currentPose + get() = heldPose ?: controller?.currentPose ?: CameraPoseSources.currentPose fun play(data: CutsceneData, loop: Boolean = false, anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED) { val playback = CutscenePlaybackController().also { @@ -70,7 +69,6 @@ object CutsceneCameraSystem { } fun update(minecraft: Minecraft) { - StoryCameraSystem.update() val active = controller ?: return val level = minecraft.level if (level == null || minecraft.player == null) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt index 0b38c1e95..08cafc965 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt @@ -4,7 +4,6 @@ import net.minecraft.server.packs.resources.ResourceManager import net.minecraft.server.packs.resources.ResourceManagerReloadListener import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.api.ReloadListener -import ru.hollowhorizon.hollowengine.client.dialogue.DialogueScreenUi import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader import ru.hollowhorizon.hollowengine.common.scripting.UI_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -23,7 +22,6 @@ object UiScriptLoader : ResourceManagerReloadListener { fun reload() { UiDefinitionRegistry.clear() - DialogueScreenUi.register(UiDefinitionRegistry::register) ScriptRegistry.list(".$UI_SCRIPT_EXTENSION").forEach { id -> ScriptLoader.execute(id).onFailure { error -> HollowEngine.LOGGER.error("Failed to execute UI script: {}", ScriptRegistry.display(id), error) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index cc571b2c3..9bd75c7f9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -641,7 +641,11 @@ internal class HollowAddonRuntime( } finally { refreshEnabledSnapshot() mutex.unlock() - if (loadedSnapshot !== loadedBefore) reloadRunningServers() + if (loadedSnapshot !== loadedBefore) { + runCatching { HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) } + .onFailure { HollowEngine.LOGGER.error("Failed to apply the changed set of addons", it) } + reloadRunningServers() + } if (started && assetsOf(packsBefore) != assetsOf(resourcePackSnapshot)) reloadClientResources() } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt new file mode 100644 index 000000000..ebf61d4da --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt @@ -0,0 +1,13 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler + +/** + * The set of loaded addons has changed. Fires once after a batch of loads and unloads, such as the + * startup or an addon being enabled, so whatever was built from contributions of several addons can be + * rebuilt once they are all in place. + */ +class HollowAddonsChangedEvent : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt index 91504ba58..a3b89b4af 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.common.commands import com.mojang.brigadier.arguments.BoolArgumentType import com.mojang.brigadier.arguments.FloatArgumentType -import com.mojang.brigadier.arguments.IntegerArgumentType import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder @@ -34,7 +33,6 @@ import ru.hollowhorizon.hollowengine.common.attachments.components.TransformComp import ru.hollowhorizon.hollowengine.common.attachments.snapshot.EntitySnapshot import ru.hollowhorizon.hollowengine.common.commands.arguments.ScriptPathArgument import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueInput import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent import ru.hollowhorizon.hollowengine.common.files.DirectoryManager @@ -286,27 +284,6 @@ private fun CommandExtension.registerUtilityCommands() { SUCCESS } } - - registerDialogueCommands() -} - -private fun CommandExtension.registerDialogueCommands() { - "dialogue" { - "advance" { - executes { - if (DialogueInput.advance(source.playerOrException)) SUCCESS - else sendFailure("hollowengine.commands.dialogue_not_in_dialogue".mcTranslate()) - } - } - - "choose"(arg("option", IntegerArgumentType.integer(0))) { - executes { - val option = IntegerArgumentType.getInteger(this, "option") - if (DialogueInput.choose(source.playerOrException, option)) SUCCESS - else sendFailure("hollowengine.commands.dialogue_no_such_option".mcTranslate(option)) - } - } - } } private fun CommandExtension.registerScriptingCommands() { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 0760de95e..74963f7e7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -83,14 +83,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.item", "ru.hollowhorizon.hollowengine.common.utils.rl", "ru.hollowhorizon.hollowengine.common.utils.literal", - "ru.hollowhorizon.hollowengine.common.dialogue.*", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.actor", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.any", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.boolean", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.list", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.number", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.signature", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.string", ), implicitReceivers = listOf(ServerReloadContext::class), clientSideReceivers = listOf(CLIENT_RELOAD_CONTEXT), @@ -201,7 +193,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.models.*", "ru.hollowhorizon.hollowengine.common.colliders.*", "ru.hollowhorizon.hollowengine.common.attachments.api.set", - "ru.hollowhorizon.hollowengine.common.dialogue.*", "ru.hollowhorizon.hollowengine.common.utils.rl", "ru.hollowhorizon.hollowengine.common.utils.literal", "ru.hollowhorizon.hollowengine.common.utils.sendToast", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt index bbcbc38e7..0bc674f3d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt @@ -6,7 +6,6 @@ import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.api.ReloadListener import ru.hollowhorizon.hollowengine.common.compat.util.currentRecipeManagerOrNull import ru.hollowhorizon.hollowengine.common.coroutines.RuntimeDispatcherState -import ru.hollowhorizon.hollowengine.common.dialogue.StoryEngine import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent @@ -26,7 +25,7 @@ object ServerReloadScripts : ResourceManagerReloadListener { } override fun onResourceManagerReload(resourceManager: ResourceManager) { - StoryEngine.prepareReload() + ServerReloadScriptsEvent.Pre.post(ServerReloadScriptsEvent.Pre()) val recipeManager = currentRecipeManagerOrNull() if (recipeManager == null) { HollowEngine.LOGGER.warn("Skipping reload scripts: RecipeManager is not initialized yet") @@ -34,7 +33,7 @@ object ServerReloadScripts : ResourceManagerReloadListener { val dispatcher = RuntimeDispatcherState.loadingServerDispatcher() runner.run(dispatcher, resourceManager, ServerReloadContext(recipeManager)) } - StoryEngine.completeReload() + ServerReloadScriptsEvent.Post.post(ServerReloadScriptsEvent.Post()) pendingCommands?.let { event -> pendingCommands = null diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt new file mode 100644 index 000000000..aad56a44f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt @@ -0,0 +1,20 @@ +package ru.hollowhorizon.hollowengine.common.scripting.reload + +import ru.hollowhorizon.hollowengine.common.events.ServerEvent +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler + +/** + * Brackets the server reload scripts of a datapack load, so a registry the scripts fill can be + * reset before they run and published after. + */ +sealed class ServerReloadScriptsEvent : ServerEvent { + /** The scripts are about to run. */ + class Pre : ServerReloadScriptsEvent() { + companion object : EventHandler
()
+    }
+
+    /** The scripts have run, even if some of them failed or were skipped. */
+    class Post : ServerReloadScriptsEvent() {
+        companion object : EventHandler()
+    }
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
index e48ddf125..b2272adee 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
@@ -2,6 +2,8 @@ package ru.hollowhorizon.hollowengine.common.ui
 
 import net.minecraft.resources.ResourceLocation
 import ru.hollowhorizon.hollowengine.HollowEngine
+import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints
+import ru.hollowhorizon.hollowengine.common.utils.rl
 import java.util.concurrent.ConcurrentHashMap
 
 /**
@@ -9,6 +11,12 @@ import java.util.concurrent.ConcurrentHashMap
  * rebuilt from scratch each time rather than merged, a renamed or deleted screen must not linger.
  */
 object UiDefinitionRegistry {
+    /**
+     * Screens addons ship. They outlive script reloads and are used only while no script declares a
+     * screen with the same id, so a project can replace any of them.
+     */
+    val builtInScreens = ExtensionPoints.create("hollowengine:ui/screens".rl, UiScreenDefinition::class)
+
     private val screens = ConcurrentHashMap()
     private val overlays = ConcurrentHashMap()
     private val surfaces = ConcurrentHashMap()
@@ -45,7 +53,8 @@ object UiDefinitionRegistry {
         surfaces[definition.id] = definition
     }
 
-    fun screen(id: ResourceLocation): UiScreenDefinition? = screens[id]
+    fun screen(id: ResourceLocation): UiScreenDefinition? =
+        screens[id] ?: builtInScreens.extensions.firstOrNull { it.id == id }
 
     fun overlay(id: ResourceLocation): UiOverlayDefinition? = overlays[id]
 
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
similarity index 99%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt
rename to runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
index 00eb1e6a1..011f943ca 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.common.dialogue.text
+package ru.hollowhorizon.hollowengine.common.ui.text
 
 /**
  * Parsed dialogue markup. Markup stays a string in the network contract, while this model gives
diff --git a/runtime/src/main/lang/en_us/hollowengine/commands.json b/runtime/src/main/lang/en_us/hollowengine/commands.json
index e85f6988f..4c2fb8e91 100644
--- a/runtime/src/main/lang/en_us/hollowengine/commands.json
+++ b/runtime/src/main/lang/en_us/hollowengine/commands.json
@@ -7,7 +7,5 @@
   "model_no_animation": "Model %s has no animation '%s'. Its animations: %s",
   "tags": "§6[§bHollow Engine§6] §7Item tags: ",
   "scripting_state_requires_node_script": "Cannot use an initial state with '%s': states are supported only by .node.kts scripts. Use a .node.kts script or run the .kts script without the state argument.",
-  "expected_script_path": "Expected a script path, for example scripts/nodes/example.node.kts or my-addon:nodes/example.node.kts",
-  "dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.",
-  "dialogue_no_such_option": "There is no option %s in the dialogue you are in."
+  "expected_script_path": "Expected a script path, for example scripts/nodes/example.node.kts or my-addon:nodes/example.node.kts"
 }
diff --git a/runtime/src/main/lang/en_us/hollowengine/dialogue.json b/runtime/src/main/lang/en_us/hollowengine/dialogue.json
deleted file mode 100644
index ca0e2ef69..000000000
--- a/runtime/src/main/lang/en_us/hollowengine/dialogue.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "advance": "[ continue ▶ ]"
-}
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/commands.json b/runtime/src/main/lang/ru_ru/hollowengine/commands.json
index f0c7438a7..6ac0d4e6f 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/commands.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/commands.json
@@ -7,7 +7,5 @@
   "model_no_animation": "В модели %s нет анимации '%s'. Её анимации: %s",
   "tags": "§6[§bHollow Engine§6] §7Теги предмета: ",
   "scripting_state_requires_node_script": "Нельзя передать начальный стейт скрипту '%s': стейты поддерживаются только скриптами .node.kts. Используйте скрипт .node.kts либо запустите .kts без аргумента стейта.",
-  "expected_script_path": "Ожидался путь к скрипту, например scripts/nodes/example.node.kts или my-addon:nodes/example.node.kts",
-  "dialogue_not_in_dialogue": "Сейчас вы не участвуете в диалоге.",
-  "dialogue_no_such_option": "В вашем диалоге нет варианта %s."
+  "expected_script_path": "Ожидался путь к скрипту, например scripts/nodes/example.node.kts или my-addon:nodes/example.node.kts"
 }
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json b/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json
deleted file mode 100644
index 171a10976..000000000
--- a/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "advance": "[ дальше ▶ ]"
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
index ba0eab392..0772b822d 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
@@ -6,15 +6,10 @@ import ru.hollowhorizon.hollowengine.client.slots.SlotGrid
 import ru.hollowhorizon.hollowengine.client.slots.Slots
 import ru.hollowhorizon.hollowengine.client.ui.*
 import ru.hollowhorizon.hollowengine.common.data.dataKey
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoiceView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoicesView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys
 import ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript
-import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind
 import ru.hollowhorizon.hollowengine.common.utils.rl
 import kotlin.test.AfterTest
 import kotlin.test.Test
-import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 
 /**
@@ -101,54 +96,4 @@ class UiScriptContentTest {
 
         assertNotNull(UiDefinitionRegistry.screen("mypack:npc_gear".rl))
     }
-
-    @Test
-    fun `a surface switches between overlay and screen from its own document`() {
-        object : UiScript() {
-            init {
-                surface("mypack:dialogue") {
-                    anchor = "hotbar"
-                    interactive()
-                    closeOnEscape = false
-                    guiScale = UiGuiScale.Auto
-                    mode { document ->
-                        if (document[DialogueUiKeys.Choices].options.isEmpty()) UiSurfaceKind.OVERLAY
-                        else UiSurfaceKind.SCREEN
-                    }
-                    content {
-                        val line = data[DialogueUiKeys.Line]
-                        val choices = data[DialogueUiKeys.Choices]
-                        Box(mode = UiBoxMode.FREE, modifier = Modifier.size(UiLength.Fill, UiLength.Fill)) {
-                            var catcher: Modifier = Modifier
-                            if (line.awaiting) {
-                                catcher = catcher.input(clickable = true).onClick { event ->
-                                    if (event.isRightClick()) {
-                                        send { putString(DialogueUiKeys.Action, DialogueUiKeys.AdvanceAction) }
-                                    }
-                                }
-                            }
-                            Box(modifier = catcher)
-                            Box(mode = UiBoxMode.FREE) {
-                                Row {
-                                    if (line.speakerEntity >= 0) Entity(line.speakerEntity)
-                                    Text(line.text)
-                                }
-                                Box(modifier = if (line.awaiting) Modifier.state(UiState.of("ready")) else null) {
-                                    Image("hollowengine:textures/gui/icons/mouse_right.png")
-                                }
-                            }
-                            choices.options.forEach { option -> Text(option.text) }
-                        }
-                    }
-                }
-            }
-        }
-
-        val surface = assertNotNull(UiDefinitionRegistry.surface("mypack:dialogue".rl))
-        assertEquals(UiSurfaceKind.OVERLAY, surface.mode(UiData()), "a line reads over the world")
-        val menu = UiData().apply {
-            this[DialogueUiKeys.Choices] = DialogueChoicesView(listOf(DialogueChoiceView(0, "Yes")))
-        }
-        assertEquals(UiSurfaceKind.SCREEN, surface.mode(menu), "a menu needs the cursor")
-    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
similarity index 91%
rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt
rename to runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
index 08bab906b..1fce52eef 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
@@ -1,8 +1,8 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
+package ru.hollowhorizon.hollowengine.addons.narrative.dialogue
 
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextAnimation
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextStyle
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextAnimation
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextStyle
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertIs

From b276d599ed8fe9763c9f99fe5578e6f3437615a4 Mon Sep 17 00:00:00 2001
From: HollowHorizon 
Date: Wed, 21 Oct 2026 13:37:55 +0300
Subject: [PATCH 02/25] Move narrative features into an addon

Extract dialogues and cutscenes into the Narrative addon, with shared camera and timeline extension points. Add type-aware IDE context completions and load freshly built addons directly in development runs.
---
 addons/README.md                              |   2 +
 .../ide/session/ScriptingAnalyzerImpl.kt      |   4 +
 .../session/completion/CompletionsProvider.kt |   6 +-
 .../ide/session/completion/ExpressionSlots.kt |  41 ++++
 .../src/test/kotlin/IdeCodeInsightTest.kt     |  14 ++
 .../addons/mcp/client/FrameCapture.kt         |   8 +-
 .../addons/mcp/client/ScreenshotTool.kt       |   2 +-
 .../addons/narrative/HollowNarrativeAddon.kt  |   9 +-
 .../narrative/client/NarrativeClient.kt       |  17 +-
 .../narrative/client/StoryScreenEffects.kt    |   8 +-
 .../narrative}/cutscene/CameraCutscene.kt     |  13 +-
 .../narrative/cutscene}/CutsceneActions.kt    |  10 +-
 .../narrative}/cutscene/CutsceneAnchor.kt     |   2 +-
 .../cutscene/CutsceneCameraSystem.kt          |  47 ++---
 .../narrative}/cutscene/CutsceneData.kt       |   2 +-
 .../cutscene/CutsceneEditorSession.kt         |   9 +-
 .../cutscene/CutsceneEnvironment.kt           |  19 +-
 .../narrative}/cutscene/CutsceneMigrations.kt |   2 +-
 .../narrative}/cutscene/CutsceneStorage.kt    |   2 +-
 .../narrative/cutscene/editor/CutsceneLang.kt |  31 +++
 .../cutscene/editor/CutsceneProperties.kt     |  90 ++++++++
 .../cutscene/editor/CutsceneTimelineDock.kt   | 197 ++++++++++++++++++
 .../dialogue/StoryEffectFunctions.kt          |   4 +-
 .../narrative/dialogue/StoryEffectPackets.kt  |   2 +-
 .../main/resources/META-INF/plugin.properties |   2 +-
 .../hollowengine-narrative/lang/en_us.json    |  27 ++-
 .../hollowengine-narrative/lang/ru_ru.json    |  27 ++-
 .../narrative}/cutscene/CutsceneCurveTest.kt  |   2 +-
 .../cutscene/CutsceneMigrationTest.kt         |   4 +-
 .../narrative}/cutscene/CutsceneTest.kt       |  22 +-
 .../impl/BootstrapRuntimeManager.java         |   8 +-
 .../runtime/AddonBootstrapContract.java       |  10 +
 build.gradle.kts                              |   5 +
 docs/en/addons/creating.mdx                   |   2 +-
 docs/en/addons/index.mdx                      |   1 +
 docs/en/addons/narrative.mdx                  |  39 ++++
 docs/en/dialogues/custom-commands.mdx         |   8 +-
 docs/en/dialogues/index.mdx                   |   8 +-
 docs/en/ide/index.mdx                         |   2 +-
 docs/en/world/cutscenes.mdx                   |   4 +
 docs/ru/addons/creating.mdx                   |   2 +-
 docs/ru/addons/index.mdx                      |   1 +
 docs/ru/addons/narrative.mdx                  |  39 ++++
 docs/ru/dialogues/custom-commands.mdx         |   8 +-
 docs/ru/dialogues/index.mdx                   |   8 +-
 docs/ru/ide/index.mdx                         |   2 +-
 docs/ru/world/cutscenes.mdx                   |   4 +
 .../bootstrap/RuntimeBridgeEntrypoint.kt      |  13 +-
 .../client/camera/CameraDirector.kt           |  61 ++++++
 .../hollowengine/client/camera/CameraPose.kt  |  14 ++
 .../client/ui/ide/HollowIdeEditorAdapters.kt  |  19 +-
 .../client/ui/ide/HollowIdeWorldMenu.kt       |   2 +-
 .../client/ui/ide/IdeContextCompletions.kt    |  46 ++--
 .../client/ui/ide/history/IdeHistories.kt     |   7 +-
 .../ide/timeline/TimeOfDayValueFormatter.kt   |  20 ++
 .../ui/ide/timeline/TimelineController.kt     |   3 +-
 .../ui/ide/timeline/TimelineSnapshot.kt       |   2 -
 .../timeline/cutscene/CameraPoseSources.kt    |  25 ---
 .../ide/timeline/ui/HollowTimelineChrome.kt   |  85 ++++----
 .../ide/timeline/ui/HollowTimelineFields.kt   |   4 +-
 .../ui/ide/timeline/ui/HollowTimelineLanes.kt |  14 +-
 .../ui/ide/timeline/ui/HollowTimelinePanel.kt | 179 +---------------
 .../timeline/ui/HollowTimelineProperties.kt   | 119 ++---------
 .../ide/timeline/ui/HollowTimelineSupport.kt  |   2 +-
 .../ui/ide/timeline/ui/IdeTimelineHost.kt     |  41 +++-
 .../ui/{CutsceneLang.kt => TimelineLang.kt}   |  32 +--
 .../ui/widgets/TextFieldCompletionState.kt    |   9 +-
 .../common/addons/HollowAddonManager.kt       |  27 ++-
 .../common/addons/HollowAddonRuntime.kt       |   9 +-
 .../common/scripting/ide/ScriptingAnalyzer.kt |   8 +
 .../scripting/mixins/MixinScriptStage.kt      |   3 +-
 .../lang/en_us/hollowengine/gui/history.json  |   4 +-
 .../gui/ide/{cutscene.json => timeline.json}  |  26 +--
 .../lang/ru_ru/hollowengine/gui/history.json  |   4 +-
 .../gui/ide/{cutscene.json => timeline.json}  |  26 +--
 .../ui/ide/IdeContextCompletionsTest.kt       |   7 +
 .../timeline/TimeOfDayValueFormatterTest.kt   |  15 ++
 .../ui/ide/timeline/ui/TimelineRowsTest.kt    |  37 ++++
 78 files changed, 1024 insertions(+), 615 deletions(-)
 create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CameraCutscene.kt (97%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene}/CutsceneActions.kt (87%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneAnchor.kt (97%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneCameraSystem.kt (69%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneData.kt (97%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneEditorSession.kt (93%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneEnvironment.kt (86%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneMigrations.kt (99%)
 rename {runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline => addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneStorage.kt (98%)
 create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt
 create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt
 create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt
 rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneCurveTest.kt (99%)
 rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneMigrationTest.kt (96%)
 rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative}/cutscene/CutsceneTest.kt (92%)
 create mode 100644 docs/en/addons/narrative.mdx
 create mode 100644 docs/ru/addons/narrative.mdx
 create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt
 create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt
 create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt
 delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt
 rename runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/{CutsceneLang.kt => TimelineLang.kt} (76%)
 rename runtime/src/main/lang/en_us/hollowengine/gui/ide/{cutscene.json => timeline.json} (78%)
 rename runtime/src/main/lang/ru_ru/hollowengine/gui/ide/{cutscene.json => timeline.json} (78%)
 create mode 100644 runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt
 create mode 100644 runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt

diff --git a/addons/README.md b/addons/README.md
index c0722db7f..8df43fb2e 100644
--- a/addons/README.md
+++ b/addons/README.md
@@ -15,6 +15,8 @@ Copy the jar to either of these directories:
 - `mods/` - the usual choice for distributed addons and modpacks;
 - `hollowengine/addons/`- useful during development or when you want to keep addons separate from mods.
 
+In a development run of this repository nothing has to be copied: `runClient` and `runServer` build every addon first and pass `build/addon-jars` to the game with `-Dhollowengine.devAddons`. That directory is looked at before the two others, so a copy of the same addon left in `mods/` never wins, and it is watched like them: run `./gradlew buildAddons` while the game is open and the rebuilt addons are reloaded in place. Addons with bootstrap libraries still need a restart.
+
 The runtime scans and watches both directories. If the same addon id is present in both at startup, the newest version wins; when the versions are equal, the copy in `hollowengine/addons/` has priority. Do not keep duplicate copies unless you are deliberately testing version selection.
 
 Runtime diagnostics and lifecycle controls are available to operators:
diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt
index 43e2cde87..7cd2dddf4 100644
--- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt
@@ -14,6 +14,7 @@ import org.jetbrains.kotlin.psi.KtPsiFactory
 import org.jetbrains.kotlin.psi.KtUserType
 import ru.hollowhorizon.hollowengine.common.files.DirectoryManager
 import ru.hollowhorizon.hollowengine.common.ide.session.completion.createCompletions
+import ru.hollowhorizon.hollowengine.common.ide.session.completion.findAcceptedExpressionTypes
 import ru.hollowhorizon.hollowengine.common.ide.session.definition.LibraryNavigation
 import ru.hollowhorizon.hollowengine.common.ide.session.definition.findDefinition
 import ru.hollowhorizon.hollowengine.common.ide.session.diagnostic.diagnosticCode
@@ -318,6 +319,9 @@ class ScriptingAnalyzerImpl internal constructor(
             kotlinGutterTargets(getOrCreateFile(name, text), offset)
         }
 
+    override fun acceptedExpressionTypes(name: String, text: String, offset: Int, types: List): List? =
+        synchronized(lock) { findAcceptedExpressionTypes(getOrCreateFile(name, text), offset, types) }
+
     override fun signatureHelp(name: String, text: String, offset: Int): SignatureHelp? = synchronized(lock) {
         findSignatureHelp(getOrCreateFile(name, text), offset)
     }
diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt
index 778c0cd51..877feef6b 100644
--- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt
@@ -56,7 +56,7 @@ fun ScriptingAnalyzerImpl.createCompletions(file: KtFile, offset: Int, sink: Com
     }
 }
 
-private fun ScriptingAnalyzerImpl.createFileForCompletion(original: KtFile, offset: Int): KtFile {
+internal fun ScriptingAnalyzerImpl.createFileForCompletion(original: KtFile, offset: Int): KtFile {
     val safeOffset = offset.coerceIn(0, original.textLength)
     val textWithInsertedFakeIdentifier = original.text.replaceRange(safeOffset, safeOffset, COMPLETION_FAKE_IDENTIFIER)
     val copyKtFile = KtPsiFactory(original.project, markGenerated = true, eventSystemEnabled = true).createFile(
@@ -550,7 +550,7 @@ private fun CompletionItemsCollector.completeScope(scope: KaScope) {
 }
 
 context(_: KaSession)
-private fun getPosition(element: KtElement): CompletionPosition? {
+internal fun getPosition(element: KtElement): CompletionPosition? {
     // Проверка, находимся ли мы в директиве package или import
     val importDirective = element.parentOfType(withSelf = true)
     val packageDirective = element.parentOfType(withSelf = true)
@@ -699,7 +699,7 @@ fun KtUserType.isAnnotation(): Boolean {
     return constructor.parent is KtAnnotationEntry
 }
 
-private sealed interface CompletionPosition {
+internal sealed interface CompletionPosition {
     val prefix: String?
 
     class Identifier(override val prefix: String) : CompletionPosition
diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt
new file mode 100644
index 000000000..e87097fce
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt
@@ -0,0 +1,41 @@
+package ru.hollowhorizon.hollowengine.common.ide.session.completion
+
+import com.intellij.psi.util.parentOfType
+import org.jetbrains.kotlin.analysis.api.analyzeCopy
+import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode
+import org.jetbrains.kotlin.analysis.api.types.KaErrorType
+import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType
+import org.jetbrains.kotlin.name.ClassId
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtCallExpression
+import org.jetbrains.kotlin.psi.KtExpression
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNameReferenceExpression
+import ru.hollowhorizon.hollowengine.common.ide.session.ScriptingAnalyzerImpl
+
+/**
+ * Which of [types] an expression typed at [offset] may have. Null when [offset] is not where an expression
+ * starts: after a dot, in a declaration name, in an import or a type. Where nothing is expected, or where the
+ * value is thrown away as a statement, every candidate fits.
+ */
+fun ScriptingAnalyzerImpl.findAcceptedExpressionTypes(file: KtFile, offset: Int, types: List): List? {
+    val safeOffset = offset.coerceIn(0, file.textLength)
+    val copy = createFileForCompletion(file, safeOffset)
+    try {
+        val reference = copy.findElementAt(safeOffset)?.parentOfType(withSelf = true)
+            ?: return null
+        val call = reference.parent as? KtCallExpression
+        val expression: KtExpression = if (call?.calleeExpression == reference) call else reference
+
+        return analyzeCopy(expression, KaDanglingFileResolutionMode.PREFER_SELF) {
+            if (getPosition(reference) !is CompletionPosition.Identifier) return@analyzeCopy null
+            val expected = expression.expectedType
+            if (expected == null || expected is KaErrorType || expected is KaTypeParameterType || expected.isUnitType) {
+                return@analyzeCopy types
+            }
+            types.filter { fqName -> buildClassType(ClassId.topLevel(FqName(fqName))).isSubtypeOf(expected) }
+        }
+    } finally {
+        cleanupFile(copy)
+    }
+}
diff --git a/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt b/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt
index 8c4b507c0..01fee659c 100644
--- a/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt
+++ b/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt
@@ -89,6 +89,20 @@ class IdeCodeInsightTest {
         assertEquals("someOtherFunction", items.first { it.name == "someOtherFunction" }.insert)
     }
 
+    @Test
+    fun `expression snippets fit only where an expression of their type may go`() {
+        val types = listOf("kotlin.String", "kotlin.Int")
+        fun accepted(text: String) =
+            analyzer.acceptedExpressionTypes("slots.kts", text.replace("|", ""), text.indexOf('|'), types)
+
+        assertEquals(listOf("kotlin.Int"), accepted("fun takesInt(value: Int) = value\ntakesInt(|)"))
+        assertEquals(listOf("kotlin.String"), accepted("val text: String = po|"))
+        assertEquals(types, accepted("val number = 1\npo|"))
+        assertEquals(types, accepted("fun run(block: () -> Unit) = block()\nrun { po| }"))
+        assertNull(accepted("val text = \"abc\".le|"))
+        assertNull(accepted("val po|"))
+    }
+
     @Test
     fun `completion preserves existing lambda bodies and callable references`() {
         for (usage in listOf("someOldFunction { println(42) }", "::someOldFunction")) {
diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt
index dc55c3b9c..a58515ce6 100644
--- a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt
+++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt
@@ -6,8 +6,8 @@ import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeoutOrNull
 import net.minecraft.client.Minecraft
 import net.minecraft.client.Screenshot
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem
+import ru.hollowhorizon.hollowengine.client.camera.CameraDirector
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
 import ru.hollowhorizon.hollowengine.client.ui.screen.HollowWaitScreen
 import ru.hollowhorizon.hollowengine.client.utils.lang
 import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher
@@ -47,13 +47,13 @@ object FrameCapture {
             val waitScreen = pose?.let {
                 HollowWaitScreen(WAIT_HEADING.lang, WAIT_MESSAGE.lang).also(minecraft::setScreen)
             }
-            CutsceneCameraSystem.heldPose = pose
+            CameraDirector.heldPose = pose
             pending = request
             try {
                 withTimeoutOrNull(CAPTURE_TIMEOUT) { request.image.await() }
             } finally {
                 pending = null
-                CutsceneCameraSystem.heldPose = null
+                CameraDirector.heldPose = null
                 if (waitScreen != null && minecraft.screen === waitScreen) minecraft.setScreen(previousScreen)
             }
         }
diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt
index 0bd1c65f6..f342e1086 100644
--- a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt
+++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt
@@ -11,7 +11,7 @@ import ru.hollowhorizon.hollowengine.addons.mcp.ToolInputException
 import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter
 import ru.hollowhorizon.hollowengine.addons.mcp.contentResult
 import ru.hollowhorizon.hollowengine.addons.mcp.errorResult
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 import java.util.Base64
 import java.util.Locale
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt
index 2f95253e0..45efd6963 100644
--- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt
@@ -18,7 +18,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry
 import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient
 
 /**
- * Dialogues and the `.story` language. NPCs, their action channels and cutscenes stay in the engine;
+ * Dialogues, the `.story` language and cutscenes. NPCs and their action channels stay in the engine;
  * this addon builds stories on top of them.
  */
 class HollowNarrativeAddon : HollowAddonEntrypoint {
@@ -32,7 +32,7 @@ class HollowNarrativeAddon : HollowAddonEntrypoint {
     fun addDefaultImports(event: ScriptDefaultImportsEvent) {
         when (event.suffix) {
             RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS
-            NODE_SCRIPT_EXTENSION -> event.defaultImports += DIALOGUE_PACKAGE
+            NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS
         }
     }
 
@@ -58,5 +58,10 @@ class HollowNarrativeAddon : HollowAddonEntrypoint {
         val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE) +
             listOf("actor", "any", "boolean", "list", "number", "signature", "string")
                 .map { "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.$it" }
+
+        /** Nodes start dialogues and play cutscenes for players. */
+        val NODE_IMPORTS = listOf(DIALOGUE_PACKAGE) +
+            listOf("playCutscene", "playCutsceneAt", "playCutsceneNear", "playCutsceneHere", "stopCutscene")
+                .map { "ru.hollowhorizon.hollowengine.addons.narrative.cutscene.$it" }
     }
 }
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt
index ddf7085c0..08c57acf7 100644
--- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt
@@ -1,21 +1,32 @@
 package ru.hollowhorizon.hollowengine.addons.narrative.client
 
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor.CutsceneTimeline
 import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryScriptingAnalyzer
+import ru.hollowhorizon.hollowengine.client.camera.CameraDirector
 import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeScriptTemplate
 import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService
 import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeLanguage
 import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeScriptTemplate
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPoseSources
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.IdeTimelines
 import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensions
 import ru.hollowhorizon.hollowengine.common.events.ClientOnly
 import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry
 
-/** What the addon adds to the client: the dialogue window and camera, and `.story` support in the IDE. */
+/**
+ * What the addon adds to the client: the dialogue window, the cutscene and dialogue cameras, the cutscene
+ * timeline and `.story` support in the IDE.
+ */
 @ClientOnly
 internal object NarrativeClient {
+    /** A cutscene is the more specific instruction, so it holds the camera over a dialogue's own. */
+    private const val CUTSCENE_CAMERA_PRIORITY = 10
+
     fun register(extensions: HollowAddonExtensions) {
         extensions.register(UiDefinitionRegistry.builtInScreens, "dialogue", DialogueScreenUi.definition)
-        extensions.register(CameraPoseSources.point, "story", StoryCameraSystem)
+        extensions.register(CameraDirector.sources, "cutscene", CutsceneCameraSystem, CUTSCENE_CAMERA_PRIORITY)
+        extensions.register(CameraDirector.sources, "story", StoryCameraSystem)
+        extensions.register(IdeTimelines.defaults, "cutscene", CutsceneTimeline)
         extensions.registerIdeLanguage(
             HollowIdeLanguageService.extensions("story", listOf("story")) { StoryScriptingAnalyzer },
         )
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt
index 1d8b51a80..2cf51c66f 100644
--- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt
@@ -2,12 +2,12 @@ package ru.hollowhorizon.hollowengine.addons.narrative.client
 
 import net.minecraft.client.Minecraft
 import net.minecraft.client.renderer.RenderType
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPoseSource
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
+import ru.hollowhorizon.hollowengine.client.camera.CameraPoseSource
 import ru.hollowhorizon.hollowengine.common.events.ClientOnly
 import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent
 import ru.hollowhorizon.hollowengine.common.events.client.render.RenderHudEvent
-import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent
+import ru.hollowhorizon.hollowengine.common.events.client.render.RenderTickEvent
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 
 /**
@@ -113,7 +113,7 @@ object StoryCameraSystem : CameraPoseSource {
      * is no longer alive to look through it.
      */
     @SubscribeEvent
-    fun onClientTick(event: TickEvent.Client) {
+    fun onRenderTick(event: RenderTickEvent.Pre) {
         if (releaseAfter != 0L && System.currentTimeMillis() >= releaseAfter) clear()
         if (target == null) return
         val player = Minecraft.getInstance().player
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt
similarity index 97%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt
index f94701bcf..4c76cd8e6 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt
@@ -1,20 +1,11 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.*
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 import kotlin.math.max
 import kotlin.math.min
 
-data class CameraPose(
-    val position: Vec3f = Vec3f.ZERO,
-    val rotation: Vec3f = Vec3f.ZERO,
-    val fov: Float = 70f,
-) {
-    val pitch: Float get() = rotation.x
-    val yaw: Float get() = rotation.y
-    val roll: Float get() = rotation.z
-}
-
 /** The ids the camera rig is built from; a saved file binds back to its drivers through them. */
 object CameraRig {
     const val CAMERA_ID = "camera"
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt
similarity index 87%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt
index f45e031f9..cf8334c5d 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.common.scripting.story.functions.player
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import kotlinx.serialization.Serializable
 import net.minecraft.core.BlockPos
@@ -6,10 +6,10 @@ import net.minecraft.server.level.ServerPlayer
 import net.minecraft.world.entity.Entity
 import net.minecraft.world.entity.player.Player
 import net.minecraft.world.phys.Vec3
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneAnchor
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneAnchorKind
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneStorage
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchor
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchorKind
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage
 import ru.hollowhorizon.hollowengine.common.network.HollowPacket
 import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler
 
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt
similarity index 97%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt
index c7ebd0973..de83a960d 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import kotlinx.serialization.Serializable
 import net.minecraft.client.Minecraft
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt
similarity index 69%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt
index ece01deb3..f449ddca0 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt
@@ -1,15 +1,20 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import net.minecraft.client.Minecraft
 import net.minecraft.world.phys.Vec3
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
+import ru.hollowhorizon.hollowengine.client.camera.CameraPoseSource
 import ru.hollowhorizon.hollowengine.common.events.ClientOnly
 import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent
-import ru.hollowhorizon.hollowengine.common.events.client.render.RenderItemInHandEvent
-import ru.hollowhorizon.hollowengine.common.events.client.render.RenderOverlayEvent
+import ru.hollowhorizon.hollowengine.common.events.client.render.RenderTickEvent
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 
+/**
+ * The cutscene being played or previewed. It holds the camera above a dialogue's own camera, because it is
+ * the more specific instruction, and hides the hand and the screen overlays while it does.
+ */
 @ClientOnly
-object CutsceneCameraSystem {
+object CutsceneCameraSystem : CameraPoseSource {
     private var controller: CutscenePlaybackController? = null
     private var isPreview = false
     private val environmentOverride = CutsceneEnvironmentOverride()
@@ -17,20 +22,12 @@ object CutsceneCameraSystem {
     val activeController: CutscenePlaybackController?
         get() = controller
 
-    /**
-     * A pose held by a tool that needs a frame seen from a given point. It wins over cutscenes and
-     * [CameraPoseSources] while it is set and hides the hand like a cutscene does.
-     */
-    @Volatile
-    var heldPose: CameraPose? = null
-
-    /**
-     * The pose the camera should take. A held pose wins over everything; a cutscene being played wins
-     * over the poses of [CameraPoseSources], because it is the more specific instruction. The render
-     * bridge reads this one property, so all of them arrive through the same path.
-     */
-    val currentPose: CameraPose?
-        get() = heldPose ?: controller?.currentPose ?: CameraPoseSources.currentPose
+    override val currentPose: CameraPose?
+        get() = controller?.currentPose
+
+    override val hidesHand: Boolean get() = true
+
+    override val hidesOverlays: Boolean get() = true
 
     fun play(data: CutsceneData, loop: Boolean = false, anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED) {
         val playback = CutscenePlaybackController().also {
@@ -68,8 +65,10 @@ object CutsceneCameraSystem {
         isPreview = false
     }
 
-    fun update(minecraft: Minecraft) {
+    @SubscribeEvent
+    fun onRenderTick(event: RenderTickEvent.Pre) {
         val active = controller ?: return
+        val minecraft = event.minecraft
         val level = minecraft.level
         if (level == null || minecraft.player == null) {
             stop()
@@ -94,14 +93,4 @@ object CutsceneCameraSystem {
     }
 
     private fun Vec3.toVec3f(): Vec3f = Vec3f(x.toFloat(), y.toFloat(), z.toFloat())
-
-    @SubscribeEvent
-    fun onRenderOverlays(event: RenderOverlayEvent.Pre) {
-        if (controller != null) event.isCanceled = true
-    }
-
-    @SubscribeEvent
-    fun onRenderHand(event: RenderItemInHandEvent) {
-        if (controller != null || heldPose != null) event.isCanceled = true
-    }
 }
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt
similarity index 97%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt
index 5775fa1c2..571b387c2 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import kotlinx.serialization.Serializable
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.HandleMode
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt
similarity index 93%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt
index 5729bb093..c22a08393 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt
@@ -1,7 +1,8 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import androidx.compose.runtime.mutableStateOf
 import net.minecraft.client.Minecraft
+import ru.hollowhorizon.hollowengine.client.history.UndoLabel
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineEdits
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineKeys
@@ -76,7 +77,7 @@ class CutsceneEditorSession {
     }
 
     fun moveOrigin(position: Vec3f, yaw: Float, keepWorld: Boolean) {
-        timeline.edit(if (keepWorld) TimelineEdits.REANCHOR_CUTSCENE else TimelineEdits.MOVE_CUTSCENE) {
+        timeline.edit(if (keepWorld) REANCHOR else MOVE) {
             if (keepWorld) playback.reanchor(position, yaw)
             else playback.origin = playback.origin.moved(position, yaw)
         }
@@ -124,7 +125,9 @@ class CutsceneEditorSession {
     }
 }
 
-
 object CutsceneEditorSessions {
     val default = CutsceneEditorSession()
 }
+
+private val MOVE = UndoLabel("${UndoLabel.LANG}.timeline.move_cutscene")
+private val REANCHOR = UndoLabel("${UndoLabel.LANG}.timeline.reanchor_cutscene")
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt
similarity index 86%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt
index c8c5a5d27..97c708883 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt
@@ -1,10 +1,9 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import net.minecraft.client.Minecraft
 import net.minecraft.client.multiplayer.ClientLevel
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.*
 import kotlin.math.abs
-import kotlin.math.floor
 import kotlin.math.max
 import kotlin.math.roundToLong
 
@@ -24,21 +23,7 @@ object EnvironmentRig {
     const val TIME_OF_DAY_ID = "world.environment.time_of_day"
     const val WEATHER_ID = "world.environment.weather"
 
-    const val DAY_TICKS = 24_000L
-}
-
-object TimeOfDayValueFormatter : ChannelValueFormatter {
-    override fun format(value: Float): String {
-        val dayTicks = EnvironmentRig.DAY_TICKS.toFloat()
-        val normalized = ((value % dayTicks) + dayTicks) % dayTicks
-        val minutesSinceSix = floor(normalized * MINUTES_PER_DAY / dayTicks).toInt()
-        val totalMinutes = (minutesSinceSix + SIX_AM_MINUTES) % MINUTES_PER_DAY
-        return "%02d:%02d".format(totalMinutes / MINUTES_PER_HOUR, totalMinutes % MINUTES_PER_HOUR)
-    }
-
-    private const val MINUTES_PER_HOUR = 60
-    private const val MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
-    private const val SIX_AM_MINUTES = 6 * MINUTES_PER_HOUR
+    const val DAY_TICKS = TimeOfDayValueFormatter.DAY_TICKS
 }
 
 class TimeOfDayPropertyType : PropertyType {
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt
similarity index 99%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt
index e7e05cf7b..5a5869ca9 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt
similarity index 98%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt
rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt
index 88d405628..3d6ab5945 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import net.minecraft.nbt.CompoundTag
 import ru.hollowhorizon.hollowengine.common.files.DirectoryManager
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt
new file mode 100644
index 000000000..4e5b088b3
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt
@@ -0,0 +1,31 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor
+
+/** Translation keys of the cutscene editor; the timeline's own are in `TimelineLang`. */
+internal object CutsceneLang {
+    private const val ROOT = "hollowengine.gui.ide.cutscene."
+
+    const val SAVE_TITLE = ROOT + "dialog.save_title"
+    const val LOAD_TITLE = ROOT + "dialog.load_title"
+    const val NAME = ROOT + "dialog.name"
+    const val NO_CUTSCENES = ROOT + "dialog.no_cutscenes"
+
+    const val PREVIEW = ROOT + "section.preview"
+    const val ORIGIN = ROOT + "section.origin"
+
+    const val CAMERA_ON = ROOT + "preview.camera_on"
+    const val CAMERA_OFF = ROOT + "preview.camera_off"
+    const val PLAYING = ROOT + "preview.playing"
+    const val PAUSED = ROOT + "preview.paused"
+    const val CURRENT_TIME = ROOT + "preview.current_time"
+    const val DURATION = ROOT + "preview.duration"
+
+    const val ORIGIN_X = ROOT + "origin.x"
+    const val ORIGIN_Y = ROOT + "origin.y"
+    const val ORIGIN_Z = ROOT + "origin.z"
+    const val ORIGIN_YAW = ROOT + "origin.yaw"
+    const val ORIGIN_MOVE_HERE = ROOT + "origin.move_here"
+    const val ORIGIN_REBASE = ROOT + "origin.rebase"
+
+    const val WORLD = ROOT + "keyframe.world"
+    const val WORLD_ROTATION = ROOT + "keyframe.world_rotation"
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt
new file mode 100644
index 000000000..847d54479
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt
@@ -0,0 +1,90 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor
+
+import androidx.compose.runtime.*
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSession
+import ru.hollowhorizon.hollowengine.client.ui.*
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineColors
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineSelectionFields
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.ToolbarButton
+import ru.hollowhorizon.hollowengine.client.ui.inspector.*
+import ru.hollowhorizon.hollowengine.client.utils.lang
+import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
+
+/** The cutscene's own fields, composed inside whichever panel the shared inspector is drawn in. */
+@Composable
+internal fun CutsceneProperties(session: CutsceneEditorSession, refresh: () -> Unit) {
+    PreviewSection(session.timeline, refresh)
+    OriginSection(session, refresh)
+    TimelineSelectionFields(session.timeline, refresh) { WorldReadout(session) }
+}
+
+@Composable
+private fun PreviewSection(controller: TimelineController, refresh: () -> Unit) {
+    Section(CutsceneLang.PREVIEW.lang) {
+        Row(
+            modifier = Modifier.size(100.percent, 24.px)
+                .alignItems(vertical = UiAlign.CENTER)
+                .gap(8.px)
+        ) {
+            Pill(
+                if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_ON.lang
+                else CutsceneLang.CAMERA_OFF.lang,
+                controller.isCameraPreviewEnabled
+            ) {
+                controller.applyCameraPreviewEnabled(!controller.isCameraPreviewEnabled)
+                refresh()
+            }
+            Text(
+                if (controller.isPlaying) CutsceneLang.PLAYING.lang else CutsceneLang.PAUSED.lang,
+                modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted),
+            )
+        }
+        Readonly(CutsceneLang.CURRENT_TIME.lang, "%.3f s".format(controller.currentTime).replace(',', '.'))
+        Readonly(CutsceneLang.DURATION.lang, "%.3f s".format(controller.workAreaEnd).replace(',', '.'))
+    }
+}
+
+@Composable
+private fun OriginSection(session: CutsceneEditorSession, refresh: () -> Unit) {
+    val origin = session.playback.origin
+    Section(CutsceneLang.ORIGIN.lang, id = "timeline-origin-section") {
+        NumberRow(CutsceneLang.ORIGIN_X.lang, origin.x) { next ->
+            session.moveOrigin(Vec3f(next, origin.y, origin.z), origin.yaw, keepWorld = false)
+            refresh()
+        }
+        NumberRow(CutsceneLang.ORIGIN_Y.lang, origin.y) { next ->
+            session.moveOrigin(Vec3f(origin.x, next, origin.z), origin.yaw, keepWorld = false)
+            refresh()
+        }
+        NumberRow(CutsceneLang.ORIGIN_Z.lang, origin.z) { next ->
+            session.moveOrigin(Vec3f(origin.x, origin.y, next), origin.yaw, keepWorld = false)
+            refresh()
+        }
+        NumberRow(CutsceneLang.ORIGIN_YAW.lang, origin.yaw, min = -360f, max = 360f, step = 1f) { next ->
+            session.moveOrigin(origin.position, next, keepWorld = false)
+            refresh()
+        }
+        Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(6.px)) {
+            ToolbarButton(CutsceneLang.ORIGIN_MOVE_HERE.lang, "timeline-origin-move") {
+                session.originToPlayer(keepWorld = false)
+                refresh()
+            }
+            ToolbarButton(CutsceneLang.ORIGIN_REBASE.lang, "timeline-origin-rebase") {
+                session.originToPlayer(keepWorld = true)
+                refresh()
+            }
+        }
+    }
+}
+
+
+@Composable
+private fun WorldReadout(session: CutsceneEditorSession) {
+    val pose = session.playback.currentPose
+    Readonly(CutsceneLang.WORLD.lang, formatVec3(pose.position))
+    Readonly(CutsceneLang.WORLD_ROTATION.lang, formatVec3(pose.rotation))
+}
+
+private fun formatVec3(vector: Vec3f): String =
+    "%.2f  %.2f  %.2f".format(vector.x, vector.y, vector.z).replace(',', '.')
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt
new file mode 100644
index 000000000..e533105ec
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt
@@ -0,0 +1,197 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor
+
+import androidx.compose.runtime.*
+import kotlinx.coroutines.isActive
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSession
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSessions
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage
+import ru.hollowhorizon.hollowengine.client.ui.*
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.DefaultTimeline
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.HollowTimelineEditor
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.LocalTimelineRevision
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineColors
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineLang
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.ToolbarButton
+import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget
+import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector
+import ru.hollowhorizon.hollowengine.client.utils.lang
+
+private const val CutsceneIcon = "hollowengine:textures/gui/icons/film.svg"
+
+@Composable
+internal fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean = true) {
+    session.uiRevision.value
+    var dialog by remember { mutableStateOf(CutsceneDialog.NONE) }
+
+    LaunchedEffect(session) {
+        var lastNanos = -1L
+        while (isActive) {
+            withFrameNanos { now ->
+                val delta =
+                    if (lastNanos < 0L) 0f else ((now - lastNanos) / 1_000_000_000.0).toFloat().coerceIn(0f, 0.1f)
+                lastNanos = now
+                if (session.timeline.isPlaying) session.update(delta)
+                if (session.timeline.curveAxis.advance(now)) session.invalidateUi()
+            }
+        }
+    }
+
+    PublishInspector(source = "cutscene", key = cutsceneInspectorKey(session)) {
+        cutsceneInspectorTarget(session)
+    }
+
+    CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) {
+        Box(id = "cutscene-timeline-dock", mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) {
+            HollowTimelineEditor(
+                controller = session.timeline,
+                refresh = session::invalidateUi,
+                onKeyInput = session::onHollowUiKey,
+                keyboardActive = keyboardActive,
+                onCapture = { session.captureFrame(session.timeline.currentTime) },
+                onSave = { dialog = CutsceneDialog.SAVE },
+                onLoad = { dialog = CutsceneDialog.LOAD },
+            )
+
+            when (dialog) {
+                CutsceneDialog.SAVE -> SaveCutsceneDialog(session) { dialog = CutsceneDialog.NONE }
+                CutsceneDialog.LOAD -> LoadCutsceneDialog(session) { dialog = CutsceneDialog.NONE }
+                CutsceneDialog.NONE -> {}
+            }
+        }
+    }
+}
+
+private fun cutsceneInspectorTarget(session: CutsceneEditorSession) = InspectorTarget(
+    id = "cutscene-${cutsceneInspectorKey(session)}",
+    title = TimelineLang.PROPERTIES.lang,
+    icon = CutsceneIcon,
+) {
+    CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) {
+        CutsceneProperties(session, refresh = session::invalidateUi)
+    }
+}
+
+private fun cutsceneInspectorKey(session: CutsceneEditorSession): String {
+    val controller = session.timeline
+    val keyframe = controller.selectedKeyframes.firstOrNull()
+        ?: return if (controller.isWorkAreaSelected) "work-area" else "none"
+    return "key-${controller.curveOf(keyframe)?.spec?.name}-${keyframe.time}"
+}
+
+private enum class CutsceneDialog { NONE, SAVE, LOAD }
+
+// ---------------------------------------------------------------------------
+// Save / Load dialogs (lightweight overlays over the timeline dock)
+// ---------------------------------------------------------------------------
+
+@Composable
+private fun SaveCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) {
+    var name by remember { mutableStateOf(session.playback.toData().name) }
+    DialogFrame(CutsceneLang.SAVE_TITLE.lang, onClose) {
+        Text(CutsceneLang.NAME.lang, modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted))
+        TextField(
+            value = name,
+            onChange = { name = it },
+            modifier = Modifier.size(100.percent, 24.px)
+                .background(TimelineColors.Background)
+                .border(1.px, TimelineColors.Border, 3f)
+                .padding(6.px, 2.px)
+                .foreground(TimelineColors.Text)
+                .fontSize(11f),
+        )
+        Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(8.px).align(horizontal = UiAlign.END)) {
+            ToolbarButton(TimelineLang.CANCEL.lang, "cutscene-save-cancel") { onClose() }
+            ToolbarButton(TimelineLang.SAVE.lang, "cutscene-save-confirm", primary = true) {
+                if (name.isNotBlank()) {
+                    session.exportCutscene("", name.trim())
+                    onClose()
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun LoadCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) {
+    val files = remember { CutsceneStorage.listFiles() }
+    DialogFrame(CutsceneLang.LOAD_TITLE.lang, onClose) {
+        if (files.isEmpty()) {
+            Text(CutsceneLang.NO_CUTSCENES.lang, modifier = Modifier.fontSize(11f).foreground(TimelineColors.Muted))
+        } else {
+            Column(
+                modifier = Modifier.size(100.percent, UiLength.Auto)
+                    .gap(2.px)
+                    .scrollable(horizontal = false),
+            ) {
+                files.forEach { file ->
+                    Box(
+                        modifier = Modifier.size(100.percent, 24.px)
+                            .background(TimelineColors.Background)
+                            .border(1.px, TimelineColors.Border, 3f)
+                            .cursor(UiCursorShape.HAND)
+                            .onClick {
+                                session.importCutscene(file)
+                                onClose()
+                                it.consume()
+                            },
+                    ) {
+                        Text(
+                            file.removeSuffix(".${CutsceneStorage.EXTENSION}"),
+                            modifier = Modifier.align(vertical = UiAlign.CENTER)
+                                .margin(8.px, 0.px, 0.px, 0.px)
+                                .fontSize(11f)
+                                .foreground(TimelineColors.Text),
+                        )
+                    }
+                }
+            }
+        }
+        Row(modifier = Modifier.size(100.percent, UiLength.Auto).align(horizontal = UiAlign.END)) {
+            ToolbarButton(TimelineLang.CLOSE.lang, "cutscene-load-close") { onClose() }
+        }
+    }
+}
+
+@Composable
+private fun DialogFrame(title: String, onClose: () -> Unit, content: HollowUiContent) {
+    val viewport = LocalUiViewport.current
+    Popup(
+        anchorBounds = viewport,
+        alignment = CenteredOnAnchor,
+        id = "cutscene-dialog-popup",
+        layer = 100,
+        modal = true,
+        onDismiss = onClose,
+    ) {
+        Column(
+            id = "cutscene-dialog",
+            modifier = Modifier.size(340.px, UiLength.Auto)
+                .maxSize(height = (viewport.height - 80f).coerceAtLeast(120f).px)
+                .background(TimelineColors.Panel)
+                .border(1.px, TimelineColors.Border, 6f)
+                .padding(14.px)
+                .gap(8.px)
+                .scrollable(horizontal = false),
+        ) {
+            Text(title, modifier = Modifier.fontSize(13f).foreground(TimelineColors.Text))
+            content()
+        }
+    }
+}
+
+private val CenteredOnAnchor = UiPopupAlignment(
+    anchorHorizontal = UiAlign.CENTER,
+    anchorVertical = UiAlign.CENTER,
+    popupHorizontal = UiAlign.CENTER,
+    popupVertical = UiAlign.CENTER,
+)
+
+/** The cutscene being authored, shown in the timeline window while no editor has published its own timeline. */
+internal object CutsceneTimeline : DefaultTimeline {
+    override val controller: TimelineController
+        get() = CutsceneEditorSessions.default.timeline
+
+    @Composable
+    override fun Content(keyboardActive: Boolean) = CutsceneTimelineDock(CutsceneEditorSessions.default, keyboardActive)
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt
index ee2718d9c..3e3bfdc76 100644
--- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectFunctions.kt
@@ -9,8 +9,8 @@ import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number
 import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string
 import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent
 import ru.hollowhorizon.hollowengine.common.events.entity.LivingEntityDeathEvent
-import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.playCutscene
-import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.stopCutscene
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.playCutscene
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.stopCutscene
 import ru.hollowhorizon.hollowengine.common.ui.hud.EngineHudLayers
 import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers
 import ru.hollowhorizon.hollowengine.common.ui.net.hiddenHudLayers
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt
index f2f71b49d..b3f5f98e6 100644
--- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
 import net.minecraft.world.entity.player.Player
 import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryCameraSystem
 import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryFadeOverlay
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose
+import ru.hollowhorizon.hollowengine.client.camera.CameraPose
 import ru.hollowhorizon.hollowengine.common.network.HollowPacket
 import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
diff --git a/addons/narrative/src/main/resources/META-INF/plugin.properties b/addons/narrative/src/main/resources/META-INF/plugin.properties
index 0d94b0bd9..9cf570520 100644
--- a/addons/narrative/src/main/resources/META-INF/plugin.properties
+++ b/addons/narrative/src/main/resources/META-INF/plugin.properties
@@ -3,5 +3,5 @@ name=HollowEngine Narrative
 version=${version}
 entry=ru.hollowhorizon.hollowengine.addons.narrative.HollowNarrativeAddon
 environment=common
-description=Dialogues, the .story language with its editor support, and story functions for NPCs
+description=Dialogues, the .story language with its editor support, story functions for NPCs, and cutscenes
 authors=HollowHorizon
diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json
index dd6c023da..7ca548e09 100644
--- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json
+++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json
@@ -1,5 +1,30 @@
 {
   "hollowengine.commands.dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.",
   "hollowengine.commands.dialogue_no_such_option": "There is no option %s in the dialogue you are in.",
-  "hollowengine.dialogue.advance": "[ continue ▶ ]"
+  "hollowengine.dialogue.advance": "[ continue ▶ ]",
+  "hollowengine.gui.ide.cutscene.dialog.save_title": "Save cutscene",
+  "hollowengine.gui.ide.cutscene.dialog.load_title": "Load cutscene",
+  "hollowengine.gui.ide.cutscene.dialog.name": "Name",
+  "hollowengine.gui.ide.cutscene.dialog.no_cutscenes": "No saved cutscenes",
+  "hollowengine.gui.ide.cutscene.section.preview": "Preview",
+  "hollowengine.gui.ide.cutscene.section.origin": "Origin",
+  "hollowengine.gui.ide.cutscene.preview.camera_on": "Camera On",
+  "hollowengine.gui.ide.cutscene.preview.camera_off": "Camera Off",
+  "hollowengine.gui.ide.cutscene.preview.playing": "Playing",
+  "hollowengine.gui.ide.cutscene.preview.paused": "Paused",
+  "hollowengine.gui.ide.cutscene.preview.current_time": "Current time",
+  "hollowengine.gui.ide.cutscene.preview.duration": "Duration",
+  "hollowengine.gui.ide.cutscene.origin.x": "Origin X",
+  "hollowengine.gui.ide.cutscene.origin.y": "Origin Y",
+  "hollowengine.gui.ide.cutscene.origin.z": "Origin Z",
+  "hollowengine.gui.ide.cutscene.origin.yaw": "Yaw",
+  "hollowengine.gui.ide.cutscene.origin.move_here": "Move here",
+  "hollowengine.gui.ide.cutscene.origin.rebase": "Re-anchor",
+  "hollowengine.gui.ide.cutscene.keyframe.world": "World",
+  "hollowengine.gui.ide.cutscene.keyframe.world_rotation": "World rotation",
+  "hollowengine.gui.ide.cutscene.weather.clear": "Clear",
+  "hollowengine.gui.ide.cutscene.weather.rain": "Rain",
+  "hollowengine.gui.ide.cutscene.weather.thunder": "Thunder",
+  "hollowengine.gui.history.timeline.reanchor_cutscene": "Re-anchor cutscene",
+  "hollowengine.gui.history.timeline.move_cutscene": "Move cutscene"
 }
diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json
index 200a5a9cd..1c4121377 100644
--- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json
+++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json
@@ -1,5 +1,30 @@
 {
   "hollowengine.commands.dialogue_not_in_dialogue": "Сейчас вы не участвуете в диалоге.",
   "hollowengine.commands.dialogue_no_such_option": "В вашем диалоге нет варианта %s.",
-  "hollowengine.dialogue.advance": "[ дальше ▶ ]"
+  "hollowengine.dialogue.advance": "[ дальше ▶ ]",
+  "hollowengine.gui.ide.cutscene.dialog.save_title": "Сохранение катсцены",
+  "hollowengine.gui.ide.cutscene.dialog.load_title": "Загрузка катсцены",
+  "hollowengine.gui.ide.cutscene.dialog.name": "Название",
+  "hollowengine.gui.ide.cutscene.dialog.no_cutscenes": "Сохранённых катсцен нет",
+  "hollowengine.gui.ide.cutscene.section.preview": "Предпросмотр",
+  "hollowengine.gui.ide.cutscene.section.origin": "Начало координат",
+  "hollowengine.gui.ide.cutscene.preview.camera_on": "Камера вкл",
+  "hollowengine.gui.ide.cutscene.preview.camera_off": "Камера выкл",
+  "hollowengine.gui.ide.cutscene.preview.playing": "Воспроизведение",
+  "hollowengine.gui.ide.cutscene.preview.paused": "Пауза",
+  "hollowengine.gui.ide.cutscene.preview.current_time": "Текущее время",
+  "hollowengine.gui.ide.cutscene.preview.duration": "Длительность",
+  "hollowengine.gui.ide.cutscene.origin.x": "Начало X",
+  "hollowengine.gui.ide.cutscene.origin.y": "Начало Y",
+  "hollowengine.gui.ide.cutscene.origin.z": "Начало Z",
+  "hollowengine.gui.ide.cutscene.origin.yaw": "Поворот",
+  "hollowengine.gui.ide.cutscene.origin.move_here": "Перенести сюда",
+  "hollowengine.gui.ide.cutscene.origin.rebase": "Перепривязать",
+  "hollowengine.gui.ide.cutscene.keyframe.world": "В мире",
+  "hollowengine.gui.ide.cutscene.keyframe.world_rotation": "Поворот в мире",
+  "hollowengine.gui.ide.cutscene.weather.clear": "Ясно",
+  "hollowengine.gui.ide.cutscene.weather.rain": "Дождь",
+  "hollowengine.gui.ide.cutscene.weather.thunder": "Гроза",
+  "hollowengine.gui.history.timeline.reanchor_cutscene": "Перепривязка катсцены",
+  "hollowengine.gui.history.timeline.move_cutscene": "Перемещение катсцены"
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt
similarity index 99%
rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt
rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt
index 9a08da49e..97ace56ec 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt
+++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import org.junit.jupiter.api.Test
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.*
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt
similarity index 96%
rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt
rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt
index cf68b235e..cce0af95c 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt
+++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt
@@ -1,8 +1,8 @@
-package ru.hollowhorizon.hollowengine.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import org.junit.jupiter.api.Test
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.Keyframe
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.*
+import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.*
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt
similarity index 92%
rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt
rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt
index 2d7eac8c5..2d24c5577 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt
+++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt
@@ -1,9 +1,7 @@
-package ru.hollowhorizon.hollowengine.cutscene
+package ru.hollowhorizon.hollowengine.addons.narrative.cutscene
 
 import org.junit.jupiter.api.Test
 import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.*
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.*
-import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.timelineRows
 import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
@@ -300,24 +298,6 @@ class CutsceneTest {
         assertEquals(null, playback.currentEnvironment.weather)
     }
 
-    @Test
-    fun `time of day formatter follows the minecraft clock`() {
-        assertEquals("06:00", TimeOfDayValueFormatter.format(0f))
-        assertEquals("12:00", TimeOfDayValueFormatter.format(6_000f))
-        assertEquals("00:00", TimeOfDayValueFormatter.format(18_000f))
-        assertEquals("05:59", TimeOfDayValueFormatter.format(23_999f))
-        assertEquals("07:00", TimeOfDayValueFormatter.format(25_000f))
-    }
-
-    @Test
-    fun `discrete tracks are omitted from curve editor rows`() {
-        val playback = CutscenePlaybackController()
-        val rows = timelineRows(playback.timeline, curveEditorOnly = true)
-
-        assertTrue(rows.any { it.property === playback.timeOfDay })
-        assertFalse(rows.any { it.property === playback.weather })
-    }
-
     private fun floatCurve(controller: TimelineController): ChannelCurve {
         val property = controller.addProperty(
             listOf("Test"),
diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java
index 3dd512dd0..b982af4fd 100644
--- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java
+++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java
@@ -3,6 +3,7 @@
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import ru.hollowhorizon.hollowengine.bootstrap.impl.transform.RuntimeClassTransformers;
+import ru.hollowhorizon.hollowengine.bootstrap.runtime.AddonBootstrapContract;
 import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimeBridge;
 import ru.hollowhorizon.hollowengine.bootstrap.runtime.mixins.ScriptMixinProvider;
 
@@ -28,7 +29,12 @@ private BootstrapRuntimeManager() {
     }
 
     private static List addonSources(File hollowEngineDirectory) {
-        return List.of(new File(hollowEngineDirectory, "addons"), new File("mods"));
+        List sources = new ArrayList<>();
+        File development = AddonBootstrapContract.developmentAddonsDirectory();
+        if (development != null) sources.add(development);
+        sources.add(new File(hollowEngineDirectory, "addons"));
+        sources.add(new File("mods"));
+        return sources;
     }
 
     private static String enginePackage() {
diff --git a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java
index 9a2c602f5..9fbbfb6ee 100644
--- a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java
+++ b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java
@@ -1,5 +1,6 @@
 package ru.hollowhorizon.hollowengine.bootstrap.runtime;
 
+import java.io.File;
 import java.util.List;
 
 public final class AddonBootstrapContract {
@@ -8,6 +9,8 @@ public final class AddonBootstrapContract {
     public static final String REGULAR_LIBRARY_PATH = "hollowengine-addon-libs/";
     public static final String LOADED_ADDON_FINGERPRINTS_PROPERTY = "hollowengine.addons.bootstrap.loaded";
     public static final String REJECTED_ADDON_FINGERPRINTS_PROPERTY = "hollowengine.addons.bootstrap.rejected";
+    /** A directory of addon jars a development run loads ahead of every other source; set by the Gradle run tasks. */
+    public static final String DEVELOPMENT_ADDONS_PROPERTY = "hollowengine.devAddons";
     public static final List HOST_NATIVE_LIBRARY_PREFIXES = List.of(
             "lwjgl", "jemalloc", "glfw", "openal", "opengl", "stb", "tinyfd", "shaderc", "vulkan",
             "jinput", "jna-", "jna-platform-", "netty-", "netty-tcnative", "oshi-core"
@@ -15,4 +18,11 @@ public final class AddonBootstrapContract {
 
     private AddonBootstrapContract() {
     }
+
+    /** The directory named by {@link #DEVELOPMENT_ADDONS_PROPERTY}, or null outside a development run. */
+    public static File developmentAddonsDirectory() {
+        String path = System.getProperty(DEVELOPMENT_ADDONS_PROPERTY);
+        if (path == null || path.isBlank()) return null;
+        return new File(path).getAbsoluteFile();
+    }
 }
diff --git a/build.gradle.kts b/build.gradle.kts
index 0f53c33c9..f5c40b242 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,6 +4,7 @@ import me.modmuss50.mpp.ReleaseType
 import net.fabricmc.loom.api.LoomGradleExtensionAPI
 import net.fabricmc.loom.task.GenerateSourcesTask
 import net.fabricmc.loom.task.RemapJarTask
+import net.fabricmc.loom.task.RunGameTask
 import org.gradle.jvm.tasks.Jar
 import org.jetbrains.kotlin.gradle.dsl.JvmTarget
 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@@ -226,8 +227,12 @@ subprojects {
         extensions.configure {
             runs.configureEach {
                 jvmArguments.add("-Dhollowengine.ide.sourceRoots=${rootProject.layout.buildDirectory.file("ide/source-roots.txt").get().asFile.absolutePath}")
+                jvmArguments.add("-Dhollowengine.devAddons=${rootProject.layout.buildDirectory.dir("addon-jars").get().asFile.absolutePath}")
             }
         }
+        tasks.withType().configureEach {
+            dependsOn(rootProject.tasks.named("buildAddons"))
+        }
     }
 }
 
diff --git a/docs/en/addons/creating.mdx b/docs/en/addons/creating.mdx
index b804806e3..e027d4150 100644
--- a/docs/en/addons/creating.mdx
+++ b/docs/en/addons/creating.mdx
@@ -17,7 +17,7 @@ In the HollowEngine repository, every directory under `addons/` that contains a
 ./gradlew buildAddons
 ```
 
-The resulting jars are collected in `build/addon-jars/`.
+The resulting jars are collected in `build/addon-jars/`. The repository's own `runClient` and `runServer` build them first and load them from there, so nothing has to be copied into `mods/`. Run `buildAddons` again while the game is open and the rebuilt addons are reloaded in place; one with native libraries still needs a restart.
 
 To build an addon outside the repository, compile against the **development jar**. `./gradlew developmentJar` produces `build/development/HollowEngine---dev.jar` and `buildAndCollect` copies it into `merged/`. It exposes the engine classes, the bridge, and bundled libraries on the compile classpath. Use it as `compileOnly` with Mojang or Parchment mappings and the Kotlin version HollowEngine uses. Install the regular universal jar in the game; the development jar is only for compiling.
 
diff --git a/docs/en/addons/index.mdx b/docs/en/addons/index.mdx
index 64ed6330b..78735f175 100644
--- a/docs/en/addons/index.mdx
+++ b/docs/en/addons/index.mdx
@@ -19,6 +19,7 @@ These addons ship with HollowEngine releases:
 | [MCP server](/docs/hollowengine/addons/mcp) | `hollowengine-mcp` | Lets AI coding agents work with scripts inside the running game. |
 | [Physics](/docs/hollowengine/addons/physics) | `hollowengine-physics` | Ragdoll physics for models, powered by Jolt. |
 | [Media](/docs/hollowengine/addons/media) | `hollowengine-media` | Video playback in screens, UI, and by command. |
+| [Narrative](/docs/hollowengine/addons/narrative) | `hollowengine-narrative` | Dialogues, the `.story` language, and cutscenes. |
 
 ## Installing an addon
 
diff --git a/docs/en/addons/narrative.mdx b/docs/en/addons/narrative.mdx
new file mode 100644
index 000000000..52a7b28f5
--- /dev/null
+++ b/docs/en/addons/narrative.mdx
@@ -0,0 +1,39 @@
+---
+title: Narrative addon
+order: 9
+---
+
+

Narrative addon

+ +*Dialogues, the `.story` language, and cutscenes.* + +The narrative addon (id `hollowengine-narrative`) holds everything that tells a story on top of the engine. NPCs, their actions, and the node scripts that drive them stay in HollowEngine itself, so a world with NPCs still loads without the addon. + +## What it adds + +- The [dialogue system](/docs/hollowengine/dialogues): `.story` files, `DialogueController`, the dialogue window, and the `/he dialogue advance` and `/he dialogue choose ` commands for playing a dialogue from chat. +- The built-in [story commands](/docs/hollowengine/dialogues/commands) for timing, sound, NPCs, cameras, the HUD, and cutscenes, and `RegisterStoryFunctionsEvent` for [your own](/docs/hollowengine/dialogues/custom-commands). +- `.story` support in the IDE: highlighting, diagnostics, completion, navigation, and a **Dialogue** entry in the project tree's **New script** menu. +- [Cutscenes](/docs/hollowengine/world/cutscenes): the cutscene timeline, `playCutscene` and its variants, and the camera that plays them. + +## Using it from your scripts + +Scripts in the `hollowengine` folder see an addon's classes only when the project depends on it. Add the addon to `dependsOn` in `hollowengine/META-INF/plugin.properties`, or in the IDE's project settings: + +```properties +dependsOn=hollowengine-narrative +``` + +With the dependency in place, node scripts can use `DialogueController`, `DialogueCharacter`, and `playCutscene` without imports, and reload scripts also get the signature helpers (`string`, `number`, `actor`, and the rest) for registering story commands. Without it, those names are unresolved, and the rest of the project compiles as before. + +An addon that uses dialogues or cutscenes declares `dependsOn=hollowengine-narrative` in its own `plugin.properties` in the same way. + +## Packages + +| What | Package | +| --- | --- | +| Dialogues, story functions, events | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue` | +| Signature helpers for story commands | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang` | +| Cutscenes | `ru.hollowhorizon.hollowengine.addons.narrative.cutscene` | + +Scripts written for earlier versions imported these classes from `ru.hollowhorizon.hollowengine.common.dialogue`. Replace that prefix with `ru.hollowhorizon.hollowengine.addons.narrative.dialogue`; cutscene functions used to come from `ru.hollowhorizon.hollowengine.common.scripting.story.functions.player`. diff --git a/docs/en/dialogues/custom-commands.mdx b/docs/en/dialogues/custom-commands.mdx index fdbacfb8e..080ee1caa 100644 --- a/docs/en/dialogues/custom-commands.mdx +++ b/docs/en/dialogues/custom-commands.mdx @@ -17,8 +17,8 @@ Use `RegisterStoryFunctionsEvent` when every dialogue should see a command: ```kotlin import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.RegisterStoryFunctionsEvent -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.RegisterStoryFunctionsEvent +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string @SubscribeEvent fun registerDialogueCommands(event: RegisterStoryFunctionsEvent) { @@ -76,8 +76,8 @@ Use full descriptors when parameters are optional, have defaults, need actor res ```kotlin import kotlinx.coroutines.delay import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string import kotlin.time.Duration.Companion.milliseconds functions.add( diff --git a/docs/en/dialogues/index.mdx b/docs/en/dialogues/index.mdx index a76122fc2..413bb0147 100644 --- a/docs/en/dialogues/index.mdx +++ b/docs/en/dialogues/index.mdx @@ -9,6 +9,10 @@ order: 1 ![Dialogue system](/hollowengine/titles/dialogues/index.webp) +:::advice +Dialogues come with the [narrative addon](/docs/hollowengine/addons/narrative). Scripts in the `hollowengine` folder need `dependsOn=hollowengine-narrative` in the project's `plugin.properties` to use them. +::: + Dialogue scenarios use `.story` files. They live beside Kotlin scripts under `hollowengine/scripts`, but use a dedicated line-based language with highlighting, diagnostics, completion, and navigation in the built-in IDE. For example, `hollowengine/scripts/stories/gate.story` has the address `project:stories/gate.story`. @@ -32,8 +36,8 @@ Adjacent `@choice` blocks form one menu. Only the selected block runs. `if=` hid ## Starting it from Kotlin ```kotlin -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueCharacter -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueController val gateDialogue = DialogueController("project:stories/gate.story") diff --git a/docs/en/ide/index.mdx b/docs/en/ide/index.mdx index c674bc709..1f0682abe 100644 --- a/docs/en/ide/index.mdx +++ b/docs/en/ide/index.mdx @@ -101,7 +101,7 @@ Place library and mod source archives (`*-sources.jar`, or `.zip`) in `holloweng ### Custom completion -`IdeCompletionEvent` is posted on the client thread, where handlers may read the player and world. It is posted once per caret position. Its `path`, `text`, and `caret` identify the request; `items` starts with the engine's context snippets and accepts additional `UiTextCompletion` entries. The language service's own results are computed in the background and shown next to them. Each entry can specify insertion text, `filterText`, `caretOffset`, and `importFqName`. +`IdeCompletionEvent` is posted on the client thread, where handlers may read the player and world. It is posted once per caret position. Its `path`, `text`, and `caret` identify the request; `items` starts with the engine's context snippets, `pos(...)` at the player's position and `item(...)` for the held item, offered only where an expression of their type may go, and accepts additional `UiTextCompletion` entries. The language service's own results are computed in the background and shown next to them. Each entry can specify insertion text, `filterText`, `caretOffset`, and `importFqName`. ```kotlin import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion diff --git a/docs/en/world/cutscenes.mdx b/docs/en/world/cutscenes.mdx index e010e09ca..7a14071ca 100644 --- a/docs/en/world/cutscenes.mdx +++ b/docs/en/world/cutscenes.mdx @@ -7,6 +7,10 @@ order: 5 Record camera movement in the editor and play the scene for a chosen player. You can also set the field of view, time of day, and weather on the timeline. +:::advice +Cutscenes come with the [narrative addon](/docs/hollowengine/addons/narrative). Without it the timeline window shows only the timelines of open files, such as montages and effects. +::: + ![Cutscene editor timeline and viewport](/hollowengine/pictures/cutscenes/overview.webp) --- diff --git a/docs/ru/addons/creating.mdx b/docs/ru/addons/creating.mdx index b4a70f169..b48129f33 100644 --- a/docs/ru/addons/creating.mdx +++ b/docs/ru/addons/creating.mdx @@ -17,7 +17,7 @@ order: 2 ./gradlew buildAddons ``` -Готовые jar складываются в `build/addon-jars/`. +Готовые jar складываются в `build/addon-jars/`. Собственные `runClient` и `runServer` репозитория сначала собирают их и загружают прямо оттуда, так что копировать ничего в `mods/` не нужно. Запустите `buildAddons` ещё раз при открытой игре, и пересобранные аддоны перезагрузятся на месте; аддону с нативными библиотеками по-прежнему нужен перезапуск. Чтобы собирать аддон вне репозитория, компилируйте против **development jar**. `./gradlew developmentJar` создаёт `build/development/HollowEngine---dev.jar`, а `buildAndCollect` копирует его в `merged/`. Он выставляет классы движка, мост и встроенные библиотеки в compile classpath. Подключайте его как `compileOnly` с маппингами Mojang или Parchment и той версией Kotlin, которую использует HollowEngine. В игру ставьте обычный универсальный jar; development jar нужен только для компиляции. diff --git a/docs/ru/addons/index.mdx b/docs/ru/addons/index.mdx index 29df95b9c..ac2283484 100644 --- a/docs/ru/addons/index.mdx +++ b/docs/ru/addons/index.mdx @@ -19,6 +19,7 @@ order: 1 | [MCP-сервер](/docs/hollowengine/addons/mcp) | `hollowengine-mcp` | Позволяет AI-агентам для кода работать со скриптами в запущенной игре. | | [Физика](/docs/hollowengine/addons/physics) | `hollowengine-physics` | Физика рэгдоллов для моделей на движке Jolt. | | [Media](/docs/hollowengine/addons/media) | `hollowengine-media` | Воспроизведение видео в экранах, UI и по команде. | +| [Narrative](/docs/hollowengine/addons/narrative) | `hollowengine-narrative` | Диалоги, язык `.story` и катсцены. | ## Установка аддона diff --git a/docs/ru/addons/narrative.mdx b/docs/ru/addons/narrative.mdx new file mode 100644 index 000000000..07042737f --- /dev/null +++ b/docs/ru/addons/narrative.mdx @@ -0,0 +1,39 @@ +--- +title: Аддон Narrative +order: 9 +--- + +

Аддон Narrative

+ +*Диалоги, язык `.story` и катсцены.* + +Аддон Narrative (id `hollowengine-narrative`) содержит всё, что рассказывает историю поверх движка. NPC, их действия и управляющие ими нод-скрипты остаются в самом HollowEngine, поэтому мир с NPC загружается и без аддона. + +## Что он добавляет + +- [Диалоговую систему](/docs/hollowengine/dialogues): файлы `.story`, `DialogueController`, окно диалога и команды `/he dialogue advance` и `/he dialogue choose `, чтобы проходить диалог через чат. +- Встроенные [команды историй](/docs/hollowengine/dialogues/commands) для пауз, звука, NPC, камеры, HUD и катсцен, а также `RegisterStoryFunctionsEvent` для [своих команд](/docs/hollowengine/dialogues/custom-commands). +- Поддержку `.story` в IDE: подсветку, диагностику, автодополнение, навигацию и пункт **Dialogue** в меню **New script** дерева проекта. +- [Катсцены](/docs/hollowengine/world/cutscenes): таймлайн катсцены, `playCutscene` с вариантами и камеру, которая их проигрывает. + +## Использование из скриптов + +Скрипты папки `hollowengine` видят классы аддона, только если проект от него зависит. Добавьте аддон в `dependsOn` в `hollowengine/META-INF/plugin.properties` или в настройках проекта в IDE: + +```properties +dependsOn=hollowengine-narrative +``` + +С этой зависимостью нод-скрипты используют `DialogueController`, `DialogueCharacter` и `playCutscene` без импортов, а reload-скрипты получают ещё и помощники сигнатур (`string`, `number`, `actor` и остальные) для регистрации команд историй. Без неё эти имена не находятся, а остальной проект компилируется как раньше. + +Аддон, который пользуется диалогами или катсценами, так же объявляет `dependsOn=hollowengine-narrative` в своём `plugin.properties`. + +## Пакеты + +| Что | Пакет | +| --- | --- | +| Диалоги, функции историй, события | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue` | +| Помощники сигнатур для команд историй | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang` | +| Катсцены | `ru.hollowhorizon.hollowengine.addons.narrative.cutscene` | + +Скрипты для прошлых версий импортировали эти классы из `ru.hollowhorizon.hollowengine.common.dialogue`. Замените этот префикс на `ru.hollowhorizon.hollowengine.addons.narrative.dialogue`; функции катсцен раньше лежали в `ru.hollowhorizon.hollowengine.common.scripting.story.functions.player`. diff --git a/docs/ru/dialogues/custom-commands.mdx b/docs/ru/dialogues/custom-commands.mdx index 40eec232b..7c5310f82 100644 --- a/docs/ru/dialogues/custom-commands.mdx +++ b/docs/ru/dialogues/custom-commands.mdx @@ -17,8 +17,8 @@ order: 5 ```kotlin import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.RegisterStoryFunctionsEvent -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.RegisterStoryFunctionsEvent +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string @SubscribeEvent fun registerDialogueCommands(event: RegisterStoryFunctionsEvent) { @@ -76,8 +76,8 @@ functions.add( ```kotlin import kotlinx.coroutines.delay import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string import kotlin.time.Duration.Companion.milliseconds functions.add( diff --git a/docs/ru/dialogues/index.mdx b/docs/ru/dialogues/index.mdx index 6af32e871..a715b3dc8 100644 --- a/docs/ru/dialogues/index.mdx +++ b/docs/ru/dialogues/index.mdx @@ -9,6 +9,10 @@ order: 1 ![Диалог](/hollowengine/titles/dialogues/index.webp) +:::advice +Диалоги входят в [аддон Narrative](/docs/hollowengine/addons/narrative). Скриптам папки `hollowengine` для работы с ними нужен `dependsOn=hollowengine-narrative` в `plugin.properties` проекта. +::: + Сценарии диалогов пишутся в `.story` файлах. Они лежат рядом с Kotlin-скриптами в `hollowengine/scripts`, но используют отдельный построчный язык. Встроенная IDE поддерживает его подсветку, диагностику, автодополнение и навигацию. Например, файл `hollowengine/scripts/stories/gate.story` имеет адрес `project:stories/gate.story`. @@ -32,8 +36,8 @@ order: 1 ## Запуск из Kotlin ```kotlin -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueCharacter -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueController val gateDialogue = DialogueController("project:stories/gate.story") diff --git a/docs/ru/ide/index.mdx b/docs/ru/ide/index.mdx index edd33b7ab..50d85d3e9 100644 --- a/docs/ru/ide/index.mdx +++ b/docs/ru/ide/index.mdx @@ -100,7 +100,7 @@ order: 1 ### Свои предложения автодополнения -`IdeCompletionEvent` вызывается на клиентском потоке: обработчик может безопасно читать состояние игрока и мира. Событие приходит один раз на положение каретки. Поля `path`, `text` и `caret` описывают запрос; `items` начинается с контекстных сниппетов движка, и туда можно добавлять `UiTextCompletion`. Результаты самого языкового сервиса считаются в фоне и показываются рядом. У предложения можно задать вставляемый текст, имя для фильтрации `filterText`, положение каретки `caretOffset` и импорт `importFqName`. +`IdeCompletionEvent` вызывается на клиентском потоке: обработчик может безопасно читать состояние игрока и мира. Событие приходит один раз на положение каретки. Поля `path`, `text` и `caret` описывают запрос; `items` начинается с контекстных сниппетов движка - `pos(...)` с позицией игрока и `item(...)` с предметом в руке, которые предлагаются только там, где допустимо выражение их типа, - и туда можно добавлять `UiTextCompletion`. Результаты самого языкового сервиса считаются в фоне и показываются рядом. У предложения можно задать вставляемый текст, имя для фильтрации `filterText`, положение каретки `caretOffset` и импорт `importFqName`. ```kotlin import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion diff --git a/docs/ru/world/cutscenes.mdx b/docs/ru/world/cutscenes.mdx index 517d7bb41..ae0126caa 100644 --- a/docs/ru/world/cutscenes.mdx +++ b/docs/ru/world/cutscenes.mdx @@ -7,6 +7,10 @@ order: 5 Запишите движение камеры в редакторе и запустите сцену для выбранного игрока. На таймлайне можно также настроить поле зрения, время суток и погоду. +:::advice +Катсцены входят в [аддон Narrative](/docs/hollowengine/addons/narrative). Без него окно таймлайна показывает только таймлайны открытых файлов, например монтажей и эффектов. +::: + ![Таймлайн и окно просмотра редактора катсцен](/hollowengine/pictures/cutscenes/overview.webp) --- diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt index ef4e150c3..955e99167 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt @@ -67,6 +67,7 @@ import ru.hollowhorizon.hollowengine.bootstrap.runtime.EventBridge import ru.hollowhorizon.hollowengine.bootstrap.runtime.PathObstacles import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimeBridge import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimePlatform +import ru.hollowhorizon.hollowengine.client.camera.CameraDirector import ru.hollowhorizon.hollowengine.client.colliders.ClientColliderHooks import ru.hollowhorizon.hollowengine.client.colliders.ClientColliderPoses import ru.hollowhorizon.hollowengine.client.colliders.ColliderDebugRenderer @@ -87,7 +88,6 @@ import ru.hollowhorizon.hollowengine.client.sounds.SoundStreams import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeGameViewport import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlay import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlayPoint -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem import ru.hollowhorizon.hollowengine.client.ui.notification.NotificationOverlay import ru.hollowhorizon.hollowengine.client.ui.panels.UiPanels import ru.hollowhorizon.hollowengine.client.ui.script.UiScriptHudHost @@ -568,7 +568,6 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onClientRenderTickPre(client: Minecraft) { - CutsceneCameraSystem.update(client) RenderTickEvent.Pre.post(RenderTickEvent.Pre(client)) } @@ -781,7 +780,7 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { roll: Float, partialTick: Float, ): RuntimeBridge.CameraSetup { - val pose = CutsceneCameraSystem.currentPose.takeIf { shouldApplyCutsceneCamera() } + val pose = CameraDirector.currentPose.takeIf { shouldApplyCameraPose() } val event = CameraSetupEvent( gameRenderer = gameRenderer, camera = camera, @@ -795,8 +794,8 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun getCameraOverride(partialTick: Float): RuntimeBridge.CameraOverride { - if (!shouldApplyCutsceneCamera()) return RuntimeBridge.CameraOverride.NONE - val pose = CutsceneCameraSystem.currentPose ?: return RuntimeBridge.CameraOverride.NONE + if (!shouldApplyCameraPose()) return RuntimeBridge.CameraOverride.NONE + val pose = CameraDirector.currentPose ?: return RuntimeBridge.CameraOverride.NONE return RuntimeBridge.CameraOverride( true, pose.position.x.toDouble(), @@ -821,13 +820,13 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { camera = camera, partialTick = partialTick, changingFov = changingFov, - fov = CutsceneCameraSystem.currentPose?.takeIf { shouldApplyCutsceneCamera() }?.fov?.toDouble() ?: fov, + fov = CameraDirector.currentPose?.takeIf { shouldApplyCameraPose() }?.fov?.toDouble() ?: fov, ) CameraFovEvent.post(event) return event.fov } - private fun shouldApplyCutsceneCamera(): Boolean { + private fun shouldApplyCameraPose(): Boolean { return !IrisHelper.isShadowRendering() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt new file mode 100644 index 000000000..196cb3096 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt @@ -0,0 +1,61 @@ +package ru.hollowhorizon.hollowengine.client.camera + +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderItemInHandEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderOverlayEvent +import ru.hollowhorizon.hollowengine.common.utils.rl + +/** + * Something that wants to point the camera, such as a cutscene or a dialogue's own camera. + */ +interface CameraPoseSource { + /** Where the camera should be, or null while this source leaves it alone. */ + val currentPose: CameraPose? + + /** Whether the hand is hidden while this source holds the camera. */ + val hidesHand: Boolean get() = false + + /** Whether screen overlays such as the vignette are hidden while this source holds the camera. */ + val hidesOverlays: Boolean get() = false +} + +/** + * Decides where the camera is. The render bridge reads [currentPose] alone, so every source reaches the + * screen through the same path. + */ +@ClientOnly +object CameraDirector { + /** + * Sources addons contribute. The one with the highest priority that has a pose wins, so a cutscene + * registers above a dialogue camera. + */ + val sources = ExtensionPoints.create("hollowengine:camera/pose-sources".rl, CameraPoseSource::class) + + /** + * A pose held by a tool that needs a frame seen from a given point. It wins over every source while + * it is set and hides the hand. + */ + @Volatile + var heldPose: CameraPose? = null + + val currentPose: CameraPose? + get() = heldPose ?: activeSource?.currentPose + + fun register(id: String, source: CameraPoseSource): ExtensionHandle = sources.register(id.rl, source) + + private val activeSource: CameraPoseSource? + get() = sources.extensions.firstOrNull { it.currentPose != null } + + @SubscribeEvent + fun onRenderOverlays(event: RenderOverlayEvent.Pre) { + if (activeSource?.hidesOverlays == true) event.isCanceled = true + } + + @SubscribeEvent + fun onRenderHand(event: RenderItemInHandEvent) { + if (heldPose != null || activeSource?.hidesHand == true) event.isCanceled = true + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt new file mode 100644 index 000000000..d777c1d0c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt @@ -0,0 +1,14 @@ +package ru.hollowhorizon.hollowengine.client.camera + +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +/** Where the camera stands and looks: [rotation] is pitch, yaw and roll in degrees, as F3 shows them. */ +data class CameraPose( + val position: Vec3f = Vec3f.ZERO, + val rotation: Vec3f = Vec3f.ZERO, + val fov: Float = 70f, +) { + val pitch: Float get() = rotation.x + val yaw: Float get() = rotation.y + val roll: Float get() = rotation.z +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt index 53ddeca84..01e81f55b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt @@ -341,6 +341,14 @@ internal class HollowIdeEditorSession( val requestRevision = completionRevision.incrementAndGet() completionJob?.cancel() completionJob = scope.launch { + val acceptedTypes = runCatching { + analyzer.acceptedExpressionTypes(path, text, key.caret, ContextSnippetTypes) + }.getOrElse { failure -> + reportAnalysisFailure("expected types", failure) + null + } + requestContextCompletions(text, key, analyzer, requestRevision, acceptedTypes) + val collected = ArrayList() var publishedAt = 0L var publishedSize = -1 @@ -366,11 +374,20 @@ internal class HollowIdeEditorSession( } if (publishedSize != collected.size) publish() } + } + + private fun requestContextCompletions( + text: String, + key: CompletionKey, + analyzer: ScriptingAnalyzer, + requestRevision: Long, + acceptedTypes: List?, + ) { contextCompletionJob?.cancel() contextCompletionJob = scope.launch(Minecraft.getInstance().dispatcher) { val context = UiCompletionContext(text, key.caret) val items = runCatching { - val candidates = ideContextCompletions(path, context).toMutableList() + val candidates = ideContextCompletions(path, context, acceptedTypes).toMutableList() IdeCompletionEvent.post(IdeCompletionEvent(path, text, key.caret, candidates)).items.toList() }.getOrElse { failure -> HollowEngine.LOGGER.error("Context completions failed for '{}'", path, failure) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt index 00b9bf323..6e9790bf6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.setValue import net.minecraft.client.Minecraft import net.minecraft.world.Difficulty import net.minecraft.world.level.GameType -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.TimeOfDayValueFormatter +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimeOfDayValueFormatter import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownSlider diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt index 823fe766b..5b54a489b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt @@ -12,8 +12,22 @@ import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionCloseness import ru.hollowhorizon.hollowengine.common.scripting.ide.isKotlinCodeAt -/** Reads player and world, so it runs on client thread. */ -internal fun ideContextCompletions(path: String, context: UiCompletionContext): List { +/** What the snippets evaluate to, as scripts see these classes: they compile against Mojang names. */ +internal const val PositionSnippetType = "net.minecraft.world.phys.Vec3" +internal const val ItemSnippetType = "net.minecraft.world.item.ItemStack" +internal val ContextSnippetTypes = listOf(PositionSnippetType, ItemSnippetType) + +/** + * Snippets built from where the player stands and what they hold, offered where an expression of their type + * may go. [acceptedTypes] is what the analyzer said fits at the caret, out of [ContextSnippetTypes]; null means + * no expression starts there. Reads player and world, so it runs on client thread. + */ +internal fun ideContextCompletions( + path: String, + context: UiCompletionContext, + acceptedTypes: List?, +): List { + if (acceptedTypes.isNullOrEmpty()) return emptyList() if (!path.isKotlinSource() || !context.text.isKotlinCodeAt(context.caret)) return emptyList() val word = completionWordRange(context.text, context.caret) val suffix = context.text.substring(word.last).trimStart() @@ -32,22 +46,26 @@ internal fun ideContextCompletions(path: String, context: UiCompletionContext): patch?.let { itemSpecification(stack.itemHolder.unwrapKey().orElseThrow().location().toString(), it) } } } else null - return contextSnippetCompletions(position, specification, stack.count) + return contextSnippetCompletions(position, specification, stack.count, acceptedTypes) } -internal fun contextSnippetCompletions(position: Vec3, heldItem: String?, count: Int): List = - buildList { - add( - contextSnippet( - "pos", "pos(${position.x}, ${position.y}, ${position.z})", "player_position" - ) +internal fun contextSnippetCompletions( + position: Vec3, + heldItem: String?, + count: Int, + acceptedTypes: List = ContextSnippetTypes, +): List = buildList { + if (PositionSnippetType in acceptedTypes) add( + contextSnippet( + "pos", "pos(${position.x}, ${position.y}, ${position.z})", "player_position" ) - if (heldItem != null) add( - contextSnippet( - "item", "item(\"${heldItem.escapeForRegularKotlinString()}\", $count)", "held_item" - ) + ) + if (heldItem != null && ItemSnippetType in acceptedTypes) add( + contextSnippet( + "item", "item(\"${heldItem.escapeForRegularKotlinString()}\", $count)", "held_item" ) - } + ) +} private fun contextSnippet(name: String, insertion: String, label: String) = UiTextCompletion( label = insertion, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt index 49aacafc7..9937b63ec 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt @@ -9,7 +9,6 @@ import ru.hollowhorizon.hollowengine.client.history.UndoHistory import ru.hollowhorizon.hollowengine.client.history.UndoLabel import ru.hollowhorizon.hollowengine.client.history.UndoOwner import ru.hollowhorizon.hollowengine.client.ui.ide.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSessions import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.IdeTimelines import ru.hollowhorizon.hollowengine.client.utils.IconHelper import ru.hollowhorizon.hollowengine.client.utils.lang @@ -33,7 +32,7 @@ internal class IdeHistories( add(HistoryContext(file.id, file.title, IconHelper.forPath(file.path).toString(), history)) } - val timeline = timelineHistory() + val timeline = timelineHistory() ?: return@buildList if (none { it.history === timeline }) add(HistoryContext(TIMELINE, "$LANG.timeline".lang, TIMELINE_ICON, timeline)) } @@ -64,8 +63,8 @@ internal class IdeHistories( private fun historyOf(file: HollowIdeOpenFile): UndoHistory? = (file.document as? UndoOwner)?.history ?: textHistory(file) - private fun timelineHistory(): UndoHistory = - (IdeTimelines.current?.controller ?: CutsceneEditorSessions.default.timeline).history + private fun timelineHistory(): UndoHistory? = + (IdeTimelines.current?.controller ?: IdeTimelines.default?.controller)?.history private companion object { const val LANG = UndoLabel.LANG diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt new file mode 100644 index 000000000..a52f4206b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt @@ -0,0 +1,20 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import kotlin.math.floor + +/** Shows a day time in ticks as the clock on the wall: tick 0 is 06:00. */ +object TimeOfDayValueFormatter : ChannelValueFormatter { + const val DAY_TICKS = 24_000L + + override fun format(value: Float): String { + val dayTicks = DAY_TICKS.toFloat() + val normalized = ((value % dayTicks) + dayTicks) % dayTicks + val minutesSinceSix = floor(normalized * MINUTES_PER_DAY / dayTicks).toInt() + val totalMinutes = (minutesSinceSix + SIX_AM_MINUTES) % MINUTES_PER_DAY + return "%02d:%02d".format(totalMinutes / MINUTES_PER_HOUR, totalMinutes % MINUTES_PER_HOUR) + } + + private const val MINUTES_PER_HOUR = 60 + private const val MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR + private const val SIX_AM_MINUTES = 6 * MINUTES_PER_HOUR +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt index 269cbc79d..984f6e480 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt @@ -856,7 +856,8 @@ class TimelineController(override val history: UndoHistory = UndoHistory()) : Un } -internal fun AnimProperty<*>.decomposeDefault(into: FloatArray) { +/** Writes the channels of the property's default value into [into]. */ +fun AnimProperty<*>.decomposeDefault(into: FloatArray) { (type as PropertyType).decompose(defaultValue, into) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt index c42dbc77f..9e076f4aa 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt @@ -89,6 +89,4 @@ object TimelineEdits { val ROTATION_BASIS = label("rotation_basis") val RECORD_KEYS = label("record_keys") val CAPTURE_KEY = label("capture_key") - val MOVE_CUTSCENE = label("move_cutscene") - val REANCHOR_CUTSCENE = label("reanchor_cutscene") } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt deleted file mode 100644 index fc26ad22b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraPoseSources.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene - -import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle -import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints -import ru.hollowhorizon.hollowengine.common.utils.rl - -/** - * Something outside the cutscene system that wants to point the camera, such as a dialogue's own camera. - */ -interface CameraPoseSource { - /** Where the camera should be, or null while this source leaves it alone. */ - val currentPose: CameraPose? -} - -/** - * Camera sources addons contribute. They are asked only when no cutscene plays and no pose is held, - * so a cutscene always wins over them; among themselves the first one with a pose wins. - */ -object CameraPoseSources { - val point = ExtensionPoints.create("hollowengine:camera/pose-sources".rl, CameraPoseSource::class) - - fun register(id: String, source: CameraPoseSource): ExtensionHandle = point.register(id.rl, source) - - val currentPose: CameraPose? get() = point.extensions.firstNotNullOfOrNull { it.currentPose } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt index 572207c4a..40abe31c7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt @@ -61,11 +61,7 @@ class TimelineFeatures( val playback: Boolean = true, /** Auto-keying, for an owner that turns edits into keys while [TimelineController.isRecording] is on. */ val record: Boolean = false, -) { - companion object { - val CUTSCENE = TimelineFeatures() - } -} +) @Composable internal fun TimelineToolbar( @@ -74,7 +70,7 @@ internal fun TimelineToolbar( onSave: () -> Unit, onLoad: () -> Unit, refresh: () -> Unit, - features: TimelineFeatures = TimelineFeatures.CUTSCENE, + features: TimelineFeatures = TimelineFeatures(), ) { var menuOpen by remember { mutableStateOf(false) } var snapOpen by remember { mutableStateOf(false) } @@ -96,7 +92,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.TO_START, "timeline-start", - HollowKeys.TO_START.hint(CutsceneLang.TO_START.lang) + HollowKeys.TO_START.hint(TimelineLang.TO_START.lang) ) { controller.isPlaying = false controller.applyCurrentTime(0f) @@ -105,14 +101,14 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.PREV_KEY, "timeline-prev-key", - HollowKeys.PREVIOUS_KEY.hint(CutsceneLang.PREV_KEY.lang) + HollowKeys.PREVIOUS_KEY.hint(TimelineLang.PREV_KEY.lang) ) { if (controller.jumpToKey(-1)) refresh() } TimelineButton( if (controller.isPlaying) TimelineIcons.PAUSE else TimelineIcons.PLAY, "timeline-play", - HollowKeys.PLAY.hint(CutsceneLang.PLAY.lang), + HollowKeys.PLAY.hint(TimelineLang.PLAY.lang), active = controller.isPlaying, ) { controller.togglePlayback() @@ -121,11 +117,11 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.NEXT_KEY, "timeline-next-key", - HollowKeys.NEXT_KEY.hint(CutsceneLang.NEXT_KEY.lang) + HollowKeys.NEXT_KEY.hint(TimelineLang.NEXT_KEY.lang) ) { if (controller.jumpToKey(1)) refresh() } - TimelineButton(TimelineIcons.TO_END, "timeline-end", CutsceneLang.TO_END.lang) { + TimelineButton(TimelineIcons.TO_END, "timeline-end", TimelineLang.TO_END.lang) { controller.isPlaying = false controller.applyCurrentTime(controller.workAreaEnd) refresh() @@ -137,7 +133,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.DOPE_SHEET, "timeline-view-dope", - CutsceneLang.VIEW_DOPE_SHEET.lang, + TimelineLang.VIEW_DOPE_SHEET.lang, active = controller.viewMode == TimelineViewMode.DOPE_SHEET, ) { controller.viewMode = TimelineViewMode.DOPE_SHEET @@ -146,7 +142,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.CURVES, "timeline-view-curves", - CutsceneLang.VIEW_CURVES.lang, + TimelineLang.VIEW_CURVES.lang, active = controller.viewMode == TimelineViewMode.CURVES, ) { controller.enterCurveView() @@ -155,7 +151,7 @@ internal fun TimelineToolbar( if (controller.viewMode == TimelineViewMode.CURVES) TimelineButton( TimelineIcons.HANDLES, "timeline-curve-handles", - CutsceneLang.CURVE_HANDLES.lang, + TimelineLang.CURVE_HANDLES.lang, active = CurveHandleDisplay.all, ) { CurveHandleDisplay.all = !CurveHandleDisplay.all @@ -169,7 +165,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.RECORD, "timeline-record", - CutsceneLang.RECORD.lang, + TimelineLang.RECORD.lang, active = controller.isRecording, tags = listOf("danger"), ) { @@ -178,7 +174,7 @@ internal fun TimelineToolbar( } } if (features.capture) { - TimelineButton(TimelineIcons.CAPTURE, "timeline-capture", CutsceneLang.CAPTURE_KEYFRAME.lang) { + TimelineButton(TimelineIcons.CAPTURE, "timeline-capture", TimelineLang.CAPTURE_KEYFRAME.lang) { onCapture() refresh() } @@ -189,7 +185,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.FRAME, "timeline-frame", - (if (curves) CutsceneLang.FRAME_CURVES else CutsceneLang.FRAME_TIME).lang, + (if (curves) TimelineLang.FRAME_CURVES else TimelineLang.FRAME_TIME).lang, ) { if (curves) controller.frameCurves() controller.requestFrameTime() @@ -199,18 +195,18 @@ internal fun TimelineToolbar( TimelineSnap.enabled = !TimelineSnap.enabled } SnapFpsField() - TimelineButton(TimelineIcons.ZOOM_OUT, "timeline-zoom-out", CutsceneLang.ZOOM_OUT.lang) { + TimelineButton(TimelineIcons.ZOOM_OUT, "timeline-zoom-out", TimelineLang.ZOOM_OUT.lang) { zoomAroundCenter(controller, 1f / TimelineZoomButtonFactor) refresh() } - TimelineButton(TimelineIcons.ZOOM_IN, "timeline-zoom-in", CutsceneLang.ZOOM_IN.lang) { + TimelineButton(TimelineIcons.ZOOM_IN, "timeline-zoom-in", TimelineLang.ZOOM_IN.lang) { zoomAroundCenter(controller, TimelineZoomButtonFactor) refresh() } // Everything that doesn't fit on the bar lives in an overflow menu. Box(modifier = Modifier.onPlaced { menuAnchor = it }) { - TimelineButton(TimelineIcons.MORE, "timeline-overflow", CutsceneLang.MORE.lang, active = menuOpen) { + TimelineButton(TimelineIcons.MORE, "timeline-overflow", TimelineLang.MORE.lang, active = menuOpen) { menuOpen = !menuOpen } } @@ -221,7 +217,7 @@ internal fun TimelineToolbar( onExpandedChange = { if (!it) menuOpen = false }, items = listOfNotNull( UiDropdownItem( - CutsceneLang.DELETE_SELECTED.lang, + TimelineLang.DELETE_SELECTED.lang, icon = TimelineIcons.DELETE, enabled = controller.selectedKeyframes.isNotEmpty(), shortcut = HollowKeys.DELETE.shortcut, @@ -229,7 +225,7 @@ internal fun TimelineToolbar( controller.deleteSelectedKeyframes(); refresh() }, UiDropdownItem( - CutsceneLang.SMOOTH_SELECTED.lang, + TimelineLang.SMOOTH_SELECTED.lang, icon = TimelineIcons.SMOOTH, enabled = controller.canEditSelectedCurves, shortcut = HollowKeys.SMOOTH_KEYS.shortcut, @@ -237,8 +233,8 @@ internal fun TimelineToolbar( controller.smoothSelectedKeyframes(); refresh() }, if (!features.cameraPreview) null else UiDropdownItem( - if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_PREVIEW_ON.lang - else CutsceneLang.CAMERA_PREVIEW_OFF.lang, + if (controller.isCameraPreviewEnabled) TimelineLang.CAMERA_PREVIEW_ON.lang + else TimelineLang.CAMERA_PREVIEW_OFF.lang, icon = TimelineIcons.FILM, closeOnClick = false, separatorBefore = true, @@ -247,12 +243,12 @@ internal fun TimelineToolbar( refresh() }, if (!features.storage) null else UiDropdownItem( - CutsceneLang.SAVE.lang, + TimelineLang.SAVE.lang, icon = TimelineIcons.SAVE, separatorBefore = true, ) { onSave() }, if (!features.storage) null else UiDropdownItem( - CutsceneLang.LOAD.lang, + TimelineLang.LOAD.lang, icon = TimelineIcons.LOAD, ) { onLoad() }, ), @@ -260,7 +256,7 @@ internal fun TimelineToolbar( } } -private fun snapTooltip(): String = HollowKeys.FREE_MOVE.hint(CutsceneLang.SNAP.lang) +private fun snapTooltip(): String = HollowKeys.FREE_MOVE.hint(TimelineLang.SNAP.lang) /** How many frames a second the timeline snaps to, dragged or typed in. */ @Composable @@ -272,9 +268,9 @@ private fun SnapFpsField() { min = 1.0, max = TimelineSnap.MaxFps.toDouble(), tags = listOfNotNull("timeline-snap-fps", "off".takeUnless { TimelineSnap.enabled }), - modifier = Modifier.tooltipOnHover(CutsceneLang.SNAP_FPS.lang), + modifier = Modifier.tooltipOnHover(TimelineLang.SNAP_FPS.lang), ) { TimelineSnap.fps = it.toInt() } - Text(CutsceneLang.SNAP_UNIT.lang, tags = listOf("timeline-duration")) + Text(TimelineLang.SNAP_UNIT.lang, tags = listOf("timeline-duration")) } /** Where the playhead is, as a field: typing a time jumps there. */ @@ -286,7 +282,7 @@ private fun TimeField(controller: TimelineController, refresh: () -> Unit) { fontSize = 9f, filter = UiTextInputFilter.DECIMAL, tags = listOf("timeline-time"), - modifier = Modifier.tooltipOnHover(CutsceneLang.TIME_HINT.lang), + modifier = Modifier.tooltipOnHover(TimelineLang.TIME_HINT.lang), onChange = { typed -> val time = typed.toFloatOrNull() ?: return@TextField if (abs(time - controller.currentTime) < TimeFieldEpsilon) return@TextField @@ -337,7 +333,7 @@ internal fun TimelineHeaders( modifier = Modifier.size(width.px, TimelineRulerHeight.px), ) { Text( - CutsceneLang.TRACKS.lang, + TimelineLang.TRACKS.lang, tags = listOf("timeline-corner-label"), modifier = Modifier.align(vertical = UiAlign.CENTER), ) @@ -390,7 +386,7 @@ private fun TrackListingButton(listing: TrackListing, modifier: Modifier) { TimelineButton( TimelineIcons.FILTER, "timeline-track-listing", - CutsceneLang.TRACKS_LISTING.lang, + TimelineLang.TRACKS_LISTING.lang, active = listing.onlySelected, tags = listOf("timeline-corner-button"), ) { open = !open } @@ -401,12 +397,12 @@ private fun TrackListingButton(listing: TrackListing, modifier: Modifier) { onExpandedChange = { if (!it) open = false }, items = listOf( UiDropdownItem( - CutsceneLang.TRACKS_ONLY_SELECTED.lang, + TimelineLang.TRACKS_ONLY_SELECTED.lang, checked = listing.onlySelected, mark = UiDropdownMark.CHECKBOX, ) { listing.onlySelected = !listing.onlySelected }, - UiDropdownItem(CutsceneLang.TRACKS_SHOW_ALL.lang, separatorBefore = true, onClick = listing::showAll), - UiDropdownItem(CutsceneLang.TRACKS_HIDE_UNSELECTED.lang, onClick = listing::hideUnselected), + UiDropdownItem(TimelineLang.TRACKS_SHOW_ALL.lang, separatorBefore = true, onClick = listing::showAll), + UiDropdownItem(TimelineLang.TRACKS_HIDE_UNSELECTED.lang, onClick = listing::hideUnselected), ), ) } @@ -507,7 +503,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref RowToggle( "${row.id}-remove", TimelineIcons.CLOSE, - CutsceneLang.TRACK_REMOVE.lang, + TimelineLang.TRACK_REMOVE.lang, tags = listOf("hide-track") ) { if (property != null) { @@ -569,7 +565,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref RowToggle( "${row.id}-remove", TimelineIcons.CLOSE, - CutsceneLang.TRACK_REMOVE.lang, + TimelineLang.TRACK_REMOVE.lang, tags = listOf("hide-track") ) { hide(lane) @@ -583,7 +579,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref private fun VisibilityToggle(id: String, visible: Boolean, onClick: () -> Unit) = RowToggle( id, if (visible) TimelineIcons.EYE else TimelineIcons.EYE_OFF, - (if (visible) CutsceneLang.TRACK_HIDE else CutsceneLang.TRACK_SHOW).lang, + (if (visible) TimelineLang.TRACK_HIDE else TimelineLang.TRACK_SHOW).lang, tags = if (visible) listOf("on") else emptyList(), onClick = onClick, ) @@ -592,7 +588,7 @@ private fun VisibilityToggle(id: String, visible: Boolean, onClick: () -> Unit) private fun LockToggle(id: String, locked: Boolean, onClick: () -> Unit) = RowToggle( id, if (locked) TimelineIcons.LOCK else TimelineIcons.LOCK_OPEN, - (if (locked) CutsceneLang.TRACK_UNLOCK else CutsceneLang.TRACK_LOCK).lang, + (if (locked) TimelineLang.TRACK_UNLOCK else TimelineLang.TRACK_LOCK).lang, active = locked, onClick = onClick, ) @@ -636,7 +632,7 @@ private fun DisclosureArrow(id: String, expanded: Boolean, onClick: () -> Unit) private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: () -> Unit): List = buildList { val keys = row.curves.flatMap { it.keyframes } - add(UiDropdownItem(CutsceneLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { + add(UiDropdownItem(TimelineLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { controller.select(keys, additive = false) refresh() }) @@ -646,7 +642,7 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: if (property != null && type != null) { add( UiDropdownItem( - CutsceneLang.ROTATION_MODE.lang, + TimelineLang.ROTATION_MODE.lang, separatorBefore = true, children = RotationMode.entries.map { mode -> UiDropdownItem(rotationModeLabel(mode), checked = type.mode == mode) { @@ -661,12 +657,12 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: val hideGroup = controller.onHideGroup val group = row.group if (property != null && controller.onHideProperty != null) { - add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + add(UiDropdownItem(TimelineLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { controller.unlist(property, row.curve.takeIf { row.kind == TimelineRowKind.CHANNEL }) refresh() }) } else if (group != null && hideGroup != null) { - add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + add(UiDropdownItem(TimelineLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { hideGroup(group) refresh() }) @@ -674,7 +670,8 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: } @Composable -internal fun ToolbarButton(label: String, id: String, primary: Boolean = false, onClick: () -> Unit) = Button( +/** A text button in the timeline's look; [primary] marks the one a dialog confirms with. */ +fun ToolbarButton(label: String, id: String, primary: Boolean = false, onClick: () -> Unit) = Button( label, id = id, kind = if (primary) UiButtonKind.PRIMARY else UiButtonKind.NORMAL, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt index 72101a83a..06068b8f2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt @@ -10,8 +10,8 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint import ru.hollowhorizon.hollowengine.client.utils.lang internal fun rotationModeLabel(mode: RotationMode): String = when (mode) { - RotationMode.EULER -> CutsceneLang.ROTATION_EULER.lang - RotationMode.QUATERNION -> CutsceneLang.ROTATION_QUATERNION.lang + RotationMode.EULER -> TimelineLang.ROTATION_EULER.lang + RotationMode.QUATERNION -> TimelineLang.ROTATION_QUATERNION.lang } /** The shape of one easing preset, drawn so a curve can be picked by eye rather than by name. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt index aebc6730e..acc6ed53c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt @@ -422,7 +422,7 @@ internal fun TimelineContextMenu( else -> buildList { val curve = state.curve if (curve != null && !state.locked) { - add(UiDropdownItem(CutsceneLang.ADD_KEYFRAME.lang, icon = PulseIcon) { + add(UiDropdownItem(TimelineLang.ADD_KEYFRAME.lang, icon = PulseIcon) { controller.edit(TimelineEdits.ADD_KEY) { controller.setKey(curve, state.time, curve.valueAt(state.time, 0f)) } @@ -431,28 +431,28 @@ internal fun TimelineContextMenu( } add( UiDropdownItem( - CutsceneLang.CURVE_PRESET.lang, + TimelineLang.CURVE_PRESET.lang, icon = PresetIcon, enabled = hasCurveSelection, closeOnClick = false, ) { showPresets = true }) - add(UiDropdownItem(CutsceneLang.SMOOTH_SELECTED.lang, icon = GraphIcon, enabled = hasCurveSelection) { + add(UiDropdownItem(TimelineLang.SMOOTH_SELECTED.lang, icon = GraphIcon, enabled = hasCurveSelection) { controller.smoothSelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.COPY_SELECTED.lang, icon = CopyIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.COPY_SELECTED.lang, icon = CopyIcon, enabled = hasSelection) { controller.copySelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.CUT_SELECTED.lang, icon = CutIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.CUT_SELECTED.lang, icon = CutIcon, enabled = hasSelection) { controller.cutSelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.PASTE.lang, icon = PasteIcon, enabled = controller.canPaste) { + add(UiDropdownItem(TimelineLang.PASTE.lang, icon = PasteIcon, enabled = controller.canPaste) { controller.pasteKeyframes(state.time) refresh() }) - add(UiDropdownItem(CutsceneLang.DELETE_SELECTED.lang, icon = RemoveIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.DELETE_SELECTED.lang, icon = RemoveIcon, enabled = hasSelection) { controller.deleteSelectedKeyframes() refresh() }) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt index c3e697c16..10be3ab6b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt @@ -5,19 +5,13 @@ import kotlinx.coroutines.isActive import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.ide.CutsceneIcon import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelinePlayheadHold import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineViewMode -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSession -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneStorage -import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget -import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint -import ru.hollowhorizon.hollowengine.client.utils.lang import kotlin.math.ceil import kotlin.math.floor import kotlin.math.round @@ -28,69 +22,8 @@ private const val TimelineValuePanStep = 0.08f internal const val TimelineMinValueSpan = 0.001f internal const val TimelineMaxValueSpan = 100_000f -internal val LocalTimelineRevision = staticCompositionLocalOf { 0 } - -@Composable -fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean = true) { - session.uiRevision.value - var dialog by remember { mutableStateOf(CutsceneDialog.NONE) } - - LaunchedEffect(session) { - var lastNanos = -1L - while (isActive) { - withFrameNanos { now -> - val delta = - if (lastNanos < 0L) 0f else ((now - lastNanos) / 1_000_000_000.0).toFloat().coerceIn(0f, 0.1f) - lastNanos = now - if (session.timeline.isPlaying) session.update(delta) - if (session.timeline.curveAxis.advance(now)) session.invalidateUi() - } - } - } - - PublishInspector(source = "cutscene", key = cutsceneInspectorKey(session)) { - cutsceneInspectorTarget(session) - } - - CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { - Box(id = "cutscene-timeline-dock", mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { - HollowTimelineEditor( - controller = session.timeline, - refresh = session::invalidateUi, - onKeyInput = session::onHollowUiKey, - keyboardActive = keyboardActive, - onCapture = { session.captureFrame(session.timeline.currentTime) }, - onSave = { dialog = CutsceneDialog.SAVE }, - onLoad = { dialog = CutsceneDialog.LOAD }, - ) - - when (dialog) { - CutsceneDialog.SAVE -> SaveCutsceneDialog(session) { dialog = CutsceneDialog.NONE } - CutsceneDialog.LOAD -> LoadCutsceneDialog(session) { dialog = CutsceneDialog.NONE } - CutsceneDialog.NONE -> {} - } - } - } -} - -private fun cutsceneInspectorTarget(session: CutsceneEditorSession) = InspectorTarget( - id = "cutscene-${cutsceneInspectorKey(session)}", - title = CutsceneLang.PROPERTIES.lang, - icon = CutsceneIcon, -) { - CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { - HollowTimelineProperties(session, refresh = session::invalidateUi) - } -} - -private fun cutsceneInspectorKey(session: CutsceneEditorSession): String { - val controller = session.timeline - val keyframe = controller.selectedKeyframes.firstOrNull() - ?: return if (controller.isWorkAreaSelected) "work-area" else "none" - return "key-${controller.curveOf(keyframe)?.spec?.name}-${keyframe.time}" -} - -private enum class CutsceneDialog { NONE, SAVE, LOAD } +/** Changes whenever the edited timeline does; content drawn next to the editor reads it to stay in step. */ +val LocalTimelineRevision = staticCompositionLocalOf { 0 } @Composable fun HollowTimelineEditor( @@ -101,7 +34,7 @@ fun HollowTimelineEditor( onCapture: () -> Unit = {}, onSave: () -> Unit = {}, onLoad: () -> Unit = {}, - features: TimelineFeatures = TimelineFeatures.CUTSCENE, + features: TimelineFeatures = TimelineFeatures(), ) { val bump = refresh val scroll = rememberScrollState() @@ -485,109 +418,3 @@ internal fun Playhead(controller: TimelineController, pxPerSec: Float, rowsHeigh .inputTransparent(), ) } - -// --------------------------------------------------------------------------- -// Save / Load dialogs (lightweight overlays over the timeline dock) -// --------------------------------------------------------------------------- - -@Composable -private fun SaveCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { - var name by remember { mutableStateOf(session.playback.toData().name) } - DialogFrame(CutsceneLang.SAVE_TITLE.lang, onClose) { - Text(CutsceneLang.NAME.lang, modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted)) - TextField( - value = name, - onChange = { name = it }, - modifier = Modifier.size(100.percent, 24.px) - .background(TimelineColors.Background) - .border(1.px, TimelineColors.Border, 3f) - .padding(6.px, 2.px) - .foreground(TimelineColors.Text) - .fontSize(11f), - ) - Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(8.px).align(horizontal = UiAlign.END)) { - ToolbarButton(CutsceneLang.CANCEL.lang, "cutscene-save-cancel") { onClose() } - ToolbarButton(CutsceneLang.SAVE.lang, "cutscene-save-confirm", primary = true) { - if (name.isNotBlank()) { - session.exportCutscene("", name.trim()) - onClose() - } - } - } - } -} - -@Composable -private fun LoadCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { - val files = remember { CutsceneStorage.listFiles() } - DialogFrame(CutsceneLang.LOAD_TITLE.lang, onClose) { - if (files.isEmpty()) { - Text(CutsceneLang.NO_CUTSCENES.lang, modifier = Modifier.fontSize(11f).foreground(TimelineColors.Muted)) - } else { - Column( - modifier = Modifier.size(100.percent, UiLength.Auto) - .gap(2.px) - .scrollable(horizontal = false), - ) { - files.forEach { file -> - Box( - modifier = Modifier.size(100.percent, 24.px) - .background(TimelineColors.Background) - .border(1.px, TimelineColors.Border, 3f) - .cursor(UiCursorShape.HAND) - .onClick { - session.importCutscene(file) - onClose() - it.consume() - }, - ) { - Text( - file.removeSuffix(".${CutsceneStorage.EXTENSION}"), - modifier = Modifier.align(vertical = UiAlign.CENTER) - .margin(8.px, 0.px, 0.px, 0.px) - .fontSize(11f) - .foreground(TimelineColors.Text), - ) - } - } - } - } - Row(modifier = Modifier.size(100.percent, UiLength.Auto).align(horizontal = UiAlign.END)) { - ToolbarButton(CutsceneLang.CLOSE.lang, "cutscene-load-close") { onClose() } - } - } -} - -@Composable -internal fun DialogFrame(title: String, onClose: () -> Unit, content: HollowUiContent) { - val viewport = LocalUiViewport.current - Popup( - anchorBounds = viewport, - alignment = CenteredOnAnchor, - id = "cutscene-dialog-popup", - layer = 100, - modal = true, - onDismiss = onClose, - ) { - Column( - id = "cutscene-dialog", - modifier = Modifier.size(340.px, UiLength.Auto) - .maxSize(height = (viewport.height - 80f).coerceAtLeast(120f).px) - .background(TimelineColors.Panel) - .border(1.px, TimelineColors.Border, 6f) - .padding(14.px) - .gap(8.px) - .scrollable(horizontal = false), - ) { - Text(title, modifier = Modifier.fontSize(13f).foreground(TimelineColors.Text)) - content() - } - } -} - -private val CenteredOnAnchor = UiPopupAlignment( - anchorHorizontal = UiAlign.CENTER, - anchorVertical = UiAlign.CENTER, - popupHorizontal = UiAlign.CENTER, - popupVertical = UiAlign.CENTER, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt index a76d7f047..d0ebeb07e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt @@ -3,18 +3,8 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import androidx.compose.runtime.* import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSession import ru.hollowhorizon.hollowengine.client.ui.inspector.* import ru.hollowhorizon.hollowengine.client.utils.lang -import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f - -/** The cutscene's own fields, composed inside whichever panel the shared inspector is drawn in. */ -@Composable -internal fun HollowTimelineProperties(session: CutsceneEditorSession, refresh: () -> Unit) { - PreviewSection(session.timeline, refresh) - OriginSection(session, refresh) - TimelineSelectionFields(session.timeline, refresh) { WorldReadout(session) } -} /** * What is selected on a timeline: the keys and their curve, or the work area. @@ -35,8 +25,8 @@ fun TimelineSelectionFields( CurveSection(controller, selectedKey, selectedCurve, refresh) } InspectorButton( - if (controller.selectedKeyframes.size == 1) CutsceneLang.DELETE_KEY.lang - else CutsceneLang.DELETE_KEYS.lang, + if (controller.selectedKeyframes.size == 1) TimelineLang.DELETE_KEY.lang + else TimelineLang.DELETE_KEYS.lang, tags = listOf("danger"), ) { controller.deleteSelectedKeyframes() @@ -59,65 +49,6 @@ fun TimelineController.selectionKey(): String { return "key-${System.identityHashCode(curveOf(keyframe))}-${System.identityHashCode(keyframe)}" } -@Composable -private fun PreviewSection(controller: TimelineController, refresh: () -> Unit) { - Section(CutsceneLang.PREVIEW.lang) { - Row( - modifier = Modifier.size(100.percent, 24.px) - .alignItems(vertical = UiAlign.CENTER) - .gap(8.px) - ) { - Pill( - if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_ON.lang - else CutsceneLang.CAMERA_OFF.lang, - controller.isCameraPreviewEnabled - ) { - controller.applyCameraPreviewEnabled(!controller.isCameraPreviewEnabled) - refresh() - } - Text( - if (controller.isPlaying) CutsceneLang.PLAYING.lang else CutsceneLang.PAUSED.lang, - modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted), - ) - } - Readonly(CutsceneLang.CURRENT_TIME.lang, "%.3f s".format(controller.currentTime).replace(',', '.')) - Readonly(CutsceneLang.DURATION.lang, "%.3f s".format(controller.workAreaEnd).replace(',', '.')) - } -} - -@Composable -private fun OriginSection(session: CutsceneEditorSession, refresh: () -> Unit) { - val origin = session.playback.origin - Section(CutsceneLang.ORIGIN.lang, id = "timeline-origin-section") { - NumberRow(CutsceneLang.ORIGIN_X.lang, origin.x) { next -> - session.moveOrigin(Vec3f(next, origin.y, origin.z), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_Y.lang, origin.y) { next -> - session.moveOrigin(Vec3f(origin.x, next, origin.z), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_Z.lang, origin.z) { next -> - session.moveOrigin(Vec3f(origin.x, origin.y, next), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_YAW.lang, origin.yaw, min = -360f, max = 360f, step = 1f) { next -> - session.moveOrigin(origin.position, next, keepWorld = false) - refresh() - } - Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(6.px)) { - ToolbarButton(CutsceneLang.ORIGIN_MOVE_HERE.lang, "timeline-origin-move") { - session.originToPlayer(keepWorld = false) - refresh() - } - ToolbarButton(CutsceneLang.ORIGIN_REBASE.lang, "timeline-origin-rebase") { - session.originToPlayer(keepWorld = true) - refresh() - } - } - } -} - @Composable private fun KeyframeSection( controller: TimelineController, @@ -127,10 +58,10 @@ private fun KeyframeSection( extras: @Composable () -> Unit, ) { val count = controller.selectedKeyframes.size - val title = if (count == 1) CutsceneLang.KEYFRAME.lang else CutsceneLang.KEYFRAMES.lang(count) + val title = if (count == 1) TimelineLang.KEYFRAME.lang else TimelineLang.KEYFRAMES.lang(count) Section(title) { - Readonly(CutsceneLang.CHANNEL.lang, channelPath(controller, curve)) - NumberRow(CutsceneLang.TIME.lang, keyframe.time) { time -> + Readonly(TimelineLang.CHANNEL.lang, channelPath(controller, curve)) + NumberRow(TimelineLang.TIME.lang, keyframe.time) { time -> controller.nudgeSelectedKeyframes(snapTimelineTime(time) - keyframe.time) refresh() } @@ -139,7 +70,7 @@ private fun KeyframeSection( val channel = owner?.curves?.indexOfFirst { it === curve } ?: -1 val bounds = (if (channel >= 0) owner?.bounds(channel) else null) ?: ChannelBounds.Unbounded NumberRow( - CutsceneLang.VALUE.lang, + TimelineLang.VALUE.lang, keyframe.value, min = bounds.minimum ?: -Float.MAX_VALUE, max = bounds.maximum ?: Float.MAX_VALUE, @@ -159,7 +90,7 @@ private fun KeyframeSection( @Composable private fun DiscreteValueField(options: List, value: Float, onChange: (Float) -> Unit) { - Text(CutsceneLang.VALUE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) + Text(TimelineLang.VALUE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) PillFlow(id = "timeline-discrete-values") { options.forEachIndexed { index, option -> Pill(option.labelKey.lang, value == option.value, id = "timeline-discrete-value-$index") { @@ -174,16 +105,6 @@ private fun channelPath(controller: TimelineController, curve: ChannelCurve): St return listOfNotNull(property?.nameState, curve.name).joinToString(" / ") } -@Composable -private fun WorldReadout(session: CutsceneEditorSession) { - val pose = session.playback.currentPose - Readonly(CutsceneLang.WORLD.lang, formatVec3(pose.position)) - Readonly(CutsceneLang.WORLD_ROTATION.lang, formatVec3(pose.rotation)) -} - -private fun formatVec3(vector: Vec3f): String = - "%.2f %.2f %.2f".format(vector.x, vector.y, vector.z).replace(',', '.') - /** * Interpolation is a curve preset: picking one writes the handles that shape the segment, and the * handles can then be dragged from here or in the graph editor. @@ -200,7 +121,7 @@ private fun CurveSection( val active = CurvePresets.match(keyframe, next) var category by remember { mutableStateOf(active?.category ?: CurvePresets.categories.first()) } - Section(CutsceneLang.INTERPOLATION.lang, id = "timeline-interpolation-section") { + Section(TimelineLang.INTERPOLATION.lang, id = "timeline-interpolation-section") { active?.let { CurvePreview(it) } PillFlow(id = "timeline-preset-categories") { CurvePresets.categories.forEach { name -> @@ -217,7 +138,7 @@ private fun CurveSection( } if (keyframe.interpolation != KeyInterpolation.BEZIER) return@Section - Text(CutsceneLang.HANDLES.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) + Text(TimelineLang.HANDLES.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) PillFlow(id = "timeline-handle-modes") { HandleMode.entries.forEach { mode -> Pill(handleLabel(mode), keyframe.handleMode == mode, id = "handle-mode-${mode.name}") { @@ -228,23 +149,23 @@ private fun CurveSection( } if (keyframe.handleMode == HandleMode.AUTO) { Text( - CutsceneLang.HANDLES_AUTO_HINT.lang, + TimelineLang.HANDLES_AUTO_HINT.lang, modifier = Modifier.size(100.percent, UiLength.Fit) .fontSize(9f) .foreground(TimelineColors.Muted), ) return@Section } - TangentRow(controller, keyframe, TangentSide.INCOMING, CutsceneLang.HANDLE_IN.lang, refresh) - TangentRow(controller, keyframe, TangentSide.OUTGOING, CutsceneLang.HANDLE_OUT.lang, refresh) + TangentRow(controller, keyframe, TangentSide.INCOMING, TimelineLang.HANDLE_IN.lang, refresh) + TangentRow(controller, keyframe, TangentSide.OUTGOING, TimelineLang.HANDLE_OUT.lang, refresh) } } private fun handleLabel(mode: HandleMode): String = when (mode) { - HandleMode.AUTO -> CutsceneLang.HANDLES_AUTO.lang - HandleMode.MIRRORED -> CutsceneLang.HANDLES_MIRRORED.lang - HandleMode.ALIGNED -> CutsceneLang.HANDLES_ALIGNED.lang - HandleMode.FREE -> CutsceneLang.HANDLES_FREE.lang + HandleMode.AUTO -> TimelineLang.HANDLES_AUTO.lang + HandleMode.MIRRORED -> TimelineLang.HANDLES_MIRRORED.lang + HandleMode.ALIGNED -> TimelineLang.HANDLES_ALIGNED.lang + HandleMode.FREE -> TimelineLang.HANDLES_FREE.lang } @Composable @@ -281,8 +202,8 @@ private fun applyTangent( @Composable private fun WorkAreaSection(controller: TimelineController, refresh: () -> Unit) { - Section(CutsceneLang.WORK_AREA.lang) { - NumberRow(CutsceneLang.END.lang, controller.workAreaEnd, min = 0.1f, max = Float.POSITIVE_INFINITY) { time -> + Section(TimelineLang.WORK_AREA.lang) { + NumberRow(TimelineLang.END.lang, controller.workAreaEnd, min = 0.1f, max = Float.POSITIVE_INFINITY) { time -> controller.workAreaEnd = snapTimelineTime(time).coerceAtLeast(0.1f) if (controller.currentTime > controller.workAreaEnd) controller.applyCurrentTime(0f) refresh() @@ -292,9 +213,9 @@ private fun WorkAreaSection(controller: TimelineController, refresh: () -> Unit) @Composable private fun EmptySection() { - Section(CutsceneLang.SELECTION.lang) { + Section(TimelineLang.SELECTION.lang) { Text( - CutsceneLang.NO_SELECTION.lang, + TimelineLang.NO_SELECTION.lang, modifier = Modifier.size(100.percent, 22.px) .fontSize(11f) .foreground(TimelineColors.Muted) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt index db685b506..24eadf7c2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt @@ -278,7 +278,7 @@ internal fun UiColor.withAlpha(multiplier: Float) = * What the lanes, keys and curves are drawn with: the IDE palette of docking.hss, so the timeline * reads as part of the IDE. The chrome around them takes the same colors from timeline.hss. */ -internal object TimelineColors { +object TimelineColors { val Background = UiColor(0.110f, 0.118f, 0.133f, 1f) val Panel = UiColor(0.110f, 0.118f, 0.133f, 1f) val Row = UiColor(0.118f, 0.125f, 0.141f, 1f) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt index bcd421812..2bec33617 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt @@ -2,15 +2,16 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import androidx.compose.runtime.* import kotlinx.coroutines.isActive +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.CutsceneIcon import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineKeys -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSessions import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorStylesheet import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.utils.rl /** * What the timeline window is showing. @@ -33,6 +34,18 @@ class TimelineTarget( val overlay: HollowUiContent? = null, ) +/** + * What the timeline window shows while no editor has published a [TimelineTarget], such as the + * cutscene being authored. It draws the whole window itself. + */ +interface DefaultTimeline { + /** The timeline drawn, whose history the window's undo steps through. */ + val controller: TimelineController + + @Composable + fun Content(keyboardActive: Boolean) +} + /** * Whose timeline the shared window shows, wherever it is drawn. * @@ -42,6 +55,11 @@ object IdeTimelines { var current: TimelineTarget? by mutableStateOf(null) private set + /** Contributed by addons; the first one is shown while nothing is published. */ + val defaults = ExtensionPoints.create("hollowengine:ide/default-timelines".rl, DefaultTimeline::class) + + val default: DefaultTimeline? get() = defaults.extensions.firstOrNull() + private var owner: String? = null fun publish(source: String, target: TimelineTarget?) { @@ -74,7 +92,8 @@ fun PublishTimeline(source: String, key: Any?, target: () -> TimelineTarget?) { fun TimelineDock(keyboardActive: Boolean = true) { val target = IdeTimelines.current if (target == null) { - CutsceneTimelineDock(CutsceneEditorSessions.default, keyboardActive) + val fallback = IdeTimelines.default + if (fallback != null) fallback.Content(keyboardActive) else EmptyTimeline() return } @@ -107,7 +126,7 @@ fun TimelineDock(keyboardActive: Boolean = true) { if (!controller.hasInspectableSelection()) return@PublishInspector null InspectorTarget( id = "timeline-${target.id}-${controller.selectionKey()}", - title = CutsceneLang.PROPERTIES.lang, + title = TimelineLang.PROPERTIES.lang, icon = CutsceneIcon, ) { revision.value @@ -195,3 +214,19 @@ fun EmbeddedTimelineEditor( } private const val EmbeddedSelectionWidth = 220f + +@Composable +private fun EmptyTimeline() { + Box( + id = "ide-timeline-empty", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).background(TimelineColors.Background), + ) { + Text( + TimelineLang.EMPTY.lang, + modifier = Modifier.align(horizontal = UiAlign.CENTER, vertical = UiAlign.CENTER) + .fontSize(11f) + .foreground(TimelineColors.Muted), + ) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt similarity index 76% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt rename to runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt index 59934e573..fd7d67315 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt @@ -1,7 +1,8 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui -internal object CutsceneLang { - private const val ROOT = "hollowengine.gui.ide.cutscene." +/** Translation keys of the timeline editor. */ +object TimelineLang { + private const val ROOT = "hollowengine.gui.ide.timeline." const val PROPERTIES = ROOT + "properties" const val TRACKS = ROOT + "tracks" @@ -44,39 +45,20 @@ internal object CutsceneLang { const val ZOOM_OUT = ROOT + "action.zoom_out" const val MORE = ROOT + "action.more" - const val SAVE_TITLE = ROOT + "dialog.save_title" - const val LOAD_TITLE = ROOT + "dialog.load_title" - const val NAME = ROOT + "dialog.name" - const val NO_CUTSCENES = ROOT + "dialog.no_cutscenes" - - const val PREVIEW = ROOT + "section.preview" - const val ORIGIN = ROOT + "section.origin" const val KEYFRAME = ROOT + "section.keyframe" const val KEYFRAMES = ROOT + "section.keyframes" const val INTERPOLATION = ROOT + "section.interpolation" const val WORK_AREA = ROOT + "section.work_area" const val SELECTION = ROOT + "section.selection" - const val CAMERA_ON = ROOT + "preview.camera_on" - const val CAMERA_OFF = ROOT + "preview.camera_off" - const val PLAYING = ROOT + "preview.playing" - const val PAUSED = ROOT + "preview.paused" - const val CURRENT_TIME = ROOT + "preview.current_time" - const val DURATION = ROOT + "preview.duration" - const val TIME_HINT = ROOT + "preview.time_hint" + /** Shown when nothing with a timeline is open. */ + const val EMPTY = ROOT + "empty" - const val ORIGIN_X = ROOT + "origin.x" - const val ORIGIN_Y = ROOT + "origin.y" - const val ORIGIN_Z = ROOT + "origin.z" - const val ORIGIN_YAW = ROOT + "origin.yaw" - const val ORIGIN_MOVE_HERE = ROOT + "origin.move_here" - const val ORIGIN_REBASE = ROOT + "origin.rebase" + const val TIME_HINT = ROOT + "preview.time_hint" const val TIME = ROOT + "keyframe.time" const val VALUE = ROOT + "keyframe.value" const val CHANNEL = ROOT + "keyframe.channel" - const val WORLD = ROOT + "keyframe.world" - const val WORLD_ROTATION = ROOT + "keyframe.world_rotation" const val DELETE_KEY = ROOT + "keyframe.delete_key" const val DELETE_KEYS = ROOT + "keyframe.delete_keys" const val NO_SELECTION = ROOT + "keyframe.no_selection" @@ -101,4 +83,4 @@ internal object CutsceneLang { const val ROTATION_MODE = ROOT + "rotation.mode" const val ROTATION_EULER = ROOT + "rotation.euler" const val ROTATION_QUATERNION = ROOT + "rotation.quaternion" - } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt index a61d8e85e..986e6b5ad 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt @@ -230,12 +230,9 @@ private fun rankAgainst(candidates: List, prefix: String): Lis candidates.asSequence() .filter { it.label.isNotBlank() || it.insertText.isNotBlank() } .mapIndexedNotNull { index, item -> - val filterMatch = matchCompletion(prefix, item.filterText) - val labelMatch = if (item.filterText == item.label) filterMatch else matchCompletion(prefix, item.label) - val match = filterMatch ?: labelMatch ?: return@mapIndexedNotNull null - val displayRanges = labelMatch?.ranges ?: filterMatch?.ranges - ?.shiftInto(item.filterText, item.label) - .orEmpty() + val match = matchCompletion(prefix, item.filterText) ?: return@mapIndexedNotNull null + val displayRanges = if (item.filterText == item.label) match.ranges + else match.ranges.shiftInto(item.filterText, item.label) RankedCompletion(item.copy(matchRanges = displayRanges), match.score, index) } .sortedWith( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt index 0fa1d6a2f..76ababa46 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.common.addons import kotlinx.coroutines.runBlocking +import ru.hollowhorizon.hollowengine.bootstrap.runtime.AddonBootstrapContract import ru.hollowhorizon.hollowengine.common.files.DirectoryManager import java.io.File import kotlin.reflect.KClass @@ -23,8 +24,8 @@ object HollowAddonManager : AutoCloseable { get() = runtime?.let { addonRuntime -> runBlocking { addonRuntime.statuses() } }.orEmpty() /** - * Loaded addons from `hollowengine/addons` that carry assets or data, highest priority first. Addons - * in `mods` are left out: their loader already serves their resources. + * Loaded addons that carry assets or data, highest priority first. Addons in `mods` are left out: + * their loader already serves their resources. */ val resourcePacks: List get() = runtime?.resourcePackSnapshot.orEmpty() @@ -35,6 +36,8 @@ object HollowAddonManager : AutoCloseable { HollowAddonRuntime( sources = sources, cacheDirectory = cacheDirectory, + addonsDirectory = addonsDirectory, + modsDirectory = modsDirectory, ).also { runtime = it } } runCatching { runBlocking { created.start() } } @@ -47,10 +50,22 @@ object HollowAddonManager : AutoCloseable { .getOrThrow() } - /** Where addons are looked for, highest priority first. */ - internal fun defaultSources(): List = listOf( - DirectoryManager.HOLLOW_ENGINE.resolve("addons").toFile(), - File("mods").absoluteFile, + /** `hollowengine/addons`, which also keeps the list of addons switched off. */ + internal val addonsDirectory: File + get() = DirectoryManager.HOLLOW_ENGINE.resolve("addons").toFile() + + /** Addons here are mods as well, so the mod loader serves their resources. */ + internal val modsDirectory: File + get() = File("mods").absoluteFile + + /** + * Where addons are looked for, highest priority first. A development run puts the addons the + * Gradle build has just made ahead of the rest, so stale copies elsewhere never win. + */ + internal fun defaultSources(): List = listOfNotNull( + AddonBootstrapContract.developmentAddonsDirectory(), + addonsDirectory, + modsDirectory, ) internal val cacheDirectory: File diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index 9bd75c7f9..233b08907 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -22,8 +22,9 @@ import java.net.URLClassLoader internal class HollowAddonRuntime( private val sources: List, cacheDirectory: File, + private val addonsDirectory: File, + private val modsDirectory: File, ) { - private val addonsDirectory = sources.first() private val mutex = Mutex() private val runtimeJob = SupervisorJob() private val runtimeScope = CoroutineScope(Dispatchers.IO + runtimeJob + CoroutineName("HollowEngine addons")) @@ -616,11 +617,13 @@ internal class HollowAddonRuntime( private fun refreshSnapshot() { loadedSnapshot = loadedAddons.values.map { it.candidate.descriptor } - val addonsFolder = addonsDirectory.canonicalFile + val modsFolder = modsDirectory.canonicalFile resourcePackSnapshot = loadedAddons.values.toList().asReversed() .map(LoadedHollowAddon::candidate) .filter { candidate -> - (candidate.hasAssets || candidate.hasData) && candidate.sourceFile.parentFile?.canonicalFile == addonsFolder + // The mod loader already serves mods and the addons embedded in them. + (candidate.hasAssets || candidate.hasData) && candidate.embeddedModId == null && + candidate.sourceFile.parentFile?.canonicalFile != modsFolder } .map { candidate -> HollowAddonResourcePack( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt index 625412002..f1a06ebd1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt @@ -43,6 +43,14 @@ interface ScriptingAnalyzer { * Alt+Enter actions at the caret, such as import suggestions. */ fun intentionActions(name: String, text: String, offset: Int): List = emptyList() + + /** + * Which of [types], fully qualified class names as scripts see them, an expression typed at [offset] + * may have; used by completions that insert a whole expression. Null when [offset] is not where an + * expression starts. A language that cannot tell accepts every type. + */ + fun acceptedExpressionTypes(name: String, text: String, offset: Int, types: List): List? = types + fun signatureHelp(name: String, text: String, offset: Int): SignatureHelp? = null fun hover(name: String, text: String, offset: Int): HoverInfo? = null fun diagnostic(name: String, text: String): List diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt index 830515f9c..18fccde08 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt @@ -72,7 +72,8 @@ internal object MixinScriptStage { private class Addons { val store = HollowAddonArtifactStore(HollowAddonManager.cacheDirectory) private val directories = HollowAddonManager.defaultSources() - private val disabled = HollowAddonActivationStore(directories.first().resolve(".disabled-addons")).load() + private val disabled = + HollowAddonActivationStore(HollowAddonManager.addonsDirectory.resolve(".disabled-addons")).load() private val jars = directories.mapIndexed { priority, directory -> priority to HollowAddonProbe.listAddonJars(directory) } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/history.json b/runtime/src/main/lang/en_us/hollowengine/gui/history.json index 5b6112a66..8a3b36418 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/history.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/history.json @@ -32,7 +32,5 @@ "timeline.curve_preset": "Apply curve preset", "timeline.rotation_basis": "Change rotation basis", "timeline.record_keys": "Record keys", - "timeline.capture_key": "Capture keyframe", - "timeline.move_cutscene": "Move cutscene", - "timeline.reanchor_cutscene": "Re-anchor cutscene" + "timeline.capture_key": "Capture keyframe" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json similarity index 78% rename from runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json rename to runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json index ebe25a6ba..3dbb428d8 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json @@ -27,34 +27,14 @@ "action.load": "Load…", "action.cancel": "Cancel", "action.close": "Close", - "dialog.save_title": "Save cutscene", - "dialog.load_title": "Load cutscene", - "dialog.name": "Name", - "dialog.no_cutscenes": "No saved cutscenes", - "section.preview": "Preview", - "section.origin": "Origin", "section.keyframe": "Keyframe", "section.keyframes": "%s Keyframes", "section.interpolation": "Interpolation", "section.work_area": "Work Area", "section.selection": "Selection", - "preview.camera_on": "Camera On", - "preview.camera_off": "Camera Off", - "preview.playing": "Playing", - "preview.paused": "Paused", - "preview.current_time": "Current time", - "preview.duration": "Duration", - "origin.x": "Origin X", - "origin.y": "Origin Y", - "origin.z": "Origin Z", - "origin.yaw": "Yaw", - "origin.move_here": "Move here", - "origin.rebase": "Re-anchor", "keyframe.time": "Time", "keyframe.value": "Value", "keyframe.channel": "Channel", - "keyframe.world": "World", - "keyframe.world_rotation": "World rotation", "keyframe.delete_key": "Delete key", "keyframe.delete_keys": "Delete keys", "keyframe.no_selection": "No keyframe selected", @@ -70,9 +50,6 @@ "rotation.mode": "Rotation basis", "rotation.euler": "Euler", "rotation.quaternion": "Quaternion", - "weather.clear": "Clear", - "weather.rain": "Rain", - "weather.thunder": "Thunder", "track.select_keys": "Select its keys", "track.show": "Turn on: the track drives its value again", "track.hide": "Turn off: the track keeps its keys but no longer drives its value", @@ -88,5 +65,6 @@ "action.zoom_out": "Zoom out (Ctrl+wheel)", "action.more": "More", "view.frame_time": "Fit the work area (F)", - "preview.time_hint": "Where the playhead is; type a time to jump there" + "preview.time_hint": "Where the playhead is; type a time to jump there", + "empty": "Open a montage, an effect or another file with a timeline to edit it here." } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json index 3bc768202..11d253fd9 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json @@ -32,7 +32,5 @@ "timeline.curve_preset": "Пресет кривой", "timeline.rotation_basis": "Смена базиса вращения", "timeline.record_keys": "Запись ключей", - "timeline.capture_key": "Захват ключа", - "timeline.move_cutscene": "Перемещение катсцены", - "timeline.reanchor_cutscene": "Перепривязка катсцены" + "timeline.capture_key": "Захват ключа" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json similarity index 78% rename from runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json rename to runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json index 5583c6af8..b7a60d866 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json @@ -27,34 +27,14 @@ "action.load": "Загрузить…", "action.cancel": "Отмена", "action.close": "Закрыть", - "dialog.save_title": "Сохранение катсцены", - "dialog.load_title": "Загрузка катсцены", - "dialog.name": "Название", - "dialog.no_cutscenes": "Сохранённых катсцен нет", - "section.preview": "Предпросмотр", - "section.origin": "Начало координат", "section.keyframe": "Ключевой кадр", "section.keyframes": "Ключевых кадров: %s", "section.interpolation": "Интерполяция", "section.work_area": "Рабочая область", "section.selection": "Выделение", - "preview.camera_on": "Камера вкл", - "preview.camera_off": "Камера выкл", - "preview.playing": "Воспроизведение", - "preview.paused": "Пауза", - "preview.current_time": "Текущее время", - "preview.duration": "Длительность", - "origin.x": "Начало X", - "origin.y": "Начало Y", - "origin.z": "Начало Z", - "origin.yaw": "Поворот", - "origin.move_here": "Перенести сюда", - "origin.rebase": "Перепривязать", "keyframe.time": "Время", "keyframe.value": "Значение", "keyframe.channel": "Канал", - "keyframe.world": "В мире", - "keyframe.world_rotation": "Поворот в мире", "keyframe.delete_key": "Удалить ключ", "keyframe.delete_keys": "Удалить ключи", "keyframe.no_selection": "Ключевой кадр не выбран", @@ -70,9 +50,6 @@ "rotation.mode": "Представление поворота", "rotation.euler": "Эйлер", "rotation.quaternion": "Кватернион", - "weather.clear": "Ясно", - "weather.rain": "Дождь", - "weather.thunder": "Гроза", "track.select_keys": "Выделить ключи", "track.show": "Включить: дорожка снова управляет значением", "track.hide": "Отключить: ключи остаются, но дорожка больше не управляет значением", @@ -88,5 +65,6 @@ "action.zoom_out": "Отдалить (Ctrl+колесо)", "action.more": "Ещё", "view.frame_time": "Вписать рабочую область (F)", - "preview.time_hint": "Где стоит курсор; введите время, чтобы перейти" + "preview.time_hint": "Где стоит курсор; введите время, чтобы перейти", + "empty": "Откройте монтаж, эффект или другой файл с таймлайном, чтобы редактировать его здесь." } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt index 7145dbd4e..d7f9e8a95 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt @@ -25,6 +25,13 @@ class IdeContextCompletionsTest { assertEquals(1, contextSnippetCompletions(Vec3.ZERO, null, 0).size) } + @Test + fun `snippets are left out where their type does not fit`() { + val onlyItems = contextSnippetCompletions(Vec3.ZERO, "minecraft:diamond", 1, listOf(ItemSnippetType)) + assertEquals(listOf("item(\"minecraft:diamond\", 1)"), onlyItems.map { it.insertText }) + assertTrue(contextSnippetCompletions(Vec3.ZERO, "minecraft:diamond", 1, emptyList()).isEmpty()) + } + @Test fun `item specifications retain changed and removed components and escape Kotlin strings`() { val patch = CompoundTag().apply { diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt new file mode 100644 index 000000000..f2f1809c3 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt @@ -0,0 +1,15 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TimeOfDayValueFormatterTest { + @Test + fun `time of day formatter follows the minecraft clock`() { + assertEquals("06:00", TimeOfDayValueFormatter.format(0f)) + assertEquals("12:00", TimeOfDayValueFormatter.format(6_000f)) + assertEquals("00:00", TimeOfDayValueFormatter.format(18_000f)) + assertEquals("05:59", TimeOfDayValueFormatter.format(23_999f)) + assertEquals("07:00", TimeOfDayValueFormatter.format(25_000f)) + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt new file mode 100644 index 000000000..65cc867c4 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui + +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSampling +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSpec +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.FloatPropertyType +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.PropertyType +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController +import ru.hollowhorizon.hollowengine.common.utils.Color +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TimelineRowsTest { + @Test + fun `discrete tracks are omitted from curve editor rows`() { + val timeline = TimelineController() + val continuous = timeline.addProperty(listOf("node"), AnimProperty("size", "Size", FloatPropertyType(), 1f)) + val discrete = timeline.addProperty(listOf("node"), AnimProperty("mode", "Mode", StepPropertyType, 0f)) + + val rows = timelineRows(timeline, curveEditorOnly = true) + + assertTrue(rows.any { it.property === continuous }) + assertFalse(rows.any { it.property === discrete }) + } + + private object StepPropertyType : PropertyType { + override val id = "step" + override val channels = listOf(ChannelSpec("Mode", Color("FFFFFF"), sampling = ChannelSampling.DISCRETE)) + + override fun decompose(value: Float, into: FloatArray) { + into[0] = value + } + + override fun compose(values: FloatArray): Float = values[0] + } +} From 4adaf76fa3c9ff1a94609c3eec8d67271122a291 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Wed, 21 Oct 2026 14:40:08 +0300 Subject: [PATCH 03/25] Stabilize shared script identity and loading --- addons/compiler/build.gradle.kts | 1 + .../common/ScriptingEnvironmentFactory.kt | 18 +- .../common/compiler/CompiledScript.kt | 8 +- .../configuration/SharedScriptValidation.kt | 28 +++ .../AnnotationFirScriptJvmCompiler.kt | 17 +- .../isolated/ScriptingCompilerRemapped.kt | 73 ++++---- .../ide/session/ScriptingAnalyzerImpl.kt | 8 +- .../common/ide/session/SharedScriptPolicy.kt | 28 +++ .../kotlin/ScriptDefinitionsLifecycleTest.kt | 99 +++++++++++ .../test/kotlin/SharedScriptIdentityTest.kt | 159 +++++++++++++++++ .../narrative/cutscene/CutsceneActions.kt | 5 +- .../narrative/dialogue/StoryEffectPackets.kt | 9 +- .../scripting/DefaultScriptDefinitions.kt | 1 + .../scripting/cache/ScriptFingerprint.kt | 2 +- .../compiling/KotlinCompiledScriptJar.kt | 13 +- .../compiling/SharedScriptClasses.kt | 164 ++++++++++-------- .../scripting/compiling/SharedScriptPolicy.kt | 6 + .../common/scripting/source/ScriptRegistry.kt | 8 + runtime/src/test/kotlin/ScriptSourceTests.kt | 16 ++ .../test/kotlin/SharedScriptClassesTest.kt | 79 +++++++++ 20 files changed, 604 insertions(+), 138 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt create mode 100644 addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt create mode 100644 addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt create mode 100644 runtime/src/test/kotlin/SharedScriptClassesTest.kt diff --git a/addons/compiler/build.gradle.kts b/addons/compiler/build.gradle.kts index 8ad4820ec..dbaf3368f 100644 --- a/addons/compiler/build.gradle.kts +++ b/addons/compiler/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { runtimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) testImplementation(kotlin("test")) + testImplementation("io.insert-koin:koin-core:${rootProject.property("koinVersion")}") testImplementation("androidx.compose.runtime:runtime:$composeRuntimeVersion") testRuntimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) testRuntimeOnly(files(compilerPluginRuntimeJar.flatMap { it.archiveFile })) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt index 7829594de..ba20ea189 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt @@ -35,7 +35,10 @@ import kotlin.script.experimental.jvm.JvmDependency import kotlin.script.experimental.jvm.JvmGetScriptingClass import kotlin.script.experimental.jvm.defaultJvmScriptingHostConfiguration -class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, HollowAddonEntrypoint { +class ScriptingEnvironmentInitializerImpl( + private val prepareClasspath: (File) -> Pair> = CommonEnvironment::setup, + private val definitions: () -> List = DefaultScriptDefinitions::providers, +) : ScriptingEnvironmentInitializer, HollowAddonEntrypoint { @Volatile private var loaded: LoadedAddon? = null @@ -44,7 +47,6 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { loaded = LoadedAddon(context.addonFile, scope) - setUp(context.addonFile, scope) context.hostServices.publish(this) } @@ -70,16 +72,20 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol @Synchronized fun onAddonsChanged(event: HollowAddonsChangedEvent) { val addon = loaded ?: return - val current = built ?: return - val scriptTypes = DefaultScriptDefinitions.providers() + val current = built ?: run { + setUp(addon.file, addon.scope) + return + } + val scriptTypes = definitions() if (scriptTypes == current.scriptTypes) return + logI("Reloading script definitions after addon imports or script types changed") ScriptingEnvironment.clear() start(addon, current.mappings, current.classpath, scriptTypes) } private fun setUp(addonFile: File, scope: CoroutineScope) { - val (mappings, classpath) = CommonEnvironment.setup(addonFile) - start(LoadedAddon(addonFile, scope), mappings, classpath, DefaultScriptDefinitions.providers()) + val (mappings, classpath) = prepareClasspath(addonFile) + start(LoadedAddon(addonFile, scope), mappings, classpath, definitions()) } private fun start( diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt index 16ecdd190..49448ae42 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt @@ -13,15 +13,19 @@ import kotlin.script.experimental.api.ScriptCompilationConfiguration import kotlin.script.experimental.api.ScriptEvaluationConfiguration import kotlin.script.experimental.api.implicitReceivers import kotlin.script.experimental.api.with +import kotlin.script.experimental.api.valueOr +import kotlin.script.experimental.api.CompiledScript as KotlinCompiledScript data class CompiledScriptImpl( override val name: String, - val script: kotlin.script.experimental.api.CompiledScript, + val script: KotlinCompiledScript, val evalConfiguration: ScriptEvaluationConfiguration, ) : CompiledScript { private val scriptClassDelegate = lazy { runScriptingBlocking { - (script.getClass(evalConfiguration) as ResultWithDiagnostics.Success).value + script.getClass(evalConfiguration).valueOr { failure -> + throw ScriptEvaluationException(name, failure.reports.map { it.convert() }) + } } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt new file mode 100644 index 000000000..401271050 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.compiler.configuration + +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptImportError +import org.jetbrains.kotlin.scripting.resolve.resolvedImportScripts +import kotlin.script.experimental.api.ScriptCompilationConfiguration +import kotlin.script.experimental.api.ScriptDiagnostic +import kotlin.script.experimental.api.SourceCode + +internal fun sharedImportDiagnostics( + sources: List, + configuration: (SourceCode) -> ScriptCompilationConfiguration, +): List = buildList { + sources.forEach { script -> + val configured = configuration(script) + if (configured[ScriptCompilationConfiguration.isSharedScript] != true) return@forEach + configured[ScriptCompilationConfiguration.resolvedImportScripts].orEmpty().filter { + configuration(it)[ScriptCompilationConfiguration.isSharedScript] != true + }.forEach { dependency -> + add(ScriptDiagnostic( + ScriptDiagnostic.unspecifiedError, + sharedScriptImportError(script.locationId ?: script.name.orEmpty(), dependency.locationId ?: dependency.name.orEmpty()), + ScriptDiagnostic.Severity.ERROR, + script.locationId, + )) + } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt index 043ab7ad9..c7c65537b 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt @@ -39,7 +39,6 @@ import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.name.NameUtils import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.jetbrains.kotlin.scripting.compiler.plugin.configureFirSession -import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.getOrStoreRefinedCompilationConfiguration import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.getRefinedOrBaseCompilationConfiguration import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.scriptRefinedCompilationConfigurationsCache import org.jetbrains.kotlin.scripting.compiler.plugin.dependencies.collectScriptsCompilationDependenciesRecursively @@ -53,6 +52,8 @@ import org.jetbrains.kotlin.scripting.compiler.plugin.impl.makeCompiledScript import org.jetbrains.kotlin.scripting.compiler.plugin.impl.refineAllForK2 import org.jetbrains.kotlin.scripting.compiler.plugin.impl.selectJvmTarget import org.jetbrains.kotlin.scripting.compiler.plugin.impl.updateWithCompilerOptions +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition +import ru.hollowhorizon.hollowengine.common.compiler.configuration.sharedImportDiagnostics import org.jetbrains.kotlin.utils.addToStdlib.firstIsInstanceOrNull import kotlin.script.experimental.api.CompiledScript import kotlin.script.experimental.api.ResultWithDiagnostics @@ -83,6 +84,7 @@ import kotlin.script.experimental.jvm.util.toClassPathOrEmpty internal class AnnotationFirScriptJvmCompiler( state: K2ScriptingCompilerEnvironment, private val convertToFir: SourceCode.(FirSession, BaseDiagnosticsCollector) -> FirFile, + private val definitions: List, ) { private val state = state as? K2ScriptingCompilerEnvironmentInternal ?: error("Expected the internal K2 scripting compiler environment, got ${state::class}") @@ -160,12 +162,21 @@ internal class AnnotationFirScriptJvmCompiler( val allSourceFiles = mutableListOf(script) val (classpath, importedSources, sourceDependencies) = collectScriptsCompilationDependenciesRecursively(allSourceFiles) { importedScript -> - state.hostConfiguration.getOrStoreRefinedCompilationConfiguration(importedScript) { source, baseConfig -> - baseConfig.refineAll(source) + if (importedScript == script) scriptRefinedCompilationConfiguration.asSuccess() + else { + val definition = definitions.filter { importedScript.name?.endsWith(it.fileExtension) == true } + .maxByOrNull { it.fileExtension.length } + val refined = (definition?.compilationConfiguration ?: state.baseScriptCompilationConfiguration) + .refineAll(importedScript) + state.hostConfiguration[ScriptingHostConfiguration.scriptRefinedCompilationConfigurationsCache] + ?.storeRefinedCompilationConfiguration(importedScript, refined) + refined } }.valueOr { return it } allSourceFiles.addAll(importedSources) val compilationSources = importedSources + script + val sharedErrors = sharedImportDiagnostics(compilationSources, ::getRefinedConfiguration) + if (sharedErrors.isNotEmpty()) return failure(reporting, reporting.diagnosticsCollector, *sharedErrors.toTypedArray()) val ignoredOptionsReportingState = state.compilerContext.ignoredOptionsReportingState val updatedCompilerOptions = allSourceFiles.flatMapTo(mutableListOf()) { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt index 2ae79b84f..1d8513209 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt @@ -19,6 +19,12 @@ import ru.hollowhorizon.hollowengine.common.compiler.createHollowEngineCompilerP import ru.hollowhorizon.hollowengine.common.config.HollowEngineConfig import ru.hollowhorizon.hollowengine.common.files.DirectoryManager import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses +import ru.hollowhorizon.hollowengine.common.scripting.compiling.MemoryClassLoader +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptId +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptFingerprint +import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import ru.hollowhorizon.hollowengine.common.scripting.deobf.NeoForgeEnvironmentSetup import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.ClassRemappingSession import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.RemappingClasspath @@ -29,10 +35,7 @@ import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinSpecWriter import ru.hollowhorizon.hollowengine.common.scripting.compiling.isClientSideScript import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings import ru.hollowhorizon.hollowengine.common.utils.RuntimeFlags -import java.io.ByteArrayInputStream -import java.io.InputStream import java.io.Serializable -import java.security.ProtectionDomain import kotlin.script.experimental.api.* import kotlin.script.experimental.host.ScriptingHostConfiguration import kotlin.script.experimental.jvm.compilationCache @@ -110,6 +113,21 @@ class ScriptJvmCompilerRemapped( val jvmScript = script as? KJvmCompiledScript ?: return script val module = jvmScript.getCompiledModule() as? KJvmCompiledModuleInMemory ?: return jvmScript val outputFiles = module.compilerOutputFiles + extraFiles + val configuration = jvmScript.compilationConfiguration.with { + if (jvmScript.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] == true) { + val id = jvmScript.sourceLocationId?.let(ScriptRegistry::idOfLocation) + val fingerprint = id?.let(ScriptFingerprint::compute) + if (id != null && fingerprint != null) { + sharedScriptId(id.qualified) + sharedScriptFingerprint(fingerprint.code) + } + } + } + val imports = jvmScript.otherScripts.map { remapCompiledScript(it) } + val metadata = KJvmCompiledScript( + jvmScript.sourceLocationId, configuration, jvmScript.scriptClassFQName, + jvmScript.resultField, imports, null, + ) val remappingSession = if (remapToRuntime && RuntimeFlags.production && !NeoForgeEnvironmentSetup.isAvailable()) { ClassRemappingSession( mappings(), @@ -124,14 +142,14 @@ class ScriptJvmCompilerRemapped( debugSave(path, bytes) if (path.endsWith(".class")) remappingSession?.remap(bytes) ?: bytes else bytes }, - jvmScript.otherScripts, + listOf(metadata), ) return KJvmCompiledScript( jvmScript.sourceLocationId, - jvmScript.compilationConfiguration, + configuration, jvmScript.scriptClassFQName, jvmScript.resultField, - jvmScript.otherScripts.map { remapCompiledScript(it) }, + imports, remappedModule, ) } @@ -168,6 +186,7 @@ class HollowEngineScriptCompiler( ): ResultWithDiagnostics = withMessageCollector { messageCollector -> withScriptCompilationCache(script, scriptCompilationConfiguration, messageCollector) { withConfiguredK2ScriptCompilerWithLightTree( + definitions, scriptCompilationConfiguration.with { hostConfiguration(this@HollowEngineScriptCompiler.hostConfiguration) }, @@ -193,6 +212,7 @@ class HollowEngineScriptCompiler( } private fun withConfiguredK2ScriptCompilerWithLightTree( + definitions: List, scriptCompilationConfiguration: ScriptCompilationConfiguration, parentMessageCollector: MessageCollector? = null, configureCompiler: CompilerConfiguration.() -> Unit = {}, @@ -209,7 +229,8 @@ private fun withConfiguredK2ScriptCompilerWithLightTree( ?: defaultJvmScriptingHostConfiguration, configureCompiler ), - SourceCode::convertToFirViaLightTree + SourceCode::convertToFirViaLightTree, + definitions, ) ) } finally { @@ -246,40 +267,8 @@ private class RemappedCompiledModule( ) : KJvmCompiledModuleInMemory, Serializable { override fun createClassLoader(baseClassLoader: ClassLoader?): ClassLoader { val parent = baseClassLoader ?: HollowEngine::class.java.classLoader - return RemappedCompiledScriptClassLoader( - compilerOutputFiles, - parent, - SharedScriptClasses.loadersFor(imports, compilerOutputFiles, parent), - ) - } -} - -private class RemappedCompiledScriptClassLoader( - private val files: Map, - parent: ClassLoader?, - private val shared: Map, -) : ClassLoader(parent) { - override fun loadClass(name: String, resolve: Boolean): Class<*> { - val owner = sharedOwnerOf(name) ?: return super.loadClass(name, resolve) - return owner.loadClass(name).also { if (resolve) resolveClass(it) } - } - - /** Nested and synthetic classes belong to the script that declared them: `Quests$Payload` to `Quests`. */ - private fun sharedOwnerOf(name: String): ClassLoader? { - if (shared.isEmpty()) return null - shared[name]?.let { return it } - val outer = name.substringBefore('$') - return if (outer == name) null else shared[outer] - } - - override fun findClass(name: String): Class<*> { - val path = name.replace('.', '/') + ".class" - val bytes = files[path] ?: throw ClassNotFoundException(name) - val protectionDomain = ProtectionDomain(null, null) - return defineClass(name, bytes, 0, bytes.size, protectionDomain) - } - - override fun getResourceAsStream(name: String): InputStream? { - return files[name]?.let(::ByteArrayInputStream) + return MemoryClassLoader(compilerOutputFiles, parent).apply { + shareClassesOf(SharedScriptClasses.loadersFor(imports, compilerOutputFiles, parent)) + } } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt index 7cd2dddf4..c2c6695bc 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt @@ -33,6 +33,7 @@ import ru.hollowhorizon.hollowengine.common.ide.session.structure.kotlinGutterTa import ru.hollowhorizon.hollowengine.common.ide.session.symbols.searchSymbols import ru.hollowhorizon.hollowengine.common.ide.session.symbols.symbolSource import ru.hollowhorizon.hollowengine.common.scripting.ide.* +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptImportError import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -232,7 +233,11 @@ class ScriptingAnalyzerImpl internal constructor( ) return@argumentLoop } - importedFiles += createPsiFile(importedName, importedText, files, activeFiles) + val importedFile = createPsiFile(importedName, importedText, files, activeFiles) + if (file.isSharedScript() && !importedFile.isSharedScript()) { + diagnostics += importDiagnostic(file, expression, sharedScriptImportError(file.name, importedName)) + } + importedFiles += importedFile } } return ImportResolution(importedFiles, diagnostics) @@ -255,6 +260,7 @@ class ScriptingAnalyzerImpl internal constructor( /** The path [HollowIdeModel][ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeModel] opens files by. */ private fun editorPathOf(file: File): String { + ScriptRegistry.idOf(file)?.takeIf { it.namespace != ScriptRegistry.sandboxNamespace }?.let { return it.qualified } val root = DirectoryManager.HOLLOW_ENGINE.toAbsolutePath().normalize() val path = file.toPath().toAbsolutePath().normalize() if (!path.startsWith(root)) return file.name diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt new file mode 100644 index 000000000..1e1310295 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.ide.session + +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinitionProvider +import org.jetbrains.kotlin.scripting.resolve.KtFileScriptSource +import ru.hollowhorizon.hollowengine.common.scripting.annotations.SharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import kotlin.script.experimental.api.ScriptCompilationConfiguration + +internal fun KtFile.isSharedScript(): Boolean { + val definition = ScriptDefinitionProvider.getInstance(project)?.findDefinition(KtFileScriptSource(this)) + if (definition?.compilationConfiguration?.get(ScriptCompilationConfiguration.isSharedScript) == true) return true + val marker = SharedScript::class.qualifiedName!! + val aliases = importDirectives.mapNotNull { directive -> + directive.importPath?.takeIf { it.fqName.asString() == marker } + ?.let { directive.aliasName ?: it.fqName.shortName().asString() } + }.toMutableSet() + val shadowed = importDirectives.any { directive -> + val path = directive.importPath ?: return@any false + !path.isAllUnder && (directive.aliasName ?: path.fqName.shortName().asString()) == "SharedScript" && + path.fqName.asString() != marker + } + if (!shadowed) aliases += "SharedScript" + return annotationEntries.any { entry -> + val name = entry.typeReference?.text?.replace("`", "") + name == marker || name in aliases + } +} diff --git a/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt new file mode 100644 index 000000000..e341400af --- /dev/null +++ b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt @@ -0,0 +1,99 @@ +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import org.koin.core.Koin +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentInitializerImpl +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonDescriptor +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonHostServices +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonsChangedEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefaultImportsEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefinitionsEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironmentInitializer +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import java.io.File +import java.util.jar.JarOutputStream +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +abstract class LifecycleScript + +class ScriptDefinitionsLifecycleTest { + @Test + fun `compiler waits for addon batch and refreshes late definitions without remapping`(@TempDir directory: File) = runBlocking { + val scope = CoroutineScope(Job()) + val addonScope = CoroutineScope(Job()) + val classpath = System.getProperty("java.class.path").split(File.pathSeparator).map(::File).filter(File::exists) + var preparations = 0 + val initializer = ScriptingEnvironmentInitializerImpl( + prepareClasspath = { preparations++; Mappings.EMPTY to classpath }, + definitions = { + val providers = mutableListOf(ScriptClassProvider("kts", LifecycleScript::class.qualifiedName!!)) + ScriptDefinitionsEvent.post(ScriptDefinitionsEvent(providers)) + providers.map { provider -> + val imports = provider.defaultImports.toMutableList() + ScriptDefaultImportsEvent.post(ScriptDefaultImportsEvent(provider.extension, imports)) + provider.copy(defaultImports = imports.distinct()) + } + }, + ) + val compilerJar = directory.resolve("compiler.jar").also { JarOutputStream(it.outputStream()).close() } + val services = TestHostServices() + val context = HollowAddonContext( + services, HollowAddonDescriptor("compiler", "test", null), compilerJar, javaClass.classLoader, Koin(), + ) + try { + ScriptingEnvironment.clear() + initializer.load(context, scope) + HollowAddonsChangedEvent.subscribe(scope) { initializer.onAddonsChanged(it) } + assertEquals(0, preparations) + assertFalse(ScriptingEnvironment.isAvailable()) + assertSame(initializer, services.findService(ScriptingEnvironmentInitializer::class)) + + ScriptDefaultImportsEvent.subscribe(addonScope) { it.defaultImports += "java.time.Instant" } + ScriptDefinitionsEvent.subscribe(addonScope) { + it.providers += ScriptClassProvider("addon.kts", LifecycleScript::class.qualifiedName!!) + } + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + val first = ScriptingEnvironment.INSTANCE + assertTrue(first.compiler.compile("first.addon.kts", "val value = Instant.EPOCH").isSuccess) + assertTrue(first.analyzer.diagnostic("first.addon.kts", "val value = Instant.EPOCH").isEmpty()) + assertEquals(1, preparations) + + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + assertSame(first, ScriptingEnvironment.INSTANCE) + + addonScope.cancel() + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + val refreshed = ScriptingEnvironment.INSTANCE + assertNotSame(first, refreshed) + assertEquals(1, preparations) + assertTrue(refreshed.compiler.compile("second.kts", "val value = Instant.EPOCH").isFailure) + assertTrue(refreshed.analyzer.diagnostic("second.kts", "val value = Instant.EPOCH").isNotEmpty()) + } finally { + scope.cancel() + addonScope.cancel() + initializer.unload(context) + File("hollowengine").deleteRecursively() + } + } + + private class TestHostServices : HollowAddonHostServices { + private val services = HashMap, Any>() + override fun log(message: String) = Unit + override fun publishService(type: KClass, instance: T) { services[type] = instance } + override fun unpublishService(type: KClass, instance: T) { services.remove(type, instance) } + @Suppress("UNCHECKED_CAST") + override fun findService(type: KClass): T? = services[type] as T? + override fun findServices(type: KClass): List = listOfNotNull(findService(type)) + } +} diff --git a/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt b/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt new file mode 100644 index 000000000..a6b3135af --- /dev/null +++ b/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt @@ -0,0 +1,159 @@ +import org.junit.jupiter.api.io.TempDir +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.annotations.Import +import ru.hollowhorizon.hollowengine.common.scripting.annotations.SharedScript +import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache +import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses +import ru.hollowhorizon.hollowengine.common.scripting.compiling.loadKotlinCompiledScriptFromJar +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import ru.hollowhorizon.hollowengine.common.scripting.source.DirectoryScriptSource +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +abstract class IdentityStartup + +class SharedScriptIdentityTest { + @Test + fun `packet classes have one identity through nested relative imports and startup roots`(@TempDir directory: File) { + directory.resolve("datas").mkdirs() + directory.resolve("packets").mkdirs() + directory.resolve("datas/ArrowData.kts").writeText( + "@file:SharedScript\nimport kotlinx.serialization.Serializable\n@Serializable data class ArrowData(val value: Boolean)", + ) + directory.resolve("packets/PlayerArrowsPacket.kts").writeText( + """ + @file:SharedScript + @file:Import("../datas/ArrowData.kts") + import kotlinx.serialization.Serializable + @Serializable + class PlayerArrowsPacket(val list: List) + """.trimIndent(), + ) + directory.resolve("fnf.startup.kts").writeText( + """ + @file:Import("./packets/PlayerArrowsPacket.kts") + val packet = PlayerArrowsPacket(listOf(ArrowData(true))) + """.trimIndent(), + ) + directory.resolve("fnf.reader.kts").writeText( + """ + @file:Import("./packets/PlayerArrowsPacket.kts", "./fnf.startup.kts") + import kotlinx.serialization.json.Json + import kotlinx.serialization.encodeToString + import kotlinx.serialization.decodeFromString + val packetClass = PlayerArrowsPacket::class.java + val dataClass = ArrowData::class.java + val startupPacket = packet + val roundTrip = Json.decodeFromString(Json.encodeToString(packet)) + """.trimIndent(), + ) + withScripts(directory, "packet-identity") { _, namespace -> + val startupId = ScriptId(namespace, "fnf.startup.kts") + val readerId = ScriptId(namespace, "fnf.reader.kts") + val startup = ScriptLoader.execute(startupId).getOrThrow() + val packet = startup.javaClass.getMethod("getPacket").invoke(startup) + val reader = ScriptLoader.execute(readerId).getOrThrow() + assertSame(packet.javaClass, reader.javaClass.getMethod("getPacketClass").invoke(reader)) + assertSame(packet, reader.javaClass.getMethod("getStartupPacket").invoke(reader)) + val roundTrip = reader.javaClass.getMethod("getRoundTrip").invoke(reader) + assertSame(packet.javaClass, roundTrip.javaClass) + val data = (packet.javaClass.getMethod("getList").invoke(packet) as List<*>).single()!! + assertSame(data.javaClass, reader.javaClass.getMethod("getDataClass").invoke(reader)) + val decodedData = (roundTrip.javaClass.getMethod("getList").invoke(roundTrip) as List<*>).single()!! + assertSame(data.javaClass, decodedData.javaClass) + SharedScriptClasses.keepInstance(startup::class) + SharedScriptClasses.clearInstances() + repeat(2) { + val cached = ScriptCache.artifact(readerId).loadKotlinCompiledScriptFromJar(javaClass.classLoader) + .execute().getOrThrow() + assertSame(packet.javaClass, cached.javaClass.getMethod("getPacketClass").invoke(cached)) + assertSame(packet, cached.javaClass.getMethod("getStartupPacket").invoke(cached)) + } + val rerun = ScriptCache.artifact(startupId).loadKotlinCompiledScriptFromJar(javaClass.classLoader) + .execute().getOrThrow() + assertSame(startup, rerun) + + directory.resolve("datas/ArrowData.kts").appendText("\nval revision = 2") + val failure = assertFailsWith { + ScriptLoader.execute(readerId).getOrThrow() + } + assertTrue(failure.message.orEmpty().contains("Restart the game"), failure.message) + } + } + + @Test + fun `shared dependency policy rejects private imports in compiler and IDE`(@TempDir directory: File) { + directory.resolve("data.kts").writeText("class PrivateData") + directory.resolve("packet.kts").writeText( + "@file:SharedScript\n@file:Import(\"data.kts\")\nclass Packet(val data: PrivateData)", + ) + directory.resolve("main.kts").writeText("@file:Import(\"packet.kts\")\nval packet = Packet(PrivateData())") + withScripts(directory, "private-shared-import") { environment, namespace -> + val failure = ScriptLoader.compile(ScriptId(namespace, "main.kts")).exceptionOrNull() + assertTrue(failure?.message.orEmpty().contains("imports non-shared script"), failure?.message) + assertTrue(failure?.message.orEmpty().contains("data.kts"), failure?.message) + val diagnostics = environment.analyzer.diagnostic( + "$namespace:main.kts", directory.resolve("main.kts").readText(), + ) + assertTrue(diagnostics.any { it.message.contains("imports non-shared script") }, diagnostics.toString()) + + val startupText = "@file:Import(\"data.kts\")\nval data = PrivateData()" + directory.resolve("bad.startup.kts").writeText(startupText) + assertTrue(ScriptLoader.compile(ScriptId(namespace, "bad.startup.kts")).isFailure) + assertTrue(environment.analyzer.diagnostic("$namespace:bad.startup.kts", startupText) + .any { it.message.contains("imports non-shared script") }) + } + } + + @Test + fun `loading a compiled shared script does not reidentify it after a source edit`(@TempDir directory: File) { + directory.resolve("model.kts").writeText("@file:SharedScript\nclass Model") + directory.resolve("first.kts").writeText("@file:Import(\"model.kts\")\nval model = Model()") + directory.resolve("second.kts").writeText("@file:Import(\"model.kts\")\nval model = Model()") + withScripts(directory, "frozen-shared-identity") { _, namespace -> + val first = ScriptLoader.compile(ScriptId(namespace, "first.kts")).getOrThrow() + val second = ScriptLoader.compile(ScriptId(namespace, "second.kts")).getOrThrow() + directory.resolve("model.kts").appendText("\nval revision = 2") + val firstInstance = first.execute().getOrThrow() + val secondInstance = second.execute().getOrThrow() + val firstModel = firstInstance.javaClass.getMethod("getModel").invoke(firstInstance) + val secondModel = secondInstance.javaClass.getMethod("getModel").invoke(secondInstance) + assertSame(firstModel.javaClass, secondModel.javaClass) + } + } + + private fun withScripts( + directory: File, + namespace: String, + body: (ScriptingEnvironmentImpl, String) -> Unit, + ) { + val imports = listOf(Import::class.qualifiedName!!, SharedScript::class.qualifiedName!!) + val classpath = System.getProperty("java.class.path").split(File.pathSeparator).map(::File).filter(File::exists) + val environment = ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = classpath, + scriptTypes = listOf( + ScriptClassProvider("kts", "kotlin.Any", imports), + ScriptClassProvider("startup.kts", IdentityStartup::class.qualifiedName!!, imports, shared = true), + ), + mappings = Mappings.EMPTY, + ) + ScriptRegistry.register(DirectoryScriptSource(namespace, directory, javaClass.classLoader, fingerprint = "test")) + ScriptingEnvironment.INSTANCE = environment + try { + body(environment, namespace) + } finally { + ScriptRegistry.unregister(namespace) + ScriptingEnvironment.clear() + File("hollowengine").deleteRecursively() + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt index cf8334c5d..930dc2674 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt @@ -10,6 +10,7 @@ import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchor import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchorKind import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage +import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler @@ -99,7 +100,7 @@ class PlayCutscenePacket( val path: String, val loop: Boolean = false, val anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) { val data = CutsceneStorage.load(path) CutsceneCameraSystem.play(data, loop, anchor) @@ -108,7 +109,7 @@ class PlayCutscenePacket( @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -object StopCutscenePacket : HollowPacket { +object StopCutscenePacket : HollowAddonPacket { override fun handle(player: Player) { CutsceneCameraSystem.stop() } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt index b3f5f98e6..77fdad3f3 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt @@ -5,6 +5,7 @@ import net.minecraft.world.entity.player.Player import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryCameraSystem import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryFadeOverlay import ru.hollowhorizon.hollowengine.client.camera.CameraPose +import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f @@ -15,13 +16,13 @@ class StoryFadePacket( private val alpha: Float, private val durationMillis: Long, private val color: Int, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) = StoryFadeOverlay.fadeTo(alpha, durationMillis, color) } @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -object StoryClearFadePacket : HollowPacket { +object StoryClearFadePacket : HollowAddonPacket { override fun handle(player: Player) = StoryFadeOverlay.clear() } @@ -36,7 +37,7 @@ class StoryCameraPacket( private val roll: Float, private val fov: Float, private val durationMillis: Long, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) = StoryCameraSystem.moveTo( CameraPose(Vec3f(x, y, z), Vec3f(pitch, yaw, roll), fov), durationMillis, @@ -45,6 +46,6 @@ class StoryCameraPacket( @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -class StoryReleaseCameraPacket(private val durationMillis: Long) : HollowPacket { +class StoryReleaseCameraPacket(private val durationMillis: Long) : HollowAddonPacket { override fun handle(player: Player) = StoryCameraSystem.release(durationMillis) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 74963f7e7..5ea0e2008 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -12,6 +12,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.reload.ReloadScript import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadContext import ru.hollowhorizon.hollowengine.common.scripting.startup.StartupScript import ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript +import ru.hollowhorizon.hollowengine.logI import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider as Provider const val NODE_SCRIPT_EXTENSION = "node.kts" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt index 2964e60bc..32cddf865 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt @@ -21,7 +21,7 @@ import java.security.MessageDigest */ object ScriptFingerprint { /** Bumped whenever the layout of a cached artifact changes in a way older jars cannot satisfy. */ - const val FORMAT_VERSION = 3 + const val FORMAT_VERSION = 4 /** What a build compiles addon scripts for: Mojang names, which is what NeoForge runs on. */ const val NAMED_PRODUCTION_RUNTIME = "neoforge/official/production" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt index dfc3e3b72..a49afa031 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt @@ -57,7 +57,9 @@ internal class KotlinCompiledScriptJar( private val evaluator = HollowEngineScriptEvaluator() private val scriptClass = lazy { runBlocking { - (script.getClass(evaluationConfiguration) as ResultWithDiagnostics.Success).value + script.getClass(evaluationConfiguration).valueOr { failure -> + throw ScriptEvaluationException(name, failure.reports.map { it.convert() }) + } } } @@ -102,6 +104,10 @@ internal val JvmScriptEvaluationConfigurationKeys.actualClassLoader by Propertie /** Whether this compiled script opted into instance sharing with `@file:SharedScript`. */ val ScriptCompilationConfigurationKeys.isSharedScript by PropertiesCollection.key() +/** Frozen at compilation, so editing the sources cannot relabel already compiled bytecode. */ +val ScriptCompilationConfigurationKeys.sharedScriptId by PropertiesCollection.key() +val ScriptCompilationConfigurationKeys.sharedScriptFingerprint by PropertiesCollection.key() + /** Whether this compiled script declared `@file:ClientSide`. */ val ScriptCompilationConfigurationKeys.isClientSideScript by PropertiesCollection.key() @@ -132,7 +138,6 @@ open class HollowEngineScriptEvaluator : ScriptEvaluator { compiledScript.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] == true if (canShareInstance) { - if (!imported) SharedScriptClasses.adopt(compiledScript, scriptClass) SharedScriptClasses.instanceOf(scriptClass)?.asSuccess()?.let { return@evaluation it } } @@ -303,7 +308,7 @@ internal class KJvmCompiledScriptFromJar( val script = createScriptFromClassLoader(scriptClassName, classLoader) classLoader.shareClassesOf( SharedScriptClasses.loadersFor( - script.otherScripts, + listOf(script), entries, baseClassLoader, ScriptCache.sharedScriptsOf(file), @@ -379,5 +384,5 @@ fun ScriptDiagnostic.convert(): Diagnostic { ScriptDiagnostic.Severity.WARNING -> Severity.WARNING ScriptDiagnostic.Severity.ERROR -> Severity.ERROR ScriptDiagnostic.Severity.FATAL -> Severity.FATAL - }, message) + }, exception?.message?.takeUnless { it.isBlank() || message.contains(it) }?.let { "$message: $it" } ?: message) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt index 7b1593a4e..0a0f4aa31 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt @@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache.SharedScriptRef import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses.clearInstances +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import java.io.File import java.util.jar.JarFile @@ -23,15 +24,13 @@ import kotlin.script.experimental.jvm.impl.KJvmCompiledScript * file, and `@file:SharedScript` could only share an instance inside a single import graph. */ object SharedScriptClasses { - private class Entry(val identity: String, val loader: ClassLoader) - - /** Bytecode of one shared script, and what identifies the revision it came from. */ - private class Classes(val identity: String, val entries: Map) + private class Entry(val reference: SharedScriptRef, val loader: ClassLoader, val dependencies: List) { + val identity: String get() = reference.fingerprint + } - private val loaders = HashMap() + private val loaders = HashMap() private val instances = HashMap, EvaluationResult>() - private val kept = HashSet>() - private val reportedMismatches = HashSet() + private val kept = HashSet() /** * Where the classes of shared scripts among [imports] have to be looked up, by class name. @@ -46,9 +45,9 @@ object SharedScriptClasses { parent: ClassLoader?, declared: Map = emptyMap(), ): Map { - val result = LinkedHashMap() + val result = LinkedHashMap() imports.forEach { collect(it, entries, parent, declared, result) } - return result + return result.mapValues { it.value.loader } } private fun collect( @@ -56,82 +55,104 @@ object SharedScriptClasses { entries: Map, parent: ClassLoader?, declared: Map, - result: LinkedHashMap, + result: LinkedHashMap, ) { script.otherScripts.forEach { collect(it, entries, parent, declared, result) } if (script.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) return - val name = (script as? KJvmCompiledScript)?.scriptClassFQName ?: return - if (name in result) return - - // No stable identity means no safe sharing: better a private copy than a wrong one. - val reference = declared[name] ?: referenceOf(script) ?: return - val existing = loaders[name] + val jvmScript = script as? KJvmCompiledScript + ?: fail("Shared script '${script.sourceLocationId}' has no JVM class metadata") + val name = jvmScript.scriptClassFQName + script.otherScripts.forEach { dependency -> + if (dependency.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) { + fail(sharedScriptImportError(script.sourceLocationId.orEmpty(), dependency.sourceLocationId.orEmpty())) + } + } + val recorded = storedReferenceOf(jvmScript) + val declaration = declared[name] + if (recorded != null && declaration != null && recorded != declaration) { + fail("Conflicting shared script metadata for '$name': serialized identity=$recorded, artifact manifest=$declaration. " + + "Rebuild the artifact and its shared dependencies together.") + } + val reference = declaration ?: recorded ?: referenceOf(jvmScript) + ?: fail("Cannot resolve shared script identity: class='$name', source='${script.sourceLocationId}', " + + "registered namespaces=${ScriptRegistry.sources().map { it.namespace }}. " + + "Register the source before compilation or rebuild the compiled artifact; refusing to create a private copy.") + result[name]?.let { resolved -> + val previous = resolved.reference + if (previous != reference) { + fail("Shared script class '$name' has conflicting identities in one import graph: '$previous' and '$reference'. " + + "Use distinct packages for scripts declaring the same class name. Existing loader=${resolved.loader}.") + } + return + } + val existing = loaders[reference.id] if (existing != null && existing.identity == reference.fingerprint) { - result[name] = existing.loader + HollowEngine.LOGGER.debug("Reusing shared script '{}', class={}, fingerprint={}, loader={}", + reference.id.qualified, name, reference.fingerprint, existing.loader) + result[name] = existing return } - val classes = classesOf(reference, entries) ?: return - - val entry = if (existing != null && existing.identity == classes.identity) { - existing - } else { - val delegates = result.toMap() - Entry( - classes.identity, MemoryClassLoader(classes.entries, parent).apply { shareClassesOf(delegates) }).also { - loaders[name] = it + if (existing != null) { + if (existing.loader in kept) { + fail("Cannot replace startup/shared script '${reference.id.qualified}' while its instance is retained: " + + "old fingerprint=${existing.identity}, new fingerprint=${reference.fingerprint}, loader=${existing.loader}. Restart the game.") } } - result[name] = entry.loader + val classes = classesOf(reference, entries) + val loader = MemoryClassLoader(classes, parent).apply { shareClassesOf(result.mapValues { it.value.loader }) } + val dependencies = script.otherScripts.filterIsInstance() + .mapNotNull { result[it.scriptClassFQName] } + val entry = Entry(reference, loader, dependencies) + loaders[reference.id] = entry + if (existing != null) { + HollowEngine.LOGGER.warn("Shared script '{}' changed identity: class={}, old fingerprint={}, new fingerprint={}, old loader={}, new loader={}", + reference.id.qualified, name, existing.identity, reference.fingerprint, existing.loader, loader) + } + HollowEngine.LOGGER.debug("Created shared script loader: script={}, class={}, fingerprint={}, parent={}, loader={}", + reference.id.qualified, name, reference.fingerprint, parent, loader) + result[name] = entry } - /** - * Makes the classes of a shared script that runs on its own the ones its importers link against, so - * they get the instance it created. Without this the root and an importer would each define their own - * class for one file, and an instance remembered for one is invisible to the other. - */ - @Synchronized - fun adopt(script: CompiledScript, scriptClass: KClass<*>) { - val identity = when (script) { - is KJvmCompiledScriptFromJar -> script.fingerprint - is KJvmCompiledScript -> referenceOf(script)?.fingerprint - else -> null - } ?: return - val name = scriptClass.java.name - if (loaders[name]?.identity == identity) return - loaders[name] = Entry(identity, scriptClass.java.classLoader) + private fun storedReferenceOf(script: KJvmCompiledScript): SharedScriptRef? { + val configuration = script.compilationConfiguration + val storedId = configuration[ScriptCompilationConfiguration.sharedScriptId] + val storedFingerprint = configuration[ScriptCompilationConfiguration.sharedScriptFingerprint] + if (storedId != null && storedFingerprint != null) { + return SharedScriptRef(script.scriptClassFQName, ScriptRegistry.parse(storedId), storedFingerprint) + } + return null } private fun referenceOf(script: KJvmCompiledScript): SharedScriptRef? { val location = script.sourceLocationId ?: return null - val id = ScriptRegistry.idOf(File(location)) ?: runCatching { ScriptRegistry.parse(location) }.getOrNull() - ?.takeIf { ScriptRegistry.artifacts(it) != null } ?: return null - val fingerprint = runCatching { ScriptFingerprint.compute(id) }.getOrNull() ?: return null + val id = ScriptRegistry.idOfLocation(location) ?: return null + val fingerprint = runCatching { ScriptFingerprint.compute(id) } + .onFailure { HollowEngine.LOGGER.error("Cannot compute shared script identity for '${id.qualified}' at '$location'", it) } + .getOrNull() ?: return null return SharedScriptRef(script.scriptClassFQName, id, fingerprint.code) } - private fun classesOf(reference: SharedScriptRef, fallback: Map): Classes? { + private fun classesOf(reference: SharedScriptRef, fallback: Map): Map { val artifact = artifactFor(reference) - if (artifact != null) { - val provided = ScriptCache.hashOf(artifact) - if (provided != reference.fingerprint) reportMismatch(reference, artifact, provided) + if (artifact != null && ScriptCache.hashOf(artifact) == reference.fingerprint) { return runCatching { - Classes( - provided ?: artifact.identity(), entriesOf(artifact) - ) - }.onFailure { HollowEngine.LOGGER.error("Unreadable shared script '{}'", artifact, it) }.getOrNull() + entriesOf(artifact) + }.getOrElse { fail("Unreadable shared script '$artifact': ${it.message}") } } if (fallback.containsKey(classFileOf(reference.scriptClass))) { - return Classes(reference.fingerprint, fallback) + val prefix = reference.scriptClass.replace('.', '/') + return fallback.filterKeys { it == "$prefix.class" || it.startsWith("$prefix$") } } - HollowEngine.LOGGER.error( - "Shared script '{}' has no compiled classes: neither '{}' nor an artifact shipped with it exists, and the artifacts importing it carry no copy. Reinstall the pack, or install HollowEngineCompiler Addon to rebuild it.", - ScriptRegistry.display(reference.id), - ScriptCache.sharedArtifact(reference.id), - ) - return null + if (artifact != null) { + fail("Shared script '${reference.id.qualified}' artifact '$artifact' has fingerprint '${ScriptCache.hashOf(artifact)}', " + + "expected '${reference.fingerprint}'. Rebuild its importers and shared artifact together.") + } + + fail("Shared script '${reference.id.qualified}' has no compiled classes: neither '${ScriptCache.sharedArtifact(reference.id)}' " + + "nor a bundled artifact or in-memory copy exists. Reinstall the pack or rebuild it with the compiler addon.") } private fun artifactFor(reference: SharedScriptRef): File? { @@ -142,17 +163,9 @@ object SharedScriptClasses { return candidates.firstOrNull { ScriptCache.hashOf(it) == reference.fingerprint } ?: candidates.firstOrNull() } - private fun File.identity(): String = "$canonicalPath@${lastModified()}:${length()}" - - private fun reportMismatch(reference: SharedScriptRef, artifact: File, provided: String?) { - if (!reportedMismatches.add("${reference.id.qualified}=$provided")) return - HollowEngine.LOGGER.warn( - "Compiled shared script '{}' is not the one the scripts importing it were built against ({} holds {} instead of {}); linking against it anyway", - ScriptRegistry.display(reference.id), - artifact, - provided ?: "no fingerprint", - reference.fingerprint, - ) + private fun fail(message: String): Nothing { + HollowEngine.LOGGER.error(message) + throw IllegalStateException(message) } private fun entriesOf(artifact: File): Map = JarFile(artifact).use { jar -> @@ -173,12 +186,17 @@ object SharedScriptClasses { /** Keeps the instance of [scriptClass] through [clearInstances], for scripts that cannot run twice. */ @Synchronized fun keepInstance(scriptClass: KClass<*>) { - kept += scriptClass + fun keep(entry: Entry) { + if (!kept.add(entry.loader)) return + entry.dependencies.forEach(::keep) + } + val loader = scriptClass.java.classLoader + loaders.values.firstOrNull { it.loader === loader }?.let(::keep) ?: kept.add(loader) } /** Forgets the state of shared scripts, so it does not move into the next world. */ @Synchronized fun clearInstances() { - instances.keys.retainAll(kept) + instances.keys.removeIf { it.java.classLoader !in kept } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt new file mode 100644 index 000000000..953ec2b78 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt @@ -0,0 +1,6 @@ +package ru.hollowhorizon.hollowengine.common.scripting.compiling + +/** Shared bytecode must never link against per-importer classes, including implementation details. */ +fun sharedScriptImportError(script: String, dependency: String): String = + "Shared script '$script' imports non-shared script '$dependency'. " + + "Add @file:SharedScript to the dependency; all transitive imports must be shared as well." diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt index e41bfda26..2f2c606b5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt @@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.HollowEngineBuild import ru.hollowhorizon.hollowengine.common.addons.HollowAddonClassLoader import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry.parse import java.io.File +import java.net.URI import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -119,6 +120,13 @@ object ScriptRegistry { */ fun idOf(file: File): ScriptId? = filesToIds[file.canonicalPath] + /** Physical paths, file URIs and portable namespace IDs used by Kotlin script metadata. */ + fun idOfLocation(location: String): ScriptId? { + val file = runCatching { if (location.startsWith("file:")) File(URI(location)) else File(location) }.getOrNull() + file?.let { runCatching { idOf(it) }.getOrNull() }?.let { return it } + return runCatching { parse(location) }.getOrNull()?.takeIf { artifacts(it) != null } + } + /** * Classpath the scripts of [namespace] compile against: its own, plus that of every namespace it * declares in `dependsOn`, so a project can call the classes of the addons it depends on. diff --git a/runtime/src/test/kotlin/ScriptSourceTests.kt b/runtime/src/test/kotlin/ScriptSourceTests.kt index 674f37ec0..a4ab31d6d 100644 --- a/runtime/src/test/kotlin/ScriptSourceTests.kt +++ b/runtime/src/test/kotlin/ScriptSourceTests.kt @@ -8,6 +8,22 @@ import java.util.jar.JarOutputStream import kotlin.test.* class ScriptSourceTests { + @Test + fun `compiled metadata resolves file paths URIs and portable identities`(@TempDir root: File) { + val file = root.resolve("a script.kts").apply { writeText("class Example") } + val id = ScriptId("metadata-locations", file.name) + ScriptRegistry.register(directorySource(id.namespace, root)) + try { + ScriptRegistry.artifacts(id) + assertEquals(id, ScriptRegistry.idOfLocation(file.path)) + assertEquals(id, ScriptRegistry.idOfLocation(file.toURI().toString())) + assertEquals(id, ScriptRegistry.idOfLocation(id.qualified)) + assertNull(ScriptRegistry.idOfLocation("missing-namespace:missing.kts")) + } finally { + ScriptRegistry.unregister(id.namespace) + } + } + @Test fun `unqualified paths keep their pre-namespace spelling`() { val id = ScriptRegistry.parse("scripts/nodes/example.node.kts") diff --git a/runtime/src/test/kotlin/SharedScriptClassesTest.kt b/runtime/src/test/kotlin/SharedScriptClassesTest.kt new file mode 100644 index 000000000..fec14726a --- /dev/null +++ b/runtime/src/test/kotlin/SharedScriptClassesTest.kt @@ -0,0 +1,79 @@ +import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache.SharedScriptRef +import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptFingerprint +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptId +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import kotlin.script.experimental.api.ScriptCompilationConfiguration +import kotlin.script.experimental.jvm.impl.KJvmCompiledScript +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertTrue + +class SharedScriptClassesTest { + @Test + fun `an unresolved shared identity fails instead of loading a private copy`() { + val script = sharedScript("UnregisteredShared") + val failure = assertFailsWith { + SharedScriptClasses.loadersFor(listOf(script), emptyMap(), javaClass.classLoader) + } + assertTrue(failure.message.orEmpty().contains("Cannot resolve shared script identity")) + assertTrue(failure.message.orEmpty().contains("UnregisteredShared")) + } + + @Test + fun `equal generated class names in separate import graphs keep independent namespaces`() { + val script = sharedScript("IndependentShared") + fun load(namespace: String): ClassLoader { + val reference = SharedScriptRef("IndependentShared", ScriptId(namespace, "common.kts"), "revision") + return SharedScriptClasses.loadersFor( + listOf(script), mapOf("IndependentShared.class" to byteArrayOf()), javaClass.classLoader, + mapOf("IndependentShared" to reference), + ).getValue("IndependentShared") + } + assertNotSame(load("independent-one"), load("independent-two")) + } + + @Test + fun `different script identities cannot claim one generated name in the same import graph`() { + fun script(namespace: String) = KJvmCompiledScript( + null, ScriptCompilationConfiguration { + isSharedScript(true) + sharedScriptId("$namespace:common.kts") + sharedScriptFingerprint("revision") + }, "ConflictingShared", null, emptyList(), null, + ) + val failure = assertFailsWith { + SharedScriptClasses.loadersFor( + listOf(script("conflict-one"), script("conflict-two")), + mapOf("ConflictingShared.class" to byteArrayOf()), javaClass.classLoader, + ) + } + assertTrue(failure.message.orEmpty().contains("conflicting identities in one import graph")) + } + + @Test + fun `serialized shared identity must match the jar manifest`() { + val script = KJvmCompiledScript( + null, ScriptCompilationConfiguration { + isSharedScript(true) + sharedScriptId("conflicting-metadata:common.kts") + sharedScriptFingerprint("compiled-revision") + }, "MismatchedShared", null, emptyList(), null, + ) + val failure = assertFailsWith { + SharedScriptClasses.loadersFor( + listOf(script), emptyMap(), javaClass.classLoader, + mapOf("MismatchedShared" to SharedScriptRef( + "MismatchedShared", ScriptId("conflicting-metadata", "common.kts"), "manifest-revision", + )), + ) + } + assertTrue(failure.message.orEmpty().contains("Conflicting shared script metadata")) + } + + private fun sharedScript(name: String) = KJvmCompiledScript( + null, ScriptCompilationConfiguration { isSharedScript(true) }, name, null, emptyList(), null, + ) +} From eff81db1e43c4cf60b4df12fc1fe8e54d6e61be9 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Wed, 21 Oct 2026 14:43:37 +0300 Subject: [PATCH 04/25] Build addons for IDE application runs --- bootstrap-fabric/build.gradle.kts | 2 +- bootstrap-neoforge/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bootstrap-fabric/build.gradle.kts b/bootstrap-fabric/build.gradle.kts index 9bc5eaee0..2bb2f89c6 100644 --- a/bootstrap-fabric/build.gradle.kts +++ b/bootstrap-fabric/build.gradle.kts @@ -228,7 +228,7 @@ tasks.named("processResources") { } tasks.named("classes") { - dependsOn(embedRuntimeJar) + dependsOn(embedRuntimeJar, rootProject.tasks.named("buildAddons")) } tasks.named("shadowJar") { diff --git a/bootstrap-neoforge/build.gradle.kts b/bootstrap-neoforge/build.gradle.kts index ccfd81127..49a2a1951 100644 --- a/bootstrap-neoforge/build.gradle.kts +++ b/bootstrap-neoforge/build.gradle.kts @@ -219,7 +219,7 @@ tasks.named("processResources") { } tasks.named("classes") { - dependsOn(embedRuntimeJar) + dependsOn(embedRuntimeJar, rootProject.tasks.named("buildAddons")) } tasks.named("shadowJar") { From 1ba827780f5ea859cf34da8edbb44acf111f3b19 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Wed, 21 Oct 2026 15:05:01 +0300 Subject: [PATCH 05/25] Initialize scripting on addon load --- .../hollowengine/common/ScriptingEnvironmentFactory.kt | 6 ++---- .../src/test/kotlin/ScriptDefinitionsLifecycleTest.kt | 8 ++++---- .../addons/narrative/cutscene/CutsceneActions.kt | 1 - .../addons/narrative/dialogue/StoryEffectPackets.kt | 1 - build.gradle.kts | 4 ---- .../common/scripting/DefaultScriptDefinitions.kt | 1 - 6 files changed, 6 insertions(+), 15 deletions(-) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt index ba20ea189..408d4f048 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt @@ -47,6 +47,7 @@ class ScriptingEnvironmentInitializerImpl( override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { loaded = LoadedAddon(context.addonFile, scope) + setUp(context.addonFile, scope) context.hostServices.publish(this) } @@ -72,10 +73,7 @@ class ScriptingEnvironmentInitializerImpl( @Synchronized fun onAddonsChanged(event: HollowAddonsChangedEvent) { val addon = loaded ?: return - val current = built ?: run { - setUp(addon.file, addon.scope) - return - } + val current = built ?: return val scriptTypes = definitions() if (scriptTypes == current.scriptTypes) return logI("Reloading script definitions after addon imports or script types changed") diff --git a/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt index e341400af..9d68bd3e9 100644 --- a/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt +++ b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt @@ -20,7 +20,6 @@ import java.util.jar.JarOutputStream import kotlin.reflect.KClass import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotSame import kotlin.test.assertSame import kotlin.test.assertTrue @@ -29,7 +28,7 @@ abstract class LifecycleScript class ScriptDefinitionsLifecycleTest { @Test - fun `compiler waits for addon batch and refreshes late definitions without remapping`(@TempDir directory: File) = runBlocking { + fun `compiler starts on load and refreshes late definitions without remapping`(@TempDir directory: File) = runBlocking { val scope = CoroutineScope(Job()) val addonScope = CoroutineScope(Job()) val classpath = System.getProperty("java.class.path").split(File.pathSeparator).map(::File).filter(File::exists) @@ -55,8 +54,8 @@ class ScriptDefinitionsLifecycleTest { ScriptingEnvironment.clear() initializer.load(context, scope) HollowAddonsChangedEvent.subscribe(scope) { initializer.onAddonsChanged(it) } - assertEquals(0, preparations) - assertFalse(ScriptingEnvironment.isAvailable()) + assertEquals(1, preparations) + val initial = ScriptingEnvironment.INSTANCE assertSame(initializer, services.findService(ScriptingEnvironmentInitializer::class)) ScriptDefaultImportsEvent.subscribe(addonScope) { it.defaultImports += "java.time.Instant" } @@ -65,6 +64,7 @@ class ScriptDefinitionsLifecycleTest { } HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) val first = ScriptingEnvironment.INSTANCE + assertNotSame(initial, first) assertTrue(first.compiler.compile("first.addon.kts", "val value = Instant.EPOCH").isSuccess) assertTrue(first.analyzer.diagnostic("first.addon.kts", "val value = Instant.EPOCH").isEmpty()) assertEquals(1, preparations) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt index 930dc2674..96ddb4362 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt @@ -11,7 +11,6 @@ import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchorKin import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket -import ru.hollowhorizon.hollowengine.common.network.HollowPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler fun Player.playCutscene(path: String, loop: Boolean = false) { diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt index 77fdad3f3..a9a267db0 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryEffectPackets.kt @@ -6,7 +6,6 @@ import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryCameraSystem import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryFadeOverlay import ru.hollowhorizon.hollowengine.client.camera.CameraPose import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket -import ru.hollowhorizon.hollowengine.common.network.HollowPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f diff --git a/build.gradle.kts b/build.gradle.kts index f5c40b242..1c20bde63 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,6 @@ import me.modmuss50.mpp.ReleaseType import net.fabricmc.loom.api.LoomGradleExtensionAPI import net.fabricmc.loom.task.GenerateSourcesTask import net.fabricmc.loom.task.RemapJarTask -import net.fabricmc.loom.task.RunGameTask import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -230,9 +229,6 @@ subprojects { jvmArguments.add("-Dhollowengine.devAddons=${rootProject.layout.buildDirectory.dir("addon-jars").get().asFile.absolutePath}") } } - tasks.withType().configureEach { - dependsOn(rootProject.tasks.named("buildAddons")) - } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 5ea0e2008..74963f7e7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -12,7 +12,6 @@ import ru.hollowhorizon.hollowengine.common.scripting.reload.ReloadScript import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadContext import ru.hollowhorizon.hollowengine.common.scripting.startup.StartupScript import ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript -import ru.hollowhorizon.hollowengine.logI import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider as Provider const val NODE_SCRIPT_EXTENSION = "node.kts" From d2f553d38c94fd9d4d5c2c0ec3d38bf96e68d4c4 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 10:31:20 +0300 Subject: [PATCH 06/25] Add resumable narrative story stages Introduce a compiler and runtime for persistent story stages, including resumable call stacks, saved locals, checkpoints, stage lambdas, and parallel branches. Replace the previous state executor and wire story lifecycle and persistence into scripts and nodes. --- addons/compiler/build.gradle.kts | 11 +- .../structure/KotlinEditorStructure.kt | 2 +- .../plugin/ir/StateIrGenerationExtension.kt | 4 +- .../plugin/ir/stages/StageDiagnostics.kt | 39 + .../plugin/ir/stages/StageIrGeneration.kt | 191 +++++ .../plugin/ir/stages/StageLambdaLifter.kt | 227 ++++++ .../common/plugin/ir/stages/StageLayout.kt | 274 +++++++ .../plugin/ir/stages/StageNormalizer.kt | 422 ++++++++++ .../common/plugin/ir/stages/StageRewriter.kt | 466 +++++++++++ .../common/plugin/ir/stages/StageScope.kt | 89 +++ .../common/plugin/ir/stages/StageSymbols.kt | 88 +++ .../common/plugin/ir/stages/StageTypes.kt | 93 +++ .../ir/transformers/StateCallTransformer.kt | 120 --- .../src/test/kotlin/EditorStructureTest.kt | 2 +- .../src/test/kotlin/StageCompileErrorsTest.kt | 204 +++++ .../src/test/kotlin/StageExecutionTest.kt | 742 ++++++++++++++++++ .../src/test/kotlin/StageTestSupport.kt | 184 +++++ addons/narrative/build.gradle.kts | 2 +- .../addons/narrative/HollowNarrativeAddon.kt | 8 +- .../addons/narrative/story/StageLambda.kt | 45 ++ .../addons/narrative/story/State.kt | 39 + .../addons/narrative/story/StoryApi.kt | 137 ++++ .../addons/narrative/story/StoryFrame.kt | 251 ++++++ .../addons/narrative/story/StoryInstances.kt | 133 ++++ .../addons/narrative/story/StoryLoops.kt | 16 + .../addons/narrative/story/StoryOwners.kt | 150 ++++ .../addons/narrative/story/StoryRun.kt | 249 ++++++ .../addons/narrative/story/StoryRuns.kt | 137 ++++ .../addons/narrative/story/StorySegments.kt | 121 +++ .../addons/narrative/story/StoryValues.kt | 126 +++ .../narrative/story}/PersistentWaitTest.kt | 2 +- .../addons/narrative/story/StoryFrameTest.kt | 92 +++ .../addons/narrative/story/StoryValuesTest.kt | 77 ++ .../common/commands/HollowEngineCommands.kt | 6 +- .../scripting/DefaultScriptDefinitions.kt | 1 - .../common/scripting/ScriptStartedEvent.kt | 22 + .../scripting/annotations/Annotations.kt | 2 - .../scripting/execution/ScriptRunService.kt | 10 +- .../scripting/nodes/EntityNodeRuntime.kt | 48 +- .../common/scripting/nodes/NodeLoader.kt | 21 - .../common/scripting/nodes/NodeScript.kt | 20 + .../common/scripting/nodes/NodeSystem.kt | 98 +-- .../scripting/reload/ReloadScriptRunner.kt | 2 + .../common/scripting/source/ScriptRegistry.kt | 2 +- .../scripting/startup/StartupScripts.kt | 2 + .../common/scripting/state/PersistentWait.kt | 97 --- .../common/scripting/state/StateExecutor.kt | 80 -- .../common/scripting/state/StateHelper.kt | 85 -- .../story/functions/npcs/NPCActions.kt | 19 - 49 files changed, 4741 insertions(+), 517 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt delete mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt create mode 100644 addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageExecutionTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageTestSupport.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt rename {runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state => addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story}/PersistentWaitTest.kt (96%) create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt diff --git a/addons/compiler/build.gradle.kts b/addons/compiler/build.gradle.kts index dbaf3368f..9184dd6b7 100644 --- a/addons/compiler/build.gradle.kts +++ b/addons/compiler/build.gradle.kts @@ -30,6 +30,10 @@ val ijPlatform = "261.25134.147" val ijJava = "261.25134.137" val runtimeProjectPath = ":runtime" +val minecraftTestClasspath = project(runtimeProjectPath).configurations.named("compileClasspath").map { classpath -> + classpath.filter { file -> listOf("org.jetbrains", "androidx", "io.insert-koin").none { file.path.contains(it) } } +} +val narrativeTestClasses = provider { project(":addons:narrative").layout.buildDirectory.dir("classes/kotlin/main").get() } val lightTreeClasses = project.projectDir.resolve("lightTree") val patchedLightTreeJar = tasks.register("patchedLightTreeJar") { archiveBaseName.set("hollowengine-light-tree") @@ -171,6 +175,9 @@ dependencies { testImplementation(kotlin("test")) testImplementation("io.insert-koin:koin-core:${rootProject.property("koinVersion")}") testImplementation("androidx.compose.runtime:runtime:$composeRuntimeVersion") + testImplementation(files(narrativeTestClasses) { builtBy(":addons:narrative:compileKotlin") }) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation(files(minecraftTestClasspath)) testRuntimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) testRuntimeOnly(files(compilerPluginRuntimeJar.flatMap { it.archiveFile })) testRuntimeOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") @@ -178,6 +185,7 @@ dependencies { testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") testRuntimeOnly("org.apache.logging.log4j:log4j-api:2.23.1") + testRuntimeOnly("org.apache.logging.log4j:log4j-core:2.23.1") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -306,7 +314,8 @@ tasks.withType().configureEach { }) inputs.files(analysisClasspath).withPropertyName("ideAnalysisClasspath") doFirst { - systemProperty("hollowengine.ide.testClasspath", analysisClasspath.get().asPath) + val classpath = analysisClasspath.get().files + narrativeTestClasses.get().asFile + systemProperty("hollowengine.ide.testClasspath", classpath.joinToString(File.pathSeparator)) } useJUnitPlatform() maxHeapSize = "2g" diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt index 5749d0b01..4db9eb335 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt @@ -151,5 +151,5 @@ internal fun PsiElement.fold(placeholder: String): CodeFold? = private val AttachClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.Attach")) -private val StateClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.State")) +private val StateClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.addons.narrative.story.State")) private val EventClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.events.SubscribeEvent")) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt index 32feba5fd..8cfe05510 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt @@ -3,15 +3,15 @@ package ru.hollowhorizon.hollowengine.common.plugin.ir import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import ru.hollowhorizon.hollowengine.common.plugin.ir.stages.StageIrGeneration import ru.hollowhorizon.hollowengine.common.plugin.ir.transformers.ImportedScriptReceiverTransformer -import ru.hollowhorizon.hollowengine.common.plugin.ir.transformers.StateCallTransformer class StateIrGenerationExtension : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { Companion.pluginContext = pluginContext moduleFragment.transform(ImportedScriptReceiverTransformer(moduleFragment, pluginContext), null) - moduleFragment.transform(StateCallTransformer(), null) + StageIrGeneration(pluginContext).generate(moduleFragment) } companion object { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt new file mode 100644 index 000000000..9f0f05d3f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt @@ -0,0 +1,39 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.diagnostics.* +import org.jetbrains.kotlin.diagnostics.rendering.BaseDiagnosticRendererFactory +import org.jetbrains.kotlin.diagnostics.rendering.CommonRenderers +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrFile + +/** The errors the stage compiler reports: misplaced stage calls, values a story cannot save. */ +internal object StageDiagnostics : KtDiagnosticsContainer() { + val STAGE_ERROR = KtDiagnosticFactory1( + "HOLLOWENGINE_STAGE_ERROR", + Severity.ERROR, + SourceElementPositioningStrategies.DEFAULT, + PsiElement::class, + Messages, + ) + + override fun getRendererFactory(): BaseDiagnosticRendererFactory = Messages + + object Messages : BaseDiagnosticRendererFactory() { + override val MAP: KtDiagnosticFactoryToRendererMap by KtDiagnosticFactoryToRendererMap("HollowEngineStages") { map -> + map.put(STAGE_ERROR, "{0}", CommonRenderers.STRING) + } + } +} + +/** Reports stage errors against the file being compiled and remembers whether there were any. */ +internal class StageReporter(private val context: IrPluginContext, private val file: IrFile) { + var errors = 0 + private set + + fun error(element: IrElement, message: String) { + errors++ + context.diagnosticReporter.at(element, file).report(StageDiagnostics.STAGE_ERROR, message) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt new file mode 100644 index 000000000..77586ff83 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -0,0 +1,191 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrTry +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.util.dump +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.acceptVoid + +/** + * Compiles stages of every script in a module: marks script classes so the runtime can find their + * instances, turns `@State` lambdas into stages, rewrites each stage body for saving and resuming, and generates + * entries, that runtime starts stages through. Does nothing without story runtime on the classpath. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageIrGeneration(private val context: IrPluginContext) { + private val symbols = StageSymbols.find(context) + private val types = StageTypes(context) + + fun generate(module: IrModuleFragment) { + val symbols = symbols ?: return + module.files.forEach { file -> + file.declarations.filterIsInstance().filter { it.origin == IrDeclarationOrigin.SCRIPT_CLASS } + .forEach { script -> + ScriptStages( + StageScope(context, symbols, types, StageReporter(context, file), script), file + ).compile() + } + } + } + + private class ScriptStages(private val stages: StageScope, private val file: IrFile) { + private val script = stages.script + private val reporter = stages.reporter + + fun compile() { + script.annotations += stages.scriptAnnotation(file.fileEntry.name) + val lifter = StageLambdaLifter(stages) + script.declarations.toList().forEach { it.transform(lifter, null) } + script.declarations += lifter.lifted + + val functions = script.declarations.filterIsInstance().filter(stages::isSuspendState) + functions.groupBy(stages::stageName).filterValues { it.size > 1 }.forEach { (name, duplicates) -> + duplicates.drop(1).forEach { + reporter.error( + it, + "Two stages of this script are named '$name'; give one of them different @State(name = ...)" + ) + } + } + functions.forEach(::compile) + checkMisplaced() + } + + private fun compile(function: IrSimpleFunction) { + if (!isValid(function)) return + val body = function.body as? IrBlockBody ?: return + val name = stages.stageName(function) + val hash = body.dump().hashCode() + if (!sitesArePlaced(function)) return + + val normalizer = StageNormalizer(stages, function) + val errors = errorCount() + val statements = normalizer.normalize(body.statements.toList()) + if (errorCount() != errors) return + val layout = StageLayout(stages, function).apply { layout(statements) } + layout.saved.forEach { variable -> + val site = layout.sites.first { variable in it.live } + stages.types.problem(variable.type)?.let { problem -> + val what = + if (variable.origin == IrDeclarationOrigin.IR_TEMPORARY_VARIABLE) "A value computed before" else "'${variable.name}', used after" + reporter.error( + variable, + "$what the stage call '${site.name}' is saved there, but $problem; compute it after the call or make it serializable" + ) + } + } + if (errorCount() != errors) return + StageRewriter(stages, function, layout).apply { + rewrite(statements, name, hash) + script.declarations += entry(name) + } + } + + private fun isValid(function: IrSimpleFunction): Boolean { + val problem = when { + function.parent != script -> "A stage must be declared at the top level of a script" + function.typeParameters.isNotEmpty() -> "A stage cannot have type parameters: its arguments are saved by their declared types" + function.parameters.any { it.kind == IrParameterKind.ExtensionReceiver || it.kind == IrParameterKind.Context } -> "A stage cannot have a receiver or context parameters; pass the value as an argument" + else -> null + } + problem?.let { reporter.error(function, it); return false } + var valid = true + function.parameters.filter { it.kind == IrParameterKind.Regular }.forEach { parameter -> + stages.types.problem(parameter.type)?.let { + reporter.error( + parameter, "The arguments of a stage are saved, but '${parameter.name}' cannot be: $it" + ) + valid = false + } + } + if (!function.returnType.isUnit()) stages.types.problem(function.returnType)?.let { + reporter.error(function, "The result of a stage is saved, but $it") + valid = false + } + return valid + } + + /** Stage calls inside lambdas, `catch` or `finally` of a stage cannot be resumed and are errors. */ + private fun sitesArePlaced(function: IrSimpleFunction): Boolean { + val errors = errorCount() + function.body?.acceptChildrenVoid(object : IrVisitorVoid() { + private var nested = 0 + private var handler = 0 + + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + nested++ + super.visitFunction(declaration) + nested-- + } + + override fun visitClass(declaration: IrClass) { + nested++ + super.visitClass(declaration) + nested-- + } + + override fun visitTry(aTry: IrTry) { + aTry.tryResult.acceptVoid(this) + handler++ + aTry.catches.forEach { it.acceptVoid(this) } + aTry.finallyExpression?.acceptVoid(this) + handler-- + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + stages.siteKind(expression) ?: return + when { + nested > 0 -> reporter.error( + expression, + "A stage cannot be called from a lambda inside a stage: its place could not be saved. Use async(name) { } or call it directly" + ) + + handler > 0 -> reporter.error( + expression, + "A stage cannot be called in catch or finally: keep what you need in a variable and call it after the try" + ) + } + } + }) + return errorCount() == errors + } + + /** Whatever is left after compiling the stages: checkpoints and `@State` where they cannot work. */ + private fun checkMisplaced() { + script.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + val marked = declaration.annotations.any { it.isAnnotation(StageSymbols.STATE) } + if (marked && declaration.parent != script) reporter.error( + declaration, "A state function must be declared at the top level of a script" + ) + else if (marked && !stages.isSuspendState(declaration)) reporter.error( + declaration, "A state function must be a suspend" + ) + super.visitFunction(declaration) + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + if (stages.symbols.isCheckpoint(expression.symbol.owner)) { + reporter.error(expression, "checkpoint() must be called directly in a @State function") + } + } + }) + } + + private fun errorCount(): Int = reporter.errors + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt new file mode 100644 index 000000000..c076349e7 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -0,0 +1,227 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.IrGetValueImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.patchDeclarationParents +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name +import java.util.Collections +import java.util.IdentityHashMap + +/** + * Turns every lambda written for a `@State` function type into a stage of its own. + * + * The body moves into a new function of the script, and what the lambda captured becomes its parameters, so the + * story can start the stage again after a restart with the captured values it saved. In place of the lambda the + * call gets a `StageLambda` naming that function. The stage is named after where the lambda was written + * (`onJoin$launchStory#1`), so the name survives edits elsewhere in the script. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLambdaLifter(private val stages: StageScope) : IrElementTransformerVoid() { + private val functions = ArrayDeque() + private val owners = ArrayDeque() + private val counters = HashMap() + + /** The functions made from lambdas, in the order they were made. */ + val lifted = ArrayList() + + override fun visitProperty(declaration: IrProperty): IrStatement { + owners.addLast(declaration.name.asString()) + return super.visitProperty(declaration).also { owners.removeLast() } + } + + override fun visitFunction(declaration: IrFunction): IrStatement { + val named = + declaration.parent == stages.script && declaration is IrSimpleFunction && declaration.correspondingPropertySymbol == null + + if (named) owners.addLast(declaration.name.asString()) + functions.addLast(declaration) + + return super.visitFunction(declaration).also { + functions.removeLast() + if (named) owners.removeLast() + } + } + + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val callee = expression.symbol.owner + callee.parameters.forEach { parameter -> + if (!stages.types.isStageLambda(parameter.type)) return@forEach + val lambda = expression.arguments[parameter] as? IrFunctionExpression ?: return@forEach + lift(lambda, callee.name.asString(), parameter)?.let { expression.arguments[parameter] = it } + } + return expression + } + + private fun lift(expression: IrFunctionExpression, callee: String, parameter: IrValueParameter): IrExpression? { + val lambda = expression.function + if (lambda.parameters.isNotEmpty()) { + stages.reporter.error(expression, "A @State lambda cannot take parameters or a receiver") + return null + } + val captured = captures(lambda) ?: return null + val owner = owners.lastOrNull() ?: "script" + val key = "$owner$$callee" + val ordinal = counters.merge(key, 1, Int::plus)!! + val name = "$key#$ordinal" + val parameters = createStage(lambda, name, captured) + + val scriptThis = scriptThis() + return stages.builder(functions.last().symbol, expression).irBlock(resultType = parameter.type) { + val tag = irTemporary(irCallConstructor(stages.symbols.compoundTagConstructor, emptyList())) + with(stages) { + parameters.forEach { (value, copy) -> + +callObject( + stages.symbols.put, + irGet(tag), + irString(copy.name.asString()), + serializer(value.type), + irGet(value) + ) + } + val key = callObject(stages.symbols.keyOf, irGet(scriptThis)) + val lambdaValue = + irCallConstructor(stages.symbols.stageLambdaConstructor, listOf(lambda.returnType)).apply { + arguments[0] = key + arguments[1] = irString(name) + arguments[2] = irGet(tag) + arguments[3] = serializer(lambda.returnType) + } + +irImplicitCast(lambdaValue, parameter.type) + } + } + } + + /** The values from outside [lambda] that it reads, or null when one of them cannot be saved. */ + private fun captures(lambda: IrSimpleFunction): List? { + val inside = declaredIn(lambda) + val captured = LinkedHashSet() + var valid = true + lambda.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitGetValue(expression: IrGetValue) { + val value = expression.symbol.owner + if (value !in inside && !stages.isScriptThis(value)) captured += value + } + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + if (expression.symbol.owner !in inside) { + stages.reporter.error( + expression, + "A @State lambda cannot assign '${expression.symbol.owner.name}': it keeps a saved copy" + ) + valid = false + } + } + }) + captured.forEach { value -> + if (value is IrVariable && value.isVar) { + stages.reporter.error( + lambda, "A @State lambda cannot capture 'var ${value.name}'; copy it into a val first" + ) + valid = false + } + stages.types.problem(value.type)?.let { problem -> + stages.reporter.error( + lambda, "A @State lambda saves what it captures, but '${value.name}' cannot be saved: $problem" + ) + valid = false + } + } + return captured.toList().takeIf { valid } + } + + private fun createStage( + lambda: IrSimpleFunction, + name: String, + captured: List, + ): Map { + val script = stages.script + val stage = stages.context.irFactory.buildFun { + startOffset = lambda.startOffset + endOffset = lambda.endOffset + this.name = Name.identifier($$"stage$lambda$$${lifted.size}") + returnType = lambda.returnType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + origin = IrDeclarationOrigin.DEFINED + } + stage.parent = script + val receiver = stage.buildReceiverParameter { type = script.defaultType } + stage.parameters = listOf(receiver) + val names = HashSet() + val parameters = captured.associateWith { value -> + val base = value.name.asString().takeIf { value.name.isSpecial.not() } ?: "captured" + val unique = generateSequence(base) { "${it}_" }.first(names::add) + stage.addValueParameter(unique, value.type) + } + stage.annotations = listOf(stages.stateAnnotation(name)) + val inside = declaredIn(lambda) + stage.body = lambda.body + stage.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitGetValue(expression: IrGetValue): IrExpression { + val value = expression.symbol.owner + val replacement = + parameters[value] ?: receiver.takeIf { stages.isScriptThis(value) && value !in inside } + ?: return expression + return IrGetValueImpl( + expression.startOffset, + expression.endOffset, + replacement.type, + replacement.symbol, + expression.origin + ) + } + + override fun visitReturn(expression: IrReturn): IrExpression { + expression.transformChildrenVoid(this) + if (expression.returnTargetSymbol == lambda.symbol) expression.returnTargetSymbol = stage.symbol + return expression + } + }) + stage.patchDeclarationParents(script) + lifted += stage + return parameters + } + + /** The script instance as seen from where the lambda is written. */ + private fun scriptThis(): IrValueParameter { + functions.reversed().forEach { function -> + function.parameters.firstOrNull { it.kind == IrParameterKind.DispatchReceiver && stages.isScriptThis(it) } + ?.let { return it } + } + return stages.script.thisReceiver ?: error("Script class ${stages.script.name} has no receiver") + } +} + +/** Every value declared in [root]: its own parameters, its variables and those of anything nested in it. */ +internal fun declaredIn(root: IrElement): Set { + val declared = Collections.newSetFromMap(IdentityHashMap()) + if (root is IrFunction) declared += root.parameters + root.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitDeclaration(declaration: IrDeclarationBase) { + if (declaration is IrValueDeclaration) declared += declaration + if (declaration is IrFunction) declared += declaration.parameters + super.visitDeclaration(declaration) + } + }) + return declared +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt new file mode 100644 index 000000000..bff898e72 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt @@ -0,0 +1,274 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.declarations.IrValueDeclaration +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.acceptVoid +import java.util.* + +/** How a stage call stands in its statement once [StageNormalizer] is done. */ +internal enum class SiteForm { STATEMENT, VARIABLE, ASSIGN, RETURN } + +/** + * A stage call or checkpoint inside a stage. Positions in the frame are `index * 2` while the call runs and + * `index * 2 + 1` after it; [name] is what the save stores instead. + */ +internal class StageSite( + val index: Int, + val name: String, + val call: IrCall, + val kind: SiteKind, + val form: SiteForm, + val tail: Boolean, +) { + /** The locals that are still needed after the call, so the frame saves them when it gets here. */ + var live: List = emptyList() + + val callPosition: Int get() = index * 2 + val afterPosition: Int get() = index * 2 + 1 +} + +/** + * Finds the sites of a normalized stage body and names them by where they are: the branches and loops around + * them, then the stage called and how many calls to it precede on that level (`when#1.else/trial#1`). A + * checkpoint is named by its own name. Then works out which locals each site has to save. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLayout(private val stages: StageScope, private val function: IrSimpleFunction) { + val sites = ArrayList() + private val byCall = IdentityHashMap() + private val checkpoints = HashSet() + + fun site(call: IrCall): StageSite? = byCall[call] + + /** Every local some site saves, in the order they are declared. */ + var saved: List = emptyList() + private set + + /** The key each saved local is stored under in the frame. */ + val keys = IdentityHashMap() + + fun layout(statements: List) { + level(statements, "", HashMap(), tail = true, inTry = false) + liveness(statements) + } + + /** The range of positions of the sites inside [element], or null when there are none. */ + fun positions(element: IrElement): IntRange? { + var first = Int.MAX_VALUE + var last = Int.MIN_VALUE + element.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) = Unit + + override fun visitCall(expression: IrCall) { + byCall[expression]?.let { + first = minOf(first, it.callPosition) + last = maxOf(last, it.afterPosition) + } + super.visitCall(expression) + } + }) + return if (first > last) null else first..last + } + + private fun level( + statements: List, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + statements.forEachIndexed { index, statement -> + val last = tail && index == statements.lastIndex + statement(statement, path, counters, last, inTry) + } + } + + private fun statement( + statement: IrStatement, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + siteCall(statement)?.let { (call, form) -> + register(call, form, path, counters, tail, inTry) + return + } + when (statement) { + is IrWhen -> { + val kind = if (statement.origin == IrStatementOrigin.IF) "if" else "when" + val label = "$path$kind#${counters.next(kind)}" + statement.branches.forEachIndexed { index, branch -> + val name = when { + branch is IrElseBranch || index == statement.branches.lastIndex && branch.condition.isTrue() -> "else" + kind == "if" -> "then" + else -> "b${index + 1}" + } + level(statementsOf(branch.result), "$label.$name/", HashMap(), tail, inTry) + } + } + + is IrWhileLoop, is IrDoWhileLoop -> { + level( + statementsOf(statement.body), + "${path}loop#${counters.next("loop")}/", + HashMap(), + tail = false, + inTry = inTry + ) + } + + is IrTry -> level( + statementsOf(statement.tryResult), + "${path}try#${counters.next("try")}/", + HashMap(), + tail = false, + inTry = true + ) + + is IrBlock -> level(statement.statements, path, counters, tail, inTry) + } + } + + private fun register( + call: IrCall, + form: SiteForm, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + val kind = stages.siteKind(call)!! + val name = when (kind) { + SiteKind.CHECKPOINT -> { + val name = (call.arguments[0] as? IrConst)?.value as? String + if (name.isNullOrBlank()) { + stages.reporter.error(call, "A checkpoint needs a constant name") + } else if (!checkpoints.add(name)) { + stages.reporter.error(call, "Checkpoint '$name' appears twice in '${function.name}'") + } + name ?: "checkpoint" + } + + SiteKind.STAGE -> { + val callee = stages.stageName(call.symbol.owner) + "$path$callee#${counters.next("call:$callee")}" + } + + SiteKind.LAMBDA -> { + val receiver = (call.dispatchReceiver as? IrGetValue)?.symbol?.owner?.name?.asString() ?: "lambda" + "$path$receiver#${counters.next("call:$receiver")}" + } + } + val isTail = kind == SiteKind.STAGE && !inTry && when (form) { + SiteForm.RETURN -> true + SiteForm.STATEMENT -> tail && function.returnType.isUnit() && call.type.isUnit() + else -> false + } && call.arguments.drop(1).all { it != null } + StageSite(sites.size, name, call, kind, form, isTail).also { + sites += it + byCall[call] = it + } + } + + /** Works out [StageSite.live] in evaluation order: a local is saved at a site when it is read after it. */ + private fun liveness(statements: List) { + var clock = 0 + val declared = IdentityHashMap() + val reads = IdentityHashMap>() + val at = IdentityHashMap() + val loops = ArrayList() + var depth = 0 + + val visitor = object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + depth++ + super.visitFunction(declaration) + depth-- + } + + override fun visitVariable(declaration: IrVariable) { + declaration.initializer?.acceptVoid(this) + if (depth == 0) declared[declaration] = ++clock + } + + override fun visitGetValue(expression: IrGetValue) { + reads.getOrPut(expression.symbol.owner, ::ArrayList) += ++clock + } + + override fun visitSetValue(expression: IrSetValue) { + expression.value.acceptVoid(this) + ++clock + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + byCall[expression]?.let { at[it] = ++clock } + } + + override fun visitLoop(loop: IrLoop) { + val start = ++clock + super.visitLoop(loop) + loops += start..++clock + } + } + statements.forEach { it.acceptVoid(visitor) } + + val saved = LinkedHashSet() + sites.forEach { site -> + val time = at.getValue(site) + site.live = declared.entries.filter { (variable, declaredAt) -> + if (declaredAt >= time) return@filter false + val uses = reads[variable].orEmpty() + uses.any { it > time } || loops.any { loop -> time in loop && declaredAt < loop.first && uses.any { it in loop } } + }.sortedBy { it.value }.map { it.key } + saved += site.live + } + this.saved = saved.sortedBy { declared.getValue(it) } + val names = HashMap() + this.saved.forEach { variable -> + val base = variable.name.asString() + val count = names.merge(base, 1, Int::plus)!! + keys[variable] = if (count == 1) base else "$base#$count" + } + } + + private fun siteCall(statement: IrStatement): Pair? { + fun IrExpression?.site(): IrCall? = (this as? IrCall)?.takeIf { stages.siteKind(it) != null } + return when (statement) { + is IrCall -> statement.site()?.let { it to SiteForm.STATEMENT } + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> statement.argument.site() + ?.let { it to SiteForm.STATEMENT } + + is IrVariable -> statement.initializer.site()?.let { it to SiteForm.VARIABLE } + is IrSetValue -> statement.value.site()?.let { it to SiteForm.ASSIGN } + is IrReturn -> statement.value.site()?.takeIf { statement.returnTargetSymbol == function.symbol } + ?.let { it to SiteForm.RETURN } + + else -> null + } + } + + private fun HashMap.next(key: String): Int = merge(key, 1, Int::plus)!! + + private fun IrExpression.isTrue() = this is IrConst && value == true +} + +internal fun statementsOf(expression: IrExpression?): List = when (expression) { + null -> emptyList() + is IrContainerExpression -> expression.statements + else -> listOf(expression) +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt new file mode 100644 index 000000000..bc7c2aac7 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt @@ -0,0 +1,422 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.declarations.impl.IrVariableImpl +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.* +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.symbols.impl.IrVariableSymbolImpl +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.isNothing +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.util.* +import org.jetbrains.kotlin.ir.visitors.* +import org.jetbrains.kotlin.name.Name + +/** + * Brings a stage body into the shape [StageRewriter] works on: every stage call and checkpoint becomes a statement + * of its own: `call()`, `val x = call()`, `x = call()` or `return call()` - whose arguments hold no stage call, + * and conditions of `if`, `when` and `while` hold none either. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageNormalizer(private val stages: StageScope, private val function: IrSimpleFunction) { + private val builtIns = stages.context.irBuiltIns + private val intPlus: IrSimpleFunctionSymbol = + builtIns.intClass.owner.declarations.filterIsInstance() + .single { it.name.asString() == "plus" && it.parameters.size == 2 && it.parameters[1].type == builtIns.intType }.symbol + private var temporaries = 0 + + /** Statements that must run first, then a value that holds no stage call except possibly at its top. */ + private class Lifted(val before: List, val value: IrExpression) + + fun normalize(statements: List): List = statements.flatMap(::statement) + + /** Whether [element] calls a stage or a checkpoint, not counting nested functions. */ + fun hasSite(element: IrElement): Boolean { + var found = false + element.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + if (!found) element.acceptChildrenVoid(this) + } + + override fun visitFunction(declaration: IrFunction) = Unit + + override fun visitCall(expression: IrCall) { + if (stages.siteKind(expression) != null) found = true else super.visitCall(expression) + } + }) + return found + } + + private fun isSiteCall(expression: IrExpression) = expression is IrCall && stages.siteKind(expression) != null + + private fun statement(statement: IrStatement): List { + if (!hasSite(statement)) return listOf(statement) + return when (statement) { + is IrVariable -> value(statement.initializer!!).let { lifted -> + statement.initializer = lifted.value + lifted.before + statement + } + + is IrSetValue -> value(statement.value).let { lifted -> + statement.value = lifted.value + lifted.before + statement + } + + is IrReturn -> value(statement.value).let { lifted -> + statement.value = lifted.value + lifted.before + statement + } + + is IrThrow -> value(statement.value).let { lifted -> + val operand = materialize(lifted.value) + statement.value = operand.value + lifted.before + operand.before + statement + } + + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> value(statement.argument).let { lifted -> + statement.argument = lifted.value + lifted.before + statement + } + + is IrWhen -> listOf(whenStatement(statement)) + is IrWhileLoop -> whileLoop(statement) + is IrDoWhileLoop -> doWhileLoop(statement) + is IrBlock if statement.origin == IrStatementOrigin.FOR_LOOP -> forLoop(statement) + is IrComposite -> normalize(statement.statements) + is IrBlock -> { + val normalized = normalize(statement.statements) + statement.statements.clear() + statement.statements += normalized + listOf(statement) + } + + is IrTry -> { + statement.tryResult = block(normalize(statementsOf(statement.tryResult))) + statement.type = builtIns.unitType + listOf(statement) + } + + is IrExpression -> value(statement).let { it.before + it.value } + else -> unsupported(statement) + } + } + + /** Lifts the stage calls out of [expression]. */ + private fun value(expression: IrExpression): Lifted { + if (!hasSite(expression)) return Lifted(emptyList(), expression) + return when (expression) { + is IrFunctionAccessExpression -> { + val lifted = operands(expression.arguments.toList()) + lifted.second.forEachIndexed { index, operand -> expression.arguments[index] = operand } + Lifted(lifted.first, expression) + } + + is IrWhen -> whenValue(expression) + is IrContainerExpression -> { + val statements = expression.statements + val last = statements.lastOrNull() as? IrExpression + if (last == null || expression.type.isUnit()) return Lifted(normalize(statements), unit()) + val lifted = value(last) + Lifted(normalize(statements.dropLast(1)) + lifted.before, lifted.value) + } + + is IrTypeOperatorCall -> { + val lifted = value(expression.argument) + val operand = materialize(lifted.value) + expression.argument = operand.value + Lifted(lifted.before + operand.before, expression) + } + + is IrGetField -> { + val lifted = operands(listOf(expression.receiver)) + expression.receiver = lifted.second[0] + Lifted(lifted.first, expression) + } + + is IrSetField -> { + val lifted = operands(listOf(expression.receiver, expression.value)) + expression.receiver = lifted.second[0] + expression.value = lifted.second[1]!! + Lifted(lifted.first, expression) + } + + is IrStringConcatenation -> { + val lifted = operands(expression.arguments.toList()) + lifted.second.forEachIndexed { index, operand -> expression.arguments[index] = operand!! } + Lifted(lifted.first, expression) + } + + is IrReturn, is IrThrow -> Lifted(statement(expression), zero(expression.type)) + is IrTry -> tryValue(expression) + else -> Lifted(unsupported(expression), expression) + } + } + + /** + * Lifts [operands] in the order they are evaluated. Whatever is evaluated before the last operand that calls a + * stage moves into a temporary, so resuming that stage does not evaluate it again. + */ + private fun operands(operands: List): Pair, List> { + val last = operands.indexOfLast { it != null && hasSite(it) } + val before = ArrayList() + val result = operands.toMutableList() + for (index in 0..last) { + val lifted = value(operands[index] ?: continue) + before += lifted.before + result[index] = if (isSiteCall(lifted.value) || index < last && !isTrivial(lifted.value)) { + val temporary = variable(lifted.value.type, initializer = lifted.value) + before += temporary + get(temporary) + } else lifted.value + } + return before to result + } + + private fun whenValue(expression: IrWhen): Lifted { + if (expression.type.isUnit() || expression.type.isNothing()) return Lifted( + listOf(whenStatement(expression)), + unit() + ) + val result = variable(expression.type, isVar = true, initializer = zero(expression.type)) + expression.branches.forEach { branch -> branch.result = assign(result, branch.result) } + return Lifted(listOf(result, whenStatement(expression)), get(result)) + } + + private fun tryValue(expression: IrTry): Lifted { + if (expression.type.isUnit()) return Lifted(statement(expression), unit()) + val result = variable(expression.type, isVar = true, initializer = zero(expression.type)) + expression.tryResult = assign(result, expression.tryResult) + expression.catches.forEach { it.result = assign(result, it.result) } + return Lifted(listOf(result) + statement(expression), get(result)) + } + + /** [expression] as statements that end by storing its value in [target]. */ + private fun assign(target: IrVariable, expression: IrExpression): IrExpression { + if (expression is IrContainerExpression) { + val statements = expression.statements + val last = statements.lastOrNull() as? IrExpression ?: return expression + return block(statements.dropLast(1) + assign(target, last)) + } + if (expression.type.isNothing()) return expression + return IrSetValueImpl( + expression.startOffset, + expression.endOffset, + builtIns.unitType, + target.symbol, + expression, + null + ) + } + + private fun whenStatement(expression: IrWhen): IrExpression { + val branches = ArrayList() + for ((index, branch) in expression.branches.withIndex()) { + if (branch !is IrElseBranch && hasSite(branch.condition)) { + val lifted = value(branch.condition) + val condition = materialize(lifted.value) + val rest = IrWhenImpl( + expression.startOffset, + expression.endOffset, + builtIns.unitType, + expression.origin + ).apply { + branches += IrBranchImpl(branch.startOffset, branch.endOffset, condition.value, branch.result) + branches += expression.branches.drop(index + 1) + } + val nested = block(lifted.before + condition.before + whenStatement(rest)) + branches += IrElseBranchImpl(branch.startOffset, branch.endOffset, constTrue(), nested) + break + } + branch.result = block(normalize(statementsOf(branch.result))) + branches += branch + } + expression.branches.clear() + expression.branches += branches + expression.type = builtIns.unitType + return expression + } + + private fun whileLoop(loop: IrWhileLoop): List { + val body = normalize(statementsOf(loop.body)) + if (!hasSite(loop.condition)) { + loop.body = block(body) + return listOf(loop) + } + val lifted = value(loop.condition) + val condition = materialize(lifted.value) + val builder = stages.builder(function.symbol, loop) + val exit = IrWhenImpl(loop.startOffset, loop.endOffset, builtIns.unitType, null).apply { + branches += IrBranchImpl( + loop.startOffset, loop.endOffset, + builder.irCall(builtIns.booleanNotSymbol).apply { arguments[0] = condition.value }, + IrBreakImpl(loop.startOffset, loop.endOffset, builtIns.nothingType, loop), + ) + } + loop.condition = constTrue() + loop.body = block(lifted.before + condition.before + exit + body) + return listOf(loop) + } + + private fun doWhileLoop(loop: IrDoWhileLoop): List { + if (hasSite(loop.condition)) stages.reporter.error( + loop.condition, + "A do-while condition cannot call a stage; call it at the end of the body" + ) + loop.body = block(normalize(statementsOf(loop.body))) + return listOf(loop) + } + + /** + * Rewrites `for (x in list/range)` to count with an index the frame can save. Other iterables + * keep their iterator, and calling a stage inside them is an error. + */ + private fun forLoop(block: IrBlock): List { + val iterator = block.statements.firstOrNull() as? IrVariable + val loop = block.statements.getOrNull(1) as? IrWhileLoop + val iterable = (iterator?.initializer as? IrCall)?.arguments?.firstOrNull() + if (iterator?.origin != IrDeclarationOrigin.FOR_LOOP_ITERATOR || loop == null || iterable == null) { + return unsupported(block) + } + + val builder = stages.builder(function.symbol, block) + val index = variable(builtIns.intType, isVar = true, initializer = builder.irInt(-1), name = "index") + val prologue = ArrayList() + val bound: IrExpression + val element: (IrType) -> IrExpression + val progressionClass = stages.symbols.intProgression + when { + iterable.type.isSubtypeOfClass(builtIns.listClass) -> { + val list = variable(iterable.type, initializer = iterable, name = "list") + val listClass = builtIns.listClass.owner + prologue += list + bound = builder.irCall(listClass.getPropertyGetter("size")!!).apply { arguments[0] = get(list) } + element = { type -> + builder.irImplicitCast(builder.irCall(listClass.getSimpleFunction("get")!!).apply { + arguments[0] = get(list) + arguments[1] = get(index) + }, type) + } + } + + progressionClass != null && iterable.type.isSubtypeOfClass(progressionClass) -> { + val progression = variable(iterable.type, initializer = iterable, name = "range") + fun property(name: String) = builder.irCall(progressionClass.owner.getPropertyGetter(name)!!).apply { + arguments[0] = get(progression) + } + + val first = variable(builtIns.intType, initializer = property("first"), name = "first") + val step = variable(builtIns.intType, initializer = property("step"), name = "step") + val count = variable( + builtIns.intType, + initializer = with(stages) { builder.callObject(stages.symbols.loopCount, get(progression)) }, + name = "count" + ) + prologue += listOf(progression, first, step, count) + bound = get(count) + element = { + with(stages) { + builder.callObject( + stages.symbols.loopElement, + get(first), + get(step), + get(index) + ) + } + } + } + + else -> { + stages.reporter.error( + block, + "A for loop that calls stages can only go over a List or an Int range, not ${iterable.type.render()}" + ) + return listOf(block) + } + } + + loop.condition = builder.irBlock(resultType = builtIns.booleanType) { + +irSet(index, irCall(intPlus).apply { + arguments[0] = irGet(index) + arguments[1] = irInt(1) + }) + +irCall(builtIns.lessFunByOperandType.getValue(builtIns.intClass)).apply { + arguments[0] = irGet(index) + arguments[1] = bound + } + } + loop.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val receiver = expression.arguments.firstOrNull() as? IrGetValue + return if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) { + element(expression.type) + } else expression + } + }) + loop.origin = null + loop.body = block(normalize(statementsOf(loop.body))) + block.statements.clear() + block.statements += prologue + index + loop + block.origin = null + return listOf(block) + } + + /** Moves a stage call out of an operand into a temporary; anything else stays where it is. */ + private fun materialize(expression: IrExpression): Lifted { + if (!isSiteCall(expression)) return Lifted(emptyList(), expression) + val temporary = variable(expression.type, initializer = expression) + return Lifted(listOf(temporary), get(temporary)) + } + + private fun variable( + type: IrType, + isVar: Boolean = false, + initializer: IrExpression? = null, + name: String = "tmp", + ): IrVariable = IrVariableImpl( + initializer?.startOffset ?: function.startOffset, initializer?.endOffset ?: function.endOffset, + IrDeclarationOrigin.IR_TEMPORARY_VARIABLE, IrVariableSymbolImpl(), + Name.identifier("$name\$${temporaries++}"), type, isVar, isConst = false, isLateinit = false, + ).also { + it.parent = function + it.initializer = initializer + } + + private fun get(variable: IrVariable): IrExpression = stages.builder(function.symbol, variable).irGet(variable) + + private fun isTrivial(expression: IrExpression): Boolean = when (expression) { + is IrConst, is IrGetObjectValue, is IrGetEnumValue, is IrClassReference, is IrFunctionExpression -> true + is IrGetValue -> (expression.symbol.owner as? IrVariable)?.isVar != true + else -> false + } + + private fun statementsOf(expression: IrExpression?): List = when (expression) { + null -> emptyList() + is IrComposite -> expression.statements + is IrBlock if expression.origin == null -> expression.statements + else -> listOf(expression) + } + + private fun block(statements: List): IrBlock = + IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, statements) + + private fun zero(type: IrType): IrExpression = + IrConstImpl.defaultValueForType(function.startOffset, function.endOffset, type) + + private fun unit(): IrExpression = stages.builder(function.symbol, function).irGetObject(builtIns.unitClass) + + private fun constTrue(): IrExpression = stages.builder(function.symbol, function).irTrue() + + private fun unsupported(element: IrElement): List { + stages.reporter.error(element, "A stage cannot be called here; move the call into a statement of its own") + return emptyList() + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt new file mode 100644 index 000000000..6be5be72f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -0,0 +1,466 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildVariable +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.isNothing +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.defaultValueForType +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name + +/** + * Rewrites a normalized stage body so its frame can lead it back to where it was saved. + * + * The body keeps its structure. Every statement runs only while the stage is not resuming, or when the position + * it resumes to lies inside it; a branch or loop that holds that position is entered without evaluating its + * condition again. A stage call saves the live locals, marks the frame and cStatealls; resuming it either calls the + * saved callee again or takes the result it had. A tail call leaves the frame to the callee. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageRewriter( + private val stages: StageScope, + private val function: IrSimpleFunction, + private val layout: StageLayout, +) { + private val symbols = stages.symbols + private val builtIns = stages.context.irBuiltIns + private val returnType = function.returnType + private val saved = layout.saved.toSet() + private lateinit var frame: IrVariable + + /** Replaces the body of the stage with its rewritten form. */ + fun rewrite(statements: List, name: String, hash: Int) { + val builder = stages.builder(function.symbol, function) + val receiver = function.parameters.first { it.kind == IrParameterKind.DispatchReceiver } + frame = buildVariable( + function, + function.startOffset, + function.endOffset, + IrDeclarationOrigin.DEFINED, + Name.identifier("\$frame"), + symbols.frameType + ) + frame.initializer = with(stages) { + builder.callObject( + symbols.enter, builder.irGet(receiver), builder.irString(name), builder.irInt(hash), + builder.irCall(builtIns.arrayOf).apply { + typeArguments[0] = builtIns.stringType + arguments[0] = builder.irVararg(builtIns.stringType, layout.sites.map { builder.irString(it.name) }) + }, + ) + } + + if (!returnType.isUnit()) reportReturns(statements) + val rewritten = statements.map(::statement) + val body = ArrayList() + function.parameters.filter { it.kind == IrParameterKind.Regular }.forEach { parameter -> + body += frameCall( + builder, + "saveArg", + builder.irString(parameter.name.asString()), + serializer(builder, parameter.type), + builder.irGet(parameter) + ) + } + layout.saved.forEach { variable -> + variable.isVar = true + variable.initializer = zero(variable.type) + body += variable + } + if (layout.saved.isNotEmpty()) { + body += builder.irIfThen(builtIns.unitType, frameCall(builder, "isResuming"), builder.irBlock { + layout.saved.forEach { variable -> + val restored = frameCall( + this, + "restore", + irString(layout.keys.getValue(variable)), + serializer(this, variable.type), + irGet(variable) + ) + +irSet(variable, irAs(restored, variable.type)) + } + }) + } + body += rewritten + if (!returnType.isUnit()) body += frameCall(builder, "lost") + + val failure = buildVariable( + function, + function.startOffset, + function.endOffset, + IrDeclarationOrigin.CATCH_PARAMETER, + Name.identifier("\$failure"), + builtIns.throwableType + ) + val rethrow = builder.irBlock(resultType = builtIns.nothingType) { + +frameCall(this, "abort", irGet(failure)) + +IrThrowImpl(startOffset, endOffset, builtIns.nothingType, irGet(failure)) + } + val guarded = IrTryImpl( + function.startOffset, function.endOffset, + if (returnType.isUnit()) builtIns.unitType else builtIns.nothingType, + IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, body), + listOf(IrCatchImpl(function.startOffset, function.endOffset, failure, rethrow)), + frameCall(builder, "leave"), + ) + function.body = builder.irBlockBody { + +frame + +guarded + } + } + + private fun statement(statement: IrStatement): IrStatement { + siteOf(statement)?.let { return expandSite(it, statement) } + if (statement is IrVariable && statement in saved) return assignSaved(statement) + val positions = layout.positions(statement) ?: return guardFresh(statement) + return when (statement) { + is IrWhen -> guard(positions, routeWhen(statement)) + is IrWhileLoop -> guard(positions, routeWhile(statement)) + is IrDoWhileLoop -> guard(positions, statement.also { it.body = rewriteBlock(it.body) }) + is IrTry -> guard(positions, statement.also { it.tryResult = rewriteBlock(it.tryResult)!! }) + is IrBlock -> guard(positions, statement.also { block -> + val rewritten = block.statements.map(::statement) + block.statements.clear() + block.statements += rewritten + }) + + else -> statement.also { + stages.reporter.error( + it, + "A stage cannot be called here; move the call into a statement of its own" + ) + } + } + } + + /** A saved local is declared at the top of the stage, so where it was declared it is only assigned. */ + private fun assignSaved(variable: IrVariable): IrStatement { + val builder = stages.builder(function.symbol, variable) + val initializer = variable.initializer ?: return builder.irGetObject(builtIns.unitClass) + return builder.irIfThen( + builtIns.unitType, + not(builder, frameCall(builder, "isResuming")), + builder.irSet(variable, initializer) + ) + } + + /** A statement without sites runs only when the stage is not resuming. Declarations stay where they are. */ + private fun guardFresh(statement: IrStatement): IrStatement { + val builder = stages.builder(function.symbol, statement) + if (statement is IrVariable) { + val initializer = statement.initializer ?: return statement + statement.initializer = builder.irIfThenElse( + statement.type, + frameCall(builder, "isResuming"), + zero(statement.type), + initializer + ) + return statement + } + val expression = statement as? IrExpression ?: return statement + return builder.irIfThen(builtIns.unitType, not(builder, frameCall(builder, "isResuming")), expression) + } + + private fun guard(positions: IntRange, statement: IrExpression): IrExpression { + val builder = stages.builder(function.symbol, statement) + return builder.irIfThen( + builtIns.unitType, + frameCall(builder, "enters", builder.irInt(positions.first), builder.irInt(positions.last)), + statement + ) + } + + /** Each branch is taken when the stage resumes into it, or when it is not resuming and its condition holds. */ + private fun routeWhen(expression: IrWhen): IrWhen { + val builder = stages.builder(function.symbol, expression) + val branches = expression.branches.map { branch -> + val positions = layout.positions(branch.result) + val fresh = and(builder, not(builder, frameCall(builder, "isResuming")), branch.condition) + val condition = positions?.let { + or(builder, frameCall(builder, "resumesInto", builder.irInt(it.first), builder.irInt(it.last)), fresh) + } ?: fresh + IrBranchImpl(branch.startOffset, branch.endOffset, condition, rewriteBlock(branch.result)!!) as IrBranch + } + expression.branches.clear() + expression.branches += branches + return expression + } + + /** A loop the stage resumes into is entered without its condition, which held when the stage was saved. */ + private fun routeWhile(loop: IrWhileLoop): IrExpression = stages.builder(function.symbol, loop).irBlock { + val entering = irTemporary(frameCall(this, "isResuming"), "resuming", isMutable = true) + loop.condition = or(this, irGet(entering), loop.condition) + loop.body = IrBlockImpl( + loop.startOffset, + loop.endOffset, + builtIns.unitType, + null, + listOf(irSet(entering, irFalse()), rewriteBlock(loop.body)!!) + ) + +loop + } + + private fun rewriteBlock(expression: IrExpression?): IrExpression? = expression?.let { + IrBlockImpl(it.startOffset, it.endOffset, builtIns.unitType, null, statementsOf(it).map(::statement)) + } + + private fun expandSite(site: StageSite, statement: IrStatement): IrStatement { + val builder = stages.builder(function.symbol, statement) + return when (site.form) { + SiteForm.STATEMENT -> if (site.tail) tailCall(builder, site) else value(builder, site, used = false) + SiteForm.VARIABLE -> { + val variable = statement as IrVariable + val value = value(builder, site, used = true) + if (variable in saved) builder.irSet(variable, value) else variable.also { it.initializer = value } + } + + SiteForm.ASSIGN -> (statement as IrSetValue).also { it.value = value(builder, site, used = true) } + SiteForm.RETURN -> if (site.tail) tailCall(builder, site) else { + (statement as IrReturn).also { + it.value = returning(builder, value(builder, site, used = true), site.call.type) + } + } + } + } + + /** The value of a site: resumed, taken from the save, or computed by calling. */ + private fun value(builder: IrBuilderWithScope, site: StageSite, used: Boolean): IrExpression { + val type = if (used) site.call.type else builtIns.unitType + val keepsResult = !type.isUnit() && !type.isNothing() + val fresh = builder.irBlock(resultType = type) { + saveLocals(site) + if (site.kind == SiteKind.CHECKPOINT) { + +frameCall(this, "checkpoint", irInt(site.index)) + } else { + +frameCall(this, "beginCall", irInt(site.index)) + val completed = frameCall(this, "completeCall", irInt(site.index), site.call) + +(if (keepsResult) irAs(completed, type) else completed) + } + } + val after = frameCall( + builder, + "resumeAfter", + builder.irInt(site.index), + if (keepsResult) serializer(builder, type) else nullSerializer() + ) + val branches = ArrayList() + if (site.kind != SiteKind.CHECKPOINT) { + val resumed = frameCall(builder, "resumeCall", builder.irInt(site.index)) + branches += builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.callPosition)), + if (keepsResult) builder.irAs(resumed, type) else resumed + ) + } + branches += builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.afterPosition)), + if (keepsResult) builder.irAs(after, type) else after + ) + branches += builder.irBranch(frameCall(builder, "isResuming"), zero(type)) + branches += builder.irElseBranch(fresh) + return builder.irWhen(type, branches) + } + + /** + * A call in tail position. When the caller runs tail calls, the frame gives its place to the callee and the + * stage returns at once; otherwise it is an ordinary call whose result the stage returns. + */ + private fun tailCall(builder: IrBuilderWithScope, site: StageSite): IrExpression { + val call = site.call + val type = call.type + val callee = call.symbol.owner + val resumed = builder.irBlock(resultType = builtIns.nothingType) { + val callBranch = irBranch( + frameCall(this, "isResumingAt", irInt(site.callPosition)), + frameCall(this, "resumeCall", irInt(site.index)) + ) + if (type.isUnit()) { + +irWhen( + builtIns.unitType, + listOf( + callBranch, + irElseBranch(frameCall(this, "resumeAfter", irInt(site.index), nullSerializer())) + ) + ) + +irReturn(zero(returnType)) + } else { + val after = frameCall(this, "resumeAfter", irInt(site.index), serializer(this, type)) + val value = irAs(irWhen(builtIns.anyNType, listOf(callBranch, irElseBranch(after))), type) + +irReturn(returning(this, value, type)) + } + } + val fresh = builder.irBlock(resultType = builtIns.nothingType) { + val arguments = call.arguments.map { argument -> argument?.let { irTemporary(it, "argument") } } + arguments.forEachIndexed { index, temporary -> call.arguments[index] = temporary?.let(::irGet) } + val handOver = irBlock(resultType = builtIns.nothingType) { + val tag = irTemporary(irCallConstructor(symbols.compoundTagConstructor, emptyList())) + callee.parameters.forEachIndexed { index, parameter -> + if (parameter.kind != IrParameterKind.Regular) return@forEachIndexed + +with(stages) { + callObject( + symbols.put, + irGet(tag), + irString(parameter.name.asString()), + serializer(parameter.type), + irGet(arguments[index]!!) + ) + } + } + +frameCall(this, "tailCall", irGet(arguments[0]!!), irString(stages.stageName(callee)), irGet(tag)) + +irReturn(zero(returnType)) + } + val ordinary = irBlock(resultType = builtIns.nothingType) { + saveLocals(site) + +frameCall(this, "beginCall", irInt(site.index)) + val completed = frameCall(this, "completeCall", irInt(site.index), call) + if (returnType.isUnit()) { + +completed + +irReturn(zero(returnType)) + } else +irReturn(returning(this, irAs(completed, type), type)) + } + +irIfThenElse(builtIns.nothingType, frameCall(this, "canTailCall"), handOver, ordinary) + } + return builder.irWhen( + builtIns.unitType, listOf( + builder.irBranch( + frameCall( + builder, + "resumesInto", + builder.irInt(site.callPosition), + builder.irInt(site.afterPosition) + ), resumed + ), + builder.irBranch(frameCall(builder, "isResuming"), zero(builtIns.unitType)), + builder.irElseBranch(fresh), + ) + ) + } + + /** Every `return` of the stage that is not a stage call hands its value to the frame as well. */ + private fun reportReturns(statements: List) { + val transformer = object : IrElementTransformerVoid() { + override fun visitFunction(declaration: IrFunction): IrStatement = declaration + + override fun visitReturn(expression: IrReturn): IrExpression { + expression.transformChildrenVoid(this) + val site = (expression.value as? IrCall)?.let(layout::site) + if (expression.returnTargetSymbol == function.symbol && site == null) { + expression.value = + returning(stages.builder(function.symbol, expression), expression.value, expression.value.type) + } + return expression + } + } + IrBlockImpl( + function.startOffset, + function.endOffset, + builtIns.unitType, + null, + statements + ).transformChildrenVoid(transformer) + } + + /** Hands the value a stage returns to its frame, which keeps it for a caller resumed after a restart. */ + private fun returning(builder: IrBuilderWithScope, value: IrExpression, type: IrType): IrExpression = + builder.irAs(frameCall(builder, "returning", value, serializer(builder, returnType)), type) + + private fun IrBlockBuilder.saveLocals(site: StageSite) { + site.live.forEach { variable -> + +frameCall( + this, + "save", + irString(layout.keys.getValue(variable)), + serializer(this, variable.type), + irGet(variable) + ) + } + } + + private fun siteOf(statement: IrStatement): StageSite? { + val call = when (statement) { + is IrCall -> statement + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> statement.argument as? IrCall + is IrVariable -> statement.initializer as? IrCall + is IrSetValue -> statement.value as? IrCall + is IrReturn -> statement.value as? IrCall + else -> null + } ?: return null + return layout.site(call) + } + + private fun frameCall(builder: IrBuilderWithScope, name: String, vararg arguments: IrExpression): IrCall = + builder.irCall(symbols.frameFunction(name)).apply { + this.arguments[0] = builder.irGet(frame) + arguments.forEachIndexed { index, argument -> this.arguments[index + 1] = argument } + } + + private fun serializer(builder: IrBuilderWithScope, type: IrType): IrExpression = + with(stages) { builder.serializer(type) } + + private fun not(builder: IrBuilderWithScope, value: IrExpression): IrExpression = + builder.irCall(builtIns.booleanNotSymbol).apply { arguments[0] = value } + + private fun and(builder: IrBuilderWithScope, left: IrExpression, right: IrExpression): IrExpression = + builder.irIfThenElse(builtIns.booleanType, left, right, builder.irFalse(), IrStatementOrigin.ANDAND) + + private fun or(builder: IrBuilderWithScope, left: IrExpression, right: IrExpression): IrExpression = + builder.irIfThenElse(builtIns.booleanType, left, builder.irTrue(), right, IrStatementOrigin.OROR) + + private fun nullSerializer(): IrExpression = + IrConstImpl.constNull(function.startOffset, function.endOffset, symbols.serializerType) + + private fun zero(type: IrType): IrExpression = + if (type.isUnit()) stages.builder(function.symbol, function).irGetObject(builtIns.unitClass) + else IrConstImpl.defaultValueForType(function.startOffset, function.endOffset, type) + + /** Generates the entry the runtime calls to start or resume the stage with saved arguments. */ + fun entry(name: String): IrSimpleFunction { + val script = stages.script + val entry = stages.context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier($$"stage$entry$$${function.name.asString()}") + returnType = builtIns.anyNType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + } + entry.parent = script + val receiver = entry.buildReceiverParameter { type = script.defaultType } + entry.parameters = listOf(receiver) + val args = entry.addValueParameter("args", symbols.compoundTagType) + entry.annotations = listOf(stages.entryAnnotation(name)) + entry.body = stages.builder(entry.symbol, function).irBlockBody { + val call = irCall(function.symbol).apply { + function.parameters.forEachIndexed { index, parameter -> + arguments[index] = if (parameter.kind == IrParameterKind.DispatchReceiver) irGet(receiver) else { + val value = with(stages) { + callObject( + symbols.get, + irGet(args), + irString(parameter.name.asString()), + serializer(parameter.type) + ) + } + irAs(value, parameter.type) + } + } + } + if (returnType.isUnit()) { + +call + +irReturn(irGetObject(builtIns.unitClass)) + } else +irReturn(call) + } + return entry + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt new file mode 100644 index 000000000..d59caa593 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -0,0 +1,89 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrValueDeclaration +import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.IrSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.getValueArgument +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.util.isSuspend +import org.jetbrains.kotlin.name.Name + +/** What the stage passes over one script class share: symbols, error reporting, the script itself. */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageScope( + val context: IrPluginContext, + val symbols: StageSymbols, + val types: StageTypes, + val reporter: StageReporter, + val script: IrClass, +) { + fun builder(symbol: IrSymbol, at: IrElement): DeclarationIrBuilder = + DeclarationIrBuilder(context, symbol, at.startOffset, at.endOffset) + + /** Whether [function] is a stage: a suspend function marked `@State`. */ + fun isSuspendState(function: IrFunction): Boolean = function.isSuspend && stateAnnotation(function) != null + + /** The name saves know a stage by. */ + fun stageName(function: IrFunction): String { + val explicit = stateAnnotation(function)?.getValueArgument(Name.identifier("name")) as? IrConst + return (explicit?.value as? String)?.takeIf(String::isNotBlank) ?: function.name.asString() + } + + /** What [call] is from the point of view of the stage it is in, or null when it is an ordinary call. */ + fun siteKind(call: IrCall): SiteKind? { + val callee = call.symbol.owner + return when { + symbols.isCheckpoint(callee) -> SiteKind.CHECKPOINT + isSuspendState(callee) -> SiteKind.STAGE + callee.name.asString() == "invoke" && call.dispatchReceiver?.type?.let(types::isStageLambda) == true -> SiteKind.LAMBDA + else -> null + } + } + + /** Whether [value] is the script instance itself. */ + fun isScriptThis(value: IrValueDeclaration): Boolean = + value is IrValueParameter && value.type.classOrNull == script.symbol + + /** The serializer of [type]; function types are kept as the stage lambdas they must be. */ + fun IrBuilderWithScope.serializer(type: IrType): IrExpression = irCall(symbols.storySerializer).apply { + typeArguments[0] = + if (types.isStageLambda(type) || types.isFunction(type)) symbols.stageLambda.typeWith(context.irBuiltIns.anyNType) else type + } + + /** Calls [function] with [arguments] for all its parameters in order, receivers first. */ + fun IrBuilderWithScope.call(function: IrSimpleFunctionSymbol, vararg arguments: IrExpression?): IrCall = + irCall(function).apply { arguments.forEachIndexed { index, argument -> this.arguments[index] = argument } } + + /** Calls a member of an object such as `StoryValues`. */ + fun IrBuilderWithScope.callObject(function: IrSimpleFunctionSymbol, vararg arguments: IrExpression?): IrCall { + val owner = function.owner.parent as IrClass + return call(function, irGetObject(owner.symbol), *arguments) + } + + fun stateAnnotation(name: String): IrAnnotation = annotation(symbols.stateConstructor, name) + + fun entryAnnotation(name: String): IrAnnotation = annotation(symbols.stageEntry, name) + + fun scriptAnnotation(path: String): IrAnnotation = annotation(symbols.stageScript, path) + + private fun annotation(constructor: IrConstructorSymbol, value: String): IrAnnotation = + builder(constructor, script).run { irAnnotation(constructor).apply { arguments[0] = irString(value) } } + + private fun stateAnnotation(function: IrFunction): IrConstructorCall? = + function.annotations.firstOrNull { it.isAnnotation(StageSymbols.STATE) } +} + +internal enum class SiteKind { STAGE, LAMBDA, CHECKPOINT } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt new file mode 100644 index 000000000..af7dbcb37 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -0,0 +1,88 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol +import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.constructors +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.getSimpleFunction +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.ir.stages.StageSymbols.Companion.find + +/** + * The declarations of the story runtime that stage code is rewritten against. The runtime lives in the + * Narrative addon; without it on classpath there are no stages to compile and [find] returns null. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageSymbols private constructor(context: IrPluginContext, val state: IrClassSymbol) { + private val finder = context.finderForBuiltins() + val builtIns = context.irBuiltIns + + val frame: IrClassSymbol = storyClass("StoryFrame") + val frames: IrClassSymbol = storyClass("StoryFrames") + val values: IrClassSymbol = storyClass("StoryValues") + val instances: IrClassSymbol = storyClass("StoryInstances") + val loops: IrClassSymbol = storyClass("StoryLoops") + val stageLambda: IrClassSymbol = storyClass("StageLambda") + val stageLambdaConstructor: IrConstructorSymbol = stageLambda.constructors.single() + val stageEntry: IrConstructorSymbol = storyClass("StageEntry").constructors.single() + val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() + val stateConstructor: IrConstructorSymbol = state.constructors.single() + val checkpoint: IrSimpleFunctionSymbol = storyFunction("checkpoint") + val storySerializer: IrSimpleFunctionSymbol = storyFunction("storySerializer") + + val compoundTag: IrClassSymbol = finder.findClass(ClassId.topLevel(FqName("net.minecraft.nbt.CompoundTag"))) + ?: error("CompoundTag is not on the script classpath") + val compoundTagConstructor: IrConstructorSymbol = + compoundTag.constructors.single { constructor -> constructor.owner.parameters.none { it.kind == IrParameterKind.Regular } } + + /** `KSerializer`, what [storySerializer] returns. */ + val serializerType: IrType = + finder.findClass(ClassId.topLevel(FqName("kotlinx.serialization.KSerializer")))?.typeWith(builtIns.anyNType) + ?: error("kotlinx.serialization is not on the script classpath") + + val frameType: IrType get() = frame.owner.defaultType + val compoundTagType: IrType get() = compoundTag.owner.defaultType + val intProgression: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.ranges.IntProgression"))) + + fun frameFunction(name: String): IrSimpleFunctionSymbol = + frame.getSimpleFunction(name) ?: error("StoryFrame.$name is missing from the story runtime") + + val enter: IrSimpleFunctionSymbol = member(frames, "enter") + val put: IrSimpleFunctionSymbol = member(values, "put") + val get: IrSimpleFunctionSymbol = member(values, "get") + val keyOf: IrSimpleFunctionSymbol = member(instances, "keyOf") + val loopCount: IrSimpleFunctionSymbol = member(loops, "count") + val loopElement: IrSimpleFunctionSymbol = member(loops, "element") + + fun isCheckpoint(function: IrFunction): Boolean = function.symbol == checkpoint + + + private fun storyClass(name: String): IrClassSymbol = + finder.findClass(ClassId(PACKAGE, Name.identifier(name))) ?: error("$name is missing from the story runtime") + + private fun storyFunction(name: String): IrSimpleFunctionSymbol = + finder.findFunctions(CallableId(PACKAGE, Name.identifier(name))).firstOrNull() + ?: error("$name is missing from the story runtime") + + private fun member(owner: IrClassSymbol, name: String): IrSimpleFunctionSymbol = + owner.getSimpleFunction(name) ?: error("${owner.owner.name}.$name is missing from the story runtime") + + companion object { + val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val STATE: FqName = PACKAGE.child(Name.identifier("State")) + + fun find(context: IrPluginContext): StageSymbols? = + context.finderForBuiltins().findClass(ClassId(PACKAGE, Name.identifier("State"))) + ?.let { StageSymbols(context, it) } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt new file mode 100644 index 000000000..275571e54 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -0,0 +1,93 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrSimpleType +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.IrTypeProjection +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.util.* +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** + * Says which values a story can keep. It mirrors what `StoryValues` writes at runtime: whatever kotlinx + * serialization or the engine's NBT format can write, entities as references, and stage lambdas. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageTypes(context: IrPluginContext) { + private val finder = context.finderForBuiltins() + private val entity: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("net.minecraft.world.entity.Entity"))) + private val tag: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("net.minecraft.nbt.Tag"))) + + /** Why a story cannot keep a value of [type], or null when it can. */ + fun problem(type: IrType): String? { + if (isStageLambda(type)) return null + val simple = type as? IrSimpleType ?: return "${type.render()} cannot be saved" + val klass = simple.classOrNull?.owner ?: return "${type.render()} is a type parameter, which cannot be saved" + if (entity != null && type.isSubtypeOfClass(entity)) return null + if (tag != null && type.isSubtypeOfClass(tag)) return null + val name = klass.kotlinFqName.asString() + if (name in BASIC) return null + if (isFunction(type)) { + return "a function value cannot be saved; pass a lambda to a @State parameter instead" + } + if (name == "kotlin.Array" || klass.isPrimitiveArray()) return "arrays cannot be saved; use a List" + if (name in CONTAINERS || klass.isSerializable() || name in CONTEXTUAL) { + return simple.arguments.firstNotNullOfOrNull { argument -> + (argument as? IrTypeProjection)?.type?.let(::problem) + ?: if (argument is IrTypeProjection) null else "a star projection cannot be saved" + } + } + return "${type.render()} cannot be saved: it is not @Serializable" + } + + /** Whether [type] is a function type; a story keeps such values only as stage lambdas. */ + fun isFunction(type: IrType): Boolean = type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().let { + it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") || it.startsWith("kotlin.reflect.KFunction") + } + + /** Whether [type] is a function type marked `@State`, whose values are stage lambdas. */ + fun isStageLambda(type: IrType): Boolean = + type.annotations.any { it.isAnnotation(StageSymbols.STATE) } || type.classOrNull?.owner?.kotlinFqName == STAGE_LAMBDA + + private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(SERIALIZABLE) + + private fun IrClass.isPrimitiveArray(): Boolean = + kotlinFqName.asString().let { it.startsWith("kotlin.") && it.endsWith("Array") } + + private companion object { + val SERIALIZABLE = FqName("kotlinx.serialization.Serializable") + val STAGE_LAMBDA = StageSymbols.PACKAGE.child(Name.identifier("StageLambda")) + + val BASIC = setOf( + "kotlin.Int", "kotlin.Long", "kotlin.Short", "kotlin.Byte", "kotlin.Float", "kotlin.Double", + "kotlin.Boolean", "kotlin.Char", "kotlin.String", "kotlin.Unit", + ) + + val CONTAINERS = setOf( + "kotlin.collections.List", "kotlin.collections.MutableList", "kotlin.collections.Collection", + "kotlin.collections.Set", "kotlin.collections.MutableSet", + "kotlin.collections.Map", "kotlin.collections.MutableMap", + "kotlin.Pair", "kotlin.Triple", + ) + + /** Classes the engine's NBT format writes without them being `@Serializable`. */ + val CONTEXTUAL = setOf( + "java.util.UUID", + "net.minecraft.core.BlockPos", + "net.minecraft.resources.ResourceLocation", + "net.minecraft.world.phys.Vec3", + "net.minecraft.network.chat.Component", + "net.minecraft.world.item.ItemStack", + "org.joml.Matrix4f", + "org.joml.Vector3d", + "org.joml.Vector3f", + "ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef", + ) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt deleted file mode 100644 index f0fabe673..000000000 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt +++ /dev/null @@ -1,120 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.plugin.ir.transformers - -import org.jetbrains.kotlin.backend.common.pop -import org.jetbrains.kotlin.backend.common.push -import org.jetbrains.kotlin.ir.IrStatement -import org.jetbrains.kotlin.ir.builders.irCall -import org.jetbrains.kotlin.ir.builders.irString -import org.jetbrains.kotlin.ir.declarations.IrFunction -import org.jetbrains.kotlin.ir.declarations.IrParameterKind -import org.jetbrains.kotlin.ir.expressions.IrCall -import org.jetbrains.kotlin.ir.expressions.IrConst -import org.jetbrains.kotlin.ir.expressions.IrConstructorCall -import org.jetbrains.kotlin.ir.expressions.IrExpression -import org.jetbrains.kotlin.ir.expressions.impl.IrReturnImpl -import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol -import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI -import org.jetbrains.kotlin.ir.util.getValueArgument -import org.jetbrains.kotlin.ir.util.isAnnotation -import org.jetbrains.kotlin.ir.util.isSuspend -import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid -import org.jetbrains.kotlin.name.CallableId -import org.jetbrains.kotlin.name.FqName -import org.jetbrains.kotlin.name.Name -import ru.hollowhorizon.hollowengine.common.plugin.ir.StateIrGenerationExtension.Companion.pluginContext -import ru.hollowhorizon.hollowengine.common.plugin.ir.util.builder - -private val transitionCallableId = CallableId( - packageName = FqName("ru.hollowhorizon.hollowengine.common.scripting.state"), - callableName = Name.identifier("transition"), -) - -private val transitionSymbol: IrSimpleFunctionSymbol by lazy { - pluginContext.finderForBuiltins() - .findFunctions(transitionCallableId) - .singleOrNull { symbol -> - val function = symbol.owner - - function.isSuspend && - function.parameters.any { parameter -> - parameter.kind == IrParameterKind.Regular && - parameter.name.asString() == "target" && - parameter.type == pluginContext.irBuiltIns.stringType - } - } - ?: error("Cannot find suspend function $transitionCallableId") -} - -class StateCallTransformer : IrElementTransformerVoid() { - private val stateFunctions = mutableListOf() - - override fun visitFunction(declaration: IrFunction): IrStatement { - val isStateFunction = declaration.hasStateAnnotation() && declaration.isSuspend - - if (isStateFunction) stateFunctions.push(declaration) - - val result = super.visitFunction(declaration) - - if (isStateFunction) stateFunctions.pop() - - return result - } - - @OptIn(UnsafeDuringIrConstructionAPI::class) - override fun visitCall(expression: IrCall): IrExpression { - val target = expression.symbol.owner - - val stateName = target.getStateNameOrNull() - ?: return super.visitCall(expression) - - if (!target.isSuspend) { - return super.visitCall(expression) - } - - val currentStateFunction = stateFunctions.lastOrNull() - ?: return super.visitCall(expression) - - val builder = currentStateFunction.builder() - - val transitionCall = builder.irCall(transitionSymbol).apply { - val targetParameter = symbol.owner.parameters.single { - it.kind == IrParameterKind.Regular && - it.name.asString() == "target" - } - - arguments[targetParameter] = builder.irString(stateName) - } - - return IrReturnImpl( - expression.startOffset, - expression.endOffset, - pluginContext.irBuiltIns.anyNType, - currentStateFunction.symbol, - transitionCall, - ) - } -} - -private val stateAnnotationFqName = FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.State") - -private fun IrFunction.hasStateAnnotation(): Boolean { - return annotations.any { it.isAnnotation(stateAnnotationFqName) } -} - -private fun IrFunction.getStateNameOrNull(): String? { - val annotation = annotations.firstOrNull { it.isAnnotation(stateAnnotationFqName) } - ?: return null - - val explicitName = annotation.getStringArgumentByName("name") - - return explicitName - ?.takeIf { it.isNotBlank() } - ?: name.asString() -} - -@OptIn(UnsafeDuringIrConstructionAPI::class) -private fun IrConstructorCall.getStringArgumentByName(parameterName: String): String? { - val argument = getValueArgument(Name.identifier(parameterName)) ?: return null - - return (argument as? IrConst)?.value as? String -} \ No newline at end of file diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index 18918c943..3a68b992a 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -150,7 +150,7 @@ class EditorStructureTest { assertTrue(analyzer.structure("example.kt", "fun call() {}").runTargets.isEmpty()) assertTrue(analyzer.structure("example.ui.kts", "").runTargets.isEmpty()) val text = - "import ru.hollowhorizon.hollowengine.common.scripting.annotations.State as Entry\n" + "object Names { const val ENTRY = \"Begin\" }\n@Entry(Names.ENTRY) suspend fun first() {}\n" + "@Entry suspend fun second() {}\n@Entry fun invalid() {}" + "import ru.hollowhorizon.hollowengine.addons.narrative.story.State as Entry\n" + "object Names { const val ENTRY = \"Begin\" }\n@Entry(Names.ENTRY) suspend fun first() {}\n" + "@Entry suspend fun second() {}\n@Entry fun invalid() {}" val targets = analyzer.structure("states.node.kts", text).runTargets assertEquals(listOf(null, "Begin", "second"), targets.map { it.state }) assertEquals(0, targets.first().offset) diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt new file mode 100644 index 000000000..ac18ba91a --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -0,0 +1,204 @@ +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains + +/** What a stage cannot do is a compilation error that says why, not a story that breaks after a restart. */ +class StageCompileErrorsTest { + private lateinit var compiler: StageCompiler + + @BeforeTest + fun setUp() { + compiler = StageCompiler() + } + + @AfterTest + fun tearDown() = compiler.close() + + private fun assertError(expected: String, code: String) = assertContains(compiler.errors(code), expected) + + @Test + fun `a stage cannot be called from a lambda inside a stage`() = assertError( + "cannot be called from a lambda", """ + @State suspend fun step() {} + + @State + suspend fun main() { + listOf(1, 2).forEach { step() } + } + """ + ) + + @Test + fun `a stage cannot be called in catch or finally`() { + assertError( + "cannot be called in catch or finally", """ + @State suspend fun step() {} + + @State + suspend fun main() { + try { log("try") } catch (failure: Exception) { step() } + } + """ + ) + assertError( + "cannot be called in catch or finally", """ + @State suspend fun step() {} + + @State + suspend fun main() { + try { log("try") } finally { step() } + } + """ + ) + } + + @Test + fun `a local that cannot be saved cannot outlive a stage call`() = assertError( + "'iterator', used after the stage call 'step#1'", """ + @State suspend fun step() {} + + @State + suspend fun main() { + val iterator = listOf(1).iterator() + step() + log(iterator.next().toString()) + } + """ + ) + + @Test + fun `a value computed before a stage call in the same expression must be saveable`() = assertError( + "A value computed before the stage call 'name#1'", """ + @State suspend fun name(): String = "a" + + fun join(first: Any, second: String) = log(first.toString() + second) + + @State + suspend fun main() { + join(Any(), name()) + } + """ + ) + + @Test + fun `arguments and results of stages must be saveable`() { + assertError( + "'target' cannot be", """ + @State suspend fun aim(target: Any) {} + """ + ) + assertError( + "The result of a stage is saved", """ + @State suspend fun make(): Any = Any() + """ + ) + assertError( + "cannot have type parameters", """ + @State suspend fun pass(value: T) {} + """ + ) + assertError( + "cannot have a receiver", """ + @State suspend fun String.shout() {} + """ + ) + } + + @Test + fun `a stage must be a top level suspend function of a script`() { + assertError( + "must be a suspend function", """ + @State fun plain() {} + """ + ) + assertError( + "must be declared at the top level of a script", """ + class Holder { + @State suspend fun inner() {} + } + """ + ) + } + + @Test + fun `two stages cannot share a name`() = assertError( + "Two stages of this script are named 'same'", """ + @State(name = "same") suspend fun one() {} + + @State(name = "same") suspend fun two() {} + """ + ) + + @Test + fun `checkpoints need a constant unique name inside a stage`() { + assertError( + "must be called directly in a @State function", """ + suspend fun helper() = checkpoint("outside") + """ + ) + assertError( + "needs a constant name", """ + @State + suspend fun main() { + val name = "dynamic" + log("x") + checkpoint(name) + } + """ + ) + assertError( + "appears twice", """ + @State + suspend fun main() { + checkpoint("gate") + checkpoint("gate") + } + """ + ) + } + + @Test + fun `a stage lambda saves what it captures so it can capture only values that can be saved`() { + assertError( + "cannot capture 'var counter'", """ + fun start(runs: StoryRuns) { + var counter = 1 + counter++ + runs.launch("story") { log(counter.toString()) } + } + """ + ) + assertError( + "'lock' cannot be saved", """ + fun start(runs: StoryRuns) { + val lock = Any() + runs.launch("story") { log(lock.toString()) } + } + """ + ) + } + + @Test + fun `loops that call stages must count with something a frame can save`() { + assertError( + "can only go over a List or an Int range", """ + @State suspend fun step(value: Int) {} + + @State + suspend fun main() { + for (value in setOf(1, 2)) step(value) + } + """ + ) + assertError( + "A do-while condition cannot call a stage", """ + @State suspend fun again(): Boolean = false + + @State + suspend fun main() { + do { log("x") } while (again()) + } + """ + ) + } +} diff --git a/addons/compiler/src/test/kotlin/StageExecutionTest.kt b/addons/compiler/src/test/kotlin/StageExecutionTest.kt new file mode 100644 index 000000000..77f7e02e4 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageExecutionTest.kt @@ -0,0 +1,742 @@ +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Stages run like ordinary code, and a restart brings each frame back to the boundary it last passed: the code + * before that boundary does not run again, the segment after it does. + */ +class StageExecutionTest { + private lateinit var compiler: StageCompiler + + @BeforeTest + fun setUp() { + StageTrace.reset() + compiler = StageCompiler() + } + + @AfterTest + fun tearDown() = compiler.close() + + private fun TestScope.world(name: String, code: String) = StageWorld(this, compiler.compile(name, code)) + + @Test + fun `a nested stage resumes inside the call and the caller continues after it`() = runTest { + val world = world("nested", """ + @State + suspend fun main() { + log("main:start") + first() + log("main:after") + await("x") + log("main:end") + } + + @State + suspend fun first() { + log("first:start") + await("y") + log("first:end") + } + """) + world.launch("main") + assertEquals(listOf("main:start", "first:start"), StageTrace.take()) + + world.restart() + assertEquals(listOf("first:start"), StageTrace.take(), "first restarts from its entry, main does not run again") + + world.release("y") + assertEquals(listOf("first:end", "main:after"), StageTrace.take()) + + world.restart() + assertEquals(listOf("main:after"), StageTrace.take(), "main repeats only the segment after first") + + world.release("x") + assertEquals(listOf("main:end"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty(), "a finished story leaves its owner") + world.stop() + } + + @Test + fun `the result of a finished stage call is restored instead of calling again`() = runTest { + val world = world("result", $$""" + @State + suspend fun main() { + val value = compute() + log("got $value") + await("x") + log("end $value") + } + + @State + suspend fun compute(): Int { + log("compute") + await("c") + return 42 + } + """ + ) + world.launch("main") + world.release("c") + assertEquals(listOf("compute", "got 42"), StageTrace.take()) + + world.restart() + assertEquals(listOf("got 42"), StageTrace.take()) + world.release("x") + assertEquals(listOf("end 42"), StageTrace.take()) + world.stop() + } + + @Test + fun `locals that outlive a stage call keep the value they had at it`() = runTest { + val world = world("locals", $$""" + fun prefix(): String { + log("prefix") + return "there" + } + + @State + suspend fun step() { + log("step") + await("s") + } + + @State + suspend fun main() { + val greeting = "hi " + prefix() + var count = 1 + step() + count += 1 + log("$greeting $count") + await("x") + step() + log("$greeting $count") + } + """ + ) + world.launch("main") + assertEquals(listOf("prefix", "step"), StageTrace.take()) + + world.restart() + assertEquals(listOf("step"), StageTrace.take(), "the locals come from the save, prefix() is not called again") + + world.release("s") + assertEquals(listOf("hi there 2"), StageTrace.take()) + + world.restart() + assertEquals(listOf("hi there 2"), StageTrace.take(), "count is restored as it was at the call, then incremented again") + + world.release("x") + assertEquals(listOf("step", "hi there 2"), StageTrace.take()) + world.stop() + } + + @Test + fun `what an expression computed before a stage call in it is not computed again`() = runTest { + val world = world("temporaries", """ + fun label(text: String): String { + log("label ${'$'}text") + return text + } + + @State + suspend fun value(): String { + await("v") + return "b" + } + + @State + suspend fun main() { + log(label("a") + value() + label("c")) + } + """) + world.launch("main") + assertEquals(listOf("label a"), StageTrace.take()) + + world.restart() + assertEquals(emptyList(), StageTrace.take()) + + world.release("v") + assertEquals(listOf("label c", "abc"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch taken before a restart is taken again without evaluating its condition`() = runTest { + val world = world("branches", """ + fun decide(): Boolean { + log("decide") + return StageTrace.token == "left" + } + + fun pick(): Int { + log("pick") + return 2 + } + + @State + suspend fun left() { + log("left") + await("l") + } + + @State + suspend fun right() = log("right") + + @State + suspend fun number(value: Int): Int { + log("number ${'$'}value") + await("n${'$'}value") + return value * 10 + } + + @State + suspend fun main() { + if (decide()) left() else right() + val result = when (pick()) { + 1 -> number(1) + else -> number(2) + } + log("result ${'$'}result") + } + """) + StageTrace.token = "left" + world.launch("main") + assertEquals(listOf("decide", "left"), StageTrace.take()) + + StageTrace.token = "right" + world.restart() + assertEquals(listOf("left"), StageTrace.take(), "the condition is not evaluated again, so the token does not matter") + + world.release("l") + assertEquals(listOf("pick", "number 2"), StageTrace.take()) + + world.restart() + assertEquals(listOf("number 2"), StageTrace.take()) + + world.release("n2") + assertEquals(listOf("result 20"), StageTrace.take()) + world.stop() + } + + @Test + fun `a loop resumes in the iteration it was in`() = runTest { + val world = world("loops", """ + fun more(i: Int): Boolean { + log("check ${'$'}i") + return i < 3 + } + + @State + suspend fun tick(i: Int) { + log("tick ${'$'}i") + await("t${'$'}i") + } + + @State + suspend fun main() { + var i = 0 + while (more(i)) { + tick(i) + i++ + } + log("done ${'$'}i") + } + """) + world.launch("main") + world.release("t0") + assertEquals(listOf("check 0", "tick 0", "check 1", "tick 1"), StageTrace.take()) + + world.restart() + assertEquals(listOf("tick 1"), StageTrace.take(), "the loop is entered without checking its condition") + + world.release("t1") + world.release("t2") + assertEquals(listOf("check 2", "tick 2", "check 3", "done 3"), StageTrace.take()) + world.stop() + } + + @Test + fun `for loops over lists and ranges resume at the element they were on`() = runTest { + val world = world("for", """ + @State + suspend fun visit(name: String) { + log("visit ${'$'}name") + await(name) + } + + @State + suspend fun count(n: Int) { + log("count ${'$'}n") + await("n${'$'}n") + } + + @State + suspend fun main() { + for (name in listOf("a", "b", "c")) visit(name) + for (n in 6 downTo 1 step 2) count(n) + log("done") + } + """) + world.launch("main") + world.release("a") + assertEquals(listOf("visit a", "visit b"), StageTrace.take()) + + world.restart() + assertEquals(listOf("visit b"), StageTrace.take()) + + world.release("b") + world.release("c") + assertEquals(listOf("visit c", "count 6"), StageTrace.take()) + + world.release("n6") + world.restart() + assertEquals(listOf("count 4", "count 4"), StageTrace.take()) + + world.release("n4") + world.release("n2") + assertEquals(listOf("count 2", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a stage resumed inside try is still guarded by its catch and finally`() = runTest { + val world = world("guarded", """ + @State + suspend fun risky() { + log("risky") + await("r") + throw IllegalArgumentException("boom") + } + + @State + suspend fun main() { + try { + risky() + log("not reached") + } catch (failure: IllegalArgumentException) { + log("caught ${'$'}{failure.message}") + } finally { + log("finally") + } + log("after") + } + """) + world.launch("main") + assertEquals(listOf("risky"), StageTrace.take()) + + world.restart() + assertEquals(listOf("finally", "risky"), StageTrace.take(), "finally runs when the story is unloaded, then the stage resumes") + + world.release("r") + assertEquals(listOf("caught boom", "finally", "after"), StageTrace.take()) + world.stop() + } + + @Test + fun `a checkpoint ends a segment and segment data survives a restart`() = runTest { + val world = world("checkpoints", """ + @State + suspend fun main() { + log("intro") + checkpoint("gate") + log("segment") + once("reward") { log("reward") } + val boss = remember("boss") { + log("spawn") + "ogre" + } + log("boss ${'$'}boss") + await("x") + log("end") + } + """) + world.launch("main") + assertEquals(listOf("intro", "segment", "reward", "spawn", "boss ogre"), StageTrace.take()) + + world.restart() + assertEquals(listOf("segment", "boss ogre"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + world.stop() + } + + @Test + fun `tail calls replace the frame so a story can go back and forth forever`() = runTest { + val world = world("tails", """ + @State + suspend fun main() = ping(0) + + @State + suspend fun ping(n: Int) { + log("ping ${'$'}n") + await("p${'$'}n") + if (n < 3) pong(n + 1) else log("stop") + } + + @State + suspend fun pong(n: Int) { + log("pong ${'$'}n") + await("q${'$'}n") + ping(n + 1) + } + """) + val story = world.launch("main") + assertEquals(listOf("ping 0"), StageTrace.take()) + assertEquals("ping", story.state) + + world.release("p0") + assertEquals("pong", story.state, "pong took the place of ping") + + world.restart() + assertEquals(listOf("pong 1", "pong 1"), StageTrace.take()) + val resumed = world.runs["main"]!! + assertEquals("pong", resumed.state) + + world.release("q1") + world.release("p2") + world.release("q3") + world.release("p4") + assertEquals(listOf("ping 2", "pong 3", "ping 4", "stop"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty()) + world.stop() + } + + @Test + fun `a tail call that returns a value hands it to the caller of the replaced stage`() = runTest { + val world = world("valueTails", """ + @State + suspend fun countdown(n: Int): Int { + log("n ${'$'}n") + await("c${'$'}n") + if (n == 0) return 100 + return countdown(n - 1) + } + + @State + suspend fun main() { + val value = countdown(2) + log("value ${'$'}value") + } + """) + val story = world.launch("main") + world.release("c2") + assertEquals("main > countdown", story.state.replace(Regex("@[^ ]+"), "")) + + world.restart() + world.release("c1") + world.release("c0") + assertEquals(listOf("n 2", "n 1", "n 1", "n 0", "value 100"), StageTrace.take()) + world.stop() + } + + @Test + fun `saved variables belong to their run and survive a restart`() = runTest { + val world = world("variables", """ + var trust by saved(0) + + @State + suspend fun main() { + trust += 1 + checkpoint("counted") + log("trust ${'$'}trust") + await("x") + trust += 10 + log("trust ${'$'}trust") + } + """) + world.launch("main", key = "a") + world.launch("main", key = "b") + assertEquals(listOf("trust 1", "trust 1"), StageTrace.take()) + + world.restart() + assertEquals(listOf("trust 1", "trust 1"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("trust 11", "trust 11"), StageTrace.take()) + world.stop() + } + + @Test + fun `a launched lambda is a stage that keeps what it captured`() = runTest { + val world = world("lambdas", $$""" + @State + suspend fun meet(who: String) { + log("meet $who") + await("m") + } + + fun start(runs: StoryRuns, who: String) { + val greeting = "hello" + runs.launch("greet") { + log("$greeting $who") + meet(who) + log("bye $who") + } + } + """ + ) + world.call("start", world.runs, "alex") + assertEquals(listOf("hello alex", "meet alex"), StageTrace.take()) + + world.restart() + assertEquals(listOf("meet alex"), StageTrace.take()) + + world.release("m") + assertEquals(listOf("bye alex"), StageTrace.take()) + world.stop() + } + + @Test + fun `a stage lambda passed to a stage resumes inside its call`() = runTest { + val world = world("lambdaArguments", """ + @State + suspend fun twice(body: @State suspend () -> Unit) { + body() + log("middle") + body() + } + + @State + suspend fun step() { + log("step") + await("w") + } + + @State + suspend fun main() { + twice { step() } + log("done") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("step", "step"), StageTrace.take()) + + world.release("w") + assertEquals(listOf("middle", "step", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `branches run alongside, keep their results across a restart and end with their stage`() = runTest { + val world = world("branches", $$""" + @State + suspend fun work(name: String): Int { + log("work $name") + await(name) + return name.length + } + + @State + suspend fun ambient() { + log("ambient") + await("never") + } + + @State + suspend fun scene() { + async("ambient") { ambient() } + await("scene") + } + + @State + suspend fun main() { + val a = async("a") { work("a") } + val bb = async("bb") { work("bb") } + log("sum ${a.await() + bb.await()}") + scene() + log("after scene") + await("end") + } + """ + ) + world.launch("main") + assertEquals(listOf("work a", "work bb"), StageTrace.take()) + + world.release("a") + world.restart() + assertEquals(listOf("work bb"), StageTrace.take(), "the finished branch keeps its result, the other one resumes") + + world.release("bb") + assertEquals(listOf("sum 3", "ambient"), StageTrace.take()) + assertTrue(world.save().getCompound("main").getCompound("children").contains("ambient")) + + world.release("scene") + assertEquals(listOf("after scene"), StageTrace.take()) + assertFalse(world.save().getCompound("main").getCompound("children").contains("ambient"), "the branch ended with scene") + world.stop() + } + + @Test + fun `a recompiled script resumes where its sites still exist and starts over where they do not`() = runTest { + val first = compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("one") + wait() + log("two") + } + """) + val world = StageWorld(this, first) + world.launch("main") + assertEquals(listOf("one"), StageTrace.take()) + + world.restart(compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("one, edited") + log("more") + wait() + log("two, edited") + } + """)) + assertEquals(emptyList(), StageTrace.take(), "the call to wait is still there, so main resumes inside it") + + world.restart(compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("rewritten") + await("f2") + } + """)) + assertEquals(listOf("rewritten"), StageTrace.take(), "the call is gone, so main starts over") + world.stop() + } + + @Test + fun `a stage entered from ordinary code is resumed only with the arguments it was saved with`() = runTest { + val world = world("helpers", """ + @State + suspend fun inner(name: String) { + log("inner ${'$'}name") + checkpoint("in") + await("i") + } + + suspend fun helper() { + log("helper") + inner(StageTrace.token) + } + + @State + suspend fun main() { + helper() + log("after") + } + """) + StageTrace.token = "a" + world.launch("main") + assertEquals(listOf("helper", "inner a"), StageTrace.take()) + + world.restart() + assertEquals(listOf("helper"), StageTrace.take(), "the same call adopts the saved frame and resumes after the checkpoint") + + StageTrace.token = "b" + world.restart() + assertEquals(listOf("helper", "inner b"), StageTrace.take(), "another argument means another call, which starts over") + + world.release("i") + assertEquals(listOf("after"), StageTrace.take()) + world.stop() + } + + @Test + fun `a story waits for the script it runs in`() = runTest { + val world = world("waits", """ + @State + suspend fun main() { + log("start") + checkpoint("started") + log("resumed") + await("x") + } + """) + world.launch("main") + assertEquals(listOf("start", "resumed"), StageTrace.take()) + + world.restart(startScript = false) + assertEquals(emptyList(), StageTrace.take(), "nothing runs without the script") + + world.startScript() + assertEquals(listOf("resumed"), StageTrace.take()) + world.stop() + } + + @Test + fun `serializable classes of the script are saved with the frame`() = runTest { + val world = world("serializable", """ + @kotlinx.serialization.Serializable + data class Loot(val name: String, val count: Int) + + @State + suspend fun main() { + val loot = Loot("gold", 3) + checkpoint("looted") + log(loot.toString()) + await("x") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("Loot(name=gold, count=3)", "Loot(name=gold, count=3)"), StageTrace.take()) + world.stop() + } + + @Test + fun `launching a running key returns that run and stopping it ends it`() = runTest { + val world = world("keys", """ + fun start(runs: StoryRuns) { + runs.launch("quest") { + log("start") + await("x") + } + } + """) + world.call("start", world.runs) + world.call("start", world.runs) + assertEquals(listOf("start"), StageTrace.take()) + + world.runs.stop("quest") + world.settle() + assertTrue(world.runs.keys.isEmpty()) + world.call("start", world.runs) + assertEquals(listOf("start"), StageTrace.take()) + world.stop() + } + + @Test + fun `a saved story can be read back after being written out`() = runTest { + val world = world("stored", """ + @State + suspend fun main() { + val items = listOf("sword", "shield") + checkpoint("armed") + log(items.joinToString()) + await("x") + } + """) + world.launch("main") + val saved = world.save().getCompound("main") + val frame = saved.getList("frames", 10).getCompound(0) + assertEquals("main", frame.getString("stage")) + assertEquals("after:armed", frame.getString("position")) + assertTrue(frame.getCompound("locals").contains("items")) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt new file mode 100644 index 000000000..753747fcf --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -0,0 +1,184 @@ +import StageTrace.release +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryHandle +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryInstances +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryOwner +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRuns +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import java.io.File +import kotlin.coroutines.ContinuationInterceptor +import kotlin.io.path.createTempDirectory +import kotlin.test.fail + +/** The base of the scripts stage tests compile. */ +abstract class StageTestScript + +/** What test scripts write down and wait for; it outlives a simulated restart, like the world does. */ +object StageTrace { + val events = mutableListOf() + + /** A value scripts read and tests change, to make code behave differently after a restart. */ + @JvmStatic + var token: String = "" + private val released = HashSet() + private val waiting = HashMap>>() + + @JvmStatic + fun log(event: String) { + events += event + } + + /** Suspends until the test calls [release] with [name]; returns at once if it already did. */ + @JvmStatic + suspend fun await(name: String) { + if (name in released) return + CompletableDeferred().also { waiting.getOrPut(name, ::ArrayList) += it }.await() + } + + fun release(name: String) { + released += name + waiting.remove(name)?.forEach { it.complete(Unit) } + } + + fun take(): List = events.toList().also { events.clear() } + + fun reset() { + events.clear() + token = "" + released.clear() + waiting.clear() + } +} + +/** Compiles stage scripts with the real compiler and plugin. */ +class StageCompiler : AutoCloseable { + val environment = ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank) + .map(::File).filter(File::exists), + scriptTypes = listOf( + ScriptClassProvider( + extension = ".stage.kts", + baseClass = StageTestScript::class.qualifiedName!!, + defaultImports = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "StageTrace.log", + "StageTrace.await" + ), + ), + ), + mappings = Mappings.EMPTY, + ).also { ScriptingEnvironment.INSTANCE = it } + + private val sources = createTempDirectory("stages").toFile() + + /** Compiles [code] from a file, as the game compiles scripts, so the same [name] keeps its path across versions. */ + fun compile(name: String, code: String): CompiledScript = + compileFile(name, code).getOrElse { fail("Expected the script to compile: $it") } + + /** The errors compiling [code] reports; fails if it compiles. */ + fun errors(code: String, name: String = "broken"): String { + val failure = compileFile(name, code).exceptionOrNull() ?: fail("Expected the script not to compile:\n$code") + return failure.toString() + } + + private fun compileFile(name: String, code: String): Result = + environment.compiler.compile(sources.resolve("$name.stage.kts").apply { writeText(code.trimIndent()) }) + + override fun close() { + sources.deleteRecursively() + sources.deleteRecursively() + ScriptingEnvironment.clear() + environment.close() + File("hollowengine").deleteRecursively() + } +} + +/** + * A script instance with an owner of stories, which can be stopped and brought back from what it saved, the way + * a server restart does: the runs are written out, everything is canceled, a fresh instance of the script is + * started and the runs are read back. + */ +class StageWorld(private val test: TestScope, private var compiled: CompiledScript) { + private class Owner(override val scope: CoroutineScope) : StoryOwner { + override val server: MinecraftServer get() = error("There is no server in stage tests") + override val entity: Entity? get() = null + override fun changed() = Unit + } + + private var scope = newScope() + var instance: Any = execute() + private set + var runs = StoryRuns(Owner(scope)) + private set + + val key: String get() = StoryInstances.keyOf(instance) + + fun launch(stage: String, key: String = "main"): StoryHandle = + runs.launch(key, this.key, stage).also { settle() } + + /** Calls a function the script declares with [arguments]. */ + fun call(name: String, vararg arguments: Any?): Any? { + val method = instance.javaClass.declaredMethods.first { it.name == name && it.parameterCount == arguments.size } + method.trySetAccessible() + return method.invoke(instance, *arguments).also { settle() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun settle() = test.advanceUntilIdle() + + fun release(name: String) { + StageTrace.release(name) + settle() + } + + /** What the runs would be written to the world save as. */ + fun save(): CompoundTag = runs.save() + + /** + * Stops everything and starts it again from the save, optionally with the script recompiled as [changed]. + * With [startScript] false the stories are read back before the script runs again; [startScript] runs it. + */ + fun restart(changed: CompiledScript? = null, startScript: Boolean = true) { + val saved = save() + scope.cancel() + settle() + changed?.let { compiled = it } + scope = newScope() + if (startScript) instance = execute() + runs = StoryRuns(Owner(scope)) + runs.load(saved) + settle() + } + + fun startScript() { + instance = execute() + settle() + } + + fun stop() { + scope.cancel() + settle() + } + + private fun newScope() = CoroutineScope(test.coroutineContext[ContinuationInterceptor]!! + SupervisorJob()) + + private fun execute(): Any { + val instance = compiled.execute { }.getOrElse { fail("The script failed to start: $it") } + StoryInstances.register(instance, scope) + return instance + } +} diff --git a/addons/narrative/build.gradle.kts b/addons/narrative/build.gradle.kts index 76908319d..5196a714a 100644 --- a/addons/narrative/build.gradle.kts +++ b/addons/narrative/build.gradle.kts @@ -8,7 +8,7 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") val serializationVersion = rootProject.property("serializationVersion") as String - add("testRuntimeOnly", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") + add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") } tasks.withType().configureEach { useJUnitPlatform() } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt index 45efd6963..47d80db50 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt @@ -12,6 +12,8 @@ import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent import ru.hollowhorizon.hollowengine.common.scripting.NODE_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.RELOAD_SCRIPT_EXTENSION +import ru.hollowhorizon.hollowengine.common.scripting.SERVER_CONSOLE_SCRIPT_EXTENSION +import ru.hollowhorizon.hollowengine.common.scripting.STARTUP_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefaultImportsEvent import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadScriptsEvent import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -31,8 +33,9 @@ class HollowNarrativeAddon : HollowAddonEntrypoint { @SubscribeEvent fun addDefaultImports(event: ScriptDefaultImportsEvent) { when (event.suffix) { - RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS - NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS + STORY_PACKAGE + NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + STORY_PACKAGE + STARTUP_SCRIPT_EXTENSION, SERVER_CONSOLE_SCRIPT_EXTENSION -> event.defaultImports += STORY_PACKAGE } } @@ -53,6 +56,7 @@ class HollowNarrativeAddon : HollowAddonEntrypoint { private companion object { const val DIALOGUE_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.*" + const val STORY_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.story.*" /** Reload scripts register story functions, so they also get the signature helpers. */ val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE) + diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt new file mode 100644 index 000000000..85656a8d3 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt @@ -0,0 +1,45 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT + +/** + * A lambda, that compiler turned into a stage: written where a `@State suspend () -> T` is expected, its body + * became the stage [stage] of the script [instance] and what it captured became [args]. + * + * @param result reads the value the stage returns; only known where the lambda was written. + */ +class StageLambda( + val instance: String, + val stage: String, + val args: CompoundTag, + internal val result: KSerializer? = null, +) : suspend () -> T { + @Suppress("UNCHECKED_CAST") + override suspend fun invoke(): T = StoryInstances.invoke(instance, stage, args) as T + + /** The first frame of a run that starts with this lambda. */ + internal fun entry(run: StoryRun) = StoryFrame(run, instance, stage).apply { args = this@StageLambda.args.copy() } + + @Serializable + private class Saved(val instance: String, val stage: String, @Serializable(with = ForCompoundNBT::class) val args: CompoundTag) + + internal object Serializer : KSerializer> { + override val descriptor: SerialDescriptor = SerialDescriptor("story.stage", Saved.serializer().descriptor) + + override fun serialize(encoder: Encoder, value: StageLambda<*>) = + encoder.encodeSerializableValue(Saved.serializer(), Saved(value.instance, value.stage, value.args)) + + override fun deserialize(decoder: Decoder): StageLambda<*> = + decoder.decodeSerializableValue(Saved.serializer()).let { StageLambda(it.instance, it.stage, it.args) } + } +} + +/** The value of a stage lambda, failing clearly when something else was passed where one was expected. */ +internal fun (suspend () -> Any?).asStage(where: String): StageLambda<*> = this as? StageLambda<*> + ?: throw StoryException("$where takes a lambda written in place, not a function value") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt new file mode 100644 index 000000000..3c038aff2 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -0,0 +1,39 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +/** + * A stage: a suspend function whose place in a story survives a restart. + * + * Calling a stage returns to the caller like any other call, and the run keeps the whole call stack. Every + * function on that stack continues after a restart from the last stage call or [checkpoint] it passed, with + * its arguments and the locals it still needs restored. Arguments, the result and such locals must therefore be + * serializable; entities are kept as references. + * + * On a function type, as in `block: @State suspend () -> Unit`, it makes the lambda written there a stage of + * its own: what the lambda captures is saved as its arguments. [launchStory] and [async] take their bodies so. + * + * @param name the name saves refer to; the function name when empty. Set it to keep saves working after a rename. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE) +@Retention(AnnotationRetention.BINARY) +annotation class State(val name: String = "") + +/** + * Ends a segment of the enclosing stage: after a restart the stage continues from here rather than from its + * previous stage call. Must be called directly in a `@State` function, and [name] must be unique within it. + */ +@Suppress("RedundantSuspendModifier") +suspend fun checkpoint(name: String): Unit = + throw StoryException("checkpoint(\"$name\") must be called directly in a @State function") + +/** Marks the entry the compiler generates for a stage. The runtime calls it to start or resume the stage by [name]. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageEntry(val name: String) + +/** Marks a script class that declares stages with the source [path] its stages are addressed by. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageScript(val path: String) + +/** A story could not do what was asked of it. */ +class StoryException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt new file mode 100644 index 000000000..1cec19c26 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -0,0 +1,137 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.selects.select +import kotlinx.serialization.KSerializer +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import kotlin.jvm.internal.CallableReference +import kotlin.jvm.internal.ClassBasedDeclarationContainer +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Starts the story [key] of the server with [block] as its first stage and returns its handle. A story already + * running under [key] is returned instead, so a trigger may call this every time it fires. + */ +fun MinecraftServer.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.server(this).launch(key, block) + +/** Starts the story [key] of this entity; it sleeps while the entity is unloaded and ends with it. */ +fun Entity.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.entity(this).launch(key, block) + +/** Starts the story [key] of this node; it sleeps while the node does not run. */ +fun NodeScript.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.node(this).launch(key, block) + +/** Stops the story [key] of the server; returns whether it was running. */ +fun MinecraftServer.stopStory(key: String): Boolean = StoryOwners.server(this).stop(key) + +fun Entity.stopStory(key: String): Boolean = StoryOwners.entity(this).stop(key) + +fun NodeScript.stopStory(key: String): Boolean = StoryOwners.node(this).stop(key) + +/** What the current stage knows about its story: whom it runs for. */ +val context: StoryContext + get() = (StoryRun.current() ?: throw StoryException("context is only available while a story runs")).context + +/** + * Starts the branch [name] of the current story and returns its handle. The branch is saved with the story and + * ends when the stage that started it returns. After a restart the same call returns the branch already running. + */ +suspend fun async(name: String, block: @State suspend () -> T): StoryHandle { + val run = currentRun("async(\"$name\")") + val lambda = block.asStage("async(\"$name\")") + return StoryHandle(run.branch(name, lambda), lambda.result) +} + +/** A run of a story or of one of its branches. */ +class StoryHandle internal constructor( + private val run: StoryRun, + private val result: KSerializer?, +) { + val key: String get() = run.path + + val isActive: Boolean get() = !run.state.done + + /** Where the run is: its stages and their positions. */ + val state: String get() = run.describe() + + /** Waits for the run to end and returns its result; throws if it failed or was stopped. */ + @Suppress("UNCHECKED_CAST") + suspend fun await(): T = when (run.awaitDone()) { + StoryRun.Status.COMPLETED -> { + val saved = run.result + if (result == null || saved == null) Unit as T else StoryValues.decode(result, saved) as T + } + StoryRun.Status.FAILED -> throw StoryException("Story '$key' failed: ${run.failure}") + else -> throw CancellationException("Story '$key' was stopped") + } + + /** Waits for the run to end without looking at how it ended. */ + suspend fun join() { + run.awaitDone() + } + + fun cancel() = run.stop() +} + +/** Waits for every one of [handles]. */ +suspend fun all(vararg handles: StoryHandle<*>) = handles.forEach { it.join() } + +/** Waits for the first of [handles] to end, stops the others and returns the one that ended. */ +suspend fun race(vararg handles: StoryHandle<*>): StoryHandle<*> = coroutineScope { + val waits = handles.map { handle -> async { handle.join(); handle } } + val winner = select { waits.forEach { wait -> wait.onAwait { it } } } + waits.forEach { it.cancel() } + handles.filter { it !== winner }.forEach(StoryHandle<*>::cancel) + winner +} + +/** Waits for [count] of [handles] to end, stops the rest and returns those that ended. */ +suspend fun any(count: Int, vararg handles: StoryHandle<*>): List> { + require(count in 0..handles.size) { "Cannot wait for $count of ${handles.size} branches" } + val ended = ArrayList>() + while (ended.size < count) ended += race(*handles.filter { it !in ended && it.isActive }.toTypedArray()) + handles.filter { it !in ended }.forEach(StoryHandle<*>::cancel) + return ended +} + +/** + * A variable of the story that reads it: every run keeps its own value and saves it, branches share their + * story's. Declare it at the top level of a script: `var trust by saved(0)`. + */ +inline fun saved(default: T): ReadWriteProperty = SavedVariable(default, storySerializer()) + +@PublishedApi +internal class SavedVariable(private val default: T, private val serializer: KSerializer) : ReadWriteProperty { + @Suppress("UNCHECKED_CAST") + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + val tag = run(property).vars.get(name(property)) ?: return default + return StoryValues.decode(serializer, tag) as T + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + val run = run(property) + StoryValues.put(run.vars, name(property), serializer, value) + run.changed() + } + + private fun run(property: KProperty<*>) = + StoryRun.current() ?: throw StoryException("'${property.name}' is a story variable and only exists while a story runs") + + /** The property qualified by the script declaring it, which a script property reference knows as its owner. */ + private fun name(property: KProperty<*>): String { + val owner = ((property as? CallableReference)?.owner as? ClassBasedDeclarationContainer)?.jClass + val script = owner?.getAnnotation(StageScript::class.java)?.path ?: owner?.name + return script?.let { "$it/${property.name}" } ?: property.name + } +} + +internal suspend fun currentRun(what: String): StoryRun = + currentCoroutineContext()[StoryRun.Element]?.run ?: throw StoryException("$what is only available while a story runs") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt new file mode 100644 index 000000000..0f9dff532 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -0,0 +1,251 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.StringTag +import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine + +/** + * One call of a stage on a story's stack. + * + * The compiler rewrites every `@State` function to drive its frame: the stage enters it, saves the locals it + * still needs at each stage call and [checkpoint], and on a restart is led back to where it was ([resumesInto]). + * A frame is saved at those boundaries only, so a stage that restarts repeats the segment it was in. + * + * The positions the generated code passes around are `site * 2` for "inside the call at site" and + * `site * 2 + 1` for "after it"; the save names sites instead, so it survives the code being recompiled. + */ +class StoryFrame internal constructor( + internal val run: StoryRun, + val instance: String, + val stage: String, +) { + internal var hash = 0 + internal var position: String? = null + internal var args = CompoundTag() + internal var locals = CompoundTag() + internal var result: Tag? = null + internal var segment = CompoundTag() + internal val children = LinkedHashSet() + internal var trampolined = false + private var sites: Array = emptyArray() + private var resuming = false + private var target = -1 + private var replaced = false + private var failure: Throwable? = null + + /** Whether the stage is still finding its way back to the position it was saved at. */ + fun isResuming(): Boolean = resuming + + /** Whether code holding the positions [from]..[to] runs: always, unless the stage is resuming elsewhere. */ + fun enters(from: Int, to: Int): Boolean = !resuming || target in from..to + + /** Whether the stage is resuming to one of the positions [from]..[to]. */ + fun resumesInto(from: Int, to: Int): Boolean = resuming && target in from..to + + /** Whether the stage is resuming exactly to [position]. */ + fun isResumingAt(position: Int): Boolean = resuming && target == position + + /** + * Remembers an argument, which the stage is started with again after a restart. A saved frame that code + * enters with other arguments is not the one it was saved as, so the stage starts over. + */ + fun saveArg(name: String, serializer: KSerializer, value: Any?) { + val encoded = StoryValues.encode(serializer, value) + val saved = args.get(name) + if (resuming && saved != null && saved != encoded) startOver("'$stage' was entered with another '$name'") + args.put(name, encoded) + } + + /** Reached only if a stage that returns a value ran off its end, which the compiler rules out. */ + fun lost(): Nothing = throw StoryException("Stage '$stage' ended without returning a value") + + /** Remembers a local that outlives the next boundary. */ + fun save(name: String, serializer: KSerializer, value: Any?) { + StoryValues.put(locals, name, serializer, value) + } + + /** The saved value of a local, or [current] when the boundary resumed to did not need it. */ + fun restore(name: String, serializer: KSerializer, current: Any?): Any? { + val tag = locals.get(name) ?: return current + return StoryValues.decode(serializer, tag) + } + + /** The stage is about to call the stage at [site]. */ + fun beginCall(site: Int) { + run.discardPending("'$stage' moved on") + position = CALL + sites[site] + result = null + run.lastResult = null + run.enterTrampolined = true + run.changed() + } + + /** The call at [site] returned [value]; runs any tail call it left and returns the final value. */ + suspend fun completeCall(site: Int, value: Any?): Any? { + var current = value + while (true) { + val tail = run.tail ?: break + run.tail = null + run.enterTrampolined = true + current = StoryInstances.invoke(tail.instance, tail.stage, tail.args) + } + position = AFTER + sites[site] + result = run.lastResult + run.lastResult = null + segment = CompoundTag() + run.changed() + return current + } + + /** Resumes the call at [site] that was in progress when the story was saved. */ + suspend fun resumeCall(site: Int): Any? { + resuming = false + val next = run.pending.firstOrNull() + ?: throw StoryException("The saved story lost the stage '$stage' was waiting for at '${sites[site]}'") + run.enterTrampolined = true + return completeCall(site, StoryInstances.invoke(next.instance, next.stage, next.args)) + } + + /** Resumes after the call at [site]; returns its saved result, or null when [serializer] is null. */ + fun resumeAfter(site: Int, serializer: KSerializer?): Any? { + resuming = false + if (serializer == null) return null + val saved = result ?: throw StoryException("The saved story lost the result of '${sites[site]}' in '$stage'") + return StoryValues.decode(serializer, saved) + } + + /** Ends a segment at the checkpoint at [site]. */ + fun checkpoint(site: Int) { + run.discardPending("'$stage' moved on") + position = AFTER + sites[site] + result = null + segment = CompoundTag() + run.changed() + } + + /** Whether the caller runs a tail call this stage leaves, so the stage may give its place away. */ + fun canTailCall(): Boolean = trampolined + + /** Gives this frame's place to [stage] of [receiver], which the caller starts with [args] once this stage returns. */ + fun tailCall(receiver: Any, stage: String, args: CompoundTag) { + close() + replaced = true + run.tail = StoryRun.Tail(StoryInstances.keyOf(receiver), stage, args) + run.changed() + } + + /** The stage returns [value]; the caller reads it back with [serializer] after a restart. */ + fun returning(value: Any?, serializer: KSerializer): Any? { + run.lastResult = StoryValues.encode(serializer, value) + return value + } + + /** The stage is leaving with [failure]. */ + fun abort(failure: Throwable) { + this.failure = failure + } + + /** The stage is leaving. Its frame stays when the whole story stopped, so it can be saved as it was. */ + fun leave() { + if (replaced) return + val reason = failure + failure = null + if (run.isCancelled || reason is StoryEntityMissing) return + close() + run.changed() + } + + private fun startOver(reason: String) { + HollowEngine.LOGGER.warn("Stage '{}' of '{}' starts over: {}", stage, instance, reason) + resuming = false + target = -1 + position = null + locals = CompoundTag() + result = null + segment = CompoundTag() + run.discardPending(reason) + } + + internal fun adopt(hash: Int, sites: Array): StoryFrame { + this.sites = sites + val saved = position + target = saved?.let(::positionOf) ?: -1 + if (saved != null && target < 0) { + startOver("it no longer has '$saved'") + } else if (saved != null && hash != this.hash) { + HollowEngine.LOGGER.debug( + "Stage '{}' of '{}' changed since it was saved, resuming at '{}'", + stage, + instance, + saved + ) + } + this.hash = hash + resuming = target >= 0 + return this + } + + internal fun fresh(hash: Int, sites: Array): StoryFrame { + this.hash = hash + this.sites = sites + return this + } + + /** Takes the frame off the stack together with the branches it started. */ + private fun close() { + check(run.frames.lastOrNull() === this) { + "Stages of story '${run.path}' ran side by side; start parallel branches with async(name) { }" + } + run.frames.removeAt(run.frames.lastIndex) + children.forEach { run.closeChild(it) } + children.clear() + } + + private fun positionOf(saved: String): Int { + val call = saved.startsWith(CALL) + val site = sites.indexOf(saved.removePrefix(if (call) CALL else AFTER)) + return if (site < 0) -1 else site * 2 + if (call) 0 else 1 + } + + internal fun save(): CompoundTag = CompoundTag().apply { + putString("instance", instance) + putString("stage", stage) + putInt("hash", hash) + position?.let { putString("position", it) } + put("args", args) + put("locals", locals) + result?.let { put("result", it) } + put("segment", segment) + put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) + } + + internal companion object { + private const val CALL = "call:" + private const val AFTER = "after:" + + fun load(run: StoryRun, tag: CompoundTag) = + StoryFrame(run, tag.getString("instance"), tag.getString("stage")).apply { + hash = tag.getInt("hash") + position = tag.getString("position").takeIf { tag.contains("position") } + args = tag.getCompound("args") + locals = tag.getCompound("locals") + result = tag.get("result") + segment = tag.getCompound("segment") + tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } + } + } +} + +/** Where the code the compiler generates for stages reaches the running story. */ +object StoryFrames { + /** Enters [stage] of [receiver], resuming the saved frame when the story is being restored. */ + suspend fun enter(receiver: Any, stage: String, hash: Int, sites: Array): StoryFrame { + val run = currentCoroutineContext()[StoryRun.Element]?.run + ?: throw StoryException("Stage '$stage' was called outside a story; start one with launchStory") + return run.enter(receiver, stage, hash, sites) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt new file mode 100644 index 000000000..a34f37cda --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -0,0 +1,133 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.IdentityHashMap +import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn + +/** + * The script instances stages run in, under keys that survive a restart. + * + * A key is the source path of the script ([StageScript]); a node adds what it is attached to, since every + * entity runs its own instance of an attached node. A saved frame names its instance by key, so a story + * resumes once a script with that key is running again. + */ +object StoryInstances { + private class Entry(val instance: Any, val job: Job?) + + private val byKey = HashMap() + private val keys = IdentityHashMap() + private val entries = HashMap, Map>() + private val listeners = ArrayList<(String) -> Unit>() + + /** + * Makes [instance] and the scripts it imports available to stories until [scope] ends. The compiler marks + * every script class with [StageScript]; other instances are ignored. + */ + fun register(instance: Any, scope: CoroutineScope) { + val job = scope.coroutineContext.job + val added = ArrayList() + register(instance, job, added) + job.invokeOnCompletion { added.forEach { key -> remove(key, instance) } } + added.forEach { key -> listeners.forEach { it(key) } } + } + + /** The key [instance] is addressed by, registering it if it was not. */ + fun keyOf(instance: Any): String = keys[instance] ?: keyFor(instance).also { key -> + keys[instance] = key + byKey.putIfAbsent(key, Entry(instance, null)) + } + + /** The running instance registered under [key], if any. */ + fun find(key: String): Any? = byKey[key]?.instance + + /** The job of the instance under [key], which ends when the instance stops; null when nothing bounds it. */ + internal fun jobOf(key: String): Job? = byKey[key]?.job + + /** Calls [listener] with every key that becomes available. */ + internal fun onAvailable(listener: (String) -> Unit) { + listeners += listener + } + + /** + * Starts or resumes [stage] of the instance under [key] with the encoded [args] and returns what it + * returns. + */ + suspend fun invoke(key: String, stage: String, args: CompoundTag): Any? { + val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") + val method = entries.getOrPut(instance.javaClass) { scanEntries(instance.javaClass) }[stage] + ?: throw StoryException("Script '$key' has no stage '$stage'") + return suspendCoroutineUninterceptedOrReturn { continuation -> + try { + method.invoke(instance, args, continuation) + } catch (failure: InvocationTargetException) { + throw failure.targetException + } + } + } + + /** Whether the instance under [key] declares [stage]. */ + fun hasStage(key: String, stage: String): Boolean { + val instance = find(key) ?: return false + return stage in entries.getOrPut(instance.javaClass) { scanEntries(instance.javaClass) } + } + + /** Forgets everything, for tests and server shutdown. */ + internal fun clear() { + byKey.clear() + keys.clear() + } + + private fun register(instance: Any, job: Job, added: MutableList) { + if (!instance.javaClass.isAnnotationPresent(StageScript::class.java)) return + val key = keyFor(instance) + if (byKey[key]?.let { it.instance === instance && it.job != null } == true) return + keys[instance] = key + byKey[key] = Entry(instance, job) + added += key + importsOf(instance).forEach { register(it, job, added) } + } + + private fun remove(key: String, instance: Any) { + if (byKey[key]?.instance === instance) byKey.remove(key) + keys.remove(instance) + } + + /** + * The script id when the source path belongs to a known script, so a world moved to another directory still + * finds its scripts; the path itself otherwise. + */ + private fun keyFor(instance: Any): String { + val location = instance.javaClass.getAnnotation(StageScript::class.java)?.path ?: return instance.javaClass.name + val path = ScriptRegistry.idOfLocation(location)?.let(ScriptRegistry::display) ?: location + if (instance !is NodeScript) return path + return when (val host = instance.host) { + is NodeHost.Server -> "$path@server" + is NodeHost.OfEntity -> "$path@${host.entity.uuid}" + } + } + + /** The imported scripts an instance holds: they come in as constructor arguments kept in fields. */ + private fun importsOf(instance: Any): List = instance.javaClass.declaredFields + .filter { !Modifier.isStatic(it.modifiers) && !it.type.isPrimitive } + .filter { it.type.isAnnotationPresent(StageScript::class.java) } + .mapNotNull { field -> + runCatching { field.trySetAccessible(); field.get(instance) } + .onFailure { HollowEngine.LOGGER.warn("Cannot read imported script '{}'", field.name, it) } + .getOrNull() + } + + private fun scanEntries(type: Class<*>): Map = type.declaredMethods + .mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } } + .onEach { (_, method) -> method.trySetAccessible() } + .toMap() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt new file mode 100644 index 000000000..11243b73c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt @@ -0,0 +1,16 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +/** + * What the compiler turns a `for` over a range into when the loop calls stages: an index it can save instead + * of an iterator it cannot. + */ +object StoryLoops { + /** How many values [progression] goes through. */ + @JvmStatic + fun count(progression: IntProgression): Int = + if (progression.isEmpty()) 0 else (progression.last - progression.first) / progression.step + 1 + + /** The value at [index] of a progression starting at [first] with [step]. */ + @JvmStatic + fun element(first: Int, step: Int, index: Int): Int = first + index * step +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt new file mode 100644 index 000000000..a9b3f0389 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -0,0 +1,150 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import net.minecraft.world.level.Level +import net.minecraft.world.level.storage.LevelResource +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope +import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import ru.hollowhorizon.hollowengine.common.data.DataKey +import ru.hollowhorizon.hollowengine.common.data.data +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.entity.EntityEvent +import ru.hollowhorizon.hollowengine.common.events.entity.EntityLoadedEvent +import ru.hollowhorizon.hollowengine.common.events.level.LevelEvent +import ru.hollowhorizon.hollowengine.common.events.server.ServerEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeSection +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT +import ru.hollowhorizon.hollowengine.common.utils.nbt.loadAsNBT +import ru.hollowhorizon.hollowengine.common.utils.nbt.save +import java.nio.file.Files +import java.nio.file.Path +import java.util.* + +/** + * The owners stories run for, and how each keeps its runs: the server in a file of the world, an entity in its + * own data, a node in its save. + */ +object StoryOwners { + private val servers = WeakHashMap() + private val entities = WeakHashMap() + private val nodes = WeakHashMap() + + private val ENTITY_STORIES = DataKey("hollowengine-narrative:stories", ForCompoundNBT, null) + + fun server(server: MinecraftServer): StoryRuns = servers.getOrPut(server) { StoryRuns(ServerOwner(server)) } + + fun entity(entity: Entity): StoryRuns { + check(!entity.level().isClientSide) { "Stories run on the server" } + return entities.getOrPut(entity) { StoryRuns(EntityOwner(entity)) } + } + + fun node(node: NodeScript): StoryRuns = nodes.getOrPut(node) { StoryRuns(NodeOwner(node)) } + + /** Makes every script that starts reachable to stories, and gives nodes their runs and their entry stage. */ + @SubscribeEvent + fun onScriptStarted(event: ScriptStartedEvent) { + StoryInstances.register(event.script, event.scope) // Каждый скрипт может содержать State-функции, поэтому их нужно зарегистрировать + val node = event.script as? NodeScript ?: return + node.section(NODE_SECTION, object : NodeSection { + override fun save(): Tag? = nodes[node]?.save()?.takeIf { !it.isEmpty } + + override fun load(tag: Tag) { + (tag as? CompoundTag)?.let(node(node)::load) + } + }) + val entry = event.entry ?: return + runCatching { + node(node).launch( + NODE_ENTRY_KEY, StoryInstances.keyOf(node), entry + ) + }.onFailure { HollowEngine.LOGGER.error("Cannot start node '{}' at stage '{}'", node.path, entry, it) } + } + + @SubscribeEvent + fun onLevelLoad(event: LevelEvent.Load) { + val server = event.level.server ?: return + if (event.level.dimension() != Level.OVERWORLD) return + val file = serverFile(server) + if (!Files.exists(file)) return + runCatching { + Files.newInputStream(file).use { it.loadAsNBT() } as? CompoundTag + }.onFailure { HollowEngine.LOGGER.error("Cannot read the stories of the server from {}", file, it) }.getOrNull() + ?.let(server(server)::load) + } + + @SubscribeEvent + fun onLevelSave(event: LevelEvent.Save) { + val server = event.level.server ?: return + if (event.level.dimension() == Level.OVERWORLD) saveServer(server) + } + + @SubscribeEvent + fun onServerStopping(event: ServerEvent.Stoping) { + saveServer(event.server) + servers.remove(event.server) + StoryWaits.clear() + } + + @SubscribeEvent + fun onEntityLoaded(event: EntityLoadedEvent) { + val entity = event.entity + if (entity.level().isClientSide) return + StoryWaits.entityLoaded(entity.uuid) + entities[entity]?.let { it.wake(); return } + entity.data[ENTITY_STORIES]?.let(entity(entity)::load) + } + + @SubscribeEvent + fun onEntityRemoved(event: EntityEvent.Removed) { + if (!event.reason.shouldDestroy()) return + entities.remove(event.entity)?.let { runs -> runs.keys.toList().forEach(runs::stop) } + } + + private fun saveServer(server: MinecraftServer) { + val runs = servers[server] ?: return + val file = serverFile(server) + runCatching { + Files.createDirectories(file.parent) + Files.newOutputStream(file).use { runs.save().save(it) } + }.onFailure { HollowEngine.LOGGER.error("Cannot save the stories of the server to {}", file, it) } + } + + private fun serverFile(server: MinecraftServer): Path = + server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("hollowengine-narrative-stories.dat") + + private class ServerOwner(override val server: MinecraftServer) : StoryOwner { + override val scope: CoroutineScope get() = server.runtimeContext.scope + override val entity: Entity? get() = null + override fun changed() = Unit + } + + private class EntityOwner(override val entity: Entity) : StoryOwner { + override val scope: CoroutineScope get() = entity.coroutineScope + override val server: MinecraftServer + get() = entity.server ?: throw StoryException("Entity ${entity.uuid} has no server") + + override fun changed() { + entity.data[ENTITY_STORIES] = entity(entity).save() + } + } + + private class NodeOwner(private val node: NodeScript) : StoryOwner { + override val scope: CoroutineScope get() = node + override val server: MinecraftServer get() = node.server + override val entity: Entity? get() = (node.host as? NodeHost.OfEntity)?.entity + override fun changed() = Unit + } + + private const val NODE_SECTION = "hollowengine-narrative:stories" + + /** The run a node started with an entry stage, by `/hollowengine scripting run` or the editor, is kept under. */ + const val NODE_ENTRY_KEY = "main" +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt new file mode 100644 index 000000000..2bd7963cd --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -0,0 +1,249 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRun.Companion.current +import kotlin.coroutines.CoroutineContext + +/** + * A running story: the stack of stage frames plus the branches it started with [async]. + * + * A run belongs to an owner ([StoryRuns]) under a key. It is saved whole: frames, branches, variables. + * A run that cannot go on right now (its owner or a script it runs in is gone) sleeps with that state kept and starts again from it. + */ +class StoryRun internal constructor( + internal val runs: StoryRuns, + val name: String, + internal val parent: StoryRun?, +) { + enum class Status(val done: Boolean) { SLEEPING(false), RUNNING(false), COMPLETED(true), FAILED(true), STOPPED(true) } + + internal class Tail(val instance: String, val stage: String, val args: CompoundTag) + + internal val frames = ArrayList() + internal val pending = ArrayDeque() + internal val children = LinkedHashMap() + private val ownVars = CompoundTag() + private val status = MutableStateFlow(Status.SLEEPING) + private var job: Job? = null + private val watched = HashMap() + internal var tail: Tail? = null + internal var lastResult: Tag? = null + internal var enterTrampolined = false + internal var result: Tag? = null + internal var failure: String? = null + private var stopping = false + + /** Where the run is kept: its key under the owner, or `parent/name` for a branch. */ + val path: String get() = parent?.let { "${it.path}/$name" } ?: name + + val context: StoryContext get() = runs.owner.context(path) + + /** Variables declared with [saved], shared by the whole tree of runs. */ + internal val vars: CompoundTag get() = parent?.vars ?: ownVars + + val state: Status get() = status.value + + internal val isCancelled: Boolean get() = job?.isCancelled == true + + /** Starts or wakes the run; does nothing while it runs or once it ended. */ + internal fun start() { + if (state.done || job?.isActive == true) return + val previous = job + if (previous != null && !previous.isCompleted) { + previous.invokeOnCompletion { start() } + return + } + val missing = (frames + pending).map { it.instance }.firstOrNull { StoryInstances.find(it) == null } + if (missing != null) { + runs.waitFor(missing, this) + return + } + if (parent != null && parent.state != Status.RUNNING) return + stopping = false + pending.addAll(0, frames) + frames.clear() + status.value = Status.RUNNING + val parentJob = parent?.job ?: runs.owner.scope.coroutineContext.job + job = runs.owner.scope.launch(Element(this) + parentJob) { drive() } + children.values.forEach { it.start() } + } + + private suspend fun drive() { + try { + val root = pending.firstOrNull() ?: throw StoryException("Story '$path' has nothing to run") + enterTrampolined = true + StoryInstances.invoke(root.instance, root.stage, root.args) + while (true) { + val next = tail ?: break + tail = null + enterTrampolined = true + StoryInstances.invoke(next.instance, next.stage, next.args) + } + finish(Status.COMPLETED, lastResult, null) + } catch (missing: StoryEntityMissing) { + HollowEngine.LOGGER.debug("Story '{}' waits for entity {}", path, missing.uuid) + pause() + runs.waitFor(missing.uuid, this) + } catch (cancelled: CancellationException) { + if (stopping) finish(Status.STOPPED, null, null) else pause() + throw cancelled + } catch (failure: Throwable) { + HollowEngine.LOGGER.error("Story '{}' failed", path, failure) + finish(Status.FAILED, null, failure.message ?: failure.javaClass.simpleName) + } finally { + unwatch() + } + } + + internal fun enter(receiver: Any, stage: String, hash: Int, sites: Array): StoryFrame { + val key = StoryInstances.keyOf(receiver) + val trampolined = enterTrampolined + enterTrampolined = false + val saved = pending.firstOrNull() + val frame = if (saved != null && saved.instance == key && saved.stage == stage) { + pending.removeFirst().adopt(hash, sites) + } else { + saved?.let { discardPending("'$stage' was entered where '${it.stage}' was saved") } + StoryFrame(this, key, stage).fresh(hash, sites) + } + if (frames.size >= MAX_DEPTH) { + throw StoryException("Story '$path' nests more than $MAX_DEPTH stage calls; end a stage with a tail call") + } + frame.trampolined = trampolined + frames += frame + watch(key) + changed() + return frame + } + + /** Drops saved frames nobody adopted: the code no longer reaches them. */ + internal fun discardPending(reason: String) { + if (pending.isEmpty()) return + HollowEngine.LOGGER.warn("Story '{}' drops its saved stages {}: {}", path, pending.map { it.stage }, reason) + pending.clear() + } + + /** The branch [name] that the frame on top of the stack starts, or the one it started before a restart. */ + internal fun branch(name: String, lambda: StageLambda<*>): StoryRun { + require('/' !in name && name.isNotBlank()) { "A branch name must be non-blank and contain no '/': '$name'" } + val frame = frames.lastOrNull() ?: throw StoryException("async(\"$name\") must be called from a stage") + children[name]?.let { return it } + val child = StoryRun(runs, name, this) + child.pending += lambda.entry(child) + children[name] = child + frame.children += name + changed() + child.start() + return child + } + + internal fun closeChild(name: String) { + children.remove(name)?.stop() + } + + /** Stops the run for good, along with its branches. */ + internal fun stop() { + if (state.done) return + stopping = true + val running = job + if (running?.isActive == true) running.cancel(CancellationException("Story '$path' was stopped")) + else finish(Status.STOPPED, null, null) + } + + /** Puts the run to sleep, keeping where it is; [start] wakes it. */ + internal fun sleep(reason: String) { + if (state != Status.RUNNING) return + HollowEngine.LOGGER.debug("Story '{}' sleeps: {}", path, reason) + job?.cancel(CancellationException("Story '$path' sleeps: $reason")) + } + + internal suspend fun awaitDone(): Status = status.first { it.done } + + internal fun changed() = runs.changed() + + private fun pause() { + status.value = Status.SLEEPING + pending.addAll(0, frames) + frames.clear() + tail = null + changed() + } + + private fun finish(status: Status, result: Tag?, failure: String?) { + this.result = result + this.failure = failure + frames.clear() + pending.clear() + this.status.value = status + if (parent == null) runs.ended(this) else changed() + } + + private fun watch(key: String) { + if (key in watched) return + val instanceJob = StoryInstances.jobOf(key) ?: return + watched[key] = instanceJob.invokeOnCompletion { + runs.waitFor(key, root) + root.sleep("script '$key' stopped") + } + } + + private fun unwatch() { + watched.values.forEach(DisposableHandle::dispose) + watched.clear() + } + + private val root: StoryRun get() = parent?.root ?: this + + internal fun save(): CompoundTag = CompoundTag().apply { + putString("status", (if (state == Status.RUNNING) Status.SLEEPING else state).name) + put("frames", ListTag().apply { (frames + pending).forEach { add(it.save()) } }) + if (children.isNotEmpty()) put( + "children", + CompoundTag().apply { children.forEach { (name, run) -> put(name, run.save()) } }) + if (parent == null && !ownVars.isEmpty) put("vars", ownVars.copy()) + result?.let { put("result", it) } + failure?.let { putString("failure", it) } + } + + /** What the run is doing, for logs and the inspector. */ + fun describe(): String = (frames + pending).joinToString(" > ") { frame -> + frame.position?.let { "${frame.stage}@$it" } ?: frame.stage + }.ifEmpty { state.name.lowercase() } + + /** Makes the run reachable from the stages it runs, and from [current] in code that is not suspending. */ + internal class Element(val run: StoryRun) : ThreadContextElement { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key<*> get() = Key + + override fun updateThreadContext(context: CoroutineContext): StoryRun? = CURRENT.get().also { CURRENT.set(run) } + + override fun restoreThreadContext(context: CoroutineContext, oldState: StoryRun?) = CURRENT.set(oldState) + } + + internal companion object { + const val MAX_DEPTH = 32 + private val CURRENT = ThreadLocal() + + /** The run whose code is executing on this thread. */ + fun current(): StoryRun? = CURRENT.get() + + fun load(runs: StoryRuns, name: String, parent: StoryRun?, tag: CompoundTag): StoryRun = + StoryRun(runs, name, parent).apply { + status.value = runCatching { Status.valueOf(tag.getString("status")) }.getOrDefault(Status.SLEEPING) + tag.getList("frames", Tag.TAG_COMPOUND.toInt()) + .forEach { pending += StoryFrame.load(this, it as CompoundTag) } + val saved = tag.getCompound("children") + saved.allKeys.forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } + ownVars.merge(tag.getCompound("vars")) + result = tag.get("result") + failure = tag.getString("failure").takeIf { tag.contains("failure") } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt new file mode 100644 index 000000000..dc6f06a1d --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -0,0 +1,137 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import java.util.UUID + +/** + * Whoever a story runs for: the server, an entity or player, or a node. The owner keeps its runs and + * decides how long they live: a run of a player sleeps while the player is offline, a run of a node while + * the node does not run. + */ +interface StoryOwner { + /** Where the runs of this owner execute; it ends when the owner goes away. */ + val scope: CoroutineScope + + val server: MinecraftServer + + /** The entity the stories are about, if the owner has one. */ + val entity: Entity? + + /** The runs of this owner changed and should be saved. */ + fun changed() + + /** The context a stage of the run [path] sees. */ + fun context(path: String): StoryContext = StoryContext(this, path) +} + +/** What a stage knows about the run it is part of; see [context]. */ +class StoryContext internal constructor(val owner: StoryOwner, val run: String) { + val server: MinecraftServer get() = owner.server + + val entity: Entity get() = owner.entity ?: throw StoryException("Story '$run' does not belong to an entity") + + val player: ServerPlayer get() = entity as? ServerPlayer ?: throw StoryException("Story '$run' does not belong to a player") +} + +/** The runs of one [owner], by key. */ +class StoryRuns(val owner: StoryOwner) { + private val runs = LinkedHashMap() + + /** + * Starts [block] as the run [key] and returns its handle; a run already going under [key] is returned as it + * is. [block] must be a lambda written at the call, which the compiler turns into a stage. + */ + fun launch(key: String, block: @State suspend () -> Unit): StoryHandle { + val lambda = block as? StageLambda<*> + ?: throw StoryException("launchStory(\"$key\") takes a lambda written in place, not a function value") + return start(key) { run -> lambda.entry(run) } + } + + /** Starts [stage] of the running script [instance] as the run [key], replacing a run already under [key]. */ + fun launch(key: String, instance: String, stage: String): StoryHandle { + if (!StoryInstances.hasStage(instance, stage)) throw StoryException("Script '$instance' has no stage '$stage'") + stop(key) + return start(key) { run -> StoryFrame(run, instance, stage) } + } + + /** Stops the run [key]; returns whether there was one. */ + fun stop(key: String): Boolean { + val run = runs.remove(key) ?: return false + run.stop() + changed() + return true + } + + operator fun get(key: String): StoryHandle? = runs[key]?.let { StoryHandle(it, null) } + + val keys: Set get() = runs.keys + + /** Everything to save. */ + fun save(): CompoundTag = CompoundTag().apply { runs.forEach { (key, run) -> put(key, run.save()) } } + + /** Restores what [save] wrote and starts it. */ + fun load(tag: CompoundTag) { + tag.allKeys.filter { it !in runs }.forEach { key -> + val run = StoryRun.load(this, key, null, tag.getCompound(key)) + if (run.state.done) return@forEach + runs[key] = run + run.start() + } + } + + /** Wakes every sleeping run. */ + fun wake() = runs.values.toList().forEach(StoryRun::start) + + internal fun changed() = owner.changed() + + internal fun ended(run: StoryRun) { + if (runs[run.name] === run) runs.remove(run.name) + changed() + } + + internal fun waitFor(instance: String, run: StoryRun) = StoryWaits.instance(instance, run) + + internal fun waitFor(entity: UUID, run: StoryRun) = StoryWaits.entity(entity, run) + + private fun start(key: String, entry: (StoryRun) -> StoryFrame): StoryHandle { + require('/' !in key && key.isNotBlank()) { "A story key must be non-blank and contain no '/': '$key'" } + runs[key]?.let { return StoryHandle(it, null) } + val run = StoryRun(this, key, null) + run.pending += entry(run) + runs[key] = run + changed() + run.start() + return StoryHandle(run, null) + } +} + +/** Runs that sleep until a script or an entity they need is back. */ +internal object StoryWaits { + private val instances = HashMap>() + private val entities = HashMap>() + + init { + StoryInstances.onAvailable { key -> instances.remove(key)?.forEach(StoryRun::start) } + } + + fun instance(key: String, run: StoryRun) { + instances.getOrPut(key, ::LinkedHashSet) += run + } + + fun entity(uuid: UUID, run: StoryRun) { + entities.getOrPut(uuid, ::LinkedHashSet) += run + } + + fun entityLoaded(uuid: UUID) { + entities.remove(uuid)?.forEach(StoryRun::start) + } + + fun clear() { + instances.clear() + entities.clear() + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt new file mode 100644 index 000000000..d2b602cad --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt @@ -0,0 +1,121 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.delay +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.StringTag +import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import java.lang.Math.addExact +import java.time.Instant +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * The data of the segment the current stage is in: since the last stage call or checkpoint. It is saved with + * the story and starts empty at the next boundary, so it remembers what a segment already did when the segment + * repeats after a restart. + */ +suspend fun segmentTag(): CompoundTag = currentFrame("segmentTag()").segment + +/** Runs [block] once per segment: a segment repeated after a restart skips it. */ +suspend fun once(name: String, block: suspend () -> Unit) { + val frame = currentFrame("once(\"$name\")") + val done = frame.segment.getList(ONCE, Tag.TAG_STRING.toInt()) + if (done.any { it.asString == name }) return + block() + done.add(StringTag.valueOf(name)) + frame.segment.put(ONCE, done) + frame.run.changed() +} + +/** Computes a value once per segment: a segment repeated after a restart gets the value computed before. */ +suspend inline fun remember(name: String, noinline block: suspend () -> T): T = + remember(name, storySerializer(), block) + +@PublishedApi +@Suppress("UNCHECKED_CAST") +internal suspend fun remember(name: String, serializer: KSerializer, block: suspend () -> T): T { + val frame = currentFrame("remember(\"$name\")") + frame.segment.get(name)?.let { return StoryValues.decode(serializer, it) as T } + val value = block() + StoryValues.put(frame.segment, name, serializer, value) + frame.run.changed() + return value +} + +/** Forgets what [remember] or [once] kept under [name]; returns whether there was something. */ +suspend fun forget(name: String): Boolean { + val frame = currentFrame("forget(\"$name\")") + val segment = frame.segment + val done = segment.getList(ONCE, Tag.TAG_STRING.toInt()) + val wasDone = done.removeIf { it.asString == name } + val existed = segment.contains(name) + segment.remove(name) + if (wasDone) segment.put(ONCE, done) + frame.run.changed() + return existed || wasDone +} + +/** Waits for loaded server ticks; a segment repeated after a restart continues the same countdown. */ +suspend fun wait(ticks: Int, name: String) { + val frame = currentFrame("wait(ticks, \"$name\")") + waitPersistent(frame.segment, name, ticks, { currentServer.overworld().gameTime }) { delay(TICK) } +} + +/** Waits for wall-clock time, counting the time the game was not running. */ +suspend fun wait(duration: Duration, name: String) { + require(duration.isFinite() && duration >= Duration.ZERO) { "Realtime wait duration must be finite and non-negative" } + wait(Instant.ofEpochMilli(addExact(System.currentTimeMillis(), duration.inWholeMilliseconds)), name) +} + +/** Waits until [instant]; the deadline set the first time is kept across restarts. */ +suspend fun wait(instant: Instant, name: String) { + val frame = currentFrame("wait(instant, \"$name\")") + waitUntil(frame.segment, name, instant.toEpochMilli(), System::currentTimeMillis) { delay(it.milliseconds) } +} + +internal suspend fun waitPersistent( + tag: CompoundTag, + name: String, + ticks: Int, + currentTick: () -> Long, + pause: suspend () -> Unit, +) { + require(ticks >= 0) { "Persistent wait duration cannot be negative" } + val key = waitKey(GAME_TIME, name) + val deadline = if (tag.contains(key)) tag.getLong(key) else addExact(currentTick(), ticks.toLong()).also { tag.putLong(key, it) } + while (currentTick() < deadline) pause() + tag.remove(key) +} + +internal suspend fun waitUntil( + tag: CompoundTag, + name: String, + requestedDeadline: Long, + nowMillis: () -> Long, + pause: suspend (Long) -> Unit, +) { + val key = waitKey(REAL_TIME, name) + val deadline = if (tag.contains(key)) tag.getLong(key) else requestedDeadline.also { tag.putLong(key, it) } + while (true) { + val remaining = deadline - nowMillis() + if (remaining <= 0L) break + pause(minOf(remaining, MAX_REALTIME_SLEEP_MILLIS)) + } + tag.remove(key) +} + +private suspend fun currentFrame(what: String): StoryFrame = + currentRun(what).frames.lastOrNull() ?: throw StoryException("$what must be called from a stage") + +private fun waitKey(kind: String, name: String): String { + require(name.isNotBlank()) { "Persistent wait name cannot be blank" } + return "wait$.$kind.$name" +} + +private const val ONCE = "once$" +private const val GAME_TIME = "game" +private const val REAL_TIME = "real" +private const val MAX_REALTIME_SLEEP_MILLIS = 60_000L +private val TICK = 50.milliseconds diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt new file mode 100644 index 000000000..183f7e3be --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -0,0 +1,126 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryValues.put +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef +import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs +import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull +import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat +import java.util.* +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +/** + * Turns the values a story keeps (stage arguments, results, locals, saved variables) into NBT and back. + * + * Everything [NBTFormat] can write is accepted. Entities, at any depth of a list, set, map, pair or triple, + * are written as references and found again when read. + */ +object StoryValues { + private val serializers = HashMap>() + + /** Finds the entity a reference names, or null when it is not around right now. */ + var entities: (UUID, KClass) -> Entity? = ::findOnServer + + @Suppress("UNCHECKED_CAST") + fun serializer(type: KType): KSerializer = serializers.getOrPut(type) { + val classifier = + type.classifier as? KClass<*> ?: throw StoryException("A story cannot keep a value of type $type") + val arguments by lazy { + type.arguments.map { argument -> + argument.type?.let(::serializer) ?: throw StoryException("A story cannot keep a value of type $type") + } + } + val base: KSerializer<*> = when { + Entity::class.java.isAssignableFrom(classifier.java) -> EntityReference(classifier as KClass) + classifier == NpcRef::class -> NpcReference + classifier.isFunction() -> StageLambda.Serializer + classifier == List::class || classifier == Collection::class -> ListSerializer(arguments[0]) + classifier == Set::class -> SetSerializer(arguments[0]) + classifier == Map::class -> MapSerializer(arguments[0], arguments[1]) + classifier == Pair::class -> PairSerializer(arguments[0], arguments[1]) + classifier == Triple::class -> TripleSerializer(arguments[0], arguments[1], arguments[2]) + else -> runCatching { NBTFormat.serializersModule.serializer(type) }.getOrElse { + throw StoryException("A story cannot keep a value of type $type: it is not serializable", it) + } + } + (if (type.isMarkedNullable) (base as KSerializer).nullable else base) as KSerializer + } + + /** Writes [value]; a top-level null gets a marker of its own, which the NBT format has no reliable form for. */ + fun encode(serializer: KSerializer, value: Any?): Tag = + if (value == null) NULL.copy() else NBTFormat.serialize(serializer, value) + + fun decode(serializer: KSerializer, tag: Tag): Any? = + if (tag == NULL) null else NBTFormat.deserialize(serializer, tag) + + private val NULL = CompoundTag().apply { putBoolean("story\$null", true) } + + /** Writes [value] into [tag] under [name]. */ + @JvmStatic + fun put(tag: CompoundTag, name: String, serializer: KSerializer, value: Any?) { + tag.put(name, encode(serializer, value)) + } + + /** Reads what [put] wrote under [name]. */ + @JvmStatic + fun get(tag: CompoundTag, name: String, serializer: KSerializer): Any? { + val value = tag.get(name) ?: throw StoryException("The saved story has no value '$name'") + return decode(serializer, value) + } + + private fun KClass<*>.isFunction(): Boolean = this == StageLambda::class || qualifiedName.orEmpty().let { + it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") + } || Function::class.java.isAssignableFrom(java) + + private fun findOnServer(uuid: UUID, type: KClass): Entity? { + val server = currentServerOrNull() ?: return null + if (NpcEntity::class.java.isAssignableFrom(type.java)) server.npcs.resolve(uuid)?.let { return it } + return server.findEntity(uuid) + } + + private fun MinecraftServer.findEntity(uuid: UUID): Entity? = + playerList.getPlayer(uuid) ?: allLevels.firstNotNullOfOrNull { it.getEntity(uuid) } + + private class EntityReference(private val type: KClass) : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.entity", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Entity) = encoder.encodeString(value.uuid.toString()) + + override fun deserialize(decoder: Decoder): Entity { + val uuid = UUID.fromString(decoder.decodeString()) + val entity = entities(uuid, type) ?: throw StoryEntityMissing(uuid) + if (!type.isInstance(entity)) { + throw StoryException("Entity $uuid is a ${entity.javaClass.simpleName}, not a ${type.simpleName}") + } + return entity + } + } + + private object NpcReference : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.npc", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: NpcRef) = encoder.encodeString(value.uuid.toString()) + + override fun deserialize(decoder: Decoder): NpcRef = NpcRef(UUID.fromString(decoder.decodeString())) + } +} + +/** The serializer a story keeps a [T] with. The compiler calls it for every value a stage saves. */ +inline fun storySerializer(): KSerializer = StoryValues.serializer(typeOf()) + +/** A saved story names an entity that is not loaded, so the run waits until it is. */ +class StoryEntityMissing(val uuid: UUID) : RuntimeException("Entity $uuid is not loaded") diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt similarity index 96% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt index 6d050fba9..bafd55973 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.scripting.state +package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt new file mode 100644 index 000000000..2e63f9406 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -0,0 +1,92 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.IntTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** How a saved frame finds its way back, without a compiler: the positions the generated code asks about. */ +class StoryFrameTest { + private object Owner : StoryOwner { + override val scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val server: MinecraftServer get() = error("no server") + override val entity: Entity? get() = null + override fun changed() = Unit + } + + private val sites = arrayOf("intro#1", "if#1.then/trial#1", "gate") + + private fun saved(position: String?, configure: CompoundTag.() -> Unit = {}): StoryRun { + val frame = CompoundTag().apply { + putString("instance", "quests/village.kts") + putString("stage", "prologue") + putInt("hash", 7) + position?.let { putString("position", it) } + put("args", CompoundTag().apply { put("times", IntTag.valueOf(2)) }) + put("locals", CompoundTag().apply { put("map", IntTag.valueOf(5)) }) + configure() + } + val tag = CompoundTag().apply { put("frames", net.minecraft.nbt.ListTag().apply { add(frame) }) } + return StoryRun.load(StoryRuns(Owner), "quest", null, tag) + } + + @Test + fun `a frame saved inside a call resumes into that call only`() { + val run = saved("call:if#1.then/trial#1") + val frame = run.pending.removeFirst().adopt(7, sites) + assertTrue(frame.isResuming()) + assertTrue(frame.isResumingAt(2)) + assertTrue(frame.enters(2, 3)) + assertFalse(frame.enters(0, 1), "code before the call is skipped") + assertTrue(frame.resumesInto(2, 5)) + assertEquals(5, StoryValues.decode(storySerializer(), frame.locals.get("map")!!)) + } + + @Test + fun `a frame saved after a checkpoint resumes after it`() { + val frame = saved("after:gate").pending.removeFirst().adopt(9, sites) + assertTrue(frame.isResumingAt(5)) + } + + @Test + fun `a frame whose position is gone starts over without its locals`() { + val frame = saved("after:removed#1").pending.removeFirst().adopt(7, sites) + assertFalse(frame.isResuming()) + assertTrue(frame.enters(0, 1)) + assertTrue(frame.locals.isEmpty) + } + + @Test + fun `a frame entered with other arguments starts over`() { + val frame = saved("after:gate").pending.removeFirst().adopt(7, sites) + frame.saveArg("times", storySerializer(), 2) + assertTrue(frame.isResuming(), "the same arguments keep the saved position") + frame.saveArg("times", storySerializer(), 3) + assertFalse(frame.isResuming()) + } + + @Test + fun `a run keeps its frames, branches and variables through a save`() { + val run = saved("after:gate") { + put("segment", CompoundTag().apply { putBoolean("done", true) }) + } + val tag = CompoundTag().apply { + put("frames", net.minecraft.nbt.ListTag().apply { run.pending.forEach { add(it.save()) } }) + put("children", CompoundTag().apply { put("merchant", saved(null).save()) }) + put("vars", CompoundTag().apply { putInt("quests/village.kts/trust", 3) }) + } + val restored = StoryRun.load(StoryRuns(Owner), "quest", null, tag) + assertEquals("prologue@after:gate", restored.describe()) + assertEquals(setOf("merchant"), restored.children.keys) + assertEquals("quest/merchant", restored.children.getValue("merchant").path) + assertEquals(3, restored.vars.getInt("quests/village.kts/trust")) + assertEquals(restored.vars, restored.children.getValue("merchant").vars, "branches share the variables of their story") + assertTrue(restored.pending.single().segment.getBoolean("done")) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt new file mode 100644 index 000000000..8b555dee6 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt @@ -0,0 +1,77 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.Serializable +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.StringTag +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player +import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class StoryValuesTest { + @Serializable + data class Loot(val name: String, val count: Int) + + @AfterTest + fun restoreResolver() { + StoryValues.entities = { _, _ -> null } + } + + private inline fun roundTrip(value: T): T { + val tag = CompoundTag() + StoryValues.put(tag, "value", storySerializer(), value) + @Suppress("UNCHECKED_CAST") + return StoryValues.get(tag, "value", storySerializer()) as T + } + + @Test + fun `plain values and collections of them come back as they were`() { + assertEquals(42, roundTrip(42)) + assertEquals("text", roundTrip("text")) + assertNull(roundTrip(null)) + assertEquals(listOf("a", "b"), roundTrip(listOf("a", "b"))) + assertEquals(mapOf("gold" to 3), roundTrip(mapOf("gold" to 3))) + assertEquals("a" to 1, roundTrip("a" to 1)) + assertEquals(Loot("sword", 1), roundTrip(Loot("sword", 1))) + } + + @Test + fun `NPC references and stage lambdas are saved by what identifies them`() { + val uuid = UUID.randomUUID() + assertEquals(uuid, roundTrip(NpcRef(uuid)).uuid) + + val lambda = StageLambda("quests/village.kts", "script\$launchStory#1", CompoundTag().apply { putString("who", "alex") }) + val restored = roundTrip>(lambda) + assertEquals(lambda.instance, restored.instance) + assertEquals(lambda.stage, restored.stage) + assertEquals(lambda.args, restored.args) + } + + @Test + fun `entities are kept as references, at any depth, and found again when read`() { + val uuid = UUID.randomUUID() + val asked = ArrayList>() + StoryValues.entities = { id, type -> + asked += id to type.simpleName!! + null + } + assertFailsWith { StoryValues.decode(storySerializer(), StringTag.valueOf(uuid.toString())) } + assertEquals(listOf(uuid to "Player"), asked) + + val listDescriptor = storySerializer>().descriptor + assertEquals("story.entity", listDescriptor.getElementDescriptor(0).serialName) + val mapDescriptor = storySerializer>().descriptor + assertEquals("story.entity", mapDescriptor.getElementDescriptor(1).serialName.removeSuffix("?")) + } + + @Test + fun `a value that cannot be saved is reported by its type`() { + val failure = assertFailsWith { storySerializer() } + assertEquals(true, failure.message?.contains("java.lang.Thread")) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt index a3b89b4af..b27ff5786 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt @@ -49,7 +49,6 @@ import ru.hollowhorizon.hollowengine.common.scripting.nodes.addNode import ru.hollowhorizon.hollowengine.common.scripting.nodes.removeNode import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext import ru.hollowhorizon.hollowengine.common.utils.* import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import ru.hollowhorizon.hollowengine.common.vfx.Vfx @@ -316,7 +315,7 @@ private fun CommandExtension.registerScriptingCommands() { } source.server.addNode( ScriptRegistry.display(id), - context = StateContext(nextState = StringArgumentType.getString(this, "state")) + entry = StringArgumentType.getString(this, "state") ) SUCCESS } @@ -480,8 +479,7 @@ private fun com.mojang.brigadier.context.CommandContext.atta if (!isNodeScriptPath(id.path)) { return sendFailure("hollowengine.commands.scripting_state_requires_node_script".mcTranslate(path)) } - val context = state?.let { StateContext(nextState = it) } - return if (EntityNodeRuntime.attach(entity, path, context = context)) { + return if (EntityNodeRuntime.attach(entity, path, entry = state)) { sendSuccess(true) { "Attached node '$path' to ${entity.name.string}".literal } } else { sendFailure("Failed to attach node '$path' to ${entity.name.string}".literal) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 74963f7e7..8241152c5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -142,7 +142,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.coroutines.*", "ru.hollowhorizon.hollowengine.common.scripting.annotations.*", "ru.hollowhorizon.hollowengine.common.scripting.nodes.*", - "ru.hollowhorizon.hollowengine.common.scripting.state.*", "kotlinx.coroutines.delay", "kotlinx.coroutines.yield", "kotlinx.coroutines.withTimeout", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt new file mode 100644 index 000000000..0d0c192a5 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.common.scripting + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId + +/** + * A script instance has run its body and is about to start: a node, a reload script or a startup script. + * Addons pick up the instances they work with here. + * + * @param scope lives exactly as long as the instance does. + * @param entry what the instance was asked to start with, such as the stage named by `/hollowengine scripting run`. + */ +class ScriptStartedEvent( + val id: ScriptId, + val script: Any, + val scope: CoroutineScope, + val entry: String? = null, +) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt index ca45cb53e..f2a8c0607 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt @@ -45,5 +45,3 @@ annotation class ClientSide @Target(AnnotationTarget.FILE) annotation class ServerSide -@Target(AnnotationTarget.FUNCTION) -annotation class State(val name: String = "") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt index 7fc97b71d..4f642cbc5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt @@ -15,11 +15,10 @@ import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeManager import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript -import ru.hollowhorizon.hollowengine.common.scripting.nodes.addNode -import ru.hollowhorizon.hollowengine.common.scripting.nodes.nodeStateFunctions +import ru.hollowhorizon.hollowengine.common.scripting.nodes.nodeSave +import ru.hollowhorizon.hollowengine.common.scripting.nodes.startNode import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext import ru.hollowhorizon.hollowengine.common.utils.PlayerPermissions import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext @@ -115,11 +114,8 @@ class ScriptRunService( tracker.markRunning(path) if (node) { require(compiled.implicitReceiverCount == 1) { "An attached node must be started on its entity" } - require(state == null || state in compiled.type.java.nodeStateFunctions()) { "Unknown node state: $state" } if (nodes.contains(path) || nodes.isStopping(path)) return@start - server.addNode( - path, tag = null, context = state?.let { StateContext(nextState = it) }, compiled = compiled - ) + server.startNode(path, nodeSave(null), entry = state, compiled = compiled) check(nodes.contains(path)) { "Node initialization failed; see the server log" } } else ScriptLoader.executeCompiled(id, precompiled = compiled).getOrThrow() } catch (cancelled: CancellationException) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt index 02030c331..aca4450f1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt @@ -11,7 +11,6 @@ import ru.hollowhorizon.hollowengine.common.attachments.editor.ScriptEditorInfo import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext /** * Entry point for the nodes attached to entities via `@file:Attach`. @@ -22,8 +21,14 @@ import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext * so cancelling that scope tears its nodes down. */ object EntityNodeRuntime { - fun attach(entity: Entity, path: String, tag: CompoundTag? = null, context: StateContext? = null): Boolean = - AttachmentRegistry.attachments(entity).nodes.attach(path, tag, context) + /** + * Attaches the node [path] to [entity]. + * + * @param tag the data its `onLoad` handlers receive. + * @param entry what to start with, see [ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent.entry]. + */ + fun attach(entity: Entity, path: String, tag: CompoundTag? = null, entry: String? = null): Boolean = + AttachmentRegistry.attachments(entity).nodes.attach(path, tag, entry) fun detach(entity: Entity, path: String): Boolean = managerOrNull(entity)?.detach(path) ?: false @@ -70,56 +75,49 @@ class EntityNodeManager(private val entity: Entity) { /** Attached nodes whose namespace is currently unavailable. See [NodeManager] for the rationale. */ private val dormant = mutableMapOf() - fun attach(path: String, tag: CompoundTag?, context: StateContext?): Boolean { + fun attach(path: String, tag: CompoundTag?, entry: String?): Boolean { val canonicalPath = canonicalNodePath(path) if (nodes.containsKey(canonicalPath) || lifecycle.isPending(canonicalPath)) return false - if (tag == null && context == null) { + if (tag == null && entry == null) { dormant[canonicalPath]?.let { return attachSaved(canonicalPath, it) } } - return start(canonicalPath, tag, context) + return start(canonicalPath, nodeSave(tag), entry) } /** Attaches a node from the state [serialize] wrote for it. */ - private fun attachSaved(canonicalPath: String, nodeTag: CompoundTag, compiled: CompiledScript? = null): Boolean = start( - canonicalPath, - nodeTag.getCompound("extras"), - (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) }, - compiled, - ) - - private fun start(canonicalPath: String, tag: CompoundTag?, context: StateContext?, compiled: CompiledScript? = null): Boolean { - dormant[canonicalPath] = CompoundTag().apply { - tag?.let { put("extras", it.copy()) } - context?.let { put("states", it.serialize().copy()) } - } + private fun attachSaved(canonicalPath: String, nodeTag: CompoundTag, compiled: CompiledScript? = null): Boolean = + start(canonicalPath, nodeTag, entry = null, compiled) + + private fun start(canonicalPath: String, saved: CompoundTag, entry: String?, compiled: CompiledScript? = null): Boolean { + dormant[canonicalPath] = saved.copy() var accepted = true lifecycle.start(canonicalPath) { - accepted = startNow(canonicalPath, tag, context, compiled) + accepted = startNow(canonicalPath, saved, entry, compiled) } return accepted } private fun startNow( canonicalPath: String, - tag: CompoundTag?, - context: StateContext?, + saved: CompoundTag, + entry: String?, compiled: CompiledScript?, ): Boolean { val server = entity.server ?: return false - val (script, executor) = buildNode( + val script = buildNode( host = NodeHost.OfEntity(entity), parentScope = entity.coroutineScope, path = canonicalPath, - tag = tag, + saved = saved, + entry = entry, receivers = listOf(server, entity), compiled = compiled, ) ?: return false dormant.remove(canonicalPath) - nodes[canonicalPath] = RunningNode(script, executor, context) - context?.let { executor.start(it) } + nodes[canonicalPath] = RunningNode(script) return true } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt deleted file mode 100644 index ccdaaa397..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.nodes - -import ru.hollowhorizon.hollowengine.common.scripting.annotations.State -import ru.hollowhorizon.hollowengine.common.scripting.state.StateExecutor -import ru.hollowhorizon.hollowengine.common.utils.LambdaGenerator -import ru.hollowhorizon.hollowengine.common.utils.UnsafeTools -import java.lang.invoke.MethodHandles -import java.lang.reflect.Method - -fun NodeScript.prepareExecutor(executor: StateExecutor) { - val lookup = MethodHandles.privateLookupIn(javaClass, UnsafeTools.lookup) - - javaClass.nodeStateFunctions().forEach { (name, method) -> - executor.addState(name, LambdaGenerator.createSuspendRunnable(lookup, method, this)) - } -} - -internal fun Class<*>.nodeStateFunctions(): Map = declaredMethods.mapNotNull { method -> - val annotation = method.getAnnotation(State::class.java) ?: return@mapNotNull null - (annotation.name.takeIf(String::isNotBlank) ?: method.name) to method -}.toMap() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt index f401fae0e..c3b0d6bb6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt @@ -2,6 +2,7 @@ package ru.hollowhorizon.hollowengine.common.scripting.nodes import kotlinx.coroutines.CoroutineScope import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag import net.minecraft.server.MinecraftServer import kotlin.coroutines.CoroutineContext @@ -24,8 +25,27 @@ abstract class NodeScript( internal val onSaveHandlers = mutableListOf<(SerializationContext) -> Unit>() internal val onLoadHandlers = mutableListOf<(SerializationContext) -> Unit>() internal val onStartHandlers = mutableListOf Unit>() + internal val sections = linkedMapOf() val editor: NodeEditor = NodeEditor(this) + + /** + * Keeps [section] with this node under [key], beside the script's own `onSave` data. Register it from + * [ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent]: the saved value is handed back + * right after, before `onLoad`. + */ + fun section(key: String, section: NodeSection) { + require(sections.putIfAbsent(key, section) == null) { "Node '$path' already has a section '$key'" } + } +} + +/** Data an addon stores with a node, see [NodeScript.section]. */ +interface NodeSection { + /** What to write into the node save, or null to write nothing. */ + fun save(): Tag? + + /** Restores what [save] wrote the last time the node was saved. */ + fun load(tag: Tag) } data class SerializationContext( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt index 1d747c41c..51785c370 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt @@ -6,10 +6,9 @@ import net.minecraft.server.MinecraftServer import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext -import ru.hollowhorizon.hollowengine.common.scripting.state.StateExecutor import kotlin.script.experimental.api.ScriptEvaluationConfiguration import kotlin.script.experimental.api.constructorArgs import kotlin.script.experimental.api.implicitReceivers @@ -62,19 +61,16 @@ class NodeManager(val server: MinecraftServer) { HollowEngine.LOGGER.info("Node '{}' is kept dormant: its namespace is not installed", key) return@runCatching } - val extras = nodeTag.getCompound("extras") - val context = (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) } - server.addNode(key, extras, context) + server.startNode(key, nodeTag, entry = null, compiled = null) }.onFailure { HollowEngine.LOGGER.error("Error while deserializing node '$key'", it) } } } - internal fun register(script: NodeScript, executor: StateExecutor, context: StateContext?) { + internal fun register(script: NodeScript) { dormant.remove(script.path) - nodes[script.path] = RunningNode(script, executor, context) - context?.let { executor.start(it) } + nodes[script.path] = RunningNode(script) notifyExecutionChanged() } @@ -145,12 +141,7 @@ class NodeManager(val server: MinecraftServer) { } private fun startSaved(path: String, saved: CompoundTag, compiled: CompiledScript) { - server.addNode( - path, - saved.getCompound("extras"), - (saved.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) }, - compiled, - ) + server.startNode(path, saved, entry = null, compiled = compiled) } /** Stops the nodes of [namespace] without losing their state. */ @@ -173,8 +164,7 @@ class NodeManager(val server: MinecraftServer) { if (path in nodes || lifecycle.isPending(path)) return@forEach dormant.remove(path) runCatching { - val context = (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) } - server.addNode(path, nodeTag.getCompound("extras"), context) + server.startNode(path, nodeTag, entry = null, compiled = null) }.onFailure { HollowEngine.LOGGER.error("Error while resuming node '$path'", it) } } } @@ -182,43 +172,51 @@ class NodeManager(val server: MinecraftServer) { private fun isAvailable(path: String): Boolean = ScriptRegistry.source(ScriptRegistry.parse(path).namespace) != null } -internal class RunningNode( - val script: NodeScript, - val executor: StateExecutor, - val context: StateContext?, -) { +internal class RunningNode(val script: NodeScript) { /** The tag this node would be written to the world save as. */ fun persist(server: MinecraftServer): CompoundTag { val serialization = SerializationContext(server, CompoundTag()) script.onSaveHandlers.forEach { it(serialization) } + val sections = CompoundTag() + script.sections.forEach { (key, section) -> section.save()?.let { sections.put(key, it) } } return CompoundTag().apply { - put("extras", serialization.tag) - context?.let { put("states", it.serialize()) } + put(EXTRAS, serialization.tag) + if (!sections.isEmpty) put(SECTIONS, sections) }.copy() } } +/** A node save written by [RunningNode.persist] that holds only the script's own [extras]. */ +internal fun nodeSave(extras: CompoundTag?): CompoundTag = + CompoundTag().apply { extras?.let { put(EXTRAS, it.copy()) } } + /** * Compiles [path], binds it to a fresh child scope of [host]'s scope, runs the script body (which - * registers its handlers), then replays `onLoad` -> `onStart` -> `@State` states. + * registers its handlers), announces it with [ScriptStartedEvent], then replays the sections and `onLoad` + * of [saved] and launches `onStart`. * * The bound [NodeScript] is returned so the caller can register it with the owning manager; `null` is * returned when compilation is unavailable or fails. + * + * @param saved what [RunningNode.persist] wrote for the node. + * @param entry what to start with, passed on to [ScriptStartedEvent]. */ internal fun buildNode( host: NodeHost, parentScope: CoroutineScope, path: String, - tag: CompoundTag?, + saved: CompoundTag, + entry: String?, receivers: List, compiled: CompiledScript? = null, -): Pair? { +): NodeScript? { val id = ScriptRegistry.parse(path) // Nodes are keyed by their path in the world save, so every spelling of one script has to settle on // the same string before anything is registered. val canonicalPath = ScriptRegistry.display(id) val nodeScope = CoroutineScope(parentScope.coroutineContext + SupervisorJob(parentScope.coroutineContext.job)) - val binding = NodeBinding(host, nodeScope, tag) + val extras = saved.takeIf { it.contains(EXTRAS) }?.getCompound(EXTRAS) + val binding = NodeBinding(host, nodeScope, extras) val validate: (CompiledScript) -> Unit = { program -> receiverMismatch(program.implicitReceiverCount, host, receivers)?.let { error(it) } @@ -234,16 +232,18 @@ internal fun buildNode( }.getOrNull() ?: return null return runCatching { - val executor = StateExecutor(nodeScope) - script.prepareExecutor(executor) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, nodeScope, entry)) - tag?.let { extras -> - val context = SerializationContext(host.server, extras) - script.onLoadHandlers.forEach { it(context) } + val sections = saved.getCompound(SECTIONS) + script.sections.forEach { (key, section) -> sections.get(key)?.let(section::load) } + + extras?.let { + val context = SerializationContext(host.server, it) + script.onLoadHandlers.forEach { load -> load(context) } } script.onStartHandlers.forEach { start -> nodeScope.launch { start() } } - script to executor + script }.onFailure { HollowEngine.LOGGER.error("Error while initializing $canonicalPath", it) nodeScope.cancel() @@ -285,33 +285,39 @@ private fun NodeHost.describe(): String = when (this) { */ fun canonicalNodePath(path: String): String = ScriptRegistry.display(ScriptRegistry.parse(path)) -fun MinecraftServer.addNode(path: String, tag: CompoundTag? = null, context: StateContext? = null) = - addNode(path, tag, context, null) +/** + * Starts the server node [path], restarting it if it already runs. + * + * @param tag the data its `onLoad` handlers receive. + * @param entry what to start with, see [ScriptStartedEvent.entry]. + */ +fun MinecraftServer.addNode(path: String, tag: CompoundTag? = null, entry: String? = null) = + startNode(path, nodeSave(tag), entry, null) -internal fun MinecraftServer.addNode( +internal fun MinecraftServer.startNode( path: String, - tag: CompoundTag?, - context: StateContext?, + saved: CompoundTag, + entry: String?, compiled: CompiledScript?, ) { - val saved = CompoundTag().apply { - tag?.let { put("extras", it.copy()) } - context?.let { put("states", it.serialize().copy()) } - } - runtimeContext.nodes.startAfterStop(path, saved) { - val (script, executor) = buildNode( + runtimeContext.nodes.startAfterStop(path, saved.copy()) { + val script = buildNode( host = NodeHost.Server(this), parentScope = runtimeContext.scope, path = path, - tag = tag, + saved = saved, + entry = entry, receivers = listOf(this), compiled = compiled, ) ?: return@startAfterStop - runtimeContext.nodes.register(script, executor, context) + runtimeContext.nodes.register(script) } } fun MinecraftServer.removeNode(path: String) { runtimeContext.nodes.removeNode(path) } + +private const val EXTRAS = "extras" +private const val SECTIONS = "sections" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt index a96baf8f0..08736e082 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.scripting.RELOAD_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import kotlin.script.experimental.api.constructorArgs @@ -51,6 +52,7 @@ internal class ReloadScriptRunner(private val side: LogicalSide) { implicitReceivers(receiver) }.onSuccess { script -> ScriptEventHandlers.subscribe(id, script, scriptScope) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, scriptScope)) }.onFailure { error -> scriptScope.cancel() HollowEngine.LOGGER.error("Failed to execute reload script: {}", ScriptRegistry.display(id), error) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt index 2f2c606b5..6c658bb0f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt @@ -115,7 +115,7 @@ object ScriptRegistry { fun artifacts(raw: String): ScriptArtifacts? = artifacts(parse(raw)) /** - * The script a materialised source file belongs to. Addon sources are extracted into the cache + * The script a materialized source file belongs to. Addon sources are extracted into the cache * directory, so the path alone does not say which namespace owns them. */ fun idOf(file: File): ScriptId? = filesToIds[file.canonicalPath] diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt index fc90c12c8..8aeb58144 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt @@ -8,6 +8,7 @@ import ru.hollowhorizon.hollowengine.client.scripting.StartupScriptNotice import ru.hollowhorizon.hollowengine.common.scripting.STARTUP_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports @@ -45,6 +46,7 @@ object StartupScripts { }.onSuccess { script -> SharedScriptClasses.keepInstance(script::class) ScriptEventHandlers.subscribe(id, script, scope) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, scope)) }.onFailure { error -> HollowEngine.LOGGER.error("Failed to execute startup script: {}", ScriptRegistry.display(id), error) }.isSuccess diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt deleted file mode 100644 index 2993e0c32..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt +++ /dev/null @@ -1,97 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.state - -import kotlinx.coroutines.delay -import net.minecraft.nbt.CompoundTag -import ru.hollowhorizon.hollowengine.common.utils.currentServer -import java.lang.Math.addExact -import java.time.Instant -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Waits for loaded server ticks and resumes from the same deadline after a save and restart. - * Must be called from an `@State` function. - */ -suspend fun waitPersistent(name: String, ticks: Int) { - waitPersistent( - tag = stateTag(), - name = name, - ticks = ticks, - currentTick = { currentServer.overworld().gameTime }, - pause = { delay(TICK_DURATION) }, - ) -} - -/** - * Waits for wall-clock time, including time for which the game was not running. - * Must be called from an `@State` function. - */ -suspend fun waitRealtime(name: String, duration: Duration) { - require(duration.isFinite() && duration >= Duration.ZERO) { - "Realtime wait duration must be finite and non-negative" - } - val now = System.currentTimeMillis() - waitUntil(name, Instant.ofEpochMilli(addExact(now, duration.inWholeMilliseconds))) -} - -/** - * Waits until an absolute date while preserving the first stored deadline across restarts. - * Must be called from an `@State` function. - */ -suspend fun waitUntil(name: String, instant: Instant) { - waitUntil( - tag = stateTag(), - name = name, - requestedDeadline = instant.toEpochMilli(), - nowMillis = System::currentTimeMillis, - pause = { delay(it.milliseconds) }, - ) -} - -internal suspend fun waitPersistent( - tag: CompoundTag, - name: String, - ticks: Int, - currentTick: () -> Long, - pause: suspend () -> Unit, -) { - require(ticks >= 0) { "Persistent wait duration cannot be negative" } - val key = waitKey(GAME_TIME_KIND, name) - val deadline = if (tag.contains(key)) { - tag.getLong(key) - } else { - addExact(currentTick(), ticks.toLong()).also { tag.putLong(key, it) } - } - - while (currentTick() < deadline) pause() - tag.remove(key) -} - -internal suspend fun waitUntil( - tag: CompoundTag, - name: String, - requestedDeadline: Long, - nowMillis: () -> Long, - pause: suspend (Long) -> Unit, -) { - val key = waitKey(REAL_TIME_KIND, name) - val deadline = if (tag.contains(key)) tag.getLong(key) else requestedDeadline.also { tag.putLong(key, it) } - - while (true) { - val remaining = deadline - nowMillis() - if (remaining <= 0L) break - pause(minOf(remaining, MAX_REALTIME_SLEEP_MILLIS)) - } - tag.remove(key) -} - -private fun waitKey(kind: String, name: String): String { - require(name.isNotBlank()) { "Persistent wait name cannot be blank" } - return "$WAIT_PREFIX.$kind.$name" -} - -private val TICK_DURATION = 50.milliseconds -private const val WAIT_PREFIX = "wait$" -private const val GAME_TIME_KIND = "game" -private const val REAL_TIME_KIND = "real" -private const val MAX_REALTIME_SLEEP_MILLIS = 60_000L diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt deleted file mode 100644 index 8d04d1ecf..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt +++ /dev/null @@ -1,80 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.state - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import net.minecraft.nbt.CompoundTag -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext - -class StateExecutor(val scope: CoroutineScope) { - private val states = mutableMapOf Unit>() - - fun addState(name: String, state: suspend () -> Unit) { - states[name] = state - } - - fun start(context: StateContext, start: CoroutineStart = CoroutineStart.DEFAULT) { - context.executor = this - - context.job = scope.launch(context, start) { - while (true) { - val nextState = context.nextState ?: break - states[nextState]?.invoke() - - context.children.values.forEach { - if (it.job.isActive) it.job.cancel() - } - context.tag.allKeys.forEach { key -> - states.remove(key) - } - - if (nextState == context.nextState) break - } - } - } -} - -suspend fun async(name: String, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend () -> Unit): Job { - val state = stateContext() - val context = state.children.computeIfAbsent(name) { - StateContext(CompoundTag(), nextState = name) - } - state.executor.addState(name, block) - state.executor.start(context, start) - return context.job -} - -class StateContext( - val tag: CompoundTag = CompoundTag(), - val children: MutableMap = mutableMapOf(), - var nextState: String? = null, -) : AbstractCoroutineContextElement(Key) { - object Key : CoroutineContext.Key - - internal lateinit var executor: StateExecutor - internal lateinit var job: Job - - fun serialize(): CompoundTag { - val tag = CompoundTag() - nextState?.let { tag.putString("next_state", it) } - tag.put("children", CompoundTag().apply { - children.forEach { (name, context) -> put(name, context.serialize()) } - }) - tag.put("self", this.tag) - return tag - } - - companion object { - - fun deserialize(serialized: CompoundTag): StateContext { - val children = serialized.getCompound("children") - return StateContext( - serialized.getCompound("self"), - children.allKeys.associateWith { deserialize(children.getCompound(it)) }.toMutableMap(), - serialized.getString("next_state") - ) - } - } -} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt deleted file mode 100644 index db8036e2f..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt +++ /dev/null @@ -1,85 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.state - -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.serializer -import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.StringTag -import ru.hollowhorizon.hollowengine.common.data.DataKey -import ru.hollowhorizon.hollowengine.common.data.read -import ru.hollowhorizon.hollowengine.common.data.write -import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat - -internal suspend fun stateContext(): StateContext { - return currentCoroutineContext()[StateContext.Key] ?: error("StateContext not found!") -} - -internal suspend fun transition(target: String) { - stateContext().nextState = target -} - -suspend fun stateTag(): CompoundTag = stateContext().tag - -suspend fun runOnce(name: String, block: suspend () -> Unit) { - val tag = stateTag() - val list = tag.getList("once_tasks$", 8) - val hasEntry = list.map { (it as StringTag).asString }.contains(name) - if (hasEntry) return - - block() - - list.add(StringTag.valueOf(name)) - tag.put("once_tasks$", list) -} - -@OptIn(InternalSerializationApi::class) -suspend inline fun remember(name: String, block: suspend () -> T): T { - val tag = stateTag() - - val serializer = T::class.serializer() - - tag.get(name)?.let { - return NBTFormat.deserialize(serializer, it) - } - - val value = block() - tag.put(name, NBTFormat.serialize(serializer, value)) - return value -} - -suspend fun forget(name: String): Boolean { - val tag = stateTag() - val exists = tag.contains(name) - tag.remove(name) - return exists -} - -suspend fun stateGet(key: DataKey): T? = - stateTag().read(key) ?: key.defaultValue?.invoke() - -suspend fun stateGetOrPut(key: DataKey, defaultValue: () -> T): T { - val tag = stateTag() - tag.read(key)?.let { return it } - return defaultValue().also { tag.write(key, it) } -} - -suspend fun stateGetOrPut(key: DataKey): T = - stateGetOrPut(key, key.defaultValue ?: error("Data key '${key.name}' has no default value")) - -suspend fun stateSet(key: DataKey, value: T) { - stateTag().write(key, value) -} - -suspend fun stateUpdate(key: DataKey, transform: (T) -> T): T { - val current = stateGet(key) ?: error("Data key '${key.name}' is not set and has no default value") - return transform(current).also { stateSet(key, it) } -} - -suspend fun stateContains(key: DataKey<*>): Boolean = stateTag().contains(key.name) - -suspend fun stateRemove(key: DataKey<*>): Boolean { - val tag = stateTag() - val existed = tag.contains(key.name) - tag.remove(key.name) - return existed -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt index bebe8d7e2..67f441853 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt @@ -26,11 +26,7 @@ import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted import ru.hollowhorizon.hollowengine.common.npcs.navigation.* -import ru.hollowhorizon.hollowengine.common.scripting.state.waitPersistent -import ru.hollowhorizon.hollowengine.common.scripting.state.waitRealtime -import ru.hollowhorizon.hollowengine.common.scripting.state.waitUntil import ru.hollowhorizon.hollowengine.common.utils.* -import java.time.Instant import java.util.* import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -393,21 +389,6 @@ suspend fun wait(time: Int) { delay((time * 50L).milliseconds) } -/** Waits for loaded server ticks inside `@State` and resumes the same countdown after a restart. */ -suspend fun wait(time: Int, name: String) { - waitPersistent(name, time) -} - -/** Waits inside `@State` for wall-clock time, including time for which the game was not running. */ -suspend fun wait(time: Duration, name: String) { - waitRealtime(name, time) -} - -/** Waits inside `@State` until an absolute date and resumes the same deadline after a restart. */ -suspend fun wait(time: Instant, name: String) { - waitUntil(name, time) -} - fun uuid(uuid: String): UUID = UUID.fromString(uuid) /** From 8827577fabf3d6f90cb3fe4a17326174575c3861 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 11:26:36 +0300 Subject: [PATCH 07/25] Expand resumable stages and lambda support Support stage lambdas with receivers and up to eight parameters, and save stage receivers and context parameters as arguments. Improve restart-safe control flow, iterable loops, value serialization, and recovery when saved locals or calls no longer match. --- .../plugin/ir/stages/StageIrGeneration.kt | 14 +- .../plugin/ir/stages/StageLambdaLifter.kt | 37 ++- .../plugin/ir/stages/StageNormalizer.kt | 83 +++-- .../common/plugin/ir/stages/StageRewriter.kt | 84 ++++- .../common/plugin/ir/stages/StageScope.kt | 19 +- .../common/plugin/ir/stages/StageSymbols.kt | 8 +- .../common/plugin/ir/stages/StageTypes.kt | 19 +- .../src/test/kotlin/StageCompileErrorsTest.kt | 41 ++- .../src/test/kotlin/StageControlFlowTest.kt | 290 ++++++++++++++++++ .../src/test/kotlin/StageFeaturesTest.kt | 198 ++++++++++++ .../src/test/kotlin/StageTestSupport.kt | 20 +- .../addons/narrative/story/StageLambda.kt | 111 ++++++- .../addons/narrative/story/State.kt | 8 + .../addons/narrative/story/StoryFrame.kt | 51 ++- .../addons/narrative/story/StoryInstances.kt | 42 ++- .../addons/narrative/story/StoryLoops.kt | 24 +- .../addons/narrative/story/StoryOwners.kt | 37 ++- .../addons/narrative/story/StoryRun.kt | 2 +- .../addons/narrative/story/StoryRuns.kt | 8 +- .../addons/narrative/story/StoryValues.kt | 31 +- .../addons/narrative/story/StoryFrameTest.kt | 2 +- .../addons/narrative/story/StoryValuesTest.kt | 6 +- 22 files changed, 989 insertions(+), 146 deletions(-) create mode 100644 addons/compiler/src/test/kotlin/StageControlFlowTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageFeaturesTest.kt diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt index 77586ff83..cd5c718f7 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -40,11 +40,15 @@ internal class StageIrGeneration(private val context: IrPluginContext) { private val script = stages.script private val reporter = stages.reporter + /** The stages made from lambdas, with how many of their arguments are captured values. */ + private var captures: Map = emptyMap() + fun compile() { script.annotations += stages.scriptAnnotation(file.fileEntry.name) val lifter = StageLambdaLifter(stages) script.declarations.toList().forEach { it.transform(lifter, null) } - script.declarations += lifter.lifted + script.declarations += lifter.lifted.keys + captures = lifter.lifted val functions = script.declarations.filterIsInstance().filter(stages::isSuspendState) functions.groupBy(stages::stageName).filterValues { it.size > 1 }.forEach { (name, duplicates) -> @@ -86,6 +90,7 @@ internal class StageIrGeneration(private val context: IrPluginContext) { StageRewriter(stages, function, layout).apply { rewrite(statements, name, hash) script.declarations += entry(name) + captures[function]?.let { script.declarations += lambdaCall(name, it) } } } @@ -93,15 +98,14 @@ internal class StageIrGeneration(private val context: IrPluginContext) { val problem = when { function.parent != script -> "A stage must be declared at the top level of a script" function.typeParameters.isNotEmpty() -> "A stage cannot have type parameters: its arguments are saved by their declared types" - function.parameters.any { it.kind == IrParameterKind.ExtensionReceiver || it.kind == IrParameterKind.Context } -> "A stage cannot have a receiver or context parameters; pass the value as an argument" else -> null } problem?.let { reporter.error(function, it); return false } var valid = true - function.parameters.filter { it.kind == IrParameterKind.Regular }.forEach { parameter -> + stages.arguments(function).forEach { parameter -> stages.types.problem(parameter.type)?.let { reporter.error( - parameter, "The arguments of a stage are saved, but '${parameter.name}' cannot be: $it" + parameter, "The arguments of a stage are saved, but '${stages.argumentName(function, parameter)}' cannot be: $it" ) valid = false } @@ -172,7 +176,7 @@ internal class StageIrGeneration(private val context: IrPluginContext) { declaration, "A state function must be declared at the top level of a script" ) else if (marked && !stages.isSuspendState(declaration)) reporter.error( - declaration, "A state function must be a suspend" + declaration, "A state function must be a suspend function" ) super.visitFunction(declaration) } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt index c076349e7..10f8389a3 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -24,10 +24,10 @@ import java.util.IdentityHashMap /** * Turns every lambda written for a `@State` function type into a stage of its own. * - * The body moves into a new function of the script, and what the lambda captured becomes its parameters, so the - * story can start the stage again after a restart with the captured values it saved. In place of the lambda the - * call gets a `StageLambda` naming that function. The stage is named after where the lambda was written - * (`onJoin$launchStory#1`), so the name survives edits elsewhere in the script. + * The body moves into a new function of the script. What the lambda captured becomes its first parameters, and + * the parameters of the lambda, its receiver first, follow; the story saves them all like the arguments of any + * stage. In place of the lambda the call gets a `StageLambdaN` naming that function. The stage is named after + * where the lambda was written (`onJoin$launchStory#1`), so the name survives edits elsewhere in the script. */ @OptIn(UnsafeDuringIrConstructionAPI::class) internal class StageLambdaLifter(private val stages: StageScope) : IrElementTransformerVoid() { @@ -35,8 +35,8 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran private val owners = ArrayDeque() private val counters = HashMap() - /** The functions made from lambdas, in the order they were made. */ - val lifted = ArrayList() + /** The functions made from lambdas, in the order they were made, with how many captured values they take. */ + val lifted = LinkedHashMap() override fun visitProperty(declaration: IrProperty): IrStatement { owners.addLast(declaration.name.asString()) @@ -69,8 +69,8 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran private fun lift(expression: IrFunctionExpression, callee: String, parameter: IrValueParameter): IrExpression? { val lambda = expression.function - if (lambda.parameters.isNotEmpty()) { - stages.reporter.error(expression, "A @State lambda cannot take parameters or a receiver") + val constructor = stages.symbols.stageLambdaConstructor(lambda.parameters.size) ?: run { + stages.reporter.error(expression, "A @State lambda takes at most 8 parameters, its receiver included") return null } val captured = captures(lambda) ?: return null @@ -94,8 +94,8 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran ) } val key = callObject(stages.symbols.keyOf, irGet(scriptThis)) - val lambdaValue = - irCallConstructor(stages.symbols.stageLambdaConstructor, listOf(lambda.returnType)).apply { + val typeArguments = lambda.parameters.map { it.type } + lambda.returnType + val lambdaValue = irCallConstructor(constructor, typeArguments).apply { arguments[0] = key arguments[1] = irString(name) arguments[2] = irGet(tag) @@ -166,19 +166,22 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran val receiver = stage.buildReceiverParameter { type = script.defaultType } stage.parameters = listOf(receiver) val names = HashSet() - val parameters = captured.associateWith { value -> - val base = value.name.asString().takeIf { value.name.isSpecial.not() } ?: "captured" - val unique = generateSequence(base) { "${it}_" }.first(names::add) - stage.addValueParameter(unique, value.type) + fun parameter(value: IrValueDeclaration, fallback: String): IrValueParameter { + val base = value.name.asString().takeIf { !value.name.isSpecial } ?: fallback + return stage.addValueParameter(generateSequence(base) { "${it}_" }.first(names::add), value.type) } + val parameters = captured.associateWith { parameter(it, "captured") } + val ownParameters = lambda.parameters.mapIndexed { index, value -> + value to parameter(value, if (value.kind == IrParameterKind.ExtensionReceiver) "receiver" else "p$index") + }.toMap() stage.annotations = listOf(stages.stateAnnotation(name)) val inside = declaredIn(lambda) stage.body = lambda.body stage.body?.transformChildrenVoid(object : IrElementTransformerVoid() { override fun visitGetValue(expression: IrGetValue): IrExpression { val value = expression.symbol.owner - val replacement = - parameters[value] ?: receiver.takeIf { stages.isScriptThis(value) && value !in inside } + val replacement = parameters[value] ?: ownParameters[value] + ?: receiver.takeIf { stages.isScriptThis(value) && value !in inside } ?: return expression return IrGetValueImpl( expression.startOffset, @@ -196,7 +199,7 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran } }) stage.patchDeclarationParents(script) - lifted += stage + lifted[stage] = parameters.size return parameters } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt index bc7c2aac7..15eccfb64 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt @@ -16,6 +16,7 @@ import org.jetbrains.kotlin.ir.symbols.impl.IrVariableSymbolImpl import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.isNothing import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.types.typeWith import org.jetbrains.kotlin.ir.util.* import org.jetbrains.kotlin.ir.visitors.* import org.jetbrains.kotlin.name.Name @@ -217,6 +218,8 @@ internal class StageNormalizer(private val stages: StageScope, private val funct } private fun whenStatement(expression: IrWhen): IrExpression { + // `&&` and `||` lose the shape their origins promise to the backend once their branches become statements. + if (expression.origin == IrStatementOrigin.ANDAND || expression.origin == IrStatementOrigin.OROR) expression.origin = null val branches = ArrayList() for ((index, branch) in expression.branches.withIndex()) { if (branch !is IrElseBranch && hasSite(branch.condition)) { @@ -228,8 +231,8 @@ internal class StageNormalizer(private val stages: StageScope, private val funct builtIns.unitType, expression.origin ).apply { - branches += IrBranchImpl(branch.startOffset, branch.endOffset, condition.value, branch.result) - branches += expression.branches.drop(index + 1) + this.branches += IrBranchImpl(branch.startOffset, branch.endOffset, condition.value, branch.result) + this.branches += expression.branches.drop(index + 1) } val nested = block(lifted.before + condition.before + whenStatement(rest)) branches += IrElseBranchImpl(branch.startOffset, branch.endOffset, constTrue(), nested) @@ -265,18 +268,35 @@ internal class StageNormalizer(private val stages: StageScope, private val funct return listOf(loop) } + /** + * A condition that calls stages is computed at the end of the body into a value the loop tests. `continue` + * has to compute it as well, so the body runs once inside an inner loop that `continue` leaves instead. + * The body stays a composite: the condition may read what the body declared. + */ private fun doWhileLoop(loop: IrDoWhileLoop): List { - if (hasSite(loop.condition)) stages.reporter.error( - loop.condition, - "A do-while condition cannot call a stage; call it at the end of the body" - ) - loop.body = block(normalize(statementsOf(loop.body))) + if (!hasSite(loop.condition)) { + loop.body = loop.body?.let { composite(normalize(statementsOf(it))) } + return listOf(loop) + } + val inner = IrDoWhileLoopImpl(loop.startOffset, loop.endOffset, builtIns.unitType, null) + inner.body = loop.body + inner.condition = stages.builder(function.symbol, loop).irFalse() + inner.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitContinue(jump: IrContinue): IrExpression = + if (jump.loop == loop) IrBreakImpl(jump.startOffset, jump.endOffset, builtIns.nothingType, inner) else jump + }) + inner.body = inner.body?.let { composite(normalize(statementsOf(it))) } + val lifted = value(loop.condition) + val condition = variable(builtIns.booleanType, initializer = lifted.value, name = "condition") + loop.body = composite(listOf(inner) + lifted.before + condition) + loop.condition = get(condition) return listOf(loop) } /** - * Rewrites `for (x in list/range)` to count with an index the frame can save. Other iterables - * keep their iterator, and calling a stage inside them is an error. + * Rewrites a `for` that calls stages to count with an index the frame can save. A range of Int is counted as it + * is; anything else is copied into a list first (see `StoryLoops.snapshot`), whose elements must then be + * serializable. A sequence may never end and is an error. */ private fun forLoop(block: IrBlock): List { val iterator = block.statements.firstOrNull() as? IrVariable @@ -292,10 +312,21 @@ internal class StageNormalizer(private val stages: StageScope, private val funct val bound: IrExpression val element: (IrType) -> IrExpression val progressionClass = stages.symbols.intProgression + val sequenceClass = stages.symbols.sequence + val elementType = nextCall(loop, iterator)?.type when { - iterable.type.isSubtypeOfClass(builtIns.listClass) -> { - val list = variable(iterable.type, initializer = iterable, name = "list") + progressionClass == null || !iterable.type.isSubtypeOfClass(progressionClass) -> { + if (sequenceClass != null && iterable.type.isSubtypeOfClass(sequenceClass) || elementType == null) { + stages.reporter.error( + block, + "A for loop that calls stages cannot go over ${iterable.type.render()}: it may never end; call toList() first" + ) + return listOf(block) + } val listClass = builtIns.listClass.owner + val snapshot = with(stages) { builder.callObject(stages.symbols.loopSnapshot, iterable) } + val listType = builtIns.listClass.typeWith(elementType) + val list = variable(listType, initializer = builder.irImplicitCast(snapshot, listType), name = "list") prologue += list bound = builder.irCall(listClass.getPropertyGetter("size")!!).apply { arguments[0] = get(list) } element = { type -> @@ -306,7 +337,7 @@ internal class StageNormalizer(private val stages: StageScope, private val funct } } - progressionClass != null && iterable.type.isSubtypeOfClass(progressionClass) -> { + else -> { val progression = variable(iterable.type, initializer = iterable, name = "range") fun property(name: String) = builder.irCall(progressionClass.owner.getPropertyGetter(name)!!).apply { arguments[0] = get(progression) @@ -332,14 +363,6 @@ internal class StageNormalizer(private val stages: StageScope, private val funct } } } - - else -> { - stages.reporter.error( - block, - "A for loop that calls stages can only go over a List or an Int range, not ${iterable.type.render()}" - ) - return listOf(block) - } } loop.condition = builder.irBlock(resultType = builtIns.booleanType) { @@ -369,6 +392,23 @@ internal class StageNormalizer(private val stages: StageScope, private val funct return listOf(block) } + /** The `next()` call of a `for` loop's iterator, which gives the type of its elements. */ + private fun nextCall(loop: IrWhileLoop, iterator: IrVariable): IrCall? { + var found: IrCall? = null + loop.body?.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + if (found == null) element.acceptChildrenVoid(this) + } + + override fun visitCall(expression: IrCall) { + val receiver = expression.arguments.firstOrNull() as? IrGetValue + if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) found = expression + else super.visitCall(expression) + } + }) + return found + } + /** Moves a stage call out of an operand into a temporary; anything else stays where it is. */ private fun materialize(expression: IrExpression): Lifted { if (!isSiteCall(expression)) return Lifted(emptyList(), expression) @@ -408,6 +448,9 @@ internal class StageNormalizer(private val stages: StageScope, private val funct private fun block(statements: List): IrBlock = IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, statements) + private fun composite(statements: List): IrComposite = + IrCompositeImpl(function.startOffset, function.endOffset, builtIns.unitType).apply { this.statements += statements } + private fun zero(type: IrType): IrExpression = IrConstImpl.defaultValueForType(function.startOffset, function.endOffset, type) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt index 6be5be72f..1cd7e0266 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -14,6 +14,7 @@ import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.isNothing import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.types.makeNullable import org.jetbrains.kotlin.ir.util.defaultType import org.jetbrains.kotlin.ir.util.defaultValueForType import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid @@ -65,11 +66,11 @@ internal class StageRewriter( if (!returnType.isUnit()) reportReturns(statements) val rewritten = statements.map(::statement) val body = ArrayList() - function.parameters.filter { it.kind == IrParameterKind.Regular }.forEach { parameter -> + stages.arguments(function).forEach { parameter -> body += frameCall( builder, "saveArg", - builder.irString(parameter.name.asString()), + builder.irString(stages.argumentName(function, parameter)), serializer(builder, parameter.type), builder.irGet(parameter) ) @@ -89,7 +90,8 @@ internal class StageRewriter( serializer(this, variable.type), irGet(variable) ) - +irSet(variable, irAs(restored, variable.type)) + // A local the resumed site did not save comes back as the null it was declared with. + +irSet(variable, irAs(restored, variable.type.makeNullable())) } }) } @@ -128,7 +130,7 @@ internal class StageRewriter( return when (statement) { is IrWhen -> guard(positions, routeWhen(statement)) is IrWhileLoop -> guard(positions, routeWhile(statement)) - is IrDoWhileLoop -> guard(positions, statement.also { it.body = rewriteBlock(it.body) }) + is IrDoWhileLoop -> guard(positions, statement.also { it.body = rewriteBody(it.body) }) is IrTry -> guard(positions, statement.also { it.tryResult = rewriteBlock(it.tryResult)!! }) is IrBlock -> guard(positions, statement.also { block -> val rewritten = block.statements.map(::statement) @@ -216,6 +218,12 @@ internal class StageRewriter( IrBlockImpl(it.startOffset, it.endOffset, builtIns.unitType, null, statementsOf(it).map(::statement)) } + /** The body of a do-while stays a composite: its condition may read what the body declares. */ + private fun rewriteBody(expression: IrExpression?): IrExpression? = expression?.let { + if (it !is IrComposite) return rewriteBlock(it) + IrCompositeImpl(it.startOffset, it.endOffset, builtIns.unitType).apply { statements += statementsOf(it).map(::statement) } + } + private fun expandSite(site: StageSite, statement: IrStatement): IrStatement { val builder = stages.builder(function.symbol, statement) return when (site.form) { @@ -223,10 +231,11 @@ internal class StageRewriter( SiteForm.VARIABLE -> { val variable = statement as IrVariable val value = value(builder, site, used = true) - if (variable in saved) builder.irSet(variable, value) else variable.also { it.initializer = value } + if (variable in saved) assignAt(builder, site, builder.irSet(variable, value)) + else variable.also { it.initializer = value } } - SiteForm.ASSIGN -> (statement as IrSetValue).also { it.value = value(builder, site, used = true) } + SiteForm.ASSIGN -> assignAt(builder, site, (statement as IrSetValue).also { it.value = value(builder, site, used = true) }) SiteForm.RETURN -> if (site.tail) tailCall(builder, site) else { (statement as IrReturn).also { it.value = returning(builder, value(builder, site, used = true), site.call.type) @@ -235,16 +244,28 @@ internal class StageRewriter( } } + /** + * Assigns the value of a site only when the stage runs it or resumes to it: resuming elsewhere skips the site, + * and the variable keeps the value it was restored with. + */ + private fun assignAt(builder: IrBuilderWithScope, site: StageSite, assignment: IrExpression): IrExpression = builder.irIfThen( + builtIns.unitType, + frameCall(builder, "enters", builder.irInt(site.callPosition), builder.irInt(site.afterPosition)), + assignment, + ) + /** The value of a site: resumed, taken from the save, or computed by calling. */ private fun value(builder: IrBuilderWithScope, site: StageSite, used: Boolean): IrExpression { val type = if (used) site.call.type else builtIns.unitType val keepsResult = !type.isUnit() && !type.isNothing() val fresh = builder.irBlock(resultType = type) { - saveLocals(site) if (site.kind == SiteKind.CHECKPOINT) { + saveLocals(site) +frameCall(this, "checkpoint", irInt(site.index)) } else { + // The frame remembers where it was before the locals of the call overwrite the saved ones. +frameCall(this, "beginCall", irInt(site.index)) + saveLocals(site) val completed = frameCall(this, "completeCall", irInt(site.index), site.call) +(if (keepsResult) irAs(completed, type) else completed) } @@ -306,12 +327,12 @@ internal class StageRewriter( val handOver = irBlock(resultType = builtIns.nothingType) { val tag = irTemporary(irCallConstructor(symbols.compoundTagConstructor, emptyList())) callee.parameters.forEachIndexed { index, parameter -> - if (parameter.kind != IrParameterKind.Regular) return@forEachIndexed + if (parameter.kind == IrParameterKind.DispatchReceiver) return@forEachIndexed +with(stages) { callObject( symbols.put, irGet(tag), - irString(parameter.name.asString()), + irString(stages.argumentName(callee, parameter)), serializer(parameter.type), irGet(arguments[index]!!) ) @@ -321,8 +342,8 @@ internal class StageRewriter( +irReturn(zero(returnType)) } val ordinary = irBlock(resultType = builtIns.nothingType) { - saveLocals(site) +frameCall(this, "beginCall", irInt(site.index)) + saveLocals(site) val completed = frameCall(this, "completeCall", irInt(site.index), call) if (returnType.isUnit()) { +completed @@ -448,7 +469,7 @@ internal class StageRewriter( callObject( symbols.get, irGet(args), - irString(parameter.name.asString()), + irString(stages.argumentName(function, parameter)), serializer(parameter.type) ) } @@ -463,4 +484,45 @@ internal class StageRewriter( } return entry } + + /** + * Generates the call of a stage made from a lambda: the runtime passes the [captures] it saved and the + * parameters of the call, which the stage takes after the captured values. + */ + fun lambdaCall(name: String, captures: Int): IrSimpleFunction { + val script = stages.script + val call = stages.context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier("stage\$call\$${function.name.asString()}") + returnType = builtIns.anyNType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + } + call.parent = script + val receiver = call.buildReceiverParameter { type = script.defaultType } + call.parameters = listOf(receiver) + val captured = call.addValueParameter("captured", symbols.compoundTagType) + val arguments = stages.arguments(function) + val parameters = arguments.drop(captures).map { call.addValueParameter(it.name, it.type) } + call.annotations = listOf(stages.callAnnotation(name)) + call.body = stages.builder(call.symbol, function).irBlockBody { + val invocation = irCall(function.symbol).apply { + this.arguments[0] = irGet(receiver) + arguments.forEachIndexed { index, parameter -> + this.arguments[index + 1] = if (index < captures) { + val value = with(stages) { + callObject(symbols.get, irGet(captured), irString(stages.argumentName(function, parameter)), serializer(parameter.type)) + } + irAs(value, parameter.type) + } else irGet(parameters[index - captures]) + } + } + if (returnType.isUnit()) { + +invocation + +irReturn(irGetObject(builtIns.unitClass)) + } else +irReturn(invocation) + } + return call + } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt index d59caa593..ccaa35428 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -6,6 +6,7 @@ import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.* import org.jetbrains.kotlin.ir.declarations.IrClass import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind import org.jetbrains.kotlin.ir.declarations.IrValueDeclaration import org.jetbrains.kotlin.ir.declarations.IrValueParameter import org.jetbrains.kotlin.ir.expressions.* @@ -15,7 +16,7 @@ import org.jetbrains.kotlin.ir.symbols.IrSymbol import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.classOrNull -import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.defaultType import org.jetbrains.kotlin.ir.util.getValueArgument import org.jetbrains.kotlin.ir.util.isAnnotation import org.jetbrains.kotlin.ir.util.isSuspend @@ -53,14 +54,24 @@ internal class StageScope( } } + /** The parameters a stage saves as its arguments: all of them but the script it belongs to. */ + fun arguments(function: IrFunction): List = + function.parameters.filter { it.kind != IrParameterKind.DispatchReceiver } + + /** The name [parameter] of [function] is saved under: its own name, `this` for a receiver. */ + fun argumentName(function: IrFunction, parameter: IrValueParameter): String = when { + parameter.kind == IrParameterKind.ExtensionReceiver -> "this" + parameter.name.isSpecial -> "argument${function.parameters.indexOf(parameter)}" + else -> parameter.name.asString() + } + /** Whether [value] is the script instance itself. */ fun isScriptThis(value: IrValueDeclaration): Boolean = value is IrValueParameter && value.type.classOrNull == script.symbol /** The serializer of [type]; function types are kept as the stage lambdas they must be. */ fun IrBuilderWithScope.serializer(type: IrType): IrExpression = irCall(symbols.storySerializer).apply { - typeArguments[0] = - if (types.isStageLambda(type) || types.isFunction(type)) symbols.stageLambda.typeWith(context.irBuiltIns.anyNType) else type + typeArguments[0] = if (types.isStageLambda(type) || types.isFunction(type)) symbols.stageLambda.owner.defaultType else type } /** Calls [function] with [arguments] for all its parameters in order, receivers first. */ @@ -77,6 +88,8 @@ internal class StageScope( fun entryAnnotation(name: String): IrAnnotation = annotation(symbols.stageEntry, name) + fun callAnnotation(name: String): IrAnnotation = annotation(symbols.stageCall, name) + fun scriptAnnotation(path: String): IrAnnotation = annotation(symbols.stageScript, path) private fun annotation(constructor: IrConstructorSymbol, value: String): IrAnnotation = diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt index af7dbcb37..290f1061f 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -33,8 +33,8 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val instances: IrClassSymbol = storyClass("StoryInstances") val loops: IrClassSymbol = storyClass("StoryLoops") val stageLambda: IrClassSymbol = storyClass("StageLambda") - val stageLambdaConstructor: IrConstructorSymbol = stageLambda.constructors.single() val stageEntry: IrConstructorSymbol = storyClass("StageEntry").constructors.single() + val stageCall: IrConstructorSymbol = storyClass("StageCall").constructors.single() val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() val stateConstructor: IrConstructorSymbol = state.constructors.single() val checkpoint: IrSimpleFunctionSymbol = storyFunction("checkpoint") @@ -51,6 +51,7 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st ?: error("kotlinx.serialization is not on the script classpath") val frameType: IrType get() = frame.owner.defaultType + val sequence: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.sequences.Sequence"))) val compoundTagType: IrType get() = compoundTag.owner.defaultType val intProgression: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.ranges.IntProgression"))) @@ -63,6 +64,11 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val keyOf: IrSimpleFunctionSymbol = member(instances, "keyOf") val loopCount: IrSimpleFunctionSymbol = member(loops, "count") val loopElement: IrSimpleFunctionSymbol = member(loops, "element") + val loopSnapshot: IrSimpleFunctionSymbol = member(loops, "snapshot") + + /** The constructor of the stage lambda that takes [arity] parameters, or null when there is none so large. */ + fun stageLambdaConstructor(arity: Int): IrConstructorSymbol? = + finder.findClass(ClassId(PACKAGE, Name.identifier("StageLambda$arity")))?.constructors?.single() fun isCheckpoint(function: IrFunction): Boolean = function.symbol == checkpoint diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt index 275571e54..843831d94 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -36,8 +36,8 @@ internal class StageTypes(context: IrPluginContext) { if (isFunction(type)) { return "a function value cannot be saved; pass a lambda to a @State parameter instead" } - if (name == "kotlin.Array" || klass.isPrimitiveArray()) return "arrays cannot be saved; use a List" - if (name in CONTAINERS || klass.isSerializable() || name in CONTEXTUAL) { + if (klass.isPrimitiveArray()) return null + if (name == "kotlin.Array" || name in CONTAINERS || klass.isSerializable() || name in CONTEXTUAL) { return simple.arguments.firstNotNullOfOrNull { argument -> (argument as? IrTypeProjection)?.type?.let(::problem) ?: if (argument is IrTypeProjection) null else "a star projection cannot be saved" @@ -52,17 +52,21 @@ internal class StageTypes(context: IrPluginContext) { } /** Whether [type] is a function type marked `@State`, whose values are stage lambdas. */ - fun isStageLambda(type: IrType): Boolean = - type.annotations.any { it.isAnnotation(StageSymbols.STATE) } || type.classOrNull?.owner?.kotlinFqName == STAGE_LAMBDA + fun isStageLambda(type: IrType): Boolean = type.annotations.any { it.isAnnotation(StageSymbols.STATE) } || + type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().startsWith(STAGE_LAMBDA) private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(SERIALIZABLE) - private fun IrClass.isPrimitiveArray(): Boolean = - kotlinFqName.asString().let { it.startsWith("kotlin.") && it.endsWith("Array") } + private fun IrClass.isPrimitiveArray(): Boolean = kotlinFqName.asString() in PRIMITIVE_ARRAYS private companion object { val SERIALIZABLE = FqName("kotlinx.serialization.Serializable") - val STAGE_LAMBDA = StageSymbols.PACKAGE.child(Name.identifier("StageLambda")) + val STAGE_LAMBDA = StageSymbols.PACKAGE.child(Name.identifier("StageLambda")).asString() + + val PRIMITIVE_ARRAYS = setOf( + "kotlin.IntArray", "kotlin.LongArray", "kotlin.ShortArray", "kotlin.ByteArray", "kotlin.FloatArray", + "kotlin.DoubleArray", "kotlin.BooleanArray", "kotlin.CharArray", + ) val BASIC = setOf( "kotlin.Int", "kotlin.Long", "kotlin.Short", "kotlin.Byte", "kotlin.Float", "kotlin.Double", @@ -73,6 +77,7 @@ internal class StageTypes(context: IrPluginContext) { "kotlin.collections.List", "kotlin.collections.MutableList", "kotlin.collections.Collection", "kotlin.collections.Set", "kotlin.collections.MutableSet", "kotlin.collections.Map", "kotlin.collections.MutableMap", + "kotlin.collections.Map.Entry", "kotlin.collections.MutableMap.MutableEntry", "kotlin.Pair", "kotlin.Triple", ) diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt index ac18ba91a..f914f895e 100644 --- a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -99,8 +99,8 @@ class StageCompileErrorsTest { """ ) assertError( - "cannot have a receiver", """ - @State suspend fun String.shout() {} + "'this' cannot be", """ + @State suspend fun Any.shout() {} """ ) } @@ -179,26 +179,23 @@ class StageCompileErrorsTest { } @Test - fun `loops that call stages must count with something a frame can save`() { - assertError( - "can only go over a List or an Int range", """ - @State suspend fun step(value: Int) {} + fun `a loop that calls stages cannot go over a sequence`() = assertError( + "it may never end", """ + @State suspend fun step(value: Int) {} - @State - suspend fun main() { - for (value in setOf(1, 2)) step(value) - } - """ - ) - assertError( - "A do-while condition cannot call a stage", """ - @State suspend fun again(): Boolean = false + @State + suspend fun main() { + for (value in generateSequence(1) { it + 1 }) step(value) + } + """ + ) - @State - suspend fun main() { - do { log("x") } while (again()) - } - """ - ) - } + @Test + fun `a stage lambda takes as many parameters as there are lambda classes for`() = assertError( + "at most 8 parameters", """ + fun many(block: @State suspend (Int, Int, Int, Int, Int, Int, Int, Int, Int) -> Unit) = Unit + + fun start() = many { a, b, c, d, e, f, g, h, i -> log("${'$'}a") } + """ + ) } diff --git a/addons/compiler/src/test/kotlin/StageControlFlowTest.kt b/addons/compiler/src/test/kotlin/StageControlFlowTest.kt new file mode 100644 index 000000000..8d3913ac2 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageControlFlowTest.kt @@ -0,0 +1,290 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Control flow around stage calls: expressions, nested handlers, recursion, loops, failing calls. */ +class StageControlFlowTest : StageTest() { + @Test + fun `nested finally blocks run in order when a stage returns from inside them`() = runTest { + val world = world("finally", """ + @State + suspend fun step() { + log("step") + await("s") + } + + @State + suspend fun inner(): String { + try { + try { + step() + return "done" + } finally { + log("inner finally") + } + } finally { + log("outer finally") + } + } + + @State + suspend fun main() = log(inner()) + """) + world.launch("main") + assertEquals(listOf("step"), StageTrace.take()) + + world.restart() + assertEquals(listOf("inner finally", "outer finally", "step"), StageTrace.take(), "unloading runs the finally blocks too") + + world.release("s") + assertEquals(listOf("inner finally", "outer finally", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `an expression with several stage calls resumes inside each of them`() = runTest { + val world = world("arithmetic", $$""" + @State + suspend fun first(): Int { + log("first") + await("a") + return 2 + } + + @State + suspend fun second(): Int { + log("second") + await("b") + return 3 + } + + @State + suspend fun third(): Int { + log("third") + await("c") + return 4 + } + + @State + suspend fun main() { + val result = first() + second() * third() + log("result $result") + } + """ + ) + world.launch("main") + world.release("a") + assertEquals(listOf("first", "second"), StageTrace.take()) + + world.restart() + world.release("b") + world.restart() + assertEquals(listOf("second", "third", "third"), StageTrace.take(), "first and second are not called again") + + world.release("c") + assertEquals(listOf("result 14"), StageTrace.take()) + world.stop() + } + + @Test + fun `short-circuit operators call the right side only when they need it, after a restart too`() = runTest { + val world = world("operators", $$""" + @State + suspend fun check(): Boolean { + log("check") + await("check") + return StageTrace.token == "pass" + } + + @State + suspend fun validate(): Boolean { + log("validate") + await("validate") + return true + } + + @State + suspend fun load(): String? { + log("load") + await("load") + return null + } + + @State + suspend fun fallback(): String { + log("fallback") + await("fallback") + return "default" + } + + @State + suspend fun main() { + val ok = check() && validate() + log("ok $ok") + val value = load() ?: fallback() + log("value $value") + } + """ + ) + StageTrace.token = "pass" + world.launch("main") + world.release("check") + world.restart() + assertEquals(listOf("check", "validate", "validate"), StageTrace.take()) + + world.release("validate") + world.release("load") + world.restart() + assertEquals(listOf("ok true", "load", "fallback", "fallback"), StageTrace.take()) + + world.release("fallback") + assertEquals(listOf("value default"), StageTrace.take()) + + StageTrace.token = "fail" + world.launch("main", key = "second") + assertEquals(listOf("check", "ok false", "load", "fallback", "value default"), StageTrace.take()) + world.stop() + } + + @Test + fun `recursion keeps every frame with its arguments and results`() = runTest { + val world = world("recursion", $$""" + @State + suspend fun factorial(n: Int): Int { + if (n <= 1) { + await("base") + return 1 + } + val child = factorial(n - 1) + checkpoint("return") + log("return $n") + if (n == 3) await("middle") + return n * child + } + + @State + suspend fun main() = log("result ${factorial(5)}") + """ + ) + val story = world.launch("main") + assertEquals(5, story.state.split(" > ").size - 1, "main and five calls of factorial are on the stack") + + world.restart() + world.release("base") + assertEquals(listOf("return 2", "return 3"), StageTrace.take()) + + world.restart() + assertEquals(listOf("return 3"), StageTrace.take(), "the frame of n = 3 resumes after its checkpoint with child = 2") + + world.release("middle") + assertEquals(listOf("return 4", "return 5", "result 120"), StageTrace.take()) + world.stop() + } + + @Test + fun `break and continue of nested loops work before and after a restart`() = runTest { + val world = world("jumps", $$""" + @State + suspend fun visit(i: Int, j: Int) { + log("visit $i $j") + await("v$i$j") + } + + @State + suspend fun main() { + outer@ for (i in 0 until 3) { + for (j in 0 until 3) { + if (j == 1) continue + if (i == 1) continue@outer + if (i == 2 && j == 2) break@outer + visit(i, j) + } + } + log("done") + } + """ + ) + world.launch("main") + world.release("v00") + world.restart() + assertEquals(listOf("visit 0 0", "visit 0 2", "visit 0 2"), StageTrace.take()) + + world.release("v02") + world.restart() + assertEquals(listOf("visit 2 0", "visit 2 0"), StageTrace.take()) + + world.release("v20") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a do-while condition can call a stage, and continue still checks it`() = runTest { + val world = world("doWhile", $$""" + @State + suspend fun more(round: Int): Boolean { + log("more $round") + await("m$round") + return round < 2 + } + + @State + suspend fun main() { + var round = 0 + do { + round++ + if (round == 1) continue + log("round $round") + } while (more(round)) + log("done $round") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("more 1", "more 1"), StageTrace.take()) + + world.release("m1") + world.release("m2") + assertEquals(listOf("round 2", "more 2", "done 2"), StageTrace.take()) + world.stop() + } + + @Test + fun `a failed stage call leaves its caller in the segment it was in before the call`() = runTest { + val world = world("failures", """ + @State + suspend fun step(name: String) = log("step ${'$'}name") + + @State + suspend fun risky() { + log("risky") + throw IllegalArgumentException("boom") + } + + @State + suspend fun main() { + step("a") + try { + risky() + } catch (failure: IllegalArgumentException) { + log("caught") + } + log("after") + await("x") + log("end") + } + """) + world.launch("main") + assertEquals(listOf("step a", "risky", "caught", "after"), StageTrace.take()) + val frame = world.save().getCompound("main").getList("frames", 10).getCompound(0) + assertEquals("after:step#1", frame.getString("position")) + + world.restart() + assertEquals(listOf("risky", "caught", "after"), StageTrace.take(), "the segment after step repeats") + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageFeaturesTest.kt b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt new file mode 100644 index 000000000..8af375502 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt @@ -0,0 +1,198 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** What stages accept besides plain arguments: lambdas with parameters, receivers, arrays, any iterable, edited code. */ +class StageFeaturesTest : StageTest() { + @Test + fun `a stage lambda with a receiver and parameters saves them like arguments`() = runTest { + val world = world("lambdaParameters", $$""" + @State + suspend fun twice(name: String, body: @State suspend String.(Int) -> Unit) { + name.body(1) + log("between") + name.body(2) + } + + @State + suspend fun main() { + val greeting = "hi" + twice("alex") { round -> + log("$greeting $this $round") + await("r$round") + } + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("hi alex 1", "hi alex 1"), StageTrace.take()) + + world.release("r1") + world.restart() + assertEquals(listOf("between", "hi alex 2", "hi alex 2"), StageTrace.take()) + + world.release("r2") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `stages take receivers and context parameters as saved arguments`() = runTest { + val world = world("receivers", $$""" + @State + suspend fun String.greet(times: Int) { + log("greet $this $times") + await("g") + log("bye $this") + } + + context(prefix: String) + @State + suspend fun announce(times: Int) { + log("$prefix $times") + await("a") + "alex".greet(times) + } + + context(prefix: String) + fun start(runs: StoryRuns) { + runs.launch("story") { announce(3) } + } + """ + ) + world.call("start", "hello", world.runs) + world.restart() + assertEquals(listOf("hello 3", "hello 3"), StageTrace.take()) + + world.release("a") + world.restart() + assertEquals(listOf("greet alex 3", "greet alex 3"), StageTrace.take()) + + world.release("g") + assertEquals(listOf("bye alex"), StageTrace.take()) + world.stop() + } + + @Test + fun `arrays are saved like lists`() = runTest { + val world = world("arrays", """ + @State + suspend fun show(names: Array, counts: IntArray) { + checkpoint("shown") + log(names.joinToString() + " " + counts.sum()) + await("x") + } + + @State + suspend fun main() = show(arrayOf("a", "b"), intArrayOf(1, 2)) + """) + world.launch("main") + world.restart() + assertEquals(listOf("a, b 3", "a, b 3"), StageTrace.take()) + world.stop() + } + + @Test + fun `a loop over any collection goes over a copy taken when it starts`() = runTest { + val world = world("iterables", $$""" + @State + suspend fun visit(name: String) { + log("visit $name") + await(name) + } + + @State + suspend fun main() { + val tags = mutableSetOf("x", "y") + for (tag in tags) { + visit(tag) + tags.clear() + } + for ((key, value) in mapOf("k" to 1)) visit("$key$value") + for (letter in "q") visit(letter.toString()) + log("done") + } + """ + ) + world.launch("main") + world.release("x") + world.restart() + assertEquals(listOf("visit x", "visit y", "visit y"), StageTrace.take(), "clearing the set does not stop the loop") + + world.release("y") + world.restart() + assertEquals(listOf("visit k1", "visit k1"), StageTrace.take()) + + world.release("k1") + world.release("q") + assertEquals(listOf("visit q", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a local whose type changed makes its stage start over`() = runTest { + fun version(reward: String) = $$""" + @State + suspend fun main() { + log("start") + val reward = $$reward + checkpoint("reward") + log("reward $reward") + await("x") + } + """ + val world = StageWorld(this, compiler.compile("migration", version("100"))) + world.launch("main") + assertEquals(listOf("start", "reward 100"), StageTrace.take()) + + world.restart(compiler.compile("migration", version("\"gold\""))) + assertEquals(listOf("start", "reward gold"), StageTrace.take(), "an Int does not read as a String, so main starts over") + world.stop() + } + + @Test + fun `a local that became a Float reads the saved Int as a number`() = runTest { + fun version(reward: String) = $$""" + @State + suspend fun main() { + log("start") + val reward = $$reward + checkpoint("reward") + log("reward $reward") + await("x") + } + """ + val world = StageWorld(this, compiler.compile("floats", version("100"))) + world.launch("main") + assertEquals(listOf("start", "reward 100"), StageTrace.take()) + + world.restart(compiler.compile("floats", version("100.5f"))) + assertEquals(listOf("reward 100.0"), StageTrace.take(), "the NBT number converts, so main resumes after the checkpoint") + world.stop() + } + + @Test + fun `an argument that no longer reads as its type fails the story with its name`() = runTest { + fun version(type: String, value: String) = $$""" + @State + suspend fun pay(amount: $$type) { + log("pay $amount") + await("pay") + } + + @State + suspend fun main() = pay($$value) + """ + val world = StageWorld(this, compiler.compile("arguments", version("Int", "100"))) + world.launch("main") + assertEquals(listOf("pay 100"), StageTrace.take()) + + world.restart(compiler.compile("arguments", version("String", "\"gold\""))) + assertEquals(emptyList(), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty(), "the story cannot go on with an argument it cannot read") + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt index 753747fcf..657c1540e 100644 --- a/addons/compiler/src/test/kotlin/StageTestSupport.kt +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -20,6 +20,8 @@ import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings import java.io.File import kotlin.coroutines.ContinuationInterceptor +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.io.path.createTempDirectory import kotlin.test.fail @@ -116,7 +118,7 @@ class StageWorld(private val test: TestScope, private var compiled: CompiledScri private class Owner(override val scope: CoroutineScope) : StoryOwner { override val server: MinecraftServer get() = error("There is no server in stage tests") override val entity: Entity? get() = null - override fun changed() = Unit + override fun changed(runs: StoryRuns) = Unit } private var scope = newScope() @@ -182,3 +184,19 @@ class StageWorld(private val test: TestScope, private var compiled: CompiledScri return instance } } + +/** A test that compiles stage scripts, with a fresh trace and compiler for every test. */ +abstract class StageTest { + protected lateinit var compiler: StageCompiler + + @BeforeTest + fun setUpStages() { + StageTrace.reset() + compiler = StageCompiler() + } + + @AfterTest + fun tearDownStages() = compiler.close() + + protected fun TestScope.world(name: String, code: String) = StageWorld(this, compiler.compile(name, code)) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt index 85656a8d3..1a0b407f4 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt @@ -9,37 +9,120 @@ import net.minecraft.nbt.CompoundTag import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT /** - * A lambda, that compiler turned into a stage: written where a `@State suspend () -> T` is expected, its body - * became the stage [stage] of the script [instance] and what it captured became [args]. + * A lambda, that compiler turned into a stage: written where a `@State suspend (…) -> T` is expected, its body + * became the stage [stage] of the script [instance] and what it captured became [args]. Its parameters, a + * receiver included, become the following arguments of that stage, saved like any other. + * + * It is called like the lambda it replaced, and it can be saved like any other value. [StageLambda0] to + * [StageLambda8] are its forms for each number of parameters. * * @param result reads the value the stage returns; only known where the lambda was written. */ -class StageLambda( +abstract class StageLambda( val instance: String, val stage: String, val args: CompoundTag, - internal val result: KSerializer? = null, -) : suspend () -> T { - @Suppress("UNCHECKED_CAST") - override suspend fun invoke(): T = StoryInstances.invoke(instance, stage, args) as T + internal val result: KSerializer?, + val arity: Int, +) { + /** Calls the stage with [parameters] after the captured arguments. */ + protected suspend fun call(vararg parameters: Any?): Any? = StoryInstances.call(instance, stage, args, parameters) /** The first frame of a run that starts with this lambda. */ internal fun entry(run: StoryRun) = StoryFrame(run, instance, stage).apply { args = this@StageLambda.args.copy() } @Serializable - private class Saved(val instance: String, val stage: String, @Serializable(with = ForCompoundNBT::class) val args: CompoundTag) + private class Saved( + val instance: String, + val stage: String, + @Serializable(with = ForCompoundNBT::class) val args: CompoundTag, + val arity: Int, + ) - internal object Serializer : KSerializer> { + internal object Serializer : KSerializer { override val descriptor: SerialDescriptor = SerialDescriptor("story.stage", Saved.serializer().descriptor) - override fun serialize(encoder: Encoder, value: StageLambda<*>) = - encoder.encodeSerializableValue(Saved.serializer(), Saved(value.instance, value.stage, value.args)) + override fun serialize(encoder: Encoder, value: StageLambda) = + encoder.encodeSerializableValue(Saved.serializer(), Saved(value.instance, value.stage, value.args, value.arity)) + + override fun deserialize(decoder: Decoder): StageLambda = + decoder.decodeSerializableValue(Saved.serializer()).let { of(it.arity, it.instance, it.stage, it.args) } + } + + internal companion object { + /** The largest number of parameters, a receiver included, a stage lambda can take. */ + const val MAX_ARITY = 8 - override fun deserialize(decoder: Decoder): StageLambda<*> = - decoder.decodeSerializableValue(Saved.serializer()).let { StageLambda(it.instance, it.stage, it.args) } + fun of(arity: Int, instance: String, stage: String, args: CompoundTag): StageLambda = when (arity) { + 0 -> StageLambda0(instance, stage, args) + 1 -> StageLambda1(instance, stage, args) + 2 -> StageLambda2(instance, stage, args) + 3 -> StageLambda3(instance, stage, args) + 4 -> StageLambda4(instance, stage, args) + 5 -> StageLambda5(instance, stage, args) + 6 -> StageLambda6(instance, stage, args) + 7 -> StageLambda7(instance, stage, args) + 8 -> StageLambda8(instance, stage, args) + else -> throw StoryException("A stage lambda takes at most $MAX_ARITY parameters, not $arity") + } } } +@Suppress("UNCHECKED_CAST") +class StageLambda0(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 0), suspend () -> R { + override suspend fun invoke(): R = call() as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda1(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 1), suspend (P1) -> R { + override suspend fun invoke(p1: P1): R = call(p1) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda2(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 2), suspend (P1, P2) -> R { + override suspend fun invoke(p1: P1, p2: P2): R = call(p1, p2) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda3(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 3), suspend (P1, P2, P3) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3): R = call(p1, p2, p3) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda4(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 4), suspend (P1, P2, P3, P4) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4): R = call(p1, p2, p3, p4) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda5(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 5), suspend (P1, P2, P3, P4, P5) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5): R = call(p1, p2, p3, p4, p5) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda6(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 6), suspend (P1, P2, P3, P4, P5, P6) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6): R = call(p1, p2, p3, p4, p5, p6) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda7(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 7), suspend (P1, P2, P3, P4, P5, P6, P7) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6, p7: P7): R = call(p1, p2, p3, p4, p5, p6, p7) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda8(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 8), suspend (P1, P2, P3, P4, P5, P6, P7, P8) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6, p7: P7, p8: P8): R = + call(p1, p2, p3, p4, p5, p6, p7, p8) as R +} + /** The value of a stage lambda, failing clearly when something else was passed where one was expected. */ -internal fun (suspend () -> Any?).asStage(where: String): StageLambda<*> = this as? StageLambda<*> +internal fun Function<*>.asStage(where: String): StageLambda = this as? StageLambda ?: throw StoryException("$where takes a lambda written in place, not a function value") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt index 3c038aff2..13a1179f6 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -30,6 +30,14 @@ suspend fun checkpoint(name: String): Unit = @Retention(AnnotationRetention.RUNTIME) annotation class StageEntry(val name: String) +/** + * Marks the call the compiler generates for a stage lambda: the runtime calls it with the captured values and the + * parameters of a call, and the stage saves them all as its arguments. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageCall(val name: String) + /** Marks a script class that declares stages with the source [path] its stages are addressed by. */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 0f9dff532..1cf5c96a3 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -37,6 +37,9 @@ class StoryFrame internal constructor( private var replaced = false private var failure: Throwable? = null + /** Where the frame was before the call in progress: a call that fails returns the frame there. */ + private var previous: CompoundTag? = null + /** Whether the stage is still finding its way back to the position it was saved at. */ fun isResuming(): Boolean = resuming @@ -68,15 +71,30 @@ class StoryFrame internal constructor( StoryValues.put(locals, name, serializer, value) } - /** The saved value of a local, or [current] when the boundary resumed to did not need it. */ + /** + * The saved value of a local, or [current] when the boundary resumed to did not need it. A local that no + * longer reads as its type, after its declaration changed, makes the stage start over. + */ fun restore(name: String, serializer: KSerializer, current: Any?): Any? { val tag = locals.get(name) ?: return current - return StoryValues.decode(serializer, tag) + return try { + StoryValues.decode(serializer, tag) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + startOver("its local '$name' no longer reads as ${serializer.descriptor.serialName}: ${failure.message}") + current + } } - /** The stage is about to call the stage at [site]. */ + /** The stage is about to call the stage at [site]; the locals the call needs are saved right after. */ fun beginCall(site: Int) { run.discardPending("'$stage' moved on") + previous = CompoundTag().apply { + position?.let { putString("position", it) } + put("locals", locals.copy()) + result?.let { put("result", it) } + } position = CALL + sites[site] result = null run.lastResult = null @@ -94,6 +112,7 @@ class StoryFrame internal constructor( current = StoryInstances.invoke(tail.instance, tail.stage, tail.args) } position = AFTER + sites[site] + previous = null result = run.lastResult run.lastResult = null segment = CompoundTag() @@ -115,13 +134,20 @@ class StoryFrame internal constructor( resuming = false if (serializer == null) return null val saved = result ?: throw StoryException("The saved story lost the result of '${sites[site]}' in '$stage'") - return StoryValues.decode(serializer, saved) + return try { + StoryValues.decode(serializer, saved) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + throw StoryException("The result of '${sites[site]}' saved in '$stage' no longer reads as ${serializer.descriptor.serialName}", failure) + } } /** Ends a segment at the checkpoint at [site]. */ fun checkpoint(site: Int) { run.discardPending("'$stage' moved on") position = AFTER + sites[site] + previous = null result = null segment = CompoundTag() run.changed() @@ -156,14 +182,29 @@ class StoryFrame internal constructor( failure = null if (run.isCancelled || reason is StoryEntityMissing) return close() + if (reason != null) run.frames.lastOrNull()?.callFailed() run.changed() } + /** + * The stage this frame was calling failed, and the stage goes on, catching it or not. Until its next boundary + * it is back in the segment it was in before the call, which a restart then repeats. + */ + private fun callFailed() { + val before = previous ?: return + if (position?.startsWith(CALL) != true) return + position = before.getString("position").takeIf { before.contains("position") } + locals = before.getCompound("locals") + result = before.get("result") + previous = null + } + private fun startOver(reason: String) { HollowEngine.LOGGER.warn("Stage '{}' of '{}' starts over: {}", stage, instance, reason) resuming = false target = -1 position = null + previous = null locals = CompoundTag() result = null segment = CompoundTag() @@ -219,6 +260,7 @@ class StoryFrame internal constructor( put("args", args) put("locals", locals) result?.let { put("result", it) } + previous?.let { put("previous", it) } put("segment", segment) put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) } @@ -234,6 +276,7 @@ class StoryFrame internal constructor( args = tag.getCompound("args") locals = tag.getCompound("locals") result = tag.get("result") + previous = tag.getCompound("previous").takeIf { tag.contains("previous") } segment = tag.getCompound("segment") tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index a34f37cda..060536a4c 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -26,7 +26,7 @@ object StoryInstances { private val byKey = HashMap() private val keys = IdentityHashMap() - private val entries = HashMap, Map>() + private val stages = HashMap, Stages>() private val listeners = ArrayList<(String) -> Unit>() /** @@ -62,25 +62,31 @@ object StoryInstances { * Starts or resumes [stage] of the instance under [key] with the encoded [args] and returns what it * returns. */ - suspend fun invoke(key: String, stage: String, args: CompoundTag): Any? { + suspend fun invoke(key: String, stage: String, args: CompoundTag): Any? = + invoke(key, stage, { it.entries }, arrayOf(args)) + + /** Calls the stage lambda [stage] of the instance under [key] with its [captured] values and [parameters]. */ + suspend fun call(key: String, stage: String, captured: CompoundTag, parameters: Array): Any? = + invoke(key, stage, { it.calls }, arrayOf(captured, *parameters)) + + /** Whether the instance under [key] declares [stage]. */ + fun hasStage(key: String, stage: String): Boolean { + val instance = find(key) ?: return false + return stage in stagesOf(instance.javaClass).entries + } + + private suspend fun invoke(key: String, stage: String, pick: (Stages) -> Map, arguments: Array): Any? { val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") - val method = entries.getOrPut(instance.javaClass) { scanEntries(instance.javaClass) }[stage] - ?: throw StoryException("Script '$key' has no stage '$stage'") + val method = pick(stagesOf(instance.javaClass))[stage] ?: throw StoryException("Script '$key' has no stage '$stage'") return suspendCoroutineUninterceptedOrReturn { continuation -> try { - method.invoke(instance, args, continuation) + method.invoke(instance, *arguments, continuation) } catch (failure: InvocationTargetException) { throw failure.targetException } } } - /** Whether the instance under [key] declares [stage]. */ - fun hasStage(key: String, stage: String): Boolean { - val instance = find(key) ?: return false - return stage in entries.getOrPut(instance.javaClass) { scanEntries(instance.javaClass) } - } - /** Forgets everything, for tests and server shutdown. */ internal fun clear() { byKey.clear() @@ -126,8 +132,14 @@ object StoryInstances { .getOrNull() } - private fun scanEntries(type: Class<*>): Map = type.declaredMethods - .mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } } - .onEach { (_, method) -> method.trySetAccessible() } - .toMap() + private fun stagesOf(type: Class<*>): Stages = stages.getOrPut(type) { + val methods = type.declaredMethods.onEach { it.trySetAccessible() } + Stages( + methods.mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } }.toMap(), + methods.mapNotNull { method -> method.getAnnotation(StageCall::class.java)?.let { it.name to method } }.toMap(), + ) + } + + /** The generated methods of a script class: entries that start or resume a stage, calls of stage lambdas. */ + private class Stages(val entries: Map, val calls: Map) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt index 11243b73c..91b92b13d 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt @@ -1,8 +1,9 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story /** - * What the compiler turns a `for` over a range into when the loop calls stages: an index it can save instead - * of an iterator it cannot. + * What the compiler turns a `for` into when the loop calls stages: an index it can save instead of an iterator it + * cannot. A range is counted as it is; anything else is first copied into a list, so a collection changed while + * the story waits does not shift the elements under the saved index. */ object StoryLoops { /** How many values [progression] goes through. */ @@ -13,4 +14,23 @@ object StoryLoops { /** The value at [index] of a progression starting at [first] with [step]. */ @JvmStatic fun element(first: Int, step: Int, index: Int): Int = first + index * step + + /** The elements a `for` over [source] goes through, in order. */ + @JvmStatic + fun snapshot(source: Any?): List = when (source) { + is Iterable<*> -> source.toList() + is Map<*, *> -> source.entries.toList() + is Array<*> -> source.toList() + is IntArray -> source.toList() + is LongArray -> source.toList() + is ShortArray -> source.toList() + is ByteArray -> source.toList() + is FloatArray -> source.toList() + is DoubleArray -> source.toList() + is BooleanArray -> source.toList() + is CharArray -> source.toList() + is CharSequence -> source.toList() + is Iterator<*> -> source.asSequence().toList() + else -> throw StoryException("A loop that calls stages cannot go over ${source?.javaClass?.name}") + } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt index a9b3f0389..a4cf7de8a 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -1,10 +1,12 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.job import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.Tag import net.minecraft.server.MinecraftServer import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player import net.minecraft.world.level.Level import net.minecraft.world.level.storage.LevelResource import ru.hollowhorizon.hollowengine.HollowEngine @@ -26,16 +28,15 @@ import ru.hollowhorizon.hollowengine.common.utils.nbt.loadAsNBT import ru.hollowhorizon.hollowengine.common.utils.nbt.save import java.nio.file.Files import java.nio.file.Path -import java.util.* /** * The owners stories run for, and how each keeps its runs: the server in a file of the world, an entity in its * own data, a node in its save. */ object StoryOwners { - private val servers = WeakHashMap() - private val entities = WeakHashMap() - private val nodes = WeakHashMap() + private val servers = HashMap() + private val entities = HashMap() + private val nodes = HashMap() private val ENTITY_STORIES = DataKey("hollowengine-narrative:stories", ForCompoundNBT, null) @@ -46,18 +47,21 @@ object StoryOwners { return entities.getOrPut(entity) { StoryRuns(EntityOwner(entity)) } } - fun node(node: NodeScript): StoryRuns = nodes.getOrPut(node) { StoryRuns(NodeOwner(node)) } + fun node(node: NodeScript): StoryRuns = nodes[node] ?: throw StoryException("Node '${node.path}' is not running") /** Makes every script that starts reachable to stories, and gives nodes their runs and their entry stage. */ @SubscribeEvent fun onScriptStarted(event: ScriptStartedEvent) { StoryInstances.register(event.script, event.scope) // Каждый скрипт может содержать State-функции, поэтому их нужно зарегистрировать val node = event.script as? NodeScript ?: return + val runs = StoryRuns(NodeOwner(node)) + nodes[node] = runs + event.scope.coroutineContext.job.invokeOnCompletion { nodes.remove(node) } node.section(NODE_SECTION, object : NodeSection { - override fun save(): Tag? = nodes[node]?.save()?.takeIf { !it.isEmpty } + override fun save(): Tag? = runs.save().takeIf { !it.isEmpty } override fun load(tag: Tag) { - (tag as? CompoundTag)?.let(node(node)::load) + (tag as? CompoundTag)?.let(runs::load) } }) val entry = event.entry ?: return @@ -102,10 +106,17 @@ object StoryOwners { entity.data[ENTITY_STORIES]?.let(entity(entity)::load) } + /** + * A player is the same object after a dimension change and keeps its runs; any other entity leaving its level + * takes them along. Only a destroyed entity ends its stories, and never a player, which is removed when it + * respawns as well. + */ @SubscribeEvent fun onEntityRemoved(event: EntityEvent.Removed) { - if (!event.reason.shouldDestroy()) return - entities.remove(event.entity)?.let { runs -> runs.keys.toList().forEach(runs::stop) } + val entity = event.entity + if (entity is Player && event.reason == Entity.RemovalReason.CHANGED_DIMENSION) return + val runs = entities.remove(entity) ?: return + if (event.reason.shouldDestroy() && entity !is Player) runs.keys.toList().forEach(runs::stop) } private fun saveServer(server: MinecraftServer) { @@ -123,7 +134,7 @@ object StoryOwners { private class ServerOwner(override val server: MinecraftServer) : StoryOwner { override val scope: CoroutineScope get() = server.runtimeContext.scope override val entity: Entity? get() = null - override fun changed() = Unit + override fun changed(runs: StoryRuns) = Unit } private class EntityOwner(override val entity: Entity) : StoryOwner { @@ -131,8 +142,8 @@ object StoryOwners { override val server: MinecraftServer get() = entity.server ?: throw StoryException("Entity ${entity.uuid} has no server") - override fun changed() { - entity.data[ENTITY_STORIES] = entity(entity).save() + override fun changed(runs: StoryRuns) { + entity.data[ENTITY_STORIES] = runs.save() } } @@ -140,7 +151,7 @@ object StoryOwners { override val scope: CoroutineScope get() = node override val server: MinecraftServer get() = node.server override val entity: Entity? get() = (node.host as? NodeHost.OfEntity)?.entity - override fun changed() = Unit + override fun changed(runs: StoryRuns) = Unit } private const val NODE_SECTION = "hollowengine-narrative:stories" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index 2bd7963cd..0dd7b41f1 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -130,7 +130,7 @@ class StoryRun internal constructor( } /** The branch [name] that the frame on top of the stack starts, or the one it started before a restart. */ - internal fun branch(name: String, lambda: StageLambda<*>): StoryRun { + internal fun branch(name: String, lambda: StageLambda): StoryRun { require('/' !in name && name.isNotBlank()) { "A branch name must be non-blank and contain no '/': '$name'" } val frame = frames.lastOrNull() ?: throw StoryException("async(\"$name\") must be called from a stage") children[name]?.let { return it } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt index dc6f06a1d..c1705751f 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -21,8 +21,8 @@ interface StoryOwner { /** The entity the stories are about, if the owner has one. */ val entity: Entity? - /** The runs of this owner changed and should be saved. */ - fun changed() + /** [runs], the runs of this owner, changed and should be saved. */ + fun changed(runs: StoryRuns) /** The context a stage of the run [path] sees. */ fun context(path: String): StoryContext = StoryContext(this, path) @@ -46,7 +46,7 @@ class StoryRuns(val owner: StoryOwner) { * is. [block] must be a lambda written at the call, which the compiler turns into a stage. */ fun launch(key: String, block: @State suspend () -> Unit): StoryHandle { - val lambda = block as? StageLambda<*> + val lambda = block as? StageLambda ?: throw StoryException("launchStory(\"$key\") takes a lambda written in place, not a function value") return start(key) { run -> lambda.entry(run) } } @@ -86,7 +86,7 @@ class StoryRuns(val owner: StoryOwner) { /** Wakes every sleeping run. */ fun wake() = runs.values.toList().forEach(StoryRun::start) - internal fun changed() = owner.changed() + internal fun changed() = owner.changed(this) internal fun ended(run: StoryRun) { if (runs[run.name] === run) runs.remove(run.name) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt index 183f7e3be..1bf52d4d1 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -51,6 +51,8 @@ object StoryValues { classifier == List::class || classifier == Collection::class -> ListSerializer(arguments[0]) classifier == Set::class -> SetSerializer(arguments[0]) classifier == Map::class -> MapSerializer(arguments[0], arguments[1]) + classifier == Map.Entry::class -> EntrySerializer(arguments[0], arguments[1]) + classifier.java.isArray && !classifier.java.componentType.isPrimitive -> ArraySerializer(elementClass(type), arguments[0]) classifier == Pair::class -> PairSerializer(arguments[0], arguments[1]) classifier == Triple::class -> TripleSerializer(arguments[0], arguments[1], arguments[2]) else -> runCatching { NBTFormat.serializersModule.serializer(type) }.getOrElse { @@ -75,14 +77,24 @@ object StoryValues { tag.put(name, encode(serializer, value)) } - /** Reads what [put] wrote under [name]. */ + /** Reads what [put] wrote under [name]; a value that no longer reads as its type is reported by name. */ @JvmStatic fun get(tag: CompoundTag, name: String, serializer: KSerializer): Any? { val value = tag.get(name) ?: throw StoryException("The saved story has no value '$name'") - return decode(serializer, value) + return try { + decode(serializer, value) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + throw StoryException("The saved '$name' no longer reads as ${serializer.descriptor.serialName}", failure) + } } - private fun KClass<*>.isFunction(): Boolean = this == StageLambda::class || qualifiedName.orEmpty().let { + @Suppress("UNCHECKED_CAST") + private fun elementClass(type: KType): KClass = type.arguments.singleOrNull()?.type?.classifier as? KClass + ?: throw StoryException("A story cannot keep a value of type $type") + + private fun KClass<*>.isFunction(): Boolean = StageLambda::class.java.isAssignableFrom(java) || qualifiedName.orEmpty().let { it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") } || Function::class.java.isAssignableFrom(java) @@ -110,6 +122,19 @@ object StoryValues { } } + /** A map entry as the pair it is: the NBT format would take kotlinx's entry for a map and fail on its key. */ + private class EntrySerializer(key: KSerializer, value: KSerializer) : KSerializer> { + private val pair = PairSerializer(key, value) + + override val descriptor: SerialDescriptor = SerialDescriptor("story.entry", pair.descriptor) + + override fun serialize(encoder: Encoder, value: Map.Entry) = + encoder.encodeSerializableValue(pair, value.key to value.value) + + override fun deserialize(decoder: Decoder): Map.Entry = + decoder.decodeSerializableValue(pair).let { AbstractMap.SimpleImmutableEntry(it.first, it.second) } + } + private object NpcReference : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.npc", PrimitiveKind.STRING) diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt index 2e63f9406..a3a621d93 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -17,7 +17,7 @@ class StoryFrameTest { override val scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) override val server: MinecraftServer get() = error("no server") override val entity: Entity? get() = null - override fun changed() = Unit + override fun changed(runs: StoryRuns) = Unit } private val sites = arrayOf("intro#1", "if#1.then/trial#1", "gate") diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt index 8b555dee6..f4a20f26b 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt @@ -11,6 +11,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNull class StoryValuesTest { @@ -45,8 +46,9 @@ class StoryValuesTest { val uuid = UUID.randomUUID() assertEquals(uuid, roundTrip(NpcRef(uuid)).uuid) - val lambda = StageLambda("quests/village.kts", "script\$launchStory#1", CompoundTag().apply { putString("who", "alex") }) - val restored = roundTrip>(lambda) + val lambda = StageLambda2("quests/village.kts", "script\$launchStory#1", CompoundTag().apply { putString("who", "alex") }) + val restored = roundTrip(lambda) + assertIs>(restored, "the number of parameters comes back with the lambda") assertEquals(lambda.instance, restored.instance) assertEquals(lambda.stage, restored.stage) assertEquals(lambda.args, restored.args) From c87eac8ccb808cd0d24c7f9705abb3fbe9e1b4fb Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 13:30:10 +0300 Subject: [PATCH 08/25] Add reversible story actions and rewind support Journal serializable action calls so stories can undo and redo changes across restarts. Add checkpoint labels, temporary actions, story controls, and reversible NPC movement and interaction APIs. --- .../common/plugin/ir/stages/StageActions.kt | 111 +++++ .../plugin/ir/stages/StageIrGeneration.kt | 24 +- .../plugin/ir/stages/StageNormalizer.kt | 45 +- .../common/plugin/ir/stages/StageRewriter.kt | 75 +-- .../common/plugin/ir/stages/StageSymbols.kt | 24 +- .../src/test/kotlin/StageCompileErrorsTest.kt | 35 ++ .../src/test/kotlin/StageJournalTest.kt | 348 ++++++++++++++ .../src/test/kotlin/StageTestSupport.kt | 10 + addons/narrative/build.gradle.kts | 7 + .../addons/narrative/HollowNarrativeAddon.kt | 13 +- .../narrative/dialogue/StoryNpcFunctions.kt | 16 +- .../addons/narrative/npcs/NpcActions.kt | 284 +++++++++++ .../addons/narrative/npcs/NpcHands.kt | 206 ++++++++ .../addons/narrative/story/Actions.kt | 197 ++++++++ .../addons/narrative/story/State.kt | 11 +- .../addons/narrative/story/StoryActions.kt | 346 +++++++++++++ .../addons/narrative/story/StoryApi.kt | 33 ++ .../addons/narrative/story/StoryCommands.kt | 158 ++++++ .../addons/narrative/story/StoryFrame.kt | 43 +- .../addons/narrative/story/StoryHistory.kt | 155 ++++++ .../addons/narrative/story/StoryInstances.kt | 13 + .../addons/narrative/story/StoryJournal.kt | 160 ++++++ .../addons/narrative/story/StoryLabels.kt | 52 ++ .../addons/narrative/story/StoryOwners.kt | 6 + .../addons/narrative/story/StoryRun.kt | 130 ++++- .../addons/narrative/story/StoryRuns.kt | 3 + .../addons/narrative/story/StorySegments.kt | 47 +- .../hollowengine-narrative/lang/en_us.json | 10 + .../hollowengine-narrative/lang/ru_ru.json | 10 + .../narrative/story/StoryActionsTest.kt | 28 ++ .../addons/narrative/story/StoryValuesTest.kt | 8 + .../common/addons/HollowAddonLoadedEvent.kt | 22 + .../common/addons/HollowAddonRuntime.kt | 3 + .../common/npcs/navigation/NpcMovement.kt | 8 +- .../common/npcs/navigation/Zone.kt | 26 +- .../story/functions/npcs/NPCActions.kt | 454 ------------------ .../functions/npcs/NPCInventoryActions.kt | 18 + .../story/functions/npcs/ScriptUtilities.kt | 86 ++++ 38 files changed, 2642 insertions(+), 583 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt create mode 100644 addons/compiler/src/test/kotlin/StageJournalTest.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt new file mode 100644 index 000000000..48620f8dd --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt @@ -0,0 +1,111 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irVararg +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrVararg +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid + +/** + * Compiles the `@Action` functions of a script. The journal calls an action again to undo it, so it saves the + * receivers and parameters of every call: they must be serializable, and `action { }` or `label { }` gets them + * passed here, which a script cannot get wrong. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageActions(private val stages: StageScope) { + private val reporter = stages.reporter + + fun compile() { + stages.script.acceptChildrenVoid(object : IrVisitorVoid() { + private val functions = ArrayDeque() + private val bodies = HashMap() + + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + val action = isAction(declaration) + if (action) check(declaration as IrSimpleFunction) + functions.addLast(declaration) + super.visitFunction(declaration) + functions.removeLast() + if (action && bodies[declaration] == null) reporter.error( + declaration, + "The body of an @Action is action { } or label { }, which the journal calls it again through" + ) + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + if (!stages.symbols.isActionBody(expression.symbol.owner)) return + val function = functions.lastOrNull() + if (function == null || !isAction(function)) { + reporter.error( + expression, + "action { } and label { } must be the body of an @Action function, written directly in it" + ) + return + } + bodies.merge(function, 1, Int::plus) + pass(function, expression) + } + }) + } + + /** Passes [call] every parameter of [function], receivers first, unless the script passed some itself. */ + private fun pass(function: IrFunction, call: IrCall) { + val given = call.arguments[0] as? IrVararg + if (given != null && given.elements.isNotEmpty()) { + reporter.error( + call, + "The compiler passes the receivers and parameters of the action to action { }; leave its parentheses empty" + ) + return + } + val builder = stages.builder(function.symbol, call) + val elementType = stages.context.irBuiltIns.anyNType + call.arguments[0] = builder.irVararg(elementType, function.parameters.map { builder.irGet(it) }) + } + + private fun check(function: IrSimpleFunction) { + val owner = function.parent + val problem = when { + !function.isSuspend -> "An action must be a suspend function" + owner != stages.script && (owner as? IrClass)?.kind != ClassKind.OBJECT -> "An action must be declared at the top level of a script or in an object" + + function.typeParameters.isNotEmpty() -> "An action cannot have type parameters: the journal saves its arguments by their declared types" + function.parameters.any { it.kind == IrParameterKind.Context } -> "An action cannot take context parameters" + else -> null + } + problem?.let { reporter.error(function, it); return } + function.parameters.filter { it.kind == IrParameterKind.ExtensionReceiver || it.kind == IrParameterKind.Regular } + .filter { it.type.classOrNull != stages.symbols.checkpointScope }.forEach { parameter -> + val name = stages.argumentName(function, parameter) + if (parameter.annotations.any { it.isAnnotation(StageSymbols.UNSAVED) }) { + if (parameter.defaultValue == null) reporter.error( + parameter, + "The journal calls the action with the default of the unsaved '$name', so it needs one" + ) + return@forEach + } + stages.types.problem(parameter.type)?.let { + reporter.error( + parameter, + "The journal saves the arguments of an action, but '$name' cannot be saved: $it; mark it @Unsaved if undoing does not need it" + ) + } + } + } + + private fun isAction(function: IrFunction): Boolean = + function.annotations.any { it.isAnnotation(StageSymbols.ACTION) } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt index cd5c718f7..83e66d033 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -16,8 +16,9 @@ import org.jetbrains.kotlin.ir.visitors.acceptVoid /** * Compiles stages of every script in a module: marks script classes so the runtime can find their - * instances, turns `@State` lambdas into stages, rewrites each stage body for saving and resuming, and generates - * entries, that runtime starts stages through. Does nothing without story runtime on the classpath. + * instances, compiles their actions, turns `@State` lambdas into stages, rewrites each stage body for saving and + * resuming, and generates entries, that runtime starts stages through. Does nothing without story runtime on the + * classpath. */ @OptIn(UnsafeDuringIrConstructionAPI::class) internal class StageIrGeneration(private val context: IrPluginContext) { @@ -45,6 +46,7 @@ internal class StageIrGeneration(private val context: IrPluginContext) { fun compile() { script.annotations += stages.scriptAnnotation(file.fileEntry.name) + StageActions(stages).compile() val lifter = StageLambdaLifter(stages) script.declarations.toList().forEach { it.transform(lifter, null) } script.declarations += lifter.lifted.keys @@ -54,8 +56,7 @@ internal class StageIrGeneration(private val context: IrPluginContext) { functions.groupBy(stages::stageName).filterValues { it.size > 1 }.forEach { (name, duplicates) -> duplicates.drop(1).forEach { reporter.error( - it, - "Two stages of this script are named '$name'; give one of them different @State(name = ...)" + it, "Two stages of this script are named '$name'; give one of them different @State(name = ...)" ) } } @@ -78,6 +79,13 @@ internal class StageIrGeneration(private val context: IrPluginContext) { layout.saved.forEach { variable -> val site = layout.sites.first { variable in it.live } stages.types.problem(variable.type)?.let { problem -> + if (variable in normalizer.loopCopies) { + reporter.error( + variable, + "A for loop that calls stages goes over a copy of its elements saved with the story, but $problem; loop over values a story can keep, such as ids, and look the rest up inside" + ) + return@forEach + } val what = if (variable.origin == IrDeclarationOrigin.IR_TEMPORARY_VARIABLE) "A value computed before" else "'${variable.name}', used after" reporter.error( @@ -105,7 +113,13 @@ internal class StageIrGeneration(private val context: IrPluginContext) { stages.arguments(function).forEach { parameter -> stages.types.problem(parameter.type)?.let { reporter.error( - parameter, "The arguments of a stage are saved, but '${stages.argumentName(function, parameter)}' cannot be: $it" + parameter, + "The arguments of a stage are saved, but '${ + stages.argumentName( + function, + parameter + ) + }' cannot be: $it" ) valid = false } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt index 15eccfb64..2dd2a113c 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt @@ -34,6 +34,9 @@ internal class StageNormalizer(private val stages: StageScope, private val funct .single { it.name.asString() == "plus" && it.parameters.size == 2 && it.parameters[1].type == builtIns.intType }.symbol private var temporaries = 0 + /** The copies of the elements `for` loops go over, which a story saves while such a loop runs. */ + val loopCopies = HashSet() + /** Statements that must run first, then a value that holds no stage call except possibly at its top. */ private class Lifted(val before: List, val value: IrExpression) @@ -183,8 +186,7 @@ internal class StageNormalizer(private val stages: StageScope, private val funct private fun whenValue(expression: IrWhen): Lifted { if (expression.type.isUnit() || expression.type.isNothing()) return Lifted( - listOf(whenStatement(expression)), - unit() + listOf(whenStatement(expression)), unit() ) val result = variable(expression.type, isVar = true, initializer = zero(expression.type)) expression.branches.forEach { branch -> branch.result = assign(result, branch.result) } @@ -208,28 +210,21 @@ internal class StageNormalizer(private val stages: StageScope, private val funct } if (expression.type.isNothing()) return expression return IrSetValueImpl( - expression.startOffset, - expression.endOffset, - builtIns.unitType, - target.symbol, - expression, - null + expression.startOffset, expression.endOffset, builtIns.unitType, target.symbol, expression, null ) } private fun whenStatement(expression: IrWhen): IrExpression { // `&&` and `||` lose the shape their origins promise to the backend once their branches become statements. - if (expression.origin == IrStatementOrigin.ANDAND || expression.origin == IrStatementOrigin.OROR) expression.origin = null + if (expression.origin == IrStatementOrigin.ANDAND || expression.origin == IrStatementOrigin.OROR) expression.origin = + null val branches = ArrayList() for ((index, branch) in expression.branches.withIndex()) { if (branch !is IrElseBranch && hasSite(branch.condition)) { val lifted = value(branch.condition) val condition = materialize(lifted.value) val rest = IrWhenImpl( - expression.startOffset, - expression.endOffset, - builtIns.unitType, - expression.origin + expression.startOffset, expression.endOffset, builtIns.unitType, expression.origin ).apply { this.branches += IrBranchImpl(branch.startOffset, branch.endOffset, condition.value, branch.result) this.branches += expression.branches.drop(index + 1) @@ -282,8 +277,12 @@ internal class StageNormalizer(private val stages: StageScope, private val funct inner.body = loop.body inner.condition = stages.builder(function.symbol, loop).irFalse() inner.body?.transformChildrenVoid(object : IrElementTransformerVoid() { - override fun visitContinue(jump: IrContinue): IrExpression = - if (jump.loop == loop) IrBreakImpl(jump.startOffset, jump.endOffset, builtIns.nothingType, inner) else jump + override fun visitContinue(jump: IrContinue): IrExpression = if (jump.loop == loop) IrBreakImpl( + jump.startOffset, + jump.endOffset, + builtIns.nothingType, + inner + ) else jump }) inner.body = inner.body?.let { composite(normalize(statementsOf(it))) } val lifted = value(loop.condition) @@ -327,6 +326,7 @@ internal class StageNormalizer(private val stages: StageScope, private val funct val snapshot = with(stages) { builder.callObject(stages.symbols.loopSnapshot, iterable) } val listType = builtIns.listClass.typeWith(elementType) val list = variable(listType, initializer = builder.irImplicitCast(snapshot, listType), name = "list") + loopCopies += list prologue += list bound = builder.irCall(listClass.getPropertyGetter("size")!!).apply { arguments[0] = get(list) } element = { type -> @@ -355,10 +355,7 @@ internal class StageNormalizer(private val stages: StageScope, private val funct element = { with(stages) { builder.callObject( - stages.symbols.loopElement, - get(first), - get(step), - get(index) + stages.symbols.loopElement, get(first), get(step), get(index) ) } } @@ -402,7 +399,8 @@ internal class StageNormalizer(private val stages: StageScope, private val funct override fun visitCall(expression: IrCall) { val receiver = expression.arguments.firstOrNull() as? IrGetValue - if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) found = expression + if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) found = + expression else super.visitCall(expression) } }) @@ -448,8 +446,11 @@ internal class StageNormalizer(private val stages: StageScope, private val funct private fun block(statements: List): IrBlock = IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, statements) - private fun composite(statements: List): IrComposite = - IrCompositeImpl(function.startOffset, function.endOffset, builtIns.unitType).apply { this.statements += statements } + private fun composite(statements: List): IrComposite = IrCompositeImpl( + function.startOffset, + function.endOffset, + builtIns.unitType + ).apply { this.statements += statements } private fun zero(type: IrType): IrExpression = IrConstImpl.defaultValueForType(function.startOffset, function.endOffset, type) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt index 1cd7e0266..b16a00327 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -140,8 +140,7 @@ internal class StageRewriter( else -> statement.also { stages.reporter.error( - it, - "A stage cannot be called here; move the call into a statement of its own" + it, "A stage cannot be called here; move the call into a statement of its own" ) } } @@ -152,9 +151,7 @@ internal class StageRewriter( val builder = stages.builder(function.symbol, variable) val initializer = variable.initializer ?: return builder.irGetObject(builtIns.unitClass) return builder.irIfThen( - builtIns.unitType, - not(builder, frameCall(builder, "isResuming")), - builder.irSet(variable, initializer) + builtIns.unitType, not(builder, frameCall(builder, "isResuming")), builder.irSet(variable, initializer) ) } @@ -164,10 +161,7 @@ internal class StageRewriter( if (statement is IrVariable) { val initializer = statement.initializer ?: return statement statement.initializer = builder.irIfThenElse( - statement.type, - frameCall(builder, "isResuming"), - zero(statement.type), - initializer + statement.type, frameCall(builder, "isResuming"), zero(statement.type), initializer ) return statement } @@ -221,7 +215,11 @@ internal class StageRewriter( /** The body of a do-while stays a composite: its condition may read what the body declares. */ private fun rewriteBody(expression: IrExpression?): IrExpression? = expression?.let { if (it !is IrComposite) return rewriteBlock(it) - IrCompositeImpl(it.startOffset, it.endOffset, builtIns.unitType).apply { statements += statementsOf(it).map(::statement) } + IrCompositeImpl( + it.startOffset, + it.endOffset, + builtIns.unitType + ).apply { statements += statementsOf(it).map(::statement) } } private fun expandSite(site: StageSite, statement: IrStatement): IrStatement { @@ -235,7 +233,11 @@ internal class StageRewriter( else variable.also { it.initializer = value } } - SiteForm.ASSIGN -> assignAt(builder, site, (statement as IrSetValue).also { it.value = value(builder, site, used = true) }) + SiteForm.ASSIGN -> assignAt( + builder, + site, + (statement as IrSetValue).also { it.value = value(builder, site, used = true) }) + SiteForm.RETURN -> if (site.tail) tailCall(builder, site) else { (statement as IrReturn).also { it.value = returning(builder, value(builder, site, used = true), site.call.type) @@ -248,11 +250,12 @@ internal class StageRewriter( * Assigns the value of a site only when the stage runs it or resumes to it: resuming elsewhere skips the site, * and the variable keeps the value it was restored with. */ - private fun assignAt(builder: IrBuilderWithScope, site: StageSite, assignment: IrExpression): IrExpression = builder.irIfThen( - builtIns.unitType, - frameCall(builder, "enters", builder.irInt(site.callPosition), builder.irInt(site.afterPosition)), - assignment, - ) + private fun assignAt(builder: IrBuilderWithScope, site: StageSite, assignment: IrExpression): IrExpression = + builder.irIfThen( + builtIns.unitType, + frameCall(builder, "enters", builder.irInt(site.callPosition), builder.irInt(site.afterPosition)), + assignment, + ) /** The value of a site: resumed, taken from the save, or computed by calling. */ private fun value(builder: IrBuilderWithScope, site: StageSite, used: Boolean): IrExpression { @@ -261,7 +264,13 @@ internal class StageRewriter( val fresh = builder.irBlock(resultType = type) { if (site.kind == SiteKind.CHECKPOINT) { saveLocals(site) - +frameCall(this, "checkpoint", irInt(site.index)) + val labels = site.call.arguments.getOrNull(1) + +(if (labels == null) frameCall(this, "checkpoint", irInt(site.index)) else frameCall( + this, + "checkpoint", + irInt(site.index), + labels + )) } else { // The frame remembers where it was before the locals of the call overwrite the saved ones. +frameCall(this, "beginCall", irInt(site.index)) @@ -308,10 +317,8 @@ internal class StageRewriter( ) if (type.isUnit()) { +irWhen( - builtIns.unitType, - listOf( - callBranch, - irElseBranch(frameCall(this, "resumeAfter", irInt(site.index), nullSerializer())) + builtIns.unitType, listOf( + callBranch, irElseBranch(frameCall(this, "resumeAfter", irInt(site.index), nullSerializer())) ) ) +irReturn(zero(returnType)) @@ -356,10 +363,7 @@ internal class StageRewriter( builtIns.unitType, listOf( builder.irBranch( frameCall( - builder, - "resumesInto", - builder.irInt(site.callPosition), - builder.irInt(site.afterPosition) + builder, "resumesInto", builder.irInt(site.callPosition), builder.irInt(site.afterPosition) ), resumed ), builder.irBranch(frameCall(builder, "isResuming"), zero(builtIns.unitType)), @@ -384,11 +388,7 @@ internal class StageRewriter( } } IrBlockImpl( - function.startOffset, - function.endOffset, - builtIns.unitType, - null, - statements + function.startOffset, function.endOffset, builtIns.unitType, null, statements ).transformChildrenVoid(transformer) } @@ -399,11 +399,7 @@ internal class StageRewriter( private fun IrBlockBuilder.saveLocals(site: StageSite) { site.live.forEach { variable -> +frameCall( - this, - "save", - irString(layout.keys.getValue(variable)), - serializer(this, variable.type), - irGet(variable) + this, "save", irString(layout.keys.getValue(variable)), serializer(this, variable.type), irGet(variable) ) } } @@ -421,7 +417,7 @@ internal class StageRewriter( } private fun frameCall(builder: IrBuilderWithScope, name: String, vararg arguments: IrExpression): IrCall = - builder.irCall(symbols.frameFunction(name)).apply { + builder.irCall(symbols.frameFunction(name, arguments.size)).apply { this.arguments[0] = builder.irGet(frame) arguments.forEachIndexed { index, argument -> this.arguments[index + 1] = argument } } @@ -512,7 +508,12 @@ internal class StageRewriter( arguments.forEachIndexed { index, parameter -> this.arguments[index + 1] = if (index < captures) { val value = with(stages) { - callObject(symbols.get, irGet(captured), irString(stages.argumentName(function, parameter)), serializer(parameter.type)) + callObject( + symbols.get, + irGet(captured), + irString(stages.argumentName(function, parameter)), + serializer(parameter.type) + ) } irAs(value, parameter.type) } else irGet(parameters[index - captures]) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt index 290f1061f..a110259a0 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.common.plugin.ir.stages import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrFunction import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.symbols.IrClassSymbol import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol @@ -37,7 +38,9 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val stageCall: IrConstructorSymbol = storyClass("StageCall").constructors.single() val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() val stateConstructor: IrConstructorSymbol = state.constructors.single() - val checkpoint: IrSimpleFunctionSymbol = storyFunction("checkpoint") + val checkpoints: Set = storyFunctions("checkpoint") + val actionBodies: Set = storyFunctions("action") + storyFunctions("label") + val checkpointScope: IrClassSymbol = storyClass("CheckpointScope") val storySerializer: IrSimpleFunctionSymbol = storyFunction("storySerializer") val compoundTag: IrClassSymbol = finder.findClass(ClassId.topLevel(FqName("net.minecraft.nbt.CompoundTag"))) @@ -55,8 +58,11 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val compoundTagType: IrType get() = compoundTag.owner.defaultType val intProgression: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.ranges.IntProgression"))) - fun frameFunction(name: String): IrSimpleFunctionSymbol = - frame.getSimpleFunction(name) ?: error("StoryFrame.$name is missing from the story runtime") + /** The member [name] of the frame that takes [arity] arguments. */ + fun frameFunction(name: String, arity: Int): IrSimpleFunctionSymbol = + frame.owner.declarations.filterIsInstance() + .firstOrNull { it.name.asString() == name && it.parameters.size == arity + 1 }?.symbol + ?: error("StoryFrame.$name with $arity arguments is missing from the story runtime") val enter: IrSimpleFunctionSymbol = member(frames, "enter") val put: IrSimpleFunctionSymbol = member(values, "put") @@ -70,15 +76,19 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st fun stageLambdaConstructor(arity: Int): IrConstructorSymbol? = finder.findClass(ClassId(PACKAGE, Name.identifier("StageLambda$arity")))?.constructors?.single() - fun isCheckpoint(function: IrFunction): Boolean = function.symbol == checkpoint + fun isCheckpoint(function: IrFunction): Boolean = function.symbol in checkpoints + /** Whether [function] is `action { }` or `label { }`, the body of an action. */ + fun isActionBody(function: IrFunction): Boolean = function.symbol in actionBodies private fun storyClass(name: String): IrClassSymbol = finder.findClass(ClassId(PACKAGE, Name.identifier(name))) ?: error("$name is missing from the story runtime") private fun storyFunction(name: String): IrSimpleFunctionSymbol = - finder.findFunctions(CallableId(PACKAGE, Name.identifier(name))).firstOrNull() - ?: error("$name is missing from the story runtime") + storyFunctions(name).firstOrNull() ?: error("$name is missing from the story runtime") + + private fun storyFunctions(name: String): Set = + finder.findFunctions(CallableId(PACKAGE, Name.identifier(name))).toSet() private fun member(owner: IrClassSymbol, name: String): IrSimpleFunctionSymbol = owner.getSimpleFunction(name) ?: error("${owner.owner.name}.$name is missing from the story runtime") @@ -86,6 +96,8 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st companion object { val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") val STATE: FqName = PACKAGE.child(Name.identifier("State")) + val ACTION: FqName = PACKAGE.child(Name.identifier("Action")) + val UNSAVED: FqName = PACKAGE.child(Name.identifier("Unsaved")) fun find(context: IrPluginContext): StageSymbols? = context.finderForBuiltins().findClass(ClassId(PACKAGE, Name.identifier("State"))) diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt index f914f895e..cbe7ede02 100644 --- a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -198,4 +198,39 @@ class StageCompileErrorsTest { fun start() = many { a, b, c, d, e, f, g, h, i -> log("${'$'}a") } """ ) + + @Test + fun `a loop that calls stages cannot go over values a story cannot keep`() = assertError( + "goes over a copy of its elements", """ + @State suspend fun step() {} + + @State + suspend fun main() { + for (lock in listOf(Any(), Any())) step() + } + """ + ) + + @Test + fun `action is only written as the body of an action`() = assertError( + "must be the body of an @Action function", """ + suspend fun helper() = action { perform { } } + """ + ) + + @Test + fun `the arguments of an action must be serializable`() = assertError( + "'lock' cannot be saved", """ + @Action + suspend fun hold(lock: Any) = action { perform { } } + """ + ) + + @Test + fun `an unsaved argument of an action needs a default`() = assertError( + "needs one", """ + @Action + suspend fun hold(@Unsaved lock: Any) = action { perform { } } + """ + ) } diff --git a/addons/compiler/src/test/kotlin/StageJournalTest.kt b/addons/compiler/src/test/kotlin/StageJournalTest.kt new file mode 100644 index 000000000..8d1c6ab13 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageJournalTest.kt @@ -0,0 +1,348 @@ +import kotlinx.coroutines.test.runTest +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** Actions in the journal: what is undone when, rewinds to checkpoints and back, labels, a restart in between. */ +class StageJournalTest : StageTest() { + @Test + fun `a temporary action is undone when its stage returns and a permanent one stays`() = runTest { + val world = world("temporary", PLACE + $$""" + @State + suspend fun fight() { + temporary { place("barricade") } + place("banner") + await("win") + } + + @State + suspend fun main() { + fight() + log("after ${StageTrace.blocks}") + } + """ + ) + world.launch("main") + assertEquals(listOf("place barricade", "place banner"), StageTrace.take()) + + world.release("win") + assertEquals(listOf("undo barricade", "after [banner]"), StageTrace.take()) + world.stop() + } + + @Test + fun `a segment that repeats after a restart undoes its temporary actions first`() = runTest { + val world = world("repeat", PLACE + """ + @State + suspend fun main() { + temporary { place("barricade") } + await("x") + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place barricade", "undo barricade", "place barricade"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("done", "undo barricade"), StageTrace.take()) + assertEquals(emptySet(), StageTrace.blocks) + world.stop() + } + + @Test + fun `a rewind to a checkpoint undoes what came after it and keeps what once did`() = runTest { + val world = world("rewind", PLACE + """ + @State + suspend fun main() { + place("door") + checkpoint("gate") + once("reward") { log("reward") } + place("bridge") + await("x") + log("end") + } + """ + ) + val story = world.launch("main") + assertEquals(listOf("place door", "reward", "place bridge"), StageTrace.take()) + + story.rewind("gate") + world.settle() + assertEquals(listOf("undo bridge", "place bridge"), StageTrace.take(), "the reward is not given twice") + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + assertEquals(setOf("door", "bridge"), StageTrace.blocks) + world.stop() + } + + @Test + fun `a redo brings back what a rewind undid until the story performs something new`() = runTest { + val world = world("redo", PLACE + """ + @State + suspend fun main() { + place("door") + checkpoint("gate") + await("go") + place("bridge") + await("x") + log("end") + } + """ + ) + val story = world.launch("main") + world.release("go") + StageTrace.hold("go") + story.rewind("gate") + world.settle() + assertEquals(listOf("place door", "place bridge", "undo bridge"), StageTrace.take()) + + story.redo() + world.settle() + assertEquals(listOf("forward bridge"), StageTrace.take()) + assertEquals("main@after:gate", story.state, "the story is back where the rewind took it from") + + story.rewind("gate") + world.release("go") + assertEquals(listOf("undo bridge", "place bridge"), StageTrace.take()) + assertFailsWith("a new action ends what could be redone") { story.redo() } + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + world.stop() + } + + @Test + fun `rewind without a checkpoint repeats the segment the stage is in`() = runTest { + val world = world("segment", PLACE + """ + @State + suspend fun attempt() { + place("trap") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + log("survived") + } + + @State + suspend fun main() { + attempt() + log("done") + } + """ + ) + StageTrace.token = "fail" + world.launch("main") + assertEquals(listOf("place trap", "undo trap", "place trap", "survived", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `labels put back what they captured on every rewind to their checkpoint`() = runTest { + val world = world("labels", PLACE + """ + @Action + suspend fun CheckpointScope.flag(name: String) = label { + val had = capture { name in StageTrace.blocks } + undo { if (had) StageTrace.blocks += name else StageTrace.blocks -= name } + } + + @State + suspend fun main() { + StageTrace.blocks += "flag" + checkpoint("arena") { flag("flag") } + await("drop") + StageTrace.blocks -= "flag" + log("dropped") + await("x") + } + """ + ) + val story = world.launch("main") + repeat(2) { round -> + world.release("drop") + assertEquals(listOf("dropped"), StageTrace.take()) + assertEquals(emptySet(), StageTrace.blocks) + + StageTrace.hold("drop") + story.rewind("arena") + world.settle() + assertEquals(setOf("flag"), StageTrace.blocks, "round $round puts the flag back") + } + world.stop() + } + + @Test + fun `the journal outlives a restart and still undoes what was performed before it`() = runTest { + val world = world("restart", PLACE + """ + @State + suspend fun main() { + place("door") + checkpoint("placed") + await("x") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place door"), StageTrace.take()) + + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo door", "place door"), StageTrace.take(), "the story starts over from its entry") + world.stop() + } + + @Test + fun `a rewind to before a branch removes the branch along with what it performed`() = runTest { + val world = world("branches", PLACE + """ + @State + suspend fun main() { + checkpoint("before") + async("guard") { + place("tower") + await("g") + } + place("wall") + await("x") + } + """ + ) + val story = world.launch("main") + assertEquals(listOf("place wall", "place tower"), StageTrace.take()) + + story.rewind("before") + world.settle() + assertEquals(listOf("undo tower", "undo wall", "place wall", "place tower"), StageTrace.take()) + assertEquals(listOf("main", "before", "guard"), story.checkpoints.map { it.first }) + world.stop() + } + + @Test + fun `remember keeps its value across a rewind`() = runTest { + val world = world("remember", $$""" + @State + suspend fun main() { + checkpoint("start") + val roll = remember("roll") { StageTrace.token } + log("roll $roll") + await("x") + } + """ + ) + StageTrace.token = "first" + val story = world.launch("main") + StageTrace.token = "second" + story.rewind("start") + world.settle() + assertEquals(listOf("roll first", "roll first"), StageTrace.take()) + world.stop() + } + + @Test + fun `an unsaved parameter gets its default when the journal calls the action again`() = runTest { + val world = world("unsaved", $$""" + @Action + suspend fun mark(name: String, @Unsaved note: String = "default") = action { + undo { log("undo $name $note") } + perform { log("mark $name $note") } + } + + @State + suspend fun main() { + temporary { mark("a", "given") } + log("done") + } + """ + ) + world.launch("main") + assertEquals(listOf("mark a given", "done", "undo a default"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch takes back its temporary actions when the stage that started it returns`() = runTest { + val world = world("branchTemporary", PLACE + $$""" + @State + suspend fun fight() { + async("guards") { + temporary { place("guard") } + await("never") + } + await("win") + } + + @State + suspend fun main() { + fight() + log("after ${StageTrace.blocks}") + } + """ + ) + world.launch("main") + world.release("win") + assertEquals(listOf("place guard", "undo guard", "after []"), StageTrace.take()) + world.stop() + } + + @Test + fun `a redo fast-forwards an action as a whole, not the actions it performed again`() = runTest { + val world = world("nested", PLACE + """ + @Action + suspend fun build() = action { + undo { log("undo build") } + perform { + place("left") + place("right") + } + } + + @State + suspend fun main() { + checkpoint("empty") + await("go") + build() + await("x") + } + """ + ) + val story = world.launch("main") + world.release("go") + StageTrace.hold("go") + story.rewind("empty") + world.settle() + story.redo() + world.settle() + val trace = StageTrace.take() + assertEquals(listOf("place left", "place right", "undo right", "undo left", "undo build"), trace.take(5)) + assertEquals(listOf("place left", "place right"), trace.drop(5), "the redo performs build once, its parts with it") + world.stop() + } + + private companion object { + /** An action that puts [name] into the world and takes it back unless someone else did it first. */ + const val PLACE = $$""" + @Action + suspend fun place(name: String) = action { + val existed = capture { name in StageTrace.blocks } + undo { + if (name !in StageTrace.blocks) conflict("$name is gone") + if (!existed) StageTrace.blocks -= name + log("undo $name") + } + fastForward { + StageTrace.blocks += name + log("forward $name") + } + perform { + StageTrace.blocks += name + log("place $name") + } + } + + """ + } +} diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt index 657c1540e..59330dba8 100644 --- a/addons/compiler/src/test/kotlin/StageTestSupport.kt +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -35,6 +35,10 @@ object StageTrace { /** A value scripts read and tests change, to make code behave differently after a restart. */ @JvmStatic var token: String = "" + + /** What actions of test scripts place in the world; like the world, it outlives a restart and is not rewound. */ + @JvmStatic + val blocks = linkedSetOf() private val released = HashSet() private val waiting = HashMap>>() @@ -55,11 +59,17 @@ object StageTrace { waiting.remove(name)?.forEach { it.complete(Unit) } } + /** Makes [await] with [name] wait again, as before it was released. */ + fun hold(name: String) { + released -= name + } + fun take(): List = events.toList().also { events.clear() } fun reset() { events.clear() token = "" + blocks.clear() released.clear() waiting.clear() } diff --git a/addons/narrative/build.gradle.kts b/addons/narrative/build.gradle.kts index 5196a714a..cf1e1aef0 100644 --- a/addons/narrative/build.gradle.kts +++ b/addons/narrative/build.gradle.kts @@ -9,6 +9,13 @@ dependencies { val serializationVersion = rootProject.property("serializationVersion") as String add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") + + val kotlinVersion = rootProject.property("kotlinVersion") as String + val classGraph = "io.github.classgraph:classgraph:4.8.173" + compileOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + compileOnly(classGraph) + testImplementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + testImplementation(classGraph) } tasks.withType().configureEach { useJUnitPlatform() } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt index 47d80db50..5fe065450 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt @@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.addons.narrative.client.NarrativeClient import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCommands import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.ScriptRegistryStorySource import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.StoryEngine +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryCommands import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint import ru.hollowhorizon.hollowengine.common.addons.extensions @@ -33,9 +34,9 @@ class HollowNarrativeAddon : HollowAddonEntrypoint { @SubscribeEvent fun addDefaultImports(event: ScriptDefaultImportsEvent) { when (event.suffix) { - RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS + STORY_PACKAGE - NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + STORY_PACKAGE - STARTUP_SCRIPT_EXTENSION, SERVER_CONSOLE_SCRIPT_EXTENSION -> event.defaultImports += STORY_PACKAGE + RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS + STORY_PACKAGES + NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + STORY_PACKAGES + STARTUP_SCRIPT_EXTENSION, SERVER_CONSOLE_SCRIPT_EXTENSION -> event.defaultImports += STORY_PACKAGES } } @@ -52,11 +53,15 @@ class HollowNarrativeAddon : HollowAddonEntrypoint { "The HollowEngine root command must be registered before addon commands" } DialogueCommands.register(root) + StoryCommands.register(root) } private companion object { const val DIALOGUE_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.*" - const val STORY_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.story.*" + val STORY_PACKAGES = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "ru.hollowhorizon.hollowengine.addons.narrative.npcs.*", + ) /** Reload scripts register story functions, so they also get the signature helpers. */ val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE) + diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt index fa4cab456..a1a3d2c95 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/dialogue/StoryNpcFunctions.kt @@ -4,6 +4,10 @@ import kotlinx.coroutines.withTimeoutOrNull import net.minecraft.world.entity.LivingEntity import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.narrative.npcs.clearLookTarget +import ru.hollowhorizon.hollowengine.addons.narrative.npcs.lookAt +import ru.hollowhorizon.hollowengine.addons.narrative.npcs.move +import ru.hollowhorizon.hollowengine.addons.narrative.npcs.stopMoving import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.* import ru.hollowhorizon.hollowengine.common.entities.NpcEntity import ru.hollowhorizon.hollowengine.common.models.AnimationPlayMode @@ -12,10 +16,6 @@ import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.play import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.playAndWait import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.stopAnimation -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.clearLookTarget -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.lookAt -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.move -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.stopMoving import kotlin.time.Duration.Companion.milliseconds /** @@ -164,11 +164,3 @@ internal object StoryNpcFunctions { private val DEFAULT_SPEED = MoveOptions().speed.toFloat() private val DEFAULT_DISTANCE = MoveOptions().arrivalDistance.toFloat() } - -private suspend fun NpcEntity.move(position: Vec3, options: MoveOptions) { - move(pos = position, options = options) -} - -private suspend fun NpcEntity.move(target: LivingEntity, options: MoveOptions) { - move(entity = target, dist = options.arrivalDistance, speed = options.speed) -} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt new file mode 100644 index 000000000..450155249 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt @@ -0,0 +1,284 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import kotlinx.serialization.Serializable +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.addons.narrative.story.Unsaved +import ru.hollowhorizon.hollowengine.addons.narrative.story.action +import ru.hollowhorizon.hollowengine.common.coroutines.Ref +import ru.hollowhorizon.hollowengine.common.coroutines.entityRef +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys +import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted +import ru.hollowhorizon.hollowengine.common.npcs.navigation.* +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import kotlin.math.atan2 +import kotlin.math.sqrt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Moves the NPC toward [target] until it is within [MoveOptions.arrivalDistance], following it while it moves. + * Undone, the NPC is put back where it started; fast-forwarded, it is put next to the target. + */ +@Action("move_to_entity") +suspend fun NpcEntity.move(target: Entity, @Unsaved options: MoveOptions = MoveOptions()): MoveResult = + action(this, target, options) { + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(target.position()) } + perform { + val reference = with(server ?: currentServer) { target.entityRef } + actions.act(NpcActionKeys.MOVEMENT) { moveToEntity(reference, options) } + } + } + +/** + * Moves the NPC toward the specified entity until the distance becomes less than or equal to the specified value. + * + * @param entity The entity to move toward. + * @param dist The minimum distance to the entity; movement stops when this distance is reached. + * @param speed The NPC's movement speed. + */ +suspend fun NpcEntity.move(entity: Entity, dist: Double = 1.5, speed: Double = 1.0): MoveResult = + move(entity, MoveOptions(arrivalDistance = dist, speed = speed)) + +suspend fun NpcEntity.move(target: Ref, options: MoveOptions = MoveOptions()): MoveResult = + move(target.resolve(), options) + +fun NpcEntity.startMove(target: Ref, options: MoveOptions = MoveOptions()): NpcAction = + actions.start(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) } + +/** + * Moves the NPC to the specified entity using the default settings. (Can be specified with a space) + * + * @param mob The entity to move to. + */ +suspend infix fun NpcEntity.move(mob: Entity): MoveResult = move(entity = mob) + +/** + * Moves the NPC to [pos] until it is within [MoveOptions.arrivalDistance]. Undone, the NPC is put back where it + * started; fast-forwarded, it is put at [pos]. + */ +@Action("move_to") +suspend fun NpcEntity.move(pos: Vec3, @Unsaved options: MoveOptions = MoveOptions()): MoveResult = + action(this, pos, options) { + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(pos) } + perform { actions.act(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } } + } + +/** + * Moves the NPC to the specified position until the distance becomes less than or equal to the specified value. + * + * @param pos The position to move to. + * @param dist The minimum distance to the position; movement stops when this distance is reached. + * @param speed The NPC's movement speed. + */ +suspend fun NpcEntity.move(pos: Vec3, dist: Double = 1.5, speed: Double = 1.0): MoveResult = + move(pos, MoveOptions(arrivalDistance = dist, speed = speed)) + +fun NpcEntity.startMove(pos: Vec3, options: MoveOptions = MoveOptions()): NpcAction = + actions.start(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } + +/** + * Moves the NPC to the specified position using the default settings. + * + * @param position The position to move to. + */ +suspend infix fun NpcEntity.move(position: Vec3): MoveResult = move(pos = position) + +/** + * Moves the NPC through [points] in turn without stopping at any but the last: each one before it counts as + * passed within [MoveOptions.passDistance], and [MoveOptions.arrivalDistance] is for the last. + */ +@Action("move_through") +suspend fun NpcEntity.move(points: List, @Unsaved options: MoveOptions = MoveOptions()): MoveResult = + action(this, points, options) { + require(points.isNotEmpty()) { "A move needs at least one point" } + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(points.last()) } + perform { actions.act(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } } + } + +fun NpcEntity.startMove(points: List, options: MoveOptions = MoveOptions()): NpcAction { + require(points.isNotEmpty()) { "A move needs at least one point" } + return actions.start(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } +} + +@SimpleAction("stop_moving") +fun NpcEntity.stopMoving() { + actions.cancel(NpcActionKeys.MOVEMENT) + navigation.stop() +} + +/** + * Runs several actions of the NPC as one: the channels they use stay held until [block] returns, and losing + * any of them to a higher priority cancels the whole block with a single [Preempted]. + */ +suspend fun NpcEntity.bundle(block: suspend NpcEntity.() -> R): R = actions.perform { block() } + +/** [bundle] that waits for the actions already running on its channels instead of replacing them. */ +suspend fun NpcEntity.queue(block: suspend NpcEntity.() -> R): R = actions.queue { block() } + +/** + * Keeps the NPC out of [zone] on every way it walks, its moves and its goals alike, until [stopAvoiding]. It is + * not saved with the NPC: a script that needs it after a reload adds it again. Undone, the NPC stops avoiding it. + */ +@Action("avoid") +suspend fun NpcEntity.avoid(zone: Zone) = action(this, zone) { + undo { stopAvoiding(zone) } + fastForward { addAvoided(zone) } + perform { addAvoided(zone) } +} + +private fun NpcEntity.addAvoided(zone: Zone) { + npcNavigation.avoidedZones += zone + navigation.recomputePath() +} + +fun NpcEntity.stopAvoiding(zone: Zone) { + if (npcNavigation.avoidedZones.remove(zone)) navigation.recomputePath() +} + +fun NpcEntity.stopAvoidingAll() { + if (npcNavigation.avoidedZones.isEmpty()) return + npcNavigation.avoidedZones.clear() + navigation.recomputePath() +} + +/** + * Makes the NPC look at the specified position. Returns value, is npc looked at target, or still rotating head. + * + * @param position The position to look at. + */ +fun NpcEntity.lookAtNow(position: Vec3, maxAngularSpeed: Float = 360f): Boolean = faceTowards(position, maxAngularSpeed) + +fun NpcEntity.startLookingAt( + position: Vec3, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +): NpcAction = actions.start(NpcActionKeys.LOOK) { + rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) +} + +/** Turns the NPC to [position] over [duration]. Undone, it faces where it did before; fast-forwarded, it faces there at once. */ +@Action("look_at") +suspend fun NpcEntity.lookAt( + position: Vec3, + @Unsaved duration: Duration = DEFAULT_LOOK_DURATION, + @Unsaved maxAngularSpeed: Float = 360f, +) = action(this, position, duration, maxAngularSpeed) { + val before = capture { NpcRotation.of(this@lookAt) } + undo { before.applyTo(this@lookAt) } + fastForward { faceAt(position) } + perform { + actions.act(NpcActionKeys.LOOK) { + rotate( + { position }, + duration.inWholeMilliseconds, + maxAngularSpeed = maxAngularSpeed + ) + } + } +} + +suspend infix fun NpcEntity.lookAt(position: Vec3) { + lookAt(position, DEFAULT_LOOK_DURATION) +} + +/** + * Makes the NPC look at the specified entity. Returns value, is npc looked at target, or still rotating head. + * + * @param entity The entity to look at. + */ +fun NpcEntity.lookAtNow(entity: Entity, maxAngularSpeed: Float = 360f): Boolean = faceTowards(entity, maxAngularSpeed) + +fun NpcEntity.startLookingAt( + target: Ref, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +): NpcAction = actions.start(NpcActionKeys.LOOK) { + rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) +} + +/** Turns the NPC to [target] over [duration], following it as it moves; undone and fast-forwarded like [lookAt] at a position. */ +@Action("look_at_entity") +suspend fun NpcEntity.lookAt( + target: Entity, + @Unsaved duration: Duration = DEFAULT_LOOK_DURATION, + @Unsaved maxAngularSpeed: Float = 360f, +) = action(this, target, duration, maxAngularSpeed) { + val before = capture { NpcRotation.of(this@lookAt) } + undo { before.applyTo(this@lookAt) } + fastForward { faceAt(target.eyePosition) } + perform { + actions.act(NpcActionKeys.LOOK) { + rotate({ target.eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) + } + } +} + +suspend fun NpcEntity.lookAt(target: Ref, duration: Duration, maxAngularSpeed: Float = 360f) = + lookAt(target.resolve(), duration, maxAngularSpeed) + +suspend infix fun NpcEntity.lookAt(entity: Entity) { + lookAt(entity, DEFAULT_LOOK_DURATION) +} + +fun NpcEntity.clearLookTarget() { + actions.cancel(NpcActionKeys.LOOK) +} + +/** Puts the NPC at [position] at once, as a fast-forwarded move ends. */ +private fun NpcEntity.arriveAt(position: Vec3): MoveResult { + navigation.stop() + teleportTo(position.x, position.y, position.z) + return MoveResult.Arrived +} + +/** Turns the NPC to face [target] at once. */ +private fun NpcEntity.faceAt(target: Vec3) { + val dx = target.x - x + val dy = target.y - eyeY + val dz = target.z - z + val yaw = Math.toDegrees(atan2(dz, dx)).toFloat() - 90f + NpcRotation(yaw, yaw, -Math.toDegrees(atan2(dy, sqrt(dx * dx + dz * dz))).toFloat()).applyTo(this) +} + +/** Where an NPC stands and how it faces, which an undone move puts it back to. */ +@Serializable +internal class NpcPlacement(val x: Double, val y: Double, val z: Double, val rotation: NpcRotation) { + fun applyTo(npc: NpcEntity) { + npc.actions.cancel(NpcActionKeys.MOVEMENT) + npc.navigation.stop() + npc.teleportTo(x, y, z) + rotation.applyTo(npc) + } + + companion object { + fun of(npc: NpcEntity) = NpcPlacement(npc.x, npc.y, npc.z, NpcRotation.of(npc)) + } +} + +/** How an NPC faces: its body, its head and its pitch. */ +@Serializable +internal class NpcRotation(val body: Float, val head: Float, val pitch: Float) { + fun applyTo(npc: NpcEntity) { + npc.yRot = body + npc.yBodyRot = body + npc.yHeadRot = head + npc.xRot = pitch + } + + companion object { + fun of(npc: NpcEntity) = NpcRotation(npc.yBodyRot, npc.yHeadRot, npc.xRot) + } +} + +internal val DEFAULT_LOOK_DURATION = 1500.milliseconds diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt new file mode 100644 index 000000000..944ca8af9 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt @@ -0,0 +1,206 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import kotlinx.coroutines.delay +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket.Action.* +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.InteractionHand +import net.minecraft.world.InteractionResult +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.BlockHitResult +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.common.coroutines.Ref +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveResult +import ru.hollowhorizon.hollowengine.common.npcs.navigation.faceTowards +import ru.hollowhorizon.hollowengine.common.npcs.navigation.rotate +import kotlin.time.Duration.Companion.milliseconds + +/** + * Forces an NPC to use a block at the specified position. + * The NPC will move to the block, look at it, and perform the use action. + * + * @param pos The position of the block to be used. + */ +suspend infix fun NpcEntity.useBlock(pos: Vec3): InteractionResult = useBlock(pos, InteractionHand.MAIN_HAND) + +@SimpleAction("use_block") +suspend fun NpcEntity.useBlock( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): InteractionResult { + require(reach > 0.0) { "Interaction reach must be greater than zero" } + return actions.act(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } +} + +fun NpcEntity.startUseBlock( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): NpcAction { + require(reach > 0.0) { "Interaction reach must be greater than zero" } + return actions.start(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } +} + +private suspend fun NpcEntity.useBlockReaching( + pos: Vec3, + hand: InteractionHand, + reach: Double, + face: Direction?, +): InteractionResult { + val target = Vec3.atCenterOf(BlockPos.containing(pos)) + if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return InteractionResult.FAIL + rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + return useBlockAt(target, hand, face) +} + +fun NpcEntity.useBlockNow( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): InteractionResult { + val blockPos = BlockPos.containing(pos) + val target = Vec3.atCenterOf(blockPos) + if (eyePosition.distanceToSqr(target) > reach * reach) return InteractionResult.FAIL + + return useBlockAt(target, hand, face) +} + +private fun NpcEntity.useBlockAt(target: Vec3, hand: InteractionHand, face: Direction?): InteractionResult { + val blockPos = BlockPos.containing(target) + syncFakePlayer() + val hit = BlockHitResult(target, face ?: interactionFace(target), blockPos, false) + swing(hand) + val result = fakePlayer.gameMode.useItemOn(fakePlayer, level(), fakePlayer.getItemInHand(hand), hand, hit) + setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) + return result +} + +/** + * Forces the NPC to destroy the block at the specified position. + * The NPC will move to the block, look at it, and destroy it. + * + * @param pos The position of the block to be destroyed. + */ +suspend infix fun NpcEntity.destroyBlock(pos: Vec3): Boolean = destroyBlock(pos, DEFAULT_INTERACTION_REACH) + +@SimpleAction("destroy_block") +suspend fun NpcEntity.destroyBlock( + pos: Vec3, + reach: Double, + requireCorrectTool: Boolean = true, +): Boolean = actions.act(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } + +fun NpcEntity.startDestroyBlock( + pos: Vec3, + reach: Double = DEFAULT_INTERACTION_REACH, + requireCorrectTool: Boolean = true, +): NpcAction = actions.start(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } + +private suspend fun NpcEntity.destroyBlockReaching(pos: Vec3, reach: Double, requireCorrectTool: Boolean): Boolean { + val blockPos = BlockPos.containing(pos) + val target = Vec3.atCenterOf(blockPos) + if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return false + rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + syncFakePlayer() + val initialState = level().getBlockState(blockPos) + if (initialState.isAir || initialState.getDestroyProgress(fakePlayer, level(), blockPos) <= 0f) return false + if (requireCorrectTool && !fakePlayer.hasCorrectToolForDrops(initialState)) return false + + val face = interactionFace(target) + fakePlayer.gameMode.handleBlockBreakAction(blockPos, START_DESTROY_BLOCK, face, level().maxBuildHeight, 0) + try { + var elapsedTicks = 0 + while (level().getBlockState(blockPos) == initialState) { + fakePlayer.gameMode.tick() + elapsedTicks++ + faceTowards(target) + swing(InteractionHand.MAIN_HAND) + val progress = initialState.getDestroyProgress(fakePlayer, level(), blockPos) + if (progress * (elapsedTicks + 1) >= 1f) { + fakePlayer.gameMode.handleBlockBreakAction( + blockPos, + STOP_DESTROY_BLOCK, + face, + level().maxBuildHeight, + 0, + ) + break + } + delay(TICK_DURATION) + } + return level().getBlockState(blockPos).isAir + } finally { + if (!level().getBlockState(blockPos).isAir) { + fakePlayer.gameMode.handleBlockBreakAction( + blockPos, + ABORT_DESTROY_BLOCK, + face, + level().maxBuildHeight, + 0, + ) + } + level().destroyBlockProgress(fakePlayer.id, blockPos, -1) + setItemInHand(InteractionHand.MAIN_HAND, fakePlayer.mainHandItem.copy()) + } +} + +private fun NpcEntity.interactionFace(target: Vec3): Direction = Direction.getNearest(eyePosition.subtract(target)) + +fun NpcEntity.interactNow( + target: Entity, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): InteractionResult { + if (target.level() !== level() || distanceToSqr(target) > reach * reach) return InteractionResult.FAIL + syncFakePlayer() + val result = fakePlayer.interactOn(target, hand) + setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) + swing(hand) + return result +} + +fun NpcEntity.startInteract( + target: Ref, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): NpcAction = actions.start(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } + +private suspend fun NpcEntity.interactReaching( + target: Ref, + hand: InteractionHand, + reach: Double, +): InteractionResult { + if (move(target, MoveOptions(arrivalDistance = reach)) != MoveResult.Arrived) return InteractionResult.FAIL + val entity = target.resolve() + rotate({ entity.eyePosition }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + return interactNow(entity, hand, reach) +} + +@SimpleAction("interact") +suspend fun NpcEntity.interact( + target: Ref, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): InteractionResult = actions.act(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } + +private fun NpcEntity.syncFakePlayer() { + val level = level() as ServerLevel + fakePlayer.gameMode.setLevel(level) + fakePlayer.moveTo(x, y, z, yRot, xRot) + InteractionHand.entries.forEach { currentHand -> + fakePlayer.setItemInHand(currentHand, getItemInHand(currentHand).copy()) + } +} + +private val TICK_DURATION = 50.milliseconds +private const val DEFAULT_INTERACTION_REACH = 3.0 diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt new file mode 100644 index 000000000..e2e20fda8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -0,0 +1,197 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * A reversible action. Its body is `action { }`: within a story every call is written to the journal, which can undo + * it on a rewind and fast-forward it on a redo, after a restart too. The journal keeps the receivers and parameters + * of the call, so they must be serializable; entities are kept as references. + * + * @param id the id the journal and editors know the action by, in the namespace of its script or addon; the + * function name when empty. Overloads need ids of their own. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Action(val id: String = "") + +/** + * A parameter of an [Action] the journal does not keep, such as options holding a lambda. Undoing or fast-forwarding + * the call gets its default value instead, so it must have one, and the blocks must not depend on it. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Unsaved + +/** An action that is not undone: an ordinary function editors and `.story` can call by [id]. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SimpleAction(val id: String = "") + +@DslMarker +annotation class StoryDsl + +/** How long the journal keeps an action undoable, see [temporary]. */ +enum class Lifetime { + /** Undone when the stage that performed it exits, or before the segment that performed it repeats. */ + STAGE, + + /** Undone only by a rewind; left in the world when the story ends. */ + RUN, +} + +/** + * The body of an [Action]. [block] declares how to [capture][CaptureScope.capture] the state the action changes, + * how to [undo][CaptureScope.undo] and [fast-forward][ActionScope.fastForward] it, and ends with + * [perform][ActionScope.perform], which does it. + * + * The journal runs [block] again to undo or fast-forward the call, with the saved snapshots in place of every + * capture, so before `perform` it must only declare blocks and compute values. + * + * @param call the receivers and parameters of the action, in order; in scripts the compiler passes them. + */ +suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Performed): R { + val action = StoryActions.enclosing(block, "action { }") + ActionReplay.take(action)?.let { replay -> return ActionScope(null, replay).also { replay.scope = it }.block().value } + val context = currentCoroutineContext() + val run = context[StoryRun.Element]?.run?.takeIf { context[Replaying] == null } + val args = action.encode(call) + val record = run?.let { + val lifetime = if (context[Temporary] != null) Lifetime.STAGE else Lifetime.RUN + ActionRecord(it, action, args, lifetime, context[ActiveAction]?.seq) + } + return ActionScope(record, null).block().value +} + +/** + * The body of a checkpoint label such as [position]: like [action] without `perform`. A label captures a state + * when its checkpoint is passed and puts it back on every rewind to that checkpoint. + */ +suspend fun label(vararg call: Any?, block: suspend LabelScope.() -> Unit) { + val action = StoryActions.enclosing(block, "label { }") + ActionReplay.take(action)?.let { replay -> LabelScope(replay).also { replay.scope = it }.block(); return } + val checkpoint = call.firstNotNullOfOrNull { it as? CheckpointScope } + ?: throw StoryException("A label is only placed inside checkpoint(name) { }") + val scope = LabelScope(null).also { it.block() } + checkpoint.add(action, action.encode(call), scope.snapshots) +} + +/** + * Runs [block] with every action it performs kept for the current stage only: they are undone when the stage exits + * and before the segment repeats after a restart, like a barricade that only stands during a fight. + */ +suspend fun temporary(block: suspend () -> T): T = withContext(Temporary()) { block() } + +/** What a capture block can declare: snapshots taken before the action and how to put them back. */ +@StoryDsl +open class CaptureScope internal constructor(internal val replay: ActionReplay?) { + internal val snapshots = CompoundTag() + internal var undo: (UndoScope.() -> Unit)? = null + private var captured = 0 + internal var closed = false + + /** + * Takes a snapshot with [block] before the action changes anything. When the journal undoes the action, it + * returns the saved snapshot instead, so [undo] can use it after a restart too. + */ + inline fun capture(noinline block: () -> S): S = capture(storySerializer(), block) + + @PublishedApi + @Suppress("UNCHECKED_CAST") + internal fun capture(serializer: KSerializer, block: () -> S): S { + check(!closed) { "capture { } must come before perform { }" } + val index = captured++ + replay?.let { return it.snapshot(index, serializer) as S } + val value = block() + snapshots.put(index.toString(), StoryValues.encode(serializer, value)) + return value + } + + /** + * Puts back what the action changed, as the absolute state of its snapshots rather than a reverse delta, so it + * works wherever the action stopped. Call [UndoScope.conflict] when someone else changed it since. + */ + fun undo(block: UndoScope.() -> Unit) { + check(!closed) { "undo { } must come before perform { }" } + undo = block + } +} + +/** The blocks of an [action]. */ +class ActionScope internal constructor( + private val record: ActionRecord?, + replay: ActionReplay?, +) : CaptureScope(replay) { + internal var fastForward: (suspend () -> R)? = null + internal var perform: (suspend () -> R)? = null + + /** Brings the world to where the action ends, at once; a redo without it performs the action again. */ + fun fastForward(block: suspend () -> R) { + check(!closed) { "fastForward { } must come before perform { }" } + fastForward = block + } + + /** Does the action and ends the body; the journal records the call before [block] starts. */ + suspend fun perform(block: suspend () -> R): Performed { + check(!closed) { "An action performs once" } + closed = true + perform = block + @Suppress("UNCHECKED_CAST") + if (replay != null) return Performed(null as R) + val entry = record?.begin(snapshots) ?: return Performed(block()) + val value = withContext(ActiveAction(entry.seq)) { block() } + record.done(entry) + return Performed(value) + } +} + +/** The blocks of a [label]. */ +class LabelScope internal constructor(replay: ActionReplay?) : CaptureScope(replay) + +/** What an [undo][CaptureScope.undo] block can report. */ +@StoryDsl +class UndoScope internal constructor() { + /** Gives up undoing because someone else changed the state since; the journal logs [reason] as a warning. */ + fun conflict(reason: String): Nothing = throw ActionConflict(reason) +} + +/** The value an action ends with; only [ActionScope.perform] makes one, so the body has to end with it. */ +class Performed internal constructor(internal val value: R) + +internal class ActionConflict(reason: String) : RuntimeException(reason) + +/** Writes a call of [action] made in [run] to the journal around its perform. */ +internal class ActionRecord( + private val run: StoryRun, + private val action: StoryAction, + private val args: CompoundTag, + private val lifetime: Lifetime, + private val outer: Long?, +) { + fun begin(snapshots: CompoundTag): JournalEntry = + run.journal.record(action.id, run.path, lifetime, args, snapshots, outer).also { run.recorded() } + + fun done(entry: JournalEntry) { + entry.done = true + run.changed() + } +} + +/** Marks actions performed inside [temporary]. */ +internal class Temporary : AbstractCoroutineContextElement(Temporary) { + companion object Key : CoroutineContext.Key +} + +/** The action whose perform is running: actions inside it are its parts, which a redo of it repeats. */ +internal class ActiveAction(val seq: Long) : AbstractCoroutineContextElement(ActiveAction) { + companion object Key : CoroutineContext.Key +} + +/** Marks a fast-forward of the journal: the actions it performs again are not recorded twice. */ +internal class Replaying : AbstractCoroutineContextElement(Replaying) { + companion object Key : CoroutineContext.Key +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt index 13a1179f6..35854cbd4 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -19,12 +19,21 @@ annotation class State(val name: String = "") /** * Ends a segment of the enclosing stage: after a restart the stage continues from here rather than from its - * previous stage call. Must be called directly in a `@State` function, and [name] must be unique within it. + * previous stage call. [rewind] with [name] takes the whole story back to how it was here. Must be called directly + * in a `@State` function, and [name] must be unique within it. */ @Suppress("RedundantSuspendModifier") suspend fun checkpoint(name: String): Unit = throw StoryException("checkpoint(\"$name\") must be called directly in a @State function") +/** + * A [checkpoint] that also captures the world with [labels], such as `position(context.player)`. They run once, when + * the checkpoint is passed, and every rewind to it puts back what they captured. + */ +@Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") +suspend fun checkpoint(name: String, labels: suspend CheckpointScope.() -> Unit): Unit = + throw StoryException("checkpoint(\"$name\") { } must be called directly in a @State function") + /** Marks the entry the compiler generates for a stage. The runtime calls it to start or resume the stage by [name]. */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt new file mode 100644 index 000000000..bce0e7772 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -0,0 +1,346 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import io.github.classgraph.ClassGraph +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonLoadedEvent +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import java.lang.reflect.Method +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlin.reflect.KFunction +import kotlin.reflect.KParameter +import kotlin.reflect.full.callSuspendBy +import kotlin.reflect.jvm.isAccessible +import kotlin.reflect.jvm.kotlinFunction + +/** + * The actions stories can call and journal, by id. A script registers its actions when it starts, an addon when it + * loads. A journal record names its action by id, so after a restart it is undone by whatever declares that id then. + */ +object StoryActions { + private val byId = HashMap() + private val byMethod = HashMap() + private val byBlock = HashMap, StoryAction>() + private val users = HashMap, Int>() + + /** + * Registers the [Action] and [SimpleAction] functions [type] declares under [namespace] until [job] ends; every + * instance of a script registers its class, which stays registered while one of them runs. + */ + fun register(type: Class<*>, namespace: String, job: Job? = null) { + val count = users.merge(type, 1, Int::plus)!! + job?.invokeOnCompletion { release(type) } + if (count > 1) return + val declared = + type.declaredMethods.mapNotNull { declare(it, namespace) }.groupBy { it.id }.mapNotNull { (id, same) -> + if (same.size > 1) HollowEngine.LOGGER.error( + "Actions {} of {} share the id '{}'; give each of them an id of its own", + same.map { it.method.name }, + type.name, + id + ) + same.first() + } + declared.forEach { action -> + val previous = byId.put(action.id, action) + if (previous != null && previous.method.declaringClass.name != type.name) { + HollowEngine.LOGGER.warn( + "Action '{}' of {} replaces the one of {}", + action.id, + type.name, + previous.method.declaringClass.name + ) + } + byMethod[action.method] = action + } + } + + private fun release(type: Class<*>) { + val count = users[type] ?: return + if (count > 1) { + users[type] = count - 1 + return + } + users.remove(type) + byMethod.values.filter { it.method.declaringClass == type }.forEach(::remove) + } + + /** The action registered as [id], if any. */ + fun find(id: String): StoryAction? = byId[id] + + /** Every registered action, for editors and commands. */ + val all: Collection get() = byId.values + + /** Registers the actions of an addon that loaded, found the same way as its event listeners. */ + @SubscribeEvent + fun onAddonLoaded(event: HollowAddonLoadedEvent) { + val names = ClassGraph() + .enableClassInfo().enableAnnotationInfo().enableMethodInfo() + .disableNestedJarScanning().ignoreParentClassLoaders() + .overrideClassLoaders(event.classLoader).overrideClasspath(event.classes) + .scan().use { scan -> + (scan.getClassesWithMethodAnnotation(Action::class.java.name).names + scan.getClassesWithMethodAnnotation( + SimpleAction::class.java.name + ).names).distinct() + } + val prefix = event.packagePrefix + names.filter { prefix == null || it.startsWith(prefix) }.forEach { name -> + runCatching { + register( + Class.forName(name, false, event.classLoader), + event.id, + event.scope.coroutineContext.job + ) + }.onFailure { HollowEngine.LOGGER.error("Cannot register the actions of {}", name, it) } + } + } + + /** The action whose body [block] is: the function the compiler put the lambda in. */ + internal fun enclosing(block: Any, what: String): StoryAction { + byBlock[block.javaClass]?.let { return it } + val method = block.javaClass.enclosingMethod + val action = method?.let(byMethod::get) ?: throw StoryException( + when { + method == null -> "$what must be the body of an @Action function" + method.isAnnotationPresent(Action::class.java) -> "Action '${method.declaringClass.name}.${method.name}' is not registered; actions register when their script starts or their addon loads" + + else -> "$what must be the body of an @Action function, not of '${method.name}'" + } + ) + byBlock[block.javaClass] = action + return action + } + + /** Undoes what [entry] did; logs why when it cannot. */ + internal fun undo(entry: JournalEntry) { + val undo = replay(entry)?.scope?.undo ?: return + try { + UndoScope().undo() + } catch (conflict: ActionConflict) { + HollowEngine.LOGGER.warn("Story '{}' leaves '{}' as it is: {}", entry.run, entry.action, conflict.message) + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Story '{}' failed to undo '{}'", entry.run, entry.action, failure) + } + } + + /** Brings the world to where [entry] ended: with its fast-forward, or by performing it again. */ + @Suppress("UNCHECKED_CAST") + internal suspend fun fastForward(entry: JournalEntry) { + val scope = replay(entry)?.scope as? ActionScope ?: return + val block = scope.fastForward ?: scope.perform ?: return + try { + withContext(Replaying()) { block() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Story '{}' failed to fast-forward '{}'", entry.run, entry.action, failure) + } + } + + /** Calls the action of [entry] again with its saved receivers and parameters, declaring its blocks only. */ + private fun replay(entry: JournalEntry): ActionReplay? { + val action = byId[entry.action] ?: run { + HollowEngine.LOGGER.warn( + "Story '{}' cannot replay '{}': no action has this id now", + entry.run, + entry.action + ) + return null + } + return try { + ActionReplay(entry, action).also(action::replay) + } catch (missing: StoryEntityMissing) { + HollowEngine.LOGGER.warn( + "Story '{}' cannot replay '{}': entity {} is not loaded", + entry.run, + entry.action, + missing.uuid + ) + null + } catch (failure: Exception) { + HollowEngine.LOGGER.warn("Story '{}' cannot replay '{}': {}", entry.run, entry.action, failure.message) + null + } + } + + private fun declare(method: Method, namespace: String): StoryAction? { + val reversible = method.getAnnotation(Action::class.java) + val name = reversible?.id ?: method.getAnnotation(SimpleAction::class.java)?.id ?: return null + val id = name.ifBlank { method.name }.let { if (':' in it) it else "$namespace:$it" } + return runCatching { + StoryAction( + id, + method, + reversible != null + ) + }.onFailure { + HollowEngine.LOGGER.error( + "Action '{}' of {} cannot be used: {}", + id, + method.declaringClass.name, + it.message + ) + }.getOrNull() + } + + private fun remove(action: StoryAction) { + if (byId[action.id] === action) byId.remove(action.id) + byMethod.remove(action.method) + byBlock.values.removeIf { it === action } + } +} + +/** + * A function registered as an action: how its receivers and parameters are written to a journal record and read + * back to call it again. + */ +class StoryAction internal constructor(val id: String, internal val method: Method, val reversible: Boolean) { + private enum class Kind { SCRIPT, OBJECT, CHECKPOINT, UNSAVED, VALUE } + + private class Parameter( + val reflected: KParameter, + val name: String, + val kind: Kind, + val serializer: KSerializer? = null, + ) + + private val function: KFunction<*> = + method.kotlinFunction ?: throw StoryException("'${method.name}' is not a Kotlin function") + private val parameters: List by lazy { function.parameters.map(::parameter) } + + init { + if (reversible && !function.isSuspend) throw StoryException("Action '$id' must be a suspend function") + function.isAccessible = true + if (reversible) parameters + } + + /** The parameter names of the function, receivers included, as a record keeps them. */ + val parameterNames: List get() = parameters.map { it.name } + + internal fun encode(call: Array): CompoundTag { + if (call.size != parameters.size) throw StoryException( + "action { } of '$id' takes the ${parameters.size} receivers and parameters of its function in order, not ${call.size}" + ) + val tag = CompoundTag() + parameters.forEachIndexed { index, parameter -> + val value = call[index] + when (parameter.kind) { + Kind.SCRIPT -> tag.putString( + parameter.name, + StoryInstances.keyOf(value ?: throw StoryException("'$id' got no script")) + ) + + Kind.VALUE -> StoryValues.put(tag, parameter.name, parameter.serializer!!, value) + Kind.OBJECT, Kind.CHECKPOINT, Kind.UNSAVED -> Unit + } + } + return tag + } + + /** + * Calls the function again with what [replay] recorded, unsaved parameters left to their defaults; its body + * declares the blocks into [replay] and returns without suspending. + */ + internal fun replay(replay: ActionReplay) { + val arguments = decode(replay.entry.args) + var outcome: Result? = null + ActionReplay.begin(replay) + try { + suspend { function.callSuspendBy(arguments) }.startCoroutine(Continuation(EmptyCoroutineContext) { + outcome = it + }) + } finally { + ActionReplay.end() + } + (outcome ?: throw StoryException("'$id' suspended before its action { } declared its blocks")).getOrThrow() + if (replay.scope == null) throw StoryException("'$id' did not reach its action { }") + } + + private fun decode(tag: CompoundTag): Map = + parameters.filter { it.kind != Kind.UNSAVED }.associate { parameter -> + parameter.reflected to when (parameter.kind) { + Kind.SCRIPT -> tag.getString(parameter.name) + .let { StoryInstances.find(it) ?: throw StoryException("Script '$it' is not running") } + + Kind.OBJECT -> method.declaringClass.kotlin.objectInstance + Kind.CHECKPOINT -> CheckpointScope.REPLAY + Kind.VALUE -> StoryValues.get(tag, parameter.name, parameter.serializer!!) + Kind.UNSAVED -> null + } + } + + private fun parameter(parameter: KParameter): Parameter { + val name = if (parameter.kind == KParameter.Kind.EXTENSION_RECEIVER) "this" else parameter.name + ?: "argument${parameter.index}" + return when { + parameter.kind == KParameter.Kind.INSTANCE -> { + val owner = method.declaringClass + when { + owner.isAnnotationPresent(StageScript::class.java) -> Parameter( + parameter, + "this\$script", + Kind.SCRIPT + ) + + owner.kotlin.objectInstance != null -> Parameter(parameter, "this\$object", Kind.OBJECT) + else -> throw StoryException("Action '$id' must be declared in a script, an object or at the top level") + } + } + + parameter.kind != KParameter.Kind.EXTENSION_RECEIVER && parameter.kind != KParameter.Kind.VALUE -> throw StoryException( + "Action '$id' cannot take context parameters" + ) + + parameter.type.classifier == CheckpointScope::class -> Parameter(parameter, name, Kind.CHECKPOINT) + parameter.annotations.any { it is Unsaved } -> { + if (!parameter.isOptional) throw StoryException("The unsaved '$name' of '$id' needs a default value for the journal to call it with") + Parameter(parameter, name, Kind.UNSAVED) + } + + else -> { + val serializer = try { + StoryValues.serializer(parameter.type) + } catch (failure: StoryException) { + throw StoryException("The journal keeps the parameters of '$id', but '$name' cannot be kept: ${failure.message}") + } + Parameter(parameter, name, Kind.VALUE, serializer) + } + } + } +} + +/** A call of an action made again by the journal: the record it replays and the blocks its body declared. */ +internal class ActionReplay(val entry: JournalEntry, val action: StoryAction) { + var scope: CaptureScope? = null + + fun snapshot(index: Int, serializer: KSerializer): Any? { + val tag = entry.snapshots.get(index.toString()) + ?: throw StoryException("The record of '${action.id}' has no snapshot #$index: the action captures more than it did then") + return StoryValues.decode(serializer, tag) + } + + companion object { + private val CURRENT = ThreadLocal() + + fun begin(replay: ActionReplay) = CURRENT.set(replay) + + fun end() = CURRENT.remove() + + /** The replay [action] runs in, if its body is being called again by the journal. */ + fun take(action: StoryAction): ActionReplay? { + val replay = CURRENT.get() ?: return null + CURRENT.remove() + if (replay.action !== action) throw StoryException( + "'${replay.action.id}' reached the action { } of '${action.id}'; the body of an action must be its own action { }" + ) + return replay + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt index 1cec19c26..bcb79da90 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -2,6 +2,7 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.selects.select @@ -36,6 +37,29 @@ fun Entity.stopStory(key: String): Boolean = StoryOwners.entity(this).stop(key) fun NodeScript.stopStory(key: String): Boolean = StoryOwners.node(this).stop(key) +/** + * Rewinds the story [key] of the server, see [rewind]: to the checkpoint [checkpoint], or each of its runs to the + * start of its segment when it is null. Returns whether there is such a story. + */ +fun MinecraftServer.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.server(this)[key]?.rewind(checkpoint) != null + +fun Entity.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.entity(this)[key]?.rewind(checkpoint) != null + +fun NodeScript.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.node(this)[key]?.rewind(checkpoint) != null + +/** + * Takes the story back and does not return. Without [checkpoint] this run and its active branches each repeat the + * segment they are in; with it the whole story goes back to the latest checkpoint so named, branches started since + * included. The actions performed since are undone. What [once] and [remember] kept stays as it is. + */ +suspend fun rewind(checkpoint: String? = null): Nothing { + currentRun("rewind()").rewind(checkpoint) + awaitCancellation() +} + /** What the current stage knows about its story: whom it runs for. */ val context: StoryContext get() = (StoryRun.current() ?: throw StoryException("context is only available while a story runs")).context @@ -79,6 +103,15 @@ class StoryHandle internal constructor( } fun cancel() = run.stop() + + /** Rewinds the run from outside, see [rewind]. */ + fun rewind(checkpoint: String? = null) = run.rewind(checkpoint) + + /** Undoes the latest rewind of the story, as long as it has not performed an action since. */ + fun redo() = run.redo() + + /** The checkpoints the story can be rewound to, oldest first, with the run that passed each. */ + val checkpoints: List> get() = run.history.all.map { it.name to it.run } } /** Waits for every one of [handles]. */ diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt new file mode 100644 index 000000000..51aee8c8d --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt @@ -0,0 +1,158 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.builder.ArgumentBuilder +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.suggestion.SuggestionProvider +import com.mojang.brigadier.tree.CommandNode +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.commands.SharedSuggestionProvider +import net.minecraft.commands.arguments.EntityArgument +import ru.hollowhorizon.hollowengine.common.utils.mcTranslate + +/** + * What the stories of an owner do, and starting, stopping, rewinding and redoing + * them by hand, as a game master or an author testing a story does. + */ +internal object StoryCommands { + private typealias Context = CommandContext + + fun register(root: CommandNode) { + val story = Commands.literal("story").requires { it.hasPermission(2) } + .then(actions(Commands.literal("server")) { StoryOwners.server(it.source.server) }) + .then( + Commands.literal("entity").then( + actions(Commands.argument("entity", EntityArgument.entity())) { + StoryOwners.entity(EntityArgument.getEntity(it, "entity")) + }, + ), + ) + .then( + Commands.literal("node").then( + actions(Commands.argument("node", StringArgumentType.string()).suggests(nodes)) { + val path = StringArgumentType.getString(it, "node") + StoryOwners.serverNode(path) ?: throw StoryException("No node '$path' runs on the server") + }, + ), + ) + root.addChild(story.build()) + } + + private fun > actions(builder: T, owner: (Context) -> StoryRuns): T { + val keys = SuggestionProvider { context, suggestions -> + SharedSuggestionProvider.suggest(runCatching { owner(context).keys }.getOrDefault(emptySet()).map(::quoted), suggestions) + } + val checkpoints = SuggestionProvider { context, suggestions -> + val names = runCatching { owner(context)[key(context)]?.checkpoints?.map { it.first } }.getOrNull().orEmpty() + SharedSuggestionProvider.suggest(names.distinct().map(::quoted), suggestions) + } + fun key() = Commands.argument("key", StringArgumentType.string()).suggests(keys) + return builder + .then(Commands.literal("list").executes { run(it) { list(it, owner(it)) } }) + .then( + Commands.literal("start").then( + Commands.argument("script", StringArgumentType.string()).suggests(scripts).then( + Commands.argument("stage", StringArgumentType.string()).suggests(stages).then( + Commands.argument("key", StringArgumentType.string()).executes { run(it) { start(it, owner(it)) } }, + ), + ), + ), + ) + .then(Commands.literal("stop").then(key().executes { run(it) { stop(it, owner(it)) } })) + .then( + Commands.literal("rewind").then( + key().executes { run(it) { rewind(it, owner(it), null) } }.then( + Commands.argument("checkpoint", StringArgumentType.string()).suggests(checkpoints) + .executes { run(it) { rewind(it, owner(it), StringArgumentType.getString(it, "checkpoint")) } }, + ), + ), + ) + .then(Commands.literal("redo").then(key().executes { run(it) { redo(it, owner(it)) } })) + .then(Commands.literal("checkpoints").then(key().executes { run(it) { checkpoints(it, owner(it)) } })) + } + + private fun list(context: Context, runs: StoryRuns): Int { + if (runs.keys.isEmpty()) { + context.source.sendSuccess({ "hollowengine.commands.story.none".mcTranslate }, false) + return 0 + } + runs.keys.forEach { key -> + val state = runs[key]?.state ?: return@forEach + context.source.sendSuccess({ "hollowengine.commands.story.entry".mcTranslate(key, state) }, false) + } + return runs.keys.size + } + + private fun start(context: Context, runs: StoryRuns): Int { + val key = key(context) + val stage = StringArgumentType.getString(context, "stage") + runs.launch(key, StringArgumentType.getString(context, "script"), stage) + context.source.sendSuccess({ "hollowengine.commands.story.started".mcTranslate(key, stage) }, true) + return 1 + } + + private fun stop(context: Context, runs: StoryRuns): Int { + val key = key(context) + if (!runs.stop(key)) return missing(context, key) + context.source.sendSuccess({ "hollowengine.commands.story.stopped".mcTranslate(key) }, true) + return 1 + } + + private fun rewind(context: Context, runs: StoryRuns, checkpoint: String?): Int { + val key = key(context) + val story = runs[key] ?: return missing(context, key) + story.rewind(checkpoint) + val message = if (checkpoint == null) "hollowengine.commands.story.rewound_segments".mcTranslate(key) + else "hollowengine.commands.story.rewound".mcTranslate(key, checkpoint) + context.source.sendSuccess({ message }, true) + return 1 + } + + private fun redo(context: Context, runs: StoryRuns): Int { + val key = key(context) + val story = runs[key] ?: return missing(context, key) + story.redo() + context.source.sendSuccess({ "hollowengine.commands.story.redone".mcTranslate(key) }, true) + return 1 + } + + private fun checkpoints(context: Context, runs: StoryRuns): Int { + val key = key(context) + val story = runs[key] ?: return missing(context, key) + story.checkpoints.forEachIndexed { index, (name, run) -> + context.source.sendSuccess({ "hollowengine.commands.story.checkpoint".mcTranslate(index + 1, name, run) }, false) + } + return story.checkpoints.size + } + + private fun missing(context: Context, key: String): Int { + context.source.sendFailure("hollowengine.commands.story.missing".mcTranslate(key)) + return 0 + } + + /** Runs [command], telling the source why a story refused instead of failing the command with a stack trace. */ + private fun run(context: Context, command: () -> Int): Int = try { + command() + } catch (failure: StoryException) { + context.source.sendFailure("hollowengine.commands.story.failed".mcTranslate(failure.message.orEmpty())) + 0 + } + + private fun key(context: Context): String = StringArgumentType.getString(context, "key") + + private fun quoted(value: String): String = StringArgumentType.escapeIfRequired(value) + + private val scripts = SuggestionProvider { _, suggestions -> + SharedSuggestionProvider.suggest(StoryInstances.registered.map(::quoted), suggestions) + } + + private val stages = SuggestionProvider { context, suggestions -> + val script = runCatching { StringArgumentType.getString(context, "script") }.getOrNull() + SharedSuggestionProvider.suggest(script?.let(StoryInstances::stagesOf).orEmpty().map(::quoted), suggestions) + } + + private val nodes = SuggestionProvider { _, suggestions -> + SharedSuggestionProvider.suggest(StoryOwners.serverNodes.map(::quoted), suggestions) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 1cf5c96a3..4f13af06d 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -31,6 +31,12 @@ class StoryFrame internal constructor( internal var segment = CompoundTag() internal val children = LinkedHashSet() internal var trampolined = false + + /** The journal mark when the frame was entered: its temporary actions are those after it. */ + internal var entryMark = 0L + + /** The journal mark when the current segment started, which a repeat of the segment and `rewind()` go back to. */ + internal var segmentMark = 0L private var sites: Array = emptyArray() private var resuming = false private var target = -1 @@ -63,6 +69,16 @@ class StoryFrame internal constructor( args.put(name, encoded) } + /** + * The key [once] and [remember] keep [name] under: the stage with its arguments, the segment it is in, the name. + * Long arguments are shortened to their hash. + */ + internal fun recordKey(name: String): String { + val arguments = args.toString().removeSurrounding("{", "}") + val shown = if (arguments.length <= MAX_ARGUMENTS_IN_KEY) arguments else Integer.toHexString(arguments.hashCode()) + return "$instance/$stage($shown)/${position ?: "start"}/$name" + } + /** Reached only if a stage that returns a value ran off its end, which the compiler rules out. */ fun lost(): Nothing = throw StoryException("Stage '$stage' ended without returning a value") @@ -116,6 +132,7 @@ class StoryFrame internal constructor( result = run.lastResult run.lastResult = null segment = CompoundTag() + segmentMark = run.journal.next run.changed() return current } @@ -143,13 +160,24 @@ class StoryFrame internal constructor( } } - /** Ends a segment at the checkpoint at [site]. */ - fun checkpoint(site: Int) { + /** Ends a segment at the checkpoint at [site], which the story can be rewound to. */ + fun checkpoint(site: Int) = pass(site, emptyList()) + + /** Ends a segment at the checkpoint at [site] after [labels] captured what a rewind to it puts back. */ + suspend fun checkpoint(site: Int, labels: suspend CheckpointScope.() -> Unit) { + val scope = CheckpointScope(run.path) + scope.labels() + pass(site, scope.labels) + } + + private fun pass(site: Int, labels: List) { run.discardPending("'$stage' moved on") position = AFTER + sites[site] previous = null result = null segment = CompoundTag() + segmentMark = run.journal.next + run.history.add(sites[site], run, labels) run.changed() } @@ -208,6 +236,8 @@ class StoryFrame internal constructor( locals = CompoundTag() result = null segment = CompoundTag() + run.journal.undoTemporary(run.path, entryMark) + segmentMark = entryMark run.discardPending(reason) } @@ -227,12 +257,15 @@ class StoryFrame internal constructor( } this.hash = hash resuming = target >= 0 + if (target < 0 || target % 2 == 1) run.journal.undoTemporary(run.path, if (target < 0) entryMark else segmentMark) return this } internal fun fresh(hash: Int, sites: Array): StoryFrame { this.hash = hash this.sites = sites + entryMark = run.journal.next + segmentMark = entryMark return this } @@ -244,6 +277,7 @@ class StoryFrame internal constructor( run.frames.removeAt(run.frames.lastIndex) children.forEach { run.closeChild(it) } children.clear() + run.journal.undoTemporary(run.path, entryMark) } private fun positionOf(saved: String): Int { @@ -262,12 +296,15 @@ class StoryFrame internal constructor( result?.let { put("result", it) } previous?.let { put("previous", it) } put("segment", segment) + putLong("entryMark", entryMark) + putLong("segmentMark", segmentMark) put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) } internal companion object { private const val CALL = "call:" private const val AFTER = "after:" + private const val MAX_ARGUMENTS_IN_KEY = 48 fun load(run: StoryRun, tag: CompoundTag) = StoryFrame(run, tag.getString("instance"), tag.getString("stage")).apply { @@ -278,6 +315,8 @@ class StoryFrame internal constructor( result = tag.get("result") previous = tag.getCompound("previous").takeIf { tag.contains("previous") } segment = tag.getCompound("segment") + entryMark = tag.getLong("entryMark") + segmentMark = tag.getLong("segmentMark") tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt new file mode 100644 index 000000000..1285ef3a0 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt @@ -0,0 +1,155 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag + +/** + * The checkpoints a story passed, oldest first, and the rewinds it can redo. + * + * A checkpoint keeps the whole tree of runs as it was there and marks the journal, so a rewind to it undoes what was + * performed since and puts the runs back. A rewind can be redone until the story performs a new action. + */ +internal class StoryHistory(private val root: StoryRun) { + class Checkpoint( + val name: String, + val run: String, + val mark: Long, + val tree: CompoundTag, + val labels: List, + ) { + fun save(): CompoundTag = CompoundTag().apply { + putString("name", name) + putString("run", run) + putLong("mark", mark) + put("tree", tree) + if (labels.isNotEmpty()) put("labels", ListTag().apply { labels.forEach { add(it.save()) } }) + } + + companion object { + fun load(tag: CompoundTag) = Checkpoint( + tag.getString("name"), + tag.getString("run"), + tag.getLong("mark"), + tag.getCompound("tree"), + tag.getList("labels", Tag.TAG_COMPOUND.toInt()).map { JournalEntry.load(it as CompoundTag) }, + ) + } + } + + /** What a rewind left behind: the tree, the journal and the checkpoints as they were before it. */ + private class Redo(val tree: CompoundTag, val journal: ListTag, val checkpoints: List) { + fun save(): CompoundTag = CompoundTag().apply { + put("tree", tree) + put("journal", journal) + put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) + } + + companion object { + fun load(tag: CompoundTag) = Redo( + tag.getCompound("tree"), + tag.getList("journal", Tag.TAG_COMPOUND.toInt()), + tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()).map { Checkpoint.load(it as CompoundTag) }, + ) + } + } + + private val checkpoints = ArrayList() + private val redo = ArrayList() + + val all: List get() = checkpoints + + val canRedo: Boolean get() = redo.isNotEmpty() + + fun has(name: String): Boolean = checkpoints.any { it.name == name } + + /** [run] passed the checkpoint [name]: keeps the tree as it is now with what [labels] captured. */ + fun add(name: String, run: StoryRun, labels: List) { + checkpoints += Checkpoint(name, run.path, root.journal.next, root.saveTree(), labels) + if (checkpoints.size > MAX_CHECKPOINTS) checkpoints.removeAt(0) + } + + /** The story performed a new action, so what it rewound past cannot come back. */ + fun recorded() = redo.clear() + + /** The lowest mark a rewind can still go back to: the oldest checkpoint, or the segment a stage is in. */ + fun floor(segments: Sequence): Long = + (checkpoints.asSequence().map { it.mark } + segments).minOrNull() ?: root.journal.next + + /** Goes back to the latest checkpoint named [name]: the whole tree as it was there, labels put back. */ + suspend fun rewind(name: String) { + val index = checkpoints.indexOfLast { it.name == name } + if (index < 0) throw StoryException("Story '${root.path}' has no checkpoint '$name'") + val target = checkpoints[index] + root.halt("it rewinds to '$name'") + val before = Redo(root.saveTree(), root.journal.entriesTag(), checkpoints.toList()) + root.journal.rewind(target.mark) + target.labels.asReversed().forEach(StoryActions::undo) + checkpoints.subList(index + 1, checkpoints.size).clear() + push(before) + root.restoreTree(target.tree) + } + + /** Takes [run] and its active branches each back to the start of the segment it is in. */ + suspend fun rewindSegments(run: StoryRun) { + run.halt("it rewinds") + val before = Redo(root.saveTree(), root.journal.entriesTag(), checkpoints.toList()) + run.tree().filter { !it.state.done }.forEach { branch -> + val frame = branch.pending.lastOrNull() ?: return@forEach + root.journal.rewind(frame.segmentMark) { it.run == branch.path } + } + push(before) + } + + /** Returns to where the latest rewind started from, performing again what it undid. */ + suspend fun redo() { + val record = redo.removeLastOrNull() ?: throw StoryException("Story '${root.path}' has nothing to redo") + root.halt("it redoes a rewind") + root.journal.restore(record.journal) + checkpoints.clear() + checkpoints += record.checkpoints + root.restoreTree(record.tree) + } + + private fun push(record: Redo) { + redo += record + if (redo.size > MAX_REDO) redo.removeAt(0) + } + + fun save(): CompoundTag = CompoundTag().apply { + put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) + if (redo.isNotEmpty()) put("redo", ListTag().apply { redo.forEach { add(it.save()) } }) + } + + fun load(tag: CompoundTag) { + checkpoints.clear() + redo.clear() + tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()) + .forEach { checkpoints += Checkpoint.load(it as CompoundTag) } + tag.getList("redo", Tag.TAG_COMPOUND.toInt()).forEach { redo += Redo.load(it as CompoundTag) } + } + + private companion object { + /** Older checkpoints are forgotten, so a long story does not grow its save without end. */ + const val MAX_CHECKPOINTS = 64 + const val MAX_REDO = 8 + } +} + +/** + * What a checkpoint captures besides the story: labels such as [position] and [inventory] take the state of the world + * when the checkpoint is passed and put it back on every rewind to it. + */ +@StoryDsl +class CheckpointScope internal constructor(private val run: String) { + internal val labels = ArrayList() + + internal fun add(action: StoryAction, args: CompoundTag, snapshots: CompoundTag) { + labels += JournalEntry(-1, action.id, run, Lifetime.RUN, args, snapshots, null) + } + + internal companion object { + /** The receiver of a label the journal calls again to put back what it captured. */ + val REPLAY = CheckpointScope("") + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index 060536a4c..e33d7410b 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -69,6 +69,12 @@ object StoryInstances { suspend fun call(key: String, stage: String, captured: CompoundTag, parameters: Array): Any? = invoke(key, stage, { it.calls }, arrayOf(captured, *parameters)) + /** The keys of every running instance. */ + val registered: Set get() = byKey.keys + + /** The stages the instance under [key] declares, or none when nothing runs under it. */ + fun stagesOf(key: String): Set = find(key)?.let { stagesOf(it.javaClass).entries.keys }.orEmpty() + /** Whether the instance under [key] declares [stage]. */ fun hasStage(key: String, stage: String): Boolean { val instance = find(key) ?: return false @@ -100,6 +106,7 @@ object StoryInstances { keys[instance] = key byKey[key] = Entry(instance, job) added += key + StoryActions.register(instance.javaClass, namespaceOf(instance), job) importsOf(instance).forEach { register(it, job, added) } } @@ -122,6 +129,12 @@ object StoryInstances { } } + /** The namespace the actions of a script are registered in: that of the script, or `script` when unknown. */ + private fun namespaceOf(instance: Any): String { + val location = instance.javaClass.getAnnotation(StageScript::class.java)?.path ?: return "script" + return ScriptRegistry.idOfLocation(location)?.namespace ?: "script" + } + /** The imported scripts an instance holds: they come in as constructor arguments kept in fields. */ private fun importsOf(instance: Any): List = instance.javaClass.declaredFields .filter { !Modifier.isStatic(it.modifiers) && !it.type.isPrimitive } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt new file mode 100644 index 000000000..85267eaad --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -0,0 +1,160 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag + +/** + * One call of an action a story made: what to call again to undo or fast-forward it. + * + * @param seq the place of the call in the journal, which only grows; checkpoints and segments mark it. + * @param outer the call this one was made from, whose fast-forward repeats it too. + */ +internal class JournalEntry( + val seq: Long, + val action: String, + val run: String, + val lifetime: Lifetime, + val args: CompoundTag, + val snapshots: CompoundTag, + val outer: Long?, +) { + var done = false + + /** When the effect was taken back: by its stage exiting, for a temporary action. Null while it stands. */ + var undoneAt: Long? = null + + val active: Boolean get() = undoneAt == null + + fun save(): CompoundTag = CompoundTag().apply { + putLong("seq", seq) + putString("action", action) + putString("run", run) + putString("lifetime", lifetime.name) + put("args", args) + put("snapshots", snapshots) + outer?.let { putLong("outer", it) } + putBoolean("done", done) + undoneAt?.let { putLong("undone", it) } + } + + companion object { + fun load(tag: CompoundTag) = JournalEntry( + tag.getLong("seq"), + tag.getString("action"), + tag.getString("run"), + runCatching { Lifetime.valueOf(tag.getString("lifetime")) }.getOrDefault(Lifetime.RUN), + tag.getCompound("args"), + tag.getCompound("snapshots"), + tag.getLong("outer").takeIf { tag.contains("outer") }, + ).apply { + done = tag.getBoolean("done") + undoneAt = tag.getLong("undone").takeIf { tag.contains("undone") } + } + } +} + +/** + * The actions of a story and its branches, in the order they were performed. An entry is written before its action + * runs, so a save made in the middle of it still knows how to undo it. + * + * Entries are kept as long as a rewind may need them: a checkpoint or segment a stage is in marks the journal, + * and going back to a mark undoes what was done after it and brings back what was taken back after it. + */ +internal class StoryJournal { + private val entries = ArrayList() + + /** The place the next entry gets: a mark taken now precedes everything performed from here on. */ + var next = 0L + private set + + val isEmpty: Boolean get() = entries.isEmpty() + + fun record( + action: String, + run: String, + lifetime: Lifetime, + args: CompoundTag, + snapshots: CompoundTag, + outer: Long?, + ) = JournalEntry(next++, action, run, lifetime, args, snapshots, outer).also { entries += it } + + /** + * Takes back the temporary actions [run] performed since [from], in reverse: the stage they belong to exits, or + * the segment that performed them repeats. With [branches] the branches of [run] count too. + */ + fun undoTemporary(run: String, from: Long, branches: Boolean = false) { + val undone = entries.filter { + it.active && it.lifetime == Lifetime.STAGE && it.seq >= from && (it.run == run || branches && it.run.startsWith( + "$run/" + )) + } + undone.asReversed().forEach { entry -> + StoryActions.undo(entry) + entry.undoneAt = next++ + } + } + + /** + * Brings the world covered by [scope] back to how it was at [mark] and forgets what was performed after it: + * undoes the actions performed since, and fast-forwards the temporary ones taken back since, latest first. + */ + suspend fun rewind(mark: Long, scope: (JournalEntry) -> Boolean = { true }) { + val covered = entries.filter(scope) + val undo = covered.filter { it.active && it.seq >= mark }.map { it.seq to it } + val restore = + covered.filter { !it.active && it.seq < mark && it.undoneAt!! >= mark }.map { it.undoneAt!! to it } + val restored = restore.map { it.second.seq }.toSet() + (undo + restore).sortedByDescending { it.first }.forEach { (_, entry) -> + if (entry.active) StoryActions.undo(entry) else { + if (entry.outer !in restored) StoryActions.fastForward(entry) + entry.undoneAt = null + } + } + entries.removeAll { scope(it) && it.seq >= mark } + } + + /** Brings the world to the state [saved] describes and makes it the journal again: what a redo returns to. */ + suspend fun restore(saved: ListTag) { + val target = saved.map { JournalEntry.load(it as CompoundTag) } + val standing = target.filter { it.active }.map { it.seq }.toSet() + val current = entries.filter { it.active }.map { it.seq }.toSet() + entries.filter { it.active && it.seq !in standing }.asReversed().forEach(StoryActions::undo) + val repeated = target.filter { it.active && it.seq !in current } + val seqs = repeated.map { it.seq }.toSet() + repeated.filter { it.outer !in seqs }.forEach { StoryActions.fastForward(it) } + entries.clear() + entries += target + next = maxOf(next, (target.maxOfOrNull { it.seq } ?: -1) + 1) + } + + /** Takes back every temporary action left, for a run that ends; permanent ones stay in the world. */ + fun close(run: String) = undoTemporary(run, 0, branches = true) + + /** + * Forgets what no rewind can reach any more: nothing marks the journal before [floor], so an action taken back + * before it stays so and a permanent one performed before it stays performed. + */ + fun prune(floor: Long) { + entries.removeAll { entry -> + val undoneAt = entry.undoneAt + if (undoneAt != null) undoneAt < floor else entry.lifetime == Lifetime.RUN && entry.seq < floor + } + } + + /** What [run] performed, for the inspector and commands. */ + fun entriesOf(run: String): List = entries.filter { it.run == run } + + fun save(): CompoundTag = CompoundTag().apply { + putLong("next", next) + put("entries", entriesTag()) + } + + fun entriesTag(): ListTag = ListTag().apply { entries.forEach { add(it.save()) } } + + fun load(tag: CompoundTag) { + entries.clear() + tag.getList("entries", Tag.TAG_COMPOUND.toInt()).forEach { entries += JournalEntry.load(it as CompoundTag) } + next = maxOf(tag.getLong("next"), (entries.maxOfOrNull { it.seq } ?: -1) + 1) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt new file mode 100644 index 000000000..9f39aa5fe --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt @@ -0,0 +1,52 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.Serializable +import net.minecraft.core.registries.Registries +import net.minecraft.resources.ResourceKey +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity + +/** Where [entity] is when the checkpoint is passed; every rewind to the checkpoint brings it back there. */ +@Action("position") +suspend fun CheckpointScope.position(entity: Entity) = label(this, entity) { + val placement = capture { Placement.of(entity) } + undo { placement.applyTo(entity) } +} + +/** What [player] carries when the checkpoint is passed; every rewind to the checkpoint gives back exactly that. */ +@Action("inventory") +suspend fun CheckpointScope.inventory(player: ServerPlayer) = label(this, player) { + val inventory = player.inventory + val items = capture { (0 until inventory.containerSize).map { inventory.getItem(it).copy() } } + undo { + items.forEachIndexed { slot, stack -> inventory.setItem(slot, stack.copy()) } + player.containerMenu.broadcastChanges() + } +} + +/** A place and a facing in a dimension. */ +@Serializable +internal class Placement( + val dimension: String, + val x: Double, + val y: Double, + val z: Double, + val yaw: Float, + val pitch: Float, +) { + fun applyTo(entity: Entity) { + val server = entity.server ?: return + val key = ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(dimension)) + val level: ServerLevel = server.getLevel(key) ?: throw StoryException("Dimension '$dimension' does not exist") + entity.teleportTo(level, x, y, z, emptySet(), yaw, pitch) + entity.deltaMovement = entity.deltaMovement.scale(0.0) + } + + companion object { + fun of(entity: Entity) = Placement( + entity.level().dimension().location().toString(), entity.x, entity.y, entity.z, entity.yRot, entity.xRot, + ) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt index a4cf7de8a..4a19d999d 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -49,6 +49,12 @@ object StoryOwners { fun node(node: NodeScript): StoryRuns = nodes[node] ?: throw StoryException("Node '${node.path}' is not running") + /** The runs of the node [path] the server runs itself, not one attached to an entity. */ + fun serverNode(path: String): StoryRuns? = nodes.entries.firstOrNull { (node, _) -> node.path == path && node.host is NodeHost.Server }?.value + + /** The paths of the nodes the server runs itself. */ + val serverNodes: List get() = nodes.keys.filter { it.host is NodeHost.Server }.map { it.path } + /** Makes every script that starts reachable to stories, and gives nodes their runs and their entry stage. */ @SubscribeEvent fun onScriptStarted(event: ScriptStartedEvent) { diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index 0dd7b41f1..e34e2eefd 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -29,6 +29,10 @@ class StoryRun internal constructor( internal val pending = ArrayDeque() internal val children = LinkedHashMap() private val ownVars = CompoundTag() + private val ownRecords = CompoundTag() + private val ownJournal = StoryJournal() + private val ownHistory: StoryHistory? = if (parent == null) StoryHistory(this) else null + private var moving = false private val status = MutableStateFlow(Status.SLEEPING) private var job: Job? = null private val watched = HashMap() @@ -47,13 +51,21 @@ class StoryRun internal constructor( /** Variables declared with [saved], shared by the whole tree of runs. */ internal val vars: CompoundTag get() = parent?.vars ?: ownVars + /** What [once] and [remember] keep: shared by the tree, and left as they are by a rewind. */ + internal val records: CompoundTag get() = parent?.records ?: ownRecords + + /** The actions the whole tree of runs performed. */ + internal val journal: StoryJournal get() = parent?.journal ?: ownJournal + + internal val history: StoryHistory get() = parent?.history ?: ownHistory!! + val state: Status get() = status.value internal val isCancelled: Boolean get() = job?.isCancelled == true /** Starts or wakes the run; does nothing while it runs or once it ended. */ internal fun start() { - if (state.done || job?.isActive == true) return + if (state.done || job?.isActive == true || !isAttached || lineage.any { it.moving }) return val previous = job if (previous != null && !previous.isCompleted) { previous.invokeOnCompletion { start() } @@ -138,6 +150,7 @@ class StoryRun internal constructor( child.pending += lambda.entry(child) children[name] = child frame.children += name + history.add(name, child, emptyList()) changed() child.start() return child @@ -147,10 +160,11 @@ class StoryRun internal constructor( children.remove(name)?.stop() } - /** Stops the run for good, along with its branches. */ + /** Stops the run for good, along with its branches, and takes back their temporary actions at once. */ internal fun stop() { if (state.done) return stopping = true + journal.close(path) val running = job if (running?.isActive == true) running.cancel(CancellationException("Story '$path' was stopped")) else finish(Status.STOPPED, null, null) @@ -167,6 +181,67 @@ class StoryRun internal constructor( internal fun changed() = runs.changed() + /** The run wrote a new action to the journal, so the rewinds before it cannot be redone. */ + internal fun recorded() { + history.recorded() + changed() + } + + /** This run and every branch under it. */ + internal fun tree(): Sequence = sequenceOf(this) + children.values.asSequence().flatMap { it.tree() } + + /** + * Takes the story back: with [name], the whole tree to the latest checkpoint so named; without it, this run and + * its active branches each to the start of the segment it is in. The runs stop, the journal undoes what they + * performed since, and they start again from there. + */ + internal fun rewind(name: String?) { + if (name != null && !history.has(name)) throw StoryException("Story '${root.path}' has no checkpoint '$name'") + if (name == null) move(this) { history.rewindSegments(this) } else move(root) { history.rewind(name) } + } + + /** Undoes the latest rewind of the story: back to where it was, with the actions it undid performed again. */ + internal fun redo() { + if (!history.canRedo) throw StoryException("Story '${root.path}' has nothing to redo") + move(root) { history.redo() } + } + + /** Stops the run with its branches and waits until they have; [start] brings it back. */ + internal suspend fun halt(reason: String) { + val running = job ?: return + sleep(reason) + running.join() + } + + /** Puts the whole tree back to what [saveTree] wrote, ready to [start]. */ + internal fun restoreTree(tag: CompoundTag) { + frames.clear() + pending.clear() + children.clear() + tail = null + readTree(tag) + changed() + } + + /** Runs [change] on the stopped story outside of it, then starts [restart] again. */ + private fun move(restart: StoryRun, change: suspend () -> Unit) { + val root = root + if (root.tree().any { it.moving }) throw StoryException("Story '${root.path}' is already being rewound") + restart.moving = true + runs.owner.scope.launch { + try { + change() + } catch (failure: Exception) { + if (failure is CancellationException) throw failure + HollowEngine.LOGGER.error("Story '{}' failed to rewind", root.path, failure) + } finally { + restart.moving = false + root.changed() + restart.start() + } + } + } + private fun pause() { status.value = Status.SLEEPING pending.addAll(0, frames) @@ -180,6 +255,7 @@ class StoryRun internal constructor( this.failure = failure frames.clear() pending.clear() + journal.close(path) this.status.value = status if (parent == null) runs.ended(this) else changed() } @@ -200,17 +276,49 @@ class StoryRun internal constructor( private val root: StoryRun get() = parent?.root ?: this - internal fun save(): CompoundTag = CompoundTag().apply { + /** This run and the runs it is a branch of. */ + private val lineage: Sequence get() = generateSequence(this) { it.parent } + + /** + * Whether the run is still part of its story. A rewind replaces the branches it restores, and a run that ended + * leaves its owner, so a wait that wakes such a run afterwards does nothing. + */ + private val isAttached: Boolean get() = if (parent == null) runs.holds(this) else parent.children[name] === this && parent.isAttached + + /** Everything to save; the root adds what the tree shares over time: records, journal, checkpoints. */ + internal fun save(): CompoundTag = saveTree().apply { + if (parent != null) return@apply + journal.prune(history.floor(tree().flatMap { (it.frames + it.pending).asSequence() }.map { it.segmentMark })) + if (!ownRecords.isEmpty) put("records", ownRecords.copy()) + put("journal", journal.save()) + put("history", history.save()) + } + + /** The runs as they are: stacks, branches, variables. A checkpoint keeps this. */ + internal fun saveTree(): CompoundTag = CompoundTag().apply { putString("status", (if (state == Status.RUNNING) Status.SLEEPING else state).name) put("frames", ListTag().apply { (frames + pending).forEach { add(it.save()) } }) if (children.isNotEmpty()) put( "children", - CompoundTag().apply { children.forEach { (name, run) -> put(name, run.save()) } }) + CompoundTag().apply { children.forEach { (name, run) -> put(name, run.saveTree()) } }) if (parent == null && !ownVars.isEmpty) put("vars", ownVars.copy()) result?.let { put("result", it) } failure?.let { putString("failure", it) } } + private fun readTree(tag: CompoundTag) { + status.value = runCatching { Status.valueOf(tag.getString("status")) }.getOrDefault(Status.SLEEPING) + tag.getList("frames", Tag.TAG_COMPOUND.toInt()).forEach { pending += StoryFrame.load(this, it as CompoundTag) } + val saved = tag.getCompound("children") + saved.allKeys.forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } + if (parent == null) { + ownVars.allKeys.toList().forEach(ownVars::remove) + ownVars.merge(tag.getCompound("vars")) + } + result = tag.get("result") + failure = tag.getString("failure").takeIf { tag.contains("failure") } + } + /** What the run is doing, for logs and the inspector. */ fun describe(): String = (frames + pending).joinToString(" > ") { frame -> frame.position?.let { "${frame.stage}@$it" } ?: frame.stage @@ -236,14 +344,12 @@ class StoryRun internal constructor( fun load(runs: StoryRuns, name: String, parent: StoryRun?, tag: CompoundTag): StoryRun = StoryRun(runs, name, parent).apply { - status.value = runCatching { Status.valueOf(tag.getString("status")) }.getOrDefault(Status.SLEEPING) - tag.getList("frames", Tag.TAG_COMPOUND.toInt()) - .forEach { pending += StoryFrame.load(this, it as CompoundTag) } - val saved = tag.getCompound("children") - saved.allKeys.forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } - ownVars.merge(tag.getCompound("vars")) - result = tag.get("result") - failure = tag.getString("failure").takeIf { tag.contains("failure") } + readTree(tag) + if (parent == null) { + ownRecords.merge(tag.getCompound("records")) + ownJournal.load(tag.getCompound("journal")) + ownHistory!!.load(tag.getCompound("history")) + } } } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt index c1705751f..e43ad02c3 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -88,6 +88,8 @@ class StoryRuns(val owner: StoryOwner) { internal fun changed() = owner.changed(this) + internal fun holds(run: StoryRun): Boolean = runs[run.name] === run + internal fun ended(run: StoryRun) { if (runs[run.name] === run) runs.remove(run.name) changed() @@ -102,6 +104,7 @@ class StoryRuns(val owner: StoryOwner) { runs[key]?.let { return StoryHandle(it, null) } val run = StoryRun(this, key, null) run.pending += entry(run) + run.history.add(key, run, emptyList()) runs[key] = run changed() run.start() diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt index d2b602cad..af9a1a8cf 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt @@ -3,8 +3,6 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.delay import kotlinx.serialization.KSerializer import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.StringTag -import net.minecraft.nbt.Tag import ru.hollowhorizon.hollowengine.common.utils.currentServer import java.lang.Math.addExact import java.time.Instant @@ -18,18 +16,23 @@ import kotlin.time.Duration.Companion.milliseconds */ suspend fun segmentTag(): CompoundTag = currentFrame("segmentTag()").segment -/** Runs [block] once per segment: a segment repeated after a restart skips it. */ +/** + * Runs [block] once for the whole story: the segment repeated after a restart or a [rewind] skips it, so a reward is + * not given twice. [name] belongs to the segment it is called in, of a stage called with these arguments; inside a + * loop, put the index into it. + */ suspend fun once(name: String, block: suspend () -> Unit) { val frame = currentFrame("once(\"$name\")") - val done = frame.segment.getList(ONCE, Tag.TAG_STRING.toInt()) - if (done.any { it.asString == name }) return + val done = frame.run.records.getCompound(ONCE) + val key = frame.recordKey(name) + if (done.contains(key)) return block() - done.add(StringTag.valueOf(name)) - frame.segment.put(ONCE, done) + done.putBoolean(key, true) + frame.run.records.put(ONCE, done) frame.run.changed() } -/** Computes a value once per segment: a segment repeated after a restart gets the value computed before. */ +/** Computes a value once for the whole story, like [once]: a repeated segment gets the value computed the first time. */ suspend inline fun remember(name: String, noinline block: suspend () -> T): T = remember(name, storySerializer(), block) @@ -37,24 +40,27 @@ suspend inline fun remember(name: String, noinline block: suspend () @Suppress("UNCHECKED_CAST") internal suspend fun remember(name: String, serializer: KSerializer, block: suspend () -> T): T { val frame = currentFrame("remember(\"$name\")") - frame.segment.get(name)?.let { return StoryValues.decode(serializer, it) as T } + val values = frame.run.records.getCompound(REMEMBERED) + val key = frame.recordKey(name) + values.get(key)?.let { return StoryValues.decode(serializer, it) as T } val value = block() - StoryValues.put(frame.segment, name, serializer, value) + StoryValues.put(values, key, serializer, value) + frame.run.records.put(REMEMBERED, values) frame.run.changed() return value } -/** Forgets what [remember] or [once] kept under [name]; returns whether there was something. */ +/** Forgets what [remember] or [once] kept under [name] in this segment; returns whether there was something. */ suspend fun forget(name: String): Boolean { val frame = currentFrame("forget(\"$name\")") - val segment = frame.segment - val done = segment.getList(ONCE, Tag.TAG_STRING.toInt()) - val wasDone = done.removeIf { it.asString == name } - val existed = segment.contains(name) - segment.remove(name) - if (wasDone) segment.put(ONCE, done) - frame.run.changed() - return existed || wasDone + val key = frame.recordKey(name) + val records = frame.run.records + val existed = listOf(ONCE, REMEMBERED).count { kind -> + val tag = records.getCompound(kind) + tag.contains(key).also { if (it) tag.remove(key) } + } > 0 + if (existed) frame.run.changed() + return existed } /** Waits for loaded server ticks; a segment repeated after a restart continues the same countdown. */ @@ -114,7 +120,8 @@ private fun waitKey(kind: String, name: String): String { return "wait$.$kind.$name" } -private const val ONCE = "once$" +private const val ONCE = "once" +private const val REMEMBERED = "remembered" private const val GAME_TIME = "game" private const val REAL_TIME = "real" private const val MAX_REALTIME_SLEEP_MILLIS = 60_000L diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json index 7ca548e09..deeb8657f 100644 --- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json @@ -1,6 +1,16 @@ { "hollowengine.commands.dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.", "hollowengine.commands.dialogue_no_such_option": "There is no option %s in the dialogue you are in.", + "hollowengine.commands.story.none": "There are no stories here.", + "hollowengine.commands.story.entry": "%s: %s", + "hollowengine.commands.story.started": "Story %s started at stage %s.", + "hollowengine.commands.story.stopped": "Story %s stopped.", + "hollowengine.commands.story.missing": "There is no story %s here.", + "hollowengine.commands.story.rewound": "Story %s is going back to checkpoint %s.", + "hollowengine.commands.story.rewound_segments": "Story %s is going back to the start of the segments it is in.", + "hollowengine.commands.story.redone": "Story %s is returning to where its last rewind started.", + "hollowengine.commands.story.checkpoint": "%s. %s (%s)", + "hollowengine.commands.story.failed": "The story refused: %s", "hollowengine.dialogue.advance": "[ continue ▶ ]", "hollowengine.gui.ide.cutscene.dialog.save_title": "Save cutscene", "hollowengine.gui.ide.cutscene.dialog.load_title": "Load cutscene", diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json index 1c4121377..7a9eaf682 100644 --- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json @@ -1,6 +1,16 @@ { "hollowengine.commands.dialogue_not_in_dialogue": "Сейчас вы не участвуете в диалоге.", "hollowengine.commands.dialogue_no_such_option": "В вашем диалоге нет варианта %s.", + "hollowengine.commands.story.none": "Здесь нет историй.", + "hollowengine.commands.story.entry": "%s: %s", + "hollowengine.commands.story.started": "История %s запущена со стадии %s.", + "hollowengine.commands.story.stopped": "История %s остановлена.", + "hollowengine.commands.story.missing": "Здесь нет истории %s.", + "hollowengine.commands.story.rewound": "История %s возвращается к чекпоинту %s.", + "hollowengine.commands.story.rewound_segments": "История %s возвращается к началу отрезков, в которых она сейчас.", + "hollowengine.commands.story.redone": "История %s возвращается туда, откуда её откатили в последний раз.", + "hollowengine.commands.story.checkpoint": "%s. %s (%s)", + "hollowengine.commands.story.failed": "История отказалась: %s", "hollowengine.dialogue.advance": "[ дальше ▶ ]", "hollowengine.gui.ide.cutscene.dialog.save_title": "Сохранение катсцены", "hollowengine.gui.ide.cutscene.dialog.load_title": "Загрузка катсцены", diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt new file mode 100644 index 000000000..c52e25272 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The actions narrative declares register with parameters the journal can keep, or marks as unsaved. */ +class StoryActionsTest { + @Test + fun `npc actions keep their receiver and targets and leave the options to their defaults`() { + StoryActions.register(Class.forName("ru.hollowhorizon.hollowengine.addons.narrative.npcs.NpcActionsKt"), "test") + val move = StoryActions.find("test:move_to")!! + assertTrue(move.reversible) + assertEquals(listOf("this", "pos", "options"), move.parameterNames) + listOf("move_to_entity", "move_through", "avoid", "look_at", "look_at_entity").forEach { id -> + assertTrue(StoryActions.find("test:$id")?.reversible == true, "$id is undoable") + } + assertFalse(StoryActions.find("test:stop_moving")!!.reversible) + } + + @Test + fun `checkpoint labels register as actions of their own`() { + StoryActions.register(Class.forName("ru.hollowhorizon.hollowengine.addons.narrative.story.StoryLabelsKt"), "test") + assertEquals(listOf("this", "entity"), StoryActions.find("test:position")!!.parameterNames) + assertEquals(listOf("this", "player"), StoryActions.find("test:inventory")!!.parameterNames) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt index f4a20f26b..3c6e1b3dc 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt @@ -5,6 +5,8 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.StringTag import net.minecraft.world.entity.Entity import net.minecraft.world.entity.player.Player +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.npcs.navigation.Zone import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef import java.util.UUID import kotlin.test.AfterTest @@ -76,4 +78,10 @@ class StoryValuesTest { val failure = assertFailsWith { storySerializer() } assertEquals(true, failure.message?.contains("java.lang.Thread")) } + + @Test + fun `zones come back equal to themselves, so an undone avoid finds the zone it added`() { + val zones = listOf(Zone.sphere(Vec3(1.0, 2.0, 3.0), 4.0, cost = 5f), Zone.box(Vec3(0.0, 0.0, 0.0), Vec3(2.0, 3.0, 4.0))) + assertEquals(zones, roundTrip(zones)) + } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt new file mode 100644 index 000000000..48becbffb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import java.io.File + +/** + * The addon [id] has loaded: its [classes] are on [classLoader] and its listeners are subscribed. Lets other addons + * find declarations of their own in it, the way `@SubscribeEvent` methods are found. [scope] ends when it unloads. + * + * @param packagePrefix the package an addon embedded in a mod keeps its classes in, or null for a whole jar. + */ +class HollowAddonLoadedEvent( + val id: String, + val classes: File, + val classLoader: ClassLoader, + val scope: CoroutineScope, + val packagePrefix: String?, +) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index 233b08907..3b9e1157f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -502,6 +502,9 @@ internal class HollowAddonRuntime( HollowAddonEventRegistrar.register( candidate.classesFile, classLoader, descriptor.id, entrypoint, addonScope, embeddedPackage ) + HollowAddonLoadedEvent.post( + HollowAddonLoadedEvent(descriptor.id, candidate.classesFile, classLoader, addonScope, embeddedPackage) + ) } registerScripts(candidate, classLoader, libraries) loadedAddons[descriptor.id] = LoadedHollowAddon( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt index ed28e83a6..e865f4cc0 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt @@ -69,14 +69,15 @@ enum class Shortfall { BLOCKED, } -internal suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): MoveResult = +/** Walks to where [target] says, asked again as the NPC goes; the channel the walk holds is up to the caller. */ +suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): MoveResult = moveThrough(listOf(target), options) /** * Walks through [targets] in turn, stopping only at the last: each one before it counts as passed within * [MoveOptions.passDistance], and the NPC plans its pace along the ones still ahead, so it rounds them as turns. */ -internal suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult { +suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult { npcNavigation.settingsOverride = options.navigation?.let { tune -> (navigationComponent ?: NavigationComponent()).tune() } npcNavigation.facing.setMove(options.facing) npcNavigation.moveZones = options.avoid @@ -207,7 +208,8 @@ private fun NpcEntity.turnBodyTo(yaw: Float): Boolean { return abs(Mth.wrapDegrees(yaw - body)) <= 1f } -internal suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult { +/** Walks up to [target], following it while it moves and waiting for it as [MoveOptions.unavailableTarget] says. */ +suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult { while (true) { if (!target.isLinkAlive && options.unavailableTarget == UnavailableTargetPolicy.FAIL) { return MoveResult.TargetUnavailable diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt index 83023d8c7..8f62f8039 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt @@ -1,30 +1,46 @@ package ru.hollowhorizon.hollowengine.common.npcs.navigation +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import net.minecraft.world.phys.AABB import net.minecraft.world.phys.Vec3 /** - * A part of the world an NPC keeps out of on its way: a block counts as in it when its middle is. + * A part of the world an NPC keeps out of on its way: a block counts as in it when its middle is. Zones are compared + * by value, so one written down and read back is the same zone. */ -sealed class Zone(val cost: Float?) { +@Serializable +sealed class Zone { + /** What walking through costs, or null when the NPC never enters. */ + abstract val cost: Float? + abstract fun contains(x: Double, y: Double, z: Double): Boolean fun contains(point: Vec3): Boolean = contains(point.x, point.y, point.z) /** Everything between two corners. */ - class Box(val bounds: AABB, cost: Float? = null) : Zone(cost) { + @Serializable + @SerialName("box") + data class Box(@Contextual val from: Vec3, @Contextual val to: Vec3, override val cost: Float? = null) : Zone() { + @Transient + val bounds = AABB(from, to) + override fun contains(x: Double, y: Double, z: Double): Boolean = bounds.contains(x, y, z) } /** Everything within [radius] blocks of [center]. */ - class Sphere(val center: Vec3, val radius: Double, cost: Float? = null) : Zone(cost) { + @Serializable + @SerialName("sphere") + data class Sphere(@Contextual val center: Vec3, val radius: Double, override val cost: Float? = null) : Zone() { override fun contains(x: Double, y: Double, z: Double): Boolean = center.distanceToSqr(x, y, z) <= radius * radius } companion object { /** The box from [from] to [to]; [cost] null never enters it. */ - fun box(from: Vec3, to: Vec3, cost: Float? = null): Zone = Box(AABB(from, to), cost) + fun box(from: Vec3, to: Vec3, cost: Float? = null): Zone = Box(from, to, cost) /** Everything within [radius] blocks of [center]; [cost] null never enters it. */ fun sphere(center: Vec3, radius: Double, cost: Float? = null): Zone = Sphere(center, radius, cost) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt deleted file mode 100644 index 67f441853..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt +++ /dev/null @@ -1,454 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs - -import com.mojang.brigadier.StringReader -import kotlinx.coroutines.delay -import net.minecraft.ChatFormatting -import net.minecraft.commands.arguments.item.ItemParser -import net.minecraft.core.BlockPos -import net.minecraft.core.Direction -import net.minecraft.core.RegistryAccess -import net.minecraft.core.component.DataComponentPatch -import net.minecraft.core.registries.BuiltInRegistries -import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket.Action.* -import net.minecraft.server.level.ServerLevel -import net.minecraft.util.Mth -import net.minecraft.world.InteractionHand -import net.minecraft.world.InteractionResult -import net.minecraft.world.entity.Entity -import net.minecraft.world.entity.item.ItemEntity -import net.minecraft.world.item.ItemStack -import net.minecraft.world.phys.BlockHitResult -import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.common.coroutines.Ref -import ru.hollowhorizon.hollowengine.common.coroutines.entityRef -import ru.hollowhorizon.hollowengine.common.entities.NpcEntity -import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction -import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys -import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted -import ru.hollowhorizon.hollowengine.common.npcs.navigation.* -import ru.hollowhorizon.hollowengine.common.utils.* -import java.util.* -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Moves the NPC toward the specified entity until the distance becomes less than or equal to the specified value. - * - * @param entity The entity to move toward. - * @param dist The minimum distance to the entity; movement stops when this distance is reached. - * @param speed The NPC's movement speed. - */ -suspend fun NpcEntity.move(entity: Entity, dist: Double = 1.5, speed: Double = 1.0): MoveResult = - with(server ?: currentServer) { - move(entity.entityRef, MoveOptions(arrivalDistance = dist, speed = speed)) - } - -suspend fun NpcEntity.move(target: Ref, options: MoveOptions = MoveOptions()): MoveResult = - actions.act(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) } - -fun NpcEntity.startMove(target: Ref, options: MoveOptions = MoveOptions()): NpcAction = - actions.start(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) } - -/** - * Moves the NPC to the specified entity using the default settings. (Can be specified with a space) - * - * @param mob The entity to move to. - */ -suspend infix fun NpcEntity.move(mob: Entity): MoveResult = move(entity = mob) - -/** - * Moves the NPC to the specified position until the distance becomes less than or equal to the specified value. - * - * @param pos The position to move to. - * @param dist The minimum distance to the position; movement stops when this distance is reached. - * @param speed The NPC's movement speed. - */ -suspend fun NpcEntity.move(pos: Vec3, dist: Double = 1.5, speed: Double = 1.0): MoveResult = - move(pos, MoveOptions(arrivalDistance = dist, speed = speed)) - -suspend fun NpcEntity.move(pos: Vec3, options: MoveOptions = MoveOptions()): MoveResult = - actions.act(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } - -fun NpcEntity.startMove(pos: Vec3, options: MoveOptions = MoveOptions()): NpcAction = - actions.start(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } - -/** - * Moves the NPC to the specified position using the default settings. - * - * @param position The position to move to. - */ -suspend infix fun NpcEntity.move(position: Vec3): MoveResult = move(pos = position) - -/** - * Moves the NPC through [points] in turn without stopping at any but the last: each one before it counts as - * passed within [MoveOptions.passDistance], and [MoveOptions.arrivalDistance] is for the last. - */ -suspend fun NpcEntity.move(points: List, options: MoveOptions = MoveOptions()): MoveResult { - require(points.isNotEmpty()) { "A move needs at least one point" } - return actions.act(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } -} - -fun NpcEntity.startMove(points: List, options: MoveOptions = MoveOptions()): NpcAction { - require(points.isNotEmpty()) { "A move needs at least one point" } - return actions.start(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } -} - -fun NpcEntity.stopMoving() { - actions.cancel(NpcActionKeys.MOVEMENT) - navigation.stop() -} - -/** - * Runs several actions of the NPC as one: the channels they use stay held until [block] returns, and losing - * any of them to a higher priority cancels the whole block with a single [Preempted]. - */ -suspend fun NpcEntity.perform(block: suspend NpcEntity.() -> R): R = actions.perform { block() } - -/** [perform] that waits for the actions already running on its channels instead of replacing them. */ -suspend fun NpcEntity.queue(block: suspend NpcEntity.() -> R): R = actions.queue { block() } - -/** - * Keeps the NPC out of [zone] on every way it walks, its moves and its goals alike, until [stopAvoiding]. It is - * not saved with the NPC: a script that needs it after a reload adds it again. - */ -fun NpcEntity.avoid(zone: Zone) { - npcNavigation.avoidedZones += zone - navigation.recomputePath() -} - -fun NpcEntity.stopAvoiding(zone: Zone) { - if (npcNavigation.avoidedZones.remove(zone)) navigation.recomputePath() -} - -fun NpcEntity.stopAvoidingAll() { - if (npcNavigation.avoidedZones.isEmpty()) return - npcNavigation.avoidedZones.clear() - navigation.recomputePath() -} - -/** - * Makes the NPC look at the specified position. Returns value, is npc looked at target, or still rotating head. - * - * @param position The position to look at. - */ -fun NpcEntity.lookAtNow(position: Vec3, maxAngularSpeed: Float = 360f): Boolean = - faceTowards(position, maxAngularSpeed) - -fun NpcEntity.startLookingAt( - position: Vec3, - duration: Duration = DEFAULT_LOOK_DURATION, - maxAngularSpeed: Float = 360f, -): NpcAction = actions.start(NpcActionKeys.LOOK) { - rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) -} - -suspend fun NpcEntity.lookAt(position: Vec3, duration: Duration, maxAngularSpeed: Float = 360f) { - actions.act(NpcActionKeys.LOOK) { rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) } -} - -suspend infix fun NpcEntity.lookAt(position: Vec3) { - lookAt(position, DEFAULT_LOOK_DURATION) -} - -/** - * Makes the NPC look at the specified entity. Returns value, is npc looked at target, or still rotating head. - * - * @param entity The entity to look at. - */ -fun NpcEntity.lookAtNow(entity: Entity, maxAngularSpeed: Float = 360f): Boolean = - faceTowards(entity, maxAngularSpeed) - -fun NpcEntity.startLookingAt( - target: Ref, - duration: Duration = DEFAULT_LOOK_DURATION, - maxAngularSpeed: Float = 360f, -): NpcAction = actions.start(NpcActionKeys.LOOK) { - rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) -} - -suspend fun NpcEntity.lookAt( - target: Ref, - duration: Duration, - maxAngularSpeed: Float = 360f, -) { - actions.act(NpcActionKeys.LOOK) { - rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) - } -} - -suspend infix fun NpcEntity.lookAt(entity: Entity) { - with(server ?: currentServer) { - lookAt(entity.entityRef, DEFAULT_LOOK_DURATION) - } -} - -fun NpcEntity.clearLookTarget() { - actions.cancel(NpcActionKeys.LOOK) -} - -/** - * Forces an NPC to use a block at the specified position. - * The NPC will move to the block, look at it, and perform the use action. - * - * @param pos The position of the block to be used. - */ -suspend infix fun NpcEntity.useBlock(pos: Vec3): InteractionResult = useBlock(pos, InteractionHand.MAIN_HAND) - -suspend fun NpcEntity.useBlock( - pos: Vec3, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, - face: Direction? = null, -): InteractionResult { - require(reach > 0.0) { "Interaction reach must be greater than zero" } - return actions.act(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } -} - -fun NpcEntity.startUseBlock( - pos: Vec3, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, - face: Direction? = null, -): NpcAction { - require(reach > 0.0) { "Interaction reach must be greater than zero" } - return actions.start(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } -} - -private suspend fun NpcEntity.useBlockReaching( - pos: Vec3, - hand: InteractionHand, - reach: Double, - face: Direction?, -): InteractionResult { - val target = Vec3.atCenterOf(BlockPos.containing(pos)) - if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return InteractionResult.FAIL - rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) - return useBlockAt(target, hand, face) -} - -fun NpcEntity.useBlockNow( - pos: Vec3, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, - face: Direction? = null, -): InteractionResult { - val blockPos = BlockPos.containing(pos) - val target = Vec3.atCenterOf(blockPos) - if (eyePosition.distanceToSqr(target) > reach * reach) return InteractionResult.FAIL - - return useBlockAt(target, hand, face) -} - -private fun NpcEntity.useBlockAt(target: Vec3, hand: InteractionHand, face: Direction?): InteractionResult { - val blockPos = BlockPos.containing(target) - syncFakePlayer() - val hit = BlockHitResult(target, face ?: interactionFace(target), blockPos, false) - swing(hand) - val result = fakePlayer.gameMode.useItemOn(fakePlayer, level(), fakePlayer.getItemInHand(hand), hand, hit) - setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) - return result -} - -/** - * Forces the NPC to destroy the block at the specified position. - * The NPC will move to the block, look at it, and destroy it. - * - * @param pos The position of the block to be destroyed. - */ -suspend infix fun NpcEntity.destroyBlock(pos: Vec3): Boolean = destroyBlock(pos, DEFAULT_INTERACTION_REACH) - -suspend fun NpcEntity.destroyBlock( - pos: Vec3, - reach: Double, - requireCorrectTool: Boolean = true, -): Boolean = actions.act(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } - -fun NpcEntity.startDestroyBlock( - pos: Vec3, - reach: Double = DEFAULT_INTERACTION_REACH, - requireCorrectTool: Boolean = true, -): NpcAction = actions.start(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } - -private suspend fun NpcEntity.destroyBlockReaching(pos: Vec3, reach: Double, requireCorrectTool: Boolean): Boolean { - val blockPos = BlockPos.containing(pos) - val target = Vec3.atCenterOf(blockPos) - if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return false - rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) - syncFakePlayer() - val initialState = level().getBlockState(blockPos) - if (initialState.isAir || initialState.getDestroyProgress(fakePlayer, level(), blockPos) <= 0f) return false - if (requireCorrectTool && !fakePlayer.hasCorrectToolForDrops(initialState)) return false - - val face = interactionFace(target) - fakePlayer.gameMode.handleBlockBreakAction(blockPos, START_DESTROY_BLOCK, face, level().maxBuildHeight, 0) - try { - var elapsedTicks = 0 - while (level().getBlockState(blockPos) == initialState) { - fakePlayer.gameMode.tick() - elapsedTicks++ - faceTowards(target) - swing(InteractionHand.MAIN_HAND) - val progress = initialState.getDestroyProgress(fakePlayer, level(), blockPos) - if (progress * (elapsedTicks + 1) >= 1f) { - fakePlayer.gameMode.handleBlockBreakAction( - blockPos, - STOP_DESTROY_BLOCK, - face, - level().maxBuildHeight, - 0, - ) - break - } - delay(TICK_DURATION) - } - return level().getBlockState(blockPos).isAir - } finally { - if (!level().getBlockState(blockPos).isAir) { - fakePlayer.gameMode.handleBlockBreakAction( - blockPos, - ABORT_DESTROY_BLOCK, - face, - level().maxBuildHeight, - 0, - ) - } - level().destroyBlockProgress(fakePlayer.id, blockPos, -1) - setItemInHand(InteractionHand.MAIN_HAND, fakePlayer.mainHandItem.copy()) - } -} - -private fun NpcEntity.interactionFace(target: Vec3): Direction = Direction.getNearest(eyePosition.subtract(target)) - -fun NpcEntity.interactNow( - target: Entity, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, -): InteractionResult { - if (target.level() !== level() || distanceToSqr(target) > reach * reach) return InteractionResult.FAIL - syncFakePlayer() - val result = fakePlayer.interactOn(target, hand) - setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) - swing(hand) - return result -} - -fun NpcEntity.startInteract( - target: Ref, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, -): NpcAction = actions.start(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } - -private suspend fun NpcEntity.interactReaching( - target: Ref, - hand: InteractionHand, - reach: Double, -): InteractionResult { - if (move(target, MoveOptions(arrivalDistance = reach)) != MoveResult.Arrived) return InteractionResult.FAIL - val entity = target.resolve() - rotate({ entity.eyePosition }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) - return interactNow(entity, hand, reach) -} - -suspend fun NpcEntity.interact( - target: Ref, - hand: InteractionHand = InteractionHand.MAIN_HAND, - reach: Double = DEFAULT_INTERACTION_REACH, -): InteractionResult = actions.act(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } - -private fun NpcEntity.syncFakePlayer() { - val level = level() as ServerLevel - fakePlayer.gameMode.setLevel(level) - fakePlayer.moveTo(x, y, z, yRot, xRot) - InteractionHand.entries.forEach { currentHand -> - fakePlayer.setItemInHand(currentHand, getItemInHand(currentHand).copy()) - } -} - -/** - * Forces an NPC to drop an item from its inventory. - * - * @param item The item to be dropped. - */ -fun NpcEntity.dropItem(item: ItemStack) { - val p = position() - val entityStack = ItemEntity(level(), p.x, p.y + eyeHeight, p.z, item) - entityStack.setDefaultPickUpDelay() - val f8 = Mth.sin(xRot * Mth.PI / 180f) - val f3 = Mth.sin(yHeadRot * Mth.PI / 180f) - val f4 = Mth.cos(yHeadRot * Mth.PI / 180f) - entityStack.setDeltaMovement(-f3 * 0.3, -f8 * 0.3 + 0.1, f4 * 0.3) - level().addFreshEntity(entityStack) -} - -/** - * Pauses the coroutine for the specified number of ticks. - * - * @param time The number of ticks to delay. - */ -suspend fun wait(time: Int) { - delay((time * 50L).milliseconds) -} - -fun uuid(uuid: String): UUID = UUID.fromString(uuid) - -/** - * Создает ItemStack с указанным предметом, количеством и NBT-тегом. - * - * @param item Идентификатор предмета (например, "minecraft:apple"). - * @param count Количество предметов в стаке. - * @param nbt NBT-тег для предмета. - * @return ItemStack с заданными параметрами. - */ -fun item(item: String, count: Int = 1): ItemStack = item(item, count, DataComponentPatch.EMPTY) - -private val builtInRegistryAccess by lazy { - RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY) -} - -fun item(item: String, count: Int = 1, components: DataComponentPatch): ItemStack { - require(count > 0) { "Item count must be greater than zero" } - val reader = StringReader(item) - val registries = currentServerOrNull()?.registryAccess() ?: builtInRegistryAccess - val parsed = runCatching { ItemParser(registries).parse(reader) } - .getOrElse { error -> throw IllegalArgumentException("Invalid item specification '$item'", error) } - require(!reader.canRead()) { "Unexpected trailing data in item specification '$item'" } - - return ItemStack(parsed.item, count).apply { - applyComponents(parsed.components) - applyComponents(components) - } -} - -/** - * A getter variable that converts seconds to ticks. - * - * @param this The number to be converted from ticks to seconds - * @template - * ```node.kts - * 10.sec // Converts 10 seconds to ticks (10 * 20 = 200) - * ``` - */ -val Int.sec get() = this * 20 - -val Double.sec get() = (this * 20).toInt() - -private val DEFAULT_LOOK_DURATION = 1500.milliseconds -private val TICK_DURATION = 50.milliseconds -private const val DEFAULT_INTERACTION_REACH = 3.0 - -/** - * Makes an NPC "say" the text by sending a message to all players on the server. - * - * @param text The text of the message. - */ -infix fun NpcEntity.say(text: String) { - server?.playerList?.players?.forEach { - it.sendSystemMessage( - "[".literal.colored(ChatFormatting.GRAY) + - name + - "]: ".literal.colored(ChatFormatting.GRAY) + - text.literal - ) - } -} - diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt index 7e59e8ac3..aa384a9f8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt @@ -1,6 +1,8 @@ package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs +import net.minecraft.util.Mth import net.minecraft.world.entity.EquipmentSlot +import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.entity.player.Player import net.minecraft.world.item.ItemStack import ru.hollowhorizon.hollowengine.common.entities.NpcEntity @@ -50,3 +52,19 @@ fun NpcEntity.transferItems(target: NpcEntity, filter: ItemFilter, count: Int): fun NpcEntity.transferItems(target: Player, filter: ItemFilter, count: Int): Int = inventory.slots.transferTo(target.storageSlots(), filter, count) + +/** + * Forces an NPC to drop an item from its inventory. + * + * @param item The item to be dropped. + */ +fun NpcEntity.dropItem(item: ItemStack) { + val p = position() + val entityStack = ItemEntity(level(), p.x, p.y + eyeHeight, p.z, item) + entityStack.setDefaultPickUpDelay() + val f8 = Mth.sin(xRot * Mth.PI / 180f) + val f3 = Mth.sin(yHeadRot * Mth.PI / 180f) + val f4 = Mth.cos(yHeadRot * Mth.PI / 180f) + entityStack.setDeltaMovement(-f3 * 0.3, -f8 * 0.3 + 0.1, f4 * 0.3) + level().addFreshEntity(entityStack) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt new file mode 100644 index 000000000..8f698682a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt @@ -0,0 +1,86 @@ +package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs + +import com.mojang.brigadier.StringReader +import kotlinx.coroutines.delay +import net.minecraft.ChatFormatting +import net.minecraft.commands.arguments.item.ItemParser +import net.minecraft.core.RegistryAccess +import net.minecraft.core.component.DataComponentPatch +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.world.item.ItemStack +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.utils.colored +import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull +import ru.hollowhorizon.hollowengine.common.utils.literal +import ru.hollowhorizon.hollowengine.common.utils.plus +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +/** + * Pauses the coroutine for the specified number of ticks. + * + * @param time The number of ticks to delay. + */ +suspend fun wait(time: Int) { + delay((time * 50L).milliseconds) +} + +fun uuid(uuid: String): UUID = UUID.fromString(uuid) + +/** + * Создает ItemStack с указанным предметом, количеством и NBT-тегом. + * + * @param item Идентификатор предмета (например, "minecraft:apple"). + * @param count Количество предметов в стаке. + * @param nbt NBT-тег для предмета. + * @return ItemStack с заданными параметрами. + */ +fun item(item: String, count: Int = 1): ItemStack = item(item, count, DataComponentPatch.EMPTY) + +private val builtInRegistryAccess by lazy { + RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY) +} + +fun item(item: String, count: Int = 1, components: DataComponentPatch): ItemStack { + require(count > 0) { "Item count must be greater than zero" } + val reader = StringReader(item) + val registries = currentServerOrNull()?.registryAccess() ?: builtInRegistryAccess + val parsed = runCatching { ItemParser(registries).parse(reader) } + .getOrElse { error -> throw IllegalArgumentException("Invalid item specification '$item'", error) } + require(!reader.canRead()) { "Unexpected trailing data in item specification '$item'" } + + return ItemStack(parsed.item, count).apply { + applyComponents(parsed.components) + applyComponents(components) + } +} + +/** + * A getter variable that converts seconds to ticks. + * + * @param this The number to be converted from ticks to seconds + * @template + * ```node.kts + * 10.sec // Converts 10 seconds to ticks (10 * 20 = 200) + * ``` + */ +val Int.sec get() = this * 20 + +val Double.sec get() = (this * 20).toInt() + +/** + * Makes an NPC "say" the text by sending a message to all players on the server. + * + * @param text The text of the message. + */ +infix fun NpcEntity.say(text: String) { + server?.playerList?.players?.forEach { + it.sendSystemMessage( + "[".literal.colored(ChatFormatting.GRAY) + + name + + "]: ".literal.colored(ChatFormatting.GRAY) + + text.literal + ) + } +} + From 1341baa6fb756bc1bad8851587ec0e69368453ac Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 13:56:50 +0300 Subject: [PATCH 09/25] Share JVM scripting classes with runtime --- .../hollowengine/common/addons/HollowAddonClassLoader.kt | 2 ++ .../common/scripting/compiling/SharedScriptClasses.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt index 2096da4d9..3fa32aeb1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt @@ -35,6 +35,8 @@ internal class HollowAddonClassLoader( "kotlin.sequences.", "kotlin.script.experimental.api.", "kotlin.script.experimental.host.", + // Compiled metadata and JVM modules cross the compiler addon's boundary into the runtime evaluator. + "kotlin.script.experimental.jvm.", "kotlin.script.experimental.util.", "kotlin.text.", "kotlin.time.", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt index 0a0f4aa31..426884207 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt @@ -61,7 +61,9 @@ object SharedScriptClasses { if (script.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) return val jvmScript = script as? KJvmCompiledScript - ?: fail("Shared script '${script.sourceLocationId}' has no JVM class metadata") + ?: fail("Shared script '${script.sourceLocationId}' has no runtime-compatible JVM class metadata: " + + "actual type=${script.javaClass.name}, actual loader=${script.javaClass.classLoader}, " + + "expected loader=${KJvmCompiledScript::class.java.classLoader}") val name = jvmScript.scriptClassFQName script.otherScripts.forEach { dependency -> if (dependency.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) { From 4ff02cce6c4cd09cd2b059f7d9a97a2d52def5b0 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 14:19:58 +0300 Subject: [PATCH 10/25] Fix Kotlin addon test classpath metadata --- addons/compiler/build.gradle.kts | 6 +++--- build.gradle.kts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/addons/compiler/build.gradle.kts b/addons/compiler/build.gradle.kts index 9184dd6b7..e846df2f2 100644 --- a/addons/compiler/build.gradle.kts +++ b/addons/compiler/build.gradle.kts @@ -33,7 +33,7 @@ val runtimeProjectPath = ":runtime" val minecraftTestClasspath = project(runtimeProjectPath).configurations.named("compileClasspath").map { classpath -> classpath.filter { file -> listOf("org.jetbrains", "androidx", "io.insert-koin").none { file.path.contains(it) } } } -val narrativeTestClasses = provider { project(":addons:narrative").layout.buildDirectory.dir("classes/kotlin/main").get() } +val narrativeTestJar = provider { project(":addons:narrative").tasks.named("jar").get().archiveFile.get() } val lightTreeClasses = project.projectDir.resolve("lightTree") val patchedLightTreeJar = tasks.register("patchedLightTreeJar") { archiveBaseName.set("hollowengine-light-tree") @@ -175,7 +175,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation("io.insert-koin:koin-core:${rootProject.property("koinVersion")}") testImplementation("androidx.compose.runtime:runtime:$composeRuntimeVersion") - testImplementation(files(narrativeTestClasses) { builtBy(":addons:narrative:compileKotlin") }) + testImplementation(files(narrativeTestJar) { builtBy(":addons:narrative:jar") }) testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") testImplementation(files(minecraftTestClasspath)) testRuntimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) @@ -314,7 +314,7 @@ tasks.withType().configureEach { }) inputs.files(analysisClasspath).withPropertyName("ideAnalysisClasspath") doFirst { - val classpath = analysisClasspath.get().files + narrativeTestClasses.get().asFile + val classpath = analysisClasspath.get().files + narrativeTestJar.get().asFile systemProperty("hollowengine.ide.testClasspath", classpath.joinToString(File.pathSeparator)) } useJUnitPlatform() diff --git a/build.gradle.kts b/build.gradle.kts index 1c20bde63..63e74b9c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -116,7 +116,7 @@ fun Project.configureHollowAddon() { tasks.named("jar") { archiveClassifier.set("classes-named") - include("**/*.class") + include("**/*.class", "META-INF/*.kotlin_module") } tasks.named("remapJar") { enabled = false From 156b6335371c102e35bf2cb96cac9f54a67fd63b Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 15:50:44 +0300 Subject: [PATCH 11/25] Harden resumable stages and story runtime Add FIR diagnostics for stage and action constraints, prevent unsafe lambda captures, and preserve saved lambda result types across restarts. Improve branch lifecycle and concurrency, isolate NBT snapshots, add recoverable story saves, and handle loop overflow and coroutine cancellation safely. --- .../plugin/HollowEngineCompilerPlugin.kt | 3 + .../plugin/fir/stages/StageFirActions.kt | 76 ++++++++ .../plugin/fir/stages/StageFirBodyChecker.kt | 110 +++++++++++ .../plugin/fir/stages/StageFirCallChecker.kt | 114 ++++++++++++ .../plugin/fir/stages/StageFirExtension.kt | 26 +++ .../fir/stages/StageFirFunctionChecker.kt | 47 +++++ .../plugin/fir/stages/StageFirSignatures.kt | 32 ++++ .../common/plugin/fir/stages/StageFirTypes.kt | 58 ++++++ .../plugin/ir/stages/StageIrGeneration.kt | 18 +- .../plugin/ir/stages/StageLambdaLifter.kt | 48 ++++- .../plugin/ir/stages/StageLambdaResult.kt | 30 +++ .../common/plugin/ir/stages/StageReporter.kt | 17 ++ .../common/plugin/ir/stages/StageScope.kt | 2 + .../common/plugin/ir/stages/StageSymbols.kt | 1 + .../common/plugin/ir/stages/StageTypes.kt | 49 +---- .../{ir => }/stages/StageDiagnostics.kt | 24 +-- .../common/plugin/stages/StageMessages.kt | 22 +++ .../common/plugin/stages/StageValueTypes.kt | 44 +++++ .../src/test/kotlin/StageAnalysisTest.kt | 168 +++++++++++++++++ .../kotlin/StageCheckpointIsolationTest.kt | 31 ++++ .../src/test/kotlin/StageConcurrencyTest.kt | 172 ++++++++++++++++++ .../src/test/kotlin/StageLambdaSafetyTest.kt | 89 +++++++++ .../addons/narrative/story/StageLambda.kt | 6 +- .../addons/narrative/story/State.kt | 5 + .../addons/narrative/story/StoryApi.kt | 38 ++-- .../addons/narrative/story/StoryFrame.kt | 12 +- .../addons/narrative/story/StoryHistory.kt | 12 +- .../addons/narrative/story/StoryInstances.kt | 70 ++++--- .../addons/narrative/story/StoryJournal.kt | 8 +- .../addons/narrative/story/StoryLoops.kt | 8 +- .../addons/narrative/story/StoryOwners.kt | 16 +- .../addons/narrative/story/StoryRun.kt | 40 +++- .../addons/narrative/story/StoryStore.kt | 51 ++++++ .../addons/narrative/story/StoryFrameTest.kt | 29 ++- .../narrative/story/StoryInstancesTest.kt | 100 ++++++++++ .../addons/narrative/story/StoryLoopsTest.kt | 19 ++ .../addons/narrative/story/StoryStoreTest.kt | 34 ++++ .../hollowengine/common/fsm/StateStorage.kt | 27 +-- .../scripting/cache/ScriptFingerprint.kt | 2 +- 39 files changed, 1487 insertions(+), 171 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt rename addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/{ir => }/stages/StageDiagnostics.kt (51%) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt create mode 100644 addons/compiler/src/test/kotlin/StageAnalysisTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageConcurrencyTest.kt create mode 100644 addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt create mode 100644 addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt index 246b3d944..2d8e15d32 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt @@ -4,6 +4,8 @@ import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter +import ru.hollowhorizon.hollowengine.common.plugin.fir.stages.StageFirRegistrar import ru.hollowhorizon.hollowengine.common.plugin.ir.StateIrGenerationExtension import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinDeclarationCollector import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinIrGenerationExtension @@ -14,6 +16,7 @@ class HollowEngineCompilerPlugin : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + FirExtensionRegistrarAdapter.registerExtension(StageFirRegistrar()) IrGenerationExtension.registerExtension(StateIrGenerationExtension()) val mixins = configuration.get(MixinDeclarationCollector.KEY) ?: MixinDeclarationCollector() IrGenerationExtension.registerExtension(MixinIrGenerationExtension(mixins)) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt new file mode 100644 index 000000000..49d13bd71 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt @@ -0,0 +1,76 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isSuspend +import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.coneType +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Action arguments are persisted too; report their restrictions through the same front end as stages. */ +@OptIn(SymbolInternals::class) +internal object StageFirActions { + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirNamedFunction) { + if (!function.hasAnnotation(StageFirIds.ACTION, context.session)) return + val owner = context.containingDeclarations.lastOrNull()?.fir + val problem = when { + !function.isSuspend -> "An action must be a suspend function" + owner !is FirScript && (owner as? FirRegularClass)?.classKind != ClassKind.OBJECT -> "An action must be declared at the top level of a script or in an object" + function.typeParameters.isNotEmpty() -> "An action cannot have type parameters: the journal saves its arguments by their declared types" + function.contextParameters.isNotEmpty() -> "An action cannot take context parameters" + else -> null + } + if (problem != null) reporter.reportOn(function.source, StageDiagnostics.STAGE_ERROR, problem) + function.valueParameters.forEach { parameter -> + if (parameter.hasAnnotation(StageFirIds.UNSAVED, context.session)) { + if (parameter.defaultValue == null) reporter.reportOn( + parameter.source, + StageDiagnostics.STAGE_ERROR, + "The journal calls the action with the default of the unsaved '${parameter.name}', so it needs one" + ) + } else if (parameter.returnTypeRef.coneType.classId != StageFirIds.CHECKPOINT_SCOPE) { + parameter.returnTypeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn( + parameter.source, + StageDiagnostics.STAGE_ERROR, + "The journal saves the arguments of an action, but '${parameter.name}' cannot be saved: $it; mark it @Unsaved if undoing does not need it" + ) + } + } + } + function.receiverParameter?.let { receiver -> + if (receiver.typeRef.coneType.classId != StageFirIds.CHECKPOINT_SCOPE) { + receiver.typeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn( + receiver.source, + StageDiagnostics.STAGE_ERROR, + "The journal saves the arguments of an action, but 'this' cannot be saved: $it" + ) + } + } + } + } + + context(context: CheckerContext, reporter: DiagnosticReporter) + fun checkCall(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol.callableId?.packageName != StageFirIds.PACKAGE || symbol.name.asString() !in setOf( + "action", + "label" + ) + ) return + val owner = context.containingDeclarations.lastOrNull { it.fir is FirFunction }?.fir as? FirFunction + if (owner?.hasAnnotation(StageFirIds.ACTION, context.session) != true) reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "action { } and label { } must be the body of an @Action function, written directly in it" + ) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt new file mode 100644 index 000000000..3682c14d5 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt @@ -0,0 +1,110 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.expressions.* +import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.types.resolvedType +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +/** Structural restrictions are checked on FIR so the editor can report them before the IR rewrite. */ +@OptIn(SymbolInternals::class) +internal object StageFirBodyChecker { + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirFunction, stage: Boolean) { + val stageLambdas = HashSet() + function.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirFunctionCall) { + (element.argumentList as? FirResolvedArgumentList)?.mapping?.forEach { (argument, parameter) -> + if (parameter.returnTypeRef.coneType.isStageLambda(context.session)) { + (argument as? FirAnonymousFunctionExpression)?.anonymousFunction?.let(stageLambdas::add) + } + } + } + element.acceptChildren(this) + } + }) + val checkpoints = HashSet() + function.body?.accept(object : FirVisitorVoid() { + private var nested = 0 + private var handler = 0 + + override fun visitElement(element: FirElement) { + when (element) { + is FirAnonymousFunction if element in stageLambdas -> return + is FirFunction -> { + if (!stage) return // Named local functions receive their own declaration check. + nested++ + element.acceptChildren(this) + nested-- + } + + is FirCatch -> { + handler++ + element.acceptChildren(this) + handler-- + } + + is FirTryExpression -> { + element.tryBlock.accept(this) + element.catches.forEach { it.accept(this) } + handler++ + element.finallyBlock?.accept(this) + handler-- + } + + is FirFunctionCall -> { + checkCall(element) + element.acceptChildren(this) + } + + else -> element.acceptChildren(this) + } + } + + private fun checkCall(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + val checkpoint = + symbol.callableId?.packageName == StageFirIds.PACKAGE && symbol.name.asString() == "checkpoint" + val boundary = checkpoint || symbol.hasAnnotation( + StageFirIds.STATE, + context.session + ) || symbol.name.asString() == "invoke" && call.dispatchReceiver?.resolvedType?.isStageLambda(context.session) == true + if (!boundary) return + val problem = when { + !stage && checkpoint -> StageMessages.CHECKPOINT_OUTSIDE + !stage -> null + nested > 0 -> StageMessages.NESTED_CALL + handler > 0 -> StageMessages.HANDLER_CALL + else -> null + } + if (problem != null) reporter.reportOn(call.source, StageDiagnostics.STAGE_ERROR, problem) + if (!stage || !checkpoint) return + val name = + ((call.argumentList as? FirResolvedArgumentList)?.mapping?.entries?.firstOrNull { it.value.name.asString() == "name" }?.key as? FirLiteralExpression)?.value as? String + when { + name.isNullOrBlank() -> reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "A checkpoint needs a constant name" + ) + + !checkpoints.add(name) -> reporter.reportOn( + call.source, StageDiagnostics.STAGE_ERROR, "Checkpoint '$name' appears twice in this stage" + ) + } + } + }) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt new file mode 100644 index 000000000..d6e51f168 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -0,0 +1,114 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirFunctionCallChecker +import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirScript +import org.jetbrains.kotlin.fir.expressions.FirAnonymousFunctionExpression +import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression +import org.jetbrains.kotlin.fir.expressions.FirVariableAssignment +import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol +import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirLocalPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +@OptIn(SymbolInternals::class) +internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(expression: FirFunctionCall) { + if (context.containingDeclarations.none { it.fir is FirScript }) return + StageFirActions.checkCall(expression) + val mapping = (expression.argumentList as? FirResolvedArgumentList)?.mapping ?: return + mapping.forEach { (argument, parameter) -> + if (!parameter.returnTypeRef.coneType.isStageLambda(context.session)) return@forEach + val lambda = (argument as? FirAnonymousFunctionExpression)?.anonymousFunction + if (lambda != null) { + val arity = + lambda.valueParameters.size + lambda.contextParameters.size + if (lambda.receiverParameter != null) 1 else 0 + if (arity > 8) reporter.reportOn( + argument.source, + StageDiagnostics.STAGE_ERROR, + "A @State lambda takes at most 8 parameters, its receiver included" + ) + StageFirSignatures.check(lambda) + checkCaptures(lambda) + StageFirBodyChecker.check(lambda, stage = true) + } else { + val forwarded = + (argument as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() as? FirValueParameterSymbol + if (forwarded == null || !forwarded.resolvedReturnType.isStageLambda(context.session)) { + reporter.reportOn(argument.source, StageDiagnostics.STAGE_ERROR, StageMessages.FUNCTION_VALUE) + } + } + } + } + + context(context: CheckerContext, reporter: DiagnosticReporter) + private fun checkCaptures(lambda: FirAnonymousFunction) { + val owned = HashSet>() + lambda.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirDeclaration) owned += element.symbol + element.acceptChildren(this) + } + }) + lambda.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + when (element) { + is FirQualifiedAccessExpression -> checkAccess(element) + is FirVariableAssignment -> checkAssignment(element) + } + element.acceptChildren(this) + } + + private fun checkAccess(qualifiedAccessExpression: FirQualifiedAccessExpression) { + val symbol = qualifiedAccessExpression.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol in owned) return + val name = symbol.name.asString() + val message = when { + symbol is FirConstructorSymbol && symbol.resolvedReturnType.toRegularClassSymbol(context.session)?.fir?.isLocal == true -> StageMessages.localClosure( + "local class '${symbol.callableId.classId?.shortClassName}'" + ) + + symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> StageMessages.localClosure("local function '$name'") + symbol is FirLocalPropertySymbol && symbol.fir.isVar -> StageMessages.mutableCapture(name) + symbol is FirLocalPropertySymbol || symbol is FirValueParameterSymbol -> symbol.resolvedReturnType.stageProblem( + context.session + )?.let { StageMessages.unsavedCapture(name, it) } + + else -> null + } + if (message != null) reporter.reportOn( + qualifiedAccessExpression.source, StageDiagnostics.STAGE_ERROR, message + ) + } + + private fun checkAssignment(variableAssignment: FirVariableAssignment) { + val symbol = + (variableAssignment.lValue as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() + if (symbol != null && symbol !in owned && symbol is FirLocalPropertySymbol) { + reporter.reportOn( + variableAssignment.source, + StageDiagnostics.STAGE_ERROR, + StageMessages.assignCapture(symbol.name.asString()) + ) + } + } + }) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt new file mode 100644 index 000000000..8670d0ebb --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt @@ -0,0 +1,26 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.DeclarationCheckers +import org.jetbrains.kotlin.fir.analysis.checkers.expression.ExpressionCheckers +import org.jetbrains.kotlin.fir.analysis.extensions.FirAdditionalCheckersExtension +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** The same checkers run during compilation and standalone IDE analysis, without generating bytecode. */ +internal class StageFirRegistrar : FirExtensionRegistrar() { + override fun ExtensionRegistrarContext.configurePlugin() { + +::StageFirExtension + registerDiagnosticContainers(StageDiagnostics) + } +} + +private class StageFirExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { + override val expressionCheckers = object : ExpressionCheckers() { + override val functionCallCheckers = setOf(StageFirCallChecker) + } + + override val declarationCheckers = object : DeclarationCheckers() { + override val simpleFunctionCheckers = setOf(StageFirFunctionChecker) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt new file mode 100644 index 000000000..a3d565bb4 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt @@ -0,0 +1,47 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirSimpleFunctionChecker +import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isSuspend +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +@OptIn(SymbolInternals::class, DirectDeclarationsAccess::class) +internal object StageFirFunctionChecker : FirSimpleFunctionChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(declaration: FirNamedFunction) { + if (context.containingDeclarations.none { it.fir is FirScript }) return + StageFirActions.check(declaration) + val stage = declaration.hasAnnotation(StageFirIds.STATE, context.session) + StageFirBodyChecker.check(declaration, stage) + if (!stage) return + val owner = context.containingDeclarations.lastOrNull()?.fir as? FirScript + fun stageName(function: FirNamedFunction): String = + function.getAnnotationByClassId(StageFirIds.STATE, context.session) + ?.getStringArgument(Name.identifier("name"))?.takeIf(String::isNotBlank) ?: function.name.asString() + + val name = stageName(declaration) + val first = owner?.declarations?.filterIsInstance()?.firstOrNull { + it.hasAnnotation(StageFirIds.STATE, context.session) && stageName(it) == name + } + if (first != null && first !== declaration) reporter.reportOn( + declaration.source, + StageDiagnostics.STAGE_ERROR, + "Two stages of this script are named '$name'; give one of them different @State(name = ...)" + ) + val problem = when { + context.containingDeclarations.lastOrNull()?.fir !is FirScript -> StageMessages.TOP_LEVEL + !declaration.isSuspend -> StageMessages.SUSPEND + declaration.typeParameters.isNotEmpty() -> StageMessages.TYPE_PARAMETERS + else -> null + } + if (problem != null) reporter.reportOn(declaration.source, StageDiagnostics.STAGE_ERROR, problem) + StageFirSignatures.check(declaration) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt new file mode 100644 index 000000000..3f335db6e --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt @@ -0,0 +1,32 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.types.isUnit +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Named stages and lifted lambdas persist the same arguments and result. */ +internal object StageFirSignatures { + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirFunction) { + (function.contextParameters + function.valueParameters).forEach { parameter -> + parameter.returnTypeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn(parameter.source, StageDiagnostics.STAGE_ERROR, + "The arguments of a stage are saved, but '${parameter.name}' cannot be: $it") + } + } + function.receiverParameter?.let { receiver -> + receiver.typeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn(receiver.source, StageDiagnostics.STAGE_ERROR, + "The arguments of a stage are saved, but 'this' cannot be: $it") + } + } + val result = function.returnTypeRef.coneType + if (!result.isUnit) result.stageProblem(context.session)?.let { + reporter.reportOn(function.source, StageDiagnostics.STAGE_ERROR, "The result of a stage is saved, but $it") + } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt new file mode 100644 index 000000000..6f3de2c94 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt @@ -0,0 +1,58 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe +import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.ConeErrorType +import org.jetbrains.kotlin.fir.types.ConeKotlinType +import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjection +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.customAnnotations +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageValueTypes + +internal object StageFirIds { + val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val STATE = ClassId.topLevel(PACKAGE.child(Name.identifier("State"))) + val ACTION = ClassId.topLevel(PACKAGE.child(Name.identifier("Action"))) + val UNSAVED = ClassId.topLevel(PACKAGE.child(Name.identifier("Unsaved"))) + val CHECKPOINT_SCOPE = ClassId.topLevel(PACKAGE.child(Name.identifier("CheckpointScope"))) +} + +internal fun ConeKotlinType.isStageLambda(session: FirSession): Boolean = + customAnnotations.any { it.toAnnotationClassIdSafe(session) == StageFirIds.STATE } || + classId?.asSingleFqName()?.asString().orEmpty().startsWith(StageValueTypes.STAGE_LAMBDA) + +/** Type policy agrees with the IR checker; error types are left to Kotlin's own diagnostics. */ +@OptIn(SymbolInternals::class) +internal fun ConeKotlinType.stageProblem(session: FirSession): String? { + if (this is ConeErrorType || isStageLambda(session)) return null + val klass = toRegularClassSymbol(session) ?: return "$this is a type parameter, which cannot be saved" + val name = klass.classId.asSingleFqName().asString() + if (name in StageValueTypes.BASIC || name in StageValueTypes.PRIMITIVE_ARRAYS) return null + fun inherits(type: ConeKotlinType, visited: MutableSet = HashSet()): Boolean { + val symbol = type.toRegularClassSymbol(session) ?: return false + if (!visited.add(symbol.classId)) return false + val qualified = symbol.classId.asSingleFqName().asString() + if (qualified == "net.minecraft.world.entity.Entity" || qualified == "net.minecraft.nbt.Tag") return true + return symbol.resolvedSuperTypes.any { inherits(it, visited) } + } + if (inherits(this)) return null + if (name == "kotlin.Array" || name in StageValueTypes.CONTAINERS || name in StageValueTypes.CONTEXTUAL || + klass.classKind == ClassKind.ENUM_CLASS || klass.hasAnnotation(StageValueTypes.SERIALIZABLE_ID, session) + ) { + return typeArguments.firstNotNullOfOrNull { + (it as? ConeKotlinTypeProjection)?.type?.stageProblem(session) ?: if (it is ConeKotlinTypeProjection) null + else "a star projection cannot be saved" + } + } + if (name.startsWith("kotlin.Function") || name.startsWith("kotlin.coroutines.SuspendFunction") || name.startsWith("kotlin.reflect.KFunction")) { + return "a function value cannot be saved; pass a lambda to a @State parameter instead" + } + return "$this cannot be saved: it is not @Serializable" +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt index 83e66d033..c9bf623bb 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.ir.util.isAnnotation import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid import org.jetbrains.kotlin.ir.visitors.acceptVoid +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages /** * Compiles stages of every script in a module: marks script classes so the runtime can find their @@ -98,14 +99,17 @@ internal class StageIrGeneration(private val context: IrPluginContext) { StageRewriter(stages, function, layout).apply { rewrite(statements, name, hash) script.declarations += entry(name) - captures[function]?.let { script.declarations += lambdaCall(name, it) } + captures[function]?.let { + script.declarations += lambdaCall(name, it) + script.declarations += stages.lambdaResult(function, name) + } } } private fun isValid(function: IrSimpleFunction): Boolean { val problem = when { function.parent != script -> "A stage must be declared at the top level of a script" - function.typeParameters.isNotEmpty() -> "A stage cannot have type parameters: its arguments are saved by their declared types" + function.typeParameters.isNotEmpty() -> StageMessages.TYPE_PARAMETERS else -> null } problem?.let { reporter.error(function, it); return false } @@ -166,12 +170,12 @@ internal class StageIrGeneration(private val context: IrPluginContext) { when { nested > 0 -> reporter.error( expression, - "A stage cannot be called from a lambda inside a stage: its place could not be saved. Use async(name) { } or call it directly" + StageMessages.NESTED_CALL ) handler > 0 -> reporter.error( expression, - "A stage cannot be called in catch or finally: keep what you need in a variable and call it after the try" + StageMessages.HANDLER_CALL ) } } @@ -187,10 +191,10 @@ internal class StageIrGeneration(private val context: IrPluginContext) { override fun visitFunction(declaration: IrFunction) { val marked = declaration.annotations.any { it.isAnnotation(StageSymbols.STATE) } if (marked && declaration.parent != script) reporter.error( - declaration, "A state function must be declared at the top level of a script" + declaration, StageMessages.TOP_LEVEL ) else if (marked && !stages.isSuspendState(declaration)) reporter.error( - declaration, "A state function must be a suspend function" + declaration, StageMessages.SUSPEND ) super.visitFunction(declaration) } @@ -198,7 +202,7 @@ internal class StageIrGeneration(private val context: IrPluginContext) { override fun visitCall(expression: IrCall) { super.visitCall(expression) if (stages.symbols.isCheckpoint(expression.symbol.owner)) { - reporter.error(expression, "checkpoint() must be called directly in a @State function") + reporter.error(expression, StageMessages.CHECKPOINT_OUTSIDE) } } }) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt index 10f8389a3..4a3a270a4 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -18,6 +18,7 @@ import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages import java.util.Collections import java.util.IdentityHashMap @@ -61,7 +62,18 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran val callee = expression.symbol.owner callee.parameters.forEach { parameter -> if (!stages.types.isStageLambda(parameter.type)) return@forEach - val lambda = expression.arguments[parameter] as? IrFunctionExpression ?: return@forEach + val argument = expression.arguments[parameter] ?: return@forEach + val lambda = argument as? IrFunctionExpression + if (lambda == null) { + val forwarded = (argument as? IrGetValue)?.symbol?.owner as? IrValueParameter + if (forwarded == null || !stages.types.isStageLambda(forwarded.type)) { + stages.reporter.error( + argument, + StageMessages.FUNCTION_VALUE + ) + } + return@forEach + } lift(lambda, callee.name.asString(), parameter)?.let { expression.arguments[parameter] = it } } return expression @@ -114,6 +126,34 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran lambda.acceptChildrenVoid(object : IrVisitorVoid() { override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + private fun checkLocal(declaration: IrDeclaration, expression: IrElement, description: String) { + val parents = generateSequence(declaration.parent as? IrDeclaration) { it.parent as? IrDeclaration }.toList() + if (parents.any { it === lambda } || parents.none { it is IrFunction }) return + stages.reporter.error( + expression, + StageMessages.localClosure(description) + ) + valid = false + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val function = expression.symbol.owner + checkLocal(function, expression, "local function '${function.name}'") + } + + override fun visitFunctionReference(expression: IrFunctionReference) { + super.visitFunctionReference(expression) + val function = expression.symbol.owner + checkLocal(function, expression, "local function '${function.name}'") + } + + override fun visitConstructorCall(expression: IrConstructorCall) { + super.visitConstructorCall(expression) + val klass = expression.symbol.owner.parent as IrClass + checkLocal(klass, expression, "local class '${klass.name}'") + } + override fun visitGetValue(expression: IrGetValue) { val value = expression.symbol.owner if (value !in inside && !stages.isScriptThis(value)) captured += value @@ -124,7 +164,7 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran if (expression.symbol.owner !in inside) { stages.reporter.error( expression, - "A @State lambda cannot assign '${expression.symbol.owner.name}': it keeps a saved copy" + StageMessages.assignCapture(expression.symbol.owner.name.asString()) ) valid = false } @@ -133,13 +173,13 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran captured.forEach { value -> if (value is IrVariable && value.isVar) { stages.reporter.error( - lambda, "A @State lambda cannot capture 'var ${value.name}'; copy it into a val first" + lambda, StageMessages.mutableCapture(value.name.asString()) ) valid = false } stages.types.problem(value.type)?.let { problem -> stages.reporter.error( - lambda, "A @State lambda saves what it captures, but '${value.name}' cannot be saved: $problem" + lambda, StageMessages.unsavedCapture(value.name.asString(), problem) ) valid = false } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt new file mode 100644 index 000000000..90d7d94be --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt @@ -0,0 +1,30 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.irBlockBody +import org.jetbrains.kotlin.ir.builders.irReturn +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.name.Name + +/** A saved lambda refers to this generated factory to recover its result serializer after a restart. */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun StageScope.lambdaResult(function: IrSimpleFunction, name: String): IrSimpleFunction { + val factory = context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier("stage\$result\$${function.name.asString()}") + returnType = symbols.serializerType + visibility = DescriptorVisibilities.PUBLIC + } + factory.parent = script + factory.parameters = listOf(factory.buildReceiverParameter { type = script.defaultType }) + factory.annotations = listOf(resultAnnotation(name)) + factory.body = builder(factory.symbol, function).irBlockBody { + +irReturn(serializer(function.returnType)) + } + return factory +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt new file mode 100644 index 000000000..a92b866e9 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt @@ -0,0 +1,17 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrFile +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Reports stage errors against the file being compiled and remembers whether there were any. */ +internal class StageReporter(private val context: IrPluginContext, private val file: IrFile) { + var errors = 0 + private set + + fun error(element: IrElement, message: String) { + errors++ + context.diagnosticReporter.at(element, file).report(StageDiagnostics.STAGE_ERROR, message) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt index ccaa35428..7c95f223e 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -90,6 +90,8 @@ internal class StageScope( fun callAnnotation(name: String): IrAnnotation = annotation(symbols.stageCall, name) + fun resultAnnotation(name: String): IrAnnotation = annotation(symbols.stageResult, name) + fun scriptAnnotation(path: String): IrAnnotation = annotation(symbols.stageScript, path) private fun annotation(constructor: IrConstructorSymbol, value: String): IrAnnotation = diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt index a110259a0..08cb67fc4 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -36,6 +36,7 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val stageLambda: IrClassSymbol = storyClass("StageLambda") val stageEntry: IrConstructorSymbol = storyClass("StageEntry").constructors.single() val stageCall: IrConstructorSymbol = storyClass("StageCall").constructors.single() + val stageResult: IrConstructorSymbol = storyClass("StageResult").constructors.single() val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() val stateConstructor: IrConstructorSymbol = state.constructors.single() val checkpoints: Set = storyFunctions("checkpoint") diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt index 843831d94..7746942d0 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -12,7 +12,7 @@ import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.util.* import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName -import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageValueTypes /** * Says which values a story can keep. It mirrors what `StoryValues` writes at runtime: whatever kotlinx @@ -32,12 +32,12 @@ internal class StageTypes(context: IrPluginContext) { if (entity != null && type.isSubtypeOfClass(entity)) return null if (tag != null && type.isSubtypeOfClass(tag)) return null val name = klass.kotlinFqName.asString() - if (name in BASIC) return null + if (name in StageValueTypes.BASIC) return null if (isFunction(type)) { return "a function value cannot be saved; pass a lambda to a @State parameter instead" } if (klass.isPrimitiveArray()) return null - if (name == "kotlin.Array" || name in CONTAINERS || klass.isSerializable() || name in CONTEXTUAL) { + if (name == "kotlin.Array" || name in StageValueTypes.CONTAINERS || klass.isSerializable() || name in StageValueTypes.CONTEXTUAL) { return simple.arguments.firstNotNullOfOrNull { argument -> (argument as? IrTypeProjection)?.type?.let(::problem) ?: if (argument is IrTypeProjection) null else "a star projection cannot be saved" @@ -53,46 +53,9 @@ internal class StageTypes(context: IrPluginContext) { /** Whether [type] is a function type marked `@State`, whose values are stage lambdas. */ fun isStageLambda(type: IrType): Boolean = type.annotations.any { it.isAnnotation(StageSymbols.STATE) } || - type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().startsWith(STAGE_LAMBDA) + type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().startsWith(StageValueTypes.STAGE_LAMBDA) - private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(SERIALIZABLE) + private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(StageValueTypes.SERIALIZABLE) - private fun IrClass.isPrimitiveArray(): Boolean = kotlinFqName.asString() in PRIMITIVE_ARRAYS - - private companion object { - val SERIALIZABLE = FqName("kotlinx.serialization.Serializable") - val STAGE_LAMBDA = StageSymbols.PACKAGE.child(Name.identifier("StageLambda")).asString() - - val PRIMITIVE_ARRAYS = setOf( - "kotlin.IntArray", "kotlin.LongArray", "kotlin.ShortArray", "kotlin.ByteArray", "kotlin.FloatArray", - "kotlin.DoubleArray", "kotlin.BooleanArray", "kotlin.CharArray", - ) - - val BASIC = setOf( - "kotlin.Int", "kotlin.Long", "kotlin.Short", "kotlin.Byte", "kotlin.Float", "kotlin.Double", - "kotlin.Boolean", "kotlin.Char", "kotlin.String", "kotlin.Unit", - ) - - val CONTAINERS = setOf( - "kotlin.collections.List", "kotlin.collections.MutableList", "kotlin.collections.Collection", - "kotlin.collections.Set", "kotlin.collections.MutableSet", - "kotlin.collections.Map", "kotlin.collections.MutableMap", - "kotlin.collections.Map.Entry", "kotlin.collections.MutableMap.MutableEntry", - "kotlin.Pair", "kotlin.Triple", - ) - - /** Classes the engine's NBT format writes without them being `@Serializable`. */ - val CONTEXTUAL = setOf( - "java.util.UUID", - "net.minecraft.core.BlockPos", - "net.minecraft.resources.ResourceLocation", - "net.minecraft.world.phys.Vec3", - "net.minecraft.network.chat.Component", - "net.minecraft.world.item.ItemStack", - "org.joml.Matrix4f", - "org.joml.Vector3d", - "org.joml.Vector3f", - "ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef", - ) - } + private fun IrClass.isPrimitiveArray(): Boolean = kotlinFqName.asString() in StageValueTypes.PRIMITIVE_ARRAYS } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt similarity index 51% rename from addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt rename to addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt index 9f0f05d3f..b201bc63a 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageDiagnostics.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt @@ -1,14 +1,15 @@ -package ru.hollowhorizon.hollowengine.common.plugin.ir.stages +package ru.hollowhorizon.hollowengine.common.plugin.stages import com.intellij.psi.PsiElement -import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext -import org.jetbrains.kotlin.diagnostics.* +import org.jetbrains.kotlin.diagnostics.KtDiagnosticFactory1 +import org.jetbrains.kotlin.diagnostics.KtDiagnosticFactoryToRendererMap +import org.jetbrains.kotlin.diagnostics.KtDiagnosticsContainer +import org.jetbrains.kotlin.diagnostics.Severity +import org.jetbrains.kotlin.diagnostics.SourceElementPositioningStrategies import org.jetbrains.kotlin.diagnostics.rendering.BaseDiagnosticRendererFactory import org.jetbrains.kotlin.diagnostics.rendering.CommonRenderers -import org.jetbrains.kotlin.ir.IrElement -import org.jetbrains.kotlin.ir.declarations.IrFile -/** The errors the stage compiler reports: misplaced stage calls, values a story cannot save. */ +/** Diagnostic factories shared by Kotlin's front end, the built-in IDE, and IR validation. */ internal object StageDiagnostics : KtDiagnosticsContainer() { val STAGE_ERROR = KtDiagnosticFactory1( "HOLLOWENGINE_STAGE_ERROR", @@ -26,14 +27,3 @@ internal object StageDiagnostics : KtDiagnosticsContainer() { } } } - -/** Reports stage errors against the file being compiled and remembers whether there were any. */ -internal class StageReporter(private val context: IrPluginContext, private val file: IrFile) { - var errors = 0 - private set - - fun error(element: IrElement, message: String) { - errors++ - context.diagnosticReporter.at(element, file).report(StageDiagnostics.STAGE_ERROR, message) - } -} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt new file mode 100644 index 000000000..7a741db31 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +/** Messages shared by front-end validation and the IR safety checks. */ +internal object StageMessages { + const val FUNCTION_VALUE = "A @State parameter needs a lambda written in place or another @State parameter; a regular function value has no saved stage" + const val NESTED_CALL = "A stage cannot be called from a lambda inside a stage: its place could not be saved. Use async(name) { } or call it directly" + const val HANDLER_CALL = "A stage cannot be called in catch or finally: keep what you need in a variable and call it after the try" + const val CHECKPOINT_OUTSIDE = "checkpoint() must be called directly in a @State function" + const val TOP_LEVEL = "A state function must be declared at the top level of a script" + const val SUSPEND = "A state function must be a suspend function" + const val TYPE_PARAMETERS = "A stage cannot have type parameters: its arguments are saved by their declared types" + + fun localClosure(description: String) = + "A @State lambda cannot use $description declared outside it: its closure is not saved. Move it to the top level of the script and pass saved values as explicit arguments, or declare it inside the lambda" + + fun mutableCapture(name: String) = "A @State lambda cannot capture 'var $name'; copy it into a val first" + + fun assignCapture(name: String) = "A @State lambda cannot assign '$name': it keeps a saved copy" + + fun unsavedCapture(name: String, problem: String) = + "A @State lambda saves what it captures, but '$name' cannot be saved: $problem" +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt new file mode 100644 index 000000000..4712d7b99 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt @@ -0,0 +1,44 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** Value categories shared by FIR validation and IR persistence analysis. */ +internal object StageValueTypes { + val SERIALIZABLE = FqName("kotlinx.serialization.Serializable") + val SERIALIZABLE_ID = ClassId.topLevel(SERIALIZABLE) + val STAGE_LAMBDA = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StageLambda")).asString() + + val PRIMITIVE_ARRAYS = setOf( + "kotlin.IntArray", "kotlin.LongArray", "kotlin.ShortArray", "kotlin.ByteArray", "kotlin.FloatArray", + "kotlin.DoubleArray", "kotlin.BooleanArray", "kotlin.CharArray", + ) + + val BASIC = setOf( + "kotlin.Int", "kotlin.Long", "kotlin.Short", "kotlin.Byte", "kotlin.Float", "kotlin.Double", + "kotlin.Boolean", "kotlin.Char", "kotlin.String", "kotlin.Unit", + ) + + val CONTAINERS = setOf( + "kotlin.collections.List", "kotlin.collections.MutableList", "kotlin.collections.Collection", + "kotlin.collections.Set", "kotlin.collections.MutableSet", + "kotlin.collections.Map", "kotlin.collections.MutableMap", + "kotlin.collections.Map.Entry", "kotlin.collections.MutableMap.MutableEntry", + "kotlin.Pair", "kotlin.Triple", + ) + + /** Classes the engine's NBT format writes without them being `@Serializable`. */ + val CONTEXTUAL = setOf( + "java.util.UUID", + "net.minecraft.core.BlockPos", + "net.minecraft.resources.ResourceLocation", + "net.minecraft.world.phys.Vec3", + "net.minecraft.network.chat.Component", + "net.minecraft.world.item.ItemStack", + "org.joml.Matrix4f", + "org.joml.Vector3d", + "org.joml.Vector3f", + "ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef", + ) +} diff --git a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt new file mode 100644 index 000000000..d129c9833 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -0,0 +1,168 @@ +import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Narrative constraints must be visible before code generation in the built-in IDE. */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StageAnalysisTest { + private lateinit var compiler: StageCompiler + + @BeforeAll + fun setUp() { compiler = StageCompiler() } + + @AfterAll + fun close() = compiler.close() + private fun diagnostics(source: String): List = + compiler.environment.analyzer.diagnostic("editing.stage.kts", source.trimIndent()) + + @Test + fun `the editor locates an enclosing local helper at its call`() { + val source = """ + @State + suspend fun main() { + var timer: StoryHandle<*>? = null + suspend fun playSing(name: String) { timer?.cancel() } + listOf("left", "right").forEach { direction -> + async("input_" + direction) { playSing(direction) } + } + await("end") + } + """.trimIndent() + val errors = diagnostics(source).filter { it.severity.isError() } + val error = errors.firstOrNull { it.message.contains("local function 'playSing'") } + assertTrue(error != null, errors.toString()) + assertEquals(5, error.range.start.line) + assertTrue(error.message.contains("top level")) + } + + @Test + fun `the editor reports mutable and unserializable captures`() { + val errors = diagnostics(""" + fun start(runs: StoryRuns) { + var counter = 0 + val lock = Any() + runs.launch("main") { log(counter.toString()); log(lock.toString()) } + } + """) + assertTrue(errors.any { it.message.contains("cannot capture 'var counter'") }, errors.toString()) + assertTrue(errors.any { it.message.contains("'lock' cannot be saved") }, errors.toString()) + } + + @Test + fun `the editor validates stage declarations and argument types`() { + val errors = diagnostics(""" + @State fun plain() {} + @State suspend fun generic(value: T) {} + @State suspend fun unsafe(lock: Any): Any = lock + fun outer() { @State suspend fun local() {} } + """) + listOf("must be a suspend function", "cannot have type parameters", "'lock' cannot be", + "The result of a stage is saved", "top level").forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `safe helpers and captures do not receive narrative errors`() { + val errors = diagnostics(""" + import ru.hollowhorizon.hollowengine.addons.narrative.story.State as Stage + + fun greet(name: String) = log(name) + @Stage suspend fun main() { + val name = "left" + async("input") { + fun sing() = greet(name) + sing() + await("end") + } + await("end") + } + """).filter { it.severity.isError() } + assertTrue(errors.isEmpty(), errors.toString()) + } + + @Test + fun `the editor reports stage calls in lambdas and exception handlers`() { + val errors = diagnostics(""" + @State suspend fun step() {} + @State suspend fun main() { + listOf(1).forEach { step() } + try { log("try") } catch (failure: Exception) { step() } finally { step() } + } + """) + assertTrue(errors.any { "cannot be called from a lambda" in it.message }, errors.toString()) + assertEquals(2, errors.count { "cannot be called in catch or finally" in it.message }, errors.toString()) + } + + @Test + fun `the editor validates checkpoint placement and names`() { + val errors = diagnostics(""" + suspend fun helper() { checkpoint("outside") } + @State suspend fun main() { + checkpoint("gate") + checkpoint("gate") + checkpoint(StageTrace.token) + } + """) + listOf("must be called directly", "appears twice", "needs a constant name").forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `the editor validates actions and duplicate stage names`() { + val errors = diagnostics(""" + @State("same") suspend fun first() {} + @State("same") suspend fun second() {} + suspend fun invalid() = action { perform { } } + @Action suspend fun unsafe(lock: Any) = action { perform { } } + @Action suspend fun missing(@Unsaved lock: Any) = action { perform { } } + """) + listOf("Two stages", "must be the body of an @Action", "'lock' cannot be saved", "needs one").forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `the editor rejects function references and stored ordinary lambdas`() { + val errors = diagnostics(""" + suspend fun helper() {} + fun start(runs: StoryRuns) { + val body: @State suspend () -> Unit = {} + runs.launch("one", ::helper) + runs.launch("two", body) + } + """) + assertEquals(2, errors.count { "a regular function value has no saved stage" in it.message }, errors.toString()) + } + + @Test + fun `lambda arguments and results have the same persistence restrictions as named stages`() { + val errors = diagnostics(""" + fun consume(block: @State suspend (Any) -> Unit) {} + fun produce(block: @State suspend () -> Any) {} + fun start() { + consume { lock -> log(lock.toString()) } + produce { Any() } + } + """) + assertTrue(errors.any { "'lock' cannot be" in it.message }, errors.toString()) + assertTrue(errors.any { "The result of a stage is saved" in it.message }, errors.toString()) + } + + @Test + fun `unfinished stage code produces diagnostics without breaking editor analysis`() { + val errors = diagnostics(""" + @State suspend fun main() { + async("timer") { + val captured = + } + } + """) + assertTrue(errors.any { it.severity.isError() }, errors.toString()) + } +} diff --git a/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt b/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt new file mode 100644 index 000000000..c87774886 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt @@ -0,0 +1,31 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StageCheckpointIsolationTest : StageTest() { + @Test + fun `repeated rewinds restore the original locals of an earlier checkpoint`() = runTest { + val world = world("checkpointLocals", """ + @State suspend fun main() { + var value = 10 + checkpoint("start") + log("value " + value) + value++ + checkpoint("after") + log("after " + value) + await("end") + } + """) + var story = world.launch("main") + assertEquals(listOf("value 10", "after 11"), StageTrace.take()) + repeat(2) { + story.rewind("start") + world.settle() + assertEquals(listOf("value 10", "after 11"), StageTrace.take()) + world.restart() + story = world.runs["main"]!! + assertEquals(listOf("after 11"), StageTrace.take()) + } + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt b/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt new file mode 100644 index 000000000..2a034726d --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt @@ -0,0 +1,172 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Branch cancellation and composition must preserve the lifetime and identity of each run. */ +class StageConcurrencyTest : StageTest() { + @Test + fun `a cancelled branch can be replaced under the same name before cancellation finishes`() = runTest { + val world = world("replaceBranch", """ + @State + suspend fun main() { + val first = async("timer") { + try { await("never") } finally { log("cancelled") } + } + await("replace") + first.cancel() + val second = async("timer") { log("replacement"); await("timer") } + second.join() + log("done") + } + """) + world.launch("main") + world.release("replace") + assertEquals(listOf("cancelled", "replacement"), StageTrace.take()) + world.release("timer") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `any waits for the requested number and only then cancels the remaining branches`() = runTest { + val world = world("anyBranches", """ + @State + suspend fun main() { + val a = async("a") { await("a"); log("a") } + val b = async("b") { await("b"); log("b") } + val c = async("c") { + try { await("c"); log("c") } finally { log("close c") } + } + val ended = any(2, a, b, c) + log("winners " + ended.joinToString { it.key }) + await("end") + } + """) + world.launch("main") + world.release("a") + assertEquals(listOf("a"), StageTrace.take()) + world.release("b") + assertEquals(listOf("b", "winners main/a, main/b", "close c"), StageTrace.take()) + world.stop() + } + + @Test + fun `any counts branches that have already ended`() = runTest { + val world = world("alreadyEnded", """ + @State + suspend fun main() { + val a = async("a") { log("a") } + val b = async("b") { log("b") } + a.join() + b.join() + log("ended " + any(2, a, b).size) + } + """) + world.launch("main") + assertEquals(listOf("a", "b", "ended 2"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty()) + world.stop() + } + + @Test + fun `race rejects an empty set instead of suspending forever`() = runTest { + val world = world("emptyRace", """ + @State + suspend fun main() { + try { race() } catch (failure: IllegalArgumentException) { log("empty") } + } + """) + world.launch("main") + assertEquals(listOf("empty"), StageTrace.take()) + world.stop() + } + + @Test + fun `a saved stage lambda keeps its result type when used to start a branch`() = runTest { + val world = world("savedLambdaResult", """ + @State + suspend fun consume(body: @State suspend () -> Int) { + checkpoint("ready") + await("go") + val branch = async("value", body) + log("value " + branch.await()) + await("end") + } + + @State suspend fun main() { consume { 42 } } + """) + world.launch("main") + world.restart() + world.release("go") + assertEquals(listOf("value 42"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch name cannot be taken from another active stage`() = runTest { + val world = world("branchOwnership", """ + @State suspend fun inner() { + try { async("ambient") { await("never") } } + catch (failure: StoryException) { log("conflict " + failure.message) } + } + @State suspend fun main() { + async("ambient") { await("never") } + inner() + await("end") + } + """) + world.launch("main") + assertTrue(StageTrace.take().single().contains("belongs to another active stage")) + world.stop() + } + + @Test + fun `cancelling a completed timer allows the next timer to start`() = runTest { + val world = world("completedTimer", """ + @State suspend fun main() { + val first = async("timer") { log("first") } + first.join() + first.cancel() + val second = async("timer") { log("second") } + second.join() + log("done") + } + """) + world.launch("main") + assertEquals(listOf("first", "second", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch cancelled before it starts still completes join`() = runTest { + val world = world("cancelBeforeStart", """ + @State suspend fun main() { + val branch = async("timer") { log("unexpected"); await("never") } + branch.cancel() + branch.join() + log("stopped") + } + """) + world.launch("main") + assertEquals(listOf("stopped"), StageTrace.take()) + world.stop() + } + + @Test + fun `any validates distinct runs and zero cancels every supplied run`() = runTest { + val world = world("anyLimits", """ + @State suspend fun main() { + val branch = async("timer") { await("never") } + try { any(2, branch, branch) } + catch (failure: IllegalArgumentException) { log("duplicate") } + log("count " + any(0, branch).size) + branch.join() + log("active " + branch.isActive) + } + """) + world.launch("main") + assertEquals(listOf("duplicate", "count 0", "active false"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt new file mode 100644 index 000000000..4b39120d7 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt @@ -0,0 +1,89 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertEquals + +/** A moved stage must not retain hidden closures into the function that created it. */ +class StageLambdaSafetyTest : StageTest() { + @Test + fun `a branch cannot call an enclosing local function with hidden mutable captures`() { + val errors = compiler.errors(""" + @State + suspend fun main() { + var timer: StoryHandle<*>? = null + suspend fun playSing(name: String) { + timer?.cancel() + timer = async("resetToIdle") { await(name) } + } + listOf("left", "right").forEach { direction -> + async("input_" + direction) { playSing(direction) } + } + await("end") + } + """) + assertContains(errors, "local function 'playSing'") + assertContains(errors, "top level") + assertFalse(errors.contains("Exception while generating code"), errors) + } + + @Test + fun `an enclosing local function reference has the same diagnostic as a call`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns, name: String) { + fun greet() = log(name) + runs.launch("main") { listOf(1).forEach { ::greet.invoke() } } + } + """) + assertContains(errors, "local function 'greet'") + assertFalse(errors.contains("Exception while generating code"), errors) + } + + @Test + fun `ordinary function values cannot masquerade as generated stage lambdas`() { + val errors = compiler.errors(""" + suspend fun helper() {} + fun start(runs: StoryRuns) { runs.launch("main", ::helper) } + """) + assertContains(errors, "a regular function value has no saved stage") + val stored = compiler.errors(""" + fun start(runs: StoryRuns) { + val body: @State suspend () -> Unit = { log("hello") } + runs.launch("main", body) + } + """) + assertContains(stored, "a regular function value has no saved stage") + } + + @Test + fun `a branch cannot construct a local class declared outside it`() { + assertContains(compiler.errors(""" + fun start(runs: StoryRuns, text: String) { + class Greeting { fun show() = log(text) } + runs.launch("main") { Greeting().show() } + } + """), "local class 'Greeting'") + } + + @Test + fun `a branch can declare its own local functions and call top level helpers`() = runTest { + val world = world("localHelpers", """ + fun greet(name: String) = log(name) + + @State + suspend fun main() { + val name = "left" + async("input") { + fun sing() = greet(name) + sing() + await("end") + } + await("end") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("left", "left"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt index 1a0b407f4..5e4c8e7b8 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt @@ -16,15 +16,17 @@ import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT * It is called like the lambda it replaced, and it can be saved like any other value. [StageLambda0] to * [StageLambda8] are its forms for each number of parameters. * - * @param result reads the value the stage returns; only known where the lambda was written. + * @param resultSerializer reads the stage result; recovered through its generated factory after a restart. */ abstract class StageLambda( val instance: String, val stage: String, val args: CompoundTag, - internal val result: KSerializer?, + private val resultSerializer: KSerializer?, val arity: Int, ) { + internal val result: KSerializer get() = resultSerializer ?: StoryInstances.resultSerializer(instance, stage) + /** Calls the stage with [parameters] after the captured arguments. */ protected suspend fun call(vararg parameters: Any?): Any? = StoryInstances.call(instance, stage, args, parameters) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt index 35854cbd4..baaa42798 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -47,6 +47,11 @@ annotation class StageEntry(val name: String) @Retention(AnnotationRetention.RUNTIME) annotation class StageCall(val name: String) +/** Names a generated serializer factory for the result of a saved stage lambda. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageResult(val name: String) + /** Marks a script class that declares stages with the source [path] its stages are addressed by. */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt index bcb79da90..900f516c9 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -67,6 +67,7 @@ val context: StoryContext /** * Starts the branch [name] of the current story and returns its handle. The branch is saved with the story and * ends when the stage that started it returns. After a restart the same call returns the branch already running. + * Cancel its handle before reusing the name for a new branch; cancellation finishes before the replacement starts. */ suspend fun async(name: String, block: @State suspend () -> T): StoryHandle { val run = currentRun("async(\"$name\")") @@ -76,12 +77,12 @@ suspend fun async(name: String, block: @State suspend () -> T): StoryHandle< /** A run of a story or of one of its branches. */ class StoryHandle internal constructor( - private val run: StoryRun, + internal val run: StoryRun, private val result: KSerializer?, ) { val key: String get() = run.path - val isActive: Boolean get() = !run.state.done + val isActive: Boolean get() = !run.state.done && !run.isStopped /** Where the run is: its stages and their positions. */ val state: String get() = run.describe() @@ -102,6 +103,7 @@ class StoryHandle internal constructor( run.awaitDone() } + /** Stops this run and releases a branch name for reuse, including when the branch has already completed. */ fun cancel() = run.stop() /** Rewinds the run from outside, see [rewind]. */ @@ -118,20 +120,34 @@ class StoryHandle internal constructor( suspend fun all(vararg handles: StoryHandle<*>) = handles.forEach { it.join() } /** Waits for the first of [handles] to end, stops the others and returns the one that ended. */ -suspend fun race(vararg handles: StoryHandle<*>): StoryHandle<*> = coroutineScope { +suspend fun race(vararg handles: StoryHandle<*>): StoryHandle<*> { + require(handles.isNotEmpty()) { "race() needs at least one branch" } + val winner = awaitFirst(handles.toList()) + handles.filter { it.run !== winner.run }.forEach(StoryHandle<*>::cancel) + return winner +} + +/** Waits without changing the supplied runs; only the temporary waiter coroutines belong to this scope. */ +private suspend fun awaitFirst(handles: List>): StoryHandle<*> = coroutineScope { val waits = handles.map { handle -> async { handle.join(); handle } } - val winner = select { waits.forEach { wait -> wait.onAwait { it } } } - waits.forEach { it.cancel() } - handles.filter { it !== winner }.forEach(StoryHandle<*>::cancel) - winner + try { + select { waits.forEach { wait -> wait.onAwait { it } } } + } finally { + waits.forEach { it.cancel() } + } } -/** Waits for [count] of [handles] to end, stops the rest and returns those that ended. */ +/** Waits for [count] distinct runs in [handles] to end, stops the rest and returns those that ended. */ suspend fun any(count: Int, vararg handles: StoryHandle<*>): List> { - require(count in 0..handles.size) { "Cannot wait for $count of ${handles.size} branches" } + val remaining = handles.distinctBy { it.run }.toMutableList() + require(count in 0..remaining.size) { "Cannot wait for $count of ${remaining.size} distinct branches" } val ended = ArrayList>() - while (ended.size < count) ended += race(*handles.filter { it !in ended && it.isActive }.toTypedArray()) - handles.filter { it !in ended }.forEach(StoryHandle<*>::cancel) + while (ended.size < count) { + val winner = awaitFirst(remaining) + ended += winner + remaining.remove(winner) + } + remaining.forEach(StoryHandle<*>::cancel) return ended } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 4f13af06d..b56aad15c 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -299,7 +299,7 @@ class StoryFrame internal constructor( putLong("entryMark", entryMark) putLong("segmentMark", segmentMark) put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) - } + }.copy() internal companion object { private const val CALL = "call:" @@ -310,11 +310,11 @@ class StoryFrame internal constructor( StoryFrame(run, tag.getString("instance"), tag.getString("stage")).apply { hash = tag.getInt("hash") position = tag.getString("position").takeIf { tag.contains("position") } - args = tag.getCompound("args") - locals = tag.getCompound("locals") - result = tag.get("result") - previous = tag.getCompound("previous").takeIf { tag.contains("previous") } - segment = tag.getCompound("segment") + args = tag.getCompound("args").copy() + locals = tag.getCompound("locals").copy() + result = tag.get("result")?.copy() + previous = tag.getCompound("previous").copy().takeIf { tag.contains("previous") } + segment = tag.getCompound("segment").copy() entryMark = tag.getLong("entryMark") segmentMark = tag.getLong("segmentMark") tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt index 1285ef3a0..f6026b22e 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt @@ -22,7 +22,7 @@ internal class StoryHistory(private val root: StoryRun) { putString("name", name) putString("run", run) putLong("mark", mark) - put("tree", tree) + put("tree", tree.copy()) if (labels.isNotEmpty()) put("labels", ListTag().apply { labels.forEach { add(it.save()) } }) } @@ -31,7 +31,7 @@ internal class StoryHistory(private val root: StoryRun) { tag.getString("name"), tag.getString("run"), tag.getLong("mark"), - tag.getCompound("tree"), + tag.getCompound("tree").copy(), tag.getList("labels", Tag.TAG_COMPOUND.toInt()).map { JournalEntry.load(it as CompoundTag) }, ) } @@ -40,15 +40,15 @@ internal class StoryHistory(private val root: StoryRun) { /** What a rewind left behind: the tree, the journal and the checkpoints as they were before it. */ private class Redo(val tree: CompoundTag, val journal: ListTag, val checkpoints: List) { fun save(): CompoundTag = CompoundTag().apply { - put("tree", tree) - put("journal", journal) + put("tree", tree.copy()) + put("journal", journal.copy()) put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) } companion object { fun load(tag: CompoundTag) = Redo( - tag.getCompound("tree"), - tag.getList("journal", Tag.TAG_COMPOUND.toInt()), + tag.getCompound("tree").copy(), + tag.getList("journal", Tag.TAG_COMPOUND.toInt()).copy(), tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()).map { Checkpoint.load(it as CompoundTag) }, ) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index e33d7410b..285e86fb7 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.job +import kotlinx.serialization.KSerializer import net.minecraft.nbt.CompoundTag import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost @@ -22,11 +23,23 @@ import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn * resumes once a script with that key is running again. */ object StoryInstances { - private class Entry(val instance: Any, val job: Job?) + private class Entry(val instance: Any) { + val users = LinkedHashSet() + val lifetime = Job() + } private val byKey = HashMap() private val keys = IdentityHashMap() - private val stages = HashMap, Stages>() + private val stages = object : ClassValue() { + override fun computeValue(type: Class<*>): Stages { + val methods = type.declaredMethods + return Stages( + methods.mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } }.toMap(), + methods.mapNotNull { method -> method.getAnnotation(StageCall::class.java)?.let { it.name to method } }.toMap(), + methods.mapNotNull { method -> method.getAnnotation(StageResult::class.java)?.let { it.name to method } }.toMap(), + ) + } + } private val listeners = ArrayList<(String) -> Unit>() /** @@ -35,23 +48,20 @@ object StoryInstances { */ fun register(instance: Any, scope: CoroutineScope) { val job = scope.coroutineContext.job - val added = ArrayList() + val added = LinkedHashMap() register(instance, job, added) - job.invokeOnCompletion { added.forEach { key -> remove(key, instance) } } - added.forEach { key -> listeners.forEach { it(key) } } + job.invokeOnCompletion { added.forEach { (key, entry) -> remove(key, entry, job) } } + added.keys.filter(byKey::containsKey).forEach { key -> listeners.forEach { it(key) } } } - /** The key [instance] is addressed by, registering it if it was not. */ - fun keyOf(instance: Any): String = keys[instance] ?: keyFor(instance).also { key -> - keys[instance] = key - byKey.putIfAbsent(key, Entry(instance, null)) - } + /** The key [instance] is addressed by; [register] makes it available after construction has finished. */ + fun keyOf(instance: Any): String = keys.getOrPut(instance) { keyFor(instance) } /** The running instance registered under [key], if any. */ fun find(key: String): Any? = byKey[key]?.instance /** The job of the instance under [key], which ends when the instance stops; null when nothing bounds it. */ - internal fun jobOf(key: String): Job? = byKey[key]?.job + internal fun jobOf(key: String): Job? = byKey[key]?.takeIf { it.users.isNotEmpty() }?.lifetime /** Calls [listener] with every key that becomes available. */ internal fun onAvailable(listener: (String) -> Unit) { @@ -69,6 +79,15 @@ object StoryInstances { suspend fun call(key: String, stage: String, captured: CompoundTag, parameters: Array): Any? = invoke(key, stage, { it.calls }, arrayOf(captured, *parameters)) + /** Restores type information generated with the lambda, without serializing serializer implementations. */ + @Suppress("UNCHECKED_CAST") + internal fun resultSerializer(key: String, stage: String): KSerializer { + val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") + val factory = stagesOf(instance.javaClass).results[stage] + ?: throw StoryException("Script '$key' has no result serializer for stage '$stage'; recompile the script") + return factory.invoke(instance) as KSerializer + } + /** The keys of every running instance. */ val registered: Set get() = byKey.keys @@ -95,24 +114,29 @@ object StoryInstances { /** Forgets everything, for tests and server shutdown. */ internal fun clear() { + val entries = byKey.values.toList() byKey.clear() keys.clear() + entries.forEach { it.lifetime.cancel() } } - private fun register(instance: Any, job: Job, added: MutableList) { + private fun register(instance: Any, job: Job, added: MutableMap) { if (!instance.javaClass.isAnnotationPresent(StageScript::class.java)) return val key = keyFor(instance) - if (byKey[key]?.let { it.instance === instance && it.job != null } == true) return + val entry = byKey[key]?.takeIf { it.instance === instance } ?: Entry(instance).also { byKey[key] = it } + if (!entry.users.add(job)) return keys[instance] = key - byKey[key] = Entry(instance, job) - added += key + added[key] = entry StoryActions.register(instance.javaClass, namespaceOf(instance), job) importsOf(instance).forEach { register(it, job, added) } } - private fun remove(key: String, instance: Any) { - if (byKey[key]?.instance === instance) byKey.remove(key) - keys.remove(instance) + private fun remove(key: String, entry: Entry, job: Job) { + entry.users.remove(job) + if (entry.users.isNotEmpty()) return + if (byKey[key] === entry) byKey.remove(key) + if (byKey[key]?.instance !== entry.instance) keys.remove(entry.instance) + entry.lifetime.cancel() } /** @@ -145,14 +169,8 @@ object StoryInstances { .getOrNull() } - private fun stagesOf(type: Class<*>): Stages = stages.getOrPut(type) { - val methods = type.declaredMethods.onEach { it.trySetAccessible() } - Stages( - methods.mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } }.toMap(), - methods.mapNotNull { method -> method.getAnnotation(StageCall::class.java)?.let { it.name to method } }.toMap(), - ) - } + private fun stagesOf(type: Class<*>): Stages = stages.get(type) /** The generated methods of a script class: entries that start or resume a stage, calls of stage lambdas. */ - private class Stages(val entries: Map, val calls: Map) + private class Stages(val entries: Map, val calls: Map, val results: Map) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt index 85267eaad..9bc0512f4 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -31,8 +31,8 @@ internal class JournalEntry( putString("action", action) putString("run", run) putString("lifetime", lifetime.name) - put("args", args) - put("snapshots", snapshots) + put("args", args.copy()) + put("snapshots", snapshots.copy()) outer?.let { putLong("outer", it) } putBoolean("done", done) undoneAt?.let { putLong("undone", it) } @@ -44,8 +44,8 @@ internal class JournalEntry( tag.getString("action"), tag.getString("run"), runCatching { Lifetime.valueOf(tag.getString("lifetime")) }.getOrDefault(Lifetime.RUN), - tag.getCompound("args"), - tag.getCompound("snapshots"), + tag.getCompound("args").copy(), + tag.getCompound("snapshots").copy(), tag.getLong("outer").takeIf { tag.contains("outer") }, ).apply { done = tag.getBoolean("done") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt index 91b92b13d..1ead24a87 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt @@ -8,8 +8,12 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story object StoryLoops { /** How many values [progression] goes through. */ @JvmStatic - fun count(progression: IntProgression): Int = - if (progression.isEmpty()) 0 else (progression.last - progression.first) / progression.step + 1 + fun count(progression: IntProgression): Int { + if (progression.isEmpty()) return 0 + val size = (progression.last.toLong() - progression.first) / progression.step + 1 + require(size <= Int.MAX_VALUE) { "A resumable loop cannot index $size elements; split the progression into smaller ranges" } + return size.toInt() + } /** The value at [index] of a progression starting at [first] with [step]. */ @JvmStatic diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt index 4a19d999d..856a15f77 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -24,9 +24,6 @@ import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeSection import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT -import ru.hollowhorizon.hollowengine.common.utils.nbt.loadAsNBT -import ru.hollowhorizon.hollowengine.common.utils.nbt.save -import java.nio.file.Files import java.nio.file.Path /** @@ -82,12 +79,7 @@ object StoryOwners { fun onLevelLoad(event: LevelEvent.Load) { val server = event.level.server ?: return if (event.level.dimension() != Level.OVERWORLD) return - val file = serverFile(server) - if (!Files.exists(file)) return - runCatching { - Files.newInputStream(file).use { it.loadAsNBT() } as? CompoundTag - }.onFailure { HollowEngine.LOGGER.error("Cannot read the stories of the server from {}", file, it) }.getOrNull() - ?.let(server(server)::load) + StoryStore(serverFile(server)).read()?.let(server(server)::load) } @SubscribeEvent @@ -127,11 +119,7 @@ object StoryOwners { private fun saveServer(server: MinecraftServer) { val runs = servers[server] ?: return - val file = serverFile(server) - runCatching { - Files.createDirectories(file.parent) - Files.newOutputStream(file).use { runs.save().save(it) } - }.onFailure { HollowEngine.LOGGER.error("Cannot save the stories of the server to {}", file, it) } + StoryStore(serverFile(server)).write(runs.save()) } private fun serverFile(server: MinecraftServer): Path = diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index e34e2eefd..248cecf2f 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -63,6 +63,8 @@ class StoryRun internal constructor( internal val isCancelled: Boolean get() = job?.isCancelled == true + internal val isStopped: Boolean get() = stopping || state == Status.STOPPED + /** Starts or wakes the run; does nothing while it runs or once it ended. */ internal fun start() { if (state.done || job?.isActive == true || !isAttached || lineage.any { it.moving }) return @@ -82,7 +84,14 @@ class StoryRun internal constructor( frames.clear() status.value = Status.RUNNING val parentJob = parent?.job ?: runs.owner.scope.coroutineContext.job - job = runs.owner.scope.launch(Element(this) + parentJob) { drive() } + val running = runs.owner.scope.launch(Element(this) + parentJob, start = CoroutineStart.LAZY) { drive() } + job = running + running.invokeOnCompletion { + if (job === running && state == Status.RUNNING) { + if (stopping) finish(Status.STOPPED, null, null) else pause() + } + } + running.start() children.values.forEach { it.start() } } @@ -142,10 +151,19 @@ class StoryRun internal constructor( } /** The branch [name] that the frame on top of the stack starts, or the one it started before a restart. */ - internal fun branch(name: String, lambda: StageLambda): StoryRun { + internal suspend fun branch(name: String, lambda: StageLambda): StoryRun { require('/' !in name && name.isNotBlank()) { "A branch name must be non-blank and contain no '/': '$name'" } val frame = frames.lastOrNull() ?: throw StoryException("async(\"$name\") must be called from a stage") - children[name]?.let { return it } + children[name]?.let { previous -> + if (!previous.isStopped) { + if (name !in frame.children) throw StoryException( + "Branch '$path/$name' belongs to another active stage; use a distinct branch name" + ) + return previous + } + previous.job?.join() + frames.forEach { it.children.remove(name) } + } val child = StoryRun(runs, name, this) child.pending += lambda.entry(child) children[name] = child @@ -162,7 +180,13 @@ class StoryRun internal constructor( /** Stops the run for good, along with its branches, and takes back their temporary actions at once. */ internal fun stop() { - if (state.done) return + if (state.done) { + if (!stopping) { + stopping = true + changed() + } + return + } stopping = true journal.close(path) val running = job @@ -297,25 +321,27 @@ class StoryRun internal constructor( /** The runs as they are: stacks, branches, variables. A checkpoint keeps this. */ internal fun saveTree(): CompoundTag = CompoundTag().apply { putString("status", (if (state == Status.RUNNING) Status.SLEEPING else state).name) + if (stopping) putBoolean("stopping", true) put("frames", ListTag().apply { (frames + pending).forEach { add(it.save()) } }) if (children.isNotEmpty()) put( "children", CompoundTag().apply { children.forEach { (name, run) -> put(name, run.saveTree()) } }) if (parent == null && !ownVars.isEmpty) put("vars", ownVars.copy()) - result?.let { put("result", it) } + result?.let { put("result", it.copy()) } failure?.let { putString("failure", it) } } private fun readTree(tag: CompoundTag) { status.value = runCatching { Status.valueOf(tag.getString("status")) }.getOrDefault(Status.SLEEPING) + stopping = tag.getBoolean("stopping") tag.getList("frames", Tag.TAG_COMPOUND.toInt()).forEach { pending += StoryFrame.load(this, it as CompoundTag) } val saved = tag.getCompound("children") saved.allKeys.forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } if (parent == null) { ownVars.allKeys.toList().forEach(ownVars::remove) - ownVars.merge(tag.getCompound("vars")) + ownVars.merge(tag.getCompound("vars").copy()) } - result = tag.get("result") + result = tag.get("result")?.copy() failure = tag.getString("failure").takeIf { tag.contains("failure") } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt new file mode 100644 index 000000000..2a65433ba --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt @@ -0,0 +1,51 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.Util +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.NbtIo +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.utils.nbt.save +import java.io.DataInputStream +import java.nio.file.Files +import java.nio.file.Path + +/** Writes a complete snapshot before replacing the last save, keeping a recoverable previous version. */ +internal class StoryStore(private val file: Path) { + private val backup = file.resolveSibling("${file.fileName}_old") + + fun read(): CompoundTag? { + if (!Files.exists(file) && !Files.exists(backup)) return null + return read(file) ?: read(backup) + } + + private fun read(path: Path): CompoundTag? { + if (!Files.exists(path)) return null + return try { + DataInputStream(Files.newInputStream(path)).use(NbtIo::read) + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Cannot read stories from {}", path, failure) + null + } + } + + fun write(tag: CompoundTag): Boolean { + var temporary: Path? = null + return try { + Files.createDirectories(file.parent) + temporary = Files.createTempFile(file.parent, "${file.fileName}-", ".tmp") + Files.newOutputStream(temporary).use { tag.save(it) } + Util.safeReplaceOrMoveFile(file, temporary, backup, false).also { + if (!it) HollowEngine.LOGGER.error("Cannot replace story save {}", file) + } + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Cannot save stories to {}", file, failure) + false + } finally { + temporary?.let { path -> + runCatching { Files.deleteIfExists(path) }.onFailure { + HollowEngine.LOGGER.warn("Cannot remove temporary story save {}", path, it) + } + } + } + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt index a3a621d93..0ed9fc1b2 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.IntTag +import net.minecraft.nbt.ListTag import net.minecraft.server.MinecraftServer import net.minecraft.world.entity.Entity import kotlin.test.Test @@ -32,7 +33,7 @@ class StoryFrameTest { put("locals", CompoundTag().apply { put("map", IntTag.valueOf(5)) }) configure() } - val tag = CompoundTag().apply { put("frames", net.minecraft.nbt.ListTag().apply { add(frame) }) } + val tag = CompoundTag().apply { put("frames", ListTag().apply { add(frame) }) } return StoryRun.load(StoryRuns(Owner), "quest", null, tag) } @@ -77,7 +78,7 @@ class StoryFrameTest { put("segment", CompoundTag().apply { putBoolean("done", true) }) } val tag = CompoundTag().apply { - put("frames", net.minecraft.nbt.ListTag().apply { run.pending.forEach { add(it.save()) } }) + put("frames", ListTag().apply { run.pending.forEach { add(it.save()) } }) put("children", CompoundTag().apply { put("merchant", saved(null).save()) }) put("vars", CompoundTag().apply { putInt("quests/village.kts/trust", 3) }) } @@ -89,4 +90,28 @@ class StoryFrameTest { assertEquals(restored.vars, restored.children.getValue("merchant").vars, "branches share the variables of their story") assertTrue(restored.pending.single().segment.getBoolean("done")) } + + @Test + fun `a saved tree is a snapshot independent of the working frames`() { + val run = saved("after:gate") + val frame = run.pending.single() + val snapshot = run.saveTree() + val expected = snapshot.copy() + frame.locals.putInt("map", 99) + frame.args.putInt("times", 99) + frame.segment.putBoolean("changed", true) + assertEquals(expected, snapshot) + } + + @Test + fun `restoring a tree does not let the run mutate the checkpoint`() { + val snapshot = saved("after:gate").saveTree() + val expected = snapshot.copy() + val run = StoryRun.load(StoryRuns(Owner), "quest", null, snapshot) + val frame = run.pending.single() + frame.locals.putInt("map", 99) + frame.args.putInt("times", 99) + frame.segment.putBoolean("changed", true) + assertEquals(expected, snapshot) + } } diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt new file mode 100644 index 000000000..4c4bc33fc --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import kotlin.test.* + +class StoryInstancesTest { + @StageScript("test/imported.kts") + class Imported + + @StageScript("test/root.kts") + class Root(val imported: Imported) + + @StageScript("test/construction.kts") + class StartsDuringConstruction(runs: StoryRuns) { + var observedReady: Boolean? = null + private set + + init { + runs.launch("construction", StageLambda0(StoryInstances.keyOf(this), "start", CompoundTag())) + } + + private val ready = true + + @StageEntry("start") + suspend fun start(args: CompoundTag): Any { + observedReady = ready + return Unit + } + } + + @AfterTest + fun clear() { + StoryWaits.clear() + StoryInstances.clear() + } + + @Test + fun `stopping a script unregisters the imported instances as well`() { + val scope = CoroutineScope(Dispatchers.Unconfined + Job()) + val root = Root(Imported()) + StoryInstances.register(root, scope) + val imported = StoryInstances.keyOf(root.imported) + assertSame(root.imported, StoryInstances.find(imported)) + scope.cancel() + assertNull(StoryInstances.find(imported)) + assertNull(StoryInstances.find("test/root.kts")) + } + + @Test + fun `a shared imported instance stays available until its last scope stops`() { + val first = CoroutineScope(Dispatchers.Unconfined + Job()) + val second = CoroutineScope(Dispatchers.Unconfined + Job()) + val imported = Imported() + StoryInstances.register(Root(imported), first) + StoryInstances.register(Root(imported), second) + val lifetime = StoryInstances.jobOf("test/imported.kts")!! + first.cancel() + assertSame(imported, StoryInstances.find("test/imported.kts")) + assertFalse(lifetime.isCancelled) + second.cancel() + assertNull(StoryInstances.find("test/imported.kts")) + assertTrue(lifetime.isCancelled) + } + + @Test + fun `stopping an old script does not unregister its replacement`() { + val oldScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val newScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val old = Root(Imported()) + val replacement = Root(Imported()) + StoryInstances.register(old, oldScope) + StoryInstances.register(replacement, newScope) + oldScope.cancel() + assertSame(replacement, StoryInstances.find("test/root.kts")) + assertSame(replacement.imported, StoryInstances.find("test/imported.kts")) + newScope.cancel() + } + + @Test + fun `a story launched by a script constructor waits until the script is fully initialized`() { + val scope = CoroutineScope(Dispatchers.Unconfined + Job()) + val owner = object : StoryOwner { + override val scope = scope + override val server: MinecraftServer get() = error("no server") + override val entity: Entity? get() = null + override fun changed(runs: StoryRuns) = Unit + } + val script = StartsDuringConstruction(StoryRuns(owner)) + assertNull(script.observedReady) + StoryInstances.register(script, scope) + assertEquals(true, script.observedReady) + scope.cancel() + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt new file mode 100644 index 000000000..eb9165bad --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt @@ -0,0 +1,19 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StoryLoopsTest { + @Test + fun `progressions crossing the integer limits are counted without overflow`() { + assertEquals(3, StoryLoops.count(Int.MIN_VALUE..Int.MAX_VALUE step Int.MAX_VALUE)) + assertEquals(3, StoryLoops.count(Int.MAX_VALUE downTo Int.MIN_VALUE step Int.MAX_VALUE)) + assertEquals(0, StoryLoops.count(1..0)) + } + + @Test + fun `a progression too large for a saved index fails explicitly`() { + assertFailsWith { StoryLoops.count(Int.MIN_VALUE..Int.MAX_VALUE) } + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt new file mode 100644 index 000000000..19f63e42b --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt @@ -0,0 +1,34 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StoryStoreTest { + @TempDir + lateinit var directory: Path + + @Test + fun `a missing save has no stories`() { + assertNull(StoryStore(directory.resolve("stories.dat")).read()) + } + + @Test + fun `a damaged save recovers the previous complete snapshot`() { + val file = directory.resolve("stories.dat") + val store = StoryStore(file) + val first = CompoundTag().apply { putInt("version", 1) } + val second = CompoundTag().apply { putInt("version", 2) } + assertTrue(store.write(first)) + assertTrue(store.write(second)) + assertEquals(second, store.read()) + Files.write(file, byteArrayOf(10, 0)) + assertEquals(first, store.read()) + Files.list(directory).use { files -> assertTrue(files.noneMatch { it.fileName.toString().endsWith(".tmp") }) } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt index c30ac8d9b..246b6eaf7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt @@ -1,11 +1,9 @@ package ru.hollowhorizon.hollowengine.common.fsm -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive import net.minecraft.nbt.CompoundTag -import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope -import ru.hollowhorizon.hollowengine.common.utils.currentServer import kotlin.coroutines.CoroutineContext class StateStorage(val tag: CompoundTag) : CoroutineContext.Element { @@ -14,20 +12,11 @@ class StateStorage(val tag: CompoundTag) : CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = Key } -private fun waiter(checker: CompletableDeferred, condition: () -> Boolean) { - currentServer.coroutineScope.launch { - if (condition()) checker.complete(true) - else { - delay(50L) - waiter(checker, condition) - } +/** Polls in the caller's coroutine, so cancellation and condition failures belong to the waiter. */ +suspend fun await(condition: () -> Boolean) { + while (true) { + currentCoroutineContext().ensureActive() + if (condition()) return + delay(50L) } } - -suspend fun await(condition: () -> Boolean) { - val checker = CompletableDeferred() - - waiter(checker, condition) - - checker.await() -} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt index 32cddf865..5efef51fd 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt @@ -21,7 +21,7 @@ import java.security.MessageDigest */ object ScriptFingerprint { /** Bumped whenever the layout of a cached artifact changes in a way older jars cannot satisfy. */ - const val FORMAT_VERSION = 4 + const val FORMAT_VERSION = 5 /** What a build compiles addon scripts for: Mojang names, which is what NeoForge runs on. */ const val NAMED_PRODUCTION_RUNTIME = "neoforge/official/production" From 86ef95faa95967447a2ba085c4925c1c2b8f9263 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 16:33:31 +0300 Subject: [PATCH 12/25] Support shared vars in stage lambdas --- .../plugin/fir/stages/StageFirCallChecker.kt | 123 +++++++++++- .../plugin/ir/stages/StageIrGeneration.kt | 2 + .../plugin/ir/stages/StageLambdaLifter.kt | 10 +- .../plugin/ir/stages/StageLocalFunctions.kt | 188 ++++++++++++++++++ .../common/plugin/ir/stages/StageSymbols.kt | 8 + .../common/plugin/ir/stages/StageTypes.kt | 2 +- .../common/plugin/ir/stages/StageVarBoxes.kt | 149 ++++++++++++++ .../common/plugin/stages/StageMessages.kt | 8 +- .../common/plugin/stages/StageValueTypes.kt | 3 + .../src/test/kotlin/StageAnalysisTest.kt | 4 +- .../src/test/kotlin/StageCompileErrorsTest.kt | 2 +- .../src/test/kotlin/StageFeaturesTest.kt | 19 ++ .../src/test/kotlin/StageLambdaSafetyTest.kt | 4 +- .../src/test/kotlin/StageSharedVarsTest.kt | 176 ++++++++++++++++ .../src/test/kotlin/StageTestSupport.kt | 22 +- .../addons/narrative/story/StoryBoxes.kt | 108 ++++++++++ .../addons/narrative/story/StoryFrame.kt | 17 ++ .../addons/narrative/story/StoryInstances.kt | 16 ++ .../addons/narrative/story/StoryRun.kt | 19 +- .../addons/narrative/story/StoryValues.kt | 1 + .../common/addons/HollowAddonRuntime.kt | 8 +- .../common/scripting/source/ScriptRegistry.kt | 16 +- 22 files changed, 877 insertions(+), 28 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt create mode 100644 addons/compiler/src/test/kotlin/StageSharedVarsTest.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt index d6e51f168..526a8f26c 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -8,8 +8,13 @@ import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirFunctionCallChecker import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.declarations.FirNamedFunction +import org.jetbrains.kotlin.fir.declarations.FirProperty +import org.jetbrains.kotlin.fir.declarations.hasAnnotation import org.jetbrains.kotlin.fir.declarations.FirScript import org.jetbrains.kotlin.fir.expressions.FirAnonymousFunctionExpression +import org.jetbrains.kotlin.fir.expressions.FirCallableReferenceAccess import org.jetbrains.kotlin.fir.expressions.FirFunctionCall import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression import org.jetbrains.kotlin.fir.expressions.FirVariableAssignment @@ -34,6 +39,7 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm if (context.containingDeclarations.none { it.fir is FirScript }) return StageFirActions.checkCall(expression) val mapping = (expression.argumentList as? FirResolvedArgumentList)?.mapping ?: return + val startsStory = startsStory(expression) mapping.forEach { (argument, parameter) -> if (!parameter.returnTypeRef.coneType.isStageLambda(context.session)) return@forEach val lambda = (argument as? FirAnonymousFunctionExpression)?.anonymousFunction @@ -46,7 +52,7 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm "A @State lambda takes at most 8 parameters, its receiver included" ) StageFirSignatures.check(lambda) - checkCaptures(lambda) + checkCaptures(lambda, startsStory) StageFirBodyChecker.check(lambda, stage = true) } else { val forwarded = @@ -58,8 +64,107 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm } } + /** `launchStory` and `StoryRuns.launch` start a story of their own, which keeps copies of what it captures. */ + private fun startsStory(call: FirFunctionCall): Boolean { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return false + val name = symbol.name.asString() + return (name == "launchStory" || name == "launch") && symbol.callableId?.packageName == StageFirIds.PACKAGE + } + + /** + * Whether the var [symbol] can be shared with a `@State` lambda: a `@State` function or lambda declares it + * directly, so the compiler can turn it into a box the stage and the lambda share. + */ + context(context: CheckerContext) + private fun isShareable(symbol: FirLocalPropertySymbol): Boolean { + val owner = context.containingDeclarations.asReversed().map { it.fir }.filterIsInstance() + .firstOrNull { it.declaresDirectly(symbol) } ?: return false + return when (owner) { + is FirNamedFunction -> owner.hasAnnotation(StageFirIds.STATE, context.session) + is FirAnonymousFunction -> context.callsOrAssignments.filterIsInstance().any { call -> + (call.argumentList as? FirResolvedArgumentList)?.mapping?.any { (argument, parameter) -> + (argument as? FirAnonymousFunctionExpression)?.anonymousFunction === owner && + parameter.returnTypeRef.coneType.isStageLambda(context.session) + } == true + } + else -> false + } + } + + private fun FirFunction.declaresDirectly(symbol: FirLocalPropertySymbol): Boolean { + var found = false + body?.acceptChildren(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (found || element is FirFunction) return + if (element is FirProperty && element.symbol == symbol) found = true else element.acceptChildren(this) + } + }) + return found + } + + /** + * Why the local function [function] cannot be called from a `@State` lambda, or null when it can: the compiler + * copies it out of its scope, so what it uses from there must be saved with the lambda, like a capture. + */ + context(context: CheckerContext) + private fun localFunctionProblem(function: FirFunction, startsStory: Boolean, visited: MutableSet): String? { + if (!visited.add(function)) return null + if (function.typeParameters.isNotEmpty()) return "it has type parameters" + val owned = HashSet>() + function.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirDeclaration) owned += element.symbol + element.acceptChildren(this) + } + }) + var problem: String? = null + function.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (problem != null) return + problem = when (element) { + is FirQualifiedAccessExpression -> access(element) + is FirVariableAssignment -> assignment(element) + else -> null + } + element.acceptChildren(this) + } + + private fun access(expression: FirQualifiedAccessExpression): String? { + val symbol = expression.calleeReference.toResolvedCallableSymbol() ?: return null + if (symbol in owned) return null + val name = symbol.name.asString() + return when { + symbol is FirConstructorSymbol && symbol.resolvedReturnType.toRegularClassSymbol(context.session)?.fir?.isLocal == true -> + "it makes local class '${symbol.callableId.classId?.shortClassName}'" + + symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> + if (expression is FirCallableReferenceAccess) "it refers to local function '$name'" + else localFunctionProblem(symbol.fir, startsStory, visited) + + symbol is FirLocalPropertySymbol && symbol.fir.isVar -> when { + !isShareable(symbol) -> "it uses 'var $name', which no @State function or lambda declares directly" + startsStory -> "it uses 'var $name', and a story started here keeps copies of what it captures" + else -> symbol.resolvedReturnType.stageProblem(context.session)?.let { "it uses '$name', which cannot be saved: $it" } + } + + symbol is FirLocalPropertySymbol || symbol is FirValueParameterSymbol -> + symbol.resolvedReturnType.stageProblem(context.session)?.let { "it uses '$name', which cannot be saved: $it" } + + else -> null + } + } + + private fun assignment(assignment: FirVariableAssignment): String? { + val symbol = (assignment.lValue as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() + if (symbol !is FirLocalPropertySymbol || symbol in owned || (symbol.fir.isVar && isShareable(symbol))) return null + return "it assigns '${symbol.name}', which it can only read" + } + }) + return problem + } + context(context: CheckerContext, reporter: DiagnosticReporter) - private fun checkCaptures(lambda: FirAnonymousFunction) { + private fun checkCaptures(lambda: FirAnonymousFunction, startsStory: Boolean) { val owned = HashSet>() lambda.accept(object : FirVisitorVoid() { override fun visitElement(element: FirElement) { @@ -85,8 +190,16 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm "local class '${symbol.callableId.classId?.shortClassName}'" ) - symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> StageMessages.localClosure("local function '$name'") - symbol is FirLocalPropertySymbol && symbol.fir.isVar -> StageMessages.mutableCapture(name) + symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> + if (qualifiedAccessExpression is FirCallableReferenceAccess) StageMessages.localClosure("local function '$name'") + else localFunctionProblem(symbol.fir, startsStory, HashSet())?.let { StageMessages.localFunction(name, it) } + + symbol is FirLocalPropertySymbol && symbol.fir.isVar -> when { + !isShareable(symbol) -> StageMessages.mutableCapture(name) + startsStory -> StageMessages.sharedVar(name, "a story started here keeps copies of what it captures; pass the value instead") + else -> symbol.resolvedReturnType.stageProblem(context.session)?.let { StageMessages.sharedVar(name, "its value is saved, but $it") } + } + symbol is FirLocalPropertySymbol || symbol is FirValueParameterSymbol -> symbol.resolvedReturnType.stageProblem( context.session )?.let { StageMessages.unsavedCapture(name, it) } @@ -101,7 +214,7 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm private fun checkAssignment(variableAssignment: FirVariableAssignment) { val symbol = (variableAssignment.lValue as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() - if (symbol != null && symbol !in owned && symbol is FirLocalPropertySymbol) { + if (symbol != null && symbol !in owned && symbol is FirLocalPropertySymbol && !(symbol.fir.isVar && isShareable(symbol))) { reporter.reportOn( variableAssignment.source, StageDiagnostics.STAGE_ERROR, diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt index c9bf623bb..6224de417 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -48,9 +48,11 @@ internal class StageIrGeneration(private val context: IrPluginContext) { fun compile() { script.annotations += stages.scriptAnnotation(file.fileEntry.name) StageActions(stages).compile() + StageVarBoxes(stages).compile() val lifter = StageLambdaLifter(stages) script.declarations.toList().forEach { it.transform(lifter, null) } script.declarations += lifter.lifted.keys + script.declarations += lifter.locals.lifted captures = lifter.lifted val functions = script.declarations.filterIsInstance().filter(stages::isSuspendState) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt index 4a3a270a4..4b8667154 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -39,6 +39,9 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran /** The functions made from lambdas, in the order they were made, with how many captured values they take. */ val lifted = LinkedHashMap() + /** The local functions lifted lambdas call, copied to the script. */ + val locals = StageLocalFunctions(stages) + override fun visitProperty(declaration: IrProperty): IrStatement { owners.addLast(declaration.name.asString()) return super.visitProperty(declaration).also { owners.removeLast() } @@ -86,6 +89,7 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran return null } val captured = captures(lambda) ?: return null + locals.redirect(lambda, scriptThis()) val owner = owners.lastOrNull() ?: "script" val key = "$owner$$callee" val ordinal = counters.merge(key, 1, Int::plus)!! @@ -139,7 +143,10 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran override fun visitCall(expression: IrCall) { super.visitCall(expression) val function = expression.symbol.owner - checkLocal(function, expression, "local function '${function.name}'") + if (function.isLocal() && !function.isInside(lambda) && function.typeParameters.isNotEmpty()) { + stages.reporter.error(expression, StageMessages.localClosure("generic local function '${function.name}'")) + valid = false + } } override fun visitFunctionReference(expression: IrFunctionReference) { @@ -170,6 +177,7 @@ internal class StageLambdaLifter(private val stages: StageScope) : IrElementTran } } }) + locals.reachableFrom(lambda).forEach { function -> captured += locals.freeValues(function) } captured.forEach { value -> if (value is IrVariable && value.isVar) { stages.reporter.error( diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt new file mode 100644 index 000000000..4a0de0db6 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt @@ -0,0 +1,188 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.IrGetValue +import org.jetbrains.kotlin.ir.expressions.IrSetValue +import org.jetbrains.kotlin.ir.expressions.impl.IrGetValueImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.patchDeclarationParents +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name + +/** + * Lets a `@State` lambda call the local functions of the code it is written in. A lifted lambda leaves that code, + * so every such function gets a copy at the top level of the script, with what it used from around it as explicit + * parameters; the lambda captures those values, which makes them saved like any capture. The original stays for + * its other callers. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLocalFunctions(private val stages: StageScope) { + private class Copy(val function: IrSimpleFunction, val free: List) + + private val copies = HashMap() + + /** The copies made, which belong to the script. */ + val lifted = ArrayList() + + /** The local functions declared outside [root] that it calls, directly or through one another. */ + fun reachableFrom(root: IrFunction): Set { + val found = LinkedHashSet() + val queue = ArrayDeque(calledOutside(root)) + while (queue.isNotEmpty()) { + val function = queue.removeFirst() + if (found.add(function)) queue += calledOutside(function) + } + return found + } + + /** What [function] and the local functions it reaches read or write from around them, in a stable order. */ + fun freeValues(function: IrSimpleFunction): List = + (setOf(function) + reachableFrom(function)).flatMap(::directFree).distinct() + + /** + * Points the calls in [root] to local functions declared outside it at their copies. [dispatch] is the script + * instance there, and [valueOf] gives what to pass for each free value of a callee. + */ + fun redirect( + root: IrFunction, + dispatch: IrValueDeclaration, + valueOf: (IrValueDeclaration) -> IrValueDeclaration = { it }, + ) { + root.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val callee = expression.symbol.owner + if (!callee.isLocal() || callee.isInside(root)) return expression + val copy = copyOf(callee) + return stages.builder(root.symbol, expression).run { + callCopy(copy, callee, expression, irGet(dispatch), copy.free.map { irGet(valueOf(it)) }) + } + } + }) + } + + private fun copyOf(function: IrSimpleFunction): Copy { + copies[function]?.let { return it } + val free = freeValues(function) + val script = stages.script + val copy = function.deepCopyWithSymbols(script) + copy.attributeOwnerId = copy + copy.name = Name.identifier($$"stage$local$$${function.name.asString()}$$${lifted.size}") + copy.visibility = DescriptorVisibilities.PRIVATE + copy.origin = IrDeclarationOrigin.DEFINED + copy.parent = script + val dispatch = copy.buildReceiverParameter { type = script.defaultType } + val own = copy.parameters.toList() + val names = own.map { it.name.asString() }.toMutableSet() + val freeParameters = free.map { value -> + val base = value.name.asString().takeIf { !value.name.isSpecial } ?: "captured" + copy.addValueParameter(generateSequence(base) { "${it}_" }.first(names::add), value.type) + } + copy.parameters = + listOf(dispatch) + own.filter { it.kind == IrParameterKind.ExtensionReceiver } + freeParameters + own.filter { it.kind == IrParameterKind.Regular } + val result = Copy(copy, free) + copies[function] = result + lifted += copy + + val parameterOf = free.zip(freeParameters).toMap() + copy.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitGetValue(expression: IrGetValue): IrExpression { + val value = expression.symbol.owner + val replacement: IrValueParameter = + parameterOf[value] ?: dispatch.takeIf { stages.isScriptThis(value) && value !in declaredIn(copy) } + ?: return expression + return IrGetValueImpl( + expression.startOffset, + expression.endOffset, + replacement.type, + replacement.symbol, + expression.origin + ) + } + + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + if (expression.symbol != copy.symbol) return expression + return stages.builder(copy.symbol, expression).run { + callCopy(result, function, expression, irGet(dispatch), freeParameters.map { irGet(it) }) + } + } + }) + redirect(copy, dispatch) { value -> parameterOf[value] ?: value } + copy.patchDeclarationParents(script) + return result + } + + /** A call of [copy] in place of [call] of [original]: the script, its receiver, the free values, its arguments. */ + private fun IrBuilderWithScope.callCopy( + copy: Copy, + original: IrSimpleFunction, + call: IrCall, + dispatch: IrExpression, + free: List, + ): IrCall = irCall(copy.function.symbol, call.type).apply { + var next = 0 + arguments[next++] = dispatch + original.parameters.forEachIndexed { index, parameter -> + if (parameter.kind == IrParameterKind.ExtensionReceiver) arguments[next++] = call.arguments[index] + } + free.forEach { arguments[next++] = it } + original.parameters.forEachIndexed { index, parameter -> + if (parameter.kind == IrParameterKind.Regular) arguments[next++] = call.arguments[index] + } + } + + private fun calledOutside(root: IrFunction): Set { + val found = LinkedHashSet() + root.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val callee = expression.symbol.owner + if (callee.isLocal() && !callee.isInside(root) && callee !== root) found += callee + } + }) + return found + } + + private fun directFree(function: IrSimpleFunction): List { + val inside = declaredIn(function) + val free = LinkedHashSet() + function.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitGetValue(expression: IrGetValue) { + val value = expression.symbol.owner + if (value !in inside && !stages.isScriptThis(value)) free += value + } + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + if (expression.symbol.owner !in inside) free += expression.symbol.owner + } + }) + return free.toList() + } +} + +/** Whether this is a function declared inside another one. */ +internal fun IrFunction.isLocal(): Boolean = parent is IrFunction || visibility == DescriptorVisibilities.LOCAL + +/** Whether this is declared inside [owner], at any depth. */ +internal fun IrDeclaration.isInside(owner: IrDeclarationParent): Boolean = + generateSequence(parent) { (it as? IrDeclaration)?.parent }.any { it === owner } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt index 08cb67fc4..1944cd682 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.common.plugin.ir.stages import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrFunction import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrProperty import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.symbols.IrClassSymbol import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol @@ -34,6 +35,11 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val instances: IrClassSymbol = storyClass("StoryInstances") val loops: IrClassSymbol = storyClass("StoryLoops") val stageLambda: IrClassSymbol = storyClass("StageLambda") + val storyBox: IrClassSymbol = storyClass("StoryBox") + val boxes: IrClassSymbol = storyClass("StoryBoxes") + val box: IrSimpleFunctionSymbol = member(boxes, "box") + val boxGetter: IrSimpleFunctionSymbol = boxValue().getter!!.symbol + val boxSetter: IrSimpleFunctionSymbol = boxValue().setter!!.symbol val stageEntry: IrConstructorSymbol = storyClass("StageEntry").constructors.single() val stageCall: IrConstructorSymbol = storyClass("StageCall").constructors.single() val stageResult: IrConstructorSymbol = storyClass("StageResult").constructors.single() @@ -91,6 +97,8 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st private fun storyFunctions(name: String): Set = finder.findFunctions(CallableId(PACKAGE, Name.identifier(name))).toSet() + private fun boxValue(): IrProperty = storyBox.owner.declarations.filterIsInstance().single { it.name.asString() == "value" } + private fun member(owner: IrClassSymbol, name: String): IrSimpleFunctionSymbol = owner.getSimpleFunction(name) ?: error("${owner.owner.name}.$name is missing from the story runtime") diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt index 7746942d0..2cc9478d7 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -37,7 +37,7 @@ internal class StageTypes(context: IrPluginContext) { return "a function value cannot be saved; pass a lambda to a @State parameter instead" } if (klass.isPrimitiveArray()) return null - if (name == "kotlin.Array" || name in StageValueTypes.CONTAINERS || klass.isSerializable() || name in StageValueTypes.CONTEXTUAL) { + if (name == "kotlin.Array" || name == StageValueTypes.STORY_BOX || name in StageValueTypes.CONTAINERS || klass.isSerializable() || name in StageValueTypes.CONTEXTUAL) { return simple.arguments.firstNotNullOfOrNull { argument -> (argument as? IrTypeProjection)?.type?.let(::problem) ?: if (argument is IrTypeProjection) null else "a star projection cannot be saved" diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt new file mode 100644 index 000000000..f5ef93076 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt @@ -0,0 +1,149 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irGetObject +import org.jetbrains.kotlin.ir.builders.irString +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.declarations.impl.IrVariableImpl +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.symbols.impl.IrVariableSymbolImpl +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.kotlinFqName +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +/** + * Lets `@State` lambdas share the vars of the stage they are written in. Such a var becomes a box + * and every read and write goes through its value, so the stage and lambda see one value, + * which the story saves once. Runs before the lambdas are lifted, which then capture the box. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageVarBoxes(private val stages: StageScope) { + private val symbols = stages.symbols + + /** A `@State` lambda found in a stage and the function it is passed to. */ + private class Lambda(val function: IrSimpleFunction, val callee: IrFunction) + + fun compile() { + val lambdas = ArrayList() + stages.script.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val callee = expression.symbol.owner + callee.parameters.forEach { parameter -> + if (!stages.types.isStageLambda(parameter.type)) return@forEach + val lambda = expression.arguments[parameter] as? IrFunctionExpression ?: return@forEach + lambdas += Lambda(lambda.function, callee) + } + } + }) + val owners = stages.script.declarations.filterIsInstance() + .filter(stages::isSuspendState) + lambdas.map { it.function } + owners.forEach { owner -> box(owner, lambdas.filter { it.function !== owner && it.function.isInside(owner) }) } + } + + private fun box(owner: IrSimpleFunction, lambdas: List) { + val shared = LinkedHashMap() + val locals = StageLocalFunctions(stages) + lambdas.forEach { lambda -> + val inside = declaredIn(lambda.function) + val reached = listOf(lambda.function) + locals.reachableFrom(lambda.function) + reached.forEach { function -> + function.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitGetValue(expression: IrGetValue) = share(expression.symbol.owner) + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + share(expression.symbol.owner) + } + + private fun share(value: Any) { + if (value is IrVariable && value.isVar && value !in inside && value.parent === owner) { + shared.putIfAbsent(value, lambda) + } + } + }) + } + } + val boxes = shared.filter { (variable, lambda) -> canShare(variable, lambda) }.keys.associateWith(::boxOf) + if (boxes.isEmpty()) return + owner.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitVariable(declaration: IrVariable): IrStatement { + declaration.transformChildrenVoid(this) + val box = boxes[declaration] ?: return declaration + box.initializer = stages.builder(owner.symbol, declaration).run { + with(stages) { + irCall(symbols.box, box.type).apply { + typeArguments[0] = declaration.type + arguments[0] = irGetObject(symbols.boxes) + arguments[1] = irString(declaration.name.asString()) + arguments[2] = serializer(declaration.type) + arguments[3] = declaration.initializer + } + } + } + return box + } + + override fun visitGetValue(expression: IrGetValue): IrExpression { + val box = boxes[expression.symbol.owner] ?: return expression + return stages.builder(owner.symbol, expression).run { + irCall(symbols.boxGetter, expression.type).apply { arguments[0] = irGet(box) } + } + } + + override fun visitSetValue(expression: IrSetValue): IrExpression { + expression.transformChildrenVoid(this) + val box = boxes[expression.symbol.owner] ?: return expression + return stages.builder(owner.symbol, expression).run { + irCall(symbols.boxSetter, expression.type).apply { + arguments[0] = irGet(box) + arguments[1] = expression.value + } + } + } + }) + } + + private fun canShare(variable: IrVariable, lambda: Lambda): Boolean { + val problem = when { + variable.initializer == null -> "give it a value where it is declared" + isNewStory(lambda.callee) -> "a story started here keeps copies of what it captures; pass the value instead" + else -> stages.types.problem(variable.type)?.let { "its value is saved, but $it" } + } ?: return true + stages.reporter.error(variable, StageMessages.sharedVar(variable.name.asString(), problem)) + return false + } + + /** `launchStory` and `StoryRuns.launch` start a story of their own, which cannot share the vars of this one. */ + private fun isNewStory(callee: IrFunction): Boolean { + val name = callee.name.asString() + if (name != "launchStory" && name != "launch") return false + val owner = (callee.parent as? IrClass)?.kotlinFqName ?: callee.kotlinFqName.parent() + return owner.asString().startsWith(StageSymbols.PACKAGE.asString()) + } + + private fun boxOf(variable: IrVariable): IrVariable = IrVariableImpl( + variable.startOffset, + variable.endOffset, + IrDeclarationOrigin.DEFINED, + IrVariableSymbolImpl(), + Name.identifier($$"$${variable.name.asString()}$box"), + symbols.storyBox.typeWith(variable.type), + isVar = false, + isConst = false, + isLateinit = false, + ).also { it.parent = variable.parent } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt index 7a741db31..b1397430f 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt @@ -13,7 +13,13 @@ internal object StageMessages { fun localClosure(description: String) = "A @State lambda cannot use $description declared outside it: its closure is not saved. Move it to the top level of the script and pass saved values as explicit arguments, or declare it inside the lambda" - fun mutableCapture(name: String) = "A @State lambda cannot capture 'var $name'; copy it into a val first" + fun mutableCapture(name: String) = + "A @State lambda can share 'var $name' only with the @State function or lambda that declares it directly; declare it there or copy it into a val" + + fun localFunction(name: String, problem: String) = + "A @State lambda calls local function '$name', which is copied out of its scope with what it uses, but $problem" + + fun sharedVar(name: String, problem: String) = "A @State lambda shares 'var $name' with the stage that declares it, but $problem" fun assignCapture(name: String) = "A @State lambda cannot assign '$name': it keeps a saved copy" diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt index 4712d7b99..2d7a24d82 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt @@ -10,6 +10,9 @@ internal object StageValueTypes { val SERIALIZABLE_ID = ClassId.topLevel(SERIALIZABLE) val STAGE_LAMBDA = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StageLambda")).asString() + /** The box a shared var becomes; it is saved as its value. */ + val STORY_BOX = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StoryBox")).asString() + val PRIMITIVE_ARRAYS = setOf( "kotlin.IntArray", "kotlin.LongArray", "kotlin.ShortArray", "kotlin.ByteArray", "kotlin.FloatArray", "kotlin.DoubleArray", "kotlin.BooleanArray", "kotlin.CharArray", diff --git a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt index d129c9833..bc94699aa 100644 --- a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -36,7 +36,7 @@ class StageAnalysisTest { val error = errors.firstOrNull { it.message.contains("local function 'playSing'") } assertTrue(error != null, errors.toString()) assertEquals(5, error.range.start.line) - assertTrue(error.message.contains("top level")) + assertTrue(error.message.contains("'timer', which cannot be saved"), error.message) } @Test @@ -48,7 +48,7 @@ class StageAnalysisTest { runs.launch("main") { log(counter.toString()); log(lock.toString()) } } """) - assertTrue(errors.any { it.message.contains("cannot capture 'var counter'") }, errors.toString()) + assertTrue(errors.any { it.message.contains("can share 'var counter' only with the @State function or lambda that declares it directly") }, errors.toString()) assertTrue(errors.any { it.message.contains("'lock' cannot be saved") }, errors.toString()) } diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt index cbe7ede02..2d46b71ea 100644 --- a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -160,7 +160,7 @@ class StageCompileErrorsTest { @Test fun `a stage lambda saves what it captures so it can capture only values that can be saved`() { assertError( - "cannot capture 'var counter'", """ + "can share 'var counter' only with the @State function or lambda that declares it directly", """ fun start(runs: StoryRuns) { var counter = 1 counter++ diff --git a/addons/compiler/src/test/kotlin/StageFeaturesTest.kt b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt index 8af375502..73f0906e0 100644 --- a/addons/compiler/src/test/kotlin/StageFeaturesTest.kt +++ b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt @@ -1,4 +1,6 @@ import kotlinx.coroutines.test.runTest +import ru.hollowhorizon.hollowengine.addons.narrative.story.StageEntry +import ru.hollowhorizon.hollowengine.addons.narrative.story.StageScript import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -195,4 +197,21 @@ class StageFeaturesTest : StageTest() { assertTrue(world.runs.keys.isEmpty(), "the story cannot go on with an argument it cannot read") world.stop() } + + @Test + fun `stages of a node script compile with the definition nodes have in the game`() { + val compiled = compiler.compileAs("arena.node.kts", $$""" + @file:Import("./enums/Lane.kts") + + @State("main") + suspend fun main() { + Lane.entries.forEach { lane -> + async("lane_${lane.name}") { log(lane.name) } + } + } + """, mapOf("enums/Lane.kts" to "enum class Lane { LEFT, RIGHT }")).getOrThrow() + val type = compiled.type.java + assertTrue(type.isAnnotationPresent(StageScript::class.java)) + assertEquals(setOf("main", "main\$async#1"), type.declaredMethods.mapNotNull { it.getAnnotation(StageEntry::class.java)?.name }.toSet()) + } } diff --git a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt index 4b39120d7..319cf69bb 100644 --- a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt +++ b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertEquals /** A moved stage must not retain hidden closures into the function that created it. */ class StageLambdaSafetyTest : StageTest() { @Test - fun `a branch cannot call an enclosing local function with hidden mutable captures`() { + fun `a branch calls an enclosing local function only when what it uses can be saved`() { val errors = compiler.errors(""" @State suspend fun main() { @@ -23,7 +23,7 @@ class StageLambdaSafetyTest : StageTest() { } """) assertContains(errors, "local function 'playSing'") - assertContains(errors, "top level") + assertContains(errors, "'timer', which cannot be saved") assertFalse(errors.contains("Exception while generating code"), errors) } diff --git a/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt new file mode 100644 index 000000000..9d1fbaf26 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt @@ -0,0 +1,176 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +/** A `var` of a stage that its `@State` lambdas capture is one value for all of them, and the story saves it. */ +class StageSharedVarsTest : StageTest() { + @Test + fun `a branch changes a var of its stage, and the value survives a restart`() = runTest { + val world = world("sharedCounter", $$""" + @State + suspend fun main() { + var count = 0 + async("counter") { + count++ + log("branch $count") + await("more") + count++ + log("branch $count") + } + checkpoint("started") + await("check") + log("main $count") + } + """ + ) + world.launch("main") + world.release("more") + assertEquals(listOf("branch 1", "branch 2"), StageTrace.take()) + + world.restart() + world.release("check") + assertEquals(listOf("main 2"), StageTrace.take(), "main reads the value the branch left") + world.stop() + } + + @Test + fun `a segment that repeats keeps sharing the box with the branch it started before`() = runTest { + val world = world("sharedRepeat", $$""" + @State + suspend fun main() { + var count = 0 + async("counter") { + await("tick") + count++ + log("branch $count") + } + await("check") + log("main $count") + } + """ + ) + world.launch("main") + world.restart() + world.release("tick") + world.release("check") + assertEquals(listOf("branch 1", "main 1"), StageTrace.take()) + world.stop() + } + + @Test + fun `every loop iteration declares a var of its own`() = runTest { + val world = world("sharedLoop", $$""" + @State + suspend fun main() { + for (i in 0 until 2) { + var hits = i * 10 + async("b$i") { + hits++ + log("b$i $hits") + } + } + await("x") + } + """ + ) + world.launch("main") + assertEquals(listOf("b0 1", "b1 11"), StageTrace.take()) + world.stop() + } + + @Test + fun `a var of a stage lambda is shared with the lambdas inside it`() = runTest { + val world = world("sharedNested", $$""" + @State + suspend fun main() { + async("outer") { + var seen = "none" + async("inner") { seen = "inner" } + await("x") + log("outer sees $seen") + } + await("y") + } + """ + ) + world.launch("main") + world.release("x") + assertEquals(listOf("outer sees inner"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch calls a local function of its stage that shares a var and starts branches of its own`() = runTest { + val world = world("sharedLocal", $$""" + @State + suspend fun main() { + var last = "none" + suspend fun sing(name: String) { + log("sing $name after $last") + last = name + async("reset") { + await("r") + log("reset $name") + } + } + listOf("left", "right").forEach { direction -> + async("input_$direction") { + sing(direction) + await("hold") + } + } + await("end") + log("last $last") + } + """ + ) + world.launch("main") + assertEquals(listOf("sing left after none", "sing right after left"), StageTrace.take()) + + world.restart() + assertEquals(listOf("sing left after none", "sing right after left"), StageTrace.take(), "the segment repeats with its branches") + + world.release("r") + world.release("end") + assertEquals(listOf("reset left", "reset right", "last right"), StageTrace.take()) + world.stop() + } + + @Test + fun `a story started from a stage cannot share its vars`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns) {} + + @State + suspend fun main() { + var count = 0 + context.server.launchStory("other") { count++ } + } + """) + assertContains(errors, "keeps copies of what it captures") + } + + @Test + fun `a var declared outside a stage cannot be shared`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns) { + var count = 0 + runs.launch("story") { count++ } + } + """) + assertContains(errors, "only with the @State function or lambda that declares it directly") + } + + @Test + fun `a shared var must hold a value the story can save`() { + val errors = compiler.errors(""" + @State + suspend fun main() { + var lock = Any() + async("b") { lock = Any() } + } + """) + assertContains(errors, "its value is saved") + } +} diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt index 59330dba8..a4b3c272b 100644 --- a/addons/compiler/src/test/kotlin/StageTestSupport.kt +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -14,6 +14,8 @@ import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryInstances import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryOwner import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRuns import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions +import ru.hollowhorizon.hollowengine.common.scripting.NODE_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript @@ -75,7 +77,13 @@ object StageTrace { } } -/** Compiles stage scripts with the real compiler and plugin. */ +/** What the narrative addon adds to node scripts in the game. */ +private val NARRATIVE_IMPORTS = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "ru.hollowhorizon.hollowengine.addons.narrative.npcs.*", +) + +/** Compiles stage scripts with the real compiler and plugin, as `.stage.kts` or as a node of the game. */ class StageCompiler : AutoCloseable { val environment = ScriptingEnvironmentImpl( javaHome = File(System.getProperty("java.home")), @@ -91,6 +99,9 @@ class StageCompiler : AutoCloseable { "StageTrace.await" ), ), + DefaultScriptDefinitions.providerFor("story.$NODE_SCRIPT_EXTENSION")!!.let { node -> + node.copy(defaultImports = node.defaultImports + NARRATIVE_IMPORTS + "StageTrace.log" + "StageTrace.await") + }, ), mappings = Mappings.EMPTY, ).also { ScriptingEnvironment.INSTANCE = it } @@ -107,8 +118,13 @@ class StageCompiler : AutoCloseable { return failure.toString() } - private fun compileFile(name: String, code: String): Result = - environment.compiler.compile(sources.resolve("$name.stage.kts").apply { writeText(code.trimIndent()) }) + /** Compiles [code] as the file [fileName], with [others] written beside it for it to import. */ + fun compileAs(fileName: String, code: String, others: Map = emptyMap()): Result { + others.forEach { (name, text) -> sources.resolve(name).apply { parentFile.mkdirs() }.writeText(text.trimIndent()) } + return environment.compiler.compile(sources.resolve(fileName).apply { writeText(code.trimIndent()) }) + } + + private fun compileFile(name: String, code: String): Result = compileAs("$name.stage.kts", code) override fun close() { sources.deleteRecursively() diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt new file mode 100644 index 000000000..9e6782d68 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt @@ -0,0 +1,108 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.nbt.CompoundTag + +/** + * A `var` of a stage that `@State` lambdas captured. The compiler turns the var into a box, so the stage and its + * lambdas read and write one value, and the story saves that value once, with the stage that declared it. + */ +class StoryBox internal constructor(internal val id: String, private val table: StoryBoxTable, value: T) { + var value: T = value + set(value) { + field = value + table.changed() + } +} + +/** Where the compiler gets the boxes of shared vars from. */ +object StoryBoxes { + /** + * The box of the var [name] the current stage declares, set to [initial]. The same declaration gets the same box + * when its segment repeats after a restart, so lambdas started before it keep sharing the value. + */ + @JvmStatic + fun box(name: String, serializer: KSerializer, initial: T): StoryBox { + val run = StoryRun.current() ?: throw StoryException("var $name is shared with a @State lambda, which only works while a story runs") + val frame = run.frames.lastOrNull() ?: throw StoryException("var $name is shared with a @State lambda outside a stage") + return run.root.boxes.boxFor(frame, name, serializer, initial) + } +} + +/** The boxes of shared vars of one story, saved with its tree. */ +internal class StoryBoxTable(private val root: StoryRun) { + private class Live(val box: StoryBox, val serializer: KSerializer) + + private val live = LinkedHashMap() + private var saved = CompoundTag() + private var next = 0L + + @Suppress("UNCHECKED_CAST") + fun boxFor(frame: StoryFrame, name: String, serializer: KSerializer, initial: T): StoryBox { + val key = "box\$$name#${frame.nextBoxOrdinal(name)}" + val id = frame.segment.getString(key).ifEmpty { + "${frame.stage}.$name#${next++}".also { id -> + frame.segment.putString(key, id) + frame.boxes += id + } + } + val box = live[id]?.box ?: StoryBox(id, this, initial).also { live[id] = Live(it, serializer) } + saved.remove(id) + box.value = initial + return box as StoryBox + } + + /** The box [id] a lambda or a restored local refers to. */ + fun resolve(id: String, serializer: KSerializer): StoryBox { + live[id]?.let { return it.box } + val tag = saved.get(id) ?: throw StoryException( + "A @State lambda refers to a var that is gone: the stage that declared it returned, or it belongs to another story" + ) + val box = StoryBox(id, this, StoryValues.decode(serializer, tag)) + live[id] = Live(box, serializer) + saved.remove(id) + return box + } + + /** The stage that declared [ids] returned. */ + fun release(ids: Collection) { + ids.forEach { id -> + live.remove(id) + saved.remove(id) + } + } + + fun changed() = root.changed() + + fun save(): CompoundTag = CompoundTag().apply { + putLong("next", next) + val values = saved.copy() + live.forEach { (id, entry) -> values.put(id, StoryValues.encode(entry.serializer, entry.box.value)) } + put("values", values) + } + + /** Puts back what [save] wrote; boxes are made again as the code that uses them asks for them. */ + fun load(tag: CompoundTag) { + live.clear() + saved = tag.getCompound("values").copy() + next = maxOf(next, tag.getLong("next")) + } +} + +/** A box is written as its id; reading it finds the box the story keeps under that id. */ +internal class StoryBoxSerializer(private val value: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.box", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: StoryBox) = encoder.encodeString(value.id) + + override fun deserialize(decoder: Decoder): StoryBox { + val id = decoder.decodeString() + val run = StoryRun.current() ?: throw StoryException("A shared var is only read while its story runs") + return run.root.boxes.resolve(id, value) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index b56aad15c..77cf1b08c 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -29,6 +29,16 @@ class StoryFrame internal constructor( internal var locals = CompoundTag() internal var result: Tag? = null internal var segment = CompoundTag() + set(value) { + field = value + boxOrdinals.clear() + } + + /** The boxes of shared vars this frame declared; they go when it returns. */ + internal val boxes = LinkedHashSet() + + /** How many times each shared var was declared in this segment since the frame last started running it. */ + private val boxOrdinals = HashMap() internal val children = LinkedHashSet() internal var trampolined = false @@ -257,6 +267,7 @@ class StoryFrame internal constructor( } this.hash = hash resuming = target >= 0 + boxOrdinals.clear() if (target < 0 || target % 2 == 1) run.journal.undoTemporary(run.path, if (target < 0) entryMark else segmentMark) return this } @@ -278,8 +289,12 @@ class StoryFrame internal constructor( children.forEach { run.closeChild(it) } children.clear() run.journal.undoTemporary(run.path, entryMark) + run.root.boxes.release(boxes) } + /** The next ordinal of a declaration of the shared var [name], which tells loop iterations apart. */ + internal fun nextBoxOrdinal(name: String): Int = boxOrdinals.merge(name, 1, Int::plus)!! - 1 + private fun positionOf(saved: String): Int { val call = saved.startsWith(CALL) val site = sites.indexOf(saved.removePrefix(if (call) CALL else AFTER)) @@ -298,6 +313,7 @@ class StoryFrame internal constructor( put("segment", segment) putLong("entryMark", entryMark) putLong("segmentMark", segmentMark) + if (boxes.isNotEmpty()) put("boxes", ListTag().apply { boxes.forEach { add(StringTag.valueOf(it)) } }) put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) }.copy() @@ -317,6 +333,7 @@ class StoryFrame internal constructor( segment = tag.getCompound("segment").copy() entryMark = tag.getLong("entryMark") segmentMark = tag.getLong("segmentMark") + tag.getList("boxes", Tag.TAG_STRING.toInt()).forEach { boxes += it.asString } tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index 285e86fb7..76ccdd00d 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -47,6 +47,7 @@ object StoryInstances { * every script class with [StageScript]; other instances are ignored. */ fun register(instance: Any, scope: CoroutineScope) { + checkStoryClasses(instance.javaClass) val job = scope.coroutineContext.job val added = LinkedHashMap() register(instance, job, added) @@ -153,6 +154,21 @@ object StoryInstances { } } + /** + * Reports a script that was compiled with stages but sees another copy of this addon: its stages and actions + * carry annotations stories do not recognize, so they would silently not exist. + */ + private fun checkStoryClasses(type: Class<*>) { + if (type.isAnnotationPresent(StageScript::class.java)) return + val seen = runCatching { Class.forName(StageScript::class.java.name, false, type.classLoader) }.getOrNull() ?: return + if (seen === StageScript::class.java) return + HollowEngine.LOGGER.error( + "Script {} runs with a copy of the narrative addon of its own, so stories cannot see its stages and actions. " + + "List 'hollowengine-narrative' in dependsOn of the project the script belongs to", + type.name, + ) + } + /** The namespace the actions of a script are registered in: that of the script, or `script` when unknown. */ private fun namespaceOf(instance: Any): String { val location = instance.javaClass.getAnnotation(StageScript::class.java)?.path ?: return "script" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index 248cecf2f..82b0fec76 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.ListTag +import net.minecraft.nbt.StringTag import net.minecraft.nbt.Tag import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRun.Companion.current @@ -29,6 +30,7 @@ class StoryRun internal constructor( internal val pending = ArrayDeque() internal val children = LinkedHashMap() private val ownVars = CompoundTag() + private val ownBoxes: StoryBoxTable? = if (parent == null) StoryBoxTable(this) else null private val ownRecords = CompoundTag() private val ownJournal = StoryJournal() private val ownHistory: StoryHistory? = if (parent == null) StoryHistory(this) else null @@ -51,6 +53,9 @@ class StoryRun internal constructor( /** Variables declared with [saved], shared by the whole tree of runs. */ internal val vars: CompoundTag get() = parent?.vars ?: ownVars + /** The vars stages share with their `@State` lambdas, saved with the tree like locals. */ + internal val boxes: StoryBoxTable get() = parent?.boxes ?: ownBoxes!! + /** What [once] and [remember] keep: shared by the tree, and left as they are by a rewind. */ internal val records: CompoundTag get() = parent?.records ?: ownRecords @@ -298,7 +303,7 @@ class StoryRun internal constructor( watched.clear() } - private val root: StoryRun get() = parent?.root ?: this + internal val root: StoryRun get() = parent?.root ?: this /** This run and the runs it is a branch of. */ private val lineage: Sequence get() = generateSequence(this) { it.parent } @@ -323,10 +328,12 @@ class StoryRun internal constructor( putString("status", (if (state == Status.RUNNING) Status.SLEEPING else state).name) if (stopping) putBoolean("stopping", true) put("frames", ListTag().apply { (frames + pending).forEach { add(it.save()) } }) - if (children.isNotEmpty()) put( - "children", - CompoundTag().apply { children.forEach { (name, run) -> put(name, run.saveTree()) } }) + if (children.isNotEmpty()) { + put("children", CompoundTag().apply { children.forEach { (name, run) -> put(name, run.saveTree()) } }) + put("order", ListTag().apply { children.keys.forEach { add(StringTag.valueOf(it)) } }) + } if (parent == null && !ownVars.isEmpty) put("vars", ownVars.copy()) + if (parent == null) put("boxes", ownBoxes!!.save()) result?.let { put("result", it.copy()) } failure?.let { putString("failure", it) } } @@ -336,10 +343,12 @@ class StoryRun internal constructor( stopping = tag.getBoolean("stopping") tag.getList("frames", Tag.TAG_COMPOUND.toInt()).forEach { pending += StoryFrame.load(this, it as CompoundTag) } val saved = tag.getCompound("children") - saved.allKeys.forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } + val order = tag.getList("order", Tag.TAG_STRING.toInt()).map { it.asString }.filter(saved::contains) + (order + saved.allKeys.filter { it !in order }.sorted()).forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } if (parent == null) { ownVars.allKeys.toList().forEach(ownVars::remove) ownVars.merge(tag.getCompound("vars").copy()) + ownBoxes!!.load(tag.getCompound("boxes")) } result = tag.get("result")?.copy() failure = tag.getString("failure").takeIf { tag.contains("failure") } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt index 1bf52d4d1..7f5b1c3d3 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -53,6 +53,7 @@ object StoryValues { classifier == Map::class -> MapSerializer(arguments[0], arguments[1]) classifier == Map.Entry::class -> EntrySerializer(arguments[0], arguments[1]) classifier.java.isArray && !classifier.java.componentType.isPrimitive -> ArraySerializer(elementClass(type), arguments[0]) + classifier == StoryBox::class -> StoryBoxSerializer(arguments[0]) classifier == Pair::class -> PairSerializer(arguments[0], arguments[1]) classifier == Triple::class -> TripleSerializer(arguments[0], arguments[1], arguments[2]) else -> runCatching { NBTFormat.serializersModule.serializer(type) }.getOrElse { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index 3b9e1157f..34227645c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -533,9 +533,10 @@ internal class HollowAddonRuntime( } /** - * Publishes the addon's `scripts/` directory as a namespace of its own. The addon jar and its - * bundled libraries go on the compilation classpath and its classloader becomes the parent of the - * compiled scripts, so an addon can move part of its own logic into scripts. + * Publishes the addon as a namespace of its own, with its `scripts/` directory if it has one. The addon jar + * and its bundled libraries go on the compilation classpath and its classloader becomes the parent of the + * compiled scripts, so an addon can move part of its own logic into scripts. An addon without scripts is + * published too: projects that list it in `dependsOn` reach its classes through it. */ private fun registerScripts( candidate: HollowAddonCandidate, @@ -552,7 +553,6 @@ internal class HollowAddonRuntime( fingerprint = descriptor.version, storageKey = candidate.fingerprint, ) - if (source.list().isEmpty()) return ScriptSourceLifecycle.install() ScriptRegistry.register(source) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt index 6c658bb0f..834d28f4d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt @@ -141,11 +141,21 @@ object ScriptRegistry { */ fun classLoader(namespace: String): ClassLoader? { val source = source(namespace) ?: return null - val dependencies = closure(namespace).filter { it !== source }.map(ScriptSource::classLoader).distinct() - if (dependencies.isEmpty()) return source.classLoader return synchronized(lock) { dependencyLoaders.getOrPut(namespace) { - HollowAddonClassLoader(emptyArray(), source.classLoader, dependencies) + val closure = closure(namespace) + closure.forEach { dependent -> + dependent.dependencies.filter { source(it) == null }.forEach { missing -> + HollowEngine.LOGGER.warn( + "Scripts of '{}' depend on '{}', which is not loaded: its classes are not available to them", + dependent.namespace, + missing, + ) + } + } + val dependencies = closure.filter { it !== source }.map(ScriptSource::classLoader).distinct() + if (dependencies.isEmpty()) source.classLoader + else HollowAddonClassLoader(emptyArray(), source.classLoader, dependencies) } } } From fcc4e6f1dd4c1c59a34ec159f2e3404fd2ed2840 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 16:50:37 +0300 Subject: [PATCH 13/25] Load scripts with namespace classloaders --- .../addons/narrative/story/StoryInstances.kt | 14 +++++++++++--- .../hollowengine/common/scripting/ScriptLoader.kt | 7 +++---- .../common/scripting/nodes/NodeAttachTargets.kt | 2 +- .../common/scripting/reload/ScriptSides.kt | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index 76ccdd00d..f1938a26b 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -160,12 +160,17 @@ object StoryInstances { */ private fun checkStoryClasses(type: Class<*>) { if (type.isAnnotationPresent(StageScript::class.java)) return - val seen = runCatching { Class.forName(StageScript::class.java.name, false, type.classLoader) }.getOrNull() ?: return - if (seen === StageScript::class.java) return + val seen = runCatching { Class.forName(StageScript::class.java.name, false, type.classLoader) }.getOrNull() + val problem = when { + seen != null && seen !== StageScript::class.java -> "runs with a copy of the narrative addon of its own" + seen == null && type.declaredMethods.any { it.name.startsWith(ENTRY_PREFIX) } -> "declares stages but cannot see the narrative addon" + else -> return + } HollowEngine.LOGGER.error( - "Script {} runs with a copy of the narrative addon of its own, so stories cannot see its stages and actions. " + + "Script {} {}, so stories cannot see its stages and actions. " + "List 'hollowengine-narrative' in dependsOn of the project the script belongs to", type.name, + problem, ) } @@ -187,6 +192,9 @@ object StoryInstances { private fun stagesOf(type: Class<*>): Stages = stages.get(type) + /** How the compiler names the entries it generates for stages. */ + private const val ENTRY_PREFIX = "stage\$entry\$" + /** The generated methods of a script class: entries that start or resume a stage, calls of stage lambdas. */ private class Stages(val entries: Map, val calls: Map, val results: Map) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt index f78de40ed..acffe3236 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt @@ -157,10 +157,9 @@ object ScriptLoader { } } - private fun load(id: ScriptId, jar: File): CompiledScript { - val classLoader = ScriptRegistry.source(id.namespace)?.classLoader - return jar.loadKotlinCompiledScriptFromJar(classLoader) - } + /** Loads a compiled artifact in the classloader a fresh compilation would run in, dependencies included. */ + private fun load(id: ScriptId, jar: File): CompiledScript = + jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.classLoader(id.namespace)) private const val SCRIPT_EXTENSION = ".kts" private const val MAX_CAUSE_DEPTH = 8 diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt index 0a22827a5..affd3a970 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt @@ -37,7 +37,7 @@ object NodeAttachTargets { fun of(id: ScriptId): AttachTarget { val file = ScriptRegistry.artifacts(id)?.sourceFile ?: return AttachTarget.Unknown val stamp = runCatching { file.lastModified() }.getOrDefault(0L) - val classLoader = ScriptRegistry.source(id.namespace)?.classLoader ?: NodeAttachTargets::class.java.classLoader + val classLoader = ScriptRegistry.classLoader(id.namespace) ?: NodeAttachTargets::class.java.classLoader val key = file.path cache[key]?.takeIf { it.stamp == stamp && it.classLoader === classLoader }?.let { return it.target } val target = read(file, id.fileName, classLoader) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt index 2ea142644..144937132 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt @@ -14,7 +14,7 @@ object ScriptSides { artifacts.sourceFile?.let { source -> return declaresClientSide(source.readText()) } val jar = artifacts.precompiled ?: return false return runCatching { - jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.source(id.namespace)?.classLoader).isClientSide + jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.classLoader(id.namespace)).isClientSide }.onFailure { error -> HollowEngine.LOGGER.error("Cannot tell the side of '{}' from its compiled artifact", ScriptRegistry.display(id), error) }.getOrDefault(false) From 207ff6c41d9d4ecb2a20f18c9178dfc5b71713cf Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 17:31:26 +0300 Subject: [PATCH 14/25] Add story markers to the IDE gutter Show stage, checkpoint, and reversible-action markers with localized tooltips, and keep gutter markers and run targets aligned while edits are pending analysis. Also report when captured mutable variables need an initializer. --- .../common/ide/session/AnalysisEnvironment.kt | 2 +- .../structure/KotlinEditorStructure.kt | 38 ++++++++++++++++--- .../plugin/fir/stages/StageFirBodyChecker.kt | 25 ++++++++++++ .../plugin/fir/stages/StageFirCallChecker.kt | 1 + .../src/test/kotlin/EditorStructureTest.kt | 21 ++++++++++ .../src/test/kotlin/StageCompileErrorsTest.kt | 13 +++++++ .../client/ui/ide/HollowIdeEditorAdapters.kt | 21 +++++++++- .../client/ui/ide/HollowIdeGutter.kt | 2 +- .../common/scripting/ide/EditorStructure.kt | 6 ++- .../en_us/hollowengine/gui/ide/gutter.json | 3 ++ .../ru_ru/hollowengine/gui/ide/gutter.json | 3 ++ .../textures/gui/icons/gutter/action.svg | 1 + .../textures/gui/icons/gutter/checkpoint.svg | 1 + .../textures/gui/icons/gutter/stage.svg | 1 + .../client/ui/ide/EditorStructureShiftTest.kt | 37 ++++++++++++++++++ 15 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg create mode 100644 runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt index fdff60cf6..22db52f2b 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt @@ -512,7 +512,7 @@ class AnalysisEnvironment( implementationClassName, DefaultPluginDescriptor("hollowengine-analysis-api"), ) - point.registerExtension(extension, LoadingOrder.ANY, projectDisposable) + point.registerExtension(extension, LoadingOrder.ANY, kotlinCoreProjectEnvironment.environment.parentDisposable) } private fun MockProject.registerFirAnalysisServices() { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt index 4db9eb335..1b7b521cd 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.kdoc.psi.api.KDoc import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf @@ -73,6 +74,7 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur val symbol = declaration.markerSymbol() if (symbol is KaNamedFunctionSymbol) { if (EventClassId in symbol.annotations) markers += GutterMarker(offset, GutterMarkerKind.EVENT) + if (ActionClassId in symbol.annotations) markers += GutterMarker(offset, GutterMarkerKind.ACTION) if (node && !attached && StateClassId in symbol.annotations && declaration is KtNamedFunction && declaration.parent == file.script?.blockExpression && symbol.isSuspend && symbol.valueParameters.isEmpty() && !symbol.isExtension && symbol.contextParameters.isEmpty() && declaration.bodyExpression != null) { val annotation = symbol.annotations.firstOrNull { it.classId == StateClassId } val value = annotation?.arguments?.firstOrNull { it.name.asString() == "name" }?.expression @@ -99,11 +101,13 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur else -> null } ?: return@forEach val call = (expression.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> ?: return@forEach - if ((call.symbol as? KaNamedFunctionSymbol)?.isSuspend == true && coroutineLines.add( - file.text.lastIndexOf( - '\n', offset - ) + 1 - ) + val line = file.text.lastIndexOf('\n', offset) + 1 + stageMarker(expression, call)?.let { marker -> + markers += marker.copy(offset = offset) + coroutineLines += line + return@forEach + } + if ((call.symbol as? KaNamedFunctionSymbol)?.isSuspend == true && coroutineLines.add(line) ) { markers += GutterMarker(offset, GutterMarkerKind.COROUTINE) } @@ -151,5 +155,27 @@ internal fun PsiElement.fold(placeholder: String): CodeFold? = private val AttachClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.Attach")) -private val StateClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.addons.narrative.story.State")) +private val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") +private val StateClassId = ClassId(StoryPackage, Name.identifier("State")) +private val ActionClassId = ClassId(StoryPackage, Name.identifier("Action")) +private val CheckpointName = Name.identifier("checkpoint") + +/** + * The marker of a boundary of a story stage at [expression]: a named checkpoint, or the call of a stage or a stage + * lambda, after which a restart and `rewind()` resume. The offset is filled in by the caller. + */ +private fun KaSession.stageMarker(expression: KtExpression, call: KaFunctionCall<*>): GutterMarker? { + val symbol = call.symbol as? KaNamedFunctionSymbol ?: return null + if (symbol.callableId?.packageName == StoryPackage && symbol.name == CheckpointName) { + val name = (expression as? KtCallExpression)?.valueArguments?.firstOrNull()?.getArgumentExpression() + ?.let { (it as? KtStringTemplateExpression)?.entries?.singleOrNull() as? KtLiteralStringTemplateEntry }?.text + return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name ?: "?")) + } + if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) + val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type + if (symbol.name.asString() == "invoke" && receiver != null && StateClassId in receiver.annotations) { + return GutterMarker(0, GutterMarkerKind.STAGE, listOf((expression as? KtCallExpression)?.calleeExpression?.text ?: "invoke")) + } + return null +} private val EventClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.events.SubscribeEvent")) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt index 3682c14d5..45f082b9f 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt @@ -5,7 +5,12 @@ import org.jetbrains.kotlin.diagnostics.reportOn import org.jetbrains.kotlin.fir.FirElement import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction +import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.declarations.FirNamedFunction +import org.jetbrains.kotlin.fir.declarations.FirScript +import org.jetbrains.kotlin.fir.declarations.getAnnotationByClassId +import org.jetbrains.kotlin.fir.declarations.getStringArgument import org.jetbrains.kotlin.fir.declarations.hasAnnotation import org.jetbrains.kotlin.fir.expressions.* import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList @@ -14,12 +19,26 @@ import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.types.coneType import org.jetbrains.kotlin.fir.types.resolvedType import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import org.jetbrains.kotlin.name.Name import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages /** Structural restrictions are checked on FIR so the editor can report them before the IR rewrite. */ @OptIn(SymbolInternals::class) internal object StageFirBodyChecker { + /** The names of the functions of the script being checked, the names their `@State` gives them included. */ + @OptIn(DirectDeclarationsAccess::class) + context(context: CheckerContext) + private fun functionNames(): Set { + val script = context.containingDeclarations.map { it.fir }.filterIsInstance().firstOrNull() ?: return emptySet() + return script.declarations.filterIsInstance().flatMapTo(HashSet()) { function -> + listOfNotNull( + function.name.asString(), + function.getAnnotationByClassId(StageFirIds.STATE, context.session)?.getStringArgument(Name.identifier("name")), + ) + } + } + context(context: CheckerContext, reporter: DiagnosticReporter) fun check(function: FirFunction, stage: Boolean) { val stageLambdas = HashSet() @@ -103,6 +122,12 @@ internal object StageFirBodyChecker { !checkpoints.add(name) -> reporter.reportOn( call.source, StageDiagnostics.STAGE_ERROR, "Checkpoint '$name' appears twice in this stage" ) + + name in functionNames() -> reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "A checkpoint cannot be named '$name': the script already has a function with this name, and rewind(\"$name\") would read as either" + ) } } }) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt index 526a8f26c..9f573e788 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -197,6 +197,7 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm symbol is FirLocalPropertySymbol && symbol.fir.isVar -> when { !isShareable(symbol) -> StageMessages.mutableCapture(name) startsStory -> StageMessages.sharedVar(name, "a story started here keeps copies of what it captures; pass the value instead") + symbol.fir.initializer == null -> StageMessages.sharedVar(name, "give it a value where it is declared") else -> symbol.resolvedReturnType.stageProblem(context.session)?.let { StageMessages.sharedVar(name, "its value is saved, but $it") } } diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index 3a68b992a..ab21d0027 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -156,6 +156,27 @@ class EditorStructureTest { assertEquals(0, targets.first().offset) } + @Test + fun `story stages mark their boundaries, checkpoints and actions in the gutter`() { + val source = """ + import ru.hollowhorizon.hollowengine.addons.narrative.story.* + @Action suspend fun place(name: String) = action { perform { } } + @State suspend fun step() {} + @State suspend fun main() { + step() + checkpoint("arena") + place("x") + } + """.trimIndent() + val markers = analyzer.structure("Story.kts", source).markers + assertEquals(GutterMarkerKind.ACTION, markers.single { it.offset == source.indexOf("place(name") }.kind) + val stage = markers.single { it.kind == GutterMarkerKind.STAGE } + assertEquals(source.indexOf(" step()") + 4, stage.offset) + assertEquals(listOf("step"), stage.args) + assertEquals(listOf("arena"), markers.single { it.kind == GutterMarkerKind.CHECKPOINT }.args) + assertEquals(GutterMarkerKind.COROUTINE, markers.single { it.offset == source.indexOf("place(\"x") }.kind) + } + @Test fun `attached nodes have no server run targets`() { val text = diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt index 2d46b71ea..3bf1fd72e 100644 --- a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -233,4 +233,17 @@ class StageCompileErrorsTest { suspend fun hold(@Unsaved lock: Any) = action { perform { } } """ ) + + @Test + fun `a checkpoint cannot share its name with a function of the script`() = assertError( + "A checkpoint cannot be named 'arena'", """ + @State suspend fun arena() {} + + @State + suspend fun main() { + checkpoint("arena") + arena() + } + """ + ) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt index 01e81f55b..c5395deb4 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt @@ -196,7 +196,7 @@ internal class HollowIdeEditorSession( val current = snapshot if (current.matchesText(text, analyzer)) return current.structure requestAnalysis(text, analyzer) - return EditorStructure(folds = shiftCodeFoldsThroughEdit(current.text, text, current.structure.folds)) + return shiftEditorStructureThroughEdit(current.text, text, current.structure) } val canFormat: Boolean get() = currentAnalyzer().canFormat @@ -654,6 +654,25 @@ private const val OccurrenceBackgroundAlpha = 0.24f private val OccurrenceFallbackBackground = UiColor(0.66f, 0.72f, 0.78f, OccurrenceBackgroundAlpha) private val OccurrenceFallbackStyle = UiInlineStyle().withBackground(OccurrenceFallbackBackground) +/** + * [structure] of [originalText] as it applies to [editedText]: what lies before or after the edit moves with the + * text, and what the edit touched waits for the next analysis. + */ +internal fun shiftEditorStructureThroughEdit(originalText: String, editedText: String, structure: EditorStructure): EditorStructure { + if (originalText == editedText) return structure + val edit = uiTextEditSpan(originalText, editedText) + fun shifted(offset: Int): Int? = when { + offset < edit.oldStart -> offset + offset >= edit.oldEnd -> offset + edit.delta + else -> null + } + return EditorStructure( + folds = shiftCodeFoldsThroughEdit(originalText, editedText, structure.folds), + markers = structure.markers.mapNotNull { marker -> shifted(marker.offset)?.let { marker.copy(offset = it) } }, + runTargets = structure.runTargets.mapNotNull { target -> shifted(target.offset)?.let { target.copy(offset = it) } }, + ) +} + internal fun shiftDiagnosticsForEditedText( originalText: String, editedText: String, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt index cdcb19c6d..ed1026a07 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt @@ -23,7 +23,7 @@ internal fun ideGutterMarkers( structure.markers.forEach { marker -> val kind = marker.kind.name.lowercase() add(UiGutterMarker("$kind-${marker.offset}", marker.offset, - "hollowengine:textures/gui/icons/gutter/$kind.svg", "hollowengine.gui.ide.gutter.$kind".lang, + "hollowengine:textures/gui/icons/gutter/$kind.svg", "hollowengine.gui.ide.gutter.$kind".lang(*marker.args.toTypedArray()), listOf("gutter-$kind"), onClick = if (!marker.kind.navigable) null else { { if (file.text == analyzedText) navigate(marker.offset) } })) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 23dd8d04a..50e3a12d5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt @@ -21,11 +21,15 @@ enum class GutterMarkerKind(val navigable: Boolean) { OVERRIDE(true), COROUTINE(false), EVENT(false), + STAGE(false), + CHECKPOINT(false), + ACTION(false), } data class GutterNavigationTarget(val label: String, val location: DefinitionLocation) -data class GutterMarker(val offset: Int, val kind: GutterMarkerKind) +/** [args] fill in the tooltip of the kind, such as the name of a checkpoint. */ +data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList()) enum class ScriptRunKind { PLAIN, NODE } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json index 824985a8d..b1a78e782 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json @@ -4,6 +4,9 @@ "override": "Go to overridden declaration", "coroutine": "Suspend function call", "event": "Event handler", + "stage": "Stage %s: once it returns, a restart and rewind() continue below this line", + "checkpoint": "Checkpoint %1$s: rewind(\"%1$s\") returns here, and a restart continues below", + "action": "Reversible action: the story journal can undo it", "run": "Run script", "run_state": "Run state %s", "stop": "Stop script", diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json index 4d26aa641..2062871ae 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json @@ -4,6 +4,9 @@ "override": "Перейти к переопределённой декларации", "coroutine": "Вызов suspend-функции", "event": "Обработчик события", + "stage": "Стадия %s: когда она вернётся, рестарт и rewind() продолжат ниже этой строки", + "checkpoint": "Чекпоинт %1$s: rewind(\"%1$s\") возвращает сюда, рестарт продолжает ниже", + "action": "Обратимое действие: журнал истории может его отменить", "run": "Запустить скрипт", "run_state": "Запустить состояние %s", "stop": "Остановить скрипт", diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg new file mode 100644 index 000000000..fe1c31ba2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg @@ -0,0 +1 @@ + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg new file mode 100644 index 000000000..167cc4119 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg @@ -0,0 +1 @@ + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg new file mode 100644 index 000000000..7fa5b6cc7 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg @@ -0,0 +1 @@ + diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt new file mode 100644 index 000000000..f7d406cbc --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import ru.hollowhorizon.hollowengine.common.scripting.ide.EditorStructure +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptRunKind +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptRunTarget +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The gutter keeps its markers between an edit and the analysis of the edited text. */ +class EditorStructureShiftTest { + private val original = "fun first() {}\n@State suspend fun second() {}\nfun third() {}" + private val structure = EditorStructure( + markers = listOf( + GutterMarker(original.indexOf("first"), GutterMarkerKind.EVENT), + GutterMarker(original.indexOf("third"), GutterMarkerKind.OVERRIDE), + ), + runTargets = listOf(ScriptRunTarget(original.indexOf("second"), ScriptRunKind.NODE, "second")), + ) + + @Test + fun `markers before and after an edit move with the text`() { + val edited = original.replace("fun first() {}", "fun first() { work() }") + val shifted = shiftEditorStructureThroughEdit(original, edited, structure) + assertEquals(listOf(edited.indexOf("first"), edited.indexOf("third")), shifted.markers.map { it.offset }) + assertEquals(edited.indexOf("second"), shifted.runTargets.single().offset) + } + + @Test + fun `a marker the edit went through waits for the next analysis`() { + val edited = original.replace("fun third", "fun rd") + val shifted = shiftEditorStructureThroughEdit(original, edited, structure) + assertEquals(listOf(GutterMarkerKind.EVENT), shifted.markers.map { it.kind }) + assertEquals(1, shifted.runTargets.size) + } +} From 5958bb7efd1cdea9374ac4bdd46a3418d1abf21b Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 20:03:55 +0300 Subject: [PATCH 15/25] Rework story checkpoints and rewind semantics --- .../ide/session/ScriptingAnalyzerImpl.kt | 6 +- .../completion/CheckpointNameCompletions.kt | 51 ++++++++ .../structure/KotlinEditorStructure.kt | 22 +--- .../session/structure/StoryGutterMarkers.kt | 100 +++++++++++++++ .../plugin/fir/stages/StageFirCallChecker.kt | 45 +++++++ .../common/plugin/fir/stages/StageFirTypes.kt | 2 +- .../common/plugin/ir/stages/StageTypes.kt | 2 +- .../common/plugin/stages/StageDiagnostics.kt | 9 ++ .../common/plugin/stages/StageValueTypes.kt | 3 + .../src/test/kotlin/EditorStructureTest.kt | 24 ++++ .../src/test/kotlin/StageAnalysisTest.kt | 31 +++++ .../src/test/kotlin/StageJournalTest.kt | 119 ++++++++++-------- .../src/test/kotlin/StageSharedVarsTest.kt | 36 ++++++ .../addons/narrative/npcs/NpcActions.kt | 16 ++- .../addons/narrative/story/Actions.kt | 28 +++-- .../addons/narrative/story/StoryActions.kt | 29 +++-- .../addons/narrative/story/StoryApi.kt | 18 +-- .../addons/narrative/story/StoryCommands.kt | 13 +- .../narrative/story/StoryDeferredReplays.kt | 80 ++++++++++++ .../addons/narrative/story/StoryFrame.kt | 2 +- .../narrative/story/StoryHandleSerializer.kt | 22 ++++ .../addons/narrative/story/StoryHistory.kt | 78 ++++-------- .../addons/narrative/story/StoryInstances.kt | 13 ++ .../addons/narrative/story/StoryJournal.kt | 50 ++++---- .../addons/narrative/story/StoryOwners.kt | 11 ++ .../addons/narrative/story/StoryRun.kt | 29 +++-- .../addons/narrative/story/StoryRuns.kt | 14 +++ .../addons/narrative/story/StoryValues.kt | 4 +- .../hollowengine-narrative/lang/en_us.json | 3 +- .../hollowengine-narrative/lang/ru_ru.json | 3 +- .../client/ui/ide/HollowIdeGutter.kt | 7 +- .../client/ui/widgets/UiEditorGutter.kt | 9 +- .../common/scripting/ScriptFailedEvent.kt | 13 ++ .../common/scripting/ide/EditorStructure.kt | 18 ++- .../scripting/nodes/EntityNodeRuntime.kt | 20 +-- .../common/scripting/nodes/NodeSystem.kt | 16 ++- .../scripting/reload/ReloadScriptRunner.kt | 2 + .../scripting/startup/StartupScripts.kt | 2 + .../en_us/hollowengine/gui/ide/gutter.json | 4 + .../ru_ru/hollowengine/gui/ide/gutter.json | 4 + .../textures/gui/icons/gutter/checkpoint.svg | 2 +- .../textures/gui/icons/gutter/rewind.svg | 1 + .../textures/gui/icons/gutter/temporary.svg | 1 + 43 files changed, 715 insertions(+), 247 deletions(-) create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt create mode 100644 addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt create mode 100644 addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt index c2c6695bc..9f607b272 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.jetbrains.kotlin.psi.KtUserType import ru.hollowhorizon.hollowengine.common.files.DirectoryManager +import ru.hollowhorizon.hollowengine.common.ide.session.completion.checkpointNameCompletions import ru.hollowhorizon.hollowengine.common.ide.session.completion.createCompletions import ru.hollowhorizon.hollowengine.common.ide.session.completion.findAcceptedExpressionTypes import ru.hollowhorizon.hollowengine.common.ide.session.definition.LibraryNavigation @@ -309,7 +310,10 @@ class ScriptingAnalyzerImpl internal constructor( } override fun completions(name: String, text: String, offset: Int, sink: CompletionSink) = synchronized(lock) { - createCompletions(getOrCreateFile(name, text), offset, sink) + val file = getOrCreateFile(name, text) + val checkpoints = checkpointNameCompletions(file, offset) + if (checkpoints != null) sink.emit(checkpoints) else createCompletions(file, offset, sink) + Unit } override fun definition(name: String, text: String, offset: Int): DefinitionLocation? = synchronized(lock) { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt new file mode 100644 index 000000000..a5e4c286a --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt @@ -0,0 +1,51 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.completion + +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtValueArgument +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItemTag +import ru.hollowhorizon.hollowengine.common.scripting.ide.declarationCompletionItem + +/** + * The checkpoints of [file] for the string a story names one in, `rewind("…")` or `temporary(until = "…")`, or null + * when the caret at [offset] is not in such a string and the ordinary completion should run. + */ +internal fun checkpointNameCompletions(file: KtFile, offset: Int): List? { + val element = file.findElementAt(offset) ?: file.findElementAt(offset - 1) ?: return null + val template = element.getParentOfType(false) ?: return null + if (offset <= template.textRange.startOffset || offset >= template.textRange.endOffset) return null + val argument = template.parent as? KtValueArgument ?: return null + val call = argument.getParentOfType(true) ?: return null + val named = argument.getArgumentName()?.asName?.asString() + val namesCheckpoint = when (call.calleeExpression?.text) { + "rewind" -> named == "checkpoint" || named == null && call.valueArguments.firstOrNull() === argument + "temporary" -> named == "until" + else -> false + } + if (!namesCheckpoint) return null + val prefix = file.text.substring(template.textRange.startOffset + 1, offset) + return checkpointNames(file).filter { it.startsWith(prefix) }.map { name -> + declarationCompletionItem { + show = name + insert = name + this.name = name + tag = CompletionItemTag.PROPERTY + fqName = null + wordChars = "-." + } + } +} + +/** The names the checkpoints of [file] are given as constants. */ +internal fun checkpointNames(file: KtFile): List = file.collectDescendantsOfType() + .filter { it.calleeExpression?.text == "checkpoint" } + .mapNotNull { call -> + val template = call.valueArguments.firstOrNull()?.getArgumentExpression() as? KtStringTemplateExpression + (template?.entries?.singleOrNull() as? KtLiteralStringTemplateEntry)?.text + } + .distinct() diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt index 1b7b521cd..abcea2143 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt @@ -92,6 +92,7 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur } } val coroutineLines = HashSet() + val story = StoryGutterMarkers(this, file) file.collectDescendantsOfType().forEach { expression -> val offset = when (expression) { is KtCallExpression -> expression.calleeExpression?.textOffset @@ -102,7 +103,7 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur } ?: return@forEach val call = (expression.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> ?: return@forEach val line = file.text.lastIndexOf('\n', offset) + 1 - stageMarker(expression, call)?.let { marker -> + story.markerFor(expression, call)?.let { marker -> markers += marker.copy(offset = offset) coroutineLines += line return@forEach @@ -158,24 +159,5 @@ private val AttachClassId = private val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") private val StateClassId = ClassId(StoryPackage, Name.identifier("State")) private val ActionClassId = ClassId(StoryPackage, Name.identifier("Action")) -private val CheckpointName = Name.identifier("checkpoint") -/** - * The marker of a boundary of a story stage at [expression]: a named checkpoint, or the call of a stage or a stage - * lambda, after which a restart and `rewind()` resume. The offset is filled in by the caller. - */ -private fun KaSession.stageMarker(expression: KtExpression, call: KaFunctionCall<*>): GutterMarker? { - val symbol = call.symbol as? KaNamedFunctionSymbol ?: return null - if (symbol.callableId?.packageName == StoryPackage && symbol.name == CheckpointName) { - val name = (expression as? KtCallExpression)?.valueArguments?.firstOrNull()?.getArgumentExpression() - ?.let { (it as? KtStringTemplateExpression)?.entries?.singleOrNull() as? KtLiteralStringTemplateEntry }?.text - return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name ?: "?")) - } - if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) - val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type - if (symbol.name.asString() == "invoke" && receiver != null && StateClassId in receiver.annotations) { - return GutterMarker(0, GutterMarkerKind.STAGE, listOf((expression as? KtCallExpression)?.calleeExpression?.text ?: "invoke")) - } - return null -} private val EventClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.events.SubscribeEvent")) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt new file mode 100644 index 000000000..0f0cdc8d6 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.structure + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.KaSuccessCallInfo +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import org.jetbrains.kotlin.psi.psiUtil.parents +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind + +/** + * The gutter markers of story code: stage calls, after which a restart continues, and checkpoints with the calls that + * refer to them. A checkpoint is colored by its name, and `rewind` and `temporary` get the color of the checkpoint + * they lead to, so a call that names none still shows which one it means. + */ +internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) { + private class Checkpoint(val owner: KtFunction?, val offset: Int, val name: String) + + private val checkpoints: List = file.collectDescendantsOfType() + .filter { it.calleeExpression?.text == CheckpointName.asString() && isStory(it, CheckpointName) } + .mapNotNull { call -> literal(call.valueArguments.firstOrNull())?.let { Checkpoint(owner(call), call.textOffset, it) } } + + /** The marker of [call] at [expression], at offset 0 for the caller to place, or null when it is no story call. */ + fun markerFor(expression: KtExpression, call: KaFunctionCall<*>): GutterMarker? = with(session) { + val symbol = call.symbol as? KaNamedFunctionSymbol ?: return null + val callExpression = expression as? KtCallExpression + if (symbol.callableId?.packageName == StoryPackage) when (symbol.name) { + CheckpointName -> { + val name = literal(callExpression?.valueArguments?.firstOrNull()) ?: "?" + return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name), color = name) + } + + RewindName -> { + val target = argument(callExpression, "checkpoint", 0) + ?: checkpoints.lastOrNull { it.owner == owner(expression) && it.offset < expression.textOffset }?.name + return target?.let { GutterMarker(0, GutterMarkerKind.REWIND, listOf(it), color = it) } + ?: GutterMarker(0, GutterMarkerKind.REWIND_START) + } + + TemporaryName -> { + val target = argument(callExpression, "until", null) + ?: checkpoints.firstOrNull { it.owner == owner(expression) && it.offset > expression.textOffset }?.name + return target?.let { GutterMarker(0, GutterMarkerKind.TEMPORARY, listOf(it), color = it) } + ?: GutterMarker(0, GutterMarkerKind.TEMPORARY_OPEN) + } + } + if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) + val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type + if (symbol.name.asString() == "invoke" && receiver != null && StateClassId in receiver.annotations) { + return GutterMarker(0, GutterMarkerKind.STAGE, listOf(callExpression?.calleeExpression?.text ?: "invoke")) + } + return null + } + + /** The code a checkpoint belongs to: the function or `@State` lambda it is written in, which is one run's. */ + private fun owner(element: PsiElement): KtFunction? = element.parents.filterIsInstance() + .firstOrNull { it is KtNamedFunction || it is KtFunctionLiteral && isStageLambda(it) } + + private fun isStageLambda(literal: KtFunctionLiteral): Boolean = with(session) { + val lambda = literal.parent as? KtLambdaExpression ?: return false + val call = lambda.getParentOfType(true) ?: return false + val resolved = (call.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> ?: return false + val parameter = resolved.argumentMapping[lambda] ?: return false + StateClassId in parameter.returnType.annotations + } + + private fun isStory(call: KtCallExpression, name: Name): Boolean = with(session) { + val symbol = ((call.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*>)?.symbol as? KaNamedFunctionSymbol + symbol?.callableId?.packageName == StoryPackage && symbol.name == name + } + + /** The constant string passed for [parameter], by name or, when [position] is given, in that place. */ + private fun argument(call: KtCallExpression?, parameter: String, position: Int?): String? { + val arguments = call?.valueArguments ?: return null + val argument = arguments.firstOrNull { it.getArgumentName()?.asName?.asString() == parameter } + ?: position?.let { arguments.getOrNull(it)?.takeIf { argument -> argument.getArgumentName() == null } } + return literal(argument) + } + + private fun literal(argument: ValueArgument?): String? { + val template = argument?.getArgumentExpression() as? KtStringTemplateExpression ?: return null + return (template.entries.singleOrNull() as? KtLiteralStringTemplateEntry)?.text + } + + private companion object { + val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val StateClassId = ClassId(StoryPackage, Name.identifier("State")) + val CheckpointName = Name.identifier("checkpoint") + val RewindName = Name.identifier("rewind") + val TemporaryName = Name.identifier("temporary") + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt index 9f573e788..8a31d7f0b 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -8,6 +8,7 @@ import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirFunctionCallChecker import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirFile import org.jetbrains.kotlin.fir.declarations.FirFunction import org.jetbrains.kotlin.fir.declarations.FirNamedFunction import org.jetbrains.kotlin.fir.declarations.FirProperty @@ -16,6 +17,7 @@ import org.jetbrains.kotlin.fir.declarations.FirScript import org.jetbrains.kotlin.fir.expressions.FirAnonymousFunctionExpression import org.jetbrains.kotlin.fir.expressions.FirCallableReferenceAccess import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.expressions.FirLiteralExpression import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression import org.jetbrains.kotlin.fir.expressions.FirVariableAssignment import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList @@ -38,6 +40,7 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm override fun check(expression: FirFunctionCall) { if (context.containingDeclarations.none { it.fir is FirScript }) return StageFirActions.checkCall(expression) + checkCheckpointName(expression) val mapping = (expression.argumentList as? FirResolvedArgumentList)?.mapping ?: return val startsStory = startsStory(expression) mapping.forEach { (argument, parameter) -> @@ -64,6 +67,48 @@ internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Comm } } + /** + * Warns about `rewind("x")` and `temporary(until = "x")` naming no checkpoint of the script. It is no error: + * another script whose stages the story calls may pass one under that name. + */ + context(context: CheckerContext, reporter: DiagnosticReporter) + private fun checkCheckpointName(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol.callableId?.packageName != StageFirIds.PACKAGE) return + val parameter = when (symbol.name.asString()) { + "rewind" -> "checkpoint" + "temporary" -> "until" + else -> return + } + val argument = (call.argumentList as? FirResolvedArgumentList)?.mapping?.entries + ?.firstOrNull { it.value.name.asString() == parameter }?.key as? FirLiteralExpression ?: return + val name = argument.value as? String ?: return + val file = context.containingDeclarations.map { it.fir }.filterIsInstance().firstOrNull() ?: return + if (name in checkpointNames(file)) return + reporter.reportOn( + argument.source, + StageDiagnostics.STAGE_WARNING, + "No checkpoint of this script is named '$name': it only works if another script the story runs passes one" + ) + } + + /** The constant names the checkpoints of [file] are given. */ + private fun checkpointNames(file: FirFile): Set { + val names = HashSet() + file.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirFunctionCall) { + val symbol = element.calleeReference.toResolvedCallableSymbol() + if (symbol?.callableId?.packageName == StageFirIds.PACKAGE && symbol.name.asString() == "checkpoint") { + ((element.argumentList.arguments.firstOrNull() as? FirLiteralExpression)?.value as? String)?.let(names::add) + } + } + element.acceptChildren(this) + } + }) + return names + } + /** `launchStory` and `StoryRuns.launch` start a story of their own, which keeps copies of what it captures. */ private fun startsStory(call: FirFunctionCall): Boolean { val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return false diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt index 6f3de2c94..624c7ee24 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt @@ -34,7 +34,7 @@ internal fun ConeKotlinType.stageProblem(session: FirSession): String? { if (this is ConeErrorType || isStageLambda(session)) return null val klass = toRegularClassSymbol(session) ?: return "$this is a type parameter, which cannot be saved" val name = klass.classId.asSingleFqName().asString() - if (name in StageValueTypes.BASIC || name in StageValueTypes.PRIMITIVE_ARRAYS) return null + if (name in StageValueTypes.BASIC || name in StageValueTypes.PRIMITIVE_ARRAYS || name == StageValueTypes.STORY_HANDLE) return null fun inherits(type: ConeKotlinType, visited: MutableSet = HashSet()): Boolean { val symbol = type.toRegularClassSymbol(session) ?: return false if (!visited.add(symbol.classId)) return false diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt index 2cc9478d7..9a73f7118 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -32,7 +32,7 @@ internal class StageTypes(context: IrPluginContext) { if (entity != null && type.isSubtypeOfClass(entity)) return null if (tag != null && type.isSubtypeOfClass(tag)) return null val name = klass.kotlinFqName.asString() - if (name in StageValueTypes.BASIC) return null + if (name in StageValueTypes.BASIC || name == StageValueTypes.STORY_HANDLE) return null if (isFunction(type)) { return "a function value cannot be saved; pass a lambda to a @State parameter instead" } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt index b201bc63a..dbbb9c65e 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt @@ -19,11 +19,20 @@ internal object StageDiagnostics : KtDiagnosticsContainer() { Messages, ) + val STAGE_WARNING = KtDiagnosticFactory1( + "HOLLOWENGINE_STAGE_WARNING", + Severity.WARNING, + SourceElementPositioningStrategies.DEFAULT, + PsiElement::class, + Messages, + ) + override fun getRendererFactory(): BaseDiagnosticRendererFactory = Messages object Messages : BaseDiagnosticRendererFactory() { override val MAP: KtDiagnosticFactoryToRendererMap by KtDiagnosticFactoryToRendererMap("HollowEngineStages") { map -> map.put(STAGE_ERROR, "{0}", CommonRenderers.STRING) + map.put(STAGE_WARNING, "{0}", CommonRenderers.STRING) } } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt index 2d7a24d82..c92de9f02 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt @@ -10,6 +10,9 @@ internal object StageValueTypes { val SERIALIZABLE_ID = ClassId.topLevel(SERIALIZABLE) val STAGE_LAMBDA = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StageLambda")).asString() + /** A handle of a run, saved as the path of the run whatever its result type. */ + val STORY_HANDLE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StoryHandle")).asString() + /** The box a shared var becomes; it is saved as its value. */ val STORY_BOX = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StoryBox")).asString() diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index ab21d0027..84ce7d9c9 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -177,6 +177,30 @@ class EditorStructureTest { assertEquals(GutterMarkerKind.COROUTINE, markers.single { it.offset == source.indexOf("place(\"x") }.kind) } + @Test + fun `rewind and temporary take the color of the checkpoint they lead to`() { + val source = """ + import ru.hollowhorizon.hollowengine.addons.narrative.story.* + @State suspend fun main() { + rewind() + checkpoint("arena") + temporary { } + rewind() + temporary(until = "boss") { } + rewind("arena") + checkpoint("boss") + } + """.trimIndent() + val markers = analyzer.structure("Colors.kts", source).markers + fun at(text: String, from: Int = 0) = markers.single { it.offset == source.indexOf(text, from) } + assertEquals(GutterMarkerKind.REWIND_START, at("rewind()").kind) + assertEquals("arena", at("checkpoint(\"arena").color) + assertEquals("boss", at("temporary {").color, "a temporary without until goes at the next checkpoint") + assertEquals("arena", at("rewind()", source.indexOf("temporary {")).color, "rewind goes to the checkpoint before it") + assertEquals("boss", at("temporary(until").color) + assertEquals("arena", at("rewind(\"arena").color) + } + @Test fun `attached nodes have no server run targets`() { val text = diff --git a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt index bc94699aa..5c16662a3 100644 --- a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -1,3 +1,4 @@ +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionSink import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -165,4 +166,34 @@ class StageAnalysisTest { """) assertTrue(errors.any { it.severity.isError() }, errors.toString()) } + + @Test + fun `a rewind to a checkpoint the script does not have is a warning`() { + val warnings = diagnostics(""" + @State + suspend fun main() { + checkpoint("arena") + rewind("arnea") + } + """).filter { !it.severity.isError() } + assertTrue(warnings.any { "No checkpoint of this script is named 'arnea'" in it.message }, warnings.toString()) + } + + @Test + fun `the names of checkpoints are offered inside rewind`() { + val source = """ + @State + suspend fun main() { + checkpoint("arena") + checkpoint("boss") + rewind("ar") + } + """.trimIndent() + val items = ArrayList() + compiler.environment.analyzer.completions("names.stage.kts", source, source.indexOf("\"ar\")") + 3, CompletionSink { batch -> + batch.forEach { items += it.show } + true + }) + assertEquals(listOf("arena"), items) + } } diff --git a/addons/compiler/src/test/kotlin/StageJournalTest.kt b/addons/compiler/src/test/kotlin/StageJournalTest.kt index 8d1c6ab13..96f585cda 100644 --- a/addons/compiler/src/test/kotlin/StageJournalTest.kt +++ b/addons/compiler/src/test/kotlin/StageJournalTest.kt @@ -1,13 +1,11 @@ import kotlinx.coroutines.test.runTest -import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -/** Actions in the journal: what is undone when, rewinds to checkpoints and back, labels, a restart in between. */ +/** Actions in the journal: what is undone when, rewinds to checkpoints, labels, a restart in between. */ class StageJournalTest : StageTest() { @Test - fun `a temporary action is undone when its stage returns and a permanent one stays`() = runTest { + fun `a temporary action stands until the next checkpoint of its run and a permanent one stays`() = runTest { val world = world("temporary", PLACE + $$""" @State suspend fun fight() { @@ -20,6 +18,8 @@ class StageJournalTest : StageTest() { suspend fun main() { fight() log("after ${StageTrace.blocks}") + checkpoint("won") + log("passed ${StageTrace.blocks}") } """ ) @@ -27,7 +27,32 @@ class StageJournalTest : StageTest() { assertEquals(listOf("place barricade", "place banner"), StageTrace.take()) world.release("win") - assertEquals(listOf("undo barricade", "after [banner]"), StageTrace.take()) + assertEquals(listOf("after [barricade, banner]", "undo barricade", "passed [banner]"), StageTrace.take()) + world.stop() + } + + @Test + fun `a temporary action until a named checkpoint goes when any run of the story passes it`() = runTest { + val world = world("until", PLACE + $$""" + @State + suspend fun main() { + temporary(until = "defeated") { place("gate") } + checkpoint("arena") + log("arena ${StageTrace.blocks}") + async("boss") { + await("kill") + checkpoint("defeated") + log("defeated ${StageTrace.blocks}") + } + await("end") + } + """ + ) + world.launch("main") + assertEquals(listOf("place gate", "arena [gate]"), StageTrace.take(), "another checkpoint leaves it standing") + + world.release("kill") + assertEquals(listOf("undo gate", "defeated []"), StageTrace.take()) world.stop() } @@ -80,43 +105,36 @@ class StageJournalTest : StageTest() { } @Test - fun `a redo brings back what a rewind undid until the story performs something new`() = runTest { - val world = world("redo", PLACE + """ + fun `rewind without a name goes back to the latest checkpoint, past the stage calls after it`() = runTest { + val world = world("latest", PLACE + """ + @State + suspend fun step() = log("step") + @State suspend fun main() { place("door") checkpoint("gate") - await("go") + step() place("bridge") - await("x") - log("end") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + log("done") } """ ) - val story = world.launch("main") - world.release("go") - StageTrace.hold("go") - story.rewind("gate") - world.settle() - assertEquals(listOf("place door", "place bridge", "undo bridge"), StageTrace.take()) - - story.redo() - world.settle() - assertEquals(listOf("forward bridge"), StageTrace.take()) - assertEquals("main@after:gate", story.state, "the story is back where the rewind took it from") - - story.rewind("gate") - world.release("go") - assertEquals(listOf("undo bridge", "place bridge"), StageTrace.take()) - assertFailsWith("a new action ends what could be redone") { story.redo() } - - world.release("x") - assertEquals(listOf("end"), StageTrace.take()) + StageTrace.token = "fail" + world.launch("main") + assertEquals( + listOf("place door", "step", "place bridge", "undo bridge", "step", "place bridge", "done"), + StageTrace.take(), + ) world.stop() } @Test - fun `rewind without a checkpoint repeats the segment the stage is in`() = runTest { + fun `rewind with no checkpoint before it starts its run over`() = runTest { val world = world("segment", PLACE + """ @State suspend fun attempt() { @@ -289,36 +307,29 @@ class StageJournalTest : StageTest() { } @Test - fun `a redo fast-forwards an action as a whole, not the actions it performed again`() = runTest { - val world = world("nested", PLACE + """ - @Action - suspend fun build() = action { - undo { log("undo build") } - perform { - place("left") - place("right") - } - } - + fun `rewind in a branch takes back the branch alone`() = runTest { + val world = world("branchRewind", PLACE + """ @State suspend fun main() { - checkpoint("empty") - await("go") - build() + place("wall") + async("guard") { + place("tower") + checkpoint("posted") + place("flag") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + await("g") + } await("x") } """ ) - val story = world.launch("main") - world.release("go") - StageTrace.hold("go") - story.rewind("empty") - world.settle() - story.redo() - world.settle() - val trace = StageTrace.take() - assertEquals(listOf("place left", "place right", "undo right", "undo left", "undo build"), trace.take(5)) - assertEquals(listOf("place left", "place right"), trace.drop(5), "the redo performs build once, its parts with it") + StageTrace.token = "fail" + world.launch("main") + assertEquals(listOf("place wall", "place tower", "place flag", "undo flag", "place flag"), StageTrace.take()) + assertEquals(setOf("wall", "tower", "flag"), StageTrace.blocks) world.stop() } diff --git a/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt index 9d1fbaf26..d847aefbc 100644 --- a/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt +++ b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt @@ -137,6 +137,42 @@ class StageSharedVarsTest : StageTest() { world.stop() } + @Test + fun `a shared var can hold the handle of a branch, which a restart finds again`() = runTest { + val world = world("sharedHandle", $$""" + @State + suspend fun main() { + var timer: StoryHandle<*>? = null + suspend fun sing(name: String) { + timer?.cancel() + timer = async("reset") { + await("r$name") + log("reset $name") + } + } + async("input") { + sing("left") + await("next") + sing("right") + checkpoint("sung") + await("hold") + } + checkpoint("listening") + await("end") + log("timer ${timer?.key}") + } + """ + ) + world.launch("main") + world.release("next") + world.restart() + world.release("rleft") + world.release("rright") + world.release("end") + assertEquals(listOf("reset right", "timer main/input/reset"), StageTrace.take(), "the cancelled timer stays cancelled") + world.stop() + } + @Test fun `a story started from a stage cannot share its vars`() { val errors = compiler.errors(""" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt index 450155249..2669f8d39 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.addons.narrative.npcs import kotlinx.serialization.Serializable +import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.Entity import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.addons.narrative.story.Action @@ -14,6 +15,7 @@ import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted import ru.hollowhorizon.hollowengine.common.npcs.navigation.* +import ru.hollowhorizon.hollowengine.common.npcs.registry.ref import ru.hollowhorizon.hollowengine.common.utils.currentServer import kotlin.math.atan2 import kotlin.math.sqrt @@ -238,10 +240,20 @@ fun NpcEntity.clearLookTarget() { /** Puts the NPC at [position] at once, as a fast-forwarded move ends. */ private fun NpcEntity.arriveAt(position: Vec3): MoveResult { navigation.stop() - teleportTo(position.x, position.y, position.z) + place(position, yRot, xRot) return MoveResult.Arrived } +/** + * Puts the NPC at [position], with a body or without one: an NPC whose chunk is unloaded is moved through the + * registry, which keeps it outside chunks, so an undo or a fast-forward need not wait for it. + */ +private fun NpcEntity.place(position: Vec3, yaw: Float, pitch: Float) { + val level = level() as? ServerLevel + if (level == null || ref.isLoaded) teleportTo(position.x, position.y, position.z) + else ref.teleportTo(level, position, yaw, pitch) +} + /** Turns the NPC to face [target] at once. */ private fun NpcEntity.faceAt(target: Vec3) { val dx = target.x - x @@ -257,7 +269,7 @@ internal class NpcPlacement(val x: Double, val y: Double, val z: Double, val rot fun applyTo(npc: NpcEntity) { npc.actions.cancel(NpcActionKeys.MOVEMENT) npc.navigation.stop() - npc.teleportTo(x, y, z) + npc.place(Vec3(x, y, z), rotation.body, rotation.pitch) rotation.applyTo(npc) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt index e2e20fda8..b56b8b314 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -9,7 +9,7 @@ import kotlin.coroutines.CoroutineContext /** * A reversible action. Its body is `action { }`: within a story every call is written to the journal, which can undo - * it on a rewind and fast-forward it on a redo, after a restart too. The journal keeps the receivers and parameters + * it on a rewind, after a restart too. The journal keeps the receivers and parameters * of the call, so they must be serializable; entities are kept as references. * * @param id the id the journal and editors know the action by, in the namespace of its script or addon; the @@ -35,10 +35,10 @@ annotation class SimpleAction(val id: String = "") @DslMarker annotation class StoryDsl -/** How long the journal keeps an action undoable, see [temporary]. */ +/** How long an action stands in the world, see [temporary]. */ enum class Lifetime { - /** Undone when the stage that performed it exits, or before the segment that performed it repeats. */ - STAGE, + /** Taken back when the story passes its checkpoint or ends, and before the segment that performed it repeats. */ + TEMPORARY, /** Undone only by a rewind; left in the world when the story ends. */ RUN, @@ -61,8 +61,8 @@ suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Pe val run = context[StoryRun.Element]?.run?.takeIf { context[Replaying] == null } val args = action.encode(call) val record = run?.let { - val lifetime = if (context[Temporary] != null) Lifetime.STAGE else Lifetime.RUN - ActionRecord(it, action, args, lifetime, context[ActiveAction]?.seq) + val temporary = context[Temporary] + ActionRecord(it, action, args, if (temporary != null) Lifetime.TEMPORARY else Lifetime.RUN, temporary?.until, context[ActiveAction]?.seq) } return ActionScope(record, null).block().value } @@ -81,10 +81,11 @@ suspend fun label(vararg call: Any?, block: suspend LabelScope.() -> Unit) { } /** - * Runs [block] with every action it performs kept for the current stage only: they are undone when the stage exits - * and before the segment repeats after a restart, like a barricade that only stands during a fight. + * Runs [block] with every action it performs kept until a checkpoint, like a barricade that only stands during a + * fight: they are taken back when the story passes the checkpoint [until], or the next one of this run when it is + * null, and when the story ends. A segment that repeats after a restart takes them back before it performs them again. */ -suspend fun temporary(block: suspend () -> T): T = withContext(Temporary()) { block() } +suspend fun temporary(until: String? = null, block: suspend () -> T): T = withContext(Temporary(until)) { block() } /** What a capture block can declare: snapshots taken before the action and how to put them back. */ @StoryDsl @@ -129,7 +130,7 @@ class ActionScope internal constructor( internal var fastForward: (suspend () -> R)? = null internal var perform: (suspend () -> R)? = null - /** Brings the world to where the action ends, at once; a redo without it performs the action again. */ + /** Brings the world to where the action ends, at once; without it the action is performed again. */ fun fastForward(block: suspend () -> R) { check(!closed) { "fastForward { } must come before perform { }" } fastForward = block @@ -170,10 +171,11 @@ internal class ActionRecord( private val action: StoryAction, private val args: CompoundTag, private val lifetime: Lifetime, + private val until: String?, private val outer: Long?, ) { fun begin(snapshots: CompoundTag): JournalEntry = - run.journal.record(action.id, run.path, lifetime, args, snapshots, outer).also { run.recorded() } + run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer).also { run.changed() } fun done(entry: JournalEntry) { entry.done = true @@ -182,11 +184,11 @@ internal class ActionRecord( } /** Marks actions performed inside [temporary]. */ -internal class Temporary : AbstractCoroutineContextElement(Temporary) { +internal class Temporary(val until: String?) : AbstractCoroutineContextElement(Temporary) { companion object Key : CoroutineContext.Key } -/** The action whose perform is running: actions inside it are its parts, which a redo of it repeats. */ +/** The action whose perform is running: actions inside it are its parts, which a fast-forward of it repeats. */ internal class ActiveAction(val seq: Long) : AbstractCoroutineContextElement(ActiveAction) { companion object Key : CoroutineContext.Key } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt index bce0e7772..0f6e1e3b7 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -120,7 +120,13 @@ object StoryActions { /** Undoes what [entry] did; logs why when it cannot. */ internal fun undo(entry: JournalEntry) { - val undo = replay(entry)?.scope?.undo ?: return + val replay = try { + replay(entry) + } catch (missing: StoryEntityMissing) { + StoryDeferredReplays.undo(entry, missing.uuid) + return + } + val undo = replay?.scope?.undo ?: return try { UndoScope().undo() } catch (conflict: ActionConflict) { @@ -133,7 +139,13 @@ object StoryActions { /** Brings the world to where [entry] ended: with its fast-forward, or by performing it again. */ @Suppress("UNCHECKED_CAST") internal suspend fun fastForward(entry: JournalEntry) { - val scope = replay(entry)?.scope as? ActionScope ?: return + val replay = try { + replay(entry) + } catch (missing: StoryEntityMissing) { + StoryDeferredReplays.fastForward(entry, missing.uuid) + return + } + val scope = replay?.scope as? ActionScope ?: return val block = scope.fastForward ?: scope.perform ?: return try { withContext(Replaying()) { block() } @@ -144,7 +156,10 @@ object StoryActions { } } - /** Calls the action of [entry] again with its saved receivers and parameters, declaring its blocks only. */ + /** + * Calls the action of [entry] again with its saved receivers and parameters, declaring its blocks only. An entity + * it needs that is not loaded is thrown as [StoryEntityMissing], so the caller can wait for it. + */ private fun replay(entry: JournalEntry): ActionReplay? { val action = byId[entry.action] ?: run { HollowEngine.LOGGER.warn( @@ -157,13 +172,7 @@ object StoryActions { return try { ActionReplay(entry, action).also(action::replay) } catch (missing: StoryEntityMissing) { - HollowEngine.LOGGER.warn( - "Story '{}' cannot replay '{}': entity {} is not loaded", - entry.run, - entry.action, - missing.uuid - ) - null + throw missing } catch (failure: Exception) { HollowEngine.LOGGER.warn("Story '{}' cannot replay '{}': {}", entry.run, entry.action, failure.message) null diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt index 900f516c9..5e82fe7a0 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -38,8 +38,8 @@ fun Entity.stopStory(key: String): Boolean = StoryOwners.entity(this).stop(key) fun NodeScript.stopStory(key: String): Boolean = StoryOwners.node(this).stop(key) /** - * Rewinds the story [key] of the server, see [rewind]: to the checkpoint [checkpoint], or each of its runs to the - * start of its segment when it is null. Returns whether there is such a story. + * Rewinds the story [key] of the server, see [rewind]: to the checkpoint [checkpoint], or to the latest checkpoint + * the story passed when it is null. Returns whether there is such a story. */ fun MinecraftServer.rewindStory(key: String, checkpoint: String? = null): Boolean = StoryOwners.server(this)[key]?.rewind(checkpoint) != null @@ -51,9 +51,10 @@ fun NodeScript.rewindStory(key: String, checkpoint: String? = null): Boolean = StoryOwners.node(this)[key]?.rewind(checkpoint) != null /** - * Takes the story back and does not return. Without [checkpoint] this run and its active branches each repeat the - * segment they are in; with it the whole story goes back to the latest checkpoint so named, branches started since - * included. The actions performed since are undone. What [once] and [remember] kept stays as it is. + * Takes the story back and does not return. Without [checkpoint] this run and its branches go back to the latest + * checkpoint this run passed, its start if there was none; with it the whole story goes back to the latest + * checkpoint so named, branches started since included. The actions performed since are undone. What [once] and + * [remember] kept stays as it is. */ suspend fun rewind(checkpoint: String? = null): Nothing { currentRun("rewind()").rewind(checkpoint) @@ -75,7 +76,10 @@ suspend fun async(name: String, block: @State suspend () -> T): StoryHandle< return StoryHandle(run.branch(name, lambda), lambda.result) } -/** A run of a story or of one of its branches. */ +/** + * A run of a story or of one of its branches. A story can keep it like any other value: it is saved as the path of + * the run and found again in the same story when read. + */ class StoryHandle internal constructor( internal val run: StoryRun, private val result: KSerializer?, @@ -109,8 +113,6 @@ class StoryHandle internal constructor( /** Rewinds the run from outside, see [rewind]. */ fun rewind(checkpoint: String? = null) = run.rewind(checkpoint) - /** Undoes the latest rewind of the story, as long as it has not performed an action since. */ - fun redo() = run.redo() /** The checkpoints the story can be rewound to, oldest first, with the run that passed each. */ val checkpoints: List> get() = run.history.all.map { it.name to it.run } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt index 51aee8c8d..c8a563e10 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt @@ -12,7 +12,7 @@ import net.minecraft.commands.arguments.EntityArgument import ru.hollowhorizon.hollowengine.common.utils.mcTranslate /** - * What the stories of an owner do, and starting, stopping, rewinding and redoing + * What the stories of an owner do, and starting, stopping and rewinding * them by hand, as a game master or an author testing a story does. */ internal object StoryCommands { @@ -68,7 +68,6 @@ internal object StoryCommands { ), ), ) - .then(Commands.literal("redo").then(key().executes { run(it) { redo(it, owner(it)) } })) .then(Commands.literal("checkpoints").then(key().executes { run(it) { checkpoints(it, owner(it)) } })) } @@ -103,20 +102,12 @@ internal object StoryCommands { val key = key(context) val story = runs[key] ?: return missing(context, key) story.rewind(checkpoint) - val message = if (checkpoint == null) "hollowengine.commands.story.rewound_segments".mcTranslate(key) + val message = if (checkpoint == null) "hollowengine.commands.story.rewound_latest".mcTranslate(key) else "hollowengine.commands.story.rewound".mcTranslate(key, checkpoint) context.source.sendSuccess({ message }, true) return 1 } - private fun redo(context: Context, runs: StoryRuns): Int { - val key = key(context) - val story = runs[key] ?: return missing(context, key) - story.redo() - context.source.sendSuccess({ "hollowengine.commands.story.redone".mcTranslate(key) }, true) - return 1 - } - private fun checkpoints(context: Context, runs: StoryRuns): Int { val key = key(context) val story = runs[key] ?: return missing(context, key) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt new file mode 100644 index 000000000..e2fa8b61b --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt @@ -0,0 +1,80 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.launch +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import java.util.UUID + +/** + * Undos and fast-forwards that need an entity which is not loaded: a player who is offline, a mob in an unloaded + * chunk. Skipping them would leave the world as the story no longer is, so they wait for the entity and run when it + * loads. They are kept with the server, apart from any story, so a story that ends meanwhile still gets them done. + */ +internal object StoryDeferredReplays { + private enum class Kind { UNDO, FAST_FORWARD } + + private class Deferred(val kind: Kind, val entry: JournalEntry) + + private val waiting = LinkedHashMap>() + + /** What [save] wrote for this server, changed since: the owner of the queue should save it. */ + var changed = false + private set + + fun undo(entry: JournalEntry, entity: UUID) = defer(Kind.UNDO, entry, entity) + + fun fastForward(entry: JournalEntry, entity: UUID) = defer(Kind.FAST_FORWARD, entry, entity) + + /** [entity] loaded: runs what waited for it, in the order it was deferred. */ + fun entityLoaded(entity: Entity) { + val deferred = waiting.remove(entity.uuid) ?: return + changed = true + deferred.forEach { replay -> + when (replay.kind) { + Kind.UNDO -> StoryActions.undo(replay.entry) + Kind.FAST_FORWARD -> { + val server = entity.server ?: return@forEach + server.runtimeContext.scope.launch { StoryActions.fastForward(replay.entry) } + } + } + } + } + + fun save(): CompoundTag = CompoundTag().apply { + waiting.forEach { (uuid, deferred) -> + put(uuid.toString(), ListTag().apply { + deferred.forEach { replay -> + add(replay.entry.save().apply { putString("deferred", replay.kind.name) }) + } + }) + } + changed = false + } + + fun load(tag: CompoundTag) { + waiting.clear() + tag.allKeys.forEach { key -> + val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@forEach + waiting[uuid] = tag.getList(key, Tag.TAG_COMPOUND.toInt()).mapTo(ArrayList()) { + val saved = it as CompoundTag + Deferred(runCatching { Kind.valueOf(saved.getString("deferred")) }.getOrDefault(Kind.UNDO), JournalEntry.load(saved)) + } + } + changed = false + } + + fun clear() { + waiting.clear() + changed = false + } + + private fun defer(kind: Kind, entry: JournalEntry, entity: UUID) { + HollowEngine.LOGGER.info("Story '{}' waits for entity {} to {} '{}'", entry.run, entity, kind.name.lowercase(), entry.action) + waiting.getOrPut(entity, ::ArrayList) += Deferred(kind, entry) + changed = true + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 77cf1b08c..328570a8b 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -187,6 +187,7 @@ class StoryFrame internal constructor( result = null segment = CompoundTag() segmentMark = run.journal.next + run.journal.checkpointPassed(run.path, sites[site]) run.history.add(sites[site], run, labels) run.changed() } @@ -288,7 +289,6 @@ class StoryFrame internal constructor( run.frames.removeAt(run.frames.lastIndex) children.forEach { run.closeChild(it) } children.clear() - run.journal.undoTemporary(run.path, entryMark) run.root.boxes.release(boxes) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt new file mode 100644 index 000000000..828ab5c56 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** A handle is written as the path of its run, and read back as the run under that path in the current story. */ +internal class StoryHandleSerializer(private val result: KSerializer?) : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.handle", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: StoryHandle) = encoder.encodeString(value.run.path) + + override fun deserialize(decoder: Decoder): StoryHandle { + val path = decoder.decodeString() + val current = StoryRun.current() ?: throw StoryException("A story handle is only read while a story runs") + val run = current.runs.find(path) ?: StoryRun.ended(current.runs, path.substringAfterLast('/')) + return StoryHandle(run, result) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt index f6026b22e..2843f0ffb 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt @@ -5,10 +5,10 @@ import net.minecraft.nbt.ListTag import net.minecraft.nbt.Tag /** - * The checkpoints a story passed, oldest first, and the rewinds it can redo. + * The checkpoints a story passed, oldest first. * * A checkpoint keeps the whole tree of runs as it was there and marks the journal, so a rewind to it undoes what was - * performed since and puts the runs back. A rewind can be redone until the story performs a new action. + * performed since and puts the runs back. */ internal class StoryHistory(private val root: StoryRun) { class Checkpoint( @@ -37,41 +37,21 @@ internal class StoryHistory(private val root: StoryRun) { } } - /** What a rewind left behind: the tree, the journal and the checkpoints as they were before it. */ - private class Redo(val tree: CompoundTag, val journal: ListTag, val checkpoints: List) { - fun save(): CompoundTag = CompoundTag().apply { - put("tree", tree.copy()) - put("journal", journal.copy()) - put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) - } - - companion object { - fun load(tag: CompoundTag) = Redo( - tag.getCompound("tree").copy(), - tag.getList("journal", Tag.TAG_COMPOUND.toInt()).copy(), - tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()).map { Checkpoint.load(it as CompoundTag) }, - ) - } - } - private val checkpoints = ArrayList() - private val redo = ArrayList() val all: List get() = checkpoints - val canRedo: Boolean get() = redo.isNotEmpty() - fun has(name: String): Boolean = checkpoints.any { it.name == name } + /** Whether [run] passed a checkpoint of its own that is still kept: its entry at least, unless it was forgotten. */ + fun hasOwn(run: StoryRun): Boolean = checkpoints.any { it.run == run.path } + /** [run] passed the checkpoint [name]: keeps the tree as it is now with what [labels] captured. */ fun add(name: String, run: StoryRun, labels: List) { checkpoints += Checkpoint(name, run.path, root.journal.next, root.saveTree(), labels) if (checkpoints.size > MAX_CHECKPOINTS) checkpoints.removeAt(0) } - /** The story performed a new action, so what it rewound past cannot come back. */ - fun recorded() = redo.clear() - /** The lowest mark a rewind can still go back to: the oldest checkpoint, or the segment a stage is in. */ fun floor(segments: Sequence): Long = (checkpoints.asSequence().map { it.mark } + segments).minOrNull() ?: root.journal.next @@ -82,57 +62,45 @@ internal class StoryHistory(private val root: StoryRun) { if (index < 0) throw StoryException("Story '${root.path}' has no checkpoint '$name'") val target = checkpoints[index] root.halt("it rewinds to '$name'") - val before = Redo(root.saveTree(), root.journal.entriesTag(), checkpoints.toList()) root.journal.rewind(target.mark) target.labels.asReversed().forEach(StoryActions::undo) checkpoints.subList(index + 1, checkpoints.size).clear() - push(before) root.restoreTree(target.tree) } - /** Takes [run] and its active branches each back to the start of the segment it is in. */ - suspend fun rewindSegments(run: StoryRun) { - run.halt("it rewinds") - val before = Redo(root.saveTree(), root.journal.entriesTag(), checkpoints.toList()) - run.tree().filter { !it.state.done }.forEach { branch -> - val frame = branch.pending.lastOrNull() ?: return@forEach - root.journal.rewind(frame.segmentMark) { it.run == branch.path } - } - push(before) - } - - /** Returns to where the latest rewind started from, performing again what it undid. */ - suspend fun redo() { - val record = redo.removeLastOrNull() ?: throw StoryException("Story '${root.path}' has nothing to redo") - root.halt("it redoes a rewind") - root.journal.restore(record.journal) - checkpoints.clear() - checkpoints += record.checkpoints - root.restoreTree(record.tree) + /** + * Takes [run] back to the latest checkpoint it passed itself, its entry at the least: [run] and its branches + * as they were there, what they performed since undone. Whatever runs beside it goes on. + */ + suspend fun rewindOwn(run: StoryRun) { + val index = checkpoints.indexOfLast { it.run == run.path } + if (index < 0) throw StoryException("Story '${run.path}' has no checkpoint left to rewind to") + val target = checkpoints[index] + run.halt("it rewinds to '${target.name}'") + val inside = { path: String -> path == run.path || path.startsWith("${run.path}/") } + root.journal.rewind(target.mark) { inside(it.run) } + target.labels.asReversed().forEach(StoryActions::undo) + checkpoints.subList(index + 1, checkpoints.size).removeAll { inside(it.run) } + run.restoreTree(subtree(target.tree, run.path)) } - private fun push(record: Redo) { - redo += record - if (redo.size > MAX_REDO) redo.removeAt(0) - } + /** What [tree], saved for the root, holds for the run at [path]. */ + private fun subtree(tree: CompoundTag, path: String): CompoundTag = + path.split('/').drop(1).fold(tree) { tag, name -> tag.getCompound("children").getCompound(name) } fun save(): CompoundTag = CompoundTag().apply { put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) - if (redo.isNotEmpty()) put("redo", ListTag().apply { redo.forEach { add(it.save()) } }) } fun load(tag: CompoundTag) { checkpoints.clear() - redo.clear() tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()) .forEach { checkpoints += Checkpoint.load(it as CompoundTag) } - tag.getList("redo", Tag.TAG_COMPOUND.toInt()).forEach { redo += Redo.load(it as CompoundTag) } } private companion object { /** Older checkpoints are forgotten, so a long story does not grow its save without end. */ const val MAX_CHECKPOINTS = 64 - const val MAX_REDO = 8 } } @@ -145,7 +113,7 @@ class CheckpointScope internal constructor(private val run: String) { internal val labels = ArrayList() internal fun add(action: StoryAction, args: CompoundTag, snapshots: CompoundTag) { - labels += JournalEntry(-1, action.id, run, Lifetime.RUN, args, snapshots, null) + labels += JournalEntry(-1, action.id, run, Lifetime.RUN, null, args, snapshots, null) } internal companion object { diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index f1938a26b..8fc3a8fbc 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -8,6 +8,7 @@ import net.minecraft.nbt.CompoundTag import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method @@ -89,6 +90,18 @@ object StoryInstances { return factory.invoke(instance) as KSerializer } + /** The script an instance [key] belongs to, or null when the key does not name a script. */ + internal fun scriptOf(key: String): ScriptId? = runCatching { ScriptRegistry.parse(key.substringBefore('@')) }.getOrNull() + + /** + * Whether the script of [key] no longer exists, so nothing will run under [key] again. A script of a namespace + * that is not installed right now is not gone: turning its addon on brings it back. + */ + internal fun isGone(key: String): Boolean { + val id = scriptOf(key) ?: return false + return ScriptRegistry.source(id.namespace) != null && ScriptRegistry.artifacts(id) == null + } + /** The keys of every running instance. */ val registered: Set get() = byKey.keys diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt index 9bc0512f4..6b2907966 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -8,6 +8,7 @@ import net.minecraft.nbt.Tag * One call of an action a story made: what to call again to undo or fast-forward it. * * @param seq the place of the call in the journal, which only grows; checkpoints and segments mark it. + * @param until the checkpoint that takes a temporary action back; null for the next one its run passes. * @param outer the call this one was made from, whose fast-forward repeats it too. */ internal class JournalEntry( @@ -15,13 +16,14 @@ internal class JournalEntry( val action: String, val run: String, val lifetime: Lifetime, + val until: String?, val args: CompoundTag, val snapshots: CompoundTag, val outer: Long?, ) { var done = false - /** When the effect was taken back: by its stage exiting, for a temporary action. Null while it stands. */ + /** When a temporary effect was taken back, by its checkpoint or its run ending. Null while it stands. */ var undoneAt: Long? = null val active: Boolean get() = undoneAt == null @@ -31,6 +33,7 @@ internal class JournalEntry( putString("action", action) putString("run", run) putString("lifetime", lifetime.name) + until?.let { putString("until", it) } put("args", args.copy()) put("snapshots", snapshots.copy()) outer?.let { putLong("outer", it) } @@ -44,6 +47,7 @@ internal class JournalEntry( tag.getString("action"), tag.getString("run"), runCatching { Lifetime.valueOf(tag.getString("lifetime")) }.getOrDefault(Lifetime.RUN), + tag.getString("until").takeIf { tag.contains("until") }, tag.getCompound("args").copy(), tag.getCompound("snapshots").copy(), tag.getLong("outer").takeIf { tag.contains("outer") }, @@ -74,22 +78,28 @@ internal class StoryJournal { action: String, run: String, lifetime: Lifetime, + until: String?, args: CompoundTag, snapshots: CompoundTag, outer: Long?, - ) = JournalEntry(next++, action, run, lifetime, args, snapshots, outer).also { entries += it } + ) = JournalEntry(next++, action, run, lifetime, until, args, snapshots, outer).also { entries += it } /** - * Takes back the temporary actions [run] performed since [from], in reverse: the stage they belong to exits, or - * the segment that performed them repeats. With [branches] the branches of [run] count too. + * Takes back the temporary actions [run] performed since [from], in reverse: the segment that performed them + * repeats after a restart, or the run ends. With [branches] the branches of [run] count too. */ - fun undoTemporary(run: String, from: Long, branches: Boolean = false) { - val undone = entries.filter { - it.active && it.lifetime == Lifetime.STAGE && it.seq >= from && (it.run == run || branches && it.run.startsWith( - "$run/" - )) - } - undone.asReversed().forEach { entry -> + fun undoTemporary(run: String, from: Long, branches: Boolean = false) = takeBack { + it.seq >= from && (it.run == run || branches && it.run.startsWith("$run/")) + } + + /** + * [run] passed the checkpoint [name]: takes back the temporary actions that last until it, those [run] performed + * without naming a checkpoint and those of any run that named this one. + */ + fun checkpointPassed(run: String, name: String) = takeBack { it.until == name || it.until == null && it.run == run } + + private fun takeBack(which: (JournalEntry) -> Boolean) { + entries.filter { it.active && it.lifetime == Lifetime.TEMPORARY && which(it) }.asReversed().forEach { entry -> StoryActions.undo(entry) entry.undoneAt = next++ } @@ -114,20 +124,6 @@ internal class StoryJournal { entries.removeAll { scope(it) && it.seq >= mark } } - /** Brings the world to the state [saved] describes and makes it the journal again: what a redo returns to. */ - suspend fun restore(saved: ListTag) { - val target = saved.map { JournalEntry.load(it as CompoundTag) } - val standing = target.filter { it.active }.map { it.seq }.toSet() - val current = entries.filter { it.active }.map { it.seq }.toSet() - entries.filter { it.active && it.seq !in standing }.asReversed().forEach(StoryActions::undo) - val repeated = target.filter { it.active && it.seq !in current } - val seqs = repeated.map { it.seq }.toSet() - repeated.filter { it.outer !in seqs }.forEach { StoryActions.fastForward(it) } - entries.clear() - entries += target - next = maxOf(next, (target.maxOfOrNull { it.seq } ?: -1) + 1) - } - /** Takes back every temporary action left, for a run that ends; permanent ones stay in the world. */ fun close(run: String) = undoTemporary(run, 0, branches = true) @@ -147,11 +143,9 @@ internal class StoryJournal { fun save(): CompoundTag = CompoundTag().apply { putLong("next", next) - put("entries", entriesTag()) + put("entries", ListTag().apply { entries.forEach { add(it.save()) } }) } - fun entriesTag(): ListTag = ListTag().apply { entries.forEach { add(it.save()) } } - fun load(tag: CompoundTag) { entries.clear() tag.getList("entries", Tag.TAG_COMPOUND.toInt()).forEach { entries += JournalEntry.load(it as CompoundTag) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt index 856a15f77..678c3c94f 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -19,6 +19,7 @@ import ru.hollowhorizon.hollowengine.common.events.entity.EntityEvent import ru.hollowhorizon.hollowengine.common.events.entity.EntityLoadedEvent import ru.hollowhorizon.hollowengine.common.events.level.LevelEvent import ru.hollowhorizon.hollowengine.common.events.server.ServerEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript @@ -75,10 +76,14 @@ object StoryOwners { }.onFailure { HollowEngine.LOGGER.error("Cannot start node '{}' at stage '{}'", node.path, entry, it) } } + @SubscribeEvent + fun onScriptFailed(event: ScriptFailedEvent) = StoryWaits.failed(event.id) + @SubscribeEvent fun onLevelLoad(event: LevelEvent.Load) { val server = event.level.server ?: return if (event.level.dimension() != Level.OVERWORLD) return + StoryDeferredReplays.load(StoryStore(deferredFile(server)).read() ?: CompoundTag()) StoryStore(serverFile(server)).read()?.let(server(server)::load) } @@ -93,6 +98,7 @@ object StoryOwners { saveServer(event.server) servers.remove(event.server) StoryWaits.clear() + StoryDeferredReplays.clear() } @SubscribeEvent @@ -100,6 +106,7 @@ object StoryOwners { val entity = event.entity if (entity.level().isClientSide) return StoryWaits.entityLoaded(entity.uuid) + StoryDeferredReplays.entityLoaded(entity) entities[entity]?.let { it.wake(); return } entity.data[ENTITY_STORIES]?.let(entity(entity)::load) } @@ -118,10 +125,14 @@ object StoryOwners { } private fun saveServer(server: MinecraftServer) { + if (StoryDeferredReplays.changed) StoryStore(deferredFile(server)).write(StoryDeferredReplays.save()) val runs = servers[server] ?: return StoryStore(serverFile(server)).write(runs.save()) } + private fun deferredFile(server: MinecraftServer): Path = + server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("hollowengine-narrative-deferred.dat") + private fun serverFile(server: MinecraftServer): Path = server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("hollowengine-narrative-stories.dat") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index 82b0fec76..e84c1a252 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -80,7 +80,7 @@ class StoryRun internal constructor( } val missing = (frames + pending).map { it.instance }.firstOrNull { StoryInstances.find(it) == null } if (missing != null) { - runs.waitFor(missing, this) + if (StoryInstances.isGone(missing)) discard("its script '$missing' no longer exists") else runs.waitFor(missing, this) return } if (parent != null && parent.state != Status.RUNNING) return @@ -183,6 +183,13 @@ class StoryRun internal constructor( children.remove(name)?.stop() } + /** Removes the whole story for good: a script it runs in will not come back, so it would wait forever. */ + internal fun discard(reason: String) { + val root = root + HollowEngine.LOGGER.error("Story '{}' is removed: {}", root.path, reason) + if (!root.runs.stop(root.name)) root.stop() + } + /** Stops the run for good, along with its branches, and takes back their temporary actions at once. */ internal fun stop() { if (state.done) { @@ -210,29 +217,18 @@ class StoryRun internal constructor( internal fun changed() = runs.changed() - /** The run wrote a new action to the journal, so the rewinds before it cannot be redone. */ - internal fun recorded() { - history.recorded() - changed() - } - /** This run and every branch under it. */ internal fun tree(): Sequence = sequenceOf(this) + children.values.asSequence().flatMap { it.tree() } /** * Takes the story back: with [name], the whole tree to the latest checkpoint so named; without it, this run and - * its active branches each to the start of the segment it is in. The runs stop, the journal undoes what they + * its branches to the latest checkpoint this run passed itself. The runs stop, the journal undoes what they * performed since, and they start again from there. */ internal fun rewind(name: String?) { if (name != null && !history.has(name)) throw StoryException("Story '${root.path}' has no checkpoint '$name'") - if (name == null) move(this) { history.rewindSegments(this) } else move(root) { history.rewind(name) } - } - - /** Undoes the latest rewind of the story: back to where it was, with the actions it undid performed again. */ - internal fun redo() { - if (!history.canRedo) throw StoryException("Story '${root.path}' has nothing to redo") - move(root) { history.redo() } + if (name == null && !history.hasOwn(this)) throw StoryException("Story '$path' has no checkpoint left to rewind to") + if (name == null) move(this) { history.rewindOwn(this) } else move(root) { history.rewind(name) } } /** Stops the run with its branches and waits until they have; [start] brings it back. */ @@ -377,6 +373,9 @@ class StoryRun internal constructor( /** The run whose code is executing on this thread. */ fun current(): StoryRun? = CURRENT.get() + /** A run a saved handle names that has ended and left its story: all a handle can still do is report so. */ + fun ended(runs: StoryRuns, name: String): StoryRun = StoryRun(runs, name, null).apply { status.value = Status.COMPLETED } + fun load(runs: StoryRuns, name: String, parent: StoryRun?, tag: CompoundTag): StoryRun = StoryRun(runs, name, parent).apply { readTree(tag) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt index e43ad02c3..28041b753 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -5,6 +5,7 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import java.util.UUID /** @@ -90,6 +91,12 @@ class StoryRuns(val owner: StoryOwner) { internal fun holds(run: StoryRun): Boolean = runs[run.name] === run + /** The run at [path]: a story under its key, then its branches by name. */ + internal fun find(path: String): StoryRun? { + val names = path.split('/') + return names.drop(1).fold(runs[names.first()] ?: return null) { run, name -> run.children[name] ?: return null } + } + internal fun ended(run: StoryRun) { if (runs[run.name] === run) runs.remove(run.name) changed() @@ -129,6 +136,13 @@ internal object StoryWaits { entities.getOrPut(uuid, ::LinkedHashSet) += run } + /** The script [id] could not start: the stories waiting for an instance of it go. */ + fun failed(id: ScriptId) { + instances.keys.filter { StoryInstances.scriptOf(it) == id }.forEach { key -> + instances.remove(key)?.forEach { it.discard("its script '$key' could not start") } + } + } + fun entityLoaded(uuid: UUID) { entities.remove(uuid)?.forEach(StoryRun::start) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt index 7f5b1c3d3..0728b648e 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -48,6 +48,7 @@ object StoryValues { Entity::class.java.isAssignableFrom(classifier.java) -> EntityReference(classifier as KClass) classifier == NpcRef::class -> NpcReference classifier.isFunction() -> StageLambda.Serializer + classifier == StoryHandle::class -> StoryHandleSerializer(type.arguments.firstOrNull()?.type?.let(::serializer)) classifier == List::class || classifier == Collection::class -> ListSerializer(arguments[0]) classifier == Set::class -> SetSerializer(arguments[0]) classifier == Map::class -> MapSerializer(arguments[0], arguments[1]) @@ -99,10 +100,11 @@ object StoryValues { it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") } || Function::class.java.isAssignableFrom(java) + /** A loaded entity, or an NPC without a body: the registry keeps those apart from chunks, so they are always there. */ private fun findOnServer(uuid: UUID, type: KClass): Entity? { val server = currentServerOrNull() ?: return null if (NpcEntity::class.java.isAssignableFrom(type.java)) server.npcs.resolve(uuid)?.let { return it } - return server.findEntity(uuid) + return server.findEntity(uuid) ?: server.npcs.resolve(uuid) } private fun MinecraftServer.findEntity(uuid: UUID): Entity? = diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json index deeb8657f..7cd6f6791 100644 --- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json @@ -7,8 +7,7 @@ "hollowengine.commands.story.stopped": "Story %s stopped.", "hollowengine.commands.story.missing": "There is no story %s here.", "hollowengine.commands.story.rewound": "Story %s is going back to checkpoint %s.", - "hollowengine.commands.story.rewound_segments": "Story %s is going back to the start of the segments it is in.", - "hollowengine.commands.story.redone": "Story %s is returning to where its last rewind started.", + "hollowengine.commands.story.rewound_latest": "Story %s is going back to the latest checkpoint it passed.", "hollowengine.commands.story.checkpoint": "%s. %s (%s)", "hollowengine.commands.story.failed": "The story refused: %s", "hollowengine.dialogue.advance": "[ continue ▶ ]", diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json index 7a9eaf682..9b13e432d 100644 --- a/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json @@ -7,8 +7,7 @@ "hollowengine.commands.story.stopped": "История %s остановлена.", "hollowengine.commands.story.missing": "Здесь нет истории %s.", "hollowengine.commands.story.rewound": "История %s возвращается к чекпоинту %s.", - "hollowengine.commands.story.rewound_segments": "История %s возвращается к началу отрезков, в которых она сейчас.", - "hollowengine.commands.story.redone": "История %s возвращается туда, откуда её откатили в последний раз.", + "hollowengine.commands.story.rewound_latest": "История %s возвращается к последнему пройденному чекпоинту.", "hollowengine.commands.story.checkpoint": "%s. %s (%s)", "hollowengine.commands.story.failed": "История отказалась: %s", "hollowengine.dialogue.advance": "[ дальше ▶ ]", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt index ed1026a07..183382fb3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt @@ -2,6 +2,8 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import ru.hollowhorizon.hollowengine.client.HighlightTheme +import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.widgets.UiGutterMarker import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.scripting.execution.ScriptExecutionStatus @@ -22,11 +24,12 @@ internal fun ideGutterMarkers( buildList { structure.markers.forEach { marker -> val kind = marker.kind.name.lowercase() + val icon = marker.kind.icon ?: kind add(UiGutterMarker("$kind-${marker.offset}", marker.offset, - "hollowengine:textures/gui/icons/gutter/$kind.svg", "hollowengine.gui.ide.gutter.$kind".lang(*marker.args.toTypedArray()), + "hollowengine:textures/gui/icons/gutter/$icon.svg", "hollowengine.gui.ide.gutter.$kind".lang(*marker.args.toTypedArray()), listOf("gutter-$kind"), onClick = if (!marker.kind.navigable) null else { { if (file.text == analyzedText) navigate(marker.offset) } - })) + }, tint = marker.color?.let { UiColor.fromArgb(HighlightTheme.dslMarkerArgb(it)) })) } if (canRun) structure.runTargets.forEach { target -> val status = execution?.status ?: ScriptExecutionStatus.IDLE diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt index 9f7d6cb42..aac321d88 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt @@ -24,6 +24,7 @@ data class UiGutterMarker( val tooltip: String, val tags: List = emptyList(), val onClick: (() -> Unit)? = null, + val tint: UiColor? = null, ) internal fun editableFieldActionGutterWidth(folds: List, markers: List, lines: List, fontSize: Float): Float { @@ -63,7 +64,7 @@ internal fun EditableFieldGutter( Text((index + 1).toString(), modifier = Modifier.position(0.px, y.px).foreground(color).textWrap(false)) byLine[index].orEmpty().forEachIndexed { column, marker -> GutterButton("$fieldId-gutter-${marker.id}", marker.icon, marker.tooltip, markerX + column * slot, y, - slot, layout.lineHeight, marker.tags, marker.onClick) + slot, layout.lineHeight, marker.tags, marker.tint, marker.onClick) } foldLines[index]?.maxByOrNull { it.end - it.start }?.let { fold -> val closed = state.folding.isCollapsed(fold) @@ -83,13 +84,13 @@ internal fun EditableFieldGutter( @Composable private fun GutterButton(id: String, icon: String, tooltip: String, x: Float, y: Float, - width: Float, height: Float, tags: List, action: (() -> Unit)?) { + width: Float, height: Float, tags: List, tint: UiColor? = null, action: (() -> Unit)?) { Box(id = id, mode = UiBoxMode.STACK, tags = listOf("gutter-marker") + tags + listOfNotNull("gutter-action".takeIf { action != null }), modifier = Modifier.position(x.px, y.px).size(width.px, height.px) .input(hoverable = true, clickable = action != null).tooltipOnHover(tooltip) .onClick { event -> action?.invoke(); event.consume() }) { - Image(icon, tags = listOf("gutter-marker-icon"), modifier = Modifier.size((width - 4f).px, (width - 4f).px) - .align(UiAlign.CENTER, UiAlign.CENTER)) + val size = Modifier.size((width - 4f).px, (width - 4f).px).align(UiAlign.CENTER, UiAlign.CENTER) + Image(icon, tags = listOf("gutter-marker-icon"), modifier = tint?.let { size.tint(it) } ?: size) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt new file mode 100644 index 000000000..14f79f80a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt @@ -0,0 +1,13 @@ +package ru.hollowhorizon.hollowengine.common.scripting + +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId + +/** + * A script instance of [id] could not start on the server: the script is gone, does not compile, or threw while + * starting. Addons drop what waited for such an instance instead of waiting forever. + */ +class ScriptFailedEvent(val id: ScriptId) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 50e3a12d5..7f93f3a77 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt @@ -16,20 +16,30 @@ data class CodeFold( val collapsedByDefault: Boolean = false, ) -/** A [navigable] marker leads somewhere through [ScriptingAnalyzer.gutterTargets]. */ -enum class GutterMarkerKind(val navigable: Boolean) { +/** + * A [navigable] marker leads somewhere through [ScriptingAnalyzer.gutterTargets]; [icon] names its picture when + * it shares one with another kind. + */ +enum class GutterMarkerKind(val navigable: Boolean, val icon: String? = null) { OVERRIDE(true), COROUTINE(false), EVENT(false), STAGE(false), CHECKPOINT(false), ACTION(false), + REWIND(false), + REWIND_START(false, "rewind"), + TEMPORARY(false), + TEMPORARY_OPEN(false, "temporary"), } data class GutterNavigationTarget(val label: String, val location: DefinitionLocation) -/** [args] fill in the tooltip of the kind, such as the name of a checkpoint. */ -data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList()) +/** + * [args] fill in the tooltip of the kind, such as the name of a checkpoint. Markers with the same [color] key are + * drawn in the same color, which tells apart the checkpoints and the calls that lead to each. + */ +data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList(), val color: String? = null) enum class ScriptRunKind { PLAIN, NODE } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt index aca4450f1..b0a0fd2a8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt @@ -114,7 +114,13 @@ class EntityNodeManager(private val entity: Entity) { entry = entry, receivers = listOf(server, entity), compiled = compiled, - ) ?: return false + ) + if (script == null) { + if (dormant.remove(canonicalPath) != null) { + HollowEngine.LOGGER.error("Node '{}' is removed from {}: it could not start", canonicalPath, entity.uuid) + } + return false + } dormant.remove(canonicalPath) nodes[canonicalPath] = RunningNode(script) @@ -186,9 +192,8 @@ class EntityNodeManager(private val entity: Entity) { } /** - * A node that cannot be attached right now stays dormant rather than being dropped: [serialize] - * writes dormant nodes back out, so a script that was briefly unavailable costs the entity a node - * until something attaches it again, not forever at the next world save. + * A node whose namespace is not installed right now stays dormant: [serialize] writes it back out, so + * turning the addon on again brings it back. A node that is installed but cannot start is removed. */ fun deserialize(tag: CompoundTag) { tag.allKeys.forEach { path -> @@ -201,13 +206,8 @@ class EntityNodeManager(private val entity: Entity) { .onFailure { HollowEngine.LOGGER.error("Error while deserializing entity node '$path'", it) } .getOrDefault(false) - if (attached) return@forEach + if (attached || available) return@forEach dormant[canonicalPath] = nodeTag - // A missing namespace is an ordinary wait. Anything else means the node is not running on an - // entity that is saved with it, which is worth hearing about the first time it happens. - if (available) { - HollowEngine.LOGGER.warn("Entity node '{}' of {} stays dormant: nothing attached it", canonicalPath, entity.uuid) - } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt index 51785c370..0c9f3f9ab 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt @@ -5,6 +5,7 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.server.MinecraftServer import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript @@ -74,6 +75,17 @@ class NodeManager(val server: MinecraftServer) { notifyExecutionChanged() } + /** + * Forgets the saved node [path], which could not start: its script is gone or broken. Kept, it would fail + * at every start and stay in the world save with nothing to remove it. + */ + internal fun discard(path: String) { + val canonicalPath = canonicalNodePath(path) + if (dormant.remove(canonicalPath) == null) return + HollowEngine.LOGGER.error("Server node '{}' is removed from the world: it could not start", canonicalPath) + notifyExecutionChanged() + } + fun removeNode(path: String) { val canonicalPath = canonicalNodePath(path) dormant.remove(canonicalPath) @@ -229,6 +241,7 @@ internal fun buildNode( val script = ScriptLoader.executeCompiled(id, validate, compiled, configure).onFailure { HollowEngine.LOGGER.error("Error while loading $canonicalPath", it) nodeScope.cancel() + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.getOrNull() ?: return null return runCatching { @@ -247,6 +260,7 @@ internal fun buildNode( }.onFailure { HollowEngine.LOGGER.error("Error while initializing $canonicalPath", it) nodeScope.cancel() + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.getOrNull() } @@ -309,7 +323,7 @@ internal fun MinecraftServer.startNode( entry = entry, receivers = listOf(this), compiled = compiled, - ) ?: return@startAfterStop + ) ?: return@startAfterStop runtimeContext.nodes.discard(path) runtimeContext.nodes.register(script) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt index 08736e082..a54261481 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.scripting.RELOAD_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -56,6 +57,7 @@ internal class ReloadScriptRunner(private val side: LogicalSide) { }.onFailure { error -> scriptScope.cancel() HollowEngine.LOGGER.error("Failed to execute reload script: {}", ScriptRegistry.display(id), error) + if (side == LogicalSide.SERVER) ScriptFailedEvent.post(ScriptFailedEvent(id)) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt index 8aeb58144..5a756a822 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt @@ -8,6 +8,7 @@ import ru.hollowhorizon.hollowengine.client.scripting.StartupScriptNotice import ru.hollowhorizon.hollowengine.common.scripting.STARTUP_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId @@ -49,6 +50,7 @@ object StartupScripts { ScriptStartedEvent.post(ScriptStartedEvent(id, script, scope)) }.onFailure { error -> HollowEngine.LOGGER.error("Failed to execute startup script: {}", ScriptRegistry.display(id), error) + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.isSuccess private fun orderedScripts(): List { diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json index b1a78e782..f73deeb2d 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json @@ -6,6 +6,10 @@ "event": "Event handler", "stage": "Stage %s: once it returns, a restart and rewind() continue below this line", "checkpoint": "Checkpoint %1$s: rewind(\"%1$s\") returns here, and a restart continues below", + "rewind": "rewind() goes back to checkpoint %s", + "rewind_start": "rewind() goes back to where this run started: no checkpoint precedes it", + "temporary": "Taken back at checkpoint %s, or when the story ends", + "temporary_open": "Taken back at the next checkpoint this run passes, or when the story ends", "action": "Reversible action: the story journal can undo it", "run": "Run script", "run_state": "Run state %s", diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json index 2062871ae..516250b14 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json @@ -6,6 +6,10 @@ "event": "Обработчик события", "stage": "Стадия %s: когда она вернётся, рестарт и rewind() продолжат ниже этой строки", "checkpoint": "Чекпоинт %1$s: rewind(\"%1$s\") возвращает сюда, рестарт продолжает ниже", + "rewind": "rewind() возвращает к чекпоинту %s", + "rewind_start": "rewind() возвращает к началу этого прогона: чекпоинтов перед ним нет", + "temporary": "Снимается на чекпоинте %s или в конце истории", + "temporary_open": "Снимается на следующем чекпоинте этого прогона или в конце истории", "action": "Обратимое действие: журнал истории может его отменить", "run": "Запустить скрипт", "run_state": "Запустить состояние %s", diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg index 167cc4119..2058a6164 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg @@ -1 +1 @@ - + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg new file mode 100644 index 000000000..553a6e404 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg @@ -0,0 +1 @@ + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg new file mode 100644 index 000000000..0e93d3a50 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg @@ -0,0 +1 @@ + From d3c73f6e8aeff06ebd5af15dfbc17c82f60e5d13 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Thu, 22 Oct 2026 21:47:38 +0300 Subject: [PATCH 16/25] Test stage safety with non-saveable locals Replace StoryHandle-based test state with an Any? local, isolating checks that stage analysis rejects non-saveable captured variables. And fix #121 --- addons/compiler/src/test/kotlin/StageAnalysisTest.kt | 6 +++--- addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt | 8 ++++---- .../hollowengine/addons/narrative/story/StoryRun.kt | 2 +- .../hollowengine/addons/narrative/story/StoryRuns.kt | 8 ++++++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt index 5c16662a3..eba29ddad 100644 --- a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -25,8 +25,8 @@ class StageAnalysisTest { val source = """ @State suspend fun main() { - var timer: StoryHandle<*>? = null - suspend fun playSing(name: String) { timer?.cancel() } + var lock: Any? = null + suspend fun playSing(name: String) { lock = name } listOf("left", "right").forEach { direction -> async("input_" + direction) { playSing(direction) } } @@ -37,7 +37,7 @@ class StageAnalysisTest { val error = errors.firstOrNull { it.message.contains("local function 'playSing'") } assertTrue(error != null, errors.toString()) assertEquals(5, error.range.start.line) - assertTrue(error.message.contains("'timer', which cannot be saved"), error.message) + assertTrue(error.message.contains("'lock', which cannot be saved"), error.message) } @Test diff --git a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt index 319cf69bb..f3cdf4f9d 100644 --- a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt +++ b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt @@ -11,10 +11,10 @@ class StageLambdaSafetyTest : StageTest() { val errors = compiler.errors(""" @State suspend fun main() { - var timer: StoryHandle<*>? = null + var lock: Any? = null suspend fun playSing(name: String) { - timer?.cancel() - timer = async("resetToIdle") { await(name) } + lock = name + async("resetToIdle") { await(name) } } listOf("left", "right").forEach { direction -> async("input_" + direction) { playSing(direction) } @@ -23,7 +23,7 @@ class StageLambdaSafetyTest : StageTest() { } """) assertContains(errors, "local function 'playSing'") - assertContains(errors, "'timer', which cannot be saved") + assertContains(errors, "'lock', which cannot be saved") assertFalse(errors.contains("Exception while generating code"), errors) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index e84c1a252..424a85cd1 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -80,7 +80,7 @@ class StoryRun internal constructor( } val missing = (frames + pending).map { it.instance }.firstOrNull { StoryInstances.find(it) == null } if (missing != null) { - if (StoryInstances.isGone(missing)) discard("its script '$missing' no longer exists") else runs.waitFor(missing, this) + runs.waitFor(missing, this) return } if (parent != null && parent.state != Status.RUNNING) return diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt index 28041b753..a66052137 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -74,13 +74,17 @@ class StoryRuns(val owner: StoryOwner) { /** Everything to save. */ fun save(): CompoundTag = CompoundTag().apply { runs.forEach { (key, run) -> put(key, run.save()) } } - /** Restores what [save] wrote and starts it. */ + /** + * Restores what [save] wrote and starts it. A story that runs in a script which no longer exists is removed: + * nothing would ever wake it. + */ fun load(tag: CompoundTag) { tag.allKeys.filter { it !in runs }.forEach { key -> val run = StoryRun.load(this, key, null, tag.getCompound(key)) if (run.state.done) return@forEach runs[key] = run - run.start() + val gone = run.tree().flatMap { (it.frames + it.pending).asSequence() }.map { it.instance }.firstOrNull(StoryInstances::isGone) + if (gone != null) run.discard("its script '$gone' no longer exists") else run.start() } } From 33b5e4ace0c34a36588ec781dd66f9a89a4a169a Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Fri, 23 Oct 2026 12:25:01 +0300 Subject: [PATCH 17/25] Fix NBT tag decoding and script variable keys Decode whole NBT tags at the root and log deserialization failures. Resolve saved narrative variables through registered script paths so their keys remain stable when worlds move. --- .../addons/narrative/story/StoryApi.kt | 2 +- .../addons/narrative/story/StoryInstances.kt | 12 +++++++---- .../hollowengine/common/data/DataKey.kt | 4 +++- .../common/utils/nbt/NBTReader.kt | 2 ++ .../common/utils/nbt/SpecificEncoders.kt | 5 ++++- .../common/data/NbtDataStoreTest.kt | 21 +++++++++++++++++++ 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt index 5e82fe7a0..c298e57e2 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -179,7 +179,7 @@ internal class SavedVariable(private val default: T, private val serializer: /** The property qualified by the script declaring it, which a script property reference knows as its owner. */ private fun name(property: KProperty<*>): String { val owner = ((property as? CallableReference)?.owner as? ClassBasedDeclarationContainer)?.jClass - val script = owner?.getAnnotation(StageScript::class.java)?.path ?: owner?.name + val script = owner?.let { StoryInstances.scriptPathOf(it) ?: it.name } return script?.let { "$it/${property.name}" } ?: property.name } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt index 8fc3a8fbc..5afef2621 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -154,12 +154,16 @@ object StoryInstances { } /** - * The script id when the source path belongs to a known script, so a world moved to another directory still - * finds its scripts; the path itself otherwise. + * The script id of [type] when its source path belongs to a known script, so a world moved to another directory + * still finds its scripts; the path itself otherwise, and null for a class that is not a script. */ + internal fun scriptPathOf(type: Class<*>): String? { + val location = type.getAnnotation(StageScript::class.java)?.path ?: return null + return ScriptRegistry.idOfLocation(location)?.let(ScriptRegistry::display) ?: location + } + private fun keyFor(instance: Any): String { - val location = instance.javaClass.getAnnotation(StageScript::class.java)?.path ?: return instance.javaClass.name - val path = ScriptRegistry.idOfLocation(location)?.let(ScriptRegistry::display) ?: location + val path = scriptPathOf(instance.javaClass) ?: return instance.javaClass.name if (instance !is NodeScript) return path return when (val host = instance.host) { is NodeHost.Server -> "$path@server" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt index a4c39be16..564c51feb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat /** @@ -83,6 +84,7 @@ fun CompoundTag.write(key: DataKey, value: T) { fun DataKey.encode(value: T): Tag = NBTFormat.serialize(serializer, value) /** Deserializes [tag] with [key]'s serializer. */ -fun DataKey.decode(tag: Tag): T? = runCatching { NBTFormat.deserialize(serializer, tag) }.getOrNull() +fun DataKey.decode(tag: Tag): T? = runCatching { NBTFormat.deserialize(serializer, tag) } + .onFailure { HollowEngine.LOGGER.warn("Failed to decode $this", it) }.getOrNull() internal fun DataKey.default(): T? = defaultValue?.invoke() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt index bc5196760..99d22ce6b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt @@ -95,6 +95,8 @@ private sealed class AbstractNBTReader(val format: NBTFormat, open val map: Tag) override fun decodeTaggedTag(key: String): Tag = getValue(key) + override fun rootTag(): Tag = map + private inline fun getNumberValue( tag: String, getter: NumericTag.() -> T, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt index 1779e0ee8..ea217e457 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt @@ -14,8 +14,11 @@ internal abstract class NamedValueTagEncoder : NamedValueEncoder(), ICanEncodeTa @OptIn(InternalSerializationApi::class) internal abstract class NamedValueNbtDecoder : NamedValueDecoder(), ICanDecodeTag { - final override fun decodeTag(): Tag = decodeTaggedTag(popTag()) + final override fun decodeTag(): Tag = if (currentTagOrNull == null) rootTag() else decodeTaggedTag(popTag()) abstract fun decodeTaggedTag(key: String): Tag + + /** The tag being read, for a value read as a whole tag at the top level, where it has no name. */ + abstract fun rootTag(): Tag } internal interface ICanEncodeTag : ICanEncodeCompoundNBT { diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt index e3b4fefc9..323aab8f7 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt @@ -1,5 +1,10 @@ package ru.hollowhorizon.hollowengine.common.data +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.StringTag +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForTag import kotlin.test.* class NbtDataStoreTest { @@ -21,6 +26,22 @@ class NbtDataStoreTest { assertTrue(visits in restored) } + @Test + fun `a key holding a whole tag reads back what was written`() { + val compound = DataKey("hollowengine:stories", ForCompoundNBT, null) + val list = DataKey("hollowengine:history", ForTag, null) + val stories = CompoundTag().apply { put("quest", CompoundTag().apply { putString("stage", "main") }) } + val history = ListTag().apply { add(StringTag.valueOf("gate")) } + val data = NbtDataStore() + + data[compound] = stories + data[list] = history + + val restored = NbtDataStore().apply { load(data.save()) } + assertEquals(stories, restored[compound]) + assertEquals(history, restored[list]) + } + @Test fun `removal restores a non-persistent default`() { val data = NbtDataStore() From 2a8deb4018253213b81212fd4642948cbb248d61 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Fri, 23 Oct 2026 13:05:28 +0300 Subject: [PATCH 18/25] Improve story persistence and gutter navigation Save entity-owned stories during entity serialization instead of eagerly on every change, and allow undo to resolve off-world NPCs. Add distinct checkpoint colors and navigation targets for rewind and temporary calls. --- .../ide/session/ScriptingAnalyzerImpl.kt | 2 +- .../structure/KotlinEditorStructure.kt | 14 ++++- .../session/structure/StoryGutterMarkers.kt | 52 +++++++++++++------ .../src/test/kotlin/EditorStructureTest.kt | 16 ++++-- .../src/test/kotlin/StageTestSupport.kt | 1 - .../addons/narrative/story/Actions.kt | 3 +- .../addons/narrative/story/StoryActions.kt | 2 +- .../addons/narrative/story/StoryApi.kt | 1 - .../addons/narrative/story/StoryBoxes.kt | 16 ++---- .../addons/narrative/story/StoryFrame.kt | 5 -- .../addons/narrative/story/StoryOwners.kt | 14 ++--- .../addons/narrative/story/StoryRun.kt | 16 ++---- .../addons/narrative/story/StoryRuns.kt | 8 --- .../addons/narrative/story/StorySegments.kt | 3 -- .../addons/narrative/story/StoryValues.kt | 20 +++++-- .../addons/narrative/story/StoryFrameTest.kt | 1 - .../narrative/story/StoryInstancesTest.kt | 1 - .../bootstrap/RuntimeBridgeEntrypoint.kt | 6 ++- .../hollowengine/client/HighlightTheme.kt | 8 +++ .../client/ui/ide/HollowIdeGutter.kt | 3 +- .../common/events/entity/EntityEvent.kt | 5 ++ .../common/scripting/ide/EditorStructure.kt | 10 ++-- 22 files changed, 118 insertions(+), 89 deletions(-) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt index 9f607b272..63d50b0a3 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt @@ -326,7 +326,7 @@ class ScriptingAnalyzerImpl internal constructor( override fun gutterTargets(name: String, text: String, offset: Int): List = synchronized(lock) { - kotlinGutterTargets(getOrCreateFile(name, text), offset) + kotlinGutterTargets(getOrCreateFile(name, text), name, offset) } override fun acceptedExpressionTypes(name: String, text: String, offset: Int, types: List): List? = diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt index abcea2143..7d49e00d6 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt @@ -117,8 +117,18 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur return EditorStructure(folds.distinctBy { it.start to it.end }, markers.distinctBy { it.offset to it.kind }, runs) } -/** The declarations an override marker at [offset] leads to; an accessor marker leads to the base accessors. */ -internal fun ScriptingAnalyzerImpl.kotlinGutterTargets(file: KtFile, offset: Int): List { +/** + * Where the marker at [offset] of the file [path] leads: an override marker to the declarations it overrides, an + * accessor marker to the base accessors, and a `rewind` or `temporary` marker to its checkpoint. + */ +internal fun ScriptingAnalyzerImpl.kotlinGutterTargets(file: KtFile, path: String, offset: Int): List { + val story = file.findElementAt(offset)?.parentsWithSelf?.filterIsInstance() + ?.firstOrNull { it.calleeExpression?.textOffset == offset } + if (story != null) return analyze(file) { + StoryGutterMarkers(this, file).checkpointsOf(story).map { checkpoint -> + GutterNavigationTarget(file.text.substring(checkpoint).lineSequence().first().trim(), DefinitionLocation(path, checkpoint)) + } + } val owner = file.findElementAt(offset)?.parentsWithSelf?.firstOrNull { element -> (element is KtPropertyAccessor || element is KtCallableDeclaration) && element.textOffset == offset } ?: return emptyList() diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt index 0f0cdc8d6..5aeca4ff8 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt @@ -13,13 +13,15 @@ import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.psi.psiUtil.parents +import ru.hollowhorizon.hollowengine.client.HighlightTheme import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind /** * The gutter markers of story code: stage calls, after which a restart continues, and checkpoints with the calls that - * refer to them. A checkpoint is colored by its name, and `rewind` and `temporary` get the color of the checkpoint - * they lead to, so a call that names none still shows which one it means. + * refer to them. Each checkpoint name of the file gets its own color, in the order the names first appear, and + * `rewind` and `temporary` get the color of the checkpoint they lead to, so a call that names none still shows which + * one it means. */ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) { private class Checkpoint(val owner: KtFunction?, val offset: Int, val name: String) @@ -28,6 +30,8 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) .filter { it.calleeExpression?.text == CheckpointName.asString() && isStory(it, CheckpointName) } .mapNotNull { call -> literal(call.valueArguments.firstOrNull())?.let { Checkpoint(owner(call), call.textOffset, it) } } + private val colors = LinkedHashMap().apply { checkpoints.forEach { getOrPut(it.name) { size } } } + /** The marker of [call] at [expression], at offset 0 for the caller to place, or null when it is no story call. */ fun markerFor(expression: KtExpression, call: KaFunctionCall<*>): GutterMarker? = with(session) { val symbol = call.symbol as? KaNamedFunctionSymbol ?: return null @@ -35,22 +39,16 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) if (symbol.callableId?.packageName == StoryPackage) when (symbol.name) { CheckpointName -> { val name = literal(callExpression?.valueArguments?.firstOrNull()) ?: "?" - return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name), color = name) + return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name), color = color(name)) } - RewindName -> { - val target = argument(callExpression, "checkpoint", 0) - ?: checkpoints.lastOrNull { it.owner == owner(expression) && it.offset < expression.textOffset }?.name - return target?.let { GutterMarker(0, GutterMarkerKind.REWIND, listOf(it), color = it) } - ?: GutterMarker(0, GutterMarkerKind.REWIND_START) - } + RewindName -> return rewindTarget(callExpression, expression) + ?.let { GutterMarker(0, GutterMarkerKind.REWIND, listOf(it), color = color(it)) } + ?: GutterMarker(0, GutterMarkerKind.REWIND_START) - TemporaryName -> { - val target = argument(callExpression, "until", null) - ?: checkpoints.firstOrNull { it.owner == owner(expression) && it.offset > expression.textOffset }?.name - return target?.let { GutterMarker(0, GutterMarkerKind.TEMPORARY, listOf(it), color = it) } - ?: GutterMarker(0, GutterMarkerKind.TEMPORARY_OPEN) - } + TemporaryName -> return temporaryTarget(callExpression, expression) + ?.let { GutterMarker(0, GutterMarkerKind.TEMPORARY, listOf(it), color = color(it)) } + ?: GutterMarker(0, GutterMarkerKind.TEMPORARY_OPEN) } if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type @@ -60,6 +58,30 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) return null } + /** + * The offsets of the checkpoints the `rewind` or `temporary` call [call] leads to: those of the code it is written + * in first, then the checkpoints of the same name elsewhere in the file, which another run of the story may pass. + */ + fun checkpointsOf(call: KtCallExpression): List { + val target = when { + isStory(call, RewindName) -> rewindTarget(call, call) + isStory(call, TemporaryName) -> temporaryTarget(call, call) + else -> null + } ?: return emptyList() + val owner = owner(call) + return checkpoints.filter { it.name == target }.sortedBy { if (it.owner == owner) 0 else 1 }.map { it.offset } + } + + /** The checkpoint a `rewind` goes to: the one it names, or the last one before it in the same code. */ + private fun rewindTarget(call: KtCallExpression?, expression: KtExpression): String? = argument(call, "checkpoint", 0) + ?: checkpoints.lastOrNull { it.owner == owner(expression) && it.offset < expression.textOffset }?.name + + /** The checkpoint a `temporary` stands until: the one it names, or the next one after it in the same code. */ + private fun temporaryTarget(call: KtCallExpression?, expression: KtExpression): String? = argument(call, "until", null) + ?: checkpoints.firstOrNull { it.owner == owner(expression) && it.offset > expression.textOffset }?.name + + private fun color(name: String): Int = HighlightTheme.paletteArgb(colors.getOrPut(name) { colors.size }) + /** The code a checkpoint belongs to: the function or `@State` lambda it is written in, which is one run's. */ private fun owner(element: PsiElement): KtFunction? = element.parents.filterIsInstance() .firstOrNull { it is KtNamedFunction || it is KtFunctionLiteral && isStageLambda(it) } diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index 84ce7d9c9..ddf8f5f6c 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -193,12 +193,18 @@ class EditorStructureTest { """.trimIndent() val markers = analyzer.structure("Colors.kts", source).markers fun at(text: String, from: Int = 0) = markers.single { it.offset == source.indexOf(text, from) } + val arena = at("checkpoint(\"arena").color + val boss = at("checkpoint(\"boss").color assertEquals(GutterMarkerKind.REWIND_START, at("rewind()").kind) - assertEquals("arena", at("checkpoint(\"arena").color) - assertEquals("boss", at("temporary {").color, "a temporary without until goes at the next checkpoint") - assertEquals("arena", at("rewind()", source.indexOf("temporary {")).color, "rewind goes to the checkpoint before it") - assertEquals("boss", at("temporary(until").color) - assertEquals("arena", at("rewind(\"arena").color) + assertNotEquals(arena, boss) + assertEquals(boss, at("temporary {").color, "a temporary without until goes at the next checkpoint") + assertEquals(arena, at("rewind()", source.indexOf("temporary {")).color, "rewind goes to the checkpoint before it") + assertEquals(boss, at("temporary(until").color) + assertEquals(arena, at("rewind(\"arena").color) + + fun target(text: String) = analyzer.gutterTargets("Colors.kts", source, source.indexOf(text)).single().location.offset + assertEquals(source.indexOf("checkpoint(\"boss"), target("temporary(until")) + assertEquals(source.indexOf("checkpoint(\"arena"), target("rewind(\"arena")) } @Test diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt index a4b3c272b..dd4cbd9e2 100644 --- a/addons/compiler/src/test/kotlin/StageTestSupport.kt +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -144,7 +144,6 @@ class StageWorld(private val test: TestScope, private var compiled: CompiledScri private class Owner(override val scope: CoroutineScope) : StoryOwner { override val server: MinecraftServer get() = error("There is no server in stage tests") override val entity: Entity? get() = null - override fun changed(runs: StoryRuns) = Unit } private var scope = newScope() diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt index b56b8b314..d05b7d187 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -175,11 +175,10 @@ internal class ActionRecord( private val outer: Long?, ) { fun begin(snapshots: CompoundTag): JournalEntry = - run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer).also { run.changed() } + run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer) fun done(entry: JournalEntry) { entry.done = true - run.changed() } } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt index 0f6e1e3b7..9d8cef4ce 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -121,7 +121,7 @@ object StoryActions { /** Undoes what [entry] did; logs why when it cannot. */ internal fun undo(entry: JournalEntry) { val replay = try { - replay(entry) + StoryValues.withOffWorldNpcs { replay(entry) } } catch (missing: StoryEntityMissing) { StoryDeferredReplays.undo(entry, missing.uuid) return diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt index c298e57e2..7ddf095c3 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -170,7 +170,6 @@ internal class SavedVariable(private val default: T, private val serializer: override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { val run = run(property) StoryValues.put(run.vars, name(property), serializer, value) - run.changed() } private fun run(property: KProperty<*>) = diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt index 9e6782d68..40e0acc56 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt @@ -12,13 +12,7 @@ import net.minecraft.nbt.CompoundTag * A `var` of a stage that `@State` lambdas captured. The compiler turns the var into a box, so the stage and its * lambdas read and write one value, and the story saves that value once, with the stage that declared it. */ -class StoryBox internal constructor(internal val id: String, private val table: StoryBoxTable, value: T) { - var value: T = value - set(value) { - field = value - table.changed() - } -} +class StoryBox internal constructor(internal val id: String, var value: T) /** Where the compiler gets the boxes of shared vars from. */ object StoryBoxes { @@ -35,7 +29,7 @@ object StoryBoxes { } /** The boxes of shared vars of one story, saved with its tree. */ -internal class StoryBoxTable(private val root: StoryRun) { +internal class StoryBoxTable { private class Live(val box: StoryBox, val serializer: KSerializer) private val live = LinkedHashMap() @@ -51,7 +45,7 @@ internal class StoryBoxTable(private val root: StoryRun) { frame.boxes += id } } - val box = live[id]?.box ?: StoryBox(id, this, initial).also { live[id] = Live(it, serializer) } + val box = live[id]?.box ?: StoryBox(id, initial).also { live[id] = Live(it, serializer) } saved.remove(id) box.value = initial return box as StoryBox @@ -63,7 +57,7 @@ internal class StoryBoxTable(private val root: StoryRun) { val tag = saved.get(id) ?: throw StoryException( "A @State lambda refers to a var that is gone: the stage that declared it returned, or it belongs to another story" ) - val box = StoryBox(id, this, StoryValues.decode(serializer, tag)) + val box = StoryBox(id, StoryValues.decode(serializer, tag)) live[id] = Live(box, serializer) saved.remove(id) return box @@ -77,8 +71,6 @@ internal class StoryBoxTable(private val root: StoryRun) { } } - fun changed() = root.changed() - fun save(): CompoundTag = CompoundTag().apply { putLong("next", next) val values = saved.copy() diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 328570a8b..490ffb641 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -125,7 +125,6 @@ class StoryFrame internal constructor( result = null run.lastResult = null run.enterTrampolined = true - run.changed() } /** The call at [site] returned [value]; runs any tail call it left and returns the final value. */ @@ -143,7 +142,6 @@ class StoryFrame internal constructor( run.lastResult = null segment = CompoundTag() segmentMark = run.journal.next - run.changed() return current } @@ -189,7 +187,6 @@ class StoryFrame internal constructor( segmentMark = run.journal.next run.journal.checkpointPassed(run.path, sites[site]) run.history.add(sites[site], run, labels) - run.changed() } /** Whether the caller runs a tail call this stage leaves, so the stage may give its place away. */ @@ -200,7 +197,6 @@ class StoryFrame internal constructor( close() replaced = true run.tail = StoryRun.Tail(StoryInstances.keyOf(receiver), stage, args) - run.changed() } /** The stage returns [value]; the caller reads it back with [serializer] after a restart. */ @@ -222,7 +218,6 @@ class StoryFrame internal constructor( if (run.isCancelled || reason is StoryEntityMissing) return close() if (reason != null) run.frames.lastOrNull()?.callFailed() - run.changed() } /** diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt index 678c3c94f..657c29ffe 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -111,6 +111,14 @@ object StoryOwners { entity.data[ENTITY_STORIES]?.let(entity(entity)::load) } + /** An entity keeps its runs in its own data, written as the entity is saved, so the save has them as they are. */ + @SubscribeEvent + fun onEntitySaving(event: EntityEvent.Saving) { + val entity = event.entity + val runs = entities[entity] ?: return + if (runs.keys.isEmpty()) entity.data.remove(ENTITY_STORIES) else entity.data[ENTITY_STORIES] = runs.save() + } + /** * A player is the same object after a dimension change and keeps its runs; any other entity leaving its level * takes them along. Only a destroyed entity ends its stories, and never a player, which is removed when it @@ -139,24 +147,18 @@ object StoryOwners { private class ServerOwner(override val server: MinecraftServer) : StoryOwner { override val scope: CoroutineScope get() = server.runtimeContext.scope override val entity: Entity? get() = null - override fun changed(runs: StoryRuns) = Unit } private class EntityOwner(override val entity: Entity) : StoryOwner { override val scope: CoroutineScope get() = entity.coroutineScope override val server: MinecraftServer get() = entity.server ?: throw StoryException("Entity ${entity.uuid} has no server") - - override fun changed(runs: StoryRuns) { - entity.data[ENTITY_STORIES] = runs.save() - } } private class NodeOwner(private val node: NodeScript) : StoryOwner { override val scope: CoroutineScope get() = node override val server: MinecraftServer get() = node.server override val entity: Entity? get() = (node.host as? NodeHost.OfEntity)?.entity - override fun changed(runs: StoryRuns) = Unit } private const val NODE_SECTION = "hollowengine-narrative:stories" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index 424a85cd1..baa0eaada 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -30,7 +30,7 @@ class StoryRun internal constructor( internal val pending = ArrayDeque() internal val children = LinkedHashMap() private val ownVars = CompoundTag() - private val ownBoxes: StoryBoxTable? = if (parent == null) StoryBoxTable(this) else null + private val ownBoxes: StoryBoxTable? = if (parent == null) StoryBoxTable() else null private val ownRecords = CompoundTag() private val ownJournal = StoryJournal() private val ownHistory: StoryHistory? = if (parent == null) StoryHistory(this) else null @@ -144,7 +144,6 @@ class StoryRun internal constructor( frame.trampolined = trampolined frames += frame watch(key) - changed() return frame } @@ -174,7 +173,6 @@ class StoryRun internal constructor( children[name] = child frame.children += name history.add(name, child, emptyList()) - changed() child.start() return child } @@ -193,10 +191,7 @@ class StoryRun internal constructor( /** Stops the run for good, along with its branches, and takes back their temporary actions at once. */ internal fun stop() { if (state.done) { - if (!stopping) { - stopping = true - changed() - } + stopping = true return } stopping = true @@ -215,8 +210,6 @@ class StoryRun internal constructor( internal suspend fun awaitDone(): Status = status.first { it.done } - internal fun changed() = runs.changed() - /** This run and every branch under it. */ internal fun tree(): Sequence = sequenceOf(this) + children.values.asSequence().flatMap { it.tree() } @@ -245,7 +238,6 @@ class StoryRun internal constructor( children.clear() tail = null readTree(tag) - changed() } /** Runs [change] on the stopped story outside of it, then starts [restart] again. */ @@ -261,7 +253,6 @@ class StoryRun internal constructor( HollowEngine.LOGGER.error("Story '{}' failed to rewind", root.path, failure) } finally { restart.moving = false - root.changed() restart.start() } } @@ -272,7 +263,6 @@ class StoryRun internal constructor( pending.addAll(0, frames) frames.clear() tail = null - changed() } private fun finish(status: Status, result: Tag?, failure: String?) { @@ -282,7 +272,7 @@ class StoryRun internal constructor( pending.clear() journal.close(path) this.status.value = status - if (parent == null) runs.ended(this) else changed() + if (parent == null) runs.ended(this) } private fun watch(key: String) { diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt index a66052137..8e78afdc8 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -22,9 +22,6 @@ interface StoryOwner { /** The entity the stories are about, if the owner has one. */ val entity: Entity? - /** [runs], the runs of this owner, changed and should be saved. */ - fun changed(runs: StoryRuns) - /** The context a stage of the run [path] sees. */ fun context(path: String): StoryContext = StoryContext(this, path) } @@ -63,7 +60,6 @@ class StoryRuns(val owner: StoryOwner) { fun stop(key: String): Boolean { val run = runs.remove(key) ?: return false run.stop() - changed() return true } @@ -91,8 +87,6 @@ class StoryRuns(val owner: StoryOwner) { /** Wakes every sleeping run. */ fun wake() = runs.values.toList().forEach(StoryRun::start) - internal fun changed() = owner.changed(this) - internal fun holds(run: StoryRun): Boolean = runs[run.name] === run /** The run at [path]: a story under its key, then its branches by name. */ @@ -103,7 +97,6 @@ class StoryRuns(val owner: StoryOwner) { internal fun ended(run: StoryRun) { if (runs[run.name] === run) runs.remove(run.name) - changed() } internal fun waitFor(instance: String, run: StoryRun) = StoryWaits.instance(instance, run) @@ -117,7 +110,6 @@ class StoryRuns(val owner: StoryOwner) { run.pending += entry(run) run.history.add(key, run, emptyList()) runs[key] = run - changed() run.start() return StoryHandle(run, null) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt index af9a1a8cf..9cfa8821d 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt @@ -29,7 +29,6 @@ suspend fun once(name: String, block: suspend () -> Unit) { block() done.putBoolean(key, true) frame.run.records.put(ONCE, done) - frame.run.changed() } /** Computes a value once for the whole story, like [once]: a repeated segment gets the value computed the first time. */ @@ -46,7 +45,6 @@ internal suspend fun remember(name: String, serializer: KSerializer, b val value = block() StoryValues.put(values, key, serializer, value) frame.run.records.put(REMEMBERED, values) - frame.run.changed() return value } @@ -59,7 +57,6 @@ suspend fun forget(name: String): Boolean { val tag = records.getCompound(kind) tag.contains(key).also { if (it) tag.remove(key) } } > 0 - if (existed) frame.run.changed() return existed } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt index 0728b648e..d44eb1ae4 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -13,7 +13,6 @@ import net.minecraft.nbt.Tag import net.minecraft.server.MinecraftServer import net.minecraft.world.entity.Entity import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryValues.put -import ru.hollowhorizon.hollowengine.common.entities.NpcEntity import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull @@ -35,6 +34,22 @@ object StoryValues { /** Finds the entity a reference names, or null when it is not around right now. */ var entities: (UUID, KClass) -> Entity? = ::findOnServer + private val offWorld = ThreadLocal.withInitial { false } + + /** + * Runs [block] with a reference to an NPC that has no body read as the NPC kept in the registry. An undo puts such + * an NPC back through the registry at once; a stage would act on a copy that never moves, so it waits for the body. + */ + internal fun withOffWorldNpcs(block: () -> T): T { + val before = offWorld.get() + offWorld.set(true) + try { + return block() + } finally { + offWorld.set(before) + } + } + @Suppress("UNCHECKED_CAST") fun serializer(type: KType): KSerializer = serializers.getOrPut(type) { val classifier = @@ -103,8 +118,7 @@ object StoryValues { /** A loaded entity, or an NPC without a body: the registry keeps those apart from chunks, so they are always there. */ private fun findOnServer(uuid: UUID, type: KClass): Entity? { val server = currentServerOrNull() ?: return null - if (NpcEntity::class.java.isAssignableFrom(type.java)) server.npcs.resolve(uuid)?.let { return it } - return server.findEntity(uuid) ?: server.npcs.resolve(uuid) + return server.findEntity(uuid) ?: server.npcs.resolve(uuid)?.takeIf { offWorld.get() } } private fun MinecraftServer.findEntity(uuid: UUID): Entity? = diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt index 0ed9fc1b2..645e8e2a4 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -18,7 +18,6 @@ class StoryFrameTest { override val scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) override val server: MinecraftServer get() = error("no server") override val entity: Entity? get() = null - override fun changed(runs: StoryRuns) = Unit } private val sites = arrayOf("intro#1", "if#1.then/trial#1", "gate") diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt index 4c4bc33fc..11c0a7efb 100644 --- a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt @@ -89,7 +89,6 @@ class StoryInstancesTest { override val scope = scope override val server: MinecraftServer get() = error("no server") override val entity: Entity? get() = null - override fun changed(runs: StoryRuns) = Unit } val script = StartsDuringConstruction(StoryRuns(owner)) assertNull(script.observedReady) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt index 955e99167..2989461be 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt @@ -31,6 +31,7 @@ import net.minecraft.client.sounds.ChannelAccess import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.core.NonNullList +import net.minecraft.nbt.CompoundTag import net.minecraft.network.chat.Component import net.minecraft.resources.FileToIdConverter import net.minecraft.resources.ResourceLocation @@ -610,11 +611,12 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { AttachmentRegistry.close(level) } - override fun onEntitySaved(entity: Entity, tag: net.minecraft.nbt.CompoundTag) { + override fun onEntitySaved(entity: Entity, tag: CompoundTag) { + EntityEvent.Saving.post(EntityEvent.Saving(entity)) AttachmentRegistry.saveEntity(entity, tag) } - override fun onEntityLoaded(entity: Entity, tag: net.minecraft.nbt.CompoundTag) { + override fun onEntityLoaded(entity: Entity, tag: CompoundTag) { AttachmentRegistry.loadEntity(entity, tag) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt index b51c66a35..aa3e3149a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt @@ -32,6 +32,14 @@ object HighlightTheme { return hsvToArgb(hue, DslMarkerSaturation, DslMarkerValue) } + /** + * The color of slot [index] of a set of things told apart by color. Each slot turns the hue by the golden angle, + * so neighboring slots are always far apart, however many there are. + */ + fun paletteArgb(index: Int): Int = hsvToArgb((index * GoldenAngle) % 360f, PaletteSaturation, DslMarkerValue) + + private const val GoldenAngle = 137.50776f + private const val PaletteSaturation = 0.6f private const val DslMarkerSaturation = 0.45f private const val DslMarkerValue = 0.88f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt index 183382fb3..a6d15740a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import ru.hollowhorizon.hollowengine.client.HighlightTheme import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.widgets.UiGutterMarker import ru.hollowhorizon.hollowengine.client.utils.lang @@ -29,7 +28,7 @@ internal fun ideGutterMarkers( "hollowengine:textures/gui/icons/gutter/$icon.svg", "hollowengine.gui.ide.gutter.$kind".lang(*marker.args.toTypedArray()), listOf("gutter-$kind"), onClick = if (!marker.kind.navigable) null else { { if (file.text == analyzedText) navigate(marker.offset) } - }, tint = marker.color?.let { UiColor.fromArgb(HighlightTheme.dslMarkerArgb(it)) })) + }, tint = marker.color?.let { UiColor.fromArgb(it) })) } if (canRun) structure.runTargets.forEach { target -> val status = execution?.status ?: ScriptExecutionStatus.IDLE diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt index aba605a0a..49756163e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt @@ -19,6 +19,11 @@ open class EntityEvent(val entity: Entity) : Event { companion object : EventHandler() } + /** The entity is being written to NBT; what goes into its data now is part of this save. */ + class Saving(entity: Entity) : EntityEvent(entity) { + companion object : EventHandler() + } + /** The entity is leaving its level for [reason]; it is still whole when this is posted. */ class Removed(entity: Entity, val reason: Entity.RemovalReason) : EntityEvent(entity) { companion object : EventHandler() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 7f93f3a77..66277a1fb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt @@ -27,19 +27,19 @@ enum class GutterMarkerKind(val navigable: Boolean, val icon: String? = null) { STAGE(false), CHECKPOINT(false), ACTION(false), - REWIND(false), + REWIND(true), REWIND_START(false, "rewind"), - TEMPORARY(false), + TEMPORARY(true), TEMPORARY_OPEN(false, "temporary"), } data class GutterNavigationTarget(val label: String, val location: DefinitionLocation) /** - * [args] fill in the tooltip of the kind, such as the name of a checkpoint. Markers with the same [color] key are - * drawn in the same color, which tells apart the checkpoints and the calls that lead to each. + * [args] fill in the tooltip of the kind, such as the name of a checkpoint. [color] tints the icon, as ARGB: the + * checkpoints and the calls that lead to each share one. */ -data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList(), val color: String? = null) +data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList(), val color: Int? = null) enum class ScriptRunKind { PLAIN, NODE } From 125c806f9c55805336870dde383f7164ebfaf0e6 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Fri, 23 Oct 2026 13:25:41 +0300 Subject: [PATCH 19/25] Handle external story-action cancellations Treat cancellations caused by an unloaded, non-destroyed entity as a wait for that entity to return. Other external cancellations now fail the story instead of saving it in a state with nothing to resume. --- .../src/test/kotlin/StageExecutionTest.kt | 24 +++++++++++++++++++ .../src/test/kotlin/StageTestSupport.kt | 6 +++++ .../addons/narrative/story/Actions.kt | 17 ++++++++++++- .../addons/narrative/story/StoryRun.kt | 9 ++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/addons/compiler/src/test/kotlin/StageExecutionTest.kt b/addons/compiler/src/test/kotlin/StageExecutionTest.kt index 77f7e02e4..5662faba9 100644 --- a/addons/compiler/src/test/kotlin/StageExecutionTest.kt +++ b/addons/compiler/src/test/kotlin/StageExecutionTest.kt @@ -739,4 +739,28 @@ class StageExecutionTest { assertTrue(frame.getCompound("locals").contains("items")) world.stop() } + + @Test + fun `a cancellation that is not the story's own fails the story instead of putting it to sleep`() = runTest { + val world = world("preempted", """ + @State + suspend fun main() { + walk() + log("after") + } + + @State + suspend fun walk() { + log("walking") + await("path") + } + """) + val handle = world.launch("main") + StageTrace.cancel("path") + world.settle() + assertEquals(listOf("walking"), StageTrace.take()) + assertFalse(handle.isActive) + assertTrue(world.runs.keys.isEmpty(), "a failed story is not saved to wake up with nothing to run") + world.stop() + } } diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt index dd4cbd9e2..cc08a0e47 100644 --- a/addons/compiler/src/test/kotlin/StageTestSupport.kt +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -1,4 +1,5 @@ import StageTrace.release +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -61,6 +62,11 @@ object StageTrace { waiting.remove(name)?.forEach { it.complete(Unit) } } + /** Cancels what waits in [await] with [name], as an NPC channel that another owner takes cancels its action. */ + fun cancel(name: String) { + waiting.remove(name)?.forEach { it.cancel(CancellationException("'$name' was taken")) } + } + /** Makes [await] with [name] wait again, as before it was released. */ fun hold(name: String) { released -= name diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt index d05b7d187..2b5dd984e 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -1,9 +1,12 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer import net.minecraft.nbt.CompoundTag +import net.minecraft.world.entity.Entity import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -64,9 +67,21 @@ suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Pe val temporary = context[Temporary] ActionRecord(it, action, args, if (temporary != null) Lifetime.TEMPORARY else Lifetime.RUN, temporary?.until, context[ActiveAction]?.seq) } - return ActionScope(record, null).block().value + return try { + ActionScope(record, null).block().value + } catch (cancelled: CancellationException) { + throw run?.let { unloaded(call) }?.takeIf { currentCoroutineContext().isActive } ?: cancelled + } } +/** + * An entity of the call that left the world without being destroyed, like the body of an NPC whose chunk unloaded + * in the middle of the action, which cancels what the body was doing. The story then waits for the entity to come back. + */ +private fun unloaded(call: Array): StoryEntityMissing? = call.firstNotNullOfOrNull { value -> + (value as? Entity)?.takeIf { it.removalReason?.shouldDestroy() == false } +}?.let { StoryEntityMissing(it.uuid) } + /** * The body of a checkpoint label such as [position]: like [action] without `perform`. A label captures a state * when its checkpoint is passed and puts it back on every rewind to that checkpoint. diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt index baa0eaada..05c77c67a 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -7,6 +7,7 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.ListTag import net.minecraft.nbt.StringTag import net.minecraft.nbt.Tag +import net.minecraft.world.entity.Entity import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRun.Companion.current import kotlin.coroutines.CoroutineContext @@ -115,8 +116,14 @@ class StoryRun internal constructor( } catch (missing: StoryEntityMissing) { HollowEngine.LOGGER.debug("Story '{}' waits for entity {}", path, missing.uuid) pause() - runs.waitFor(missing.uuid, this) + if (StoryValues.entities(missing.uuid, Entity::class) != null) job?.invokeOnCompletion { start() } + else runs.waitFor(missing.uuid, this) } catch (cancelled: CancellationException) { + if (currentCoroutineContext().isActive) { + HollowEngine.LOGGER.error("Story '{}' failed", path, cancelled) + finish(Status.FAILED, null, cancelled.message ?: cancelled.javaClass.simpleName) + return + } if (stopping) finish(Status.STOPPED, null, null) else pause() throw cancelled } catch (failure: Throwable) { From a8af6531de724411ff724e1408e1baffe7318548 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Fri, 23 Oct 2026 14:09:55 +0300 Subject: [PATCH 20/25] Resume action calls across story restarts Treat direct action calls as stage boundaries so completed actions are not repeated and interrupted actions finish under their existing journal record after a restart. Persist serializable action results, recognize action aliases, and mark action boundaries in the editor gutter. --- .../session/structure/StoryGutterMarkers.kt | 27 ++++- .../plugin/ir/stages/StageIrGeneration.kt | 14 ++- .../common/plugin/ir/stages/StageLayout.kt | 7 +- .../common/plugin/ir/stages/StageRewriter.kt | 32 +++++ .../common/plugin/ir/stages/StageScope.kt | 15 ++- .../common/plugin/ir/stages/StageSymbols.kt | 1 + .../src/test/kotlin/EditorStructureTest.kt | 4 +- .../src/test/kotlin/StageCompileErrorsTest.kt | 30 +++++ .../src/test/kotlin/StageJournalTest.kt | 112 ++++++++++++++++++ .../addons/narrative/npcs/NpcActions.kt | 9 ++ .../addons/narrative/story/Actions.kt | 34 ++++-- .../addons/narrative/story/StoryActions.kt | 23 ++++ .../addons/narrative/story/StoryFrame.kt | 57 +++++++++ .../addons/narrative/story/StoryJournal.kt | 2 + .../common/scripting/ide/EditorStructure.kt | 1 + .../en_us/hollowengine/gui/ide/gutter.json | 3 +- .../ru_ru/hollowengine/gui/ide/gutter.json | 3 +- 17 files changed, 356 insertions(+), 18 deletions(-) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt index 5aeca4ff8..9efb64f44 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt @@ -18,10 +18,10 @@ import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind /** - * The gutter markers of story code: stage calls, after which a restart continues, and checkpoints with the calls that - * refer to them. Each checkpoint name of the file gets its own color, in the order the names first appear, and - * `rewind` and `temporary` get the color of the checkpoint they lead to, so a call that names none still shows which - * one it means. + * The gutter markers of story code: stage and action calls, after which a restart continues, and checkpoints with the + * calls that refer to them. Each checkpoint name of the file gets its own color, in the order the names first appear, + * and `rewind` and `temporary` get the color of the checkpoint they lead to, so a call that names none still shows + * which one it means. */ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) { private class Checkpoint(val owner: KtFunction?, val offset: Int, val name: String) @@ -51,6 +51,9 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) ?: GutterMarker(0, GutterMarkerKind.TEMPORARY_OPEN) } if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) + if ((ActionClassId in symbol.annotations || ActionAliasClassId in symbol.annotations) && isBoundary(expression)) { + return GutterMarker(0, GutterMarkerKind.ACTION_CALL, listOf(symbol.name.asString())) + } val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type if (symbol.name.asString() == "invoke" && receiver != null && StateClassId in receiver.annotations) { return GutterMarker(0, GutterMarkerKind.STAGE, listOf(callExpression?.calleeExpression?.text ?: "invoke")) @@ -82,6 +85,20 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) private fun color(name: String): Int = HighlightTheme.paletteArgb(colors.getOrPut(name) { colors.size }) + /** + * Whether an action called at [expression] is a boundary: it is written in a stage or `@State` lambda itself, not + * in a lambda inside it or in `catch` and `finally`, where it is an ordinary call. + */ + private fun isBoundary(expression: KtExpression): Boolean = with(session) { + val function = expression.parents.filterIsInstance().firstOrNull() ?: return false + val stage = when (function) { + is KtNamedFunction -> StateClassId in function.symbol.annotations + is KtFunctionLiteral -> isStageLambda(function) + else -> false + } + stage && expression.parents.takeWhile { it != function }.none { it is KtCatchClause || it is KtFinallySection } + } + /** The code a checkpoint belongs to: the function or `@State` lambda it is written in, which is one run's. */ private fun owner(element: PsiElement): KtFunction? = element.parents.filterIsInstance() .firstOrNull { it is KtNamedFunction || it is KtFunctionLiteral && isStageLambda(it) } @@ -115,6 +132,8 @@ internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) private companion object { val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") val StateClassId = ClassId(StoryPackage, Name.identifier("State")) + val ActionClassId = ClassId(StoryPackage, Name.identifier("Action")) + val ActionAliasClassId = ClassId(StoryPackage, Name.identifier("ActionAlias")) val CheckpointName = Name.identifier("checkpoint") val RewindName = Name.identifier("rewind") val TemporaryName = Name.identifier("temporary") diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt index 6224de417..ecdba7872 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -93,7 +93,15 @@ internal class StageIrGeneration(private val context: IrPluginContext) { if (variable.origin == IrDeclarationOrigin.IR_TEMPORARY_VARIABLE) "A value computed before" else "'${variable.name}', used after" reporter.error( variable, - "$what the stage call '${site.name}' is saved there, but $problem; compute it after the call or make it serializable" + "$what the ${if (site.kind == SiteKind.ACTION) "action" else "stage"} call '${site.name}' is saved there, but $problem; compute it after the call or make it serializable" + ) + } + } + layout.sites.filter { it.kind == SiteKind.ACTION && it.form != SiteForm.STATEMENT && !it.call.type.isUnit() }.forEach { site -> + stages.types.problem(site.call.type)?.let { problem -> + reporter.error( + site.call, + "The result of the action call '${site.name}' is saved for a restart after it, but $problem; use the result in a statement of its own or return a serializable value" ) } } @@ -168,7 +176,9 @@ internal class StageIrGeneration(private val context: IrPluginContext) { override fun visitCall(expression: IrCall) { super.visitCall(expression) - stages.siteKind(expression) ?: return + // An action elsewhere in a stage is an ordinary call: only one written in the stage itself is a boundary. + val kind = stages.siteKind(expression) ?: return + if (kind == SiteKind.ACTION) return when { nested > 0 -> reporter.error( expression, diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt index bff898e72..72e190acb 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt @@ -18,7 +18,7 @@ import java.util.* internal enum class SiteForm { STATEMENT, VARIABLE, ASSIGN, RETURN } /** - * A stage call or checkpoint inside a stage. Positions in the frame are `index * 2` while the call runs and + * A stage call, action call or checkpoint inside a stage. Positions in the frame are `index * 2` while the call runs and * `index * 2 + 1` after it; [name] is what the save stores instead. */ internal class StageSite( @@ -166,6 +166,11 @@ internal class StageLayout(private val stages: StageScope, private val function: "$path$callee#${counters.next("call:$callee")}" } + SiteKind.ACTION -> { + val callee = call.symbol.owner.name.asString() + "$path$callee#${counters.next("call:$callee")}" + } + SiteKind.LAMBDA -> { val receiver = (call.dispatchReceiver as? IrGetValue)?.symbol?.owner?.name?.asString() ?: "lambda" "$path$receiver#${counters.next("call:$receiver")}" diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt index b16a00327..64ef22dc2 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -261,6 +261,7 @@ internal class StageRewriter( private fun value(builder: IrBuilderWithScope, site: StageSite, used: Boolean): IrExpression { val type = if (used) site.call.type else builtIns.unitType val keepsResult = !type.isUnit() && !type.isNothing() + if (site.kind == SiteKind.ACTION) return action(builder, site, type, keepsResult) val fresh = builder.irBlock(resultType = type) { if (site.kind == SiteKind.CHECKPOINT) { saveLocals(site) @@ -302,6 +303,37 @@ internal class StageRewriter( return builder.irWhen(type, branches) } + /** + * The value of an action call: performed, finished from its record when a restart interrupted it, or taken from + * the save when it was done before the restart. + */ + private fun action(builder: IrBuilderWithScope, site: StageSite, type: IrType, keepsResult: Boolean): IrExpression { + val resultSerializer = { at: IrBuilderWithScope -> if (keepsResult) serializer(at, type) else nullSerializer() } + fun completed(at: IrBuilderWithScope, value: IrExpression): IrExpression = + frameCall(at, "completeAction", at.irInt(site.index), value, resultSerializer(at)).let { if (keepsResult) at.irAs(it, type) else it } + + val fresh = builder.irBlock(resultType = type) { + +frameCall(this, "beginAction", irInt(site.index)) + saveLocals(site) + +completed(this, site.call) + } + val after = frameCall(builder, "resumeAfter", builder.irInt(site.index), resultSerializer(builder)) + return builder.irWhen( + type, listOf( + builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.callPosition)), + completed(builder, frameCall(builder, "resumeAction", builder.irInt(site.index))) + ), + builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.afterPosition)), + if (keepsResult) builder.irAs(after, type) else after + ), + builder.irBranch(frameCall(builder, "isResuming"), zero(type)), + builder.irElseBranch(fresh), + ) + ) + } + /** * A call in tail position. When the caller runs tail calls, the frame gives its place to the callee and the * stage returns at once; otherwise it is an ordinary call whose result the stage returns. diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt index 7c95f223e..9c761f904 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -49,11 +49,20 @@ internal class StageScope( return when { symbols.isCheckpoint(callee) -> SiteKind.CHECKPOINT isSuspendState(callee) -> SiteKind.STAGE + isAction(callee) -> SiteKind.ACTION callee.name.asString() == "invoke" && call.dispatchReceiver?.type?.let(types::isStageLambda) == true -> SiteKind.LAMBDA else -> null } } + /** + * Whether [function] is an action a stage can call as a boundary: a suspend `@Action` or `@ActionAlias` that is + * not a checkpoint label, which only runs inside `checkpoint(name) { }`. + */ + private fun isAction(function: IrFunction): Boolean = function.isSuspend && + function.annotations.any { it.isAnnotation(StageSymbols.ACTION) || it.isAnnotation(StageSymbols.ACTION_ALIAS) } && + function.parameters.none { it.type.classOrNull == symbols.checkpointScope } + /** The parameters a stage saves as its arguments: all of them but the script it belongs to. */ fun arguments(function: IrFunction): List = function.parameters.filter { it.kind != IrParameterKind.DispatchReceiver } @@ -101,4 +110,8 @@ internal class StageScope( function.annotations.firstOrNull { it.isAnnotation(StageSymbols.STATE) } } -internal enum class SiteKind { STAGE, LAMBDA, CHECKPOINT } +/** + * The calls a stage is cut at. An [ACTION] call is a boundary too: a restart does not perform a finished action + * again, and finishes one it interrupted. + */ +internal enum class SiteKind { STAGE, LAMBDA, CHECKPOINT, ACTION } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt index 1944cd682..7eec3a511 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -106,6 +106,7 @@ internal class StageSymbols private constructor(context: IrPluginContext, val st val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") val STATE: FqName = PACKAGE.child(Name.identifier("State")) val ACTION: FqName = PACKAGE.child(Name.identifier("Action")) + val ACTION_ALIAS: FqName = PACKAGE.child(Name.identifier("ActionAlias")) val UNSAVED: FqName = PACKAGE.child(Name.identifier("Unsaved")) fun find(context: IrPluginContext): StageSymbols? = diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index ddf8f5f6c..617827883 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -166,6 +166,7 @@ class EditorStructureTest { step() checkpoint("arena") place("x") + listOf(1).forEach { place("y") } } """.trimIndent() val markers = analyzer.structure("Story.kts", source).markers @@ -174,7 +175,8 @@ class EditorStructureTest { assertEquals(source.indexOf(" step()") + 4, stage.offset) assertEquals(listOf("step"), stage.args) assertEquals(listOf("arena"), markers.single { it.kind == GutterMarkerKind.CHECKPOINT }.args) - assertEquals(GutterMarkerKind.COROUTINE, markers.single { it.offset == source.indexOf("place(\"x") }.kind) + assertEquals(GutterMarkerKind.ACTION_CALL, markers.single { it.offset == source.indexOf("place(\"x") }.kind) + assertEquals(GutterMarkerKind.COROUTINE, markers.single { it.offset == source.indexOf("place(\"y") }.kind, "in a lambda it is no boundary") } @Test diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt index 3bf1fd72e..ed2aeaf6b 100644 --- a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -234,6 +234,36 @@ class StageCompileErrorsTest { """ ) + @Test + fun `the result of an action a stage keeps must be saveable`() = assertError( + "The result of the action call 'lock#1'", """ + @Action + suspend fun lock(): Any = action { perform { Any() } } + + @State + suspend fun main() { + val held = lock() + log(held.toString()) + } + """ + ) + + @Test + fun `an action of an addon is a boundary like an action of the script`() = assertError( + "'iterator', used after the action call 'move#1'", """ + import net.minecraft.world.phys.Vec3 + import ru.hollowhorizon.hollowengine.addons.narrative.npcs.move + import ru.hollowhorizon.hollowengine.common.entities.NpcEntity + + @State + suspend fun main(guard: NpcEntity) { + val iterator = listOf(1).iterator() + guard.move(Vec3.ZERO) + log(iterator.next().toString()) + } + """ + ) + @Test fun `a checkpoint cannot share its name with a function of the script`() = assertError( "A checkpoint cannot be named 'arena'", """ diff --git a/addons/compiler/src/test/kotlin/StageJournalTest.kt b/addons/compiler/src/test/kotlin/StageJournalTest.kt index 96f585cda..9369bfdee 100644 --- a/addons/compiler/src/test/kotlin/StageJournalTest.kt +++ b/addons/compiler/src/test/kotlin/StageJournalTest.kt @@ -77,6 +77,118 @@ class StageJournalTest : StageTest() { world.stop() } + @Test + fun `an action finished before a restart is not performed again, and its one record still undoes it`() = runTest { + val world = world("finished", PLACE + """ + @State + suspend fun main() { + place("door") + await("x") + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place door"), StageTrace.take(), "the restart goes on after the action") + + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo door", "place door"), StageTrace.take()) + world.stop() + } + + @Test + fun `an action a restart interrupts is finished under its record, with the snapshot it took first`() = runTest { + val world = world("interrupted", $$""" + @Action + suspend fun walk(name: String) = action { + val there = capture { log("capture $name"); name in StageTrace.blocks } + undo { + if (!there) StageTrace.blocks -= name + log("undo $name") + } + perform { + log("walk $name") + StageTrace.await(name) + StageTrace.blocks += name + } + } + + @State + suspend fun main() { + walk("gate") + log("arrived") + await("x") + } + """ + ) + world.launch("main") + assertEquals(listOf("capture gate", "walk gate"), StageTrace.take()) + + world.restart() + assertEquals(listOf("walk gate"), StageTrace.take(), "the walk goes on without a new capture") + + world.release("gate") + assertEquals(listOf("arrived"), StageTrace.take()) + StageTrace.hold("gate") + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo gate", "capture gate", "walk gate"), StageTrace.take(), "one record, undone once") + assertEquals(emptySet(), StageTrace.blocks) + world.stop() + } + + @Test + fun `a restart before an action wrote its record repeats the segment before the call`() = runTest { + val world = world("unwritten", PLACE + """ + suspend fun target(): String { + StageTrace.await("aim") + return "door" + } + + @State + suspend fun main() { + log("start") + place(target()) + await("x") + } + """ + ) + world.launch("main") + world.restart() + world.release("aim") + assertEquals(listOf("start", "start", "place door"), StageTrace.take()) + world.stop() + } + + @Test + fun `the result of an action is kept for a restart after it`() = runTest { + val world = world("counted", $$""" + @Action + suspend fun count(name: String): Int = action { + undo { } + perform { + log("count $name") + 42 + } + } + + @State + suspend fun main() { + val counted = count("sheep") + await("x") + log("got $counted") + } + """ + ) + world.launch("main") + world.restart() + world.release("x") + assertEquals(listOf("count sheep", "got 42"), StageTrace.take()) + world.stop() + } + @Test fun `a rewind to a checkpoint undoes what came after it and keeps what once did`() = runTest { val world = world("rewind", PLACE + """ diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt index 2669f8d39..39d57d760 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt @@ -5,6 +5,7 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.Entity import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.ActionAlias import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction import ru.hollowhorizon.hollowengine.addons.narrative.story.Unsaved import ru.hollowhorizon.hollowengine.addons.narrative.story.action @@ -45,9 +46,11 @@ suspend fun NpcEntity.move(target: Entity, @Unsaved options: MoveOptions = MoveO * @param dist The minimum distance to the entity; movement stops when this distance is reached. * @param speed The NPC's movement speed. */ +@ActionAlias suspend fun NpcEntity.move(entity: Entity, dist: Double = 1.5, speed: Double = 1.0): MoveResult = move(entity, MoveOptions(arrivalDistance = dist, speed = speed)) +@ActionAlias suspend fun NpcEntity.move(target: Ref, options: MoveOptions = MoveOptions()): MoveResult = move(target.resolve(), options) @@ -59,6 +62,7 @@ fun NpcEntity.startMove(target: Ref, options: MoveOptions = MoveOpti * * @param mob The entity to move to. */ +@ActionAlias suspend infix fun NpcEntity.move(mob: Entity): MoveResult = move(entity = mob) /** @@ -81,6 +85,7 @@ suspend fun NpcEntity.move(pos: Vec3, @Unsaved options: MoveOptions = MoveOption * @param dist The minimum distance to the position; movement stops when this distance is reached. * @param speed The NPC's movement speed. */ +@ActionAlias suspend fun NpcEntity.move(pos: Vec3, dist: Double = 1.5, speed: Double = 1.0): MoveResult = move(pos, MoveOptions(arrivalDistance = dist, speed = speed)) @@ -92,6 +97,7 @@ fun NpcEntity.startMove(pos: Vec3, options: MoveOptions = MoveOptions()): NpcAct * * @param position The position to move to. */ +@ActionAlias suspend infix fun NpcEntity.move(position: Vec3): MoveResult = move(pos = position) /** @@ -190,6 +196,7 @@ suspend fun NpcEntity.lookAt( } } +@ActionAlias suspend infix fun NpcEntity.lookAt(position: Vec3) { lookAt(position, DEFAULT_LOOK_DURATION) } @@ -226,9 +233,11 @@ suspend fun NpcEntity.lookAt( } } +@ActionAlias suspend fun NpcEntity.lookAt(target: Ref, duration: Duration, maxAngularSpeed: Float = 360f) = lookAt(target.resolve(), duration, maxAngularSpeed) +@ActionAlias suspend infix fun NpcEntity.lookAt(entity: Entity) { lookAt(entity, DEFAULT_LOOK_DURATION) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt index 2b5dd984e..821bf0d57 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -30,6 +30,14 @@ annotation class Action(val id: String = "") @Retention(AnnotationRetention.RUNTIME) annotation class Unsaved +/** + * A shortcut that performs exactly one [Action], such as an overload taking other arguments. A stage calls it as it + * would call the action itself: the call is a boundary that a restart does not repeat once it is done. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ActionAlias + /** An action that is not undone: an ordinary function editors and `.story` can call by [id]. */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @@ -59,18 +67,26 @@ enum class Lifetime { */ suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Performed): R { val action = StoryActions.enclosing(block, "action { }") - ActionReplay.take(action)?.let { replay -> return ActionScope(null, replay).also { replay.scope = it }.block().value } + ActionReplay.take(action)?.let { replay -> + replay.call = call + return ActionScope(null, replay).also { replay.scope = it }.block().value + } val context = currentCoroutineContext() val run = context[StoryRun.Element]?.run?.takeIf { context[Replaying] == null } val args = action.encode(call) + // A stage calling the action directly keeps its record, to finish the action instead of repeating it after a restart. + val site = run?.frames?.lastOrNull()?.takeIf { context[ActiveAction] == null && it.claimsAction() } val record = run?.let { val temporary = context[Temporary] - ActionRecord(it, action, args, if (temporary != null) Lifetime.TEMPORARY else Lifetime.RUN, temporary?.until, context[ActiveAction]?.seq) + ActionRecord(it, action, args, if (temporary != null) Lifetime.TEMPORARY else Lifetime.RUN, temporary?.until, context[ActiveAction]?.seq, site) } return try { ActionScope(record, null).block().value - } catch (cancelled: CancellationException) { - throw run?.let { unloaded(call) }?.takeIf { currentCoroutineContext().isActive } ?: cancelled + } catch (failure: Throwable) { + val unloading = failure is CancellationException && !currentCoroutineContext().isActive || failure is StoryEntityMissing + val missing = if (failure is CancellationException && !unloading) run?.let { unloaded(call) } else null + if (!unloading && missing == null) site?.actionFailed() + throw missing ?: failure } } @@ -78,7 +94,7 @@ suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Pe * An entity of the call that left the world without being destroyed, like the body of an NPC whose chunk unloaded * in the middle of the action, which cancels what the body was doing. The story then waits for the entity to come back. */ -private fun unloaded(call: Array): StoryEntityMissing? = call.firstNotNullOfOrNull { value -> +internal fun unloaded(call: Array): StoryEntityMissing? = call.firstNotNullOfOrNull { value -> (value as? Entity)?.takeIf { it.removalReason?.shouldDestroy() == false } }?.let { StoryEntityMissing(it.uuid) } @@ -180,7 +196,10 @@ class Performed internal constructor(internal val value: R) internal class ActionConflict(reason: String) : RuntimeException(reason) -/** Writes a call of [action] made in [run] to the journal around its perform. */ +/** + * Writes a call of [action] made in [run] to the journal around its perform; [site] is the frame of the stage that + * calls it directly, which keeps the record to finish the action after a restart. + */ internal class ActionRecord( private val run: StoryRun, private val action: StoryAction, @@ -188,9 +207,10 @@ internal class ActionRecord( private val lifetime: Lifetime, private val until: String?, private val outer: Long?, + private val site: StoryFrame?, ) { fun begin(snapshots: CompoundTag): JournalEntry = - run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer) + run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer).also { site?.action = it.seq } fun done(entry: JournalEntry) { entry.done = true diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt index 9d8cef4ce..75c92b3d9 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -3,6 +3,8 @@ package ru.hollowhorizon.hollowengine.addons.narrative.story import io.github.classgraph.ClassGraph import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer @@ -156,6 +158,24 @@ object StoryActions { } } + /** + * Finishes the action of [entry], which a restart interrupted: performs it again under the same record, with the + * snapshots taken before it first started, and returns its result. + */ + @Suppress("UNCHECKED_CAST") + internal suspend fun resume(entry: JournalEntry): Any? { + val replay = replay(entry) ?: throw StoryException("Story '${entry.run}' cannot finish '${entry.action}'") + val perform = (replay.scope as? ActionScope)?.perform + ?: throw StoryException("Story '${entry.run}' cannot finish '${entry.action}': it performs nothing") + val value = try { + withContext(ActiveAction(entry.seq)) { perform() } + } catch (cancelled: CancellationException) { + throw unloaded(replay.call)?.takeIf { currentCoroutineContext().isActive } ?: cancelled + } + entry.done = true + return value + } + /** * Calls the action of [entry] again with its saved receivers and parameters, declaring its blocks only. An entity * it needs that is not loaded is thrown as [StoryEntityMissing], so the caller can wait for it. @@ -329,6 +349,9 @@ class StoryAction internal constructor(val id: String, internal val method: Meth internal class ActionReplay(val entry: JournalEntry, val action: StoryAction) { var scope: CaptureScope? = null + /** The receivers and parameters the call was made with again. */ + var call: Array = emptyArray() + fun snapshot(index: Int, serializer: KSerializer): Any? { val tag = entry.snapshots.get(index.toString()) ?: throw StoryException("The record of '${action.id}' has no snapshot #$index: the action captures more than it did then") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt index 490ffb641..10e597307 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -56,6 +56,12 @@ class StoryFrame internal constructor( /** Where the frame was before the call in progress: a call that fails returns the frame there. */ private var previous: CompoundTag? = null + /** + * The journal record of the action called at the current position, which a restart in the middle of it finishes; + * [NO_RECORD] until the action writes it, null when no action is being called. + */ + internal var action: Long? = null + /** Whether the stage is still finding its way back to the position it was saved at. */ fun isResuming(): Boolean = resuming @@ -168,6 +174,50 @@ class StoryFrame internal constructor( } } + /** + * The stage is about to call the action at [site]. The call is a boundary: once it is done a restart does not + * perform it again, and a restart in the middle of it finishes it with the record it wrote. + */ + fun beginAction(site: Int) { + run.discardPending("'$stage' moved on") + previous = CompoundTag().apply { + position?.let { putString("position", it) } + put("locals", locals.copy()) + result?.let { put("result", it) } + } + position = CALL + sites[site] + result = null + action = NO_RECORD + } + + /** The action at [site] returned [value], which the frame keeps with [serializer] for a restart after it. */ + fun completeAction(site: Int, value: Any?, serializer: KSerializer?): Any? { + position = AFTER + sites[site] + previous = null + action = null + result = serializer?.let { StoryValues.encode(it, value) } + segment = CompoundTag() + segmentMark = run.journal.next + return value + } + + /** Finishes the action at [site] that was in progress when the story was saved, from the record it wrote. */ + suspend fun resumeAction(site: Int): Any? { + resuming = false + val entry = action?.let(run.journal::entry) + ?: throw StoryException("The saved story lost the record of '${sites[site]}' in '$stage'") + return StoryActions.resume(entry) + } + + /** Whether the action starting now is the one called at the current position, which records it in [action]. */ + internal fun claimsAction(): Boolean = action == NO_RECORD + + /** The action called at the current position failed, and the stage goes on in the segment it was in before. */ + internal fun actionFailed() { + action = null + callFailed() + } + /** Ends a segment at the checkpoint at [site], which the story can be rewound to. */ fun checkpoint(site: Int) = pass(site, emptyList()) @@ -249,6 +299,10 @@ class StoryFrame internal constructor( internal fun adopt(hash: Int, sites: Array): StoryFrame { this.sites = sites + if (action == NO_RECORD) { + action = null + callFailed() + } val saved = position target = saved?.let(::positionOf) ?: -1 if (saved != null && target < 0) { @@ -305,6 +359,7 @@ class StoryFrame internal constructor( put("locals", locals) result?.let { put("result", it) } previous?.let { put("previous", it) } + action?.let { putLong("action", it) } put("segment", segment) putLong("entryMark", entryMark) putLong("segmentMark", segmentMark) @@ -315,6 +370,7 @@ class StoryFrame internal constructor( internal companion object { private const val CALL = "call:" private const val AFTER = "after:" + const val NO_RECORD = -1L private const val MAX_ARGUMENTS_IN_KEY = 48 fun load(run: StoryRun, tag: CompoundTag) = @@ -325,6 +381,7 @@ class StoryFrame internal constructor( locals = tag.getCompound("locals").copy() result = tag.get("result")?.copy() previous = tag.getCompound("previous").copy().takeIf { tag.contains("previous") } + action = tag.getLong("action").takeIf { tag.contains("action") } segment = tag.getCompound("segment").copy() entryMark = tag.getLong("entryMark") segmentMark = tag.getLong("segmentMark") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt index 6b2907966..b0e653689 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -138,6 +138,8 @@ internal class StoryJournal { } } + fun entry(seq: Long): JournalEntry? = entries.firstOrNull { it.seq == seq } + /** What [run] performed, for the inspector and commands. */ fun entriesOf(run: String): List = entries.filter { it.run == run } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 66277a1fb..f88307b48 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt @@ -27,6 +27,7 @@ enum class GutterMarkerKind(val navigable: Boolean, val icon: String? = null) { STAGE(false), CHECKPOINT(false), ACTION(false), + ACTION_CALL(false, "action"), REWIND(true), REWIND_START(false, "rewind"), TEMPORARY(true), diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json index f73deeb2d..e275d1ecd 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json @@ -4,13 +4,14 @@ "override": "Go to overridden declaration", "coroutine": "Suspend function call", "event": "Event handler", - "stage": "Stage %s: once it returns, a restart and rewind() continue below this line", + "stage": "Stage %s: once it returns, a restart continues below this line", "checkpoint": "Checkpoint %1$s: rewind(\"%1$s\") returns here, and a restart continues below", "rewind": "rewind() goes back to checkpoint %s", "rewind_start": "rewind() goes back to where this run started: no checkpoint precedes it", "temporary": "Taken back at checkpoint %s, or when the story ends", "temporary_open": "Taken back at the next checkpoint this run passes, or when the story ends", "action": "Reversible action: the story journal can undo it", + "action_call": "Action %s: once it is done, a restart continues below this line; one the restart interrupts is finished", "run": "Run script", "run_state": "Run state %s", "stop": "Stop script", diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json index 516250b14..f78ce56f9 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json @@ -4,13 +4,14 @@ "override": "Перейти к переопределённой декларации", "coroutine": "Вызов suspend-функции", "event": "Обработчик события", - "stage": "Стадия %s: когда она вернётся, рестарт и rewind() продолжат ниже этой строки", + "stage": "Стадия %s: когда она вернётся, рестарт продолжит ниже этой строки", "checkpoint": "Чекпоинт %1$s: rewind(\"%1$s\") возвращает сюда, рестарт продолжает ниже", "rewind": "rewind() возвращает к чекпоинту %s", "rewind_start": "rewind() возвращает к началу этого прогона: чекпоинтов перед ним нет", "temporary": "Снимается на чекпоинте %s или в конце истории", "temporary_open": "Снимается на следующем чекпоинте этого прогона или в конце истории", "action": "Обратимое действие: журнал истории может его отменить", + "action_call": "Действие %s: когда оно закончится, рестарт продолжит ниже этой строки; прерванное рестартом - продолжит действие", "run": "Запустить скрипт", "run_state": "Запустить состояние %s", "stop": "Остановить скрипт", From 44814b6f7b23950a00e00c6d14f6d2a64c99be7c Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Fri, 23 Oct 2026 15:03:10 +0300 Subject: [PATCH 21/25] Keep NPC movement chunks loaded Run position and entity-follow movement inside forceLoad so NPCs can finish walks without nearby players. Treat stale entity references replaced in the registry as unloaded during story actions. --- .../addons/narrative/story/Actions.kt | 10 +++++++--- .../common/npcs/navigation/NpcMovement.kt | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt index 821bf0d57..151fd710c 100644 --- a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -91,11 +91,15 @@ suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Pe } /** - * An entity of the call that left the world without being destroyed, like the body of an NPC whose chunk unloaded - * in the middle of the action, which cancels what the body was doing. The story then waits for the entity to come back. + * An entity of the call that is no longer the one in the world, without having been destroyed: the body of an NPC + * whose chunk unloaded, or a copy kept by the registry that a new body replaced. Either cancels what it was doing in + * the middle of the action, and the story then waits for the entity to come back. */ internal fun unloaded(call: Array): StoryEntityMissing? = call.firstNotNullOfOrNull { value -> - (value as? Entity)?.takeIf { it.removalReason?.shouldDestroy() == false } + (value as? Entity)?.takeIf { entity -> + val reason = entity.removalReason + if (reason != null) !reason.shouldDestroy() else StoryValues.entities(entity.uuid, entity::class) !== entity + } }?.let { StoryEntityMissing(it.uuid) } /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt index e865f4cc0..a772f22cb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt @@ -7,6 +7,7 @@ import net.minecraft.world.level.pathfinder.Path import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.common.coroutines.Ref import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.forceLoad import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds @@ -69,7 +70,10 @@ enum class Shortfall { BLOCKED, } -/** Walks to where [target] says, asked again as the NPC goes; the channel the walk holds is up to the caller. */ +/** + * Walks to where [target] says, asked again as the NPC goes; the channel the walk holds is up to the caller. Like + * every walk, it keeps the chunks around the NPC loaded until it ends, so it finishes where no player is. + */ suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): MoveResult = moveThrough(listOf(target), options) @@ -77,7 +81,7 @@ suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): * Walks through [targets] in turn, stopping only at the last: each one before it counts as passed within * [MoveOptions.passDistance], and the NPC plans its pace along the ones still ahead, so it rounds them as turns. */ -suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult { +suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult = forceLoad { npcNavigation.settingsOverride = options.navigation?.let { tune -> (navigationComponent ?: NavigationComponent()).tune() } npcNavigation.facing.setMove(options.facing) npcNavigation.moveZones = options.avoid @@ -86,10 +90,10 @@ suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptio val last = index == targets.lastIndex val reach = if (last) maxOf(options.arrivalDistance, MIN_ARRIVAL_DISTANCE) else options.passDistance val result = walkLeg(target, targets.subList(index + 1, targets.size), options, reach, last) - if (result != MoveResult.Arrived) return result + if (result != MoveResult.Arrived) return@forceLoad result } finishFacing(options.facing) - return MoveResult.Arrived + MoveResult.Arrived } finally { navigation.stop() npcNavigation.settingsOverride = null @@ -209,7 +213,10 @@ private fun NpcEntity.turnBodyTo(yaw: Float): Boolean { } /** Walks up to [target], following it while it moves and waiting for it as [MoveOptions.unavailableTarget] says. */ -suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult { +suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult = + forceLoad { follow(target, options) } + +private suspend fun NpcEntity.follow(target: Ref, options: MoveOptions): MoveResult { while (true) { if (!target.isLinkAlive && options.unavailableTarget == UnavailableTargetPolicy.FAIL) { return MoveResult.TargetUnavailable From d503534f0f23387a61a55d76d264835a981ebc2c Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Sat, 24 Oct 2026 16:03:36 +0300 Subject: [PATCH 22/25] Add trackball rotation and timeline selection #117 --- .../client/editor/GizmoKeyboardTransform.kt | 29 +++++- .../client/editor/GizmoManipulator.kt | 3 + .../client/editor/GizmoTrackballRotation.kt | 37 ++++++++ .../hollowengine/client/keymap/HollowKeys.kt | 2 +- .../client/ui/HollowUiInputController.kt | 5 ++ .../client/ui/ide/GizmoToolbar.kt | 32 ++++--- .../ide/files/montage/MontageEditorPanel.kt | 4 +- .../files/montage/MontageTimelineSession.kt | 8 +- .../client/ui/ide/files/rig/RigEditorPanel.kt | 2 +- .../client/ui/ide/files/rig/RigViewport.kt | 13 ++- .../client/ui/ide/files/vfx/VfxEditorPanel.kt | 15 +++- .../ui/ide/files/vfx/VfxTimelineBridge.kt | 4 +- .../client/ui/ide/timeline/TimelineApi.kt | 2 + .../client/ui/ide/timeline/TimelineKeys.kt | 2 + .../ui/ide/timeline/TimelineSelection.kt | 41 +++++++++ .../ide/timeline/ui/HollowTimelineChrome.kt | 14 ++- .../ide/timeline/ui/HollowTimelineSupport.kt | 4 + .../ui/ide/timeline/ui/TimelineTrackClicks.kt | 15 ++++ .../client/ui/widgets/OrbitPivot.kt | 38 -------- .../client/ui/widgets/UiViewAxes.kt | 5 +- .../common/config/HollowEngineConfig.kt | 4 - .../en_us/hollowengine/gui/ide/gizmo.json | 3 +- .../lang/en_us/hollowengine/gui/keymap.json | 2 +- .../ru_ru/hollowengine/gui/ide/gizmo.json | 3 +- .../lang/ru_ru/hollowengine/gui/keymap.json | 2 +- .../textures/gui/icons/orbit_selection.svg | 5 -- .../ui/styles/animator-editor.hss | 8 ++ .../hollowengine/ui/styles/timeline.hss | 4 + .../src/test/kotlin/MontageRigEditingTest.kt | 15 ++++ .../editor/GizmoTrackballRotationTest.kt | 90 +++++++++++++++++++ .../timeline/ui/HollowTimelinePointerTest.kt | 20 +++++ .../ui/ide/timeline/TimelineSelectionTest.kt | 63 +++++++++++++ .../hollowengine/client/ui/input/FocusTest.kt | 43 +++++++++ 33 files changed, 455 insertions(+), 82 deletions(-) create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt delete mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt delete mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg create mode 100644 runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt create mode 100644 runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt index 1c01fde85..8a237398b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt @@ -1,5 +1,8 @@ package ru.hollowhorizon.hollowengine.client.editor +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import net.minecraft.world.phys.Vec3 import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys @@ -21,6 +24,10 @@ enum class GizmoKeyResult { CANCELLED, } +enum class GizmoRotationMode { + VIEW_ALIGNED, TRACKBALL, +} + /** * A transform started from the keyboard, the way Blender does it: G, R or S begins it where the pointer * is, the pointer drives it, X, Y or Z holds it to an axis (of the orientation the gizmo is set to first, of @@ -43,6 +50,11 @@ class GizmoKeyboardTransform( var orientation: GizmoOrientation? = null private set + var rotationMode by mutableStateOf(GizmoRotationMode.VIEW_ALIGNED) + private set + + private val trackball = manipulator.trackball(start, startX, startY) + private val primary = if (mode == GizmoEditMode.SCALE) GizmoOrientation.LOCAL else GizmoSettings.orientation private lateinit var handle: GizmoHandle @@ -53,11 +65,24 @@ class GizmoKeyboardTransform( } /** Where the pointer at ([x], [y]) takes the object, or null while it cannot tell. */ - fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? = manipulator.update(drag, x, y, modifiers) + fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? = + if (mode == GizmoEditMode.ROTATE && rotationMode == GizmoRotationMode.TRACKBALL) { + trackball.update(x, y, modifiers) + } else manipulator.update(drag, x, y, modifiers) fun key(key: Int, modifiers: Int): GizmoKeyResult { val axis = AXIS_ACTIONS.indexOfFirst { it.matches(key, modifiers) } return when { + mode == GizmoEditMode.ROTATE && HollowKeys.ROTATE.matches(key, modifiers) -> { + rotationMode = if (rotationMode == GizmoRotationMode.VIEW_ALIGNED) { + GizmoRotationMode.TRACKBALL + } else GizmoRotationMode.VIEW_ALIGNED + this.axis = null + orientation = null + restart() + GizmoKeyResult.CHANGED + } + axis >= 0 -> { constrain(axis) GizmoKeyResult.CHANGED @@ -74,6 +99,7 @@ class GizmoKeyboardTransform( /** The line of the axis it is held to, and the angle swept while rotating. */ fun draw(scope: UiCanvasDrawScope) { + if (mode == GizmoEditMode.ROTATE && rotationMode == GizmoRotationMode.TRACKBALL) return GizmoRenderer.drawRotationSector(scope, geometry, projector, drag) GizmoRenderer.drawHandles(scope, listOf(handle), null, null) } @@ -83,6 +109,7 @@ class GizmoKeyboardTransform( * over from where it began, so motion along the old axis does not carry over. */ private fun constrain(index: Int) { + rotationMode = GizmoRotationMode.VIEW_ALIGNED when { axis != index -> { axis = index diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt index ecb026058..f3e0824a8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt @@ -97,6 +97,9 @@ class GizmoManipulator( private val projector: GizmoProjector, private val snapping: GizmoSnapping = GizmoSnapping.WORLD, ) { + internal fun trackball(start: GizmoTransformValues, x: Float, y: Float) = + GizmoTrackballRotation(projector, start, x, y, snapping) + fun begin(handle: GizmoHandle, values: GizmoTransformValues, pointerX: Float, pointerY: Float): GizmoDrag { val axis = handle.worldAxis?.normalize() val drag = GizmoDrag(handle.id, handle.worldOrigin, axis, values) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt new file mode 100644 index 000000000..464e41b58 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.common.utils.math.QuatF +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.utils.math.deg +import kotlin.math.hypot +import kotlin.math.round + +/** Free rotation about the camera's right and up axes, measured from the original pointer position. */ +internal class GizmoTrackballRotation( + projector: GizmoProjector, + private val start: GizmoTransformValues, + private val startX: Float, + private val startY: Float, + private val snapping: GizmoSnapping, +) { + private val axes = projector.viewAxes() + private val degreesPerPixel = 360f / minOf(projector.width, projector.height) + + fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? { + val (right, up) = axes ?: return null + val dx = x - startX + val dy = y - startY + val distance = hypot(dx, dy) + if (distance <= 1e-6f) return start + val axis = up.scale(dx.toDouble()).add(right.scale(dy.toDouble())).normalize() + val speed = if (modifiers and GLFW.GLFW_MOD_SHIFT != 0) 0.1f else 1f + var angle = distance * degreesPerPixel * speed + GizmoStep.of(modifiers)?.let { step -> + val tick = snapping.rotation(step).toFloat() + angle = round(angle / tick) * tick + } + val rotation = QuatF(angle.deg, Vec3f(axis.x.toFloat(), axis.y.toFloat(), axis.z.toFloat())) + return start.copy(rotation = (rotation * start.rotation).normed()) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt index c1fc20c4a..f3b6709c6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt @@ -70,6 +70,7 @@ object HollowKeys { val NEXT_KEY = action("timeline.next_key", TIMELINE, key(GLFW.GLFW_KEY_UP)) val PREVIOUS_KEY = action("timeline.previous_key", TIMELINE, key(GLFW.GLFW_KEY_DOWN)) val SMOOTH_KEYS = action("timeline.smooth_keys", TIMELINE) + val SELECT_KEY_COLUMNS = action("timeline.select_key_columns", TIMELINE, key(GLFW.GLFW_KEY_K)) val NUDGE_LEFT = action("timeline.nudge_left", TIMELINE, key(GLFW.GLFW_KEY_LEFT)) val NUDGE_RIGHT = action("timeline.nudge_right", TIMELINE, key(GLFW.GLFW_KEY_RIGHT)) val NUDGE_LEFT_FAR = action("timeline.nudge_left_far", TIMELINE, key(GLFW.GLFW_KEY_LEFT, SHIFT)) @@ -105,7 +106,6 @@ object HollowKeys { val ORBIT_UP = action("viewport.orbit_up", VIEWPORT, key(GLFW.GLFW_KEY_KP_8)) val ORBIT_DOWN = action("viewport.orbit_down", VIEWPORT, key(GLFW.GLFW_KEY_KP_2)) val VIEW_SELECTED = action("viewport.view_selected", VIEWPORT, key(GLFW.GLFW_KEY_KP_DECIMAL)) - val ORBIT_PIVOT = action("viewport.orbit_pivot", VIEWPORT) // Node Graphs val ADD_NODE = action("graph.add_node", GRAPH, key(GLFW.GLFW_KEY_INSERT), key(GLFW.GLFW_KEY_N, CONTROL)) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt index 35ee1a5f8..3eaa00a3e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt @@ -388,6 +388,10 @@ class HollowUiInputController { */ private fun focusTargets(frame: HollowUiFrame): List { val set = LinkedHashSet() + activeScope?.takeIf { it in frame.nodes }?.let { scope -> + focusByScope[scope]?.takeIf { it in frame.nodes }?.let { set += it } + set += scope + } primaryFocus()?.takeIf { it in frame.nodes }?.let { set += it } focusByScope.values.forEach { if (it in frame.nodes) set += it } frame.nodes.forEach { if (it.resolvedSnapshot.focusScope) set += it } @@ -442,6 +446,7 @@ class HollowUiInputController { private fun updateFocus(frame: HollowUiFrame, node: UiNode, dispatch: (UiEvent) -> Boolean) { val scope = node.enclosingFocusScope() ?: return setFocus(frame, scope, node.focusTargetWithin(scope), dispatch) + activeScope = scope } /** Sets (or clears, when [target] is null) the focused target of [scope]; other scopes untouched. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt index fd17a2778..c90b2f4d5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt @@ -4,10 +4,13 @@ import androidx.compose.runtime.Composable import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode import ru.hollowhorizon.hollowengine.client.editor.GizmoOrientation import ru.hollowhorizon.hollowengine.client.editor.GizmoSettings +import ru.hollowhorizon.hollowengine.client.editor.GizmoSession import ru.hollowhorizon.hollowengine.client.keymap.HollowKeyAction import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.inputTransparent import ru.hollowhorizon.hollowengine.client.ui.widgets.IconButton -import ru.hollowhorizon.hollowengine.client.ui.widgets.OrbitPivot import ru.hollowhorizon.hollowengine.client.utils.lang /** A manipulator of the gizmo as a toolbar shows it: its icon, its name and the key that starts it. */ @@ -40,22 +43,29 @@ internal val gizmoOrientationTooltip: String * and the world share them, so switching one switches all. */ @Composable -internal fun GizmoToolbarButtons(iconSize: Float = 12f) { +internal fun GizmoToolbarButtons(id: String, iconSize: Float = 12f) { GizmoModeButtons.forEach { button -> - IconButton(button.icon, button.label, active = button.mode in GizmoSettings.modes, iconSize = iconSize) { + IconButton( + button.icon, button.label, id = "$id-mode-${button.mode.name.lowercase()}", + active = button.mode in GizmoSettings.modes, iconSize = iconSize, + ) { GizmoSettings.toggle(button.mode) } } - GizmoOrientationButton(iconSize) - IconButton( - OrbitSelectionIcon, - HollowKeys.ORBIT_PIVOT.hint("hollowengine.gui.ide.gizmo.orbit_selection".lang), - active = OrbitPivot.aroundSelection, - iconSize = iconSize, - ) { OrbitPivot.aroundSelection = !OrbitPivot.aroundSelection } + GizmoOrientationButton(iconSize, id = "$id-orientation") } -private const val OrbitSelectionIcon = "hollowengine:textures/gui/icons/orbit_selection.svg" +@Composable +internal fun GizmoRotationHint(session: GizmoSession, modifier: Modifier = Modifier) { + val transform = session.keyboard ?: return + if (transform.mode != GizmoEditMode.ROTATE || transform.axis != null) return + val mode = transform.rotationMode.name.lowercase() + Text( + HollowKeys.ROTATE.hint("hollowengine.gui.ide.gizmo.rotation.$mode".lang), + tags = listOf("viewport-transform-hint"), + modifier = modifier.inputTransparent(), + ) +} @Composable internal fun GizmoOrientationButton(iconSize: Float = 12f, id: String? = null, tags: List = emptyList()) = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt index 512dcf006..5f134a80e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt @@ -105,7 +105,7 @@ internal fun MontageEditorPanel(file: HollowIdeOpenFile) { state.sceneLane = null val part = rigState.selectedPart?.takeIf { state.rigs.rig.holder(bone).attachment(it) != null } val attachments = document.montage.attachments.filter { it.bone == bone }.map { it.spec.id } - session.select(bone, attachments + listOfNotNull(part)) + session.select(bone, attachments + listOfNotNull(part), selectedAttachment = part) } val bones = remember(model) { viewer.nodes.flatMap { node -> node.walk().map(RuntimeNode::name) } } @@ -198,7 +198,7 @@ private fun Toolbar(state: MontageEditorState) { IconButton(ColliderIcon, "hollowengine.gui.rig_editor.toggle_colliders".lang, iconSize = 12f, active = rigState.showColliders) { rigState.showColliders = !rigState.showColliders } - if (rigState.partSelection != null) GizmoToolbarButtons() + if (rigState.partSelection != null) GizmoToolbarButtons("montage-gizmo-${System.identityHashCode(state)}") } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt index 57f4e379d..094004336 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt @@ -70,6 +70,7 @@ internal class MontageTimelineSession( /** What is selected in the scene now, as group keys, or the key of a lane picked there. */ private var selection: Set = emptySet() + private var pickedGroup: String? = null private var onlySelectedState by mutableStateOf(false) override var onlySelected: Boolean @@ -259,17 +260,19 @@ internal class MontageTimelineSession( /** Opens [lane], picked in the scene in place of a bone, as what is selected now. */ fun selectLane(lane: SegmentLane) { + pickedGroup = null selection = setOf(laneKey(lane)) lane.isListed = true applyListing() } /** Opens the rows of what is selected in the scene: [bone], or the root, and the attachments [attachments] on it. */ - fun select(bone: String?, attachments: Collection) { + fun select(bone: String?, attachments: Collection, selectedAttachment: String? = null) { + pickedGroup = selectedAttachment?.let { attachmentKey(bone, it) } ?: bone?.let(::boneKey) selection = attachments.mapTo(listOfNotNull(bone?.let(::boneKey)).toMutableSet()) { attachmentKey(bone, it) } bone?.let(::touchBone) attachments.forEach { touchAttachment(bone, it) } - if (onlySelectedState) applyListing() + applyListing() } override fun showAll() { @@ -572,6 +575,7 @@ internal class MontageTimelineSession( } private fun applyListing(group: TrackGroup) { + group.isSelected = pickedGroup != null && groupKeys[group] == pickedGroup group.children.forEach(::applyListing) val key = groupKeys[group] val shown = key == null || !onlySelectedState || key in selection diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt index 2d339f0e5..cd1effa68 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt @@ -123,7 +123,7 @@ private fun Toolbar( IconButton(ColliderIcon, rigText("toggle_colliders"), iconSize = 12f, active = state.showColliders) { state.showColliders = !state.showColliders } - if (state.partSelection != null) GizmoToolbarButtons() + if (state.partSelection != null) GizmoToolbarButtons("rig-gizmo-${System.identityHashCode(state)}") if (RigPreviews.isAvailable) { IconButton( icon = if (preview == null) PhysicsIcon else StopIcon, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt index 7314ff329..7c4234986 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.client.history.UndoKeys import ru.hollowhorizon.hollowengine.client.models.internal.rig.RigPreview import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoPointerWrap +import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoRotationHint import ru.hollowhorizon.hollowengine.client.ui.widgets.Model import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput import ru.hollowhorizon.hollowengine.client.ui.widgets.ViewAxes @@ -21,10 +22,15 @@ internal fun RigViewport( onKey: (UiKeyInput) -> Boolean = { false }, ) { val viewer = state.viewer - Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + val viewportId = "rig-viewport-${System.identityHashCode(state)}" + Box( + id = "$viewportId-focus", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).focusScope(), + ) { Model( state = viewer, - id = "rig-viewport", + id = viewportId, tags = listOf("ide-file-viewport"), modifier = Modifier.size(100.percent, 100.percent).clip().input(hoverable = true, clickable = true).focus() .onPlaced { state.viewportBounds = it } @@ -81,7 +87,8 @@ internal fun RigViewport( if (state.gizmo.capture(viewer)) session.draw(this, state.gizmoTarget()) }, ) - ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px)) + ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px).focusScope()) + GizmoRotationHint(session, Modifier.align(UiAlign.START, UiAlign.END).margin(8.px)) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt index 5fa66a30b..7b0a5c01d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt @@ -11,6 +11,7 @@ import ru.hollowhorizon.hollowengine.client.history.UndoKeys import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoPointerWrap +import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoRotationHint import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoToolbarButtons import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile import ru.hollowhorizon.hollowengine.client.ui.ide.PublishScene @@ -147,7 +148,12 @@ internal fun VfxEditorPanel(file: HollowIdeOpenFile) { } Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 0.px).grow(1f)) { - Viewport(document, state, selected) + Box( + id = "vfx-viewport-focus-${System.identityHashCode(state)}", + modifier = Modifier.size(100.percent, 100.percent).focusScope(), + ) { + Viewport(document, state, selected) + } Toolbar(state) ParticleCounter(state.preview) } @@ -172,7 +178,7 @@ private fun Toolbar(state: VfxEditorState) { IconButton(ShapeIcon, vfxText("toggle_shape"), iconSize = 12f, active = preview.showShape) { preview.showShape = !preview.showShape } - GizmoToolbarButtons() + GizmoToolbarButtons("vfx-gizmo-${System.identityHashCode(state)}") } } @@ -291,7 +297,7 @@ private fun Viewport(document: HollowIdeVfxDocument, state: VfxEditorState, sele } Box( - id = "vfx-viewport", + id = "vfx-viewport-${System.identityHashCode(state)}", tags = listOf("ide-file-viewport"), mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent).clip().onPlaced { rect -> @@ -374,7 +380,8 @@ private fun Viewport(document: HollowIdeVfxDocument, state: VfxEditorState, sele session.draw(this, target()) }, ) - ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px)) + ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px).focusScope()) + GizmoRotationHint(session, Modifier.align(UiAlign.START, UiAlign.END).margin(8.px)) KeyInsertMenu(state.keyMenu, onPick = { parts -> selected?.let { state.session.insertKeys(it, parts) } }) { state.keyMenu = null } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt index 742f923ef..6d2a67512 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt @@ -262,7 +262,8 @@ class VfxTimelineSession( fun select(nodeId: String?) { selection = nodeId selectedLane = null - if (nodeId?.let(touched::add) == true || onlySelectedState) applyListing() + nodeId?.let(touched::add) + applyListing() } /** Opens [lane], picked in the scene in place of a node, as what is selected now. */ @@ -317,6 +318,7 @@ class VfxTimelineSession( private fun applyListing(group: TrackGroup) { group.children.forEach(::applyListing) val node = group.properties.firstOrNull()?.let(::nodeOf) + group.isSelected = node != null && node == selection val shown = !onlySelectedState || node == selection group.properties.forEach { property -> val keyed = property.curves.any { it.keyframes.isNotEmpty() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt index a004282e9..2e56cfa04 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt @@ -343,6 +343,8 @@ interface TrackListing { class TrackGroup(name: String) { var nameState by mutableStateOf(name) + /** Whether the scene object represented by this group is selected. */ + var isSelected by mutableStateOf(false) var isCollapsed by mutableStateOf(false) var isLocked by mutableStateOf(false) var isVisible by mutableStateOf(true) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt index 77610df3a..4f9d3ec4b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt @@ -21,6 +21,8 @@ object TimelineKeys { /** What each action does to the timeline; false when it had nothing to act on, so the key goes on. */ private val commands: List Boolean>> = listOf( + HollowKeys.SELECT_ALL to { it.selectAllKeys().let { true } }, + HollowKeys.SELECT_KEY_COLUMNS to { it.selectKeyColumns().let { true } }, HollowKeys.TOGGLE_CURVES to { timeline -> if (timeline.viewMode == TimelineViewMode.CURVES) timeline.viewMode = TimelineViewMode.DOPE_SHEET else timeline.enterCurveView() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt new file mode 100644 index 000000000..61b9c712f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt @@ -0,0 +1,41 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import kotlin.math.abs + +/** Keys available to selection commands, including channels inside folded groups. */ +private fun TimelineController.selectableCurves(respectFocus: Boolean = true): List = buildList { + fun visit(group: TrackGroup) { + if (!group.hasListedContent || group.isLocked || !group.isVisible) return + group.properties.filter { it.isListed && it.isVisible && !it.isLocked }.forEach { property -> + property.curves.filterTo(this) { curve -> + curve.isListed && curve.isVisible && (viewMode != TimelineViewMode.CURVES || + curve.spec.supportsCurveEditor && (!respectFocus || focusedCurves.isEmpty() || isFocused(curve))) + } + } + group.children.forEach(::visit) + } + groups.forEach(::visit) +} + +fun TimelineController.selectAllKeys() = select(selectableCurves().flatMap { it.keyframes }, additive = false) + +/** Extends a pose selection across channels; with no selected keys, uses the playhead. */ +fun TimelineController.selectKeyColumns() { + val times = selectedKeyframes.map { it.time }.ifEmpty { listOf(currentTime) }.sorted() + val keys = selectableCurves().flatMap { curve -> + curve.keyframes.filter { key -> + val index = times.binarySearch(key.time) + if (index >= 0) true else { + val insertion = -index - 1 + listOfNotNull(times.getOrNull(insertion), times.getOrNull(insertion - 1)) + .any { abs(it - key.time) <= ChannelCurve.KEY_TIME_EPSILON } + } + } + } + select(keys, additive = true) +} + +fun TimelineController.selectTrackKeys(curves: List, additive: Boolean = false) { + val available = selectableCurves(respectFocus = false).toSet() + select(curves.filter { it in available }.flatMap { it.keyframes }, additive) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt index 40abe31c7..cb2062221 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt @@ -432,10 +432,12 @@ private fun TimelineHeaderRow( val isCurveView = controller.viewMode == TimelineViewMode.CURVES val rowCurves = if (isCurveView) row.curves.filter { it.spec.supportsCurveEditor } else row.curves val isFocused = isCurveView && rowCurves.isNotEmpty() && rowCurves.all { controller.isFocused(it) } + val clicks = remember(row.id) { TimelineTrackClicks() } val tags = buildList { add("timeline-row") add(row.kind.name.lowercase()) if (isFocused) add("focused") + if (row.selected) add("scene-selected") if (row.locked) add("locked") if (!row.visible) add("muted") } @@ -449,6 +451,14 @@ private fun TimelineHeaderRow( event.consume() return@onClick } + if (!event.isLeftClick()) return@onClick + val now = event.frame?.nowMillis ?: return@onClick + if (clicks.click(now)) { + controller.selectTrackKeys(row.curves, additive = event.isAdditiveSelection()) + event.consume() + refresh() + return@onClick + } // In the graph a click picks what the graph shows; ctrl adds to it. Everywhere else // the row list is what it always was. if (isCurveView && rowCurves.isNotEmpty()) { @@ -456,7 +466,7 @@ private fun TimelineHeaderRow( } when { row.group != null -> row.group.isCollapsed = !row.group.isCollapsed - else -> if (!isCurveView) controller.clearSelection() + else -> if (!isCurveView && !event.isAdditiveSelection()) controller.clearSelection() } event.consume() refresh() @@ -633,7 +643,7 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: buildList { val keys = row.curves.flatMap { it.keyframes } add(UiDropdownItem(TimelineLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { - controller.select(keys, additive = false) + controller.selectTrackKeys(row.curves) refresh() }) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt index 24eadf7c2..e0a5eb614 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt @@ -66,6 +66,7 @@ internal data class TimelineRow( val locked: Boolean = false, val visible: Boolean = true, val color: Color? = null, + val selected: Boolean = false, ) { val curves: List get() = when { @@ -115,6 +116,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole height = TimelineGroupRowHeight, kind = TimelineRowKind.GROUP, group = group, + selected = group.isSelected, locked = locked, visible = visible, ) @@ -143,6 +145,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole locked = propertyLocked, visible = propertyVisible && singleCurve.isVisible, color = singleCurve.color, + selected = group.isSelected, ) y += TimelinePropertyRowHeight return@forEach @@ -162,6 +165,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole locked = propertyLocked, visible = propertyVisible && curve.isVisible, color = curve.color, + selected = group.isSelected, ) y += TimelineChannelRowHeight } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt new file mode 100644 index 000000000..bc1c92fce --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt @@ -0,0 +1,15 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui + +internal class TimelineTrackClicks { + private var previous: Long? = null + + fun click(now: Long): Boolean { + val double = previous?.let { now - it in 0..DoubleClickMillis } == true + previous = if (double) null else now + return double + } + + private companion object { + const val DoubleClickMillis = 300L + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt deleted file mode 100644 index 4cdfceecf..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.widgets - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import ru.hollowhorizon.hollowengine.common.config.HollowEngineConfig -import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f - -/** - * What every preview of the editor turns around: its own center, or what is selected in it. One setting for all of - * them, kept in the config. - */ -object OrbitPivot { - private var loaded = false - private var selectionValue by mutableStateOf(false) - - var aroundSelection: Boolean - get() { - load() - return selectionValue - } - set(value) { - load() - if (selectionValue == value) return - selectionValue = value - HollowEngineConfig.previewOrbitSelection = value - } - - /** Where [camera] turns about now: the selection when the previews circle it and there is one, else null. */ - fun of(camera: OrbitCamera): Vec3f? = - if (aroundSelection) camera.selectionCenter else null - - private fun load() { - if (loaded) return - loaded = true - selectionValue = HollowEngineConfig.previewOrbitSelection - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt index 8511c2ff8..2da0d23fe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt @@ -45,8 +45,8 @@ interface OrbitCamera { fun focus(point: Vec3f) } -/** Turns [OrbitCamera] to [yaw] and [pitch] about whatever [OrbitPivot] says the previews circle now. */ -fun OrbitCamera.orbitTo(yaw: Float, pitch: Float) = turnTo(yaw, pitch, OrbitPivot.of(this)) +/** Turns the camera about its current view center. */ +fun OrbitCamera.orbitTo(yaw: Float, pitch: Float) = turnTo(yaw, pitch) /** * Yaw and pitch for [back] on a camera whose back direction is (sin y, sin p, cos y) scaled by cos p, with y the yaw @@ -83,7 +83,6 @@ class OrbitCameraTurn(val camera: OrbitCamera) { } when { HollowKeys.VIEW_SELECTED.matches(key, modifiers) -> camera.focus(camera.selectionCenter ?: return false) - HollowKeys.ORBIT_PIVOT.matches(key, modifiers) -> OrbitPivot.aroundSelection = !OrbitPivot.aroundSelection HollowKeys.ORBIT_LEFT.matches(key, modifiers) -> orbitBy(-OrbitStep, 0f) HollowKeys.ORBIT_RIGHT.matches(key, modifiers) -> orbitBy(OrbitStep, 0f) HollowKeys.ORBIT_UP.matches(key, modifiers) -> orbitBy(0f, OrbitStep) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt index e0c7fe7fa..6b5d0ae19 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt @@ -46,10 +46,6 @@ object HollowEngineConfig : Config() { @PropertyName("transform_gizmo_orientation") var gizmoOrientation by property(GizmoOrientation.GLOBAL.name) - @PropertyComment("Whether the editor's previews turn around what is selected rather than around their own center") - @PropertyName("preview_orbit_selection") - var previewOrbitSelection by property(false) - @PropertyComment("Whether the timeline snaps times to frames: keys, the playhead, blocks") @PropertyName("timeline_snap") var timelineSnap by property(true) diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json index e79a2acdf..99cc1be6f 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json @@ -6,5 +6,6 @@ "orientation.global": "global", "orientation.local": "local", "orientation.view": "view", - "orbit_selection": "Orbit around the selection instead of the view's center" + "rotation.view_aligned": "View-aligned rotation · Switch to trackball", + "rotation.trackball": "Trackball rotation · Switch to view-aligned" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json b/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json index e46747c49..ce3b64ce7 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json @@ -54,6 +54,7 @@ "action.timeline.next_key": "Next key", "action.timeline.previous_key": "Previous key", "action.timeline.smooth_keys": "Smooth the selected keys", + "action.timeline.select_key_columns": "Select keys at the playhead or selected times", "action.timeline.nudge_left": "Nudge back", "action.timeline.nudge_right": "Nudge forward", "action.timeline.nudge_left_far": "Nudge back further", @@ -87,7 +88,6 @@ "action.viewport.orbit_up": "Orbit up", "action.viewport.orbit_down": "Orbit down", "action.viewport.view_selected": "Center the view on the selection", - "action.viewport.orbit_pivot": "Orbit around the selection or the center", "action.graph.add_node": "Add a node", "action.graph.group": "Group the selection", "action.graph.ungroup": "Ungroup", diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json index 508b92a5a..5943cd30b 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json @@ -6,5 +6,6 @@ "orientation.global": "глобальные", "orientation.local": "локальные", "orientation.view": "вида", - "orbit_selection": "Вращать вид вокруг выделенного, а не вокруг центра" + "rotation.view_aligned": "Вращение в плоскости вида | Переключить на трекбол", + "rotation.trackball": "Трекбол-вращение | Переключить в плоскость вида" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json index 26aec74fd..0ff4792e3 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json @@ -87,7 +87,7 @@ "action.viewport.orbit_up": "Повернуть вид вверх", "action.viewport.orbit_down": "Повернуть вид вниз", "action.viewport.view_selected": "Центрировать вид на выделенном", - "action.viewport.orbit_pivot": "Вращать вид вокруг выделенного или центра", + "action.timeline.select_key_columns": "Выделить ключи на курсоре или метках выделенных ключей", "action.graph.add_node": "Добавить ноду", "action.graph.group": "Сгруппировать выделенное", "action.graph.ungroup": "Разгруппировать", diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg deleted file mode 100644 index a3821f228..000000000 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss index 2814a73ad..dcf66dd29 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss @@ -7,6 +7,14 @@ border-radius: $radius; } +.viewport-transform-hint { + padding: 4px 6px; + background: alpha($bg-window, 0.86); + foreground: $text-strong; + font-size: $font-size; + border-radius: $radius; +} + /* A state of a controller graph; the frame is the graph node's. */ .animator-state { padding: 6px 8px; diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss index 05d3f142e..4cfebf953 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss @@ -129,6 +129,10 @@ .timeline-row.muted .timeline-row-label { foreground: $text-faint; } +.timeline-row.scene-selected { background: alpha($accent, 0.18); } +.timeline-row.scene-selected:hover { background: alpha($accent, 0.28); } +.timeline-row.scene-selected .timeline-row-label { foreground: $accent; } + /* The strips at the ends of a block that trim it: shown only while the pointer is on one. */ .timeline-segment-grip { background: transparent; diff --git a/runtime/src/test/kotlin/MontageRigEditingTest.kt b/runtime/src/test/kotlin/MontageRigEditingTest.kt index cfe65c90c..5ace5ddcb 100644 --- a/runtime/src/test/kotlin/MontageRigEditingTest.kt +++ b/runtime/src/test/kotlin/MontageRigEditingTest.kt @@ -9,8 +9,23 @@ import ru.hollowhorizon.hollowengine.common.utils.math.eulerDegreesXyz import ru.hollowhorizon.hollowengine.common.utils.math.eulerRotationXyz import kotlin.test.Test import kotlin.test.assertTrue +import kotlin.test.assertEquals class MontageRigEditingTest { + @Test + fun `picking a bone updates track highlights without filtering the track list`() { + val (_, session) = session(recording = false) + session.select("Body", emptyList()) + val body = session.timeline.groups.single { it.isSelected } + session.select("Arm", emptyList()) + val arm = session.timeline.groups.single { it.isSelected } + assertTrue(body !== arm) + assertTrue(body.isListed) + assertTrue(!body.isSelected) + session.select(null, emptyList()) + assertEquals(0, session.timeline.groups.count { it.isSelected }) + } + private fun session(recording: Boolean): Pair { val document = HollowIdeMontageDocument("{\"duration\": 2.0}".toByteArray()) val rigs = MontageRigEditing(document) { ModelRig() } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt new file mode 100644 index 000000000..51810e2cc --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt @@ -0,0 +1,90 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import net.minecraft.world.phys.Vec3 +import org.joml.Matrix4f +import org.junit.jupiter.api.Test +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.utils.math.rotateBy +import ru.hollowhorizon.hollowengine.common.utils.math.QuatF +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GizmoTrackballRotationTest { + private val start = GizmoTransformValues(Vec3f.ZERO, QuatF.IDENTITY, Vec3f(1f, 1f, 1f)) + + @Test + fun `switching rotation modes stays in one edit and cancellation restores the target`() { + val session = GizmoSession(projector()) + val target = Target(start) + assertTrue(session.key(target, GLFW.GLFW_KEY_R, 0, 450f, 300f)) + session.moveTo(500f, 350f, 0) + assertTrue(session.key(target, GLFW.GLFW_KEY_R, 0, 500f, 350f)) + session.moveTo(550f, 400f, 0) + assertNotEquals(start.rotation, target.values.rotation) + assertEquals(1, target.begun) + assertEquals(0, target.ended) + assertTrue(session.key(target, GLFW.GLFW_KEY_ESCAPE, 0, 550f, 400f)) + assertEquals(start, target.values) + assertEquals(1, target.ended) + assertNull(session.keyboard) + } + + @Test + fun `repeated rotate toggles the mode and axis constraints leave trackball`() { + val projector = projector() + val transform = GizmoKeyboardTransform( + GizmoEditMode.ROTATE, start, 450f, 300f, projector, GizmoGeometry(projector), GizmoManipulator(projector), + ) + assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode) + assertEquals(GizmoKeyResult.CHANGED, transform.key(GLFW.GLFW_KEY_R, 0)) + assertEquals(GizmoRotationMode.TRACKBALL, transform.rotationMode) + assertNotEquals(start.rotation, assertNotNull(transform.update(500f, 350f, 0)).rotation) + transform.key(GLFW.GLFW_KEY_R, 0) + assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode) + transform.key(GLFW.GLFW_KEY_R, 0) + transform.key(GLFW.GLFW_KEY_X, 0) + assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode) + assertEquals(0, transform.axis) + transform.key(GLFW.GLFW_KEY_R, 0) + assertEquals(GizmoRotationMode.TRACKBALL, transform.rotationMode) + assertNull(transform.axis) + } + + @Test + fun `trackball follows camera axes and recomputes from the original pose with snapping and fine motion`() { + val trackball = GizmoTrackballRotation(projector(), start, 400f, 300f, GizmoSnapping.MODEL) + assertEquals(start, trackball.update(400f, 300f, 0)) + val horizontal = assertNotNull(trackball.update(550f, 300f, 0)) + val front = Vec3f.Z_AXIS.rotateBy(horizontal.rotation) + assertEquals(1f, front.x, 0.0001f) + assertEquals(0f, front.z, 0.0001f) + trackball.update(550f, 450f, 0) + assertEquals(horizontal, trackball.update(550f, 300f, 0)) + val fine = assertNotNull(trackball.update(550f, 300f, GLFW.GLFW_MOD_SHIFT)) + val equivalent = assertNotNull(trackball.update(415f, 300f, 0)) + assertEquals(equivalent.rotation, fine.rotation) + val snapped = assertNotNull(trackball.update(403f, 300f, GLFW.GLFW_MOD_CONTROL)) + assertEquals(start.rotation, snapped.rotation) + assertEquals(start.translation, horizontal.translation) + assertEquals(start.scale, horizontal.scale) + } + + private fun projector() = GizmoProjector().also { + it.capture(Matrix4f(), Matrix4f().perspective(1f, 800f / 600f, 0.1f, 100f), Vec3(0.0, 0.0, 5.0), 60f, 800f, 600f) + } + + private class Target(private val start: GizmoTransformValues) : GizmoTarget { + override var values = start + var begun = 0 + var ended = 0 + + override fun place(values: GizmoTransformValues, mode: GizmoEditMode) { this.values = values } + override fun restore() { values = start } + override fun beginEdit() { begun++ } + override fun endEdit() { ended++ } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt index e379f3277..09b36b7c0 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt @@ -13,6 +13,26 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class HollowTimelinePointerTest { + @Test + fun `double clicking a channel name selects its keys and scene selection marks its rows`() { + val (controller, curve) = timelineWithKeys(1f, 2f) + controller.groups.single().isSelected = true + HollowUiSurface().use { surface -> + surface.setContent { HollowTimelineEditor(controller, refresh = {}) } + val frame = surface.frame(800f, 500f, -1f, -1f, 0L) + val header = frame.nodes.single { it.id?.startsWith("header-timeline-property-") == true } + assertTrue("scene-selected" in header.tags) + val rect = frame.layout[header].rect + val x = rect.x + 40f + val y = rect.y + rect.height * 0.5f + surface.runtime.mouseClicked(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT) + surface.runtime.mouseReleased(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT) + surface.frame(800f, 500f, x, y, 100_000_000L) + surface.runtime.mouseClicked(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT) + assertEquals(curve.keyframes.toSet(), controller.selectedKeyframes.toSet()) + } + } + @Test fun `ctrl click on empty lane keeps the keyframe selection`() { val (controller, curve) = timelineWithKeys(1f, 2f) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt new file mode 100644 index 000000000..3b647ecde --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt @@ -0,0 +1,63 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import org.junit.jupiter.api.Test +import org.lwjgl.glfw.GLFW +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TimelineSelectionTest { + @Test + fun `select all includes folded groups but skips locked and unlisted channels`() { + val timeline = TimelineController() + val folded = property(timeline, "folded") + timeline.group(listOf("folded")).isCollapsed = true + val locked = property(timeline, "locked") + timeline.group(listOf("locked")).isLocked = true + val hidden = property(timeline, "hidden").also { it.isListed = false } + val channel = property(timeline, "channel").also { it.curves.single().isListed = false } + + assertTrue(TimelineKeys.handle(timeline, GLFW.GLFW_KEY_A, GLFW.GLFW_MOD_CONTROL)) + assertEquals(folded.keyframes.toSet(), timeline.selectedKeyframes.toSet()) + assertTrue(listOf(locked, hidden, channel).none { it.keyframes.any(timeline::isSelected) }) + } + + @Test + fun `column selection starts at the playhead and extends all selected times across tracks`() { + val timeline = TimelineController() + val first = property(timeline, "first") + val second = property(timeline, "second") + val nearby = Keyframe(1f + ChannelCurve.KEY_TIME_EPSILON * 0.5f, 0f) + second.curves.single().keyframes += nearby + timeline.applyCurrentTime(1f) + + assertTrue(TimelineKeys.handle(timeline, GLFW.GLFW_KEY_K, 0)) + assertEquals(setOf(first.keyframes[0], second.keyframes[0], nearby), timeline.selectedKeyframes.toSet()) + + timeline.select(listOf(first.keyframes[0], first.keyframes[2]), additive = false) + timeline.selectKeyColumns() + assertEquals( + setOf(first.keyframes[0], first.keyframes[2], second.keyframes[0], second.keyframes[2], nearby), + timeline.selectedKeyframes.toSet(), + ) + } + + @Test + fun `graph commands respect curve focus and track selection does not select other channels`() { + val timeline = TimelineController() + val first = property(timeline, "first") + val second = property(timeline, "second") + timeline.viewMode = TimelineViewMode.CURVES + timeline.focusCurves(first.curves, additive = false) + + timeline.selectAllKeys() + assertEquals(first.keyframes.toSet(), timeline.selectedKeyframes.toSet()) + + timeline.selectTrackKeys(second.curves) + assertEquals(second.keyframes.toSet(), timeline.selectedKeyframes.toSet()) + } + + private fun property(timeline: TimelineController, name: String): AnimProperty = + timeline.addProperty(listOf(name), AnimProperty(name, name, FloatPropertyType(), 0f)).also { property -> + property.curves.single().keyframes.addAll(listOf(1f, 2f, 3f).map { Keyframe(it, 0f) }) + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt index b8589f2b2..1008b5d4f 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt @@ -1,11 +1,54 @@ package ru.hollowhorizon.hollowengine.client.ui.input +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import org.junit.jupiter.api.Test import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import kotlin.test.assertEquals class FocusTest { + @Test + fun `toolbar clicks preserve the viewport focus and an active panel gets first use of keys`() { + val log = mutableListOf() + val revision = mutableStateOf(0) + HollowUiSurface().use { surface -> + surface.setContent { + Row(modifier = Modifier.focusScope()) { + Box(id = "preview-scope", modifier = Modifier.size(100.px, 100.px).focusScope()) { + key(revision.value) { + Box(id = "viewport", modifier = Modifier.size(100.px, 100.px).focus().onKeyInput { + log += "viewport" + it.consume() + }) + } + } + Box(id = "toolbar", modifier = Modifier.size(100.px, 100.px) + .input(clickable = true).onClick { revision.value++; it.consume() }) + Box(id = "timeline", modifier = Modifier.size(100.px, 100.px).focusScope() + .input(clickable = true).onKeyInput { + log += "timeline" + it.consume() + }) + } + } + val runtime = surface.runtime + surface.frame(300f, 100f, -1f, -1f, 0L) + runtime.mouseClicked(50f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT) + assertEquals("viewport", runtime.focusedKey) + runtime.mouseClicked(150f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT) + surface.frame(300f, 100f, -1f, -1f, 16_000_000L) + assertEquals("viewport", runtime.focusedKey) + runtime.keyPressed(GLFW.GLFW_KEY_R, 0, 0) + assertEquals(listOf("viewport"), log) + + log.clear() + runtime.mouseClicked(250f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT) + runtime.keyPressed(GLFW.GLFW_KEY_G, 0, 0) + assertEquals(listOf("timeline"), log) + } + } + private fun keyLoggingBox(id: String, layer: Int, log: MutableList, consume: Boolean): BoxNode { // A focus scope is always key-active while composed, so it is a self-contained multi-focus target. val mod = Modifier.focusScope().layer(layer).onKeyInput { input -> From 7514619e621c344318bc0f593bdad62fc893a05a Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Sat, 24 Oct 2026 16:22:37 +0300 Subject: [PATCH 23/25] Update BlockBenchModelTests.kt --- .../src/test/kotlin/BlockBenchModelTests.kt | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/runtime/src/test/kotlin/BlockBenchModelTests.kt b/runtime/src/test/kotlin/BlockBenchModelTests.kt index b63ebf9aa..a4af8a904 100644 --- a/runtime/src/test/kotlin/BlockBenchModelTests.kt +++ b/runtime/src/test/kotlin/BlockBenchModelTests.kt @@ -31,30 +31,6 @@ class BlockBenchModelTests { put("x", x.toString()); put("y", y.toString()); put("z", z.toString()) })) - @Test - fun `the bundled v5 model loads all geometry and clips without a render context`() { - val source = javaClass.classLoader.getResourceAsStream("assets/hollowengine/models/entity/hv_malora.bbmodel")!! - .bufferedReader().use { it.readText() } - val file = BlockBenchModel.parse(source) - val model = convert(file) - assertEquals(13, model.animations.size) - assertEquals(154, model.nodes.count { it.mesh != null }) - assertEquals(1, model.nodes.count { !it.visible }) - assertEquals(4, file.textures.size) - assertTrue(model.nodes.any { it.name == "aura_vfx" }) - assertEquals(16, file.textures[1].height / file.textures[1].width) - model.animations.forEach { clip -> - assertTrue(clip.duration > 0f, clip.name) - for (time in listOf(0f, clip.duration * 0.37f, clip.duration)) { - val pose = AnimationPose.sample(clip, time) - pose.entries.forEach { (_, bone) -> - bone.rotation?.let { assertTrue(it.x.isFinite() && it.y.isFinite() && it.z.isFinite() && it.w.isFinite()) } - bone.translation?.let { assertTrue(it.x.isFinite() && it.y.isFinite() && it.z.isFinite()) } - } - } - } - } - @Test fun `legacy inline groups and v5 group table preserve pivots and rotations`() { val inline = """{"meta":{"format_version":"4.10"},"outliner":[ From 05ac86eaa631d50d0b958a7def4cad7689f338e8 Mon Sep 17 00:00:00 2001 From: HollowHorizon Date: Sat, 24 Oct 2026 16:30:20 +0300 Subject: [PATCH 24/25] Sync external text-file edits in the IDE #124 --- .../client/ui/ide/HollowIdeFileTypes.kt | 2 +- .../client/ui/ide/HollowIdeFileWatcher.kt | 57 +++++++++++++++++ .../client/ui/ide/HollowIdeModel.kt | 63 ++++++++++++++++--- .../client/ui/ide/HollowIdeOverlay.kt | 21 +++++-- .../client/ui/ide/HollowIdeTextFileSync.kt | 38 +++++++++++ 5 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt index 401910285..39c7b56f5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt @@ -46,7 +46,7 @@ class HollowIdeFileType( internal fun matchesPath(path: String): Boolean? = pathMatcher?.invoke(path) fun open(path: String, bytes: ByteArray): HollowIdeOpenFile = - HollowIdeOpenFile(path, this, loader(path, bytes)) + HollowIdeOpenFile(path, this, loader(path, bytes), bytes) companion object { fun extensions( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt new file mode 100644 index 000000000..98f9a21ec --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt @@ -0,0 +1,57 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import kotlinx.coroutines.* +import ru.hollowhorizon.hollowengine.HollowEngine +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* + +/** Watches parent directories so editors that save by replacing the file are observed too. */ +internal suspend fun watchIdeFiles(paths: List, onChanged: () -> Unit) = withContext(Dispatchers.IO) { + if (paths.isEmpty()) return@withContext + try { + while (currentCoroutineContext().isActive) { + try { + FileSystems.getDefault().newWatchService().use { watcher -> + val watched = paths.asSequence() + .flatMap { path -> + val absolute = path.toAbsolutePath().normalize() + val target = runCatching { absolute.toRealPath() }.getOrDefault(absolute) + listOf(absolute.watchTarget(), target.watchTarget()) + } + .distinct().groupBy(Path::getParent) + .map { (directory, files) -> + directory.register( + watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY + ) to files.map(Path::getFileName).toSet() + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, names) -> names.flatten().toSet() } + + onChanged() + while (watched.isNotEmpty() && currentCoroutineContext().isActive) { + val key = runInterruptible { watcher.take() } + val names = watched[key].orEmpty() + val changed = key.pollEvents().any { it.kind() == OVERFLOW || it.context() in names } + if (!key.reset() || changed) break + } + } + } catch (_: NoSuchFileException) { + onChanged() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + HollowEngine.LOGGER.warn("Could not watch IDE files", error) + } +} + +/** A missing parent is observed through the nearest existing ancestor until it is recreated. */ +private fun Path.watchTarget(): Path { + var target = this + while (!Files.isDirectory(target.parent)) target = target.parent + return target +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt index 2d3d037c9..1657ac2fe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt @@ -117,7 +117,8 @@ internal class HollowIdeModel( return HollowIdeOpenResult.File(openReadOnly(path, file.readText()), created) } files[path]?.let { opened -> - if (!opened.dirty && opened.type.requiresContent) opened.refresh(file.readBytes()) + if (opened.textFileSync != null) refreshExternalTextFile(opened) + else if (!opened.dirty && opened.type.requiresContent) opened.refresh(file.readBytes()) return HollowIdeOpenResult.File(opened, created = false) } val opened = runCatching { @@ -172,7 +173,15 @@ internal class HollowIdeModel( val file = files[path]?.takeUnless { it.readOnly } ?: return false pendingSaves.remove(path)?.cancel() try { - writeIdeFile(path.fromReadablePath().toPath(), file.encode()) + val sync = file.textFileSync + if (sync != null) { + if (refreshExternalTextFile(file)) return false + if (!file.dirty) return true + if (!sync.save(file.encode(), sync.revision())) { + refreshExternalTextFile(file) + return false + } + } else writeIdeFile(path.fromReadablePath().toPath(), file.encode()) } catch (e: Exception) { HollowEngine.LOGGER.error("Could not save {}", path, e) HollowNotifications.error("hollowengine.gui.ide.file.save_failed".lang.format(path.substringAfterLast('/'))) @@ -337,7 +346,10 @@ internal class HollowIdeModel( .forEach { file -> val source = file.path.fromReadablePath() if (!source.isFile) return@forEach - runCatching { file.refresh(source.readBytes()) }.onFailure { + runCatching { + if (file.textFileSync != null) refreshExternalTextFile(file) + else file.refresh(source.readBytes()) + }.onFailure { HollowEngine.LOGGER.warn( "Could not read '{}' again: {}", file.path, @@ -347,9 +359,15 @@ internal class HollowIdeModel( } } - /** Reconcile once on return from another application, without polling the filesystem per frame. */ + /** Reconcile on a file notification or on return from another application. */ fun refreshExternalFiles() { tree.refresh() + files.values.toList().filter { it.textFileSync != null }.forEach { file -> + if (!file.path.fromReadablePath().isFile) return@forEach + runCatching { refreshExternalTextFile(file) }.onFailure { error -> + HollowEngine.LOGGER.warn("Could not reload IDE file '{}'", file.path, error) + } + } files.values.toList().filter { !it.readOnly && !it.path.fromReadablePath().exists() }.forEach { file -> pendingSaves.remove(file.path)?.cancel() if (!file.dirty) { @@ -448,19 +466,36 @@ internal class HollowIdeModel( private fun scheduleSave(path: String) { val file = files[path]?.takeIf { it.dirty } ?: return val text = file.textOrNull ?: return + val sync = file.textFileSync ?: return + val revision = sync.revision() pendingSaves.remove(path)?.cancel() - pendingSaves[path] = ioScope.launch { + val job = ioScope.launch(start = CoroutineStart.LAZY) { delay(AutoSaveDelayMillis) + val context = currentCoroutineContext() runCatching { - writeIdeFile(path.fromReadablePath().toPath(), text.toByteArray(Charsets.UTF_8)) - }.onSuccess { + sync.save(text.toByteArray(Charsets.UTF_8), revision, context::ensureActive) + }.onSuccess { saved -> Minecraft.getInstance().execute { + if (files[path] !== file || pendingSaves[path] !== context[Job]) return@execute pendingSaves.remove(path) - files[path]?.markSavedIfText(text) + if (saved) file.markSavedIfText(text) + else runCatching { refreshExternalTextFile(file) }.onFailure { error -> + HollowEngine.LOGGER.warn("Could not reload IDE file '{}' after autosave", path, error) + } tree.refresh() } + }.onFailure { error -> + if (error !is CancellationException) HollowEngine.LOGGER.warn("Could not autosave IDE file '{}'", path, error) } } + pendingSaves[path] = job + job.start() + } + + private fun refreshExternalTextFile(file: HollowIdeOpenFile): Boolean { + val changed = file.reloadExternalText() + if (changed) pendingSaves.remove(file.path)?.cancel() + return changed } } @@ -479,9 +514,12 @@ class HollowIdeOpenFile internal constructor( val path: String, val type: HollowIdeFileType, val document: HollowIdeFileDocument, + initialContent: ByteArray = document.encode(), ) { var dirty by mutableStateOf(false) private set + internal var externalTextRevision by mutableStateOf(0L) + private set private var saveHandler: (() -> Boolean)? = null val readOnly: Boolean get() = document.readOnly @@ -490,6 +528,9 @@ class HollowIdeOpenFile internal constructor( internal val textOrNull: String? get() = (document as? HollowIdeTextDocument)?.text val id: String = fileDockItemId(path) val title: String get() = path.substringAfterLast('/').ifEmpty { path } + internal val textFileSync = if (document is HollowIdeTextDocument && !readOnly && !virtual) { + HollowIdeTextFileSync(path.fromReadablePath().toPath(), initialContent) + } else null fun dockItem(): DockItem { return DockItem(id, title, IconHelper.forPath(path).toString(), dirty = dirty && !readOnly) @@ -530,6 +571,12 @@ class HollowIdeOpenFile internal constructor( document.reload(bytes) } + internal fun reloadExternalText(): Boolean = textFileSync?.reloadIfChanged { bytes -> + document.reload(bytes) + dirty = false + externalTextRevision++ + } == true + fun refreshReadOnly(next: String) { val textDocument = document as? HollowIdeTextDocument ?: return if (!readOnly) return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt index 2b2e07bf7..38845f813 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt @@ -45,6 +45,7 @@ import ru.hollowhorizon.hollowengine.common.utils.DesktopUtil import ru.hollowhorizon.hollowengine.common.utils.isProduction import ru.hollowhorizon.hollowengine.common.utils.nbt.TagModuleRevision import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.milliseconds /** A file dragged out of the project tree; anything that accepts drops can look for this payload. */ @@ -158,9 +159,9 @@ object HollowIdeOverlay { private var fileContextMenu by mutableStateOf(null) private var gutterNavigation by mutableStateOf>(emptyList()) private val dragAndDrop = UiDragAndDropState() - private var refreshFilesRequested = false + private val refreshFilesRequested = AtomicBoolean() private val externalFiles = HollowIdeExternalFiles( - onFocusGained = { refreshFilesRequested = true }, + onFocusGained = { refreshFilesRequested.set(true) }, onMove = { files, x, y -> updateExternalDrag(files, x, y) }, onLeave = { pipeline.await() @@ -567,9 +568,8 @@ object HollowIdeOverlay { } externalFiles.update(event.minecraft.window.window, isVisible() && !collapsed) if (!isVisible()) return - if (refreshFilesRequested && !externalFiles.active) { + if (!externalFiles.active && refreshFilesRequested.getAndSet(false)) { pipeline.await() - refreshFilesRequested = false model.refreshExternalFiles() } AssetManagerLifecycle.observe(event.minecraft) @@ -766,6 +766,7 @@ object HollowIdeOverlay { private fun Content() { RememberLayout() TrackActiveEditor() + WatchTextFiles() Box( id = "ide-root", modifier = Modifier.style("hollowengine:ui/styles/widgets.hss").style("hollowengine:ui/styles/ide.hss") @@ -835,6 +836,15 @@ object HollowIdeOverlay { } } + @Composable + private fun WatchTextFiles() { + val paths = model.files.values.filter { it.textFileSync != null } + .map { it.path.fromReadablePath().toPath() }.sorted() + LaunchedEffect(paths) { + if (paths.isNotEmpty()) watchIdeFiles(paths) { refreshFilesRequested.set(true) } + } + } + @Composable private fun RememberLayout() { val layout = dock.capture() @@ -1196,6 +1206,9 @@ object HollowIdeOverlay { private fun FileEditor(file: HollowIdeOpenFile) { val editorSession = remember(file.path) { editorSession(file.path) } val editorState = editorState(file) + remember(file, file.externalTextRevision) { + editorState.setText(file.text, moveCaretToEnd = false) + } val analysisRevision = editorAnalysisRevision.toLong() + editorSession.revision val diagnostics = editorSession.diagnostics(file.text) val inlayHints = editorSession.inlayHints(file.text) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt new file mode 100644 index 000000000..708c8a699 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt @@ -0,0 +1,38 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import java.nio.file.Files +import java.nio.file.Path + +/** Coordinates disk access by autosave and the client thread against the last accepted contents. */ +internal class HollowIdeTextFileSync( + private val path: Path, + initialContent: ByteArray, +) { + private var content = initialContent + private var externalRevision = 0L + + @Synchronized + fun revision(): Long = externalRevision + + /** External changes win even when the editor still has a pending local edit. */ + @Synchronized + fun reloadIfChanged(reload: (ByteArray) -> Unit): Boolean { + val current = Files.readAllBytes(path) + if (content.contentEquals(current)) return false + reload(current) + content = current + externalRevision++ + return true + } + + /** A delayed save cannot write through a reload or replace an unobserved external edit. */ + @Synchronized + fun save(bytes: ByteArray, revision: Long, checkActive: () -> Unit = {}): Boolean { + checkActive() + if (revision != externalRevision || !content.contentEquals(Files.readAllBytes(path))) return false + checkActive() + writeIdeFile(path, bytes) + content = bytes + return true + } +} From b0217c6f379b570e778a027548a1be48f70d6e86 Mon Sep 17 00:00:00 2001 From: dan_bat <97949058+bat1set@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:33:58 +0300 Subject: [PATCH 25/25] addons manager ui panel - Added new panel at ui in tab -> windows --- .../client/ui/ide/HollowIdeOverlay.kt | 3 + .../client/ui/ide/HollowIdeToolWindows.kt | 16 +- .../client/ui/ide/addons/AddonManagerPanel.kt | 322 ++++++++++++++++++ .../client/ui/layout/UiLayoutMeasurement.kt | 5 +- .../common/addons/HollowAddonDescriptor.kt | 4 + .../addons/HollowAddonDescriptorReader.kt | 8 +- .../common/addons/HollowAddonManager.kt | 3 +- .../common/addons/HollowAddonRuntime.kt | 14 +- .../en_us/hollowengine/gui/ide/addons.json | 17 + .../ru_ru/hollowengine/gui/ide/addons.json | 17 + .../textures/gui/icons/addons/effects.svg | 9 + .../textures/gui/icons/addons/installed.svg | 10 + .../textures/gui/icons/addons/layers.svg | 7 + .../textures/gui/icons/addons/library.svg | 6 + .../textures/gui/icons/addons/package.svg | 8 + .../textures/gui/icons/addons/performance.svg | 5 + .../textures/gui/icons/addons/search.svg | 5 + .../hollowengine/ui/styles/addon-manager.hss | 282 +++++++++++++++ 18 files changed, 734 insertions(+), 7 deletions(-) create mode 100644 runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/addons/AddonManagerPanel.kt create mode 100644 runtime/src/main/lang/en_us/hollowengine/gui/ide/addons.json create mode 100644 runtime/src/main/lang/ru_ru/hollowengine/gui/ide/addons.json create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/effects.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/installed.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/layers.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/library.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/package.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/performance.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/search.svg create mode 100644 runtime/src/main/resources/assets/hollowengine/ui/styles/addon-manager.hss diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt index 38845f813..13afd6ca4 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt @@ -11,6 +11,7 @@ import ru.hollowhorizon.hollowengine.client.history.UndoKeys import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.docking.* +import ru.hollowhorizon.hollowengine.client.ui.ide.addons.AddonManagerPanel import ru.hollowhorizon.hollowengine.client.ui.ide.asset.* import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeImageEditor import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.HollowIdeAnimatorEditor @@ -73,6 +74,7 @@ internal const val InspectorId = "ide-inspector" internal const val GameViewportId = "ide-game-viewport" internal const val GameViewportNodeId = "game-viewport" internal const val HistoryId = "ide-history" +internal const val AddonManagerId = "ide-addon-manager" private const val LayoutSaveDelayMillis = 600L @@ -1101,6 +1103,7 @@ object HollowIdeOverlay { ) UiProfilerId -> HollowIdeUiProfilerPanel(surface.runtime.profiler) + AddonManagerId -> AddonManagerPanel(onFocusSearch = ::requestSurfaceFocus) in contributions -> contributions.PanelContent(item.id) else -> model.files.values.firstOrNull { it.id == item.id }?.let { file -> FileTabBody(file) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt index c337d3fb2..caf456939 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt @@ -4,6 +4,8 @@ import ru.hollowhorizon.hollowengine.client.ui.docking.DockItem import ru.hollowhorizon.hollowengine.client.ui.docking.DockPlacement import ru.hollowhorizon.hollowengine.client.ui.docking.DockTarget import ru.hollowhorizon.hollowengine.client.ui.docking.DockingState +import ru.hollowhorizon.hollowengine.client.ui.ide.addons.AddonManagerIcon +import ru.hollowhorizon.hollowengine.client.ui.ide.addons.AddonManagerLang import ru.hollowhorizon.hollowengine.client.ui.ide.asset.AssetManagerLang import ru.hollowhorizon.hollowengine.client.utils.lang @@ -120,15 +122,27 @@ internal object HollowIdeToolWindows { minHeight = 130f, ) + val AddonManager = HollowIdeToolWindow( + id = AddonManagerId, + titleKey = "$AddonManagerLang.title", + icon = AddonManagerIcon, + placement = DockPlacement.CENTER, + anchors = listOf(HollowIdePanelAnchor.EDITORS), + minWidth = 220f, + minHeight = 160f, + ) + val menu: List = listOf( Project, Scene, Inspector, AssetManager, Console, History, null, Timeline, GameViewport, + null, + AddonManager, ) /** Every window the editor knows how to open, for turning a remembered id back into one. */ val all: List = listOf( - Project, Scene, AssetManager, Console, Timeline, Inspector, GameViewport, History, UiProfiler, + Project, Scene, AssetManager, Console, Timeline, Inspector, GameViewport, History, UiProfiler, AddonManager, ) fun byId(id: String): HollowIdeToolWindow? = all.firstOrNull { it.id == id } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/addons/AddonManagerPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/addons/AddonManagerPanel.kt new file mode 100644 index 000000000..69af879df --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/addons/AddonManagerPanel.kt @@ -0,0 +1,322 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.addons + +import androidx.compose.runtime.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonManager +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonState +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonStatus + +internal const val AddonManagerLang = "hollowengine.gui.ide.addons" +internal const val AddonManagerIcon = "hollowengine:textures/gui/icons/addons/installed.svg" + +private const val Stylesheet = "hollowengine:ui/styles/addon-manager.hss" +private const val SearchIcon = "hollowengine:textures/gui/icons/addons/search.svg" +private const val SearchInputId = "addon-manager-search" + +private const val MinCardWidth = 190f +private const val CardGap = 10f +private const val SkeletonCount = 3 +private const val SweepStep = 0.02f +private const val SweepStepMillis = 40L + +private object AddonListCache { + var statuses: List? = null +} + +private class AddonToggle(val id: String, val enable: Boolean) + +@Composable +internal fun AddonManagerPanel(onFocusSearch: (String) -> Unit) { + var installed by remember { mutableStateOf(AddonListCache.statuses) } + var query by remember { mutableStateOf("") } + var reloadRevision by remember { mutableStateOf(0) } + var failure by remember { mutableStateOf(null) } + val pending = remember { mutableStateMapOf() } + val toggles = remember { Channel(Channel.UNLIMITED) } + + LaunchedEffect(reloadRevision) { + val statuses = HollowAddonManager.statuses.distinctBy { it.descriptor.id } + installed = statuses + AddonListCache.statuses = statuses + } + + LaunchedEffect(Unit) { + for (request in toggles) { + val result = withContext(Dispatchers.IO) { + if (request.enable) HollowAddonManager.enable(request.id) else HollowAddonManager.disable(request.id) + } + pending.remove(request.id) + failure = if (result.successful) null else result.message + reloadRevision++ + } + } + + val onToggle: (HollowAddonStatus) -> Unit = { status -> + val id = status.descriptor.id + if (id !in pending) { + pending[id] = !status.isOn + toggles.trySend(AddonToggle(id, enable = !status.isOn)) + } + } + + Column( + tags = listOf("ide-panel", "addon-manager"), + modifier = Modifier.style(Stylesheet).size(100.percent, 100.percent).scrollable(horizontal = false), + ) { + AddonSearch(query, onChange = { query = it }, onFocus = onFocusSearch) + AddonHeader(installed?.size) + AddonProgress(busy = pending.isNotEmpty()) + failure?.let { message -> + AddonMessage(message, tags = listOf("addon-failure"), onClick = { failure = null }) + } + val addons = installed + when { + addons == null -> AddonSkeleton() + addons.isEmpty() -> AddonMessage("$AddonManagerLang.empty".lang) + else -> { + val shown = addons.filter { it.matches(query.trim()) } + if (shown.isEmpty()) AddonMessage("$AddonManagerLang.nothing_found".lang) + else AddonGrid(shown, pending, onToggle) + } + } + Box(modifier = Modifier.size(100.percent, 1.px)) + } +} + +@Composable +private fun AddonHeader(count: Int?) { + Row(tags = listOf("addon-section-header")) { + Image(AddonManagerIcon, tags = listOf("addon-section-icon")) + Text("$AddonManagerLang.installed".lang, tags = listOf("addon-section-title")) + Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + if (count != null) Text(count.toString(), tags = listOf("addon-section-count")) + } +} + +@Composable +private fun AddonProgress(busy: Boolean) { + Box(tags = listOf("addon-progress"), modifier = Modifier.size(100.percent, 2.px).clip()) { + if (busy) AddonSweep() + } +} + +@Composable +private fun AddonSweep() { + var phase by remember { mutableStateOf(0f) } + LaunchedEffect(Unit) { + while (true) { + delay(timeMillis = SweepStepMillis) + phase = (phase + SweepStep) % 1f + } + } + Layout( + content = { Box(tags = listOf("addon-progress-bar")) }, + modifier = Modifier.size(100.percent, 2.px), + measurePolicy = addonSweepPolicy(phase), + ) +} + +private fun addonSweepPolicy(phase: Float) = UiMeasurePolicy { measurables, constraints -> + val width = constraints.maxWidth.takeIf { it.isFinite() } ?: 0f + val bar = measurables.firstOrNull()?.measure(UiConstraints()) + layout(width, bar?.height ?: 0f) { + bar?.place((width + bar.width) * phase - bar.width, 0f) + } +} + +@Composable +private fun AddonSkeleton() { + Column(tags = listOf("addon-skeleton-list")) { + repeat(SkeletonCount) { + Column(tags = listOf("addon-skeleton")) { + Box(tags = listOf("addon-skeleton-line", "wide")) + Box(tags = listOf("addon-skeleton-line")) + } + } + } +} + +@Composable +private fun AddonSearch(query: String, onChange: (String) -> Unit, onFocus: (String) -> Unit) { + Row( + tags = listOf("addon-search"), + modifier = Modifier.cursor(UiCursorShape.TEXT).onClick { event -> + onFocus(SearchInputId) + event.consume() + }, + ) { + Image(SearchIcon, tags = listOf("addon-search-icon")) + TextField( + value = query, + placeholder = "$AddonManagerLang.search".lang, + onChange = onChange, + id = SearchInputId, + tags = listOf("addon-search-input"), + modifier = Modifier.grow(1f), + ) + } +} + +@Composable +private fun AddonMessage(text: String, tags: List = emptyList(), onClick: (() -> Unit)? = null) { + Text( + text, + tags = listOf("addon-message") + tags, + modifier = onClick?.let { click -> + Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND).onClick { event -> + if (event.isLeftClick()) click() + event.consume() + } + }, + ) +} + +@Composable +private fun AddonGrid( + addons: List, + pending: Map, + onToggle: (HollowAddonStatus) -> Unit, +) { + Layout( + content = { + addons.forEach { status -> + key(status.descriptor.id) { + AddonCard(status, pending[status.descriptor.id]) { onToggle(status) } + } + } + }, + tags = listOf("addon-grid"), + modifier = Modifier.size(100.percent, UiLength.Fit), + measurePolicy = AddonGridMeasurePolicy, + ) +} + +internal val AddonGridMeasurePolicy = UiMeasurePolicy { measurables, constraints -> + val width = constraints.maxWidth.takeIf { it.isFinite() } ?: (MinCardWidth * 2 + CardGap) + val columns = ((width + CardGap) / (MinCardWidth + CardGap)).toInt().coerceAtLeast(1) + val cardWidth = ((width - CardGap * (columns - 1)) / columns).coerceAtLeast(0f) + val rows = measurables.chunked(columns).map { row -> + val measured = row.map { it.measure(UiConstraints(cardWidth, cardWidth)) } + val tallest = measured.maxOf { it.height } + if (measured.all { it.height == tallest }) measured + else row.map { it.measure(UiConstraints(cardWidth, cardWidth, tallest, tallest)) } + } + val height = rows.sumOf { row -> row.maxOf { it.height }.toDouble() }.toFloat() + + CardGap * (rows.size - 1).coerceAtLeast(0) + layout(width, height) { + var y = 0f + rows.forEach { row -> + row.forEachIndexed { column, placeable -> placeable.place(column * (cardWidth + CardGap), y) } + y += row.maxOf { it.height } + CardGap + } + } +} + +@Composable +private fun AddonCard(status: HollowAddonStatus, target: Boolean?, onToggle: () -> Unit) { + val descriptor = status.descriptor + val kind = AddonKind.of(descriptor.category) + val shownOn = target ?: status.isOn + Column( + tags = listOfNotNull( + "addon-card", + kind.tag, + "enabled".takeIf { shownOn }, + "pending".takeIf { target != null }, + ), + modifier = Modifier.tooltipOnHover(status.tooltip()), + ) { + Row(tags = listOf("addon-card-header")) { + Box(tags = listOf("addon-icon-tile", kind.tag)) { + Image(kind.icon, tags = listOf("addon-icon")) + } + Column(tags = listOf("addon-heading")) { + Text( + descriptor.name, + tags = listOf("addon-name"), + modifier = Modifier.textWrap(false).textOverflow(UiTextOverflow.DOTS), + ) + Row(tags = listOf("addon-meta")) { + descriptor.category?.let { Text(it, tags = listOf("addon-category")) } + descriptor.authors.takeIf { it.isNotEmpty() }?.let { + Text( + it.joinToString(", "), + tags = listOf("addon-author"), + modifier = Modifier.textWrap(false).textOverflow(UiTextOverflow.DOTS), + ) + } + } + } + AddonSwitch(on = shownOn, pending = target != null, onToggle = onToggle) + } + Column(tags = listOf("addon-card-footer")) { + Text( + descriptor.description ?: "$AddonManagerLang.no_description".lang, + tags = listOf("addon-description"), + ) + if (target != null) { + val label = if (target) "$AddonManagerLang.enabling" else "$AddonManagerLang.disabling" + Text(label.lang, tags = listOf("addon-pending")) + } else { + Text("v${descriptor.version}", tags = listOf("addon-version")) + } + } + } +} + +@Composable +private fun AddonSwitch(on: Boolean, pending: Boolean, onToggle: () -> Unit) { + Row( + tags = listOfNotNull("addon-switch", "on".takeIf { on }, "pending".takeIf { pending }), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND).onClick { event -> + if (event.isLeftClick()) onToggle() + event.consume() + }, + ) { + Box(tags = listOf("addon-switch-knob")) + } +} + +private val HollowAddonStatus.isOn: Boolean get() = state != HollowAddonState.DISABLED + +private fun HollowAddonStatus.matches(query: String): Boolean { + if (query.isEmpty()) return true + val descriptor = descriptor + return listOfNotNull(descriptor.name, descriptor.id, descriptor.description, descriptor.category) + .plus(descriptor.authors) + .any { it.contains(query, ignoreCase = true) } +} + +private fun HollowAddonStatus.tooltip(): String = buildString { + append(descriptor.id).append(" · ").append(fileName) + append('\n').append("$AddonManagerLang.state.${state.name.lowercase()}".lang) + details?.let { append('\n').append(it) } +} + +private enum class AddonKind(val tag: String, val icon: String) { + PERFORMANCE("performance", "hollowengine:textures/gui/icons/addons/performance.svg"), + VISUAL("visual", "hollowengine:textures/gui/icons/addons/effects.svg"), + LIBRARY("library", "hollowengine:textures/gui/icons/addons/library.svg"), + INSTRUMENT("instrument", "hollowengine:textures/gui/icons/addons/layers.svg"), + OTHER("other", "hollowengine:textures/gui/icons/addons/package.svg"); + + companion object { + fun of(category: String?): AddonKind { + val name = category?.lowercase() ?: return OTHER + return when { + name.startsWith("perf") -> PERFORMANCE + name.startsWith("visual") || name.startsWith("effect") -> VISUAL + name.startsWith("lib") -> LIBRARY + name.startsWith("instrument") || name.startsWith("tool") -> INSTRUMENT + else -> OTHER + } + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt index c5fa3858e..eb3c2b32d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt @@ -321,12 +321,14 @@ private fun UiLayoutPipeline.intrinsicSize( if (layoutChildren(node).isEmpty()) return replacedIntrinsicSize(style) if (node.measurePolicy !is UiBuiltInMeasurePolicy) { + val policyHeight = + if (style.size.height is UiLength.Fit) Float.POSITIVE_INFINITY else availableHeight measureCustomContainer( node, resolved, node.measurePolicy, availableWidth, - availableHeight, + policyHeight, scrollbarReserves, ) } else { @@ -444,4 +446,3 @@ internal fun UiComputedStyle.verticalGutter(reserve: UiScrollbarReserve, referen internal fun UiComputedStyle.horizontalGutter(reserve: UiScrollbarReserve, reference: Float): Float = if (reserve.horizontal) scrollbar.resolved(reference).gutter else 0f - diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptor.kt index c8b551a1a..316b99559 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptor.kt @@ -11,6 +11,10 @@ data class HollowAddonDescriptor( val requiredClasses: List = emptyList(), val mappingNamespace: HollowAddonMappingNamespace = HollowAddonMappingNamespace.AGNOSTIC, val hostModId: String? = null, + val description: String? = null, + val authors: List = emptyList(), + val category: String? = null, + val icon: String? = null, ) enum class HollowAddonEnvironment { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptorReader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptorReader.kt index 1ed7e916b..661c24982 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptorReader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonDescriptorReader.kt @@ -29,7 +29,11 @@ internal object HollowAddonDescriptorReader { .uppercase() .let(HollowAddonEnvironment::valueOf), requiredClasses = properties.list("requiredClasses"), - hostModId = properties.getProperty("hostModId")?.trim()?.takeIf(String::isNotEmpty), + hostModId = properties.optional("hostModId"), + description = properties.optional("description"), + authors = properties.list("authors"), + category = properties.optional("category"), + icon = properties.optional("icon"), ).also(::validate) } @@ -51,6 +55,8 @@ internal object HollowAddonDescriptorReader { private fun Properties.required(name: String): String = getProperty(name)?.trim()?.takeIf(String::isNotEmpty) ?: throw IllegalArgumentException("Missing '$name' in $DESCRIPTOR_PATH") + private fun Properties.optional(name: String): String? = getProperty(name)?.trim()?.takeIf(String::isNotEmpty) + private fun Properties.list(name: String): List = getProperty(name, "") .split(',') .map(String::trim) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt index 76ababa46..42f9fd085 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt @@ -20,8 +20,9 @@ object HollowAddonManager : AutoCloseable { val restartRequired: List get() = runtime?.restartRequiredSnapshot.orEmpty() + /** The state after the last finished change; never waits for a load that is still running. */ val statuses: List - get() = runtime?.let { addonRuntime -> runBlocking { addonRuntime.statuses() } }.orEmpty() + get() = runtime?.statusSnapshot.orEmpty() /** * Loaded addons that carry assets or data, highest priority first. Addons in `mods` are left out: diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index 34227645c..2d6334328 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -59,6 +59,14 @@ internal class HollowAddonRuntime( var enabledSnapshot: List = emptyList() private set + /** + * The statuses as they were when the last change finished. Readable without the lock, so a screen listing + * the addons does not wait while a load holds it. + */ + @Volatile + var statusSnapshot: List = emptyList() + private set + @Volatile private var started = false @@ -199,9 +207,10 @@ internal class HollowAddonRuntime( drainPending() } - suspend fun statuses(): List = locked { + suspend fun statuses(): List = locked { currentStatuses() } + + private fun currentStatuses(): List = knownCandidates.values.sortedWith(compareBy({ it.descriptor.id }, { it.sourceFile.name })).map(::statusOf) - } suspend fun setEnabled(id: String, enabled: Boolean): HollowAddonOperationResult = locked { val candidates = knownCandidates.values.filter { candidate -> candidate.descriptor.id == id } @@ -646,6 +655,7 @@ internal class HollowAddonRuntime( block() } finally { refreshEnabledSnapshot() + statusSnapshot = currentStatuses() mutex.unlock() if (loadedSnapshot !== loadedBefore) { runCatching { HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/addons.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/addons.json new file mode 100644 index 000000000..f16c001f1 --- /dev/null +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/addons.json @@ -0,0 +1,17 @@ +{ + "title": "Addon Manager", + "search": "Search addons", + "installed": "Installed", + "loading": "Loading addons…", + "empty": "No addons are installed.", + "nothing_found": "Nothing found.", + "no_description": "No description.", + "enabling": "Enabling…", + "disabling": "Disabling…", + "state.loaded": "Loaded", + "state.disabled": "Disabled", + "state.restart_required": "Restart required", + "state.waiting_for_dependencies": "Waiting for dependencies", + "state.rejected": "Rejected", + "state.inactive": "Inactive" +} diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/addons.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/addons.json new file mode 100644 index 000000000..a56c92256 --- /dev/null +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/addons.json @@ -0,0 +1,17 @@ +{ + "title": "Менеджер аддонов", + "search": "Поиск по аддонам", + "installed": "Установленные", + "loading": "Загрузка списка аддонов…", + "empty": "Аддоны не установлены.", + "nothing_found": "Ничего не найдено.", + "no_description": "Без описания.", + "enabling": "Включается…", + "disabling": "Выключается…", + "state.loaded": "Загружен", + "state.disabled": "Выключен", + "state.restart_required": "Нужен перезапуск", + "state.waiting_for_dependencies": "Ждёт зависимости", + "state.rejected": "Отклонён", + "state.inactive": "Не активен" +} diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/effects.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/effects.svg new file mode 100644 index 000000000..8c74d2316 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/effects.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/installed.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/installed.svg new file mode 100644 index 000000000..7559a3356 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/installed.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/layers.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/layers.svg new file mode 100644 index 000000000..21960f716 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/layers.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/library.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/library.svg new file mode 100644 index 000000000..3a0d61912 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/library.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/package.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/package.svg new file mode 100644 index 000000000..3dcc9cccb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/package.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/performance.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/performance.svg new file mode 100644 index 000000000..d67c6cbfe --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/performance.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/search.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/search.svg new file mode 100644 index 000000000..f2948dc10 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/addons/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/addon-manager.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/addon-manager.hss new file mode 100644 index 000000000..5ee459569 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/addon-manager.hss @@ -0,0 +1,282 @@ +@import "hollowengine:ui/styles/theme.hss"; + +$addon-accent: #FEAE3F; +$addon-orange: #FEAE3F; +$addon-green: #1DB07D; +$addon-blue: #58B2EA; +$addon-tile-orange: alpha(#F39C12, 0.19); +$addon-tile-green: alpha(#1ABC9C, 0.14); +$addon-tile-blue: alpha(#3498DB, 0.19); + +.ide-panel.addon-manager { + padding: 12px; + gap: 14px; + background: $bg-panel; + @apply $thin-scroll; +} + +.addon-search { + size: 100% 20px; + padding: 0px 10px; + gap: 7px; + align-items: start center; + border-radius: 10px; + background: $bg-raised; +} + +.addon-search-icon { + size: 11px 11px; +} + +.addon-search-input { + height: 100%; + padding: 0px; + background: transparent; + border: 1px transparent; + foreground: $text; + font-size: $font-size; + @apply $field-text; +} + +.addon-section-header { + size: 100% 16px; + padding: 0px 0px 0px 6px; + gap: 6px; + align-items: start center; +} + +.addon-section-icon { + size: 10px 10px; +} + +.addon-section-title { + size: fit fit; + foreground: $text-strong; + font-size: $font-size-title; + wrap: false; +} + +.addon-message { + size: 100% fit; + padding: 6px; + foreground: $text-faint; +} + +.addon-card { + height: fit; + padding: 12px 12px 16px 12px; + gap: 12px; + border-radius: $radius-large; + background: linear-gradient(168deg, $bg-hover, $bg-raised); + border: 1px $border; + transition: border 120ms ease-out; +} + +.addon-card:hover { + border: 1px $border-strong; +} + +.addon-card.enabled { + background: linear-gradient(168deg, #403830, $bg-raised); + border: 1px $addon-accent; + shadow: 0px 0px 6px 0px alpha($addon-accent, 0.15); +} + +.addon-card.enabled.library, +.addon-card.enabled.other { + background: linear-gradient(168deg, #253837, $bg-raised); + border: 1px $addon-green; + shadow: 0px 0px 6px 0px alpha(#46FFD4, 0.1); +} + +.addon-card.enabled.instrument { + background: linear-gradient(168deg, #2C3945, $bg-raised); + border: 1px $addon-blue; + shadow: 0px 0px 6px 0px alpha(#3F8BFE, 0.15); +} + +.addon-card-header { + size: 100% fit; + gap: 8px; + align-items: start center; +} + +.addon-icon-tile { + size: 34px 34px; + align-items: center center; + border-radius: $radius-large; + background: $addon-tile-green; +} + +.addon-icon-tile.performance, +.addon-icon-tile.visual { + background: $addon-tile-orange; +} + +.addon-icon-tile.instrument { + background: $addon-tile-blue; +} + +.addon-icon { + size: 19px 19px; +} + +.addon-heading { + width: 0px; + height: fit; + grow: 1; + gap: 4px; +} + +.addon-name { + size: 100% fit; + foreground: $text; + font-size: $font-size; +} + +.addon-meta { + size: 100% fit; + gap: 6px; + align-items: start center; +} + +.addon-category { + size: fit fit; + padding: 1px 4px; + border-radius: $radius-small; + background: $border; + foreground: $text-muted; + font-size: $font-size-small; + wrap: false; +} + +.addon-author { + width: 0px; + height: fit; + grow: 1; + foreground: $text-muted; + font-size: $font-size-small; +} + +.addon-card-footer { + size: 100% fit; + padding: 2px 0px 0px 0px; + gap: 6px; +} + +.addon-description { + size: 100% fit; + foreground: $text-muted; + font-size: $font-size-small; + line-spacing: 2px; +} + +.addon-version { + size: 100% fit; + foreground: $text-faint; + font-size: $font-size-small; + text-align: right; + wrap: false; +} + +.addon-switch { + size: 26px 14px; + padding: 2px; + align-items: start center; + border-radius: 7px; + background: $border; + transition: background 120ms ease-out; +} + +.addon-switch.on { + align-items: end center; + background: $addon-accent; +} + +.addon-switch-knob { + size: 10px 10px; + border-radius: 5px; + background: $white; +} + +.addon-failure { + size: 100% fit; + padding: 6px 8px; + border-radius: $radius; + background: $danger-soft; + foreground: $danger; + font-size: $font-size-small; +} + +.addon-failure:hover { + background: alpha(#C25757, 0.3); +} + +.addon-section-count { + size: fit fit; + padding: 0px 6px; + border-radius: $radius-small; + background: $bg-raised; + foreground: $text-muted; + font-size: $font-size-small; + wrap: false; +} + +.addon-progress { + size: 100% 2px; + background: $divider; +} + +.addon-progress-bar { + size: 56px 2px; + background: $accent; + border-radius: 1px; +} + +.addon-skeleton-list { + size: 100% fit; + gap: 10px; +} + +.addon-skeleton { + size: 100% 84px; + padding: 14px; + gap: 12px; + border-radius: $radius-large; + background: $bg-field; + border: 1px $divider; +} + +.addon-skeleton-line { + size: 45% 9px; + border-radius: $radius-small; + background: $bg-hover; +} + +.addon-skeleton-line.wide { + size: 70% 9px; +} + +.addon-card.pending { + border: 1px $border-strong; +} + +.addon-pending { + size: 100% fit; + foreground: $accent; + font-size: $font-size-small; + text-align: right; + wrap: false; +} + +.addon-switch:hover { + background: $bg-pressed; +} + +.addon-switch.on:hover { + background: alpha($addon-accent, 0.8); +} + +.addon-switch.pending .addon-switch-knob { + background: $text-muted; +}