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/build.gradle.kts b/addons/compiler/build.gradle.kts index 8ad4820ec..e846df2f2 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 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") @@ -169,7 +173,11 @@ 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") + 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 })) testRuntimeOnly(files(compilerPluginRuntimeJar.flatMap { it.archiveFile })) testRuntimeOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") @@ -177,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") } @@ -305,7 +314,8 @@ tasks.withType().configureEach { }) inputs.files(analysisClasspath).withPropertyName("ideAnalysisClasspath") doFirst { - systemProperty("hollowengine.ide.testClasspath", analysisClasspath.get().asPath) + val classpath = analysisClasspath.get().files + narrativeTestJar.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/ScriptingEnvironmentFactory.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt index ded423347..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 @@ -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 @@ -33,10 +35,16 @@ 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 + @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 +53,7 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol override suspend fun unload(context: HollowAddonContext) { loaded = null + built = null ScriptingEnvironment.clear() } @@ -56,20 +65,52 @@ 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 = 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) + val (mappings, classpath) = prepareClasspath(addonFile) + start(LoadedAddon(addonFile, scope), mappings, classpath, definitions()) + } + + 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/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/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/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt index 43e2cde87..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 @@ -13,7 +13,9 @@ 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 import ru.hollowhorizon.hollowengine.common.ide.session.definition.findDefinition import ru.hollowhorizon.hollowengine.common.ide.session.diagnostic.diagnosticCode @@ -32,6 +34,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 @@ -231,7 +234,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) @@ -254,6 +261,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 @@ -302,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) { @@ -315,9 +326,12 @@ 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? = + 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/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/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/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/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..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 @@ -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 @@ -90,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 @@ -99,11 +102,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 + story.markerFor(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) } @@ -112,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() @@ -151,5 +166,8 @@ 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 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 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..9efb64f44 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt @@ -0,0 +1,141 @@ +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.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 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) + + 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) } } + + 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 + 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 = color(name)) + } + + RewindName -> return rewindTarget(callExpression, expression) + ?.let { GutterMarker(0, GutterMarkerKind.REWIND, listOf(it), color = color(it)) } + ?: GutterMarker(0, GutterMarkerKind.REWIND_START) + + 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())) + 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")) + } + 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 }) + + /** + * 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) } + + 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 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/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..45f082b9f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt @@ -0,0 +1,135 @@ +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.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 +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 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() + 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" + ) + + 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 new file mode 100644 index 000000000..8a31d7f0b --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -0,0 +1,273 @@ +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.FirFile +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.FirLiteralExpression +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) + checkCheckpointName(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 + 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, startsStory) + 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) + } + } + } + } + + /** + * 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 + 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, startsStory: Boolean) { + 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 -> + 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") + 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") } + } + + 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 && !(symbol.fir.isVar && isShareable(symbol))) { + 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..624c7ee24 --- /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 || 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 + 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/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/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 new file mode 100644 index 000000000..ecdba7872 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -0,0 +1,225 @@ +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 +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 + * 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) { + 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 + + /** 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) + 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) + 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 -> + 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( + variable, + "$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" + ) + } + } + if (errorCount() != errors) return + StageRewriter(stages, function, layout).apply { + rewrite(statements, name, hash) + script.declarations += entry(name) + 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() -> StageMessages.TYPE_PARAMETERS + else -> null + } + problem?.let { reporter.error(function, it); return false } + var valid = true + 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" + ) + 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) + // 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, + StageMessages.NESTED_CALL + ) + + handler > 0 -> reporter.error( + expression, + StageMessages.HANDLER_CALL + ) + } + } + }) + 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, StageMessages.TOP_LEVEL + ) + else if (marked && !stages.isSuspendState(declaration)) reporter.error( + declaration, StageMessages.SUSPEND + ) + super.visitFunction(declaration) + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + if (stages.symbols.isCheckpoint(expression.symbol.owner)) { + reporter.error(expression, StageMessages.CHECKPOINT_OUTSIDE) + } + } + }) + } + + 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..4b8667154 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -0,0 +1,278 @@ +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 ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages +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. 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() { + private val functions = ArrayDeque() + private val owners = ArrayDeque() + private val counters = HashMap() + + /** 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() } + } + + 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 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 + } + + private fun lift(expression: IrFunctionExpression, callee: String, parameter: IrValueParameter): IrExpression? { + val lambda = expression.function + 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 + locals.redirect(lambda, scriptThis()) + 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 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) + 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) + + 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 + 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) { + 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 + } + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + if (expression.symbol.owner !in inside) { + stages.reporter.error( + expression, + StageMessages.assignCapture(expression.symbol.owner.name.asString()) + ) + valid = false + } + } + }) + locals.reachableFrom(lambda).forEach { function -> captured += locals.freeValues(function) } + captured.forEach { value -> + if (value is IrVariable && value.isVar) { + stages.reporter.error( + lambda, StageMessages.mutableCapture(value.name.asString()) + ) + valid = false + } + stages.types.problem(value.type)?.let { problem -> + stages.reporter.error( + lambda, StageMessages.unsavedCapture(value.name.asString(), 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() + 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] ?: ownParameters[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] = parameters.size + 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/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/StageLayout.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt new file mode 100644 index 000000000..72e190acb --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt @@ -0,0 +1,279 @@ +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, 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( + 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.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")}" + } + } + 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/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/StageNormalizer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt new file mode 100644 index 000000000..2dd2a113c --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt @@ -0,0 +1,466 @@ +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.types.typeWith +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 + + /** 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) + + 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 { + // `&&` 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)) { + val lifted = value(branch.condition) + val condition = materialize(lifted.value) + val rest = IrWhenImpl( + 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) + } + 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) + } + + /** + * 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)) { + 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 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 + 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 + val sequenceClass = stages.symbols.sequence + val elementType = nextCall(loop, iterator)?.type + when { + 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") + loopCopies += list + 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) + } + } + + 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) + } + + 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) + ) + } + } + } + } + + 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) + } + + /** 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) + 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 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) + + 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/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/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt new file mode 100644 index 000000000..64ef22dc2 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -0,0 +1,561 @@ +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.types.makeNullable +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() + stages.arguments(function).forEach { parameter -> + body += frameCall( + builder, + "saveArg", + builder.irString(stages.argumentName(function, parameter)), + 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) + ) + // A local the resumed site did not save comes back as the null it was declared with. + +irSet(variable, irAs(restored, variable.type.makeNullable())) + } + }) + } + 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 = 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) + 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)) + } + + /** 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) { + 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) assignAt(builder, site, builder.irSet(variable, value)) + else variable.also { it.initializer = value } + } + + 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) + } + } + } + } + + /** + * 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() + if (site.kind == SiteKind.ACTION) return action(builder, site, type, keepsResult) + val fresh = builder.irBlock(resultType = type) { + if (site.kind == SiteKind.CHECKPOINT) { + saveLocals(site) + 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)) + saveLocals(site) + 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) + } + + /** + * 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. + */ + 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.DispatchReceiver) return@forEachIndexed + +with(stages) { + callObject( + symbols.put, + irGet(tag), + irString(stages.argumentName(callee, parameter)), + 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) { + +frameCall(this, "beginCall", irInt(site.index)) + saveLocals(site) + 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, arguments.size)).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(stages.argumentName(function, parameter)), + serializer(parameter.type) + ) + } + irAs(value, parameter.type) + } + } + } + if (returnType.isUnit()) { + +call + +irReturn(irGetObject(builtIns.unitClass)) + } else +irReturn(call) + } + 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 new file mode 100644 index 000000000..9c761f904 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -0,0 +1,117 @@ +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.IrParameterKind +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.util.defaultType +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 + 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 } + + /** 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.owner.defaultType 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 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 = + builder(constructor, script).run { irAnnotation(constructor).apply { arguments[0] = irString(value) } } + + private fun stateAnnotation(function: IrFunction): IrConstructorCall? = + function.annotations.firstOrNull { it.isAnnotation(StageSymbols.STATE) } +} + +/** + * 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 new file mode 100644 index 000000000..7eec3a511 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -0,0 +1,116 @@ +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 +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 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() + val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() + val stateConstructor: IrConstructorSymbol = state.constructors.single() + 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"))) + ?: 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 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"))) + + /** 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") + val get: IrSimpleFunctionSymbol = member(values, "get") + 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 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 = + 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 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") + + 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 ACTION_ALIAS: FqName = PACKAGE.child(Name.identifier("ActionAlias")) + val UNSAVED: FqName = PACKAGE.child(Name.identifier("Unsaved")) + + 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..9a73f7118 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -0,0 +1,61 @@ +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 ru.hollowhorizon.hollowengine.common.plugin.stages.StageValueTypes + +/** + * 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 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" + } + if (klass.isPrimitiveArray()) return null + 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" + } + } + 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?.asString().orEmpty().startsWith(StageValueTypes.STAGE_LAMBDA) + + private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(StageValueTypes.SERIALIZABLE) + + 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/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/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/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt new file mode 100644 index 000000000..dbbb9c65e --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt @@ -0,0 +1,38 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +import com.intellij.psi.PsiElement +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 + +/** 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", + Severity.ERROR, + SourceElementPositioningStrategies.DEFAULT, + PsiElement::class, + 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/StageMessages.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt new file mode 100644 index 000000000..b1397430f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt @@ -0,0 +1,28 @@ +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 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" + + 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..c92de9f02 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt @@ -0,0 +1,50 @@ +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() + + /** 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() + + 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/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index 18918c943..617827883 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -150,12 +150,65 @@ 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) } + @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") + listOf(1).forEach { place("y") } + } + """.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.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 + 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) } + val arena = at("checkpoint(\"arena").color + val boss = at("checkpoint(\"boss").color + assertEquals(GutterMarkerKind.REWIND_START, at("rewind()").kind) + 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 fun `attached nodes have no server run targets`() { val text = 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/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt new file mode 100644 index 000000000..9d68bd3e9 --- /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.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +abstract class LifecycleScript + +class ScriptDefinitionsLifecycleTest { + @Test + 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) + 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(1, preparations) + val initial = ScriptingEnvironment.INSTANCE + 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 + 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) + + 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/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt new file mode 100644 index 000000000..eba29ddad --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -0,0 +1,199 @@ +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 +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 lock: Any? = null + suspend fun playSing(name: String) { lock = name } + 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("'lock', which cannot be saved"), error.message) + } + + @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("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()) + } + + @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()) + } + + @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/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/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt new file mode 100644 index 000000000..ed2aeaf6b --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -0,0 +1,279 @@ +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( + "'this' cannot be", """ + @State suspend fun Any.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( + "can share 'var counter' only with the @State function or lambda that declares it directly", """ + 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 `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 generateSequence(1) { it + 1 }) step(value) + } + """ + ) + + @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") } + """ + ) + + @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 { } } + """ + ) + + @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'", """ + @State suspend fun arena() {} + + @State + suspend fun main() { + checkpoint("arena") + arena() + } + """ + ) +} 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/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/StageExecutionTest.kt b/addons/compiler/src/test/kotlin/StageExecutionTest.kt new file mode 100644 index 000000000..5662faba9 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageExecutionTest.kt @@ -0,0 +1,766 @@ +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() + } + + @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/StageFeaturesTest.kt b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt new file mode 100644 index 000000000..73f0906e0 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt @@ -0,0 +1,217 @@ +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 + +/** 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() + } + + @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/StageJournalTest.kt b/addons/compiler/src/test/kotlin/StageJournalTest.kt new file mode 100644 index 000000000..9369bfdee --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageJournalTest.kt @@ -0,0 +1,471 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Actions in the journal: what is undone when, rewinds to checkpoints, labels, a restart in between. */ +class StageJournalTest : StageTest() { + @Test + 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() { + temporary { place("barricade") } + place("banner") + await("win") + } + + @State + suspend fun main() { + fight() + log("after ${StageTrace.blocks}") + checkpoint("won") + log("passed ${StageTrace.blocks}") + } + """ + ) + world.launch("main") + assertEquals(listOf("place barricade", "place banner"), StageTrace.take()) + + world.release("win") + 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() + } + + @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 `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 + """ + @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 `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") + step() + place("bridge") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + log("done") + } + """ + ) + 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 with no checkpoint before it starts its run over`() = 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 `rewind in a branch takes back the branch alone`() = runTest { + val world = world("branchRewind", PLACE + """ + @State + suspend fun main() { + place("wall") + async("guard") { + place("tower") + checkpoint("posted") + place("flag") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + await("g") + } + await("x") + } + """ + ) + 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() + } + + 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/StageLambdaSafetyTest.kt b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt new file mode 100644 index 000000000..f3cdf4f9d --- /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 calls an enclosing local function only when what it uses can be saved`() { + val errors = compiler.errors(""" + @State + suspend fun main() { + var lock: Any? = null + suspend fun playSing(name: String) { + lock = name + async("resetToIdle") { await(name) } + } + listOf("left", "right").forEach { direction -> + async("input_" + direction) { playSing(direction) } + } + await("end") + } + """) + assertContains(errors, "local function 'playSing'") + assertContains(errors, "'lock', which cannot be saved") + 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/compiler/src/test/kotlin/StageSharedVarsTest.kt b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt new file mode 100644 index 000000000..d847aefbc --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt @@ -0,0 +1,212 @@ +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 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(""" + 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 new file mode 100644 index 000000000..cc08a0e47 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -0,0 +1,233 @@ +import StageTrace.release +import kotlinx.coroutines.CancellationException +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.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 +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 + +/** 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 = "" + + /** 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>>() + + @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) } + } + + /** 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 + } + + fun take(): List = events.toList().also { events.clear() } + + fun reset() { + events.clear() + token = "" + blocks.clear() + released.clear() + waiting.clear() + } +} + +/** 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")), + 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" + ), + ), + 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 } + + 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() + } + + /** 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() + 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 + } + + 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 + } +} + +/** 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/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/build.gradle.kts b/addons/narrative/build.gradle.kts new file mode 100644 index 000000000..cf1e1aef0 --- /dev/null +++ b/addons/narrative/build.gradle.kts @@ -0,0 +1,21 @@ +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("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 new file mode 100644 index 000000000..5fe065450 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt @@ -0,0 +1,76 @@ +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.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 +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 +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +/** + * 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 { + 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 + STORY_PACKAGES + NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + STORY_PACKAGES + STARTUP_SCRIPT_EXTENSION, SERVER_CONSOLE_SCRIPT_EXTENSION -> event.defaultImports += STORY_PACKAGES + } + } + + /** 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) + StoryCommands.register(root) + } + + private companion object { + const val DIALOGUE_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.dialogue.*" + 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) + + 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/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..08c57acf7 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt @@ -0,0 +1,44 @@ +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.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, 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(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 }, + ) + 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 89% 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..2cf51c66f 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.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.client.render.RenderTickEvent 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 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 83% 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..96ddb4362 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,11 +6,11 @@ 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.common.network.HollowPacket +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.HollowPacketHandler fun Player.playCutscene(path: String, loop: Boolean = false) { @@ -99,7 +99,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 +108,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/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 67% 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 60f1c7d89..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,16 +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.dialogue.StoryCameraSystem +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() @@ -18,20 +22,12 @@ object CutsceneCameraSystem { val activeController: CutscenePlaybackController? 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. - */ - @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. - */ - val currentPose: CameraPose? - get() = heldPose ?: controller?.currentPose ?: StoryCameraSystem.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 { @@ -69,9 +65,10 @@ object CutsceneCameraSystem { isPreview = false } - fun update(minecraft: Minecraft) { - StoryCameraSystem.update() + @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() @@ -96,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/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 90% 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..3e3bfdc76 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,15 +1,16 @@ -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 +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 @@ -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 74% 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..a9a267db0 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,11 +1,11 @@ -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.client.ui.ide.timeline.cutscene.CameraPose -import ru.hollowhorizon.hollowengine.common.network.HollowPacket +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.HollowPacketHandler import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f @@ -15,13 +15,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 +36,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 +45,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/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 90% 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..a1a3d2c95 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,14 @@ -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.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 import ru.hollowhorizon.hollowengine.common.npcs.navigation.Facing @@ -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/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/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..39d57d760 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt @@ -0,0 +1,305 @@ +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 +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 +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.npcs.registry.ref +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. + */ +@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) + +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. + */ +@ActionAlias +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. + */ +@ActionAlias +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. + */ +@ActionAlias +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 + ) + } + } +} + +@ActionAlias +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) + } + } +} + +@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) +} + +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() + 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 + 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.place(Vec3(x, y, z), rotation.body, rotation.pitch) + 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..151fd710c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -0,0 +1,237 @@ +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 + +/** + * A reversible action. Its body is `action { }`: within a story every call is written to the journal, which can undo + * 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 + * 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 + +/** + * 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) +annotation class SimpleAction(val id: String = "") + +@DslMarker +annotation class StoryDsl + +/** How long an action stands in the world, see [temporary]. */ +enum class Lifetime { + /** 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, +} + +/** + * 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 -> + 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, site) + } + return try { + ActionScope(record, null).block().value + } 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 + } +} + +/** + * 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 { entity -> + val reason = entity.removalReason + if (reason != null) !reason.shouldDestroy() else StoryValues.entities(entity.uuid, entity::class) !== entity + } +}?.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. + */ +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 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(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 +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; without it the action is performed 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; [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, + private val args: CompoundTag, + 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).also { site?.action = it.seq } + + fun done(entry: JournalEntry) { + entry.done = true + } +} + +/** Marks actions performed inside [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 fast-forward 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/StageLambda.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt new file mode 100644 index 000000000..5e4c8e7b8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt @@ -0,0 +1,130 @@ +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]. 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 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, + 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) + + /** 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, + val arity: Int, + ) + + 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, 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 + + 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 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 new file mode 100644 index 000000000..baaa42798 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -0,0 +1,61 @@ +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. [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) +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) + +/** 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) +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/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt new file mode 100644 index 000000000..75c92b3d9 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -0,0 +1,378 @@ +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 +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 replay = try { + StoryValues.withOffWorldNpcs { replay(entry) } + } catch (missing: StoryEntityMissing) { + StoryDeferredReplays.undo(entry, missing.uuid) + return + } + val undo = replay?.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 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() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Story '{}' failed to fast-forward '{}'", entry.run, entry.action, failure) + } + } + + /** + * 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. + */ + 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) { + throw missing + } 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 + + /** 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") + 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 new file mode 100644 index 000000000..7ddf095c3 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -0,0 +1,187 @@ +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 +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) + +/** + * 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 + +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 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) + 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 + +/** + * 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\")") + 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. 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?, +) { + val key: String get() = run.path + + val isActive: Boolean get() = !run.state.done && !run.isStopped + + /** 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() + } + + /** 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]. */ + fun rewind(checkpoint: String? = null) = run.rewind(checkpoint) + + + /** 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]. */ +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<*> { + 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 } } + try { + select { waits.forEach { wait -> wait.onAwait { it } } } + } finally { + waits.forEach { it.cancel() } + } +} + +/** 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> { + 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) { + val winner = awaitFirst(remaining) + ended += winner + remaining.remove(winner) + } + remaining.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) + } + + 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?.let { StoryInstances.scriptPathOf(it) ?: it.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/StoryBoxes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt new file mode 100644 index 000000000..40e0acc56 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt @@ -0,0 +1,100 @@ +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, var value: T) + +/** 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 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, 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, 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 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/StoryCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt new file mode 100644 index 000000000..c8a563e10 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt @@ -0,0 +1,149 @@ +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 and rewinding + * 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("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_latest".mcTranslate(key) + else "hollowengine.commands.story.rewound".mcTranslate(key, checkpoint) + context.source.sendSuccess({ message }, 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/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 new file mode 100644 index 000000000..10e597307 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -0,0 +1,402 @@ +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() + 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 + + /** 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 + 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 + + /** + * 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 + + /** 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) + } + + /** + * 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") + + /** 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. 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 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 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 + run.enterTrampolined = true + } + + /** 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] + previous = null + result = run.lastResult + run.lastResult = null + segment = CompoundTag() + segmentMark = run.journal.next + 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 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) + } + } + + /** + * 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()) + + /** 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.journal.checkpointPassed(run.path, sites[site]) + run.history.add(sites[site], run, labels) + } + + /** 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) + } + + /** 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() + if (reason != null) run.frames.lastOrNull()?.callFailed() + } + + /** + * 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() + run.journal.undoTemporary(run.path, entryMark) + segmentMark = entryMark + run.discardPending(reason) + } + + 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) { + 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 + boxOrdinals.clear() + 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 + } + + /** 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() + 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)) + 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) } + previous?.let { put("previous", it) } + action?.let { putLong("action", it) } + 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() + + 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) = + 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").copy() + 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") + tag.getList("boxes", Tag.TAG_STRING.toInt()).forEach { boxes += it.asString } + 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/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 new file mode 100644 index 000000000..2843f0ffb --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt @@ -0,0 +1,123 @@ +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. + * + * 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. + */ +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.copy()) + 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").copy(), + tag.getList("labels", Tag.TAG_COMPOUND.toInt()).map { JournalEntry.load(it as CompoundTag) }, + ) + } + } + + private val checkpoints = ArrayList() + + val all: List get() = checkpoints + + 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 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'") + root.journal.rewind(target.mark) + target.labels.asReversed().forEach(StoryActions::undo) + checkpoints.subList(index + 1, checkpoints.size).clear() + root.restoreTree(target.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)) + } + + /** 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()) } }) + } + + fun load(tag: CompoundTag) { + checkpoints.clear() + tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()) + .forEach { checkpoints += Checkpoint.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 + } +} + +/** + * 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, null, 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 new file mode 100644 index 000000000..5afef2621 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -0,0 +1,217 @@ +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 +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 +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 users = LinkedHashSet() + val lifetime = Job() + } + + private val byKey = HashMap() + private val keys = IdentityHashMap() + 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>() + + /** + * 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) { + checkStoryClasses(instance.javaClass) + val job = scope.coroutineContext.job + val added = LinkedHashMap() + register(instance, job, added) + 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; [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]?.takeIf { it.users.isNotEmpty() }?.lifetime + + /** 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? = + 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)) + + /** 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 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 + + /** 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 + 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 = pick(stagesOf(instance.javaClass))[stage] ?: throw StoryException("Script '$key' has no stage '$stage'") + return suspendCoroutineUninterceptedOrReturn { continuation -> + try { + method.invoke(instance, *arguments, continuation) + } catch (failure: InvocationTargetException) { + throw failure.targetException + } + } + } + + /** 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: MutableMap) { + if (!instance.javaClass.isAnnotationPresent(StageScript::class.java)) return + val key = keyFor(instance) + val entry = byKey[key]?.takeIf { it.instance === instance } ?: Entry(instance).also { byKey[key] = it } + if (!entry.users.add(job)) return + keys[instance] = key + added[key] = entry + StoryActions.register(instance.javaClass, namespaceOf(instance), job) + importsOf(instance).forEach { register(it, job, added) } + } + + 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() + } + + /** + * 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 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" + is NodeHost.OfEntity -> "$path@${host.entity.uuid}" + } + } + + /** + * 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() + 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 {} {}, so stories cannot see its stages and actions. " + + "List 'hollowengine-narrative' in dependsOn of the project the script belongs to", + type.name, + problem, + ) + } + + /** 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 } + .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 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/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..b0e653689 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -0,0 +1,156 @@ +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 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( + val seq: Long, + 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 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 + + fun save(): CompoundTag = CompoundTag().apply { + putLong("seq", seq) + 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) } + 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.getString("until").takeIf { tag.contains("until") }, + tag.getCompound("args").copy(), + tag.getCompound("snapshots").copy(), + 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, + until: String?, + args: CompoundTag, + snapshots: CompoundTag, + outer: Long?, + ) = JournalEntry(next++, action, run, lifetime, until, args, snapshots, outer).also { entries += it } + + /** + * 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) = 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++ + } + } + + /** + * 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 } + } + + /** 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 + } + } + + 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 } + + fun save(): CompoundTag = CompoundTag().apply { + putLong("next", next) + put("entries", 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/StoryLoops.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt new file mode 100644 index 000000000..1ead24a87 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt @@ -0,0 +1,40 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +/** + * 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. */ + @JvmStatic + 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 + 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 new file mode 100644 index 000000000..657c29ffe --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -0,0 +1,168 @@ +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 +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.ScriptFailedEvent +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 java.nio.file.Path + +/** + * 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 = HashMap() + private val entities = HashMap() + private val nodes = HashMap() + + 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[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) { + 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? = runs.save().takeIf { !it.isEmpty } + + override fun load(tag: Tag) { + (tag as? CompoundTag)?.let(runs::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 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) + } + + @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() + StoryDeferredReplays.clear() + } + + @SubscribeEvent + fun onEntityLoaded(event: EntityLoadedEvent) { + 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) + } + + /** 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 + * respawns as well. + */ + @SubscribeEvent + fun onEntityRemoved(event: EntityEvent.Removed) { + 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) { + 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") + + private class ServerOwner(override val server: MinecraftServer) : StoryOwner { + override val scope: CoroutineScope get() = server.runtimeContext.scope + override val entity: Entity? get() = null + } + + 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") + } + + 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 + } + + 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..05c77c67a --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -0,0 +1,386 @@ +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.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 + +/** + * 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 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 + private var moving = false + 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 + + /** 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 + + /** 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 + + 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 + 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 + 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() } + } + + 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() + 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) { + 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) + 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 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 { 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 + frame.children += name + history.add(name, child, emptyList()) + child.start() + return child + } + + internal fun closeChild(name: String) { + 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) { + stopping = true + 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) + } + + /** 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 } + + /** 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 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 && !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. */ + 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) + } + + /** 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 + restart.start() + } + } + } + + private fun pause() { + status.value = Status.SLEEPING + pending.addAll(0, frames) + frames.clear() + tail = null + } + + private fun finish(status: Status, result: Tag?, failure: String?) { + this.result = result + this.failure = failure + frames.clear() + pending.clear() + journal.close(path) + this.status.value = status + if (parent == null) runs.ended(this) + } + + 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() + } + + 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 } + + /** + * 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) + 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()) } }) + 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) } + } + + 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") + 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") } + } + + /** 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() + + /** 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) + 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 new file mode 100644 index 000000000..8e78afdc8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -0,0 +1,150 @@ +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 ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +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 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() + 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. 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 + 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() + } + } + + /** Wakes every sleeping run. */ + fun wake() = runs.values.toList().forEach(StoryRun::start) + + 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) + } + + 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) + run.history.add(key, run, emptyList()) + runs[key] = run + 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 + } + + /** 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) + } + + 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..9cfa8821d --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt @@ -0,0 +1,125 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.delay +import kotlinx.serialization.KSerializer +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 + +/** + * 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 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.run.records.getCompound(ONCE) + val key = frame.recordKey(name) + if (done.contains(key)) return + block() + done.putBoolean(key, true) + frame.run.records.put(ONCE, done) +} + +/** 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) + +@PublishedApi +@Suppress("UNCHECKED_CAST") +internal suspend fun remember(name: String, serializer: KSerializer, block: suspend () -> T): T { + val frame = currentFrame("remember(\"$name\")") + 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(values, key, serializer, value) + frame.run.records.put(REMEMBERED, values) + return value +} + +/** 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 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 + return existed +} + +/** 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 REMEMBERED = "remembered" +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/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/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..d44eb1ae4 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -0,0 +1,168 @@ +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.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 + + 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 = + 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 == 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]) + 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 { + 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]; 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 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) + } + } + + @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) + + /** 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 + return server.findEntity(uuid) ?: server.npcs.resolve(uuid)?.takeIf { offWorld.get() } + } + + 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 + } + } + + /** 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) + + 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/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..9cf570520 --- /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, 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 new file mode 100644 index 000000000..7cd6f6791 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/en_us.json @@ -0,0 +1,39 @@ +{ + "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_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 ▶ ]", + "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 new file mode 100644 index 000000000..9b13e432d --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/lang/ru_ru.json @@ -0,0 +1,39 @@ +{ + "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_latest": "История %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": "Загрузка катсцены", + "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/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/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/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/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/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/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt new file mode 100644 index 000000000..645e8e2a4 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -0,0 +1,116 @@ +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.nbt.ListTag +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 + } + + 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", 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", 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")) + } + + @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..11c0a7efb --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt @@ -0,0 +1,99 @@ +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 + } + 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/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..3c6e1b3dc --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt @@ -0,0 +1,87 @@ +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 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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +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 = 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) + } + + @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")) + } + + @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/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/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") { 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 d9590bca1..63e74b9c8 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") @@ -115,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 @@ -225,6 +226,7 @@ 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}") } } } @@ -282,9 +284,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/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..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 @@ -67,6 +68,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 +89,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 +569,6 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onClientRenderTickPre(client: Minecraft) { - CutsceneCameraSystem.update(client) RenderTickEvent.Pre.post(RenderTickEvent.Pre(client)) } @@ -611,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) } @@ -781,7 +782,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 +796,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 +822,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/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/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/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/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/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/HollowIdeEditorAdapters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt index 53ddeca84..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 @@ -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) @@ -637,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/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/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/HollowIdeGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt index cdcb19c6d..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,6 +2,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +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 +23,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, + "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(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/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..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 @@ -45,6 +46,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. */ @@ -72,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 @@ -158,9 +161,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 +570,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 +768,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 +838,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() @@ -1091,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) @@ -1196,6 +1209,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/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/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 + } +} 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/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/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/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/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/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/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/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/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/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/ui/HollowTimelineChrome.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt index 572207c4a..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 @@ -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), ), ) } @@ -436,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") } @@ -453,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()) { @@ -460,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() @@ -507,7 +513,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 +575,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 +589,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 +598,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,8 +642,8 @@ 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) { - controller.select(keys, additive = false) + add(UiDropdownItem(TimelineLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { + controller.selectTrackKeys(row.curves) refresh() }) @@ -646,7 +652,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 +667,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 +680,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..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 } @@ -278,7 +282,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/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/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/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/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/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/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/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/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/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/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/HollowAddonManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt index 0fa1d6a2f..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 @@ -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 @@ -19,12 +20,13 @@ 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 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 +37,8 @@ object HollowAddonManager : AutoCloseable { HollowAddonRuntime( sources = sources, cacheDirectory = cacheDirectory, + addonsDirectory = addonsDirectory, + modsDirectory = modsDirectory, ).also { runtime = it } } runCatching { runBlocking { created.start() } } @@ -47,10 +51,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 cc571b2c3..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 @@ -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")) @@ -58,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 @@ -198,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 } @@ -501,6 +511,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( @@ -529,9 +542,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, @@ -548,7 +562,6 @@ internal class HollowAddonRuntime( fingerprint = descriptor.version, storageKey = candidate.fingerprint, ) - if (source.list().isEmpty()) return ScriptSourceLifecycle.install() ScriptRegistry.register(source) } @@ -616,11 +629,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( @@ -640,8 +655,13 @@ internal class HollowAddonRuntime( block() } finally { refreshEnabledSnapshot() + statusSnapshot = currentStatuses() 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..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 @@ -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 @@ -51,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 @@ -286,27 +283,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() { @@ -339,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 } @@ -503,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/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/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/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/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/npcs/navigation/NpcMovement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt index ed28e83a6..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,14 +70,18 @@ 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. 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) /** * 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 = forceLoad { npcNavigation.settingsOverride = options.navigation?.let { tune -> (navigationComponent ?: NavigationComponent()).tune() } npcNavigation.facing.setMove(options.facing) npcNavigation.moveZones = options.avoid @@ -85,10 +90,10 @@ internal suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: 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 @@ -207,7 +212,11 @@ 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 = + 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 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/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 0760de95e..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 @@ -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), @@ -150,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", @@ -201,7 +192,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/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/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/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/cache/ScriptFingerprint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt index 2964e60bc..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 = 3 + 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" 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..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 @@ -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,106 @@ 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 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) { + 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 +165,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 +188,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/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/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 23dd8d04a..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 @@ -16,16 +16,31 @@ 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), + ACTION_CALL(false, "action"), + REWIND(true), + REWIND_START(false, "rewind"), + TEMPORARY(true), + TEMPORARY_OPEN(false, "temporary"), } 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. [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: Int? = null) enum class ScriptRunKind { PLAIN, NODE } 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/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt index 02030c331..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 @@ -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,55 @@ 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 + ) + 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, executor, context) - context?.let { executor.start(it) } + nodes[canonicalPath] = RunningNode(script) return true } @@ -188,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 -> @@ -203,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/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/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..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,11 +5,11 @@ 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 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 +62,27 @@ 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() + } + + /** + * 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() } @@ -145,12 +153,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 +176,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 +184,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) } @@ -231,22 +241,26 @@ 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 { - val executor = StateExecutor(nodeScope) - script.prepareExecutor(executor) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, nodeScope, entry)) + + val sections = saved.getCompound(SECTIONS) + script.sections.forEach { (key, section) -> sections.get(key)?.let(section::load) } - tag?.let { extras -> - val context = SerializationContext(host.server, extras) - script.onLoadHandlers.forEach { it(context) } + 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() + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.getOrNull() } @@ -285,33 +299,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 + ) ?: return@startAfterStop runtimeContext.nodes.discard(path) - 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..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,8 @@ 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 import kotlin.script.experimental.api.constructorArgs @@ -51,9 +53,11 @@ 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) + if (side == LogicalSide.SERVER) ScriptFailedEvent.post(ScriptFailedEvent(id)) } } } 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) 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/scripting/source/ScriptRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt
index e41bfda26..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
@@ -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
 
@@ -114,11 +115,18 @@ 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]
 
+    /** 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.
@@ -133,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)
             }
         }
     }
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..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,8 @@ 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
 import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports
@@ -45,8 +47,10 @@ 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)
+        ScriptFailedEvent.post(ScriptFailedEvent(id))
     }.isSuccess
 
     private fun orderedScripts(): List {
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
deleted file mode 100644
index bebe8d7e2..000000000
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt
+++ /dev/null
@@ -1,473 +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.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
-
-/**
- * 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)
-}
-
-/** 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)
-
-/**
- * Создает 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
+        )
+    }
+}
+
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/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/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/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/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/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/ide/gutter.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json
index 824985a8d..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,6 +4,14 @@
   "override": "Go to overridden declaration",
   "coroutine": "Suspend function call",
   "event": "Event handler",
+  "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/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/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/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/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/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/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/ide/gutter.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json
index 4d26aa641..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,6 +4,14 @@
   "override": "Перейти к переопределённой декларации",
   "coroutine": "Вызов suspend-функции",
   "event": "Обработчик события",
+  "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": "Остановить скрипт",
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/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/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/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..2058a6164
--- /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/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/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/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 @@
+
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/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;
+}
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/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":[
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/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,
+    )
+}
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/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)
+    }
+}
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/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/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]
+    }
+}
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 ->
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()
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