diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index ed635dcdf..000000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,145 +0,0 @@
-name: JVM tests
-
-on:
- push:
- branches: [ "**" ]
- pull_request:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 60
- if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[no ci]')
- outputs:
- summary: ${{ steps.summary.outputs.summary }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
- with:
- lfs: true
- submodules: recursive
-
- - name: Set up JDK 21
- uses: actions/setup-java@v5
- with:
- distribution: temurin
- java-version: '21'
-
- - name: Set up Gradle
- uses: gradle/actions/setup-gradle@v6
-
- - name: Make Gradle wrapper executable
- run: chmod +x ./gradlew
-
- - name: Run JVM tests
- id: tests
- run: ./gradlew test --continue --no-daemon --console=plain
-
- - name: Summarize test results
- id: summary
- if: ${{ !cancelled() }}
- shell: python
- env:
- TEST_OUTCOME: ${{ steps.tests.outcome }}
- run: |
- import os
- from pathlib import Path
- import xml.etree.ElementTree as ET
-
- reports = list(Path("build").glob("**/test-results/**/TEST-*.xml"))
- totals = dict.fromkeys(("tests", "failures", "errors", "skipped"), 0)
- for report in reports:
- suite = ET.parse(report).getroot()
- for key in totals:
- totals[key] += int(suite.get(key, 0))
-
- failed = totals["failures"] + totals["errors"]
- passed = totals["tests"] - failed - totals["skipped"]
- summary = (
- f"{totals['tests']} tests: {passed} passed, "
- f"{failed} failed, {totals['skipped']} skipped."
- if reports else "No JUnit XML reports were produced."
- )
- with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
- output.write(f"summary={summary}\n")
- with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output:
- output.write(f"## JVM test results\n\n{summary}\n")
- if totals["tests"] == 0 and os.environ["TEST_OUTCOME"] == "success":
- raise SystemExit("Gradle succeeded without producing any test results.")
-
- - name: Upload test reports
- if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v4
- with:
- name: test-reports
- path: |
- build/**/test-results/**/TEST-*.xml
- build/**/reports/tests/**
- if-no-files-found: warn
-
- notify:
- needs: test
- if: ${{ always() && needs.test.result != 'skipped' && github.event_name != 'pull_request' }}
- runs-on: ubuntu-latest
- permissions: {}
- steps:
- - name: Notify Telegram about test results
- shell: python
- env:
- TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
- TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
- TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }}
- TEST_RESULT: ${{ needs.test.result }}
- TEST_SUMMARY: ${{ needs.test.outputs.summary }}
- run: |
- import json
- import os
- from html import escape
- from urllib.error import HTTPError, URLError
- from urllib.request import Request, urlopen
-
- token = os.environ["TG_BOT_TOKEN"]
- chat_id = os.environ["TG_CHAT_ID"]
- if not token or not chat_id:
- print("::notice::Telegram notification skipped: bot token or chat ID is not configured.")
- raise SystemExit(0)
-
- result = os.environ["TEST_RESULT"]
- repository = os.environ["GITHUB_REPOSITORY"]
- branch = os.environ["GITHUB_REF_NAME"]
- revision = os.environ["GITHUB_SHA"][:7]
- run_url = (
- f"{os.environ['GITHUB_SERVER_URL']}/{repository}"
- f"/actions/runs/{os.environ['GITHUB_RUN_ID']}"
- )
- summary = os.environ["TEST_SUMMARY"] or "Test execution did not produce a summary."
- text = (
- f"
π§ͺ JVM tests: {escape(result.upper())}\n"
- f"{escape(repository)}:{escape(branch)}\n"
- f"Commit: {escape(revision)}\n"
- f"{escape(summary)}\n"
- f'View run and test reports
'
- )
- payload = {"chat_id": chat_id, "parse_mode": "HTML", "text": text,
- "disable_web_page_preview": True}
- if os.environ["TG_THREAD_ID"]:
- payload["message_thread_id"] = int(os.environ["TG_THREAD_ID"])
- request = Request(
- f"https://api.telegram.org/bot{token}/sendMessage",
- data=json.dumps(payload).encode("utf-8"),
- headers={"Content-Type": "application/json"},
- method="POST",
- )
- try:
- with urlopen(request, timeout=30) as response:
- result = json.load(response)
- except HTTPError as error:
- raise SystemExit(f"Telegram notification failed: HTTP {error.code}.") from None
- except URLError:
- raise SystemExit("Telegram notification failed: network error.") from None
- if not result.get("ok"):
- raise SystemExit("Telegram rejected the notification: " + result.get("description", "unknown error"))
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/KotlinSnippets.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/KotlinSnippets.kt
new file mode 100644
index 000000000..3fbc2f48f
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/KotlinSnippets.kt
@@ -0,0 +1,93 @@
+package ru.hollowhorizon.hollowengine.common.ide.session
+
+import com.intellij.psi.PsiErrorElement
+import com.intellij.psi.impl.source.tree.LeafPsiElement
+import com.intellij.psi.util.PsiTreeUtil
+import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource
+import org.jetbrains.kotlin.analysis.api.types.KaErrorType
+import org.jetbrains.kotlin.lexer.KtTokens
+import org.jetbrains.kotlin.psi.*
+import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
+import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector
+import org.jetbrains.kotlin.types.Variance
+import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinName
+import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinPropertyTypes
+import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinSyntax
+import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinToken
+import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType
+
+private const val PREFIX = "fun snippet() {\n"
+private const val SUFFIX = "\n}"
+
+/** Parses [text] as the body of a function, which takes an expression and statements alike. */
+internal fun kotlinSyntax(factory: KtPsiFactory, text: String): KotlinSyntax {
+ val file = factory.createFile("snippet.kt", PREFIX + text + SUFFIX)
+ val body = (file.declarations.singleOrNull() as? KtNamedFunction)?.bodyBlockExpression
+ ?: return KotlinSyntax(valid = false)
+ val valid = PsiTreeUtil.findChildOfType(file, PsiErrorElement::class.java) == null
+ return KotlinSyntax(valid, arguments(body, text), names(body), tokens(body))
+}
+
+/** The properties of [file] named from [values] and from [places], and which of the latter can hold each of the former. */
+@OptIn(KaExperimentalApi::class)
+internal fun propertyTypes(file: KtFile, values: String, places: String?): KotlinPropertyTypes = analyze(file) {
+ fun named(prefix: String?) = file.collectDescendantsOfType { prefix != null && it.name?.startsWith(prefix) == true }
+ .mapNotNull { property -> property.symbol.returnType.takeUnless { it is KaErrorType }?.let { property.name!! to it } }
+ .toMap()
+ val found = named(values)
+ val wanted = named(places)
+ KotlinPropertyTypes(
+ types = (found + wanted).mapValues { (_, type) ->
+ type.render(KaTypeRendererForSource.WITH_QUALIFIED_NAMES, Variance.INVARIANT)
+ },
+ fits = found.mapValues { (_, type) ->
+ val value = type.withNullability(false)
+ wanted.filterValues { value.isSubtypeOf(it) }.keys
+ },
+ )
+}
+
+/** The leaves of [body] that have a color of their own. */
+private fun tokens(body: KtBlockExpression): List = body.collectDescendantsOfType().mapNotNull { leaf ->
+ val type = when (leaf.elementType) {
+ in KtTokens.KEYWORDS -> TokenType.KEYWORD
+ in KtTokens.SOFT_KEYWORDS -> if (leaf.parent is KtModifierList) TokenType.KEYWORD else return@mapNotNull null
+ in KtTokens.STRINGS, KtTokens.ESCAPE_SEQUENCE -> TokenType.STRING
+ KtTokens.INTEGER_LITERAL, KtTokens.FLOAT_LITERAL -> TokenType.NUMERIC_LITERAL
+ in KtTokens.COMMENTS -> TokenType.COMMENT
+ else -> return@mapNotNull null
+ }
+ val start = leaf.textRange.startOffset - PREFIX.length
+ KotlinToken(start, start + leaf.textLength, type)
+}
+
+/** The text between the commas of the call [body] consists of, commas of what it holds aside. */
+private fun arguments(body: KtBlockExpression, text: String): List? {
+ val list = (body.statements.singleOrNull() as? KtCallExpression)?.valueArgumentList ?: return null
+ if (list.rightParenthesis?.textRange?.endOffset != PREFIX.length + text.length) return null
+ return split(list, text)
+}
+
+private fun split(list: KtValueArgumentList, text: String): List {
+ val commas =
+ list.node.getChildren(null).filter { it.elementType == KtTokens.COMMA }.map { it.startOffset - PREFIX.length }
+ val open = list.leftParenthesis!!.textRange.endOffset - PREFIX.length
+ val close = list.rightParenthesis!!.textRange.startOffset - PREFIX.length
+ val starts = listOf(open) + commas.map { it + 1 }
+ return starts.zip(commas + close) { start, end -> text.substring(start, end).trim() }
+}
+
+/** The names that mean something declared outside of [body]: its own locals and members of other things do not. */
+private fun names(body: KtBlockExpression): List {
+ val local = PsiTreeUtil.collectElementsOfType(body, KtDeclaration::class.java)
+ .filter { it is KtProperty || it is KtParameter }.mapNotNullTo(HashSet()) { it.name }
+ return PsiTreeUtil.collectElementsOfType(body, KtNameReferenceExpression::class.java).filter { name ->
+ val parent = name.parent
+ name.getReferencedName() !in local && name.getQualifiedExpressionForSelector() == null && parent !is KtValueArgumentName && parent !is KtUserType && (parent as? KtCallExpression)?.calleeExpression != name
+ }.map { name ->
+ val start = name.textRange.startOffset - PREFIX.length
+ KotlinName(name.getReferencedName(), start, start + name.textLength)
+ }.sortedBy { it.start }
+}
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..c3dc0f884 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,10 @@ 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.files.DirectoryManager.fromReadablePath
+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
@@ -27,10 +30,12 @@ import ru.hollowhorizon.hollowengine.common.ide.session.insight.findSignatureHel
import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaKotlinSourceModule
import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaRekotLibraryModule
import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaScriptModule
+import ru.hollowhorizon.hollowengine.common.ide.session.rename.SymbolRename
import ru.hollowhorizon.hollowengine.common.ide.session.structure.editorStructure
import ru.hollowhorizon.hollowengine.common.ide.session.structure.kotlinGutterTargets
import ru.hollowhorizon.hollowengine.common.ide.session.symbols.searchSymbols
import ru.hollowhorizon.hollowengine.common.ide.session.symbols.symbolSource
+import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptImportError
import ru.hollowhorizon.hollowengine.common.scripting.ide.*
import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource
import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports
@@ -52,6 +57,9 @@ class ScriptingAnalyzerImpl internal constructor(
get() = kotlinCoreProjectEnvironment.project
private val factory = KtPsiFactory(project, eventSystemEnabled = true)
+ /** Parses for fields as they are typed: on files of its own, so it never waits for analysis to finish. */
+ private val snippetFactory = KtPsiFactory(project, eventSystemEnabled = false)
+
internal val fileCache = object : LinkedHashMap(5, 0.75f, true) {
override fun removeEldestEntry(entry: MutableMap.MutableEntry?): Boolean {
val shouldRemove = size > 5
@@ -124,8 +132,7 @@ class ScriptingAnalyzerImpl internal constructor(
updateLibrarySourcesModule()
} else {
projectStructureProvider.setModule(
- file,
- KaKotlinSourceModule(project, listOf(file), sourceDependencies, classpath)
+ file, KaKotlinSourceModule(project, listOf(file), sourceDependencies, classpath)
)
}
return file
@@ -231,7 +238,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 +265,8 @@ 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 +315,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,7 +331,70 @@ 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 kotlinSyntax(text: String): KotlinSyntax = kotlinSyntax(snippetFactory, text.withUnixLineSeparators())
+
+ override fun propertyTypes(name: String, text: String, values: String, places: String?): KotlinPropertyTypes =
+ synchronized(lock) { propertyTypes(getOrCreateFile(name, text), values, places) }
+
+ override fun forget(name: String) = synchronized(lock) {
+ fileCache.remove(name)?.let { cleanupFiles(it.relatedFiles) }
+ Unit
+ }
+
+ override fun renameTarget(name: String, text: String, offset: Int): RenameTarget? = synchronized(lock) {
+ SymbolRename.target(getOrCreateFile(name, text), offset) { it == name || isProjectScript(it) }?.target
+ }
+
+ override fun rename(
+ name: String,
+ text: String,
+ offset: Int,
+ newName: String,
+ documents: Map,
+ ): List = synchronized(lock) {
+ if (!SymbolRename.accepts(newName)) return emptyList()
+ val renamed = SymbolRename.target(getOrCreateFile(name, text), offset) { it == name || isProjectScript(it) }
+ ?: return emptyList()
+ if (renamed.name == newName) return emptyList()
+ val texts = LinkedHashMap()
+ texts[name] = text
+ if (renamed.key.project) {
+ projectScripts().forEach { (path, file) ->
+ texts.getOrPut(path) {
+ documents[path] ?: file.readTextOrNull() ?: return@forEach
+ }
+ }
+ } else if (renamed.key.path != name) {
+ val declaring = renamed.key.path
+ documents[declaring]?.let { texts[declaring] = it } ?: declaring.fromReadablePath().readTextOrNull()
+ ?.let { texts[declaring] = it }
+ }
+ val word = Regex("(?
+ val normalized = source.withUnixLineSeparators()
+ if (!word.containsMatchIn(normalized)) return@mapNotNull null
+ if (!renamed.key.project && path != renamed.key.path) return@mapNotNull null
+ val edits = SymbolRename.edits(getOrCreateFile(path, normalized), renamed.key, renamed.name, newName)
+ edits.takeIf { it.isNotEmpty() }?.let { FileRename(path, normalized, it) }
+ }
+ }
+
+ /** Whether the script the editor opens as [path] is a file of the project, not one shipped by an addon. */
+ private fun isProjectScript(path: String): Boolean {
+ val file = runCatching { path.fromReadablePath() }.getOrNull() ?: return false
+ return file.isFile && projectScripts().any { it.second == file.absoluteFile.normalize() }
+ }
+
+ /** The scripts of the project, by the path the editor opens them with. */
+ private fun projectScripts(): List> =
+ ScriptRegistry.listIn(ScriptRegistry.sandboxNamespace, ".kts").mapNotNull { id ->
+ ScriptRegistry.artifacts(id)?.sourceFile?.absoluteFile?.normalize()?.let { editorPathOf(it) to it }
}
override fun signatureHelp(name: String, text: String, offset: Int): SignatureHelp? = synchronized(lock) {
@@ -356,6 +435,12 @@ private fun removeFromPsiManager(file: KtFile) {
fileManager.setViewProvider(file.virtualFile, null)
}
+private fun File.readTextOrNull(): String? = try {
+ readText()
+} catch (_: IOException) {
+ null
+}
+
private fun String.withUnixLineSeparators(): String = replace("\r\n", "\n").replace('\r', '\n')
val KtAnnotationEntry.typeName: String? get() = (typeReference?.typeElement as? KtUserType)?.referencedName
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..6051816d5 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
@@ -15,6 +15,7 @@ import org.jetbrains.kotlin.analysis.api.scopes.KaTypeScope
import org.jetbrains.kotlin.analysis.api.signatures.KaCallableSignature
import org.jetbrains.kotlin.analysis.api.symbols.*
import org.jetbrains.kotlin.analysis.api.symbols.markers.KaDeclarationContainerSymbol
+import org.jetbrains.kotlin.analysis.api.types.KaErrorType
import org.jetbrains.kotlin.analysis.api.types.KaType
import org.jetbrains.kotlin.idea.references.mainReference
import org.jetbrains.kotlin.lexer.KtTokens
@@ -56,7 +57,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(
@@ -337,30 +338,30 @@ private fun CompletionItemsCollector.completeAfterDot(
is KaPackageSymbol -> {
phase(CompletionCloseness.SCOPE)
completePackage(file, receiverTarget.fqName, nameFilter, onlyPackages)
+ return@with
}
- else -> {
- val receiverType = position.receiver.expressionType
-
- if (receiverType == null || receiverType is org.jetbrains.kotlin.analysis.api.types.KaErrorType) return@with
+ else -> Unit
+ }
- phase(CompletionCloseness.MEMBER)
- receiverType.scope?.let { completeTypeScope(it) }
+ val receiverType = position.receiver.expressionType
+ if (receiverType == null || receiverType is KaErrorType) return@with
- if (position.prefix != null) {
- phase(CompletionCloseness.SCOPE)
- val scopeContext = file.scopeContext(element)
- for (scope in scopeContext.scopes) {
- add(scope.scope.callables(nameFilter).filterShadowedJavaFields().filter { it.isExtension })
- }
- }
+ phase(CompletionCloseness.MEMBER)
+ receiverType.scope?.let { completeTypeScope(it) }
- phase(CompletionCloseness.IMPORTED)
- add(getKotlinDeclarationsFromIndex(file, nameFilter).filter { symbol ->
- symbol is KaCallableSymbol && symbol.isExtension
- }) { withImport() }
+ if (position.prefix != null) {
+ phase(CompletionCloseness.SCOPE)
+ val scopeContext = file.scopeContext(element)
+ for (scope in scopeContext.scopes) {
+ add(scope.scope.callables(nameFilter).filterShadowedJavaFields().filter { it.isExtension })
}
}
+
+ phase(CompletionCloseness.IMPORTED)
+ add(getKotlinDeclarationsFromIndex(file, nameFilter).filter { symbol ->
+ symbol is KaCallableSymbol && symbol.isExtension
+ }) { withImport() }
}
context(kaSession: KaSession)
@@ -550,7 +551,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 +700,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/diagnostic/AnimationNameDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/AnimationNameDiagnostics.kt
new file mode 100644
index 000000000..e64f54830
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/AnimationNameDiagnostics.kt
@@ -0,0 +1,88 @@
+package ru.hollowhorizon.hollowengine.common.ide.session.diagnostic
+
+import com.intellij.psi.util.PsiTreeUtil
+import org.jetbrains.kotlin.analysis.api.KaSession
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall
+import org.jetbrains.kotlin.analysis.api.resolution.KaSuccessCallInfo
+import org.jetbrains.kotlin.idea.references.mainReference
+import org.jetbrains.kotlin.name.ClassId
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.*
+import ru.hollowhorizon.hollowengine.common.scripting.ide.AnimationClips
+import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic
+import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity
+import ru.hollowhorizon.hollowengine.common.utils.Spelling
+
+private const val EDITOR = "ru.hollowhorizon.hollowengine.common.attachments.editor"
+private val AnimationClassId = ClassId.topLevel(FqName("$EDITOR.EditorAnimation"))
+private val ModelClassId = ClassId.topLevel(FqName("$EDITOR.EditorModel"))
+private val NpcIdClassId = ClassId.topLevel(FqName("$EDITOR.EditorNpcId"))
+
+internal fun animationNameDiagnostics(file: KtFile, clips: AnimationClips): List {
+ val calls = PsiTreeUtil.collectElementsOfType(file, KtCallExpression::class.java)
+ if (calls.isEmpty()) return emptyList()
+ val document = file.fileDocument
+ val diagnostics = ArrayList()
+ analyze(file) {
+ for (call in calls) {
+ val argument = call.function()?.argumentWith(AnimationClassId) ?: continue
+ val clip = argument.literal() ?: continue
+ val qualified = call.parent as? KtQualifiedExpression
+ val receiver = qualified?.takeIf { it.selectorExpression == call }?.receiverExpression ?: continue
+ val names = modelOf(receiver, clips, 0)?.let(clips::of) ?: continue
+ if (clip in names) continue
+ diagnostics += Diagnostic(document.rangeOf(argument.textRange), Severity.WARNING, message(clip, names))
+ }
+ }
+ return diagnostics
+}
+
+private fun message(clip: String, names: List): String {
+ if (names.isEmpty()) return "This model has no animations"
+ val similar = Spelling.similar(clip, names)
+ val missing = "This model has no animation \"$clip\""
+ return if (similar.isEmpty()) missing else "$missing. Did you mean: ${similar.joinToString()}?"
+}
+
+context(session: KaSession)
+private fun KtCallExpression.function(): KaFunctionCall<*>? = with(session) {
+ runCatching { (resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> }.getOrNull()
+}
+
+context(session: KaSession)
+private fun KaFunctionCall<*>.argumentWith(annotation: ClassId): KtExpression? = with(session) {
+ argumentMapping.entries.firstOrNull { (_, parameter) -> annotation in parameter.symbol.annotations }?.key
+}
+
+private const val MAX_HOPS = 4
+
+context(session: KaSession)
+private fun modelOf(expression: KtExpression, clips: AnimationClips, hops: Int): String? = with(session) {
+ if (hops > MAX_HOPS) return null
+ when (val value = KtPsiUtil.safeDeparenthesize(expression)) {
+ is KtCallExpression -> made(value, clips)
+ is KtQualifiedExpression -> (value.selectorExpression as? KtCallExpression)?.let { made(it, clips) }
+ is KtNameReferenceExpression -> {
+ val property = runCatching { value.mainReference.resolveToSymbol()?.psi }.getOrNull() as? KtProperty
+ property?.takeUnless { it.isVar }?.initializer?.let { modelOf(it, clips, hops + 1) }
+ }
+
+ else -> null
+ }
+}
+
+context(session: KaSession)
+private fun made(call: KtCallExpression, clips: AnimationClips): String? {
+ val function = call.function() ?: return null
+ return function.argumentWith(ModelClassId)?.literal()?.takeIf { it.isNotEmpty() } ?: function.argumentWith(
+ NpcIdClassId
+ )?.literal()?.let(clips::modelOfNpc)
+}
+
+private fun KtExpression.literal(): String? {
+ val template = this as? KtStringTemplateExpression ?: return null
+ if (template.hasInterpolation()) return null
+ val parts = template.entries.map { entry -> (entry as? KtLiteralStringTemplateEntry)?.text ?: return null }
+ return parts.joinToString("")
+}
diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt
index 507144b9e..eb9438a0a 100644
--- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt
@@ -9,33 +9,37 @@ import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtProperty
-import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic
-import ru.hollowhorizon.hollowengine.common.scripting.ide.Position
-import ru.hollowhorizon.hollowengine.common.scripting.ide.Range
-import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity
+import ru.hollowhorizon.hollowengine.client.editor.GameAnimationClips
+import ru.hollowhorizon.hollowengine.common.scripting.ide.*
+import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient
fun diagnosticCode(file: KtFile): List {
val document = file.fileDocument
val analysisDiagnostics = analyze(file) {
file.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS).map { diagnostic ->
- val severity = when (diagnostic.severity) {
- KaSeverity.INFO -> Severity.INFO
- KaSeverity.ERROR -> Severity.ERROR
- KaSeverity.WARNING -> Severity.WARNING
- }
- val range = diagnostic.textRanges.firstOrNull() ?: diagnostic.psi.textRange
- val property = diagnostic.psi as? KtProperty
- val fixes = if (diagnostic.factoryName == "UNUSED_VARIABLE" && property != null) {
- listOfNotNull(unusedVariableFix(property))
- } else emptyList()
- Diagnostic(document.rangeOf(range), severity, diagnostic.defaultMessage, fixes)
+ val severity = when (diagnostic.severity) {
+ KaSeverity.INFO -> Severity.INFO
+ KaSeverity.ERROR -> Severity.ERROR
+ KaSeverity.WARNING -> Severity.WARNING
}
- }
- val inspections = listOf(::unusedImportDiagnostics, ::subscribeEventDiagnostics).flatMap { inspection ->
- runCatching {
- inspection(file)
- }.getOrDefault(emptyList())
+ val range = diagnostic.textRanges.firstOrNull() ?: diagnostic.psi.textRange
+ val property = diagnostic.psi as? KtProperty
+ val fixes = if (diagnostic.factoryName == "UNUSED_VARIABLE" && property != null) {
+ listOfNotNull(unusedVariableFix(property))
+ } else emptyList()
+ Diagnostic(document.rangeOf(range), severity, diagnostic.defaultMessage, fixes)
}
+ }
+ val clips = if (isPhysicalClient) GameAnimationClips else AnimationClips.None
+ val inspections = listOf(
+ ::unusedImportDiagnostics,
+ ::subscribeEventDiagnostics,
+ { it: KtFile -> animationNameDiagnostics(it, clips) },
+ ).flatMap { inspection ->
+ runCatching {
+ inspection(file)
+ }.getOrDefault(emptyList())
+ }
return (analysisDiagnostics + syntaxDiagnostics(file) + inspections).distinctBy { diagnostic ->
Triple(diagnostic.range, diagnostic.severity, diagnostic.message)
}
diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt
new file mode 100644
index 000000000..e349dea9e
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt
@@ -0,0 +1,95 @@
+package ru.hollowhorizon.hollowengine.common.ide.session.rename
+
+import com.intellij.psi.PsiElement
+import com.intellij.psi.util.PsiTreeUtil
+import org.jetbrains.kotlin.analysis.api.KaSession
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol
+import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol
+import org.jetbrains.kotlin.idea.references.mainReference
+import org.jetbrains.kotlin.lexer.KtTokens
+import org.jetbrains.kotlin.psi.*
+import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf
+import ru.hollowhorizon.hollowengine.common.scripting.ide.RenameTarget
+import ru.hollowhorizon.hollowengine.common.scripting.ide.TextEdit
+
+internal data class DeclarationKey(val path: String, val chain: String, val project: Boolean)
+
+internal data class RenamedDeclaration(val key: DeclarationKey, val name: String, val target: RenameTarget)
+
+internal object SymbolRename {
+ private val identifier = Regex("[A-Za-z_][A-Za-z0-9_]*")
+
+ fun target(file: KtFile, offset: Int, editable: (String) -> Boolean): RenamedDeclaration? {
+ val leaf = identifierAt(file, offset) ?: return null
+ val key = analyze(file) { keysOf(leaf).firstOrNull() } ?: return null
+ if (!editable(key.path)) return null
+ val name = leaf.text.removeSurrounding("`")
+ return RenamedDeclaration(key, name, RenameTarget(name, leaf.textRange.startOffset, leaf.textRange.endOffset))
+ }
+
+ fun accepts(name: String): Boolean = identifier.matches(name)
+
+ fun edits(file: KtFile, key: DeclarationKey, oldName: String, newName: String): List {
+ val written = if (newName in hardKeywords) "`$newName`" else newName
+ val edits = ArrayList()
+ analyze(file) {
+ var leaf: PsiElement? = PsiTreeUtil.firstChild(file)
+ while (leaf != null) {
+ if (leaf.node?.elementType == KtTokens.IDENTIFIER &&
+ leaf.text.removeSurrounding("`") == oldName &&
+ key in keysOf(leaf)
+ ) {
+ edits += TextEdit(leaf.textRange.startOffset, leaf.textRange.endOffset, written)
+ }
+ leaf = PsiTreeUtil.nextLeaf(leaf)
+ }
+ }
+ return edits
+ }
+
+ private fun identifierAt(file: KtFile, offset: Int): PsiElement? {
+ val safe = offset.coerceIn(0, file.textLength)
+ return listOfNotNull(
+ file.findElementAt(safe), if (safe > 0) file.findElementAt(safe - 1) else null
+ ).firstOrNull { it.node?.elementType == KtTokens.IDENTIFIER }
+ }
+
+ context(session: KaSession)
+ private fun keysOf(leaf: PsiElement): List = with(session) {
+ val parent = leaf.parent
+ val symbols: List = runCatching {
+ when (parent) {
+ is KtSimpleNameExpression -> parent.mainReference.resolveToSymbols().toList()
+ is KtNamedDeclaration if parent.nameIdentifier == leaf -> listOf(parent.symbol)
+ else -> emptyList()
+ }
+ }.getOrElse { emptyList() }
+
+ symbols.mapNotNull { symbol ->
+ val declared = if (symbol is KaConstructorSymbol) symbol.containingSymbol ?: symbol else symbol
+ (declared.psi as? KtNamedDeclaration)?.let(::keyOf)
+ }
+ }
+
+ private fun keyOf(declaration: KtNamedDeclaration): DeclarationKey? {
+ val file = declaration.containingFile as? KtFile ?: return null
+ val local = isLocal(declaration)
+ val chain = buildString {
+ declaration.parentsWithSelf.filterIsInstance().toList().asReversed().forEach {
+ append('/').append(it.name)
+ if (it is KtFunction) append('(').append(it.valueParameters.size).append(')')
+ }
+ if (local) append('@').append(declaration.textOffset)
+ }
+ val private = declaration.parentsWithSelf.filterIsInstance()
+ .any { it.hasModifier(KtTokens.PRIVATE_KEYWORD) }
+ return DeclarationKey(file.name, chain, project = !local && !private)
+ }
+
+ private fun isLocal(declaration: KtNamedDeclaration): Boolean = declaration.parentsWithSelf.drop(1).any { parent ->
+ parent is KtFunctionLiteral || parent is KtBlockExpression && parent.parent !is KtScript
+ }
+
+ private val hardKeywords = KtTokens.KEYWORDS.types.map { it.toString() }.toSet()
+}
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..79eba67b2
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt
@@ -0,0 +1,281 @@
+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.IrParameterKind
+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 ofScript = call.symbol.owner.parameters.firstOrNull()?.kind == IrParameterKind.DispatchReceiver
+ val isTail = kind == SiteKind.STAGE && ofScript && !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..70ed40fd4
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt
@@ -0,0 +1,465 @@
+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 = stages.blank(type, function.startOffset, function.endOffset)
+
+ 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..541c1d984
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt
@@ -0,0 +1,563 @@
+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 restart = site.call.arguments[1] ?: irFalse()
+ val labels = site.call.arguments.getOrNull(2)
+ +(if (labels == null) frameCall(this, "checkpoint", irInt(site.index), restart) else frameCall(
+ this,
+ "checkpoint",
+ irInt(site.index),
+ restart,
+ 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 stages.blank(type, function.startOffset, function.endOffset)
+
+ /** 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..38245eb9f
--- /dev/null
+++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt
@@ -0,0 +1,134 @@
+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.descriptors.InlineClassRepresentation
+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.expressions.impl.IrConstImpl
+import org.jetbrains.kotlin.ir.expressions.impl.IrTypeOperatorCallImpl
+import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol
+import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol
+import org.jetbrains.kotlin.ir.symbols.IrSymbol
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrType
+import org.jetbrains.kotlin.ir.types.classOrNull
+import org.jetbrains.kotlin.ir.types.getClass
+import org.jetbrains.kotlin.ir.types.isNullable
+import org.jetbrains.kotlin.ir.util.defaultType
+import org.jetbrains.kotlin.ir.util.defaultValueForType
+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,
+) {
+ /**
+ * What stands for a value of [type] that does not exist yet. A value class is its underlying value to the JVM, so
+ * null would not fit where it goes, and building one would run its checks.
+ */
+ fun blank(type: IrType, start: Int, end: Int): IrExpression {
+ val kind = type.takeUnless { it.isNullable() }?.getClass()?.valueClassRepresentation
+ val underlying = (kind as? InlineClassRepresentation)?.underlyingType
+ ?: return IrConstImpl.defaultValueForType(start, end, type)
+ return IrTypeOperatorCallImpl(start, end, type, IrTypeOperator.IMPLICIT_CAST, type, blank(underlying, start, end))
+ }
+
+ 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/AnimationNameDiagnosticsTest.kt b/addons/compiler/src/test/kotlin/AnimationNameDiagnosticsTest.kt
new file mode 100644
index 000000000..0dcbf710f
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/AnimationNameDiagnosticsTest.kt
@@ -0,0 +1,83 @@
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl
+import ru.hollowhorizon.hollowengine.common.ide.session.diagnostic.animationNameDiagnostics
+import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider
+import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings
+import ru.hollowhorizon.hollowengine.common.scripting.ide.AnimationClips
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+abstract class ClipScript
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class AnimationNameDiagnosticsTest {
+ private val fixture = AnalysisEnvironmentTestFixture {
+ ScriptingEnvironmentImpl(
+ javaHome = File(System.getProperty("java.home")),
+ classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank)
+ .map(::File).filter(File::exists).distinctBy { it.absoluteFile.normalize() },
+ scriptTypes = listOf(
+ ScriptClassProvider(
+ extension = ".clips.kts",
+ baseClass = ClipScript::class.qualifiedName!!
+ )
+ ),
+ mappings = Mappings.EMPTY,
+ )
+ }
+
+ @BeforeAll
+ fun startEnvironment() = fixture.start()
+
+ @AfterEach
+ fun resetEnvironment() = fixture.reset()
+
+ @AfterAll
+ fun closeEnvironment() = fixture.close()
+
+ private val clips = object : AnimationClips {
+ override fun of(model: String) = if (model == "hero.glb") listOf("idle", "wave", "walk") else null
+ override fun modelOfNpc(id: String) = if (id == "village:hero") "hero.glb" else null
+ }
+
+ private val api = """
+ import ru.hollowhorizon.hollowengine.common.attachments.editor.*
+
+ class Actor
+ fun spawn(@EditorModel model: String = "") = Actor()
+ fun find(@EditorNpcId id: String) = Actor()
+ fun Actor.play(@EditorAnimation animation: String) = Unit
+ """.trimIndent()
+
+ private fun warnings(body: String): List {
+ val name = "scripts/main.clips.kts"
+ val text = "$api\n$body\n"
+ fixture.environment.analyzer.diagnostic(name, text)
+ val file = fixture.environment.analyzer.fileCache.getValue(name).file
+ return animationNameDiagnostics(file, clips).map { it.message }
+ }
+
+ @Test
+ fun `a clip the model lacks is named along with those it may have meant`() {
+ val found = warnings("val hero = spawn(model = \"hero.glb\")\nhero.play(\"wav\")\nhero.play(\"wave\")")
+ assertEquals(listOf("This model has no animation \"wav\". Did you mean: wave?"), found)
+ }
+
+ @Test
+ fun `the model of an NPC of the world comes through its id`() {
+ val found = warnings("find(\"village:hero\").play(\"dance\")")
+ assertEquals(listOf("This model has no animation \"dance\""), found)
+ }
+
+ @Test
+ fun `a receiver of unknown origin says nothing`() {
+ assertEquals(
+ emptyList(),
+ warnings("var hero = spawn(model = \"hero.glb\")\nhero.play(\"dance\")\nspawn().play(\"dance\")")
+ )
+ }
+}
diff --git a/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt b/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt
index 725006d7c..6f14b60a0 100644
--- a/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt
+++ b/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.TestInstance
import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl
import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider
import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings
+import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem
import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic
import java.io.File
import kotlin.test.Test
@@ -31,6 +32,7 @@ class CompilerPluginAnalysisTest {
ScriptClassProvider(
extension = "ui.kts",
baseClass = "ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript",
+ defaultImports = listOf("ru.hollowhorizon.hollowengine.client.ui.*"),
),
),
mappings = Mappings.EMPTY,
@@ -172,6 +174,77 @@ class CompilerPluginAnalysisTest {
}
}
+ @Test
+ fun `ui modifier completions include extensions on the companion and chained values`() {
+ withEnvironment { environment ->
+ assertNoErrors(environment.analyzer.diagnostic("modifier-calls.ui.kts", """
+ screen("test:modifier") {
+ content {
+ val modifier = Modifier.gap(4.px).padding(8.px)
+ }
+ }
+ """.trimIndent()))
+
+ val receivers = listOf("Modifier", "Modifier.Companion", "Modifier.gap(4.px)", "UiModifier")
+ for ((index, receiver) in receivers.withIndex()) {
+ for (prefix in listOf("", "pa")) {
+ val aliasImport = if (receiver == "UiModifier") {
+ "import ru.hollowhorizon.hollowengine.client.ui.Modifier as UiModifier\n"
+ } else ""
+ val text = aliasImport + "val modifier = $receiver.$prefix"
+ val items = environment.analyzer.collectCompletions(
+ "modifier-$index-${prefix.length}.ui.kts", text, text.length,
+ )
+ assertTrue(items.any { it.name == "padding" }, "$receiver.$prefix: $items")
+ if (prefix.isEmpty()) assertTrue(items.any { it.name == "gap" }, "$receiver: $items")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `class receiver completions include applicable companion extensions without instance members`() {
+ withEnvironment { environment ->
+ val declarations = """
+ open class ReceiverBase
+ object Instance : ReceiverBase()
+ class Receiver {
+ companion object : ReceiverBase() {
+ fun ownMember() = Unit
+ }
+ fun instanceOnly() = Unit
+ }
+ fun ReceiverBase.applicableExtension() = Unit
+ val ReceiverBase.extensionProperty: Int get() = 0
+ fun String.unrelatedExtension() = Unit
+ """.trimIndent()
+ for (receiver in listOf("Receiver", "Instance")) {
+ val text = "$declarations\n$receiver."
+ val items = environment.analyzer.collectCompletions("$receiver-extensions.analysis.kts", text, text.length)
+ if (receiver == "Receiver") assertTrue(items.any { it.name == "ownMember" }, items.toString())
+ assertTrue(items.any { it.name == "applicableExtension" }, items.toString())
+ assertTrue(items.any { it.name == "extensionProperty" }, items.toString())
+ assertFalse(items.any { it.name == "instanceOnly" || it.name == "unrelatedExtension" }, items.toString())
+ }
+ val text = "$declarations\nReceiverBase."
+ val items = environment.analyzer.collectCompletions("no-companion.analysis.kts", text, text.length)
+ assertFalse(items.any { it.name == "applicableExtension" }, items.toString())
+ }
+ }
+
+ @Test
+ fun `companion receiver completions offer importable extensions`() {
+ withEnvironment { environment ->
+ val text = """
+ import ru.hollowhorizon.hollowengine.client.ui.Modifier
+ val modifier = Modifier.pa
+ """.trimIndent()
+ val items = environment.analyzer.collectCompletions("importable-modifier.analysis.kts", text, text.length)
+ val declarations = items.filterIsInstance()
+ assertTrue(declarations.any { it.name == "padding" && it.import }, items.toString())
+ }
+ }
+
private fun withEnvironment(block: (ScriptingEnvironmentImpl) -> Unit) {
block(fixture.environment)
}
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/ScriptRenameTest.kt b/addons/compiler/src/test/kotlin/ScriptRenameTest.kt
new file mode 100644
index 000000000..17588d945
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/ScriptRenameTest.kt
@@ -0,0 +1,106 @@
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl
+import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider
+import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+abstract class RenameScript
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ScriptRenameTest {
+ private val fixture = AnalysisEnvironmentTestFixture {
+ ScriptingEnvironmentImpl(
+ javaHome = File(System.getProperty("java.home")),
+ classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank)
+ .map(::File).filter(File::exists).distinctBy { it.absoluteFile.normalize() },
+ scriptTypes = listOf(
+ ScriptClassProvider(
+ extension = ".rename.kts",
+ baseClass = RenameScript::class.qualifiedName!!
+ )
+ ),
+ mappings = Mappings.EMPTY,
+ )
+ }
+
+ @BeforeAll
+ fun startEnvironment() = fixture.start()
+
+ @AfterEach
+ fun resetEnvironment() = fixture.reset()
+
+ @AfterAll
+ fun closeEnvironment() = fixture.close()
+
+ private val analyzer get() = fixture.environment.analyzer
+
+ @Test
+ fun `a declaration is renamed in every script that imports it, and nowhere its name means something else`() {
+ script("core.rename.kts", "class Quest(val title: String)\nfun greet(quest: Quest) = quest.title")
+ script("other.rename.kts", "fun greet(times: Int) = times\nval twice = greet(2)")
+ val main = "@file:Import(\"core.rename.kts\")\n\nval first = greet(Quest(\"start\"))\n"
+ val caret = main.indexOf("greet")
+
+ val renames = analyzer.rename("scripts/main.rename.kts", main, caret, "welcome", emptyMap())
+ .associate { it.path to it.applied() }
+
+ assertEquals(
+ mapOf(
+ "scripts/main.rename.kts" to "@file:Import(\"core.rename.kts\")\n\nval first = welcome(Quest(\"start\"))\n",
+ "scripts/core.rename.kts" to "class Quest(val title: String)\nfun welcome(quest: Quest) = quest.title",
+ ),
+ renames,
+ )
+ }
+
+ @Test
+ fun `an open document is renamed as it is typed, not as it was saved`() {
+ script("model.rename.kts", "class Hero(val name: String)")
+ script("user.rename.kts", "@file:Import(\"model.rename.kts\")\n")
+ val open = "@file:Import(\"model.rename.kts\")\nval hero = Hero(\"Steve\")\n"
+ val main = "@file:Import(\"model.rename.kts\")\nval other = Hero(\"Alex\")\n"
+
+ val renames = analyzer.rename(
+ "scripts/main.rename.kts", main, main.indexOf("Hero"), "Champion",
+ mapOf("scripts/user.rename.kts" to open),
+ ).associate { it.path to it.applied() }
+
+ assertEquals(
+ "@file:Import(\"model.rename.kts\")\nval hero = Champion(\"Steve\")\n",
+ renames["scripts/user.rename.kts"]
+ )
+ assertEquals("class Champion(val name: String)", renames["scripts/model.rename.kts"])
+ }
+
+ @Test
+ fun `a local stays in its own body`() {
+ script("elsewhere.rename.kts", "val count = 3")
+ val main = "fun run(): Int {\n val count = 1\n return count + 1\n}\nfun walk() { val count = 2 }\n"
+
+ val renames = analyzer.rename("scripts/main.rename.kts", main, main.indexOf("count"), "total", emptyMap())
+
+ assertEquals(
+ listOf("fun run(): Int {\n val total = 1\n return total + 1\n}\nfun walk() { val count = 2 }\n"),
+ renames.map { it.applied() },
+ )
+ }
+
+ @Test
+ fun `a name from a library cannot be renamed`() {
+ val main = "val size = listOf(1).size\n"
+ assertNull(analyzer.renameTarget("scripts/main.rename.kts", main, main.indexOf("listOf")))
+ }
+
+ private fun script(path: String, text: String) {
+ File("hollowengine/scripts", path).apply {
+ parentFile.mkdirs()
+ writeText(text)
+ }
+ }
+}
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..cfdb8a3b6
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt
@@ -0,0 +1,311 @@
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.*
+import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionSink
+import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic
+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 `named values tell what they turned out to be`() {
+ val source = """
+ suspend fun first() {
+ val storyPin0 = (listOf("x"))
+ }
+ suspend fun second() {
+ val storyPin1 = (listOf("x")).iterator().next()
+ val storyPin2 = (missing)
+ val other = 1
+ }
+ """.trimIndent()
+ val types = compiler.environment.analyzer.propertyTypes("types.stage.kts", source, "storyPin").types
+ assertEquals(
+ mapOf("storyPin0" to "kotlin.collections.List", "storyPin1" to "kotlin.String"),
+ types
+ )
+ }
+
+ @Test
+ fun `values fit places the way Kotlin assigns them`() {
+ val source = """
+ suspend fun check() {
+ val storyPin0 = listOf("x")
+ val storyPin1 = listOf(1)
+ val storyPin2 = listOf()
+ val storyPin3: String? = null
+ val storyPlace0: List = listOf()
+ val storyPlace1: Iterable<*> = listOf()
+ val storyPlace2: String = ""
+ val storyPlace3: Missing = ""
+ }
+ """.trimIndent()
+ val found = compiler.environment.analyzer.propertyTypes("fits.stage.kts", source, "storyPin", "storyPlace")
+ assertEquals(setOf("storyPlace0", "storyPlace1"), found.fits["storyPin0"])
+ assertEquals(setOf("storyPlace1"), found.fits["storyPin1"])
+ assertEquals(setOf("storyPlace1"), found.fits["storyPin2"])
+ assertEquals(setOf("storyPlace2"), found.fits["storyPin3"])
+ assertTrue("storyPlace3" !in found.types)
+ }
+
+ @Test
+ fun `pins of a graph get their types from the script it compiles to`() {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("trust", "0")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Expression("text", "\"a\""),
+ StoryNode.Expression("sum", "trust + 1"),
+ StoryNode.Pure("list", "list", count = 1),
+ StoryNode.Loop("each", ""),
+ StoryNode.Code("block", "val found = 2\nfound.toString()"),
+ ),
+ links = listOf(
+ StoryLink("s", StoryPins.NEXT, "each", StoryPins.IN),
+ StoryLink("text", StoryPins.VALUE, "list", "a"),
+ StoryLink("list", "list", "each", StoryPins.ITEMS),
+ ),
+ )
+ val pins = StoryGraphCompiler.pins(graph, StoryFunctions.Builtin).entries.toList()
+ val script = StoryGraphCompiler.compile(graph).code + pins.withIndex().joinToString("") { (index, pin) ->
+ "\nsuspend fun storyPinOf$index() {\nval storyPin$index = (\n${pin.value}\n)\n}"
+ }
+ val found = compiler.environment.analyzer.propertyTypes("graph.stage.kts", script, "storyPin").types
+ val types = found.mapKeys { (name, _) -> pins[name.removePrefix("storyPin").toInt()].key }
+ assertEquals("kotlin.String", types["text" to StoryPins.VALUE])
+ assertEquals("kotlin.Int", types["sum" to StoryPins.VALUE])
+ assertEquals("kotlin.collections.List", types["list" to "list"])
+ assertEquals("kotlin.String", types["each" to StoryPins.ITEM])
+ assertEquals("kotlin.String", types["block" to StoryPins.VALUE])
+ }
+
+ @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/StageGraphTest.kt b/addons/compiler/src/test/kotlin/StageGraphTest.kt
new file mode 100644
index 000000000..776dd1012
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/StageGraphTest.kt
@@ -0,0 +1,302 @@
+import kotlinx.coroutines.test.runTest
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.GeneratedStory
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphCompiler
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryLink
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryCallKind
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryOperators
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryOption
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryPinType
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryPins
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryVariable
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/** A graph is generated into stages, so it runs, resumes and fails to compile as a hand-written script does. */
+class StageGraphTest : StageTest() {
+ private fun call(id: String, function: String, vararg arguments: Pair) =
+ StoryNode.Call(id, function, arguments = mapOf(*arguments))
+
+ private fun log(id: String, text: String) = call(id, "log", "event" to text)
+
+ private fun next(from: String, to: String, pin: String = StoryPins.NEXT) = StoryLink(from, pin, to, StoryPins.IN)
+
+ private fun generate(graph: StoryGraph): GeneratedStory =
+ StoryGraphCompiler.compile(StoryGraph.decode(graph.encode())).also { assertEquals(emptyList(), it.problems) }
+
+ /** start β count β (trust < 3 ? count : done), with a wait inside the loop. */
+ private val loop = StoryGraph(
+ variables = listOf(StoryVariable("trust", "0")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Assign("add", "trust", "trust + 1"),
+ log("say", "\"trust \$trust\""),
+ call("wait", "await", "name" to "\"step\""),
+ StoryNode.Branch("more", "trust < 3"),
+ log("done", "\"done\""),
+ ),
+ links = listOf(
+ next("s", "add"), next("add", "say"), next("say", "wait"), next("wait", "more"),
+ next("more", "add", StoryPins.THEN), next("more", "done", StoryPins.ELSE),
+ ),
+ )
+
+ @Test
+ fun `a reroute changes nothing about the story it sits in`() {
+ val plain = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), log("a", "\"a\"")), links = listOf(next("s", "a")))
+ val through = plain.copy(
+ nodes = plain.nodes + StoryNode.Reroute("r", flow = true),
+ links = listOf(next("s", "r"), next("r", "a")),
+ )
+ assertEquals(generate(plain).code, generate(through).code)
+ }
+
+ @Test
+ fun `values of operators reach a command through a reroute`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Pure("sum", "add", mapOf("a" to "2", "b" to "3")),
+ StoryNode.Pure("text", "to-text"),
+ StoryNode.Reroute("r", flow = false),
+ call("say", "log"),
+ ),
+ links = listOf(
+ next("s", "say"),
+ StoryLink("sum", "result", "r", StoryPins.VALUE),
+ StoryLink("r", StoryPins.VALUE, "text", "value"),
+ StoryLink("text", "result", "say", "event"),
+ ),
+ )
+ val world = world("operators", generate(graph).code)
+ world.launch("main")
+ assertEquals(listOf("5.0"), StageTrace.take())
+ }
+
+ @Test
+ fun `a for each node runs its body for every item`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Loop("each", "listOf(\"a\", \"b\")"),
+ call("say", "log"),
+ log("done", "\"done\""),
+ ),
+ links = listOf(
+ next("s", "each"),
+ next("each", "say", StoryPins.BODY),
+ next("each", "done"),
+ StoryLink("each", StoryPins.ITEM, "say", "event"),
+ ),
+ )
+ val world = world("for-each", generate(graph).code)
+ world.launch("main")
+ assertEquals(listOf("a", "b", "done"), StageTrace.take())
+ }
+
+ /** Operators are templates of text, so nothing but compiling them says they mean anything. */
+ @Test
+ fun `every operator compiles`() = runTest {
+ val nodes = ArrayList()
+ val links = ArrayList()
+ nodes += StoryNode.Start("s", "main")
+ var previous = "s"
+ StoryOperators.all.forEachIndexed { index, operator ->
+ val arguments = operator.inputs.filter { it.default.isEmpty() }.associate { input ->
+ input.name to when (input.type) {
+ StoryPinType.LIST -> "listOf(\"a\")"
+ StoryPinType.MAP -> "mapOf(\"a\" to \"b\")"
+ else -> "\"a\""
+ }
+ }
+ nodes += StoryNode.Pure("op$index", operator.id, arguments)
+ operator.outputs.forEachIndexed { at, output ->
+ val reader = "read${index}_$at"
+ nodes += call(reader, "log")
+ links += next(previous, reader)
+ links += StoryLink("op$index", output.name, reader, "event")
+ previous = reader
+ }
+ }
+ val code = StoryGraphCompiler.compile(StoryGraph(nodes = nodes, links = links), TestCommands()).code
+ world("operators-all", code)
+ }
+
+ /** A write outside the flow runs where its value is read, so a variable needs no place in the steps. */
+ @Test
+ fun `a variable is written when the value of its node is read`() = runTest {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("x", "0.0", "Double")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Assign("write", "x", "5.0"),
+ call("first", "log"),
+ call("second", "log", "event" to "\"x=\$x\""),
+ ),
+ links = listOf(next("s", "first"), next("first", "second"), StoryLink("write", StoryPins.VALUE, "first", "event")),
+ )
+ val world = world("assign", StoryGraphCompiler.compile(graph, TestCommands()).code)
+ world.launch("main")
+ world.settle()
+ assertEquals(listOf("5.0", "x=5.0"), StageTrace.take())
+ }
+
+ /** Every type a variable can be declared with has to compile and be saved by a story. */
+ @Test
+ fun `every type of a variable is declared and saved`() = runTest {
+ val variables = listOf(
+ "Double" to "0.0", "String" to "\"\"", "Boolean" to "false", "String?" to "", "Entity?" to "",
+ "List" to "listOf(1.0)", "Map" to "mapOf(\"a\" to 1.0)",
+ ).mapIndexed { index, (type, initial) -> StoryVariable("value$index", initial, type) }
+ val graph = StoryGraph(
+ variables = variables,
+ nodes = listOf(StoryNode.Start("s", "main"), log("first", "\"done\"")),
+ links = listOf(next("s", "first")),
+ )
+ val world = world("types", generate(graph).code)
+ world.launch("main")
+ world.settle()
+ assertEquals(listOf("done"), StageTrace.take())
+ }
+
+ @Test
+ fun `a loop of the graph runs and resumes after a restart`() = runTest {
+ val world = world("loop", generate(loop).code)
+ world.launch("main")
+ assertEquals(listOf("trust 1"), StageTrace.take())
+
+ world.restart()
+ assertEquals(emptyList(), StageTrace.take())
+ StageTrace.release("step")
+ world.settle()
+ assertEquals(listOf("trust 2", "trust 3", "done"), StageTrace.take())
+ }
+
+ @Test
+ fun `a long loop does not pile up frames`() = runTest {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("count", "0")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Assign("add", "count", "count + 1"),
+ StoryNode.Branch("more", "count < 5000"),
+ log("done", "\"done \$count\""),
+ ),
+ links = listOf(
+ next("s", "add"), next("add", "more"),
+ next("more", "add", StoryPins.THEN), next("more", "done", StoryPins.ELSE),
+ ),
+ )
+ world("long", generate(graph).code).launch("main")
+ assertEquals(listOf("done 5000"), StageTrace.take())
+ }
+
+ @Test
+ fun `values travel between stages and through a code node`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Code(
+ "made", "doubled = base * 2\nlog(\"code \$base\")",
+ inputs = mapOf("base" to "21"), outputs = mapOf("doubled" to "Int"),
+ ),
+ StoryNode.Branch("again", "false"),
+ call("wait", "await", "name" to "\"after\""),
+ call("show", "log"),
+ StoryNode.Expression("text", "\"\" + 1"),
+ StoryNode.Code("sum", "log(\"sum \$value\")", inputs = mapOf("value" to "")),
+ ),
+ links = listOf(
+ next("s", "made"), next("made", "again"),
+ next("again", "wait", StoryPins.THEN), next("again", "wait", StoryPins.ELSE),
+ next("wait", "show"), next("show", "sum"),
+ StoryLink("text", StoryPins.VALUE, "show", "event"),
+ StoryLink("made", "doubled", "sum", "value"),
+ ),
+ )
+ val world = world("values", generate(graph).code)
+ world.launch("main")
+ assertEquals(listOf("code 21"), StageTrace.take())
+ world.restart()
+ world.release("after")
+ assertEquals(listOf("1", "sum 42"), StageTrace.take())
+ }
+
+ @Test
+ fun `a choice compiles into branches of its options`() {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Choose("ask", listOf(StoryOption("ΠΠ° \"ΡΠΎΡΠ½ΠΎ\""), StoryOption("Π¦Π΅Π½Π°: $5"))),
+ log("yes", "\"yes\""),
+ log("after", "\"after\""),
+ ),
+ links = listOf(
+ next("s", "ask"), next("ask", "yes", StoryPins.option(0)),
+ next("ask", "after", StoryPins.option(1)), next("yes", "after"),
+ ),
+ )
+ val story = generate(graph)
+ compiler.compile("choice", story.code)
+ assertEquals(1, Regex("suspend fun block_after").findAll(story.code).count())
+ }
+
+ @Test
+ fun `a line with a speaker and the name of the player compiles`() {
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Say("hello", "Guide", "Hello, {player}!")),
+ links = listOf(next("s", "hello")),
+ )
+ compiler.compile("line", generate(graph).code)
+ }
+
+ @Test
+ fun `steps that do not pause share a stage and a pause gets its own`() {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("count", "0")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Assign("first", "count", "1"),
+ log("second", "\"instant\""),
+ StoryNode.Assign("third", "count", "2"),
+ call("wait", "await", "name" to "\"step\""),
+ StoryNode.Say("line", text = "Hello"),
+ ),
+ links = listOf(
+ next("s", "first"), next("first", "second"), next("second", "third"),
+ next("third", "wait"), next("wait", "line"),
+ ),
+ )
+ val kinds: (StoryNode.Call) -> StoryCallKind = {
+ if (it.function == "log") StoryCallKind.INSTANT else StoryCallKind.SUSPENDING
+ }
+ val stages = Regex("suspend fun (step_\\w+)").findAll(StoryGraphCompiler.compile(graph, TestCommands(kinds)).code)
+ assertEquals(listOf("step_first", "step_wait", "step_line"), stages.map { it.groupValues[1] }.toList())
+ }
+
+ @Test
+ fun `an error of the Kotlin compiler leads back to its node`() {
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), log("fine", "\"ok\""), call("broken", "log", "event" to "missing")),
+ links = listOf(next("s", "fine"), next("fine", "broken")),
+ )
+ val story = generate(graph)
+ val failure = compiler.errors(story.code, "located")
+ val line = story.code.lines().indexOfFirst { "missing" in it } + 1
+ assertEquals("broken", story.nodeAt(line))
+ assertTrue("missing" in failure, failure)
+ }
+
+ @Test
+ fun `problems of the graph are reported before Kotlin sees them`() {
+ val story = StoryGraphCompiler.compile(
+ StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Assign("set", "nothing", "1"), call("a", "x"), call("b", "y")),
+ links = listOf(next("s", "set"), next("set", "a"), next("set", "b"), StoryLink("gone", "value", "a", "x")),
+ )
+ )
+ assertEquals(listOf(null, "set", "set"), story.problems.map { it.node }.sortedBy { it.orEmpty() })
+ }
+}
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/compiler/src/test/kotlin/StageValueClassTest.kt b/addons/compiler/src/test/kotlin/StageValueClassTest.kt
new file mode 100644
index 000000000..af6d2c810
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/StageValueClassTest.kt
@@ -0,0 +1,26 @@
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** A value class is a primitive to the JVM, so what a stage holds in place of one cannot be null. */
+class StageValueClassTest : StageTest() {
+ @Test
+ fun `a duration can be an argument of an action`() = runTest {
+ val world = world("durationArgument", """
+ import ru.hollowhorizon.hollowengine.addons.narrative.npcs.*
+ import ru.hollowhorizon.hollowengine.addons.narrative.scene.commands.*
+ import ru.hollowhorizon.hollowengine.addons.narrative.story.*
+ import ru.hollowhorizon.hollowengine.common.entities.NpcEntity
+
+ var npc by saved(null)
+
+ @State
+ suspend fun main() {
+ if ("yes".length == 2) (npc!!).lookAt(duration = duration("2"), target = storyPlayer())
+ log("done")
+ }
+ """)
+ world.launch("main")
+ assertEquals(listOf("done"), StageTrace.take())
+ }
+}
diff --git a/addons/compiler/src/test/kotlin/StoryTopologyTest.kt b/addons/compiler/src/test/kotlin/StoryTopologyTest.kt
new file mode 100644
index 000000000..8492b8eeb
--- /dev/null
+++ b/addons/compiler/src/test/kotlin/StoryTopologyTest.kt
@@ -0,0 +1,432 @@
+import kotlinx.coroutines.test.runTest
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.*
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.copyNodes
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeInput
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeNodes
+import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.renameVariable
+import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType
+import kotlin.reflect.typeOf
+import kotlin.test.*
+
+/** Commands of tests: what a node says of itself, with every parameter taking a string. */
+internal class TestCommands(private val kinds: (StoryNode.Call) -> StoryCallKind = { StoryCallKind.SUSPENDING }) :
+ StoryFunctions {
+ override fun signature(call: StoryNode.Call) = StoryBuiltins.find(call.action.orEmpty()) ?: StoryFunctionSignature(
+ call.function, call.function, (call.arguments.keys + "event").map { StoryParam(it, typeOf()) },
+ returns = call.returns, block = call.block,
+ )
+
+ override fun kind(call: StoryNode.Call) = kinds(call)
+}
+
+/** Graphs of the shapes that are easy to get wrong, and the edits that change their shape. */
+class StoryTopologyTest : StageTest() {
+ private fun call(id: String, function: String, vararg arguments: Pair, returns: String? = null) =
+ StoryNode.Call(id, function, arguments = mapOf(*arguments), returns = returns)
+
+ private fun log(id: String, text: String = id) = call(id, "log", "event" to "\"$text\"")
+
+ private fun next(from: String, to: String, pin: String = StoryPins.NEXT) = StoryLink(from, pin, to, StoryPins.IN)
+
+ private fun compile(graph: StoryGraph) = StoryGraphCompiler.compile(graph, TestCommands())
+
+ private fun code(graph: StoryGraph) = compile(graph).also { assertEquals(emptyList(), it.problems) }.code
+
+ @Test
+ fun `branches that meet go on together`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Branch("b", "true"),
+ log("a"),
+ log("c"),
+ log("merge"),
+ log("after")
+ ),
+ links = listOf(
+ next("s", "b"), next("b", "a", StoryPins.THEN), next("b", "c", StoryPins.ELSE),
+ next("a", "merge"), next("c", "merge"), next("merge", "after"),
+ ),
+ )
+ world("diamond", code(graph)).launch("main")
+ assertEquals(listOf("a", "merge", "after"), StageTrace.take())
+ }
+
+ @Test
+ fun `a branch inside a branch finds its way back`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"), StoryNode.Branch("outer", "true"), StoryNode.Branch("inner", "false"),
+ log("x"), log("y"), log("z"), log("end"),
+ ),
+ links = listOf(
+ next("s", "outer"), next("outer", "inner", StoryPins.THEN), next("outer", "z", StoryPins.ELSE),
+ next("inner", "x", StoryPins.THEN), next("inner", "y", StoryPins.ELSE),
+ next("x", "end"), next("y", "end"), next("z", "end"),
+ ),
+ )
+ world("nested", code(graph)).launch("main")
+ assertEquals(listOf("y", "end"), StageTrace.take())
+ }
+
+ @Test
+ fun `a link back repeats what it leads to`() = runTest {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("count", "0")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"), StoryNode.Assign("add", "count", "count + 1"),
+ call("say", "log", "event" to "\"turn \$count\""), StoryNode.Branch("more", "count < 2"), log("end"),
+ ),
+ links = listOf(
+ next("s", "add"), next("add", "say"), next("say", "more"),
+ next("more", "add", StoryPins.THEN), next("more", "end", StoryPins.ELSE),
+ ),
+ )
+ world("back-edge", code(graph)).launch("main")
+ assertEquals(listOf("turn 1", "turn 2", "end"), StageTrace.take())
+ }
+
+ @Test
+ fun `a chain of reroutes survives losing a link of it`() {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Reroute("r1", flow = true),
+ StoryNode.Reroute("r2", flow = true),
+ StoryNode.Reroute("r3", flow = true),
+ log("a"),
+ ),
+ links = listOf(next("s", "r1"), next("r1", "r2"), next("r2", "r3"), next("r3", "a")),
+ )
+ val shorter = graph.removeNodes(setOf("r2"))
+ assertEquals(setOf("s", "r1", "r3", "a"), shorter.nodes.mapTo(HashSet()) { it.id })
+ assertEquals(listOf(listOf("s", "a")), shorter.dissolved().links.map { listOf(it.from, it.to) })
+ }
+
+ @Test
+ fun `a copy links up inside and keeps reading from outside`() {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"), StoryNode.Expression("value", "1"), log("a"), call("b", "log"), log("c")
+ ),
+ links = listOf(
+ next("s", "a"), next("a", "b"), next("b", "c"), StoryLink("value", StoryPins.VALUE, "b", "event")
+ ),
+ )
+ val (copied, ids) = graph.copyNodes(setOf("a", "b"), StoryPoint(10f, 10f))
+ val a = ids.getValue("a")
+ val b = ids.getValue("b")
+ assertTrue(next(a, b) in copied.links)
+ assertTrue(StoryLink("value", StoryPins.VALUE, b, "event") in copied.links)
+ assertFalse(copied.links.any { it.from == b && it.to == "c" }, "flow out of a copy waits to be linked")
+ assertFalse(copied.links.any { it.from == "s" && it.to == a }, "and so does flow into it")
+ }
+
+ @Test
+ fun `a link beats what is typed into the receiver`() {
+ val call = call("use", "show", "target" to "typed").copy(receiver = "target")
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Expression("value", "linked"), call),
+ links = listOf(next("s", "use"), StoryLink("value", StoryPins.VALUE, "use", "target")),
+ )
+ val generated = code(graph)
+ assertTrue("linked" in generated, generated)
+ assertFalse("typed" in generated, generated)
+ }
+
+ @Test
+ fun `a result that can be empty stays empty`() = runTest {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("found", "null", "String?")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ call("get", "nothing", returns = "kotlin.String?"),
+ StoryNode.Assign("keep", "found"),
+ StoryNode.Branch("empty", "found == null"),
+ log("none"),
+ log("some"),
+ ),
+ links = listOf(
+ next("s", "get"), next("get", "keep"), next("keep", "empty"),
+ next("empty", "none", StoryPins.THEN), next("empty", "some", StoryPins.ELSE),
+ StoryLink("get", StoryPins.VALUE, "keep", StoryPins.VALUE),
+ ),
+ )
+ val generated = code(graph)
+ assertFalse("!!" in generated.substringAfter("found = "), generated)
+ world("nullable", "$generated\nfun nothing(): String? = null").launch("main")
+ assertEquals(listOf("none"), StageTrace.take())
+ }
+
+ @Test
+ fun `a required result is asserted to be there`() {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"), call("get", "name", returns = "kotlin.String"), call("say", "log")
+ ),
+ links = listOf(next("s", "get"), next("get", "say"), StoryLink("get", StoryPins.VALUE, "say", "event")),
+ )
+ assertTrue("!!" in code(graph))
+ }
+
+ @Test
+ fun `moving nodes around does not change what a story means`() {
+ val graph = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), log("a")), links = listOf(next("s", "a")))
+ val moved = graph.copy(layout = mapOf("a" to StoryPoint(100f, 40f)), collapsed = setOf("a"))
+ assertEquals(graph.revision(), moved.revision())
+ assertNotEquals(graph.revision(), graph.copy(nodes = graph.nodes.dropLast(1) + log("a", "other")).revision())
+ }
+
+ @Test
+ fun `renaming a variable reaches what reads it and nothing else`() {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("trust", "0")),
+ nodes = listOf(
+ StoryNode.Say("say", text = "{trust} and trust, {trust + 1}"),
+ StoryNode.Assign("set", "trust", "trust + npc.trust + \"trust \$trust\".length + mistrust"),
+ StoryNode.Code("own", "val trust = 1\nlog(trust)"),
+ ),
+ ).renameVariable("trust", "faith")
+ assertEquals("0", graph.variables.single().initial)
+ assertEquals("{faith} and trust, {faith + 1}", (graph.node("say") as StoryNode.Say).text)
+ val set = graph.node("set") as StoryNode.Assign
+ assertEquals("faith", set.variable)
+ assertEquals("faith + npc.trust + \"trust \$faith\".length + mistrust", set.value)
+ assertEquals("val trust = 1\nlog(trust)", (graph.node("own") as StoryNode.Code).code)
+ }
+
+ @Test
+ fun `a suspending value keeps a write out of the instant steps next to it`() {
+ val graph = StoryGraph(
+ variables = listOf(StoryVariable("value", "\"\"", "String")),
+ nodes = listOf(
+ StoryNode.Start("s", "main"), log("first"), StoryNode.Assign("set", "value"), log("last"),
+ call("ask", "question", returns = "kotlin.String"),
+ ),
+ links = listOf(
+ next("s", "first"), next("first", "set"), next("set", "last"),
+ StoryLink("ask", StoryPins.VALUE, "set", StoryPins.VALUE),
+ ),
+ )
+ val kinds = TestCommands { if (it.function == "log") StoryCallKind.INSTANT else StoryCallKind.SUSPENDING }
+ val stages = Regex("suspend fun (step_\\w+)").findAll(StoryGraphCompiler.compile(graph, kinds).code)
+ assertTrue("step_set" in stages.map { it.groupValues[1] }.toList())
+ }
+
+ @Test
+ fun `a value from another branch is an error unless it can be null anyway`() {
+ fun graph(returns: String) = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Branch("b", "true"),
+ call("get", "name", returns = returns),
+ call("say", "log"),
+ log("end"),
+ ),
+ links = listOf(
+ next("s", "b"), next("b", "get", StoryPins.THEN), next("b", "say", StoryPins.ELSE),
+ StoryLink("get", StoryPins.VALUE, "say", "event"), next("get", "end"), next("say", "end"),
+ ),
+ )
+ val problem = compile(graph("kotlin.String")).problems.single()
+ assertEquals("say", problem.node)
+ assertFalse(problem.warning)
+ assertTrue(compile(graph("kotlin.String?")).problems.single().warning)
+ }
+
+ @Test
+ fun `an item is out of reach after its loop`() {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"), StoryNode.Loop("each", "listOf(1)"), log("body"), call("after", "log")
+ ),
+ links = listOf(
+ next("s", "each"), next("each", "body", StoryPins.BODY), next("each", "after"),
+ StoryLink("each", StoryPins.ITEM, "after", "event"),
+ ),
+ )
+ assertFalse(compile(graph).problems.single().warning)
+ }
+
+ @Test
+ fun `a checkpoint that asks for it takes a restart back to itself`() = runTest {
+ val world = world(
+ "checkpoint-restart", """
+ @State
+ suspend fun main() {
+ log("before")
+ checkpoint("question", restart = true)
+ log("asked")
+ await("answer")
+ log("answered")
+ }
+ """
+ )
+ world.launch("main")
+ assertEquals(listOf("before", "asked"), StageTrace.take())
+ world.restart()
+ assertEquals(listOf("asked"), StageTrace.take())
+ world.release("answer")
+ assertEquals(listOf("answered"), StageTrace.take())
+ }
+
+ @Test
+ fun `the next checkpoint turns going back off`() = runTest {
+ val world = world(
+ "checkpoint-restart-off", """
+ @State
+ suspend fun main() {
+ checkpoint("question", restart = true)
+ log("asked")
+ await("answer")
+ checkpoint("answered")
+ log("after")
+ await("end")
+ log("done")
+ }
+ """
+ )
+ world.launch("main")
+ world.release("answer")
+ assertEquals(listOf("asked", "after"), StageTrace.take())
+ world.restart()
+ world.release("end")
+ assertEquals(
+ listOf("after", "done"), StageTrace.take(), "it goes on from the last checkpoint, not the one before"
+ )
+ }
+
+ @Test
+ fun `a checkpoint node can ask for it too`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(
+ StoryNode.Start("s", "main"),
+ StoryNode.Call(
+ "mark", action = "checkpoint", arguments = mapOf("name" to "\"question\"", "restart" to "true")
+ ),
+ log("asked"), call("wait", "await", "name" to "\"answer\""),
+ ),
+ links = listOf(next("s", "mark"), next("mark", "asked"), next("asked", "wait")),
+ )
+ val world = world("checkpoint-node", code(graph))
+ world.launch("main")
+ assertEquals(listOf("asked"), StageTrace.take())
+ world.restart()
+ assertEquals(listOf("asked"), StageTrace.take())
+ }
+
+ @Test
+ fun `what a command says beats what an old file holds`() {
+ val stale = StoryNode.Call(
+ "mark", "old.name", action = "checkpoint", arguments = mapOf("name" to "\"a\""), block = true
+ )
+ val graph = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), stale), links = listOf(next("s", "mark")))
+ val generated = code(graph)
+ assertTrue("checkpoint(" in generated, generated)
+ assertFalse("old.name" in generated, generated)
+ }
+
+ @Test
+ fun `the parser splits arguments the way Kotlin reads them`() {
+ assertEquals(
+ listOf("1", "", "z"), StoryLiterals.vectorParts(StoryLiterals.vector(listOf("1", "", "z"), "vec"), "vec")
+ )
+ assertEquals(
+ listOf("max(1, 2)", "\"a,b\".length", "3"),
+ StoryLiterals.vectorParts("vec(max(1, 2), \"a,b\".length, 3)", "vec")
+ )
+ assertEquals(null, StoryKotlin.arguments("a), (b"))
+ }
+
+ @Test
+ fun `a list field keeps the Kotlin it was given`() {
+ val written = "listOf(\"a\", \"b\")"
+ val type = typeOf>()
+ assertEquals(written, StoryLiterals.expression(written, type))
+ assertEquals(written, StoryLiterals.text(written, type))
+ assertEquals(listOf("a", "b"), StoryLiterals.listed(written))
+ assertEquals("listOf(\"a\", \"c\")", StoryLiterals.list(listOf("a", "c")))
+ assertEquals("", StoryLiterals.list(emptyList()))
+ }
+
+ @Test
+ fun `code with no flow is an expression where it is read`() = runTest {
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Code("c", "\"value\""), call("say", "log")),
+ links = listOf(next("s", "say"), StoryLink("c", StoryPins.VALUE, "say", "event")),
+ )
+ world("code-value", code(graph)).launch("main")
+ assertEquals(listOf("value"), StageTrace.take())
+ }
+
+ @Test
+ fun `code in the flow has no untyped value to keep`() {
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Code("c", "\"value\""), call("say", "log")),
+ links = listOf(next("s", "c"), next("c", "say"), StoryLink("c", StoryPins.VALUE, "say", "event")),
+ )
+ assertTrue(compile(graph).problems.any { it.node == "c" || it.node == "say" })
+ }
+
+ @Test
+ fun `a list takes as many inputs as its author added`() {
+ val list = StoryNode.Pure("l", "list", mapOf("a" to "1", "b" to "2", "c" to "3", "d" to "4"), count = 4)
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), list, StoryNode.Pure("x", "list"), call("use", "log")),
+ links = listOf(
+ next("s", "use"),
+ StoryLink("l", "list", "use", "event"),
+ StoryLink("x", "list", "l", "d"),
+ ),
+ )
+ assertContains(compile(graph).code, "listOf((1), (2), (3), (emptyList()))")
+ val shorter = graph.removeInput("l", 1)
+ assertEquals(mapOf("a" to "1", "b" to "3", "c" to "4"), (shorter.node("l") as StoryNode.Pure).arguments)
+ assertEquals("c", shorter.links.single { it.from == "x" }.toPin)
+ }
+
+ @Test
+ fun `pins read what they carry without the flow that made it`() {
+ val list = StoryNode.Pure("l", "list", mapOf("a" to "\"x\""), count = 1)
+ val loop = StoryNode.Loop("each", "")
+ val sum = StoryNode.Pure("sum", "add", mapOf("b" to "1"))
+ val code = StoryNode.Code("c", "", outputs = mapOf("found" to "Int"))
+ val graph = StoryGraph(
+ nodes = listOf(StoryNode.Start("s", "main"), list, loop, sum, code),
+ links = listOf(
+ next("s", "c"),
+ next("c", "each"),
+ StoryLink("l", "list", "each", StoryPins.ITEMS),
+ StoryLink("c", "found", "sum", "a"),
+ ),
+ )
+ val pins = StoryGraphCompiler.pins(graph, TestCommands())
+ assertEquals("listOf((\"x\"))", pins["l" to "list"])
+ assertEquals("(listOf((\"x\"))).iterator().next()", pins["each" to StoryPins.ITEM])
+ assertContains(pins.getValue("sum" to "result"), "(null as Int?)")
+ }
+
+ @Test
+ fun `only a nullable variable can start empty`() {
+ val start = listOf(StoryNode.Start("s", "main"))
+ val empty = StoryGraph(variables = listOf(StoryVariable("a", "", "String?")), nodes = start)
+ assertContains(code(empty), "var a by saved(null)")
+ val required = StoryGraph(variables = listOf(StoryVariable("a", "", "String")), nodes = start)
+ assertTrue(compile(required).problems.isNotEmpty())
+ }
+
+ @Test
+ fun `the parser finds what has a color of its own`() {
+ val tokens = StoryKotlin.syntax("val a = \"x\" + 1 // note")!!.tokens
+ assertEquals(TokenType.KEYWORD, tokens.first { it.start == 0 }.type)
+ assertEquals(listOf(TokenType.KEYWORD, TokenType.STRING, TokenType.NUMERIC_LITERAL, TokenType.COMMENT), tokens.map { it.type }.distinct())
+ }
+
+ @Test
+ fun `broken Kotlin is told from fine Kotlin`() {
+ listOf(
+ "x + 1", "listOf(1, 2).size", "val a = 1\nlog(a)", "\"}\""
+ ).forEach { assertTrue(StoryKotlin.syntax(it)!!.valid, it) }
+ listOf("x +", "foo(", "\"open", "} fun other() {").forEach { assertFalse(StoryKotlin.syntax(it)!!.valid, it) }
+ }
+}
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..d7a51566d
--- /dev/null
+++ b/addons/narrative/build.gradle.kts
@@ -0,0 +1,30 @@
+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")
+ add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-json:$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() }
+
+extra["langModId"] = "hollowengine-narrative"
+apply(from = rootProject.file("gradle/lang-merge.gradle"))
+
+tasks.named("processResources") {
+ dependsOn("mergeLang")
+ from(layout.buildDirectory.dir("generated/lang"))
+}
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..ef3c6271e
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt
@@ -0,0 +1,72 @@
+package ru.hollowhorizon.hollowengine.addons.narrative
+
+import kotlinx.coroutines.CoroutineScope
+import ru.hollowhorizon.hollowengine.addons.narrative.client.NarrativeClient
+import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCommands
+import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryCommands
+import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryScripts
+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.*
+import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadScriptsEvent
+import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptTranslators
+import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient
+
+/**
+ * Dialogues, stories 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) {
+ ScriptTranslators.register(StoryScripts.EXTENSION, StoryScripts)
+ context.extensions.onUnload {
+ ScriptTranslators.unregister(StoryScripts.EXTENSION)
+ StoryScripts.stop()
+ }
+ 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
+ }
+ }
+
+ /** Stories call what scripts declare, so they load after the scripts of this reload. */
+ @SubscribeEvent
+ fun afterReloadScripts(event: ServerReloadScriptsEvent.Post) = StoryScripts.reload()
+
+ @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.scene.*"
+ val STORY_PACKAGES = listOf(
+ "ru.hollowhorizon.hollowengine.addons.narrative.story.*",
+ "ru.hollowhorizon.hollowengine.addons.narrative.npcs.*",
+ )
+
+ val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE)
+
+ /** Nodes start dialogues and play cutscenes for players. */
+ val NODE_IMPORTS = listOf(DIALOGUE_PACKAGE) + listOf(
+ "playCutscene",
+ "playCutsceneAt",
+ "playCutsceneNear",
+ "playCutsceneHere",
+ "stopCutscene"
+ ).map { "ru.hollowhorizon.hollowengine.addons.narrative.cutscene.$it" }
+ }
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt
new file mode 100644
index 000000000..796934e94
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt
@@ -0,0 +1,200 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.client
+
+import androidx.compose.runtime.*
+import net.minecraft.client.gui.screens.ChatScreen
+import org.lwjgl.glfw.GLFW
+import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceOverlay
+import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceUiKeys
+import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceView
+import ru.hollowhorizon.hollowengine.addons.narrative.speech.SpeechOverlay
+import ru.hollowhorizon.hollowengine.addons.narrative.speech.SpeechUiKeys
+import ru.hollowhorizon.hollowengine.addons.narrative.speech.StoryText
+import ru.hollowhorizon.hollowengine.client.ui.*
+import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect
+import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput
+import ru.hollowhorizon.hollowengine.common.events.ClientOnly
+import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent
+import ru.hollowhorizon.hollowengine.common.events.client.ScreenEvent
+import ru.hollowhorizon.hollowengine.common.ui.*
+import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser
+
+@ClientOnly
+internal object DialogueOverlays {
+ const val STYLESHEET = "hollowengine-narrative:ui/styles/dialogue-overlay.hss"
+ var speechBounds by mutableStateOf(null)
+
+ var screenOpen by mutableStateOf(false)
+ private set
+
+ @SubscribeEvent
+ fun onScreenOpen(event: ScreenEvent.Open) {
+ screenOpen = true
+ }
+
+ @SubscribeEvent
+ fun onScreenClose(event: ScreenEvent.Close) {
+ screenOpen = false
+ }
+
+ private val anchors = listOf(
+ OverlayAnchor.HudLayer(VanillaHudLayers.CHAT, HudPlacement.AFTER),
+ OverlayAnchor.OnScreen(ScreenMatch.OfType(ChatScreen::class.java.name, subclasses = true), ScreenLevel.CONTENT),
+ )
+
+ val speech = UiOverlayDefinition(
+ id = SpeechOverlay.ID,
+ anchors = anchors,
+ autoShow = false,
+ input = OverlayInput.INTERACTIVE,
+ rebuildEveryFrame = false,
+ exitDuration = 520L,
+ content = { Speech() },
+ )
+
+ val choice = UiOverlayDefinition(
+ id = ChoiceOverlay.ID,
+ anchors = anchors,
+ autoShow = false,
+ input = OverlayInput.INTERACTIVE,
+ rebuildEveryFrame = false,
+ exitDuration = 520L,
+ content = { Choice() },
+ )
+}
+
+@Composable
+private fun UiScope.Speech() {
+ val line = data[SpeechUiKeys.Line]
+ val text = remember(line.id) { StoryText.localized(line.text) }
+ val length = remember(text) { FormattedTextParser.parse(text).visibleLength }
+ var shown by remember(line.id) { mutableStateOf(if (line.charMillis <= 0) length else 0) }
+ val typed = shown >= length
+ LaunchedEffect(line.id) {
+ var start = -1L
+ while (shown < length) {
+ withFrameNanos { now ->
+ if (start < 0L) start = now
+ val typing = ((now - start) / 1_000_000L / line.charMillis).toInt()
+ shown = typing.coerceIn(shown, length)
+ }
+ }
+ }
+ DisposableEffect(Unit) { onDispose { DialogueOverlays.speechBounds = null } }
+
+ fun skip() {
+ if (typed) send { putString(SpeechUiKeys.Action, SpeechUiKeys.Advance) } else shown = length
+ }
+
+ val screenOpen = DialogueOverlays.screenOpen
+ var root = Modifier.style(DialogueOverlays.STYLESHEET).inputTransparent().focusScope()
+ if (!screenOpen) {
+ root = root.onKeyInput { input ->
+ if (line.text.isEmpty() || !isSkipKey(input)) return@onKeyInput
+ skip()
+ input.consume()
+ }
+ }
+ var box = Modifier.onPlaced { DialogueOverlays.speechBounds = it }
+ if (screenOpen) box = box.input(hoverable = true, clickable = true).onClick { skip() }
+
+ Box(id = "speech-root", modifier = root) {
+ if (line.text.isEmpty()) return@Box
+ Column(
+ id = "speech",
+ tags = if (line.speaker.isEmpty()) listOf("narration") else emptyList(),
+ modifier = box,
+ ) {
+ if (line.speaker.isNotEmpty()) Text(StoryText.localized(line.speaker), id = "speech-name")
+ Text(id = "speech-text") {
+ FormattedTextSpans(text, shown, listOf("speech-pending"))
+ if (typed) Span(" Β»", id = "speech-next")
+ }
+ }
+ }
+}
+
+private fun isSkipKey(input: UiKeyInput) = input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER
+
+@Composable
+private fun UiScope.Choice() {
+ val choice = data[ChoiceUiKeys.Choice]
+ val count = choice.options.size
+ var highlighted by remember { mutableStateOf(0) }
+ val open = choice.decided < 0
+
+ fun vote(index: Int) {
+ if (!open || index !in 0 until count) return
+ highlighted = index
+ send {
+ putString(ChoiceUiKeys.Action, ChoiceUiKeys.Vote)
+ putInt(ChoiceUiKeys.Index, index)
+ }
+ }
+
+ var root = Modifier.style(DialogueOverlays.STYLESHEET).focusScope()
+ if (!DialogueOverlays.screenOpen) {
+ root = root.input(clickable = true).onClick { vote(highlighted) }.onScroll { event ->
+ if (count > 0 && event.scrollY != 0f) highlighted =
+ (highlighted + if (event.scrollY > 0f) -1 else 1).mod(count)
+ event.consume()
+ }.onKeyInput { input ->
+ if (choiceKey(input, count, highlighted, ::vote) {
+ highlighted = it
+ }) input.consume()
+ }
+ }
+
+ var size by remember { mutableStateOf(null) }
+ val speech = DialogueOverlays.speechBounds
+ val own = size
+ var modifier = Modifier.onPlaced { size = it }
+ if (speech != null && own != null) {
+ modifier = modifier.position((speech.x + speech.width - own.width).px, (speech.y - 3f - own.height).px)
+ }
+
+ Box(id = "choice-root", modifier = root) {
+ Column(id = "choice", tags = if (speech != null) listOf("docked") else emptyList(), modifier = modifier) {
+ choice.options.forEachIndexed { index, text -> Option(index, text, choice, highlighted) { vote(index) } }
+ if (choice.voters > 1) Text("${choice.votes}/${choice.voters}", id = "choice-votes")
+ }
+ }
+}
+
+@Composable
+private fun Option(index: Int, text: String, choice: ChoiceView, highlighted: Int, vote: () -> Unit) {
+ val states = buildList {
+ if (index == highlighted && choice.decided < 0) add("highlighted")
+ if (index == choice.myVote) add("voted")
+ if (index == choice.decided) add("won")
+ if (choice.decided >= 0 && index != choice.decided) add("lost")
+ }
+ var modifier = Modifier.state(*states.toTypedArray())
+ if (choice.decided < 0) modifier = modifier.input(hoverable = true, clickable = true).onClick { vote() }
+ Row(id = "choice-option-$index", tags = listOf("choice-option"), modifier = modifier) {
+ Text("${index + 1}", tags = listOf("choice-number"))
+ FormattedText(StoryText.localized(text), tags = listOf("choice-text"))
+ }
+}
+
+private fun choiceKey(
+ input: UiKeyInput,
+ count: Int,
+ highlighted: Int,
+ vote: (Int) -> Unit,
+ move: (Int) -> Unit,
+): Boolean {
+ if (count == 0) return false
+ val number = when (input.key) {
+ in GLFW.GLFW_KEY_1..GLFW.GLFW_KEY_9 -> input.key - GLFW.GLFW_KEY_1
+ in GLFW.GLFW_KEY_KP_1..GLFW.GLFW_KEY_KP_9 -> input.key - GLFW.GLFW_KEY_KP_1
+ else -> null
+ }
+ return when {
+ number != null -> (number < count).also { if (it) vote(number) }
+ input.key == GLFW.GLFW_KEY_UP -> true.also { move((highlighted - 1).mod(count)) }
+ input.key == GLFW.GLFW_KEY_DOWN -> true.also { move((highlighted + 1).mod(count)) }
+ input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER -> true.also { vote(highlighted) }
+ else -> false
+ }
+}
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..a7f473aa9
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt
@@ -0,0 +1,42 @@
+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.graph.StoryGraphFilePreview
+import ru.hollowhorizon.hollowengine.client.camera.CameraDirector
+import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeScriptTemplate
+import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdePreview
+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 cutscene and dialogue cameras, the cutscene
+ * timeline and the editors of `.story` files 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(CameraDirector.sources, "cutscene", CutsceneCameraSystem, CUTSCENE_CAMERA_PRIORITY)
+ extensions.register(CameraDirector.sources, "story", StoryCameraSystem)
+ extensions.register(IdeTimelines.defaults, "cutscene", CutsceneTimeline)
+ extensions.register(UiDefinitionRegistry.builtInOverlays, "speech", DialogueOverlays.speech)
+ extensions.register(UiDefinitionRegistry.builtInOverlays, "choice", DialogueOverlays.choice)
+ extensions.registerIdePreview(StoryGraphFilePreview)
+ 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/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt
new file mode 100644
index 000000000..cb5192a2f
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt
@@ -0,0 +1,35 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.graph
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/** Writes a list in the order of [comparator], so that the same content is always the same text. */
+class SortedListSerializer(
+ elementSerializer: KSerializer,
+ private val comparator: Comparator,
+) : KSerializer> {
+ private val delegate = ListSerializer(elementSerializer)
+ override val descriptor: SerialDescriptor = delegate.descriptor
+
+ override fun serialize(encoder: Encoder, value: List) = delegate.serialize(encoder, value.sortedWith(comparator))
+
+ override fun deserialize(decoder: Decoder): List = delegate.deserialize(decoder)
+}
+
+object SortedVariablesSerializer : KSerializer> by SortedListSerializer(
+ StoryVariable.serializer(),
+ compareBy { it.name },
+)
+
+object SortedNodesSerializer : KSerializer> by SortedListSerializer(
+ StoryNode.serializer(),
+ compareBy { it.id },
+)
+
+object SortedLinksSerializer : KSerializer> by SortedListSerializer(
+ StoryLink.serializer(),
+ compareBy({ it.from }, { it.fromPin }, { it.to }, { it.toPin }),
+)
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt
new file mode 100644
index 000000000..976539837
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt
@@ -0,0 +1,51 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.graph
+
+sealed interface StoryEntityModel {
+ data class Path(val model: String) : StoryEntityModel
+ data class Npc(val id: String) : StoryEntityModel
+}
+
+object StoryEntityModels {
+ fun of(graph: StoryGraph, signatureOf: (StoryNode.Call) -> StoryFunctionSignature?): Map {
+ val plain = graph.dissolved()
+ val nodes = plain.nodes.associateBy { it.id }
+ val writers = plain.nodes.filterIsInstance().groupBy { it.variable }
+ val result = HashMap()
+ plain.nodes.filterIsInstance().forEach { call ->
+ val signature = signatureOf(call) ?: return@forEach
+ if (signature.params.none { it.meaning == StoryParamMeaning.ANIMATION }) return@forEach
+ val target = signature.params.firstOrNull { it.receiver } ?: return@forEach
+ val sources = Sources(plain, nodes, writers, signatureOf).into(call.id, target.name)
+ sources.distinct().singleOrNull()?.let { result[call.id] = it }
+ }
+ return result
+ }
+
+ private class Sources(
+ private val graph: StoryGraph,
+ private val nodes: Map,
+ private val writers: Map>,
+ private val signatureOf: (StoryNode.Call) -> StoryFunctionSignature?,
+ ) {
+ private val seen = HashSet()
+
+ fun into(node: String, pin: String): List {
+ val link = graph.index.values[node to pin] ?: return emptyList()
+ return when (val from = nodes[link.from]) {
+ is StoryNode.Call -> listOfNotNull(made(from))
+ is StoryNode.Expression -> writers[from.code.trim()].orEmpty().filter { seen.add(it.id) }
+ .flatMap { into(it.id, StoryPins.VALUE) }
+
+ else -> emptyList()
+ }
+ }
+
+ private fun made(call: StoryNode.Call): StoryEntityModel? {
+ val params = signatureOf(call)?.params ?: return null
+ fun written(meaning: StoryParamMeaning) = params.firstOrNull { it.meaning == meaning }
+ ?.let { param -> call.argument(param)?.let { StoryLiterals.literal(it, param.type) } }
+ return written(StoryParamMeaning.MODEL)?.let(StoryEntityModel::Path)
+ ?: written(StoryParamMeaning.NPC_ID)?.let(StoryEntityModel::Npc)
+ }
+ }
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt
new file mode 100644
index 000000000..bfda910e3
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt
@@ -0,0 +1,91 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.graph
+
+import kotlin.reflect.KType
+
+/** What an options object such as `MoveOptions` a parameter belongs to: [parameter] of the function, built by [constructor]. */
+data class StoryParamGroup(val parameter: String, val constructor: String)
+
+enum class StoryParamMeaning {
+ /** A clip of the model of the entity the call is made on. */
+ ANIMATION,
+
+ /** The model of the entity the call gives back. */
+ MODEL,
+
+ /** The author's id of the NPC the call gives back. */
+ NPC_ID,
+}
+
+/**
+ * One parameter of a command as the editors fill it.
+ *
+ * @param type its Kotlin type, which says how a value typed into a field is written in Kotlin.
+ * @param optional whether the call can leave it out.
+ * @param suggestions the values it takes, when they are a known set; nothing checks against them.
+ * @param title what the editor calls it instead of [name], a lang key or plain text.
+ * @param description what it is for, a lang key or plain text.
+ * @param receiver whether it is what the function is called on.
+ * @param group the options object it is a field of.
+ * @param implicit the expression it takes when nobody fills it.
+ * @param widget what edits it instead of a plain field, such as `color`.
+ * @param assets the ends of the paths of the files it names, when it names one.
+ * @param meaning what its value names, when the editor can check that.
+ */
+data class StoryParam(
+ val name: String,
+ val type: KType? = null,
+ val optional: Boolean = false,
+ val suggestions: List = emptyList(),
+ val title: String? = null,
+ val description: String? = null,
+ val receiver: Boolean = false,
+ val group: StoryParamGroup? = null,
+ val implicit: String? = null,
+ val assets: List = emptyList(),
+ val widget: String? = null,
+ val meaning: StoryParamMeaning? = null,
+)
+
+/**
+ * A command as the editors show it.
+ *
+ * @param name its id.
+ * @param title what the editor calls it, a lang key or plain text.
+ * @param description what it does, a lang key or plain text.
+ * @param icon a `namespace:textures/...` icon the editor shows beside it.
+ * @param category the heading the editor lists it under, a lang key or plain text.
+ * @param categoryColor the `#RRGGBB` nodes of the category take, or null to be given one.
+ * @param returns the type of what a call gives back, or null when it gives nothing.
+ * @param block whether a call takes the flow from [StoryPins.BODY] as its last argument, such as `temporary { }`.
+ * @param hidden whether stories that call it still work while nothing offers it for new ones.
+ */
+data class StoryFunctionSignature(
+ val name: String,
+ val function: String,
+ val params: List,
+ val title: String? = null,
+ val description: String? = null,
+ val icon: String? = null,
+ val category: String? = null,
+ val categoryColor: String? = null,
+ val returns: String? = null,
+ val block: Boolean = false,
+ val hidden: Boolean = false,
+) {
+ /** A new call of this command. */
+ fun newCall(id: String) = StoryNode.Call(id, action = name)
+}
+
+/** The expression [param] has in this call, or null when it is left out. */
+fun StoryNode.Call.argument(param: StoryParam): String? = when (val group = param.group) {
+ null -> arguments[param.name]
+ else -> groups[group.parameter]?.arguments?.get(param.name)
+}
+
+/** This call with [param] set to [value]; a blank one leaves it out. */
+fun StoryNode.Call.with(param: StoryParam, value: String): StoryNode.Call {
+ fun Map.set() = if (value.isBlank()) this - param.name else this + (param.name to value)
+ val group = param.group ?: return copy(arguments = arguments.set())
+ val written = groups[group.parameter] ?: StoryGroup(group.constructor)
+ return copy(groups = groups + (group.parameter to written.copy(arguments = written.arguments.set())))
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt
new file mode 100644
index 000000000..51b975513
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt
@@ -0,0 +1,69 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.graph
+
+import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryHandle
+import kotlin.reflect.typeOf
+
+/** How a call behaves when a restart interrupts the story. */
+enum class StoryCallKind {
+ /** Cannot suspend, so nothing can happen between it and the steps next to it. */
+ INSTANT,
+
+ /** An `@Action`: the stage compiler already keeps it from repeating once done. */
+ ACTION,
+
+ /** May suspend with nothing to mark how far it got, such as `say` waiting to be read. */
+ SUSPENDING,
+}
+
+/** What is known of the commands a story calls. A node names its command, and everything else about it comes from here. */
+interface StoryFunctions {
+ fun signature(call: StoryNode.Call): StoryFunctionSignature?
+
+ fun kind(call: StoryNode.Call): StoryCallKind
+
+ /** Knows the functions of the runtime alone; a call of anything else is treated as able to suspend. */
+ object Builtin : StoryFunctions {
+ override fun signature(call: StoryNode.Call) = call.action?.let(StoryBuiltins::find)
+
+ override fun kind(call: StoryNode.Call) = StoryCallKind.SUSPENDING
+ }
+}
+
+/** [call] with what its [StoryFunctions.signature] says, which beats anything a file written by an older version holds. */
+fun StoryFunctions.resolved(call: StoryNode.Call): StoryNode.Call {
+ val signature = signature(call) ?: return call
+ return call.copy(
+ function = signature.function,
+ returns = signature.returns,
+ block = signature.block,
+ receiver = signature.params.firstOrNull { it.receiver }?.name,
+ )
+}
+
+/** The functions of the story runtime that steer a story rather than act on the world. */
+object StoryBuiltins {
+ private const val STORY_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.story"
+ private const val HANDLE = "$STORY_PACKAGE.StoryHandle"
+ private const val FLOW = "hollowengine.gui.ide.story.category.flow"
+
+ val all: List = listOf(
+ builtin("checkpoint", param("name"), flag("restart")),
+ builtin("rewind", param("checkpoint", optional = true)),
+ builtin("temporary", param("until", optional = true)).copy(block = true),
+ builtin("async", param("name")).copy(returns = HANDLE, block = true),
+ builtin("join", handle()).copy(function = "join"),
+ builtin("cancel", handle()).copy(function = "cancel"),
+ builtin("scene", param("address")),
+ )
+
+ fun find(action: String): StoryFunctionSignature? = all.firstOrNull { it.name == action }
+
+ private fun builtin(name: String, vararg params: StoryParam) =
+ StoryFunctionSignature(name, "$STORY_PACKAGE.$name", params.toList(), category = FLOW)
+
+ private fun handle() = StoryParam("handle", typeOf>(), receiver = true)
+
+ private fun param(name: String, optional: Boolean = false) = StoryParam(name, typeOf(), optional)
+
+ private fun flag(name: String) = StoryParam(name, typeOf(), optional = true, suggestions = listOf("true", "false"))
+}
diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt
new file mode 100644
index 000000000..460280b7c
--- /dev/null
+++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt
@@ -0,0 +1,294 @@
+package ru.hollowhorizon.hollowengine.addons.narrative.graph
+
+import kotlinx.serialization.EncodeDefault
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonClassDiscriminator
+import java.util.*
+
+/**
+ * A story as authors edit it: nodes and the links between them, which [StoryGraphCompiler] turns into a script.
+ *
+ * @param imports what the Kotlin of its nodes needs imported.
+ * @param layout where the graph editor shows each node; a node without a place is arranged when it is shown.
+ * @param groups the frames the graph editor keeps nodes in.
+ * @param collapsed the nodes the graph editor shows as a title bar.
+ */
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+data class StoryGraph(
+ @EncodeDefault val version: Int = VERSION,
+ val imports: List = emptyList(),
+ @Serializable(SortedVariablesSerializer::class) val variables: List = emptyList(),
+ @Serializable(SortedNodesSerializer::class) val nodes: List