diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3e71f3b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + cache: "gradle" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build --no-daemon --stacktrace + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4632856..6894026 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,7 @@ jobs: with: distribution: temurin java-version: "21" + cache: gradle - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 @@ -33,13 +34,13 @@ jobs: - name: Make Gradle wrapper executable run: chmod +x ./gradlew - - name: Build Dokka (multi-module) - run: ./gradlew dokkaHtmlMultiModule --no-daemon + - name: Build Dokka (single-module) + run: ./gradlew dokkaHtml --no-daemon - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: - path: build/dokka/htmlMultiModule + path: build/dokka/html deploy: needs: build @@ -52,4 +53,3 @@ jobs: id: deployment uses: actions/deploy-pages@v4 - diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml deleted file mode 100644 index 0949d9e..0000000 --- a/.woodpecker/build.yml +++ /dev/null @@ -1,18 +0,0 @@ -kind: pipeline -type: docker -name: build - -when: - event: - - pull_request - branch: - - main - -steps: - - name: build - image: eclipse-temurin:21-jdk - commands: - - chmod +x ./gradlew - - ./gradlew build --stacktrace --no-daemon - - diff --git a/build.gradle.kts b/build.gradle.kts index 5282252..afce8b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,113 +1,106 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.tasks.testing.Test -import java.util.Calendar -import java.util.TimeZone - plugins { - // Root is an aggregator. Subprojects apply Kotlin + publishing. - kotlin("jvm") version "2.2.21" apply false - kotlin("plugin.serialization") version "2.2.21" apply false + // Match real plugin projects: allow Kotlin version to be controlled externally (e.g. via gradle.properties systemProp.kotlin_version). + val kotlin_version: String by System.getProperties() + kotlin("jvm").version(kotlin_version) + `java-library` + `maven-publish` id("org.jetbrains.dokka") version "2.0.0" } -allprojects { - group = "cc.modlabs" - version = System.getenv("VERSION_OVERRIDE") ?: Calendar.getInstance(TimeZone.getTimeZone("UTC")).run { - "${get(Calendar.YEAR)}.${get(Calendar.MONTH) + 1}.${get(Calendar.DAY_OF_MONTH)}.${ - String.format("%02d%02d", get(Calendar.HOUR_OF_DAY), get(Calendar.MINUTE)) - }" - } +group = "cc.modlabs" +version = System.getenv("VERSION_OVERRIDE") ?: "1.0-SNAPSHOT" - repositories { - maven("https://nexus.modlabs.cc/repository/maven-mirrors/") - } +repositories { + maven("https://nexus.modlabs.cc/repository/maven-mirrors/") } -subprojects { - apply(plugin = "org.jetbrains.kotlin.jvm") - apply(plugin = "java-library") - apply(plugin = "maven-publish") - apply(plugin = "org.jetbrains.dokka") - // Root build only wires compilation + testing. - // Publishing is configured per-module later (kept out of root to avoid Gradle plugin ordering pitfalls). +dependencies { + // Provided by the Hytale server runtime; we only compile against it. + compileOnly("com.hypixel.hytale:Server:2026.01.13-dcad8778f") - dependencies { - add("testImplementation", kotlin("test")) - add("testImplementation", "io.kotest:kotest-runner-junit5:5.9.1") - add("testImplementation", "io.kotest:kotest-assertions-core:5.9.1") - add("testImplementation", "io.mockk:mockk:1.13.14") - } + testImplementation(kotlin("test")) + testImplementation("io.kotest:kotest-runner-junit5:5.9.1") + testImplementation("io.kotest:kotest-assertions-core:5.9.1") + testImplementation("io.mockk:mockk:1.13.14") +} - tasks.withType { - options.encoding = "UTF-8" - options.release.set(21) - } +tasks.test { + useJUnitPlatform() +} - tasks.withType { - compilerOptions.jvmTarget.set(JvmTarget.JVM_21) - } +kotlin { + val requestedJvmTargetStr: String = (System.getProperty("ktale_jvm_target") ?: "25").trim() - extensions.configure { - jvmToolchain(21) + // Hytale plugins are expected to run on JVM 25. + // Kotlin may lag behind in classfile targets; clamp to the max supported by Kotlin (currently 24). + val effectiveJvmTargetStr: String = try { + org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(requestedJvmTargetStr) + requestedJvmTargetStr + } catch (_: Throwable) { + logger.warn("Requested ktale_jvm_target=$requestedJvmTargetStr but Kotlin doesn't support it; using 24 for Kotlin bytecode.") + "24" } - tasks.withType().configureEach { - useJUnitPlatform() + // Use JDK 21 to *compile*, but we can still emit newer Kotlin bytecode targets when supported. + // (This matches your requirement: JDK 21, target JVM 25 — with Kotlin clamped until it supports 25.) + jvmToolchain(21) + compilerOptions { + // Kotlin's supported JVM targets lag behind Java toolchains. Use string target for maximum compatibility. + // If your Kotlin version supports 25, this will work; otherwise set systemProp.ktale_jvm_target (e.g. 21). + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(effectiveJvmTargetStr)) } +} - // Publishing: keep it CI-safe by always enabling `mavenLocal()`, and only adding remote repos when creds exist. - extensions.configure { - withSourcesJar() +java { + toolchain { + // JDK used for compilation. + languageVersion.set(JavaLanguageVersion.of(21)) } + withSourcesJar() +} - extensions.configure { - repositories { - mavenLocal() - val user = System.getenv("NEXUS_USER") - val pass = System.getenv("NEXUS_PASS") - if (!user.isNullOrBlank() && !pass.isNullOrBlank()) { - maven { - name = "ModLabs" - url = uri("https://nexus.modlabs.cc/repository/maven-public/") - credentials { - username = user - password = pass - } +publishing { + repositories { + mavenLocal() + val user = System.getenv("NEXUS_USER") + val pass = System.getenv("NEXUS_PASS") + if (!user.isNullOrBlank() && !pass.isNullOrBlank()) { + maven { + name = "ModLabs" + url = uri("https://nexus.modlabs.cc/repository/maven-public/") + credentials { + username = user + password = pass } } } + } - publications { - create("maven") { - from(components["java"]) - pom { - name.set("KTale") - description.set("A speculative, adapter-based Kotlin server SDK foundation for Hytale (Day-1 oriented).") - url.set("https://github.com/ModLabsCC/ktale") - licenses { - license { - name.set("GPL-3.0") - url.set("https://github.com/ModLabsCC/ktale/blob/main/LICENSE") - } + publications { + create("maven") { + from(components["java"]) + pom { + name.set("KTale") + description.set("Kotlin extensions + utilities for Hytale Server plugin development.") + url.set("https://github.com/ModLabsCC/ktale") + licenses { + license { + name.set("GPL-3.0") + url.set("https://github.com/ModLabsCC/ktale/blob/main/LICENSE") } - developers { - developer { - id.set("ModLabsCC") - name.set("ModLabsCC") - email.set("contact@modlabs.cc") - } - } - scm { - connection.set("scm:git:git://github.com/ModLabsCC/ktale.git") - developerConnection.set("scm:git:git@github.com:ModLabsCC/ktale.git") - url.set("https://github.com/ModLabsCC/ktale") + } + developers { + developer { + id.set("ModLabsCC") + name.set("ModLabsCC") + email.set("contact@modlabs.cc") } } + scm { + connection.set("scm:git:git://github.com/ModLabsCC/ktale.git") + developerConnection.set("scm:git:git@github.com:ModLabsCC/ktale.git") + url.set("https://github.com/ModLabsCC/ktale") + } } } } diff --git a/docs/auto-registration.mdx b/docs/auto-registration.mdx deleted file mode 100644 index 5004b11..0000000 --- a/docs/auto-registration.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Auto-registration ---- - -Auto-registration is a **plug-and-play convenience** primarily for the standalone host model. - -## Commands - -Annotate command classes with `@AutoCommand`: - -```java -@AutoCommand -public final class Ping implements CommandDefinition { - public String getName() { return "ping"; } - public java.util.Set getAliases() { return java.util.Collections.emptySet(); } - public String getDescription() { return null; } - public Permission getPermission() { return null; } - public CommandResult execute(CommandContext ctx) { return CommandResult.Success.INSTANCE; } -} -``` - -## Events - -Annotate listener methods with `@SubscribeEvent`: - -```java -public final class Listeners { - @SubscribeEvent(MyEvent.class) - public void on(MyEvent e) { } -} -``` - -## What performs scanning? - -- `ktale-core` provides `ktale.core.autoregister.AutoRegistrar` -- `ktale-runtime-host` calls it automatically during plugin enable - -This feature is optional and host-specific; platform adapters may implement a different strategy. - - diff --git a/docs/commands.mdx b/docs/commands.mdx deleted file mode 100644 index 730b3e8..0000000 --- a/docs/commands.mdx +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Commands ---- - -## Contracts - -- `CommandRegistry` is logic-only (no IO). -- Platforms/hosts bridge inbound command text to `CommandContext`. - -## Define commands (Kotlin DSL) - -```kotlin -import ktale.core.commands.Commands -import ktale.api.commands.CommandResult - -context.commands.register( - Commands.command("ping") { - aliases("p") - execute { CommandResult.Success } - } -) -``` - -## Define commands (Java fluent) - -```java -ctx.getCommands().register( - Commands.command("ping") - .aliases("p") - .executor(c -> CommandResult.Success.INSTANCE) - .build() -); -``` - -## Auto-registration (standalone host) - -If using the standalone host, annotate command classes: - -```java -@AutoCommand -public final class Ping implements CommandDefinition { - public String getName() { return "ping"; } - public java.util.Set getAliases() { return java.util.Collections.emptySet(); } - public String getDescription() { return null; } - public Permission getPermission() { return null; } - public CommandResult execute(CommandContext ctx) { return CommandResult.Success.INSTANCE; } -} -``` - -See [Auto-registration](./auto-registration.mdx). - - diff --git a/docs/configs.mdx b/docs/configs.mdx deleted file mode 100644 index f0ea301..0000000 --- a/docs/configs.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: Configs ---- - -## Contracts - -- `ConfigManager` loads/saves typed configs based on `ConfigKey` -- migrations are explicit and versioned (`ConfigMigration`) -- storage is host/platform-specific - -## Core default - -`ktale-core` provides a format-agnostic `CoreConfigManager` that: - -- stores text with a small header `ktaleConfigVersion: ` -- applies migrations to raw text before decoding - -### YAML helper - -`ktale-core` includes `YamlConfigCodec` (kotlinx.serialization + Kaml). - -If you need a different format (TOML/JSON/Jackson), provide your own `ConfigCodec`. - - diff --git a/docs/dependency-resolution.mdx b/docs/dependency-resolution.mdx deleted file mode 100644 index ad937ad..0000000 --- a/docs/dependency-resolution.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Runtime dependency resolution ---- - -This doc applies only if KTale is used as a **standalone host runtime** (bundled server model). - -## Why - -- plugins stay clean (no shading) -- dependencies are downloaded into a cache at runtime - -## Manifest format - -### `.dependencies` - -One Maven coordinate per line: - -``` -com.foo:bar:1.2.3 -``` - -### `.repositories` - -- unauthenticated: - -``` -id https://repo.example.com/maven/ -``` - -- authenticated (env var names only; secrets not stored in jars): - -``` -id https://repo.example.com/maven/ REPO_USER_ENV REPO_PASS_ENV -``` - - diff --git a/docs/dokka-and-pages.mdx b/docs/dokka-and-pages.mdx deleted file mode 100644 index ef1db20..0000000 --- a/docs/dokka-and-pages.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Dokka + GitHub Pages ---- - -KTale generates API docs via Dokka and publishes them to GitHub Pages. - -## Local build - -```bash -./gradlew dokkaHtmlMultiModule -``` - -Output: - -- `build/dokka/htmlMultiModule` - -## GitHub Actions - -The workflow lives at: - -- `.github/workflows/docs.yml` - -It: - -- runs `./gradlew dokkaHtmlMultiModule` -- uploads `build/dokka/htmlMultiModule` -- deploys to GitHub Pages - -You must enable Pages once in repo settings: - -- **Settings → Pages → Source: GitHub Actions** - - diff --git a/docs/events.mdx b/docs/events.mdx deleted file mode 100644 index 4805208..0000000 --- a/docs/events.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Events ---- - -## Contracts - -- `EventBus` is synchronous and deterministic by default (in `ktale-core`). -- Events are plain objects implementing `ktale.api.events.Event`. -- Cancellation is capability-based (`Cancellable`). - -## Subscribe (Kotlin) - -```kotlin -context.events.subscribe { e -> - // ... -} -``` - -## Subscribe (Java) - -```java -ctx.getEvents().subscribe(MyEvent.class, EventPriority.NORMAL, (EventListener) e -> { - // ... -}); -``` - -## Auto-registration (standalone host) - -If using the standalone host, you can annotate methods with `@SubscribeEvent` and let the host auto-register: - -```java -public final class MyListeners { - @SubscribeEvent(value = MyEvent.class, priority = EventPriority.NORMAL) - public void on(MyEvent e) { } -} -``` - -See [Auto-registration](./auto-registration.mdx). - - diff --git a/docs/fake-platform-testing.mdx b/docs/fake-platform-testing.mdx deleted file mode 100644 index 8c0612d..0000000 --- a/docs/fake-platform-testing.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Fake platform & testing ---- - -KTale ships a deterministic fake platform so you can test plugins without any real server: - -- deterministic clock -- controllable scheduler execution -- simulated command dispatch -- event simulation via `EventBus` - -## Example (Kotlin) - -```kotlin -import ktale.platform.fake.FakeServer -import ktale.api.KtalePlugin -import ktale.api.PluginContext - -val server = FakeServer() - -val plugin = object : KtalePlugin { - override fun onLoad(context: PluginContext) {} - override fun onEnable(context: PluginContext) { /* register stuff */ } - override fun onDisable(context: PluginContext) {} -} - -server.runPlugin("demo", plugin) { ctx -> - // drive fake scheduler or dispatch commands here -} -``` - - diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx deleted file mode 100644 index e83fb57..0000000 --- a/docs/getting-started.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Getting started ---- - -## Install (Gradle) - -KTale is a multi-module library. Most plugins will depend on: - -- `ktale-api` (interfaces/contracts) -- `ktale-core` (default implementations) - -Example (Kotlin DSL): - -```kotlin -dependencies { - implementation("cc.modlabs:ktale-api:") - implementation("cc.modlabs:ktale-core:") -} -``` - -## Gradle plugin repositories (if using `cc.modlabs.ktale-deps`) - -If you use the KTale Gradle plugin (`cc.modlabs.ktale-deps`), ensure your build can resolve it: - -```kotlin -// settings.gradle.kts -pluginManagement { - repositories { - gradlePluginPortal() - maven("https://nexus.modlabs.cc/repository/maven-public/") - mavenLocal() - } -} -``` - -## Your first plugin - -KTale plugin entrypoint is `ktale.api.KtalePlugin`: - -```kotlin -import ktale.api.KtalePlugin -import ktale.api.PluginContext - -class MyPlugin : KtalePlugin { - override fun onLoad(context: PluginContext) {} - override fun onEnable(context: PluginContext) {} - override fun onDisable(context: PluginContext) {} -} -``` - -### Java plugins - -All major contracts are Java-first: - -- schedulers use `Runnable` + `java.time.Duration` -- event subscription uses `Class` + `EventListener` -- services use `Class` for lookup/registration - -See [Java usage](./plugins-java.mdx). - - diff --git a/docs/gradle-plugin.mdx b/docs/gradle-plugin.mdx deleted file mode 100644 index e996e6f..0000000 --- a/docs/gradle-plugin.mdx +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: Gradle plugin (dependency manifest) ---- - -KTale includes a Gradle plugin that writes dependency manifests into your jar so a standalone host can download them. - -## Plugin id - -`cc.modlabs.ktale-deps` - -## Usage (Gradle Kotlin DSL) - -### Resolve the plugin - -Add the ModLabs repo to `pluginManagement`: - -```kotlin -// settings.gradle.kts -pluginManagement { - repositories { - gradlePluginPortal() - maven("https://nexus.modlabs.cc/repository/maven-public/") - mavenLocal() - } -} -``` - -### Configure `ktaleDeps` - -```kotlin -plugins { - id("cc.modlabs.ktale-deps") -} - -ktaleDeps { - // extra deps to record - deliver("com.foo:bar:1.2.3") - - // extra repos - repository("jitpack", "https://jitpack.io/") - - // repo with auth (env var names only) - repositoryWithAuth("private", "https://repo.example.com/maven/", "REPO_USER", "REPO_PASS") - - // optional: standalone host descriptor - pluginId.set("my-plugin") - mainClass.set("com.example.MyPlugin") -} -``` - -## Files emitted into the jar - -- `.dependencies` -- optional `.repositories` -- optional `ktale-plugin.properties` (if `pluginId` + `mainClass` are set) - - diff --git a/docs/index.mdx b/docs/index.mdx index ebb37ff..a2cd6ed 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,29 +1,12 @@ --- -title: KTale Documentation -description: Day-1-ready, speculative Kotlin server SDK foundation for Hytale +title: KTale --- -## What KTale is +KTale is a lightweight Kotlin-first helper library for building **Hytale Server plugins**. -KTale is a **speculative** Kotlin server SDK foundation for Hytale. The Hytale server API does not exist (for us) yet, so KTale is designed around: - -- **Adapter-based architecture**: stable core + replaceable platform adapters -- **Capabilities over inheritance**: small, composable interfaces -- **Testability without a real server**: a deterministic fake platform -- **Honest uncertainty**: anything speculative is isolated and removable - -## Where to start - -- [Plugin developer workflow (end-to-end)](./plugin-developer-workflow.mdx) -- [Getting started](./getting-started.mdx) -- [Modules](./modules.mdx) -- [Events](./events.mdx) -- [Commands](./commands.mdx) -- [Configs](./configs.mdx) -- [Fake platform & testing](./fake-platform-testing.mdx) -- [Standalone host (bundled server model)](./standalone-host.mdx) -- [Gradle plugin: dependency manifest](./gradle-plugin.mdx) -- [Auto-registration](./auto-registration.mdx) -- [Dokka + GitHub Pages](./dokka-and-pages.mdx) +This `/docs` folder is for human-written guides (MDX/Markdown). +API reference is generated from KDoc via **Dokka** and published to **GitHub Pages** by `.github/workflows/docs.yml`. +### Guides +- [Message formatting](./message-formatting.mdx) diff --git a/docs/message-formatting.mdx b/docs/message-formatting.mdx new file mode 100644 index 0000000..fe2c40e --- /dev/null +++ b/docs/message-formatting.mdx @@ -0,0 +1,63 @@ +--- +title: Message formatting +--- + +KTale provides a small MiniMessage-like formatting layer on top of Hytale’s `com.hypixel.hytale.server.core.Message`. + +### Quick start (recommended) +Use the `Player.send(String)` extension. It parses MiniMessage-like tags and sends the resulting `Message`. + +```kotlin +import cc.modlabs.ktale.ext.send + +player.send("Hello World") +player.send("Gradient text") +player.send("<#FFAA00>Hex colors") +``` + +### Raw vs formatted +- **Raw** (no parsing): + +```kotlin +import cc.modlabs.ktale.ext.sendRaw + +player.sendRaw("This will not parse tags") +``` + +- **Formatted** (MiniMessage-like): + +```kotlin +import cc.modlabs.ktale.ext.sendMini + +player.sendMini("Tags are parsed here") +``` + +### Building messages directly +If you want a `Message` object (e.g. to insert into other messages), use `MessageBuilder`: + +```kotlin +import cc.modlabs.ktale.text.MessageBuilder + +val msg = MessageBuilder.fromMiniMessage("Warning: Danger") +player.sendMessage(msg) +``` + +Or the fluent builder API: + +```kotlin +import cc.modlabs.ktale.text.MessageBuilder + +val msg = MessageBuilder.builder() + .miniMessage("Welcome") + .text(" to the server!") + .build() +``` + +### Supported tags (current) +- Colors: ``, ``, etc. + hex colors like `<#RRGGBB>` +- Formatting: ``, ``, `` +- Gradients: `...` (color names or hex) +- Links: `text` (maps to `Message.link(...)`) + +Anything else is currently ignored (by design) to keep this lightweight and aligned with the Hytale `Message` API. + diff --git a/docs/modules.mdx b/docs/modules.mdx deleted file mode 100644 index 12b1382..0000000 --- a/docs/modules.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Modules ---- - -KTale is a Kotlin multi-module project: - -## `ktale-api` - -Stable public **contracts only**: - -- plugin lifecycle (`KtalePlugin`) -- events (`EventBus`) -- scheduler (`Scheduler`) -- commands (`CommandRegistry`) -- configs (`ConfigManager`) -- logging (`KtaleLogger`) -- service registry (`ServiceRegistry`) -- capability contracts like models/prefabs/attributes/containers (keyed, not enum-based) - -## `ktale-core` - -Minimal default implementations (platform-neutral): - -- `SimpleEventBus` -- `SimpleCommandRegistry` + `Commands` builder -- `CoreConfigManager` (+ YAML codec helpers) -- `SimpleServiceRegistry` -- `CoreRuntime` wiring helper -- `AutoRegistrar` (optional convenience) - -## `ktale-platform` - -Platform boundary interfaces (no game logic): - -- `Platform`, `PlatformClock`, `PlatformSchedulerHooks`, `PlatformCommandBridge`, logger factory - -## `ktale-platform-fake` - -Deterministic fake platform for tests and demos: - -- `FakeServer`, `FakePlatform` -- deterministic clock + controllable scheduler -- command bridge for inbound dispatch simulation - -## `ktale-platform-hytale` - -Placeholder module: - -- `HytalePlatformPlaceholder` (fails loudly; TODO-only) -- `ktale.experimental.hypothesis` package (explicit speculation) - -## `ktale-runtime-deps` - -Runtime dependency manifest + Maven resolver utilities for the “standalone host” model. - -## `ktale-runtime-host` - -Standalone host utilities that can load plugin jars and build isolated classloaders. - -## `ktale-gradle-plugin` - -Gradle plugin that writes `.dependencies` / `.repositories` (and optionally `ktale-plugin.properties`) into jars. - - diff --git a/docs/plugin-developer-workflow.mdx b/docs/plugin-developer-workflow.mdx deleted file mode 100644 index 4d7aa54..0000000 --- a/docs/plugin-developer-workflow.mdx +++ /dev/null @@ -1,233 +0,0 @@ ---- -title: Plugin developer workflow (end-to-end) ---- - -This guide is a **full workflow** for KTale plugin developers: - -1) install dependencies -2) create an entrypoint -3) register commands + listeners (manual or plug-and-play) -4) use features (prefabs, models, attributes, containers, items) -5) test everything without a real server -6) optional: package for the standalone host model (dependency manifest + runtime resolution) - -## 0) Choose your host model (important) - -KTale supports two host models: - -- **Plugin/mod SDK hosted by an official server runtime** (future) -- **Bundled/standalone server distribution that embeds KTale** (available today via `ktale-runtime-host`) - -This guide shows both. If you are targeting a future official server, focus on `ktale-api` + `ktale-core` + `ktale-platform-*` and skip the “standalone host” sections. - -## 1) Install (Gradle) - -Most plugins depend on: - -- `ktale-api` -- `ktale-core` - -Example (Gradle Kotlin DSL): - -```kotlin -dependencies { - implementation("cc.modlabs:ktale-api:") - implementation("cc.modlabs:ktale-core:") -} -``` - -### Optional (standalone host packaging) - -If you want a clean, unshaded jar and let the standalone host download deps: - -#### Resolve the Gradle plugin - -```kotlin -// settings.gradle.kts -pluginManagement { - repositories { - gradlePluginPortal() - maven("https://nexus.modlabs.cc/repository/maven-public/") - mavenLocal() - } -} -``` - -```kotlin -plugins { - id("cc.modlabs.ktale-deps") -} - -ktaleDeps { - // Standalone host descriptor (required for ktale-runtime-host) - pluginId.set("my-plugin") - mainClass.set("com.example.MyPlugin") - - // Extra dependencies to record (in addition to first-level runtime deps) - deliver("com.foo:bar:1.2.3") - - // Extra repos - repository("jitpack", "https://jitpack.io/") - - // Repo with auth using env var names (secrets are NOT stored in the jar) - repositoryWithAuth("private", "https://repo.example.com/maven/", "REPO_USER", "REPO_PASS") -} -``` - -This emits into your jar: - -- `.dependencies` -- optional `.repositories` -- `ktale-plugin.properties` - -## 2) Create a plugin entrypoint - -Implement `ktale.api.KtalePlugin`: - -```kotlin -import ktale.api.KtalePlugin -import ktale.api.PluginContext - -class MyPlugin : KtalePlugin { - override fun onLoad(context: PluginContext) {} - override fun onEnable(context: PluginContext) {} - override fun onDisable(context: PluginContext) {} -} -``` - -## 3) Commands - -### Kotlin (DSL) - -```kotlin -import ktale.core.commands.Commands -import ktale.api.commands.CommandResult - -context.commands.register( - Commands.command("ping") { - aliases("p") - execute { CommandResult.Success } - } -) -``` - -### Java (fluent) - -```java -ctx.getCommands().register( - Commands.command("ping") - .aliases("p") - .executor(c -> CommandResult.Success.INSTANCE) - .build() -); -``` - -### Plug-and-play (standalone host) - -If you use the standalone host, you can avoid manual registration: - -```java -@AutoCommand -public final class Ping implements CommandDefinition { - public String getName() { return "ping"; } - public java.util.Set getAliases() { return java.util.Collections.emptySet(); } - public String getDescription() { return null; } - public Permission getPermission() { return null; } - public CommandResult execute(CommandContext ctx) { return CommandResult.Success.INSTANCE; } -} -``` - -The host scans and registers these automatically. - -## 4) Events / listeners - -### Manual subscription - -Kotlin: - -```kotlin -import ktale.api.events.subscribe - -context.events.subscribe { e -> - context.logger.info("got event") -} -``` - -Java: - -```java -ctx.getEvents().subscribe(MyEvent.class, EventPriority.NORMAL, (EventListener) e -> { - ctx.getLogger().info("got event"); -}); -``` - -### Plug-and-play listeners (standalone host) - -```java -public final class MyListeners { - @SubscribeEvent(MyEvent.class) - public void on(MyEvent e) { - // ... - } -} -``` - -## 5) Prefabs - -Prefabs are **host-dependent** and exposed as an optional capability: - -```kotlin -val store = context.prefabs -if (store != null) { - val ids = store.list() - // load/save/delete prefabs by Key -} -``` - -The standalone host currently does not provide a prefab store by default (it can be added later as a platform capability). - -## 6) Models, attributes, containers, items (capabilities) - -KTale models these features without committing to a specific server API: - -- **IDs are opaque strings** via `ktale.api.identity.Key` - - Example ID: `Cloth_Block_Wool_Blue` - - KTale preserves the string verbatim. -- **Models** via `HasModel` (keyed) -- **Stats** via `HasAttributes` / `Attribute` (health/mana are expected keys) -- **Containers** via `Container` / `ContainerKind` (equipment/backpack/brewery bag are kinds) -- **Items** via `ItemStack` (type is a `Key`) - -## 7) Testing without a real server - -Use the deterministic fake platform: - -```kotlin -import ktale.platform.fake.FakeServer - -val server = FakeServer() -val ctx = server.createContext("demo") - -// register commands/events/schedules -// advance clock / run scheduler deterministically -``` - -This is how you keep plugin logic testable even before a real Hytale server exists. - -## 8) Standalone host run (optional) - -If you’re experimenting with KTale as a **bundled server distribution**, the standalone host can load jars and auto-register: - -- reads `ktale-plugin.properties` -- resolves `.dependencies` / `.repositories` - - supports repo auth via env var names -- builds an isolated classloader -- runs lifecycle and auto-registration - -See: - -- [Standalone host](./standalone-host.mdx) -- [Dependency resolution](./dependency-resolution.mdx) -- [Auto-registration](./auto-registration.mdx) - - diff --git a/docs/plugins-java.mdx b/docs/plugins-java.mdx deleted file mode 100644 index 0d34038..0000000 --- a/docs/plugins-java.mdx +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Java plugin usage ---- - -KTale is designed so Java plugins can use the core APIs without Kotlin-only types. - -## Example usage - -```java -import ktale.api.PluginContext; -import ktale.api.commands.CommandContext; -import ktale.api.commands.CommandResult; -import ktale.api.events.EventListener; -import ktale.api.events.EventPriority; -import ktale.core.commands.Commands; - -import java.time.Duration; - -public final class JavaUsage { - public static void demo(PluginContext ctx) { - // Services - ctx.getServices().register(String.class, "hello"); - - // Events (Class + EventListener) - ctx.getEvents().subscribe(MyEvent.class, EventPriority.NORMAL, (EventListener) e -> - ctx.getLogger().info("event!") - ); - - // Commands (fluent builder in ktale-core) - ctx.getCommands().register( - Commands.command("ping") - .executor((CommandContext c) -> CommandResult.Success.INSTANCE) - .build() - ); - - // Scheduler (Runnable + java.time.Duration) - ctx.getScheduler().runSyncDelayed(Duration.ofMillis(250), () -> ctx.getLogger().info("tick")); - } - - public static final class MyEvent implements ktale.api.events.Event {} -} -``` - - diff --git a/docs/plugins-kotlin.mdx b/docs/plugins-kotlin.mdx deleted file mode 100644 index 888841e..0000000 --- a/docs/plugins-kotlin.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Kotlin plugin usage ---- - -## Core idea - -Plugins receive a `PluginContext` and interact only with capabilities: - -```kotlin -import ktale.api.KtalePlugin -import ktale.api.PluginContext -import ktale.api.events.subscribe -import ktale.core.commands.Commands -import java.time.Duration - -class DemoPlugin : KtalePlugin { - override fun onLoad(context: PluginContext) {} - - override fun onEnable(context: PluginContext) { - // Events - context.events.subscribe { context.logger.info("event!") } - - // Commands - context.commands.register( - Commands.command("ping") { - execute { CommandResult.Success } - } - ) - - // Scheduler - context.scheduler.runSyncDelayed(Duration.ofSeconds(1)) { - context.logger.info("tick") - } - } - - override fun onDisable(context: PluginContext) {} -} -``` - - diff --git a/docs/standalone-host.mdx b/docs/standalone-host.mdx deleted file mode 100644 index 2430663..0000000 --- a/docs/standalone-host.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Standalone host (bundled server model) ---- - -KTale supports an alternate “host model” where KTale is part of a **bundled/custom server distribution**. -In this model, plugins can remain **unshaded** and the host downloads dependencies at runtime. - -## Plugin packaging - -Standalone host expects plugin jars to contain: - -- `ktale-plugin.properties` - - `id=` - - `main=` (implements `ktale.api.KtalePlugin`) -- optional `.dependencies` / `.repositories` - -## Runtime pieces - -- `ktale-runtime-deps`: reads manifests + resolves Maven deps into a cache -- `ktale-runtime-host`: loads jars, builds isolated classloaders, instantiates `KtalePlugin` - -## Auto-registration - -Standalone host enables plug-and-play: - -- `@AutoCommand` on `CommandDefinition` classes -- `@SubscribeEvent` on listener methods - -See [Auto-registration](./auto-registration.mdx). - - diff --git a/gradle.properties b/gradle.properties index 36704cb..f087dc3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,11 @@ kotlin.code.style=official -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=true +kotlin.jvm.target.validation.mode=ignore + +# Required by build.gradle.kts (mirrors your plugin build convention). +# You can override with: ./gradlew -Dkotlin_version=... +systemProp.kotlin_version=2.2.21 + +# Hytale runs on JVM 25. Kotlin cannot emit classfile 25 yet (max is currently 24), +# so the build will clamp 25 -> 24 for Kotlin bytecode until Kotlin adds 25 support. +systemProp.ktale_jvm_target=25 \ No newline at end of file diff --git a/ktale-api/build.gradle.kts b/ktale-api/build.gradle.kts deleted file mode 100644 index eaa0da3..0000000 --- a/ktale-api/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -description = "KTale stable public contracts (interfaces only)." - -dependencies { - // No implementations in this module by design. -} - - diff --git a/ktale-api/src/main/java/ktale/api/autoregister/AutoCommand.java b/ktale-api/src/main/java/ktale/api/autoregister/AutoCommand.java deleted file mode 100644 index 553ecbe..0000000 --- a/ktale-api/src/main/java/ktale/api/autoregister/AutoCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package ktale.api.autoregister; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a {@code ktale.api.commands.CommandDefinition} class as eligible for auto-registration. - * - *

Standalone hosts can discover these classes in a plugin jar and register them without manual wiring. - * The class must have a public no-arg constructor.

- */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface AutoCommand {} - - diff --git a/ktale-api/src/main/java/ktale/api/autoregister/SubscribeEvent.java b/ktale-api/src/main/java/ktale/api/autoregister/SubscribeEvent.java deleted file mode 100644 index 0bf5521..0000000 --- a/ktale-api/src/main/java/ktale/api/autoregister/SubscribeEvent.java +++ /dev/null @@ -1,25 +0,0 @@ -package ktale.api.autoregister; - -import ktale.api.events.Event; -import ktale.api.events.EventPriority; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a method as an event subscriber for auto-registration. - * - *

Host runtimes may scan plugin jars and register these methods automatically. - * This annotation is intentionally in Java so both Java and Kotlin can use it cleanly.

- */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.METHOD) -public @interface SubscribeEvent { - Class value(); - EventPriority priority() default EventPriority.NORMAL; - boolean ignoreCancelled() default false; -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/KtalePlugin.kt b/ktale-api/src/main/kotlin/ktale/api/KtalePlugin.kt deleted file mode 100644 index 8095911..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/KtalePlugin.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ktale.api - -/** - * A KTale plugin entrypoint. - * - * ## Design note (intentional constraint) - * KTale treats "unknown server APIs" as a first-class constraint. - * - * This interface is intentionally small and stable: - * - Platform adapters provide a [PluginContext]. - * - Plugin code talks to contracts in `ktale-api`, not to platform types. - * - No assumptions are made about threading, tick loops, or IO models. - */ -public interface KtalePlugin { - /** - * Called when the plugin is discovered and constructed, before it is enabled. - * - * This phase is for wiring services and reading static metadata. - * Avoid registering listeners that assume a running server. - */ - public fun onLoad(context: PluginContext) - - /** - * Called when the plugin becomes active. - * - * This phase is for registering listeners, commands, and starting scheduled tasks. - */ - public fun onEnable(context: PluginContext) - - /** - * Called when the plugin is being disabled or unloaded. - * - * Implementations should cancel tasks and release resources. - */ - public fun onDisable(context: PluginContext) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/PluginContext.kt b/ktale-api/src/main/kotlin/ktale/api/PluginContext.kt deleted file mode 100644 index 0bdcb57..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/PluginContext.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ktale.api - -import ktale.api.commands.CommandRegistry -import ktale.api.config.ConfigManager -import ktale.api.events.EventBus -import ktale.api.inventory.Container -import ktale.api.logging.KtaleLogger -import ktale.api.prefabs.PrefabStore -import ktale.api.scheduler.Scheduler -import ktale.api.services.ServiceRegistry - -/** - * Per-plugin access to platform-provided facilities. - * - * ## Stability rules - * - This is the *only* object a plugin needs to keep around. - * - It is platform-agnostic: implementations live in platform adapters. - * - It is intentionally capability-oriented instead of exposing a giant "Server" object. - */ -public interface PluginContext { - /** A human-readable plugin identifier (stable across reloads). */ - public val pluginId: String - - /** A logger scoped to this plugin. */ - public val logger: KtaleLogger - - /** Event publishing and subscription. */ - public val events: EventBus - - /** Scheduling API for sync/async/delayed/repeating work. */ - public val scheduler: Scheduler - - /** Command registration and dispatch (logic only; IO bridges are platform-specific). */ - public val commands: CommandRegistry - - /** Typed configuration access with versioned migrations. */ - public val configs: ConfigManager - - /** - * Service registry used as "DI-light". - * - * This is intentionally runtime-based (not compile-time DI) to remain adaptable - * to unknown server/container lifecycles. - */ - public val services: ServiceRegistry - - /** - * Prefab storage/hosting, if the platform supports it. - * - * Platforms that do not expose prefabs can return `null`. - */ - public val prefabs: PrefabStore? -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/CommandContext.kt b/ktale-api/src/main/kotlin/ktale/api/commands/CommandContext.kt deleted file mode 100644 index bd74613..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/CommandContext.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ktale.api.commands - -/** - * Context for a command execution. - * - * ## Design note - * Parsing is intentionally not part of this contract. - * A platform adapter may provide only tokenized args, or pre-parsed structured args. - */ -public interface CommandContext { - /** Sender of the command. */ - public val sender: CommandSender - - /** The label used to invoke the command (may be an alias). */ - public val label: String - - /** Tokenized arguments after the label (no quoting guarantees). */ - public val args: List -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/CommandDefinition.kt b/ktale-api/src/main/kotlin/ktale/api/commands/CommandDefinition.kt deleted file mode 100644 index 402ae6e..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/CommandDefinition.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ktale.api.commands - -/** - * Declarative command definition. - * - * ## Design note - * This is a *pure definition type* (no IO). - * A DSL-friendly builder is expected to live in `ktale-core`, but this contract is shaped - * so that builders can target it without depending on any platform details. - */ -public interface CommandDefinition { - /** Primary name for the command (as registered). */ - public val name: String - - /** Alternative names that map to this definition. */ - public val aliases: Set - - /** Human-readable description. */ - public val description: String? - - /** Permission required to execute this command, if any. */ - public val permission: Permission? - - /** Executes the command. */ - public fun execute(context: CommandContext): CommandResult -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/CommandRegistry.kt b/ktale-api/src/main/kotlin/ktale/api/commands/CommandRegistry.kt deleted file mode 100644 index b0c45a8..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/CommandRegistry.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ktale.api.commands - -/** - * Registry for command definitions. - * - * Implementations must not perform IO directly; platform adapters bridge registration and input. - */ -public interface CommandRegistry { - /** - * Registers [definition]. - * - * @throws IllegalArgumentException if the command name or alias is invalid or conflicts. - */ - public fun register(definition: CommandDefinition) - - /** - * Unregisters a command by its primary name. - * - * Implementations should also remove aliases that point to the definition. - */ - public fun unregister(name: String) - - /** - * Dispatches a command execution. - * - * Platform adapters typically call this after receiving user input, providing a [context] - * that contains tokens and sender info. - */ - public fun dispatch(context: CommandContext): CommandResult -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/CommandResult.kt b/ktale-api/src/main/kotlin/ktale/api/commands/CommandResult.kt deleted file mode 100644 index b5f95c1..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/CommandResult.kt +++ /dev/null @@ -1,31 +0,0 @@ -package ktale.api.commands - -/** - * Result of executing a command. - * - * This is intentionally lightweight and does not prescribe a UX. - * Platforms may map these results to their own messaging conventions. - */ -public sealed interface CommandResult { - /** Indicates success. */ - public data object Success : CommandResult - - /** - * Indicates failure with a message suitable for end users. - * - * Platforms may choose to hide or transform messages. - */ - public interface Failure : CommandResult { - public val message: String - } - - /** Sender lacks required permission. */ - public data object NoPermission : Failure { - override val message: String = "You do not have permission to use that command." - } - - /** Provided arguments are invalid for the chosen command route. */ - public interface UsageError : Failure -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/CommandSender.kt b/ktale-api/src/main/kotlin/ktale/api/commands/CommandSender.kt deleted file mode 100644 index 84f20ce..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/CommandSender.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ktale.api.commands - -/** - * Entity that can execute commands (e.g. player, console, remote admin, script). - * - * KTale intentionally models this as a capability surface, not as a concrete actor hierarchy. - */ -public interface CommandSender { - /** Display name of the sender. */ - public val name: String - - /** Sends a message to the sender. */ - public fun sendMessage(message: String) - - /** - * Checks whether the sender has [permission]. - * - * Platforms decide whether permissions are hierarchical, wildcard-based, etc. - */ - public fun hasPermission(permission: Permission): Boolean -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/commands/Permission.kt b/ktale-api/src/main/kotlin/ktale/api/commands/Permission.kt deleted file mode 100644 index 9acaae2..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/commands/Permission.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ktale.api.commands - -/** - * Abstract permission identifier. - * - * KTale does not assume any specific permission engine. A platform adapter decides how - * permissions are checked and represented. - */ -public class Permission public constructor(public val value: String) { - override fun toString(): String = value - - override fun equals(other: Any?): Boolean = other is Permission && other.value == value - - override fun hashCode(): Int = value.hashCode() - - public companion object { - /** Java-friendly factory. */ - @JvmStatic - public fun of(value: String): Permission = Permission(value) - } -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/config/ConfigCodec.kt b/ktale-api/src/main/kotlin/ktale/api/config/ConfigCodec.kt deleted file mode 100644 index aee106e..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/config/ConfigCodec.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ktale.api.config - -/** - * Encodes and decodes a typed configuration object. - * - * ## Design note - * The codec is separated from the store to avoid committing KTale to a specific format (YAML/TOML/JSON), - * while still enabling typed config objects. - * - * Codecs can be replaced without changing plugin code that uses [ConfigKey]. - */ -public interface ConfigCodec { - /** Parses [text] into a typed config object. */ - public fun decode(text: String): T - - /** Serializes [value] into text suitable for storage. */ - public fun encode(value: T): String -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/config/ConfigKey.kt b/ktale-api/src/main/kotlin/ktale/api/config/ConfigKey.kt deleted file mode 100644 index b73ddb8..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/config/ConfigKey.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ktale.api.config - -/** - * Typed handle for a configuration file/document. - * - * @param T strongly-typed config object type - */ -public interface ConfigKey { - /** - * Stable identifier for the config (often a filename without extension). - * - * Platform adapters may map this to a file, database row, or other store. - */ - public val id: String - - /** Current schema version for this config. */ - public val version: Int - - /** Codec used to parse and serialize this config. */ - public val codec: ConfigCodec - - /** Default value used when config is missing or cannot be loaded. */ - public fun defaultValue(): T - - /** - * Migrations to apply when stored config version is older than [version]. - * - * Implementations should list migrations in ascending order. - */ - public val migrations: List -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/config/ConfigManager.kt b/ktale-api/src/main/kotlin/ktale/api/config/ConfigManager.kt deleted file mode 100644 index d59df90..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/config/ConfigManager.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ktale.api.config - -/** - * Typed configuration access. - * - * Implementations are responsible for: - * - locating storage - * - applying [ConfigMigration] steps - * - decoding via [ConfigCodec] - * - * The core module provides a default file-backed implementation; platforms may override. - */ -public interface ConfigManager { - /** - * Loads a configuration for [key], applying migrations if needed. - * - * Implementations should be resilient: when loading fails, return [ConfigKey.defaultValue] - * and log a diagnostic (platform-defined logging). - */ - public fun load(key: ConfigKey): T - - /** Saves [value] for [key] using [ConfigKey.codec]. */ - public fun save(key: ConfigKey, value: T) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/config/ConfigMigration.kt b/ktale-api/src/main/kotlin/ktale/api/config/ConfigMigration.kt deleted file mode 100644 index 9a264e9..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/config/ConfigMigration.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ktale.api.config - -/** - * A versioned migration from one schema version to the next. - * - * ## Design note - * Migrations operate on *text* to avoid locking KTale into a particular parsing model. - * Core may provide higher-level helpers for structured migrations, but the public contract stays minimal. - */ -public interface ConfigMigration { - /** Schema version this migration expects. */ - public val fromVersion: Int - - /** Schema version after this migration is applied. Usually `fromVersion + 1`. */ - public val toVersion: Int - - /** - * Applies the migration. - * - * @param oldText config content at [fromVersion] - * @return migrated config content at [toVersion] - */ - public fun migrate(oldText: String): String -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/entities/EntityRef.kt b/ktale-api/src/main/kotlin/ktale/api/entities/EntityRef.kt deleted file mode 100644 index 9834fdb..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/entities/EntityRef.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ktale.api.entities - -import ktale.api.identity.Key - -/** - * Minimal reference to an entity-like thing in the host runtime. - * - * ## Design note - * KTale intentionally does not model a full entity system. - * This is just a stable handle plugins can pass around. - */ -public interface EntityRef { - /** Stable identifier for this entity within the host runtime. */ - public val id: Key - - /** - * Broad kind/type identifier for routing/logging. - * - * This is not a class hierarchy; it's a registry-style key. - */ - public val kind: Key -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/entities/HasModel.kt b/ktale-api/src/main/kotlin/ktale/api/entities/HasModel.kt deleted file mode 100644 index cde6446..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/entities/HasModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ktale.api.entities - -import ktale.api.identity.Key - -/** - * Capability: the entity can expose and/or change its "model". - * - * ## Design note - * The word "model" is used because that's what we *expect* the game UI exposes, - * but the actual host mapping is unknown. This capability stays generic: - * a model is just a [Key] into a host-provided catalog. - */ -public interface HasModel { - /** Current model key. */ - public fun model(): Key - - /** - * Requests a model change. - * - * Platforms decide validation rules and whether this is instant or eventual. - */ - public fun setModel(model: Key) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/Cancellable.kt b/ktale-api/src/main/kotlin/ktale/api/events/Cancellable.kt deleted file mode 100644 index e8b1cb9..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/Cancellable.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ktale.api.events - -/** - * Capability for events that can be cancelled. - * - * Cancellation semantics are event-defined. - * For example, a platform adapter might treat cancellation as "do not execute default behavior", - * while other events may use it as a hint to later phases. - */ -public interface Cancellable { - /** Whether the event has been cancelled. */ - public var isCancelled: Boolean -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/Event.kt b/ktale-api/src/main/kotlin/ktale/api/events/Event.kt deleted file mode 100644 index 29e6904..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/Event.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ktale.api.events - -/** - * Marker interface for events. - * - * Events are plain objects; KTale makes no assumptions about inheritance trees, - * and avoids "entity-model" coupling. - */ -public interface Event - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/EventBus.kt b/ktale-api/src/main/kotlin/ktale/api/events/EventBus.kt deleted file mode 100644 index c5f1b89..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/EventBus.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ktale.api.events - -/** - * Event publishing and subscription. - * - * ## Key constraints - * - No dependency on a real server runtime. - * - No assumptions about threading: platforms decide what "sync" means. - * - Subscription is type-based and capability-oriented (e.g. [Cancellable]). - * - * Implementations should be deterministic and test-friendly. - */ -public interface EventBus { - /** - * Publishes an event to all listeners. - * - * @return the same [event] instance for convenience. - */ - public fun post(event: E): E - - /** - * Subscribes a listener for a specific event type. - * - * @param type the exact event class to subscribe to - * @param priority relative order of invocation - * @param ignoreCancelled if `true`, the listener is skipped when [event] is [Cancellable] and cancelled - */ - public fun subscribe( - type: Class, - listener: EventListener, - priority: EventPriority, - ignoreCancelled: Boolean, - ): EventSubscription - - /** - * Java-friendly overload: defaults to [EventPriority.NORMAL], not ignoring cancelled events. - */ - public fun subscribe(type: Class, listener: EventListener): EventSubscription = - subscribe(type, listener, EventPriority.NORMAL, ignoreCancelled = false) - - /** - * Java-friendly overload: defaults to not ignoring cancelled events. - */ - public fun subscribe(type: Class, priority: EventPriority, listener: EventListener): EventSubscription = - subscribe(type, listener, priority, ignoreCancelled = false) -} - -/** - * Kotlin convenience overload. - */ -public inline fun EventBus.subscribe( - priority: EventPriority = EventPriority.NORMAL, - ignoreCancelled: Boolean = false, - noinline listener: (E) -> Unit, -): EventSubscription = subscribe(E::class.java, EventListener(listener), priority, ignoreCancelled) - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/EventListener.kt b/ktale-api/src/main/kotlin/ktale/api/events/EventListener.kt deleted file mode 100644 index ea2d986..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/EventListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ktale.api.events - -/** - * Java-friendly event listener functional interface. - * - * Kotlin users can still pass lambdas naturally; Java users can pass lambdas or method references - * without touching Kotlin function types. - */ -public fun interface EventListener { - public fun onEvent(event: E) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/EventPriority.kt b/ktale-api/src/main/kotlin/ktale/api/events/EventPriority.kt deleted file mode 100644 index af8fe69..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/EventPriority.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ktale.api.events - -/** - * Relative ordering for event listeners. - * - * ## Design note - * This is intentionally *not* modeled after any existing game plugin API. - * The goal is to provide a generic ordering mechanism that can map onto - * any future platform behavior. - */ -public enum class EventPriority { - /** - * Earliest observers. - * - * Typical use: validation, cheap pre-checks, early routing. - */ - EARLY, - - /** Default priority for most listeners. */ - NORMAL, - - /** - * Later observers. - * - * Typical use: modifications that should see effects from NORMAL listeners. - */ - LATE, - - /** - * Last observers. - * - * Typical use: metrics, logging, state mirroring (avoid mutating event here). - */ - FINAL, -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/events/EventSubscription.kt b/ktale-api/src/main/kotlin/ktale/api/events/EventSubscription.kt deleted file mode 100644 index 3d13292..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/events/EventSubscription.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ktale.api.events - -/** - * Handle for a listener registration. - * - * Implementations must make [unsubscribe] idempotent. - */ -public interface EventSubscription { - /** Unregisters the listener. Safe to call multiple times. */ - public fun unsubscribe() -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/identity/Key.kt b/ktale-api/src/main/kotlin/ktale/api/identity/Key.kt deleted file mode 100644 index f064ed8..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/identity/Key.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ktale.api.identity - -/** - * Opaque identifier for game-facing registries (items, blocks, materials, models, prefabs, etc.). - * - * ## Design note - * - This is intentionally *not* an enum: registries may be huge and may evolve. - * - This is intentionally *not* tied to any host API: it can map to strings, hashes, resource locations, etc. - * - Java-friendly: this is a normal class (not a Kotlin value class). - * - * ## Example - * IDs can be plain strings such as `Cloth_Block_Wool_Blue` (as seen in Hytale UI/tooling). - * KTale preserves the string verbatim; it does not enforce casing or separators. - */ -public class Key public constructor(public val value: String) { - init { - require(value.isNotBlank()) { "Key must not be blank" } - } - - override fun toString(): String = value - override fun equals(other: Any?): Boolean = other is Key && other.value == value - override fun hashCode(): Int = value.hashCode() - - public companion object { - @JvmStatic - public fun of(value: String): Key = Key(value) - } -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/inventory/Container.kt b/ktale-api/src/main/kotlin/ktale/api/inventory/Container.kt deleted file mode 100644 index 006eead..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/inventory/Container.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ktale.api.inventory - -import ktale.api.identity.Key -import ktale.api.items.ItemStack - -/** - * Minimal container/inventory abstraction. - * - * ## Design note - * KTale doesn't assume a particular slot model (grid vs list vs equipment). This is a generic interface - * that can represent backpacks, equipment, bags, etc. through [ContainerKind] and slot indexing. - */ -public interface Container { - /** Stable identifier for this container (best-effort). */ - public val id: Key - - /** Kind identifier (e.g. "equipment", "backpack", "brewery_bag"). */ - public val kind: ContainerKind - - /** Number of slots in this container. */ - public fun size(): Int - - public fun get(slot: Int): ItemStack? - public fun set(slot: Int, stack: ItemStack?) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/inventory/ContainerKind.kt b/ktale-api/src/main/kotlin/ktale/api/inventory/ContainerKind.kt deleted file mode 100644 index 6edc5b5..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/inventory/ContainerKind.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ktale.api.inventory - -import ktale.api.identity.Key - -/** - * A container kind identifier. - * - * This is a [Key]-backed identifier to avoid hardcoding all possible container types. - */ -public class ContainerKind public constructor(public val key: Key) { - override fun toString(): String = key.toString() - override fun equals(other: Any?): Boolean = other is ContainerKind && other.key == key - override fun hashCode(): Int = key.hashCode() - - public companion object { - @JvmStatic public fun of(value: String): ContainerKind = ContainerKind(Key.of(value)) - } -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/items/ItemStack.kt b/ktale-api/src/main/kotlin/ktale/api/items/ItemStack.kt deleted file mode 100644 index 6eb0a21..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/items/ItemStack.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ktale.api.items - -import ktale.api.identity.Key - -/** - * Minimal stack of an [ItemType]. - * - * ## Design note - * Metadata is modeled as a string-keyed map to avoid committing to a host NBT/JSON/etc system. - * - * ## ID note - * The [type] key is an opaque string identifier; IDs like `Cloth_Block_Wool_Blue` are valid. - */ -public interface ItemStack { - public val type: Key - public val amount: Int - public val meta: Map -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/items/ItemType.kt b/ktale-api/src/main/kotlin/ktale/api/items/ItemType.kt deleted file mode 100644 index 0016b78..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/items/ItemType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ktale.api.items - -import ktale.api.identity.Key - -/** - * Item/material/block identifiers are modeled as [Key]s instead of enums. - * - * This avoids rewriting the SDK when the host's registry shape is known. - */ -public interface ItemType { - public val key: Key - public val displayName: String? -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/logging/KtaleLogger.kt b/ktale-api/src/main/kotlin/ktale/api/logging/KtaleLogger.kt deleted file mode 100644 index 1e9d735..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/logging/KtaleLogger.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ktale.api.logging - -/** - * Logging abstraction. - * - * ## Design note - * KTale avoids prescribing a logging backend. Platforms may route logs to: - * - console - * - file - * - structured telemetry - * - remote sinks - * - * Implementations should treat [context] as *optional structured data*. - */ -public interface KtaleLogger { - /** Logs a message at [level] with optional structured [context]. */ - public fun log(level: LogLevel, message: String, throwable: Throwable?, context: Map) - - /** Java-friendly overload. */ - public fun log(level: LogLevel, message: String) { - log(level, message, null, emptyMap()) - } - - /** Java-friendly overload. */ - public fun log(level: LogLevel, message: String, throwable: Throwable?) { - log(level, message, throwable, emptyMap()) - } - - public fun trace(message: String) = log(LogLevel.TRACE, message) - public fun trace(message: String, throwable: Throwable?) = log(LogLevel.TRACE, message, throwable) - public fun trace(message: String, throwable: Throwable?, context: Map) = - log(LogLevel.TRACE, message, throwable, context) - - public fun debug(message: String) = log(LogLevel.DEBUG, message) - public fun debug(message: String, throwable: Throwable?) = log(LogLevel.DEBUG, message, throwable) - public fun debug(message: String, throwable: Throwable?, context: Map) = - log(LogLevel.DEBUG, message, throwable, context) - - public fun info(message: String) = log(LogLevel.INFO, message) - public fun info(message: String, throwable: Throwable?) = log(LogLevel.INFO, message, throwable) - public fun info(message: String, throwable: Throwable?, context: Map) = - log(LogLevel.INFO, message, throwable, context) - - public fun warn(message: String) = log(LogLevel.WARN, message) - public fun warn(message: String, throwable: Throwable?) = log(LogLevel.WARN, message, throwable) - public fun warn(message: String, throwable: Throwable?, context: Map) = - log(LogLevel.WARN, message, throwable, context) - - public fun error(message: String) = log(LogLevel.ERROR, message) - public fun error(message: String, throwable: Throwable?) = log(LogLevel.ERROR, message, throwable) - public fun error(message: String, throwable: Throwable?, context: Map) = - log(LogLevel.ERROR, message, throwable, context) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/logging/LogLevel.kt b/ktale-api/src/main/kotlin/ktale/api/logging/LogLevel.kt deleted file mode 100644 index 1aa9f85..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/logging/LogLevel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ktale.api.logging - -/** - * Logging severity. - */ -public enum class LogLevel { - TRACE, - DEBUG, - INFO, - WARN, - ERROR, -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/prefabs/Prefab.kt b/ktale-api/src/main/kotlin/ktale/api/prefabs/Prefab.kt deleted file mode 100644 index 9517140..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/prefabs/Prefab.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ktale.api.prefabs - -import ktale.api.identity.Key - -/** - * Opaque prefab payload. - * - * ## Design note - * KTale does not assume the prefab format (binary/JSON/custom). - * The payload is treated as opaque bytes plus a best-effort [formatHint]. - */ -public interface Prefab { - public val id: Key - public val formatHint: String? - public val bytes: ByteArray -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/prefabs/PrefabStore.kt b/ktale-api/src/main/kotlin/ktale/api/prefabs/PrefabStore.kt deleted file mode 100644 index a853238..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/prefabs/PrefabStore.kt +++ /dev/null @@ -1,29 +0,0 @@ -package ktale.api.prefabs - -import ktale.api.identity.Key - -/** - * Storage/hosting for prefabs. - * - * ## Design note - * We know "prefabs exist" as a concept, but we do not know: - * - whether the host stores them on disk, in memory, or streams them - * - whether they are world-scoped, server-scoped, or per-player - * - * This contract stays minimal and uses opaque [Prefab] data. - */ -public interface PrefabStore { - /** Returns known prefab ids. */ - public fun list(): List - - /** Loads a prefab by id, or `null` if missing. */ - public fun load(id: Key): Prefab? - - /** Saves or overwrites a prefab. */ - public fun save(prefab: Prefab) - - /** Deletes a prefab if present. */ - public fun delete(id: Key) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/scheduler/Scheduler.kt b/ktale-api/src/main/kotlin/ktale/api/scheduler/Scheduler.kt deleted file mode 100644 index acdad93..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/scheduler/Scheduler.kt +++ /dev/null @@ -1,45 +0,0 @@ -package ktale.api.scheduler - -import java.time.Duration - -/** - * Schedules work in sync/async contexts. - * - * ## Design note - * KTale does not assume a tick loop, thread affinity, or coroutine availability. - * "Sync" and "async" are *platform-defined* concepts; a platform adapter decides what - * constitutes the main thread (if any), and how async is executed. - */ -public interface Scheduler { - /** Runs [task] as soon as possible in the platform's "sync" context. */ - public fun runSync(task: Runnable): TaskHandle - - /** Runs [task] as soon as possible in the platform's "async" context. */ - public fun runAsync(task: Runnable): TaskHandle - - /** Runs [task] once after [delay] in the platform's "sync" context. */ - public fun runSyncDelayed(delay: Duration, task: Runnable): TaskHandle - - /** Runs [task] once after [delay] in the platform's "async" context. */ - public fun runAsyncDelayed(delay: Duration, task: Runnable): TaskHandle - - /** - * Runs [task] repeatedly with the given [interval] in the platform's "sync" context. - * - * Implementations should attempt to avoid drift, but exact semantics are platform-defined. - */ - public fun runSyncRepeating( - initialDelay: Duration, - interval: Duration, - task: Runnable, - ): TaskHandle - - /** Async variant of [runSyncRepeating]. */ - public fun runAsyncRepeating( - initialDelay: Duration, - interval: Duration, - task: Runnable, - ): TaskHandle -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/scheduler/SchedulerKotlin.kt b/ktale-api/src/main/kotlin/ktale/api/scheduler/SchedulerKotlin.kt deleted file mode 100644 index 57c078f..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/scheduler/SchedulerKotlin.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ktale.api.scheduler - -import java.time.Duration - -/** - * Kotlin convenience overloads for [Scheduler]. - * - * These helpers exist so that the *public contracts* can remain Java-first (Runnable / java.time.Duration), - * while Kotlin call sites still feel natural. - */ -public fun Scheduler.runSync(task: () -> Unit): TaskHandle = runSync(Runnable(task)) - -public fun Scheduler.runAsync(task: () -> Unit): TaskHandle = runAsync(Runnable(task)) - -public fun Scheduler.runSyncDelayed(delay: Duration, task: () -> Unit): TaskHandle = - runSyncDelayed(delay, Runnable(task)) - -public fun Scheduler.runAsyncDelayed(delay: Duration, task: () -> Unit): TaskHandle = - runAsyncDelayed(delay, Runnable(task)) - -public fun Scheduler.runSyncRepeating(initialDelay: Duration, interval: Duration, task: () -> Unit): TaskHandle = - runSyncRepeating(initialDelay, interval, Runnable(task)) - -public fun Scheduler.runAsyncRepeating(initialDelay: Duration, interval: Duration, task: () -> Unit): TaskHandle = - runAsyncRepeating(initialDelay, interval, Runnable(task)) - - diff --git a/ktale-api/src/main/kotlin/ktale/api/scheduler/TaskHandle.kt b/ktale-api/src/main/kotlin/ktale/api/scheduler/TaskHandle.kt deleted file mode 100644 index ae1f831..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/scheduler/TaskHandle.kt +++ /dev/null @@ -1,16 +0,0 @@ -package ktale.api.scheduler - -/** - * Handle for a scheduled task. - * - * Implementations must make [cancel] idempotent. - */ -public interface TaskHandle { - /** Cancels the task. Safe to call multiple times. */ - public fun cancel() - - /** Whether [cancel] has been requested. */ - public val isCancelled: Boolean -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/services/ServiceRegistry.kt b/ktale-api/src/main/kotlin/ktale/api/services/ServiceRegistry.kt deleted file mode 100644 index 0c7dfda..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/services/ServiceRegistry.kt +++ /dev/null @@ -1,46 +0,0 @@ -package ktale.api.services - -/** - * Minimal service registry ("DI-light"). - * - * ## Design note - * A runtime registry keeps KTale adaptable to unknown host/container lifecycles. - * This avoids forcing a DI framework choice on Day 1. - */ -public interface ServiceRegistry { - /** - * Registers a service instance. - * - * @param replace if `true`, replaces any existing service of the same [type] - * @throws IllegalStateException if a service already exists and [replace] is `false` - */ - public fun register(type: Class, instance: T, replace: Boolean) - - /** Java-friendly overload (does not replace). */ - public fun register(type: Class, instance: T) { - register(type, instance, replace = false) - } - - /** Returns a service instance if registered, otherwise `null`. */ - public fun get(type: Class): T? - - /** Returns a service instance or throws if missing. */ - public fun require(type: Class): T - - /** Unregisters a service by type. */ - public fun unregister(type: Class) -} - -public inline fun ServiceRegistry.register(instance: T, replace: Boolean = false) { - register(T::class.java, instance, replace) -} - -public inline fun ServiceRegistry.get(): T? = get(T::class.java) - -public inline fun ServiceRegistry.require(): T = require(T::class.java) - -public inline fun ServiceRegistry.unregister() { - unregister(T::class.java) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/stats/Attribute.kt b/ktale-api/src/main/kotlin/ktale/api/stats/Attribute.kt deleted file mode 100644 index 2019a18..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/stats/Attribute.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ktale.api.stats - -import ktale.api.identity.Key - -/** - * Minimal numeric attribute. - * - * ## Examples - * - health (current/max) - * - mana (current/max) - * - * ## Design note - * The host may represent these differently; this is a capability surface for plugins. - */ -public interface Attribute { - public val key: Key - public fun current(): Double - public fun max(): Double? - public fun setCurrent(value: Double) - public fun setMax(value: Double) -} - - diff --git a/ktale-api/src/main/kotlin/ktale/api/stats/HasAttributes.kt b/ktale-api/src/main/kotlin/ktale/api/stats/HasAttributes.kt deleted file mode 100644 index 863c67e..0000000 --- a/ktale-api/src/main/kotlin/ktale/api/stats/HasAttributes.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ktale.api.stats - -import ktale.api.identity.Key - -/** - * Capability: exposes a set of numeric attributes (health, mana, etc.). - * - * ## Design note - * We avoid hardcoding a full stat system; attributes are resolved by [Key]. - */ -public interface HasAttributes { - /** Returns an attribute by key, or `null` if not supported. */ - public fun attribute(key: Key): Attribute? - - /** Returns supported attribute keys (best-effort). */ - public fun attributes(): List -} - - diff --git a/ktale-core/build.gradle.kts b/ktale-core/build.gradle.kts deleted file mode 100644 index 6d504de..0000000 --- a/ktale-core/build.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -description = "KTale minimal default implementations (platform-neutral)." - -plugins { - kotlin("plugin.serialization") -} - -dependencies { - implementation(project(":ktale-api")) - implementation(project(":ktale-platform")) - - // Core must stay platform-agnostic; config parsing lives here behind KTale codecs. - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0") - implementation("com.charleskorn.kaml:kaml:0.76.0") -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/PluginContexts.kt b/ktale-core/src/main/kotlin/ktale/core/PluginContexts.kt deleted file mode 100644 index 6bdf9e1..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/PluginContexts.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ktale.core - -import ktale.api.PluginContext -import ktale.api.commands.CommandRegistry -import ktale.api.config.ConfigManager -import ktale.api.events.EventBus -import ktale.api.prefabs.PrefabStore -import ktale.api.logging.KtaleLogger -import ktale.api.scheduler.Scheduler -import ktale.api.services.ServiceRegistry -import ktale.platform.Platform - -/** - * Default core implementation of [PluginContext]. - * - * ## Design note - * This is intentionally "dumb wiring": - * it composes capability interfaces without introducing extra lifecycle assumptions. - */ -public class DefaultPluginContext( - override val pluginId: String, - platform: Platform, - override val events: EventBus, - override val scheduler: Scheduler, - override val commands: CommandRegistry, - override val configs: ConfigManager, - override val services: ServiceRegistry, - override val prefabs: PrefabStore? = null, -) : PluginContext { - override val logger: KtaleLogger = platform.loggers.logger(pluginId) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/autoregister/AutoRegistrar.kt b/ktale-core/src/main/kotlin/ktale/core/autoregister/AutoRegistrar.kt deleted file mode 100644 index f58ba77..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/autoregister/AutoRegistrar.kt +++ /dev/null @@ -1,100 +0,0 @@ -package ktale.core.autoregister - -import ktale.api.PluginContext -import ktale.api.autoregister.AutoCommand -import ktale.api.autoregister.SubscribeEvent -import ktale.api.commands.CommandDefinition -import ktale.api.events.Event -import ktale.api.events.EventListener -import ktale.api.events.EventPriority -import java.lang.reflect.Method -import java.nio.file.Path -import java.util.jar.JarFile - -/** - * Plug-and-play auto registration for commands and event listeners. - * - * ## Design note - * This lives in `ktale-core` because it is a convenience implementation, not a stable contract. - * It is safe to omit in other host runtimes. - * - * ## Rules (conventions) - * - Commands: - * - classes that implement [CommandDefinition] - * - annotated with [AutoCommand] - * - public no-arg constructor - * - Event listeners: - * - any class with methods annotated [SubscribeEvent] - * - listener class must have a public no-arg constructor - */ -public object AutoRegistrar { - public fun registerAllFromJar(pluginJar: Path, classLoader: ClassLoader, context: PluginContext) { - val classNames = readClassNames(pluginJar) - val classes = classNames.mapNotNull { name -> - try { - Class.forName(name, true, classLoader) - } catch (_: Throwable) { - null - } - } - registerAllFromClasses(classes, context) - } - - public fun registerAllFromClasses(classes: List>, context: PluginContext) { - classes.forEach { clazz -> - tryRegisterCommand(clazz, context) - tryRegisterListener(clazz, context) - } - } - - private fun readClassNames(jar: Path): List = - JarFile(jar.toFile()).use { jf -> - jf.entries().asSequence() - .filter { !it.isDirectory } - .map { it.name } - .filter { it.endsWith(".class") } - .filter { !it.contains('$') } // skip inner/anonymous classes by default - .map { it.removeSuffix(".class").replace('/', '.') } - .toList() - } - - private fun tryRegisterCommand(clazz: Class<*>, context: PluginContext) { - if (!CommandDefinition::class.java.isAssignableFrom(clazz)) return - if (!clazz.isAnnotationPresent(AutoCommand::class.java)) return - val ctor = clazz.getDeclaredConstructor() - ctor.isAccessible = true - val def = ctor.newInstance() as CommandDefinition - context.commands.register(def) - } - - private fun tryRegisterListener(clazz: Class<*>, context: PluginContext) { - val methods = clazz.declaredMethods.filter { it.isAnnotationPresent(SubscribeEvent::class.java) } - if (methods.isEmpty()) return - - val ctor = clazz.getDeclaredConstructor() - ctor.isAccessible = true - val instance = ctor.newInstance() - for (m in methods) { - registerMethodListener(instance, m, context) - } - } - - private fun registerMethodListener(instance: Any, method: Method, context: PluginContext) { - val ann = method.getAnnotation(SubscribeEvent::class.java) - val eventType = ann.value.java - val priority: EventPriority = ann.priority - val ignoreCancelled: Boolean = ann.ignoreCancelled - - method.isAccessible = true - - @Suppress("UNCHECKED_CAST") - context.events.subscribe( - eventType as Class, - EventListener { e -> method.invoke(instance, e) }, - priority, - ignoreCancelled, - ) - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/commands/BridgedCommandRegistry.kt b/ktale-core/src/main/kotlin/ktale/core/commands/BridgedCommandRegistry.kt deleted file mode 100644 index 2b4f668..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/commands/BridgedCommandRegistry.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ktale.core.commands - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandRegistry -import ktale.api.commands.CommandResult -import ktale.platform.PlatformCommandBridge - -/** - * Command registry wrapper that notifies a [PlatformCommandBridge] on registration changes. - * - * ## Design note - * `ktale-api` intentionally keeps command contracts IO-free. This wrapper is the point where - * core can *optionally* bridge command registration into a host runtime without polluting - * the logic-only registry itself. - */ -public class BridgedCommandRegistry( - private val delegate: CommandRegistry, - private val bridge: PlatformCommandBridge, -) : CommandRegistry { - init { - bridge.bind(this) - } - - override fun register(definition: CommandDefinition) { - delegate.register(definition) - bridge.onRegister(definition) - } - - override fun unregister(name: String) { - delegate.unregister(name) - bridge.onUnregister(name) - } - - override fun dispatch(context: CommandContext): CommandResult = delegate.dispatch(context) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/commands/CommandExecutor.kt b/ktale-core/src/main/kotlin/ktale/core/commands/CommandExecutor.kt deleted file mode 100644 index bbd5a2d..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/commands/CommandExecutor.kt +++ /dev/null @@ -1,16 +0,0 @@ -package ktale.core.commands - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandResult - -/** - * Java-friendly command executor functional interface. - * - * `ktale-api` exposes [ktale.api.commands.CommandDefinition.execute] as a Kotlin method, - * but Java plugins typically prefer functional interfaces for wiring. - */ -public fun interface CommandExecutor { - public fun execute(context: CommandContext): CommandResult -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/commands/Commands.kt b/ktale-core/src/main/kotlin/ktale/core/commands/Commands.kt deleted file mode 100644 index b2f7192..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/commands/Commands.kt +++ /dev/null @@ -1,102 +0,0 @@ -package ktale.core.commands - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandResult -import ktale.api.commands.Permission - -/** - * Command builders for both Kotlin and Java. - * - * ## Design note - * - This lives in `ktale-core` because it is an implementation detail (builders create [CommandDefinition] instances). - * - `ktale-api` stays implementation-free. - */ -public object Commands { - /** - * Java-friendly fluent builder entrypoint. - * - * Example (Java): - * `Commands.command("ping").executor(ctx -> CommandResult.Success).build()` - */ - @JvmStatic - public fun command(name: String): FluentBuilder = FluentBuilder(name) - - /** - * Kotlin DSL entrypoint. - * - * Example (Kotlin): - * `command("ping") { execute { CommandResult.Success } }` - */ - public fun command(name: String, block: DslBuilder.() -> Unit): CommandDefinition = - DslBuilder(name).apply(block).build() - - public class FluentBuilder internal constructor( - private val name: String, - ) { - private val aliases: MutableSet = linkedSetOf() - private var description: String? = null - private var permission: Permission? = null - private var executor: CommandExecutor? = null - - public fun aliases(vararg aliases: String): FluentBuilder = apply { this.aliases += aliases } - public fun description(description: String?): FluentBuilder = apply { this.description = description } - public fun permission(permission: Permission?): FluentBuilder = apply { this.permission = permission } - public fun executor(executor: CommandExecutor): FluentBuilder = apply { this.executor = executor } - - public fun build(): CommandDefinition { - val exec = executor ?: error("Commands.command('$name') is missing an executor") - return SimpleCommandDefinition( - name = name, - aliases = aliases.toSet(), - description = description, - permission = permission, - executor = exec, - ) - } - } - - @DslMarker - public annotation class CommandDsl - - @CommandDsl - public class DslBuilder internal constructor( - private val name: String, - ) { - private val aliases: MutableSet = linkedSetOf() - public var description: String? = null - public var permission: Permission? = null - private var executor: CommandExecutor? = null - - public fun aliases(vararg aliases: String) { - this.aliases += aliases - } - - public fun execute(block: (CommandContext) -> CommandResult) { - executor = CommandExecutor(block) - } - - public fun build(): CommandDefinition { - val exec = executor ?: error("command('$name') is missing an execute { ... } block") - return SimpleCommandDefinition( - name = name, - aliases = aliases.toSet(), - description = description, - permission = permission, - executor = exec, - ) - } - } - - private class SimpleCommandDefinition( - override val name: String, - override val aliases: Set, - override val description: String?, - override val permission: Permission?, - private val executor: CommandExecutor, - ) : CommandDefinition { - override fun execute(context: CommandContext): CommandResult = executor.execute(context) - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/commands/SimpleCommandRegistry.kt b/ktale-core/src/main/kotlin/ktale/core/commands/SimpleCommandRegistry.kt deleted file mode 100644 index f08f103..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/commands/SimpleCommandRegistry.kt +++ /dev/null @@ -1,67 +0,0 @@ -package ktale.core.commands - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandRegistry -import ktale.api.commands.CommandResult -import ktale.api.commands.Permission -import java.util.concurrent.ConcurrentHashMap - -/** - * Minimal in-memory command registry and dispatcher (logic only). - * - * ## Design note - * - No IO: platforms handle input and output routing. - * - No parsing engine: dispatch uses [CommandContext.args] as already-tokenized input. - * - No hierarchy assumptions: commands are flat and may implement their own sub-routing. - */ -public class SimpleCommandRegistry : CommandRegistry { - private val byName = ConcurrentHashMap() - private val aliasToName = ConcurrentHashMap() - - override fun register(definition: CommandDefinition) { - val name = normalize(definition.name) - require(name.isNotBlank()) { "Command name must not be blank" } - - if (byName.putIfAbsent(name, definition) != null) { - throw IllegalArgumentException("Command already registered: $name") - } - - for (aliasRaw in definition.aliases) { - val alias = normalize(aliasRaw) - require(alias.isNotBlank()) { "Alias must not be blank" } - if (alias == name) continue - val existing = aliasToName.putIfAbsent(alias, name) - if (existing != null) { - byName.remove(name) - aliasToName.entries.removeIf { it.value == name } - throw IllegalArgumentException("Alias '$alias' already registered for '$existing'") - } - } - } - - override fun unregister(name: String) { - val key = normalize(name) - byName.remove(key) - aliasToName.entries.removeIf { it.value == key } - } - - override fun dispatch(context: CommandContext): CommandResult { - val key = normalize(context.label) - val name = aliasToName[key] ?: key - val def = byName[name] ?: return UnknownCommand(name) - - val perm = def.permission - if (perm != null && !context.sender.hasPermission(perm)) return CommandResult.NoPermission - - return def.execute(context) - } - - private fun normalize(s: String): String = s.trim().lowercase() - - private data class UnknownCommand(val name: String) : CommandResult.UsageError { - override val message: String = "Unknown command: $name" - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/ConfigTextStore.kt b/ktale-core/src/main/kotlin/ktale/core/config/ConfigTextStore.kt deleted file mode 100644 index bd8dc4b..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/ConfigTextStore.kt +++ /dev/null @@ -1,16 +0,0 @@ -package ktale.core.config - -/** - * Text-based config storage boundary used by core config implementations. - * - * ## Design note - * This is internal to core on purpose: - * - `ktale-api` stays minimal and platform-agnostic. - * - Platforms can swap the storage mechanism without altering plugin-facing contracts. - */ -public interface ConfigTextStore { - public fun read(id: String): String? - public fun write(id: String, text: String) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/CoreConfigManager.kt b/ktale-core/src/main/kotlin/ktale/core/config/CoreConfigManager.kt deleted file mode 100644 index 876b005..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/CoreConfigManager.kt +++ /dev/null @@ -1,87 +0,0 @@ -package ktale.core.config - -import ktale.api.config.ConfigKey -import ktale.api.config.ConfigManager -import ktale.api.logging.KtaleLogger - -/** - * Minimal config manager that loads/saves text configs via a [ConfigTextStore]. - * - * ## Versioning - * This implementation stores configs as text and applies [ConfigKey.migrations] on the raw text. - * Version detection is intentionally simple and format-agnostic; it relies on a tiny convention: - * - * - The first non-empty, non-comment line may be: `ktaleConfigVersion: ` - * - * Platforms and plugins are free to ignore this convention and provide their own ConfigManager. - */ -public class CoreConfigManager( - private val store: ConfigTextStore, - private val logger: KtaleLogger, -) : ConfigManager { - override fun load(key: ConfigKey): T { - val existing = store.read(key.id) - if (existing == null) { - val default = key.defaultValue() - save(key, default) - return default - } - - val (storedVersion, textWithoutHeader) = parseVersionHeader(existing) - var version = storedVersion ?: 0 - var migratedText = textWithoutHeader - - if (version < key.version) { - val migrations = key.migrations.sortedBy { it.fromVersion } - for (m in migrations) { - if (m.fromVersion != version) continue - migratedText = m.migrate(migratedText) - version = m.toVersion - } - } - - if (version != key.version) { - logger.warn("Config '${key.id}' could not be fully migrated (have=$version want=${key.version}); using best-effort decode.") - } - - return try { - key.codec.decode(migratedText) - } catch (t: Throwable) { - logger.error("Failed to decode config '${key.id}', falling back to defaults.", t) - val default = key.defaultValue() - save(key, default) - default - }.also { - // Persist migrations (if any) along with header. - store.write(key.id, renderWithHeader(key.version, key.codec.encode(it))) - } - } - - override fun save(key: ConfigKey, value: T) { - val encoded = key.codec.encode(value) - store.write(key.id, renderWithHeader(key.version, encoded)) - } - - private fun renderWithHeader(version: Int, body: String): String = - "ktaleConfigVersion: $version\n$body" - - private fun parseVersionHeader(text: String): Pair { - val lines = text.lines() - for ((idx, raw) in lines.withIndex()) { - val line = raw.trim() - if (line.isEmpty()) continue - if (line.startsWith("#")) continue - if (line.startsWith("//")) continue - val prefix = "ktaleConfigVersion:" - if (line.startsWith(prefix)) { - val v = line.removePrefix(prefix).trim().toIntOrNull() - val rest = lines.drop(idx + 1).joinToString("\n") - return v to rest - } - break - } - return null to text - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/FileConfigTextStore.kt b/ktale-core/src/main/kotlin/ktale/core/config/FileConfigTextStore.kt deleted file mode 100644 index dd3fe54..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/FileConfigTextStore.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ktale.core.config - -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path - -/** - * Simple file-backed config store. - * - * ## Design note - * This is platform-neutral and intended for standalone hosts or platform adapters that store configs on disk. - * The [id] passed in is treated as a relative filename. No path traversal is allowed. - */ -public class FileConfigTextStore( - private val baseDir: Path, -) : ConfigTextStore { - init { - Files.createDirectories(baseDir) - } - - override fun read(id: String): String? { - val path = resolveSafe(id) ?: return null - if (!Files.exists(path)) return null - return Files.readString(path, StandardCharsets.UTF_8) - } - - override fun write(id: String, text: String) { - val path = resolveSafe(id) ?: return - Files.createDirectories(path.parent) - Files.writeString(path, text, StandardCharsets.UTF_8) - } - - private fun resolveSafe(id: String): Path? { - val trimmed = id.trim() - if (trimmed.isEmpty()) return null - // Extremely conservative: disallow absolute paths and parent traversal. - if (trimmed.contains("..")) return null - val p = baseDir.resolve(trimmed).normalize() - if (!p.startsWith(baseDir.normalize())) return null - return p - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/InMemoryConfigTextStore.kt b/ktale-core/src/main/kotlin/ktale/core/config/InMemoryConfigTextStore.kt deleted file mode 100644 index a8713af..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/InMemoryConfigTextStore.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ktale.core.config - -import java.util.concurrent.ConcurrentHashMap - -/** - * Simple in-memory config storage used for tests and fake platforms. - */ -public class InMemoryConfigTextStore : ConfigTextStore { - private val map = ConcurrentHashMap() - - override fun read(id: String): String? = map[id] - - override fun write(id: String, text: String) { - map[id] = text - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/yaml/DefaultYaml.kt b/ktale-core/src/main/kotlin/ktale/core/config/yaml/DefaultYaml.kt deleted file mode 100644 index 7f3a0a9..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/yaml/DefaultYaml.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ktale.core.config.yaml - -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlConfiguration - -/** - * Default YAML configuration used by KTale core. - * - * ## Design note - * This is intentionally conservative. Plugins that need different YAML behavior should supply their own [Yaml]. - */ -public object DefaultYaml { - public val instance: Yaml = Yaml( - configuration = YamlConfiguration( - encodeDefaults = true, - strictMode = false, - ) - ) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/config/yaml/YamlConfigCodec.kt b/ktale-core/src/main/kotlin/ktale/core/config/yaml/YamlConfigCodec.kt deleted file mode 100644 index 28ef5c7..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/config/yaml/YamlConfigCodec.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ktale.core.config.yaml - -import com.charleskorn.kaml.Yaml -import ktale.api.config.ConfigCodec -import kotlinx.serialization.KSerializer - -/** - * YAML codec backed by kotlinx.serialization + Kaml. - * - * ## Java compatibility note - * Java plugins can still use typed configs by providing their own [ConfigCodec] implementation - * (e.g. Jackson). This codec is provided as a batteries-included default for Kotlin users. - */ -public class YamlConfigCodec( - private val serializer: KSerializer, - private val yaml: Yaml = DefaultYaml.instance, -) : ConfigCodec { - override fun decode(text: String): T = yaml.decodeFromString(serializer, text) - override fun encode(value: T): String = yaml.encodeToString(serializer, value) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/events/SimpleEventBus.kt b/ktale-core/src/main/kotlin/ktale/core/events/SimpleEventBus.kt deleted file mode 100644 index 7271fb5..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/events/SimpleEventBus.kt +++ /dev/null @@ -1,66 +0,0 @@ -package ktale.core.events - -import ktale.api.events.Cancellable -import ktale.api.events.Event -import ktale.api.events.EventBus -import ktale.api.events.EventListener -import ktale.api.events.EventPriority -import ktale.api.events.EventSubscription -import java.util.concurrent.CopyOnWriteArrayList - -/** - * A minimal, deterministic, in-memory event bus. - * - * ## Design note - * - Uses type-exact subscriptions (no class hierarchy walking) to avoid surprising dispatch costs. - * - Is synchronous: event dispatch happens on the calling thread. - * - Thread-safe subscription/unsubscription for typical plugin usage. - */ -public class SimpleEventBus : EventBus { - private data class RegisteredListener( - val type: Class, - val priority: EventPriority, - val ignoreCancelled: Boolean, - val listener: EventListener, - val token: Any = Any(), - ) - - private val listeners = CopyOnWriteArrayList>() - - override fun post(event: E): E { - @Suppress("UNCHECKED_CAST") - val typed = listeners - .asSequence() - .filter { it.type == event.javaClass } - .map { it as RegisteredListener } - .sortedBy { it.priority.ordinal } - .toList() - - val cancelled = (event as? Cancellable)?.isCancelled == true - for (reg in typed) { - if (reg.ignoreCancelled && cancelled) continue - reg.listener.onEvent(event) - } - return event - } - - override fun subscribe( - type: Class, - listener: EventListener, - priority: EventPriority, - ignoreCancelled: Boolean, - ): EventSubscription { - val reg = RegisteredListener(type, priority, ignoreCancelled, listener) - listeners.add(reg) - return object : EventSubscription { - private var unsubscribed = false - override fun unsubscribe() { - if (unsubscribed) return - unsubscribed = true - listeners.removeIf { it.token == reg.token } - } - } - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/logging/SimpleConsoleLogger.kt b/ktale-core/src/main/kotlin/ktale/core/logging/SimpleConsoleLogger.kt deleted file mode 100644 index ea5ed02..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/logging/SimpleConsoleLogger.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ktale.core.logging - -import ktale.api.logging.KtaleLogger -import ktale.api.logging.LogLevel -import java.time.Instant - -/** - * Minimal console logger used by the fake platform and as a fallback. - */ -public class SimpleConsoleLogger( - private val name: String, - private val nowEpochMillis: () -> Long = { System.currentTimeMillis() }, -) : KtaleLogger { - override fun log(level: LogLevel, message: String, throwable: Throwable?, context: Map) { - val ts = Instant.ofEpochMilli(nowEpochMillis()).toString() - val ctx = if (context.isEmpty()) "" else " $context" - val line = "[$ts] [$level] [$name] $message$ctx" - if (level >= LogLevel.WARN) { - System.err.println(line) - throwable?.printStackTrace(System.err) - } else { - println(line) - throwable?.printStackTrace(System.out) - } - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/runtime/CoreRuntime.kt b/ktale-core/src/main/kotlin/ktale/core/runtime/CoreRuntime.kt deleted file mode 100644 index 559eb7a..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/runtime/CoreRuntime.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ktale.core.runtime - -import ktale.api.PluginContext -import ktale.api.commands.CommandRegistry -import ktale.api.config.ConfigManager -import ktale.api.events.EventBus -import ktale.api.prefabs.PrefabStore -import ktale.api.scheduler.Scheduler -import ktale.api.services.ServiceRegistry -import ktale.core.DefaultPluginContext -import ktale.core.commands.BridgedCommandRegistry -import ktale.core.commands.SimpleCommandRegistry -import ktale.core.config.ConfigTextStore -import ktale.core.config.CoreConfigManager -import ktale.core.events.SimpleEventBus -import ktale.core.scheduler.HookBackedScheduler -import ktale.core.services.SimpleServiceRegistry -import ktale.core.threading.ThreadGuards -import ktale.platform.Platform - -/** - * Minimal "Day-1" core runtime wiring helper. - * - * ## Design note - * This is *optional* glue: - * - platform adapters may choose to build their own contexts - * - fake servers can use this to reduce boilerplate - * - * This class intentionally does not guess storage locations; callers provide a [ConfigTextStore]. - */ -public class CoreRuntime( - private val platform: Platform, - private val configStore: ConfigTextStore, -) { - public val prefabs: PrefabStore? = null - public val services: ServiceRegistry = SimpleServiceRegistry() - public val events: EventBus = SimpleEventBus() - public val scheduler: Scheduler = HookBackedScheduler(platform.scheduler) - - private val baseCommands: CommandRegistry = SimpleCommandRegistry() - public val commands: CommandRegistry = BridgedCommandRegistry(baseCommands, platform.commands) - - public val configs: ConfigManager = CoreConfigManager(configStore, platform.loggers.logger("ktale-config")) - - public fun threadGuards(pluginId: String): ThreadGuards = - ThreadGuards(platform.loggers.logger("$pluginId-threading")) - - public fun pluginContext(pluginId: String): PluginContext = - DefaultPluginContext( - pluginId = pluginId, - platform = platform, - events = events, - scheduler = scheduler, - commands = commands, - configs = configs, - services = services, - prefabs = prefabs, - ) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/scheduler/HookBackedScheduler.kt b/ktale-core/src/main/kotlin/ktale/core/scheduler/HookBackedScheduler.kt deleted file mode 100644 index e32c032..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/scheduler/HookBackedScheduler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package ktale.core.scheduler - -import ktale.api.scheduler.Scheduler -import ktale.api.scheduler.TaskHandle -import ktale.platform.PlatformSchedulerHooks -import java.time.Duration - -/** - * Scheduler implementation backed by [PlatformSchedulerHooks]. - * - * ## Design note - * The KTale public contract uses Java-first types (Runnable / java.time.Duration) so Java plugins - * can use the SDK without friction. Kotlin ergonomics are provided via extension helpers in `ktale-api`. - * - * "Sync" vs "async" semantics remain platform-defined; this class is a thin adapter. - */ -public class HookBackedScheduler( - private val hooks: PlatformSchedulerHooks, -) : Scheduler { - override fun runSync(task: Runnable): TaskHandle = hooks.runSync(task) - - override fun runAsync(task: Runnable): TaskHandle = hooks.runAsync(task) - - override fun runSyncDelayed(delay: Duration, task: Runnable): TaskHandle = hooks.runSyncDelayed(delay, task) - - override fun runAsyncDelayed(delay: Duration, task: Runnable): TaskHandle = hooks.runAsyncDelayed(delay, task) - - override fun runSyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle = - hooks.runSyncRepeating(initialDelay, interval, task) - - override fun runAsyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle = - hooks.runAsyncRepeating(initialDelay, interval, task) -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/services/SimpleServiceRegistry.kt b/ktale-core/src/main/kotlin/ktale/core/services/SimpleServiceRegistry.kt deleted file mode 100644 index 5a65513..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/services/SimpleServiceRegistry.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ktale.core.services - -import ktale.api.services.ServiceRegistry -import java.util.concurrent.ConcurrentHashMap - -/** - * Minimal thread-safe service registry. - * - * ## Design note - * This is intentionally tiny and runtime-oriented to keep KTale adaptable to unknown host lifecycles. - */ -public class SimpleServiceRegistry : ServiceRegistry { - private val services = ConcurrentHashMap, Any>() - - override fun register(type: Class, instance: T, replace: Boolean) { - if (!replace) { - val existing = services.putIfAbsent(type, instance) - if (existing != null) { - throw IllegalStateException("Service already registered for type ${type.name}") - } - } else { - services[type] = instance - } - } - - @Suppress("UNCHECKED_CAST") - override fun get(type: Class): T? = services[type] as T? - - override fun require(type: Class): T = - get(type) ?: throw NoSuchElementException("Missing service for type ${type.name}") - - override fun unregister(type: Class) { - services.remove(type) - } -} - - diff --git a/ktale-core/src/main/kotlin/ktale/core/threading/ThreadGuards.kt b/ktale-core/src/main/kotlin/ktale/core/threading/ThreadGuards.kt deleted file mode 100644 index b78da87..0000000 --- a/ktale-core/src/main/kotlin/ktale/core/threading/ThreadGuards.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ktale.core.threading - -import ktale.api.logging.KtaleLogger - -/** - * Conceptual thread guards (not enforced). - * - * ## Why this exists - * We don't know the eventual server threading model (if any). - * This is a lightweight mechanism that can be used to *document* expectations - * and to optionally log diagnostics when expectations are violated. - * - * ## Non-goal - * This does not attempt to control or enforce threads. - */ -public class ThreadGuards( - private val logger: KtaleLogger, -) { - /** Marker for "expected sync context". */ - public fun expectSync(note: String) { - logger.debug("ThreadGuard(sync): $note") - } - - /** Marker for "expected async context". */ - public fun expectAsync(note: String) { - logger.debug("ThreadGuard(async): $note") - } -} - - diff --git a/ktale-core/src/test/kotlin/ktale/core/commands/BridgedCommandRegistryTest.kt b/ktale-core/src/test/kotlin/ktale/core/commands/BridgedCommandRegistryTest.kt deleted file mode 100644 index c44334c..0000000 --- a/ktale-core/src/test/kotlin/ktale/core/commands/BridgedCommandRegistryTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package ktale.core.commands - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandResult -import ktale.api.commands.CommandSender -import ktale.platform.PlatformCommandBridge - -class BridgedCommandRegistryTest : FunSpec({ - test("register/unregister notify PlatformCommandBridge") { - val bridge = mockk(relaxed = true) - val base = SimpleCommandRegistry() - val reg = BridgedCommandRegistry(base, bridge) - - val def = object : CommandDefinition { - override val name = "ping" - override val aliases = emptySet() - override val description: String? = null - override val permission = null - override fun execute(context: CommandContext): CommandResult = CommandResult.Success - } - - reg.register(def) - verify { bridge.onRegister(def) } - - reg.unregister("ping") - verify { bridge.onUnregister("ping") } - } - - test("dispatch still delegates to underlying registry") { - val bridge = mockk(relaxed = true) - val base = SimpleCommandRegistry() - val reg = BridgedCommandRegistry(base, bridge) - - val def = object : CommandDefinition { - override val name = "ping" - override val aliases = emptySet() - override val description: String? = null - override val permission = null - override fun execute(context: CommandContext): CommandResult = CommandResult.Success - } - reg.register(def) - - val sender = mockk() - every { sender.name } returns "Tester" - every { sender.sendMessage(any()) } returns Unit - every { sender.hasPermission(any()) } returns true - - val ctx = object : CommandContext { - override val sender: CommandSender = sender - override val label: String = "ping" - override val args: List = emptyList() - } - - reg.dispatch(ctx) shouldBe CommandResult.Success - } -}) - - diff --git a/ktale-core/src/test/kotlin/ktale/core/commands/SimpleCommandRegistryTest.kt b/ktale-core/src/test/kotlin/ktale/core/commands/SimpleCommandRegistryTest.kt deleted file mode 100644 index 356b241..0000000 --- a/ktale-core/src/test/kotlin/ktale/core/commands/SimpleCommandRegistryTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package ktale.core.commands - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandResult -import ktale.api.commands.CommandSender -import ktale.api.commands.Permission - -class SimpleCommandRegistryTest : FunSpec({ - test("dispatch resolves aliases and checks permissions") { - val registry = SimpleCommandRegistry() - - val sender = mockk() - every { sender.name } returns "Tester" - every { sender.sendMessage(any()) } returns Unit - every { sender.hasPermission(any()) } returns false - - val ctx = object : CommandContext { - override val sender: CommandSender = sender - override val label: String = "KT" - override val args: List = emptyList() - } - - val perm = Permission.of("ktale.test") - registry.register( - object : CommandDefinition { - override val name: String = "kt" - override val aliases: Set = setOf("KT", "kT2") - override val description: String? = null - override val permission: Permission? = perm - override fun execute(context: CommandContext): CommandResult = CommandResult.Success - } - ) - - registry.dispatch(ctx) shouldBe CommandResult.NoPermission - - every { sender.hasPermission(perm) } returns true - registry.dispatch(ctx) shouldBe CommandResult.Success - } - - test("unknown command returns usage error") { - val registry = SimpleCommandRegistry() - val sender = mockk() - every { sender.name } returns "Tester" - every { sender.sendMessage(any()) } returns Unit - every { sender.hasPermission(any()) } returns true - - val ctx = object : CommandContext { - override val sender: CommandSender = sender - override val label: String = "doesnotexist" - override val args: List = emptyList() - } - - val res = registry.dispatch(ctx) - (res is CommandResult.UsageError) shouldBe true - } - - test("Commands Kotlin DSL builder produces a usable definition") { - val registry = SimpleCommandRegistry() - val sender = mockk() - every { sender.name } returns "Tester" - every { sender.sendMessage(any()) } returns Unit - every { sender.hasPermission(any()) } returns true - - registry.register( - Commands.command("ping") { - aliases("p") - execute { CommandResult.Success } - } - ) - - val ctx = object : CommandContext { - override val sender: CommandSender = sender - override val label: String = "p" - override val args: List = emptyList() - } - - registry.dispatch(ctx) shouldBe CommandResult.Success - } -}) - - diff --git a/ktale-core/src/test/kotlin/ktale/core/config/CoreConfigManagerYamlTest.kt b/ktale-core/src/test/kotlin/ktale/core/config/CoreConfigManagerYamlTest.kt deleted file mode 100644 index 3995413..0000000 --- a/ktale-core/src/test/kotlin/ktale/core/config/CoreConfigManagerYamlTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ktale.core.config - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import ktale.api.config.ConfigKey -import ktale.api.config.ConfigMigration -import ktale.core.config.yaml.YamlConfigCodec -import ktale.core.logging.SimpleConsoleLogger -import kotlinx.serialization.Serializable - -class CoreConfigManagerYamlTest : FunSpec({ - test("missing config loads defaults and persists with header") { - val store = InMemoryConfigTextStore() - val logger = SimpleConsoleLogger("test") - val mgr = CoreConfigManager(store, logger) - - val key = object : ConfigKey { - override val id = "test.yml" - override val version = 1 - override val codec = YamlConfigCodec(TestCfg.serializer()) - override fun defaultValue(): TestCfg = TestCfg(foo = "bar") - override val migrations: List = emptyList() - } - - mgr.load(key) shouldBe TestCfg(foo = "bar") - val raw = store.read("test.yml")!! - raw.lines().first().trim() shouldBe "ktaleConfigVersion: 1" - } - - test("migrations run on text before decode") { - val store = InMemoryConfigTextStore() - val logger = SimpleConsoleLogger("test") - val mgr = CoreConfigManager(store, logger) - - // Stored v0: foo: old - store.write( - "test.yml", - """ - ktaleConfigVersion: 0 - foo: old - """.trimIndent() - ) - - val key = object : ConfigKey { - override val id = "test.yml" - override val version = 1 - override val codec = YamlConfigCodec(TestCfg.serializer()) - override fun defaultValue(): TestCfg = TestCfg(foo = "default") - override val migrations: List = listOf( - object : ConfigMigration { - override val fromVersion: Int = 0 - override val toVersion: Int = 1 - override fun migrate(oldText: String): String = oldText.replace("foo: old", "foo: migrated") - } - ) - } - - mgr.load(key) shouldBe TestCfg(foo = "migrated") - store.read("test.yml")!!.lines().first().trim() shouldBe "ktaleConfigVersion: 1" - } - - test("decode failure falls back to defaults and overwrites stored value") { - val store = InMemoryConfigTextStore() - val logger = SimpleConsoleLogger("test") - val mgr = CoreConfigManager(store, logger) - - store.write( - "test.yml", - """ - ktaleConfigVersion: 1 - this is not yaml: - """.trimIndent() - ) - - val key = object : ConfigKey { - override val id = "test.yml" - override val version = 1 - override val codec = YamlConfigCodec(TestCfg.serializer()) - override fun defaultValue(): TestCfg = TestCfg(foo = "safe") - override val migrations: List = emptyList() - } - - mgr.load(key) shouldBe TestCfg(foo = "safe") - val rewritten = store.read("test.yml")!! - key.codec.decode(rewritten.lines().drop(1).joinToString("\n")) shouldBe TestCfg(foo = "safe") - } -}) - -@Serializable -private data class TestCfg(val foo: String) - - diff --git a/ktale-core/src/test/kotlin/ktale/core/events/SimpleEventBusTest.kt b/ktale-core/src/test/kotlin/ktale/core/events/SimpleEventBusTest.kt deleted file mode 100644 index 2edc511..0000000 --- a/ktale-core/src/test/kotlin/ktale/core/events/SimpleEventBusTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package ktale.core.events - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import ktale.api.events.Cancellable -import ktale.api.events.Event -import ktale.api.events.EventListener -import ktale.api.events.EventPriority - -class SimpleEventBusTest : FunSpec({ - test("dispatches in priority order") { - val bus = SimpleEventBus() - val order = mutableListOf() - - bus.subscribe(TestEvent::class.java, EventPriority.LATE, EventListener { order += "late" }) - bus.subscribe(TestEvent::class.java, EventPriority.EARLY, EventListener { order += "early" }) - bus.subscribe(TestEvent::class.java, EventPriority.NORMAL, EventListener { order += "normal" }) - bus.subscribe(TestEvent::class.java, EventPriority.FINAL, EventListener { order += "final" }) - - bus.post(TestEvent()) - - order shouldBe listOf("early", "normal", "late", "final") - } - - test("ignoreCancelled skips listeners") { - val bus = SimpleEventBus() - val order = mutableListOf() - - bus.subscribe( - TestCancellableEvent::class.java, - EventListener { order += "ignored" }, - EventPriority.NORMAL, - ignoreCancelled = true, - ) - bus.subscribe( - TestCancellableEvent::class.java, - EventListener { order += "always" }, - EventPriority.NORMAL, - ignoreCancelled = false, - ) - - bus.post(TestCancellableEvent(isCancelled = true)) - - order shouldBe listOf("always") - } -}) - -private class TestEvent : Event - -private data class TestCancellableEvent(override var isCancelled: Boolean) : Event, Cancellable - - diff --git a/ktale-core/src/test/kotlin/ktale/core/services/SimpleServiceRegistryTest.kt b/ktale-core/src/test/kotlin/ktale/core/services/SimpleServiceRegistryTest.kt deleted file mode 100644 index 7657ad2..0000000 --- a/ktale-core/src/test/kotlin/ktale/core/services/SimpleServiceRegistryTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ktale.core.services - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.kotest.assertions.throwables.shouldThrow - -class SimpleServiceRegistryTest : FunSpec({ - test("register/get/require/unregister works") { - val reg = SimpleServiceRegistry() - - reg.get(String::class.java) shouldBe null - shouldThrow { reg.require(String::class.java) } - - reg.register(String::class.java, "hello") - reg.get(String::class.java) shouldBe "hello" - reg.require(String::class.java) shouldBe "hello" - - reg.unregister(String::class.java) - reg.get(String::class.java) shouldBe null - } - - test("register without replace refuses overwrite") { - val reg = SimpleServiceRegistry() - reg.register(String::class.java, "a") - shouldThrow { - reg.register(String::class.java, "b", replace = false) - } - reg.get(String::class.java) shouldNotBe "b" - } - - test("register with replace overwrites") { - val reg = SimpleServiceRegistry() - reg.register(String::class.java, "a") - reg.register(String::class.java, "b", replace = true) - reg.get(String::class.java) shouldBe "b" - } -}) - - diff --git a/ktale-gradle-plugin/build.gradle.kts b/ktale-gradle-plugin/build.gradle.kts deleted file mode 100644 index 0769e4a..0000000 --- a/ktale-gradle-plugin/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -description = "Gradle plugin: emits .dependencies/.repositories resources for KTale runtime dependency resolution." - -plugins { - `java-gradle-plugin` -} - -dependencies { - // Compiled against Gradle APIs, provided by Gradle at runtime. - compileOnly(gradleApi()) -} - -gradlePlugin { - plugins { - create("ktaleDeps") { - id = "cc.modlabs.ktale-deps" - implementationClass = "cc.modlabs.ktale.gradle.KtaleDepsPlugin" - displayName = "KTale dependency manifest generator" - description = "Generates .dependencies/.repositories resources for runtime dependency resolution." - } - } -} - - diff --git a/ktale-gradle-plugin/src/main/kotlin/cc/modlabs/ktale/gradle/KtaleDepsPlugin.kt b/ktale-gradle-plugin/src/main/kotlin/cc/modlabs/ktale/gradle/KtaleDepsPlugin.kt deleted file mode 100644 index 352c448..0000000 --- a/ktale-gradle-plugin/src/main/kotlin/cc/modlabs/ktale/gradle/KtaleDepsPlugin.kt +++ /dev/null @@ -1,165 +0,0 @@ -package cc.modlabs.ktale.gradle - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.Property -import java.io.File -import java.net.URI - -/** - * Gradle plugin that emits `.dependencies` and optional `.repositories` resources into the jar. - * - * ## Purpose - * When KTale is used as a standalone/bundled server runtime, it can: - * - load a plugin jar - * - read `.dependencies` / `.repositories` - * - download those dependencies into a cache - * - build a plugin classloader without shading - * - * ## Design note - * This is based on the same idea as your `KPaperGradle` plugin, but intentionally does not - * generate any server-specific bootstrap classes. - * - * See: [KPaperGradle reference implementation](https://raw.githubusercontent.com/ModLabsCC/KPaperGradle/main/src/main/kotlin/cc/modlabs/kpapergradle/KPaperGradlePlugin.kt) - */ -public class KtaleDepsPlugin : Plugin { - override fun apply(project: Project) { - val ext = project.extensions.create("ktaleDeps", KtaleDepsExtension::class.java, project.objects) - - // Default mirror for resolving runtime dependencies - project.repositories.maven { it.url = URI.create("https://nexus.modlabs.cc/repository/maven-mirrors/") } - - val generateTask = project.tasks.register("generateKtaleDependencyManifest") { t -> - t.group = "build" - t.description = "Generates .dependencies and optional .repositories into resources for runtime resolution" - t.inputs.property("deliver", ext.deliverDependencies) - t.inputs.property("repos", ext.customRepositories) - t.inputs.property("pluginId", ext.pluginId.orNull) - t.inputs.property("mainClass", ext.mainClass.orNull) - - t.doLast { - val buildDir = project.layout.buildDirectory.asFile.get() - val genResDir = File(buildDir, "generated-resources/ktale-deps") - genResDir.mkdirs() - - val coords = mutableSetOf() - - // Prefer runtimeClasspath when present (typical for JVM artifacts) - val config = project.configurations.findByName("runtimeClasspath") - ?: project.configurations.findByName("compileClasspath") - - if (config != null && config.isCanBeResolved) { - config.resolvedConfiguration.firstLevelModuleDependencies.forEach { dep -> - val g = dep.moduleGroup - val n = dep.moduleName - val v = dep.moduleVersion - if (!g.isNullOrBlank() && !n.isNullOrBlank() && !v.isNullOrBlank()) { - coords += "$g:$n:$v" - } - } - } - - coords += ext.deliverDependencies - - File(genResDir, ".dependencies").writeText(coords.sorted().joinToString("\n") + "\n") - - val reposFile = File(genResDir, ".repositories") - if (ext.customRepositories.isNotEmpty()) { - val lines = ext.customRepositories.map { r -> - buildString { - append(r.id).append(' ').append(r.url) - if (!r.usernameEnv.isNullOrBlank() && !r.passwordEnv.isNullOrBlank()) { - append(' ').append(r.usernameEnv).append(' ').append(r.passwordEnv) - } - } - } - reposFile.writeText(lines.joinToString("\n") + "\n") - } else { - if (reposFile.exists()) reposFile.delete() - } - - // Optional: generate ktale-plugin.properties for standalone hosts - val pid = ext.pluginId.orNull?.trim().orEmpty() - val main = ext.mainClass.orNull?.trim().orEmpty() - val descriptor = File(genResDir, "ktale-plugin.properties") - if (pid.isNotBlank() && main.isNotBlank()) { - descriptor.writeText("id=$pid\nmain=$main\n") - } else { - if (descriptor.exists()) descriptor.delete() - } - } - } - - // Copy into the main resources directory so it ends up inside the jar. - project.tasks.matching { it.name == "processResources" }.configureEach { task -> - task.dependsOn(generateTask) - task.doLast { - val buildDir = project.layout.buildDirectory.asFile.get() - val genResDir = File(buildDir, "generated-resources/ktale-deps") - val resourcesDir = File(buildDir, "resources/main") - resourcesDir.mkdirs() - - val deps = File(genResDir, ".dependencies") - if (deps.exists()) deps.copyTo(File(resourcesDir, ".dependencies"), overwrite = true) - - val repos = File(genResDir, ".repositories") - if (repos.exists()) repos.copyTo(File(resourcesDir, ".repositories"), overwrite = true) - - val desc = File(genResDir, "ktale-plugin.properties") - if (desc.exists()) desc.copyTo(File(resourcesDir, "ktale-plugin.properties"), overwrite = true) - } - } - } -} - -public open class KtaleDepsExtension(objects: ObjectFactory) { - /** - * Extra dependencies that should be emitted even if they are not first-level runtime deps. - * - * Example: `deliver("com.foo:bar:1.2.3")` - */ - public val deliverDependencies: MutableList = mutableListOf() - - internal val customRepositories: MutableList = mutableListOf() - - /** Optional: plugin id to write into `ktale-plugin.properties`. */ - public val pluginId: Property = objects.property(String::class.java) - - /** Optional: main class to write into `ktale-plugin.properties`. */ - public val mainClass: Property = objects.property(String::class.java) - - public fun deliver(vararg deps: String) { - deliverDependencies += deps - } - - /** DSL: repository("https://repo1.maven.org/maven2/") */ - public fun repository(url: String) { - val host = try { URI(url).host ?: url } catch (_: Exception) { url } - val id = host.replace(Regex("[^a-zA-Z0-9-_]"), "-") - customRepositories += RepoSpec(id, url) - } - - /** DSL: repository("myRepo", "https://repo.example.com/maven/") */ - public fun repository(id: String, url: String) { - customRepositories += RepoSpec(id, url) - } - - /** - * DSL: repositoryWithAuth("private", "https://repo.example.com/maven/", "REPO_USER", "REPO_PASS") - * - * IMPORTANT: only the environment variable names are written into the jar. - */ - public fun repositoryWithAuth(id: String, url: String, userEnvVar: String, passEnvVar: String) { - customRepositories += RepoSpec(id, url, userEnvVar, passEnvVar) - } - - internal data class RepoSpec( - val id: String, - val url: String, - val usernameEnv: String? = null, - val passwordEnv: String? = null, - ) -} - - diff --git a/ktale-platform-fake/build.gradle.kts b/ktale-platform-fake/build.gradle.kts deleted file mode 100644 index 63992ef..0000000 --- a/ktale-platform-fake/build.gradle.kts +++ /dev/null @@ -1,9 +0,0 @@ -description = "KTale deterministic fake platform for tests and demos (no real server required)." - -dependencies { - implementation(project(":ktale-api")) - implementation(project(":ktale-platform")) - implementation(project(":ktale-core")) -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/DeterministicClock.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/DeterministicClock.kt deleted file mode 100644 index 474b0d9..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/DeterministicClock.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ktale.platform.fake - -import ktale.platform.PlatformClock -import java.time.Duration -import java.util.concurrent.atomic.AtomicLong - -/** - * Deterministic clock for tests and fake servers. - * - * ## Design note - * Both clocks advance together: - * - [nowEpochMillis] is derived from [epochMillis]. - * - [monotonicNanos] is derived from [monoNanos]. - */ -public class DeterministicClock( - startEpochMillis: Long = 0L, -) : PlatformClock { - private val epochMillis = AtomicLong(startEpochMillis) - private val monoNanos = AtomicLong(0L) - - override fun nowEpochMillis(): Long = epochMillis.get() - - override fun monotonicNanos(): Long = monoNanos.get() - - /** Advances the clock by [duration]. */ - public fun advanceBy(duration: Duration) { - val millis = duration.toMillis() - val nanos = duration.toNanos() - epochMillis.addAndGet(millis) - monoNanos.addAndGet(nanos) - } - - /** Sets absolute epoch millis (monotonic clock is unaffected). */ - public fun setEpochMillis(value: Long) { - epochMillis.set(value) - } -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeCommandBridge.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeCommandBridge.kt deleted file mode 100644 index 1dc0d75..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeCommandBridge.kt +++ /dev/null @@ -1,41 +0,0 @@ -package ktale.platform.fake - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandRegistry -import ktale.api.commands.CommandResult -import ktale.platform.PlatformCommandBridge -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicReference - -/** - * Minimal command bridge for the fake platform. - * - * This bridge captures registrations for introspection and dispatches inbound commands into the bound registry. - */ -public class FakeCommandBridge : PlatformCommandBridge { - private val byName = ConcurrentHashMap() - private val registryRef = AtomicReference(null) - - override fun onRegister(definition: CommandDefinition) { - byName[definition.name.lowercase()] = definition - } - - override fun onUnregister(name: String) { - byName.remove(name.lowercase()) - } - - override fun bind(registry: CommandRegistry) { - registryRef.set(registry) - } - - override fun dispatchInbound(context: CommandContext): CommandResult { - val registry = registryRef.get() ?: error("FakeCommandBridge.bind(registry) was not called") - return registry.dispatch(context) - } - - /** Returns whether a command is registered (by primary name). */ - public fun isRegistered(name: String): Boolean = byName.containsKey(name.lowercase()) -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlatform.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlatform.kt deleted file mode 100644 index 340169f..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlatform.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ktale.platform.fake - -import ktale.api.logging.KtaleLogger -import ktale.core.logging.SimpleConsoleLogger -import ktale.platform.Platform -import ktale.platform.PlatformClock -import ktale.platform.PlatformCommandBridge -import ktale.platform.PlatformLoggerFactory -import ktale.platform.PlatformSchedulerHooks - -/** - * Fake platform implementation for tests and demos. - * - * ## Design note - * This platform is intentionally *fully controllable* and does not mimic any real server behavior. - * The goal is to let KTale core behavior be tested without a real server runtime. - */ -public class FakePlatform( - override val clock: DeterministicClock = DeterministicClock(), - public val schedulerHooks: FakeSchedulerHooks = FakeSchedulerHooks(clock), - public val commandBridge: FakeCommandBridge = FakeCommandBridge(), -) : Platform { - override val platformId: String = "fake" - - override val loggers: PlatformLoggerFactory = object : PlatformLoggerFactory { - override fun logger(name: String): KtaleLogger = SimpleConsoleLogger(name, nowEpochMillis = clock::nowEpochMillis) - } - - override val scheduler: PlatformSchedulerHooks - get() = schedulerHooks - - override val commands: PlatformCommandBridge - get() = commandBridge -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlayer.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlayer.kt deleted file mode 100644 index feffa84..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakePlayer.kt +++ /dev/null @@ -1,31 +0,0 @@ -package ktale.platform.fake - -import ktale.api.commands.CommandSender -import ktale.api.commands.Permission - -/** - * Minimal fake player. - * - * This implements [CommandSender] so commands can be tested without a real server. - */ -public class FakePlayer( - override val name: String, - private val permissions: MutableSet, -) : CommandSender { - public constructor(name: String) : this(name, mutableSetOf()) - - public val messages: MutableList = mutableListOf() - - override fun sendMessage(message: String) { - messages += message - } - - override fun hasPermission(permission: Permission): Boolean = - permissions.contains(permission.value) - - public fun grant(permission: String) { - permissions += permission - } -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeSchedulerHooks.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeSchedulerHooks.kt deleted file mode 100644 index d717ddd..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeSchedulerHooks.kt +++ /dev/null @@ -1,112 +0,0 @@ -package ktale.platform.fake - -import ktale.api.scheduler.TaskHandle -import ktale.platform.PlatformSchedulerHooks -import java.time.Duration -import java.util.PriorityQueue -import java.util.concurrent.atomic.AtomicLong - -/** - * Fully controllable scheduler hooks backed by a [DeterministicClock]. - * - * ## Control surface - * The platform API only exposes scheduling *hooks*; this fake exposes extra methods for tests: - * - [runDueSync] / [runDueAsync] - * - [advanceBy] (optionally auto-running due tasks) - * - * ## Semantics - * - "sync" and "async" are modeled as two independent queues. - * - Tasks only run when the test/server calls a control method. - */ -public class FakeSchedulerHooks( - private val clock: DeterministicClock, -) : PlatformSchedulerHooks { - private data class Scheduled( - val dueNanos: Long, - val intervalNanos: Long?, - val task: Runnable, - val handle: FakeTaskHandle, - val id: Long, - ) - - private val idSeq = AtomicLong(0L) - - private val syncQueue = PriorityQueue(compareBy({ it.dueNanos }, { it.id })) - private val asyncQueue = PriorityQueue(compareBy({ it.dueNanos }, { it.id })) - - override fun runSync(task: Runnable): TaskHandle = - schedule(syncQueue, Duration.ZERO, null, task) - - override fun runAsync(task: Runnable): TaskHandle = - schedule(asyncQueue, Duration.ZERO, null, task) - - override fun runSyncDelayed(delay: Duration, task: Runnable): TaskHandle = - schedule(syncQueue, delay, null, task) - - override fun runAsyncDelayed(delay: Duration, task: Runnable): TaskHandle = - schedule(asyncQueue, delay, null, task) - - override fun runSyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle = - schedule(syncQueue, initialDelay, interval, task) - - override fun runAsyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle = - schedule(asyncQueue, initialDelay, interval, task) - - private fun schedule( - q: PriorityQueue, - delay: Duration, - interval: Duration?, - task: Runnable, - ): TaskHandle { - val handle = FakeTaskHandle() - val due = clock.monotonicNanos() + delay.toNanos() - val id = idSeq.incrementAndGet() - q.add( - Scheduled( - dueNanos = due, - intervalNanos = interval?.toNanos(), - task = task, - handle = handle, - id = id, - ) - ) - return handle - } - - /** Runs all due sync tasks at the current clock time. */ - public fun runDueSync() { - runDue(syncQueue) - } - - /** Runs all due async tasks at the current clock time. */ - public fun runDueAsync() { - runDue(asyncQueue) - } - - /** Advances the clock by [duration]. */ - public fun advanceBy(duration: Duration, runDueAfterAdvance: Boolean = true) { - clock.advanceBy(duration) - if (runDueAfterAdvance) { - runDueSync() - runDueAsync() - } - } - - private fun runDue(q: PriorityQueue) { - val now = clock.monotonicNanos() - while (true) { - val next = q.peek() ?: return - if (next.dueNanos > now) return - q.poll() - if (!next.handle.isCancelled) { - next.task.run() - } - val interval = next.intervalNanos - if (interval != null && !next.handle.isCancelled) { - q.add(next.copy(dueNanos = next.dueNanos + interval)) - } - } - } -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeServer.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeServer.kt deleted file mode 100644 index d1e8dae..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeServer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ktale.platform.fake - -import ktale.api.KtalePlugin -import ktale.api.PluginContext -import ktale.api.events.Event -// DefaultPluginContext is composed by CoreRuntime. -import ktale.core.events.SimpleEventBus -import ktale.core.runtime.CoreRuntime - -/** - * Fully controllable fake server runtime. - * - * ## Purpose - * - Allows testing KTale-based plugins without any real server. - * - Provides deterministic time + manual scheduler execution. - * - Provides event simulation by posting into the core event bus. - */ -public class FakeServer( - public val platform: FakePlatform = FakePlatform(), -) { - private val runtime = CoreRuntime(platform, ktale.core.config.InMemoryConfigTextStore()) - - public val events: SimpleEventBus get() = runtime.events as SimpleEventBus - public val commands get() = runtime.commands - public val scheduler get() = runtime.scheduler - public val services get() = runtime.services - public val configs get() = runtime.configs - - public fun createContext(pluginId: String): PluginContext = - runtime.pluginContext(pluginId) - - /** Simulates the plugin lifecycle using the fake runtime. */ - public fun runPlugin(pluginId: String, plugin: KtalePlugin, block: (PluginContext) -> Unit = {}) { - val ctx = createContext(pluginId) - plugin.onLoad(ctx) - plugin.onEnable(ctx) - try { - block(ctx) - } finally { - plugin.onDisable(ctx) - } - } - - /** Posts an event into the core event bus. */ - public fun post(event: E): E = events.post(event) -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeTaskHandle.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeTaskHandle.kt deleted file mode 100644 index eb27a28..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeTaskHandle.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ktale.platform.fake - -import ktale.api.scheduler.TaskHandle -import java.util.concurrent.atomic.AtomicBoolean - -internal class FakeTaskHandle : TaskHandle { - private val cancelled = AtomicBoolean(false) - - override fun cancel() { - cancelled.set(true) - } - - override val isCancelled: Boolean - get() = cancelled.get() -} - - diff --git a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeWorld.kt b/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeWorld.kt deleted file mode 100644 index 260719d..0000000 --- a/ktale-platform-fake/src/main/kotlin/ktale/platform/fake/FakeWorld.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ktale.platform.fake - -/** - * Minimal fake world. - * - * ## Design note - * This is intentionally tiny: KTale does not model full game entities/world state. - * Plugins can store their own metadata externally if needed. - */ -public data class FakeWorld( - public val id: String, - public val name: String = id, -) - - diff --git a/ktale-platform-fake/src/test/java/ktale/examples/JavaExamplePluginUsage.java b/ktale-platform-fake/src/test/java/ktale/examples/JavaExamplePluginUsage.java deleted file mode 100644 index 8d89dd5..0000000 --- a/ktale-platform-fake/src/test/java/ktale/examples/JavaExamplePluginUsage.java +++ /dev/null @@ -1,60 +0,0 @@ -package ktale.examples; - -import ktale.api.PluginContext; -import ktale.api.commands.CommandContext; -import ktale.api.commands.CommandResult; -import ktale.api.events.Event; -import ktale.api.events.EventListener; -import ktale.api.events.EventPriority; -import ktale.core.commands.Commands; -import ktale.platform.fake.FakePlayer; -import ktale.platform.fake.FakeServer; - -import java.time.Duration; - -/** - * This class exists to prove "Java-first" KTale API ergonomics at compile time. - * - * It is not a runtime demo and does not depend on any test framework. - */ -public final class JavaExamplePluginUsage { - private JavaExamplePluginUsage() {} - - public static void compileOnlyExample() { - FakeServer server = new FakeServer(); - PluginContext ctx = server.createContext("java-demo"); - - // Service registry - ctx.getServices().register(String.class, "hello"); - - // Events (Java-first subscribe) - ctx.getEvents().subscribe(TestEvent.class, EventPriority.NORMAL, new EventListener() { - @Override - public void onEvent(TestEvent event) { - ctx.getLogger().info("Received event from Java"); - } - }); - - // Commands (Java fluent builder from ktale-core) - ctx.getCommands().register( - Commands.command("ping") - .executor((CommandContext c) -> CommandResult.Success.INSTANCE) - .build() - ); - - // Scheduler (Java Runnable + java.time.Duration) - ctx.getScheduler().runSyncDelayed(Duration.ofMillis(10), () -> ctx.getLogger().info("tick")); - - // Fake inbound command execution - FakePlayer player = new FakePlayer("Alice"); - server.getPlatform().getCommandBridge().dispatchInbound(new CommandContext() { - @Override public ktale.api.commands.CommandSender getSender() { return player; } - @Override public String getLabel() { return "ping"; } - @Override public java.util.List getArgs() { return java.util.Collections.emptyList(); } - }); - } - - public static final class TestEvent implements Event {} -} - - diff --git a/ktale-platform-fake/src/test/kotlin/ktale/examples/KotlinExamplePluginUsage.kt b/ktale-platform-fake/src/test/kotlin/ktale/examples/KotlinExamplePluginUsage.kt deleted file mode 100644 index 038949b..0000000 --- a/ktale-platform-fake/src/test/kotlin/ktale/examples/KotlinExamplePluginUsage.kt +++ /dev/null @@ -1,58 +0,0 @@ -package ktale.examples - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandResult -import ktale.api.events.Event -import ktale.api.events.subscribe -import ktale.api.services.register -import ktale.core.commands.Commands -import ktale.platform.fake.FakePlayer -import ktale.platform.fake.FakeServer -import java.time.Duration - -/** - * This file exists to prove Kotlin-first ergonomics at compile time. - * - * It is not a runtime demo and does not depend on any test framework. - */ -object KotlinExamplePluginUsage { - fun compileOnlyExample() { - val server = FakeServer() - val ctx = server.createContext("kotlin-demo") - - // Service registry (Kotlin reified helpers) - ctx.services.register("hello", replace = true) - - // Events (Kotlin reified subscribe) - ctx.events.subscribe { - ctx.logger.info("Received event from Kotlin") - } - - // Commands (Kotlin DSL builder from ktale-core) - ctx.commands.register( - Commands.command("ping") { - aliases("p") - execute { _: CommandContext -> CommandResult.Success } - } - ) - - // Scheduler (Kotlin extension helpers live in ktale-api) - ctx.scheduler.runSyncDelayed(Duration.ofMillis(10)) { - ctx.logger.info("tick") - } - - // Fake inbound command execution - val player = FakePlayer("Alice") - server.platform.commandBridge.dispatchInbound( - object : CommandContext { - override val sender = player - override val label = "ping" - override val args = emptyList() - } - ) - } - - class TestEvent : Event -} - - diff --git a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/AutoRegistrarSmokeTest.kt b/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/AutoRegistrarSmokeTest.kt deleted file mode 100644 index c3f10ce..0000000 --- a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/AutoRegistrarSmokeTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package ktale.platform.fake - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import ktale.api.autoregister.AutoCommand -import ktale.api.autoregister.SubscribeEvent -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandResult -import ktale.api.events.Event -import ktale.core.autoregister.AutoRegistrar - -class AutoRegistrarSmokeTest : FunSpec({ - test("auto-registers commands (by interface+annotation) and event listeners (by annotation)") { - val server = FakeServer() - val ctx = server.createContext("autoreg") - - AutoRegistrar.registerAllFromClasses( - listOf(AutoPingCommand::class.java, TestListener::class.java), - ctx, - ) - - // Command got bridged into FakeCommandBridge. - server.platform.commandBridge.isRegistered("ping") shouldBe true - - // Event listener is subscribed and receives events. - server.post(TestEvent()) - TestListener.seen shouldBe 1 - } -}) - -private class TestEvent : Event - -@AutoCommand -private class AutoPingCommand : CommandDefinition { - override val name: String = "ping" - override val aliases: Set = emptySet() - override val description: String? = null - override val permission = null - override fun execute(context: CommandContext): CommandResult = CommandResult.Success -} - -private class TestListener { - companion object { - @JvmStatic - var seen: Int = 0 - } - - @SubscribeEvent(TestEvent::class) - fun onTest(e: TestEvent) { - // Increment a static counter so we can assert that reflection invocation worked. - seen++ - } -} - - diff --git a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeSchedulerHooksTest.kt b/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeSchedulerHooksTest.kt deleted file mode 100644 index f0c9d67..0000000 --- a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeSchedulerHooksTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ktale.platform.fake - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import java.time.Duration - -class FakeSchedulerHooksTest : FunSpec({ - test("delayed sync task runs only after time advance") { - val platform = FakePlatform() - val scheduler = platform.schedulerHooks - - var ran = 0 - scheduler.runSyncDelayed(Duration.ofMillis(50), Runnable { ran++ }) - - scheduler.runDueSync() - ran shouldBe 0 - - scheduler.advanceBy(Duration.ofMillis(49), runDueAfterAdvance = true) - ran shouldBe 0 - - scheduler.advanceBy(Duration.ofMillis(1), runDueAfterAdvance = true) - ran shouldBe 1 - } - - test("repeating task repeats at interval and can be cancelled") { - val platform = FakePlatform() - val scheduler = platform.schedulerHooks - - var ran = 0 - val handle = scheduler.runSyncRepeating( - initialDelay = Duration.ZERO, - interval = Duration.ofMillis(10), - task = Runnable { ran++ }, - ) - - scheduler.runDueSync() - ran shouldBe 1 - - scheduler.advanceBy(Duration.ofMillis(10), runDueAfterAdvance = true) - ran shouldBe 2 - - handle.cancel() - scheduler.advanceBy(Duration.ofMillis(100), runDueAfterAdvance = true) - ran shouldBe 2 - } -}) - - diff --git a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerEndToEndConfigSchedulerTest.kt b/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerEndToEndConfigSchedulerTest.kt deleted file mode 100644 index de4430e..0000000 --- a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerEndToEndConfigSchedulerTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ktale.platform.fake - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import ktale.api.KtalePlugin -import ktale.api.PluginContext -import ktale.api.config.ConfigCodec -import ktale.api.config.ConfigKey -import ktale.api.config.ConfigMigration -import java.time.Duration - -class FakeServerEndToEndConfigSchedulerTest : FunSpec({ - test("plugin can load config and schedule deterministic work via FakeServer") { - val server = FakeServer() - - val key = object : ConfigKey { - override val id: String = "demo.yml" - override val version: Int = 1 - override val codec: ConfigCodec = object : ConfigCodec { - override fun decode(text: String): TestCfg { - // Extremely small, format-agnostic "codec" for tests: - // expects a single line: message: - val line = text.lines().firstOrNull().orEmpty() - val prefix = "message:" - val v = line.substringAfter(prefix, "").trim() - return TestCfg(message = if (v.isBlank()) "hello" else v) - } - - override fun encode(value: TestCfg): String = "message: ${value.message}" - } - override fun defaultValue(): TestCfg = TestCfg(message = "hello") - override val migrations: List = emptyList() - } - - val plugin = object : KtalePlugin { - override fun onLoad(context: PluginContext) = Unit - - override fun onEnable(context: PluginContext) { - val cfg = context.configs.load(key) - var ran = 0 - context.scheduler.runSyncDelayed(Duration.ofMillis(10), Runnable { - ran++ - context.services.register(Int::class.java, ran, replace = true) - context.logger.info("Ran scheduled task: ${cfg.message}") - }) - - // deterministically run the scheduled task - server.platform.schedulerHooks.advanceBy(Duration.ofMillis(10), runDueAfterAdvance = true) - context.services.require(Int::class.java) shouldBe 1 - } - - override fun onDisable(context: PluginContext) = Unit - } - - server.runPlugin("demo", plugin) - } -}) - -private data class TestCfg(val message: String) - - diff --git a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerSmokeTest.kt b/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerSmokeTest.kt deleted file mode 100644 index 56b51a1..0000000 --- a/ktale-platform-fake/src/test/kotlin/ktale/platform/fake/FakeServerSmokeTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ktale.platform.fake - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import ktale.api.KtalePlugin -import ktale.api.PluginContext -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandResult - -class FakeServerSmokeTest : FunSpec({ - test("fake server can run plugin lifecycle and dispatch commands") { - val server = FakeServer() - - val plugin = object : KtalePlugin { - override fun onLoad(context: PluginContext) = Unit - - override fun onEnable(context: PluginContext) { - context.commands.register( - object : CommandDefinition { - override val name: String = "ping" - override val aliases: Set = emptySet() - override val description: String? = null - override val permission = null - override fun execute(context: CommandContext): CommandResult = CommandResult.Success - } - ) - } - - override fun onDisable(context: PluginContext) = Unit - } - - server.runPlugin("test", plugin) { ctx -> - val sender = FakePlayer("Alice") - val res = server.platform.commandBridge.dispatchInbound( - object : CommandContext { - override val sender = sender - override val label = "ping" - override val args = emptyList() - } - ) - res shouldBe CommandResult.Success - } - } -}) - - diff --git a/ktale-platform-hytale/build.gradle.kts b/ktale-platform-hytale/build.gradle.kts deleted file mode 100644 index d0ffbb2..0000000 --- a/ktale-platform-hytale/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -description = "KTale Hytale platform adapter placeholder (intentionally incomplete)." - -dependencies { - implementation(project(":ktale-api")) - implementation(project(":ktale-platform")) -} - - diff --git a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/CommandRoutingHypothesis.kt b/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/CommandRoutingHypothesis.kt deleted file mode 100644 index 1a33fc6..0000000 --- a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/CommandRoutingHypothesis.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SPECULATION RATIONALE: - * We do not know how Hytale will represent commands (string-based, structured, chat-driven, etc.). - * This sketch exists to reason about what the platform adapter might need to translate. - * - * CONFIDENCE: LOW - * - * This file lives in `ktale.experimental.hypothesis` and is OPTIONAL/REMOVABLE. - * Core modules must not depend on it. - */ -package ktale.experimental.hypothesis - -/** - * Hypothetical shapes of inbound command input a host might provide. - */ -public sealed interface CommandRoutingHypothesis { - /** Host provides raw text after a leading slash (or similar). */ - public data class RawLine(val line: String) : CommandRoutingHypothesis - - /** Host provides a pre-tokenized representation. */ - public data class Tokens(val label: String, val args: List) : CommandRoutingHypothesis - - /** Host provides a structured argument model. */ - public data object Structured : CommandRoutingHypothesis -} - - diff --git a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ServerLifecycleHypothesis.kt b/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ServerLifecycleHypothesis.kt deleted file mode 100644 index ca53644..0000000 --- a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ServerLifecycleHypothesis.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPECULATION RATIONALE: - * Hytale server lifecycle contracts are unknown. Many server runtimes have phases roughly like - * "load -> enable -> disable", but we do not know: - * - whether plugins are isolated per world/realm - * - whether reloads exist - * - whether enable is synchronous or async - * - * CONFIDENCE: MEDIUM - * - * This file lives in `ktale.experimental.hypothesis` and is OPTIONAL/REMOVABLE. - * Core modules must not depend on it. - */ -package ktale.experimental.hypothesis - -/** - * Hypothetical lifecycle phases a host runtime might expose. - * - * ## Non-guarantee - * These are NOT promises about Hytale. They are a vocabulary to discuss potential mapping. - */ -public enum class ServerLifecycleHypothesis { - DISCOVERY, - LOAD, - ENABLE, - DISABLE, - SHUTDOWN, -} - - diff --git a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ThreadingHypothesis.kt b/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ThreadingHypothesis.kt deleted file mode 100644 index 21b938d..0000000 --- a/ktale-platform-hytale/src/main/kotlin/ktale/experimental/hypothesis/ThreadingHypothesis.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SPECULATION RATIONALE: - * We do not know Hytale's server threading model. Possible models include: - * - single-threaded "main loop" with background workers - * - region/world partitioned threads - * - fully async actor-style model - * - * CONFIDENCE: LOW - * - * This file lives in `ktale.experimental.hypothesis` and is OPTIONAL/REMOVABLE. - * Core modules must not depend on it. - */ -package ktale.experimental.hypothesis - -/** - * Hypothetical execution contexts that a server might provide. - * - * KTale's `sync`/`async` terminology is intentionally abstract; this enum is only a speculative mapping aid. - */ -public enum class ThreadingHypothesis { - MAIN_THREAD, - WORKER_POOL, - REGION_THREAD, - ASYNC_RUNTIME, -} - - diff --git a/ktale-platform-hytale/src/main/kotlin/ktale/platform/hytale/HytalePlatformPlaceholder.kt b/ktale-platform-hytale/src/main/kotlin/ktale/platform/hytale/HytalePlatformPlaceholder.kt deleted file mode 100644 index 2458e73..0000000 --- a/ktale-platform-hytale/src/main/kotlin/ktale/platform/hytale/HytalePlatformPlaceholder.kt +++ /dev/null @@ -1,82 +0,0 @@ -package ktale.platform.hytale - -import ktale.api.logging.KtaleLogger -import ktale.api.logging.LogLevel -import ktale.platform.Platform -import ktale.platform.PlatformClock -import ktale.platform.PlatformCommandBridge -import ktale.platform.PlatformLoggerFactory -import ktale.platform.PlatformSchedulerHooks -import java.time.Duration - -/** - * Placeholder platform adapter for Hytale. - * - * ## Important: intentionally incomplete - * The Hytale server software and API do not exist yet (for us), so this module must not - * pretend we can integrate. This class exists only to: - * - provide a compile-time location for a future adapter - * - make uncertainty explicit through TODOs - * - * ## Rules - * - No assumptions about real APIs - * - No concrete integration logic - * - Stubs must fail loudly if used - */ -public class HytalePlatformPlaceholder : Platform { - override val platformId: String = "hytale-placeholder" - - override val clock: PlatformClock = object : PlatformClock { - override fun nowEpochMillis(): Long = error("TODO(Hytale): Provide real time source from host runtime") - override fun monotonicNanos(): Long = error("TODO(Hytale): Provide monotonic clock from host runtime") - } - - override val loggers: PlatformLoggerFactory = object : PlatformLoggerFactory { - override fun logger(name: String): KtaleLogger = object : KtaleLogger { - override fun log( - level: LogLevel, - message: String, - throwable: Throwable?, - context: Map, - ) { - error("TODO(Hytale): Provide real logging backend. Tried to log [$level] $name: $message") - } - } - } - - override val scheduler: PlatformSchedulerHooks = object : PlatformSchedulerHooks { - override fun runSync(task: Runnable) = error("TODO(Hytale): Wire sync scheduling to host") - override fun runAsync(task: Runnable) = error("TODO(Hytale): Wire async scheduling to host") - override fun runSyncDelayed(delay: Duration, task: Runnable) = - error("TODO(Hytale): Wire delayed sync scheduling to host (delay=$delay)") - - override fun runAsyncDelayed(delay: Duration, task: Runnable) = - error("TODO(Hytale): Wire delayed async scheduling to host (delay=$delay)") - - override fun runSyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable) = - error("TODO(Hytale): Wire repeating sync scheduling to host (initialDelay=$initialDelay interval=$interval)") - - override fun runAsyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable) = - error("TODO(Hytale): Wire repeating async scheduling to host (initialDelay=$initialDelay interval=$interval)") - } - - override val commands: PlatformCommandBridge = object : PlatformCommandBridge { - override fun onRegister(definition: ktale.api.commands.CommandDefinition) { - error("TODO(Hytale): Register command with host (name=${definition.name})") - } - - override fun onUnregister(name: String) { - error("TODO(Hytale): Unregister command with host (name=$name)") - } - - override fun bind(registry: ktale.api.commands.CommandRegistry) { - error("TODO(Hytale): Bind inbound command execution to registry") - } - - override fun dispatchInbound(context: ktale.api.commands.CommandContext): ktale.api.commands.CommandResult { - error("TODO(Hytale): Dispatch inbound command from host into KTale") - } - } -} - - diff --git a/ktale-platform/build.gradle.kts b/ktale-platform/build.gradle.kts deleted file mode 100644 index 717d9c6..0000000 --- a/ktale-platform/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -description = "KTale platform boundary (no game logic)." - -dependencies { - implementation(project(":ktale-api")) -} - - diff --git a/ktale-platform/src/main/kotlin/ktale/platform/Platform.kt b/ktale-platform/src/main/kotlin/ktale/platform/Platform.kt deleted file mode 100644 index a1af13e..0000000 --- a/ktale-platform/src/main/kotlin/ktale/platform/Platform.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ktale.platform - -/** - * Platform boundary for KTale. - * - * ## Design note (architectural rule) - * This module defines *only* the boundary. It contains: - * - no game logic - * - no assumptions about Hytale APIs - * - no entity/world modeling - * - * ## Unknown host model (explicit uncertainty) - * KTale deliberately does not commit (yet) to whether it will be used as: - * - a plugin/mod SDK hosted by an official server runtime, or - * - a bundled/custom server software distribution that embeds KTale as its core. - * - * Both models can be represented by an implementation of this boundary without changing `ktale-api`. - * - * Core implementations may depend on this boundary, but the boundary must remain portable - * across unknown future server APIs. - */ -public interface Platform { - /** Stable identifier for diagnostics (e.g. "fake", "hytale", "custom"). */ - public val platformId: String - - /** Platform-provided time source. */ - public val clock: PlatformClock - - /** Platform-provided logging backend. */ - public val loggers: PlatformLoggerFactory - - /** - * Scheduler hooks for sync/async execution. - * - * Platforms define what "sync" and "async" mean. - */ - public val scheduler: PlatformSchedulerHooks - - /** - * Command IO bridge. - * - * This is the platform boundary for command registration and inbound command execution. - */ - public val commands: PlatformCommandBridge -} - - diff --git a/ktale-platform/src/main/kotlin/ktale/platform/PlatformClock.kt b/ktale-platform/src/main/kotlin/ktale/platform/PlatformClock.kt deleted file mode 100644 index 2fc6510..0000000 --- a/ktale-platform/src/main/kotlin/ktale/platform/PlatformClock.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ktale.platform - -/** - * Platform-provided time source. - * - * ## Design note - * KTale avoids tying itself to any specific time API (tick counters, wall clocks, etc.). - * This contract provides both: - * - a wall-ish clock ([nowEpochMillis]) for timestamps/logging - * - a monotonic clock ([monotonicNanos]) for scheduling/drift-safe measurements - */ -public interface PlatformClock { - /** Current wall-ish time in epoch milliseconds. */ - public fun nowEpochMillis(): Long - - /** - * Current monotonic time in nanoseconds. - * - * Values have no meaning as absolute timestamps; only differences are meaningful. - */ - public fun monotonicNanos(): Long -} - - diff --git a/ktale-platform/src/main/kotlin/ktale/platform/PlatformCommandBridge.kt b/ktale-platform/src/main/kotlin/ktale/platform/PlatformCommandBridge.kt deleted file mode 100644 index 7f920af..0000000 --- a/ktale-platform/src/main/kotlin/ktale/platform/PlatformCommandBridge.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ktale.platform - -import ktale.api.commands.CommandContext -import ktale.api.commands.CommandDefinition -import ktale.api.commands.CommandRegistry -import ktale.api.commands.CommandResult - -/** - * Platform boundary for command IO. - * - * ## Responsibilities - * - Let core register/unregister commands with the host (if the host supports it). - * - Allow the platform adapter to connect inbound command input to a [CommandRegistry]. - * - * ## Anti-goal - * This does not define a parsing engine. Platforms decide how input is tokenized and how - * help/completions are implemented (if at all). - */ -public interface PlatformCommandBridge { - /** - * Called by core when a command is registered. - * - * Platforms may use this to expose the command to the host so that input routes into KTale. - */ - public fun onRegister(definition: CommandDefinition) - - /** - * Called by core when a command is unregistered. - */ - public fun onUnregister(name: String) - - /** - * Binds inbound command execution to a registry. - * - * Platform adapters should call this once they have a [registry] instance to dispatch into. - */ - public fun bind(registry: CommandRegistry) - - /** - * Convenience method for platform adapters to dispatch an inbound command to the bound registry. - * - * Implementations may throw if [bind] has not been called. - */ - public fun dispatchInbound(context: CommandContext): CommandResult -} - - diff --git a/ktale-platform/src/main/kotlin/ktale/platform/PlatformLoggerFactory.kt b/ktale-platform/src/main/kotlin/ktale/platform/PlatformLoggerFactory.kt deleted file mode 100644 index 5744ddf..0000000 --- a/ktale-platform/src/main/kotlin/ktale/platform/PlatformLoggerFactory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ktale.platform - -import ktale.api.logging.KtaleLogger - -/** - * Factory for platform-backed loggers. - * - * Platforms may route logs to consoles, files, structured telemetry, or remote sinks. - */ -public interface PlatformLoggerFactory { - /** - * Creates a logger scoped to [name]. - * - * Conventionally, platforms should scope by plugin id (e.g. "myplugin") and/or component. - */ - public fun logger(name: String): KtaleLogger -} - - diff --git a/ktale-platform/src/main/kotlin/ktale/platform/PlatformSchedulerHooks.kt b/ktale-platform/src/main/kotlin/ktale/platform/PlatformSchedulerHooks.kt deleted file mode 100644 index dc42e38..0000000 --- a/ktale-platform/src/main/kotlin/ktale/platform/PlatformSchedulerHooks.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ktale.platform - -import ktale.api.scheduler.TaskHandle -import java.time.Duration - -/** - * Platform scheduler hooks. - * - * ## Design note - * This is a low-level hook surface used by `ktale-core` and adapters to map KTale scheduling concepts - * onto a host runtime. - * - * Implementations should be deterministic where possible and make [TaskHandle.cancel] idempotent. - */ -public interface PlatformSchedulerHooks { - public fun runSync(task: Runnable): TaskHandle - public fun runAsync(task: Runnable): TaskHandle - - public fun runSyncDelayed(delay: Duration, task: Runnable): TaskHandle - public fun runAsyncDelayed(delay: Duration, task: Runnable): TaskHandle - - public fun runSyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle - public fun runAsyncRepeating(initialDelay: Duration, interval: Duration, task: Runnable): TaskHandle -} - - diff --git a/ktale-runtime-deps/build.gradle.kts b/ktale-runtime-deps/build.gradle.kts deleted file mode 100644 index 8edc21d..0000000 --- a/ktale-runtime-deps/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -description = "KTale runtime dependency manifest + resolver (for standalone host runtimes)." - -dependencies { - // Maven Resolver (Aether) - used for downloading plugin dependencies at runtime. - api("org.apache.maven.resolver:maven-resolver-impl:1.9.22") - api("org.apache.maven.resolver:maven-resolver-connector-basic:1.9.22") - api("org.apache.maven.resolver:maven-resolver-transport-http:1.9.22") - api("org.apache.maven.resolver:maven-resolver-util:1.9.22") -} - - diff --git a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifest.kt b/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifest.kt deleted file mode 100644 index 6ed359e..0000000 --- a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package ktale.runtime.deps - -/** - * A runtime dependency manifest. - * - * ## Purpose - * This exists for the "KTale as a bundled/standalone server" host model: - * plugins can remain unshaded (clean jars) while the host downloads dependencies on demand. - * - * ## Non-goal - * This does not prescribe *how* classpaths are modified. - * A host runtime can use the resolved jar files to build isolated classloaders. - */ -public data class DependencyManifest( - /** Maven coordinates in `group:artifact:version` form. */ - public val coordinates: List, - /** Optional additional repositories (id + url). */ - public val repositories: List, -) { - public data class Repository( - public val id: String, - public val url: String, - /** - * Optional env var name for username. - * - * IMPORTANT: actual secrets must not be stored in jars; only env var names are recorded. - */ - public val usernameEnv: String? = null, - /** Optional env var name for password/token. */ - public val passwordEnv: String? = null, - ) -} - - diff --git a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifestReader.kt b/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifestReader.kt deleted file mode 100644 index 6b91b9b..0000000 --- a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/DependencyManifestReader.kt +++ /dev/null @@ -1,53 +0,0 @@ -package ktale.runtime.deps - -import java.io.BufferedReader -import java.io.InputStream -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets - -/** - * Reads `.dependencies` and `.repositories` resources from a jar/plugin classloader. - * - * ## File format - * - `.dependencies`: one Maven coordinate per line (`group:artifact:version`), `#` comments allowed. - * - `.repositories`: `id url` per line, `#` comments allowed. - */ -public object DependencyManifestReader { - public fun fromResources( - classLoader: ClassLoader, - dependenciesResource: String = ".dependencies", - repositoriesResource: String = ".repositories", - ): DependencyManifest { - val coords = readLines(classLoader.getResourceAsStream(dependenciesResource)) - val repos = readLines(classLoader.getResourceAsStream(repositoriesResource)) - .mapNotNull { parseRepoLine(it) } - return DependencyManifest(coords, repos) - } - - private fun readLines(stream: InputStream?): List { - if (stream == null) return emptyList() - stream.use { - val r = BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)) - return r.lineSequence() - .map { line -> line.trim() } - .filter { line -> line.isNotEmpty() && !line.startsWith("#") } - .toList() - } - } - - private fun parseRepoLine(line: String): DependencyManifest.Repository? { - // Supported formats: - // - "id url" - // - "id url USER_ENV PASS_ENV" - val parts = line.split(Regex("\\s+")) - if (parts.size < 2) return null - val id = parts[0].trim() - val url = parts[1].trim() - if (id.isEmpty() || url.isEmpty()) return null - val userEnv = parts.getOrNull(2)?.trim()?.takeIf { it.isNotEmpty() } - val passEnv = parts.getOrNull(3)?.trim()?.takeIf { it.isNotEmpty() } - return DependencyManifest.Repository(id, url, userEnv, passEnv) - } -} - - diff --git a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/MavenDependencyResolver.kt b/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/MavenDependencyResolver.kt deleted file mode 100644 index a16d6fe..0000000 --- a/ktale-runtime-deps/src/main/kotlin/ktale/runtime/deps/MavenDependencyResolver.kt +++ /dev/null @@ -1,99 +0,0 @@ -package ktale.runtime.deps - -import org.eclipse.aether.DefaultRepositorySystemSession -import org.eclipse.aether.RepositorySystem -import org.eclipse.aether.RepositorySystemSession -import org.eclipse.aether.artifact.DefaultArtifact -import org.eclipse.aether.collection.CollectRequest -import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory -import org.eclipse.aether.graph.Dependency -import org.eclipse.aether.impl.DefaultServiceLocator -import org.eclipse.aether.repository.LocalRepository -import org.eclipse.aether.repository.RemoteRepository -import org.eclipse.aether.resolution.DependencyRequest -import org.eclipse.aether.spi.connector.RepositoryConnectorFactory -import org.eclipse.aether.transport.http.HttpTransporterFactory -import org.eclipse.aether.spi.connector.transport.TransporterFactory -import org.eclipse.aether.util.repository.AuthenticationBuilder -import java.nio.file.Path - -/** - * Resolves Maven coordinates to local jar files using Maven Resolver (Aether). - * - * ## Intended usage - * This is for a standalone KTale host runtime that builds plugin classloaders from resolved jars. - * - * ## Non-goal - * This class does not modify classpaths. It only downloads and returns file paths. - */ -public class MavenDependencyResolver( - cacheDir: Path, - repositories: List = listOf( - RemoteRepository.Builder("modlabs-mirror", "default", "https://nexus.modlabs.cc/repository/maven-mirrors/").build() - ), -) { - private val repoSystem: RepositorySystem = newRepoSystem() - private val session: RepositorySystemSession = newSession(repoSystem, cacheDir) - private val repos: List = repositories - - /** - * Resolves all [coordinates] (and their transitive runtime dependencies) to local jar paths. - * - * @param coordinates Maven coords in `group:artifact:version` - */ - public fun resolve(coordinates: List): List { - val results = mutableListOf() - for (coord in coordinates) { - val rootDep = Dependency(DefaultArtifact(coord), "runtime") - val collect = CollectRequest().apply { - root = rootDep - repositories = repos - } - val request = DependencyRequest(collect, null) - val resolved = repoSystem.resolveDependencies(session, request) - for (artifactResult in resolved.artifactResults) { - val file = artifactResult.artifact.file ?: continue - results.add(file.toPath()) - } - } - return results.distinct() - } - - public companion object { - /** - * Builds a [RemoteRepository], optionally attaching authentication from environment variables. - * - * @param usernameEnv env var name for username, if any - * @param passwordEnv env var name for password/token, if any - */ - public fun repo( - id: String, - url: String, - usernameEnv: String? = null, - passwordEnv: String? = null, - ): RemoteRepository { - val builder = RemoteRepository.Builder(id, "default", url) - val user = usernameEnv?.let { System.getenv(it) }?.takeIf { it.isNotBlank() } - val pass = passwordEnv?.let { System.getenv(it) }?.takeIf { it.isNotBlank() } - if (user != null && pass != null) { - builder.setAuthentication(AuthenticationBuilder().addUsername(user).addPassword(pass).build()) - } - return builder.build() - } - - private fun newRepoSystem(): RepositorySystem { - val locator = DefaultServiceLocator() - locator.addService(RepositoryConnectorFactory::class.java, BasicRepositoryConnectorFactory::class.java) - locator.addService(TransporterFactory::class.java, HttpTransporterFactory::class.java) - return locator.getService(RepositorySystem::class.java) - } - - private fun newSession(system: RepositorySystem, cacheDir: Path): DefaultRepositorySystemSession { - val session = DefaultRepositorySystemSession() - session.localRepositoryManager = system.newLocalRepositoryManager(session, LocalRepository(cacheDir.toFile())) - return session - } - } -} - - diff --git a/ktale-runtime-deps/src/test/kotlin/ktale/runtime/deps/DependencyManifestReaderTest.kt b/ktale-runtime-deps/src/test/kotlin/ktale/runtime/deps/DependencyManifestReaderTest.kt deleted file mode 100644 index d35663b..0000000 --- a/ktale-runtime-deps/src/test/kotlin/ktale/runtime/deps/DependencyManifestReaderTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ktale.runtime.deps - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import java.io.ByteArrayInputStream -import java.net.URL -import java.net.URLConnection -import java.net.URLStreamHandler -import java.util.Collections - -class DependencyManifestReaderTest : FunSpec({ - test("reads dependencies and repositories resources") { - val loader = MapResourceClassLoader( - mapOf( - ".dependencies" to """ - # comment - cc.modlabs:ktale-api:1.0.0 - - org.example:lib:2.0.0 - """.trimIndent(), - ".repositories" to """ - # comment - modlabs https://nexus.modlabs.cc/repository/maven-mirrors/ - priv https://repo.example.com/maven/ USER_ENV PASS_ENV - """.trimIndent(), - ) - ) - - val manifest = DependencyManifestReader.fromResources(loader) - manifest.coordinates shouldBe listOf("cc.modlabs:ktale-api:1.0.0", "org.example:lib:2.0.0") - manifest.repositories shouldBe listOf( - DependencyManifest.Repository("modlabs", "https://nexus.modlabs.cc/repository/maven-mirrors/"), - DependencyManifest.Repository("priv", "https://repo.example.com/maven/", "USER_ENV", "PASS_ENV"), - ) - } -}) - -private class MapResourceClassLoader( - private val resources: Map, -) : ClassLoader(null) { - override fun getResourceAsStream(name: String): java.io.InputStream? { - val v = resources[name] ?: return null - return ByteArrayInputStream(v.toByteArray(Charsets.UTF_8)) - } -} - - diff --git a/ktale-runtime-host/build.gradle.kts b/ktale-runtime-host/build.gradle.kts deleted file mode 100644 index 1af58e0..0000000 --- a/ktale-runtime-host/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -description = "Standalone KTale plugin host (loads plugin jars + resolves deps, builds isolated classloaders)." - -dependencies { - implementation(project(":ktale-api")) - implementation(project(":ktale-platform")) - implementation(project(":ktale-core")) - implementation(project(":ktale-runtime-deps")) -} - - diff --git a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/JarPluginDescriptorReader.kt b/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/JarPluginDescriptorReader.kt deleted file mode 100644 index fed031d..0000000 --- a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/JarPluginDescriptorReader.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ktale.runtime.host - -import java.io.InputStream -import java.nio.file.Path -import java.util.Properties -import java.util.jar.JarFile - -/** - * Reads `ktale-plugin.properties` from a plugin jar. - * - * Expected keys: - * - `id` (required) - * - `main` (required) fully qualified class name implementing `ktale.api.KtalePlugin` - */ -public object JarPluginDescriptorReader { - public const val DEFAULT_RESOURCE: String = "ktale-plugin.properties" - - public fun read(jarPath: Path, resourceName: String = DEFAULT_RESOURCE): PluginDescriptor { - JarFile(jarPath.toFile()).use { jar -> - val entry = jar.getJarEntry(resourceName) - ?: error("Missing $resourceName in plugin jar: $jarPath") - jar.getInputStream(entry).use { stream -> - return parse(stream) - } - } - } - - public fun parse(input: InputStream): PluginDescriptor { - val props = Properties() - props.load(input) - val id = props.getProperty("id")?.trim().orEmpty() - val main = props.getProperty("main")?.trim().orEmpty() - require(id.isNotBlank()) { "ktale-plugin.properties missing required key: id" } - require(main.isNotBlank()) { "ktale-plugin.properties missing required key: main" } - return PluginDescriptor(id, main) - } -} - - diff --git a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/PluginDescriptor.kt b/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/PluginDescriptor.kt deleted file mode 100644 index 83aa4a5..0000000 --- a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/PluginDescriptor.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ktale.runtime.host - -/** - * Minimal standalone-host plugin descriptor. - * - * ## Design note (explicitly host-specific) - * This is NOT part of `ktale-api` because it assumes a particular packaging model: - * a plugin jar carries a properties file naming its entrypoint class. - */ -public data class PluginDescriptor( - public val id: String, - public val mainClass: String, -) - - diff --git a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginClassLoaderFactory.kt b/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginClassLoaderFactory.kt deleted file mode 100644 index e8cb438..0000000 --- a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginClassLoaderFactory.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ktale.runtime.host - -import java.net.URL -import java.net.URLClassLoader -import java.nio.file.Path - -/** - * Builds an isolated plugin classloader for a standalone host. - * - * ## Design note - * - Parent is the host classloader (provides KTale + server runtime types). - * - URLs include plugin jar + resolved dependency jars. - * - Classloader strategy (parent-first vs child-first) is a host decision; we start parent-first. - */ -public object StandalonePluginClassLoaderFactory { - public fun create(pluginJar: Path, dependencyJars: List, parent: ClassLoader): URLClassLoader { - val urls: Array = (listOf(pluginJar) + dependencyJars) - .distinct() - .map { it.toUri().toURL() } - .toTypedArray() - return URLClassLoader(urls, parent) - } -} - - diff --git a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginHost.kt b/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginHost.kt deleted file mode 100644 index 4df7452..0000000 --- a/ktale-runtime-host/src/main/kotlin/ktale/runtime/host/StandalonePluginHost.kt +++ /dev/null @@ -1,109 +0,0 @@ -package ktale.runtime.host - -import ktale.api.KtalePlugin -import ktale.core.autoregister.AutoRegistrar -import ktale.core.config.FileConfigTextStore -import ktale.core.runtime.CoreRuntime -import ktale.platform.Platform -import ktale.runtime.deps.DependencyManifestReader -import ktale.runtime.deps.MavenDependencyResolver -import java.net.URLClassLoader -import java.nio.file.Path -import java.util.concurrent.ConcurrentHashMap - -/** - * Standalone KTale plugin host. - * - * ## Explicit assumptions (host-specific) - * - Plugins are packaged as jars that contain: - * - `ktale-plugin.properties` with `id` + `main`, and optionally - * - `.dependencies` / `.repositories` for runtime resolution. - * - The host builds an isolated classloader per plugin. - * - * This is intentionally *not* part of `ktale-api` because it assumes a packaging model. - */ -public class StandalonePluginHost( - private val platform: Platform, - private val cacheDir: Path, - private val pluginDataDir: Path, -) { - private val loaded = ConcurrentHashMap() - - public fun load(pluginJar: Path): LoadedPlugin { - val descriptor = JarPluginDescriptorReader.read(pluginJar) - - if (loaded.containsKey(descriptor.id)) { - error("Plugin already loaded: ${descriptor.id}") - } - - val manifest = DependencyManifestReader.fromResources( - classLoader = jarOnlyClassLoader(pluginJar), - ) - - val repos = buildRepos(manifest.repositories) - val resolver = MavenDependencyResolver(cacheDir, repos) - val depJars = resolver.resolve(manifest.coordinates) - - val cl = StandalonePluginClassLoaderFactory.create(pluginJar, depJars, parent = javaClass.classLoader) - val plugin = instantiate(descriptor, cl) - - val runtime = CoreRuntime(platform, FileConfigTextStore(pluginDataDir.resolve(descriptor.id).resolve("config"))) - val ctx = runtime.pluginContext(descriptor.id) - - val loadedPlugin = LoadedPlugin(descriptor, plugin, cl, ctx, descriptorJarPath = pluginJar) - loaded[descriptor.id] = loadedPlugin - return loadedPlugin - } - - public fun enable(id: String) { - val p = loaded[id] ?: error("Plugin not loaded: $id") - p.instance.onLoad(p.context) - // Plug-and-play: discover + register commands/listeners before enabling. - AutoRegistrar.registerAllFromJar(p.descriptorJarPath, p.classLoader, p.context) - p.instance.onEnable(p.context) - } - - public fun disable(id: String) { - val p = loaded[id] ?: return - try { - p.instance.onDisable(p.context) - } finally { - p.classLoader.close() - loaded.remove(id) - } - } - - private fun instantiate(descriptor: PluginDescriptor, cl: ClassLoader): KtalePlugin { - val clazz = Class.forName(descriptor.mainClass, true, cl) - require(KtalePlugin::class.java.isAssignableFrom(clazz)) { - "Plugin main class does not implement KtalePlugin: ${descriptor.mainClass}" - } - val ctor = clazz.getDeclaredConstructor() - ctor.isAccessible = true - @Suppress("UNCHECKED_CAST") - return ctor.newInstance() as KtalePlugin - } - - private fun jarOnlyClassLoader(pluginJar: Path): ClassLoader = - URLClassLoader(arrayOf(pluginJar.toUri().toURL()), null) - - private fun buildRepos(extra: List): List { - val base = mutableListOf( - MavenDependencyResolver.repo("modlabs-mirror", "https://nexus.modlabs.cc/repository/maven-mirrors/") - ) - extra.forEach { r -> - base += MavenDependencyResolver.repo(r.id, r.url, r.usernameEnv, r.passwordEnv) - } - return base - } - - public data class LoadedPlugin( - public val descriptor: PluginDescriptor, - public val instance: KtalePlugin, - public val classLoader: URLClassLoader, - public val context: ktale.api.PluginContext, - internal val descriptorJarPath: Path, - ) -} - - diff --git a/ktale-runtime-host/src/test/kotlin/ktale/runtime/host/JarPluginDescriptorReaderTest.kt b/ktale-runtime-host/src/test/kotlin/ktale/runtime/host/JarPluginDescriptorReaderTest.kt deleted file mode 100644 index 0e181a9..0000000 --- a/ktale-runtime-host/src/test/kotlin/ktale/runtime/host/JarPluginDescriptorReaderTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ktale.runtime.host - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import java.nio.file.Files -import java.util.jar.JarEntry -import java.util.jar.JarOutputStream - -class JarPluginDescriptorReaderTest : FunSpec({ - test("reads ktale-plugin.properties from jar") { - val tmp = Files.createTempFile("ktale-plugin", ".jar") - JarOutputStream(Files.newOutputStream(tmp)).use { jar -> - jar.putNextEntry(JarEntry("ktale-plugin.properties")) - jar.write("id=test\nmain=example.Main\n".toByteArray(Charsets.UTF_8)) - jar.closeEntry() - } - - val desc = JarPluginDescriptorReader.read(tmp) - desc.id shouldBe "test" - desc.mainClass shouldBe "example.Main" - } -}) - - diff --git a/settings.gradle.kts b/settings.gradle.kts index dc2757e..e7972c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" -} -rootProject.name = "ktale" - -include( - "ktale-api", - "ktale-core", - "ktale-platform", - "ktale-platform-fake", - "ktale-platform-hytale", - "ktale-runtime-deps", - "ktale-gradle-plugin", - "ktale-runtime-host", -) \ No newline at end of file +rootProject.name = "ktale" \ No newline at end of file diff --git a/src/main/kotlin/cc/modlabs/ktale/ext/PlayerExtensions.kt b/src/main/kotlin/cc/modlabs/ktale/ext/PlayerExtensions.kt new file mode 100644 index 0000000..c53872e --- /dev/null +++ b/src/main/kotlin/cc/modlabs/ktale/ext/PlayerExtensions.kt @@ -0,0 +1,36 @@ +package cc.modlabs.ktale.ext + +import cc.modlabs.ktale.text.MessageBuilder +import com.hypixel.hytale.server.core.Message +import com.hypixel.hytale.server.core.entity.entities.Player + +/** + * Sends a pre-built [Message] to this player. + */ +public fun Player.send(message: Message) { + this.sendMessage(message) +} + +/** + * Sends a MiniMessage-like formatted string (via [MessageBuilder]) to this player. + * + * Example: + * + * ```kotlin + * player.send("Hello World") + * ``` + */ +public fun Player.send(text: String) { + this.send(MessageBuilder.fromMiniMessage(text)) +} + +/** Sends raw text to this player (no tag parsing). */ +public fun Player.sendRaw(text: String) { + this.send(Message.raw(text)) +} + +/** Explicit MiniMessage-like send (same as [send]). */ +public fun Player.sendMini(text: String) { + this.send(MessageBuilder.fromMiniMessage(text)) +} + diff --git a/src/main/kotlin/cc/modlabs/ktale/text/MessageBuilder.kt b/src/main/kotlin/cc/modlabs/ktale/text/MessageBuilder.kt new file mode 100644 index 0000000..0deffa4 --- /dev/null +++ b/src/main/kotlin/cc/modlabs/ktale/text/MessageBuilder.kt @@ -0,0 +1,334 @@ +package cc.modlabs.ktale.text + +import com.hypixel.hytale.server.core.Message +import java.awt.Color +import java.util.regex.Pattern + +/** + * Builder for creating Hytale [Message] objects from a MiniMessage-like format. + * + * Supports tags like ``, ``, `<#RRGGBB>`, ``, etc. + * + * Example: + * + * ```kotlin + * val msg = MessageBuilder.fromMiniMessage("Hello World!") + * player.sendMessage(msg) + * ``` + */ +public class MessageBuilder { + public companion object { + // Pattern to match MiniMessage tags: or or + private val TAG_PATTERN: Pattern = Pattern.compile("<([/]?)([^>]+)>") + + // Named colors mapping + private val NAMED_COLORS: Map = mapOf( + "black" to "#000000", + "dark_blue" to "#0000AA", + "dark_green" to "#00AA00", + "dark_aqua" to "#00AAAA", + "dark_red" to "#AA0000", + "dark_purple" to "#AA00AA", + "gold" to "#FFAA00", + "gray" to "#AAAAAA", + "grey" to "#AAAAAA", + "dark_gray" to "#555555", + "dark_grey" to "#555555", + "blue" to "#5555FF", + "green" to "#55FF55", + "aqua" to "#55FFFF", + "red" to "#FF5555", + "light_purple" to "#FF55FF", + "yellow" to "#FFFF55", + "white" to "#FFFFFF", + "reset" to "#FFFFFF", + ) + + private data class GradientInfo(val startColor: String, val endColor: String) + private data class MessageNode(val message: Message, val tagName: String, val gradientInfo: GradientInfo? = null) + + /** + * Parses a MiniMessage-like string and converts it to a Hytale [Message]. + * + * @param miniMessage The MiniMessage formatted string + * @return A Hytale [Message] + */ + public fun fromMiniMessage(miniMessage: String): Message { + if (!miniMessage.contains('<')) { + // No tags, return simple raw message + return Message.raw(miniMessage) + } + + val root = Message.empty() + val stack = mutableListOf() + stack.add(MessageNode(root, "", null)) + + var lastIndex = 0 + val matcher = TAG_PATTERN.matcher(miniMessage) + + while (matcher.find()) { + val start = matcher.start() + val end = matcher.end() + val isClosing = matcher.group(1) == "/" + val tagContent = matcher.group(2) + val tagName = parseTagName(tagContent) + + // Add text before the tag + if (start > lastIndex) { + val text = miniMessage.substring(lastIndex, start) + if (text.isNotEmpty()) { + val currentNode = stack.lastOrNull() + val gradientInfo = findGradientInfo(stack) + if (gradientInfo != null) { + applyGradientText(currentNode?.message ?: root, text, gradientInfo) + } else { + currentNode?.message?.insert(text) + } + } + } + + if (isClosing) { + // Closing tag - pop from stack until we find matching tag + for (i in stack.size - 1 downTo 1) { + if (stack[i].tagName == tagName) { + while (stack.size > i) { + stack.removeAt(stack.size - 1) + } + break + } + } + } else { + // Opening tag - create new message and apply formatting + val message = Message.empty() + val gradientInfo = applyTag(message, tagContent, tagName) + stack.lastOrNull()?.message?.insert(message) + stack.add(MessageNode(message, tagName, gradientInfo)) + } + + lastIndex = end + } + + // Add remaining text + if (lastIndex < miniMessage.length) { + val text = miniMessage.substring(lastIndex) + if (text.isNotEmpty()) { + val currentNode = stack.lastOrNull() + val gradientInfo = findGradientInfo(stack) + if (gradientInfo != null) { + applyGradientText(currentNode?.message ?: root, text, gradientInfo) + } else { + currentNode?.message?.insert(text) + } + } + } + + // If root has children, join them, otherwise return root + val children = root.children + return when { + children.isEmpty() -> root + children.size == 1 -> children[0] + else -> Message.join(*children.toTypedArray()) + } + } + + /** + * Applies a tag to a [Message]. Returns [GradientInfo] if this is a gradient tag, otherwise null. + */ + private fun applyTag(message: Message, tagContent: String, tagName: String): GradientInfo? { + val parts = tagContent.split(':') + val baseTagName = parts[0].lowercase() + val tagValue = if (parts.size > 1) parts[1] else null + + when (baseTagName) { + "gradient" -> { + if (parts.size >= 3) { + val startColor = parseColor(parts[1]) ?: return null + val endColor = parseColor(parts[2]) ?: return null + return GradientInfo(startColor, endColor) + } + return null + } + "bold", "b" -> { + message.bold(true) + return null + } + "italic", "i" -> { + message.italic(true) + return null + } + "underlined", "u" -> return null + "strikethrough", "s" -> return null + "obfuscated", "obf" -> return null + "monospace" -> { + message.monospace(true) + return null + } + "color" -> { + if (tagValue != null) { + val color = parseColor(tagValue) + if (color != null) message.color(color) + } + return null + } + "click" -> return null + "hover" -> return null + "link" -> { + if (tagValue != null) message.link(tagValue) + return null + } + else -> { + val colorHex = NAMED_COLORS[baseTagName] + if (colorHex != null) { + message.color(colorHex) + return null + } else if (baseTagName.startsWith("#")) { + if (isValidHex(baseTagName)) message.color(baseTagName) + return null + } + return null + } + } + } + + private fun findGradientInfo(stack: List): GradientInfo? { + for (i in stack.size - 1 downTo 0) { + stack[i].gradientInfo?.let { return it } + } + return null + } + + private fun applyGradientText(parentMessage: Message, text: String, gradientInfo: GradientInfo) { + if (text.isEmpty()) return + + val startColor = hexToRgb(gradientInfo.startColor) ?: return + val endColor = hexToRgb(gradientInfo.endColor) ?: return + + val chars = text.toCharArray() + val charCount = chars.size + if (charCount == 0) return + + for (i in chars.indices) { + val ratio = if (charCount > 1) i.toDouble() / (charCount - 1) else 0.0 + val interpolatedColor = interpolateColor(startColor, endColor, ratio) + val hexColor = rgbToHex(interpolatedColor) + + val charMessage = Message.raw(chars[i].toString()).color(hexColor) + parentMessage.insert(charMessage) + } + } + + private fun hexToRgb(hex: String): Triple? { + val cleanHex = hex.removePrefix("#") + if (cleanHex.length != 6) return null + + return try { + val r = cleanHex.substring(0, 2).toInt(16) + val g = cleanHex.substring(2, 4).toInt(16) + val b = cleanHex.substring(4, 6).toInt(16) + Triple(r, g, b) + } catch (_: NumberFormatException) { + null + } + } + + private fun rgbToHex(rgb: Triple): String = + String.format("#%02X%02X%02X", rgb.first, rgb.second, rgb.third) + + private fun interpolateColor( + start: Triple, + end: Triple, + ratio: Double, + ): Triple { + val r = (start.first + (end.first - start.first) * ratio).toInt().coerceIn(0, 255) + val g = (start.second + (end.second - start.second) * ratio).toInt().coerceIn(0, 255) + val b = (start.third + (end.third - start.third) * ratio).toInt().coerceIn(0, 255) + return Triple(r, g, b) + } + + private fun parseColor(colorValue: String): String? { + val trimmed = colorValue.trim() + + if (trimmed.startsWith("#")) { + return if (isValidHex(trimmed)) trimmed else null + } + + val namedColor = NAMED_COLORS[trimmed.lowercase()] + if (namedColor != null) return namedColor + + if (trimmed.length == 6 && isValidHex("#$trimmed")) { + return "#$trimmed" + } + + return null + } + + private fun isValidHex(hex: String): Boolean = + hex.matches(Regex("#[0-9A-Fa-f]{6}")) + + private fun parseTagName(tagContent: String): String = + tagContent.split(':')[0].lowercase() + + public fun builder(): MessageBuilder = MessageBuilder() + } + + private var message: Message = Message.empty() + + /** Adds raw text to the message. */ + public fun text(text: String): MessageBuilder { + message.insert(text) + return this + } + + /** Adds a MiniMessage-like formatted text. */ + public fun miniMessage(text: String): MessageBuilder { + val parsed = fromMiniMessage(text) + message.insert(parsed) + return this + } + + /** Sets the color. */ + public fun color(color: String): MessageBuilder { + message.color(color) + return this + } + + /** Sets the color. */ + public fun color(color: Color): MessageBuilder { + message.color(color) + return this + } + + /** Sets bold formatting. */ + public fun bold(bold: Boolean = true): MessageBuilder { + message.bold(bold) + return this + } + + /** Sets italic formatting. */ + public fun italic(italic: Boolean = true): MessageBuilder { + message.italic(italic) + return this + } + + /** Sets monospace formatting. */ + public fun monospace(monospace: Boolean = true): MessageBuilder { + message.monospace(monospace) + return this + } + + /** Sets a link. */ + public fun link(url: String): MessageBuilder { + message.link(url) + return this + } + + /** Inserts another message. */ + public fun insert(other: Message): MessageBuilder { + message.insert(other) + return this + } + + /** Builds the final [Message]. */ + public fun build(): Message = message +} +