diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt index bcd92e89d..35f1ce5ed 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt @@ -94,9 +94,9 @@ object Archie /** * Reserved for client-only initialization that must run after [init], from a client - * entrypoint. Currently a no-op: Archie's own config screen already registers synchronously - * inside [init], since deferring it to a client entrypoint would race Catalogue's config - * screen discovery (see [ConfigSpec.init]). + * entrypoint. Currently a no-op: Archie's own config screen(s) already register synchronously + * inside [init] via `Config.init()` (see [ConfigContainer.initClient]), so there's nothing left + * to do here. */ @JvmStatic fun initClient() diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/ConfigSpec.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/ConfigSpec.kt index 166f01caf..2b4c5916c 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/ConfigSpec.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/ConfigSpec.kt @@ -98,17 +98,16 @@ sealed class ConfigSpec(val type: Type, val mod: Mod, val title: Component, val abstract val predicate: () -> Boolean /** - * Registers all [categories] (and their subcategories) and [load]s the config file, then, on - * the client, builds and registers the Cloth Config UI screen via [initClient]. Call once - * during common mod init, on both physical sides. + * Registers all [categories] (and their subcategories) and, for a [synchronized] spec, + * registers this spec's [NetworkChannel] plus the player-join/quit listeners that push it to + * joining clients and reset [isLoaded] on disconnect. * - * [initClient] is invoked from here - synchronously, during the common `main` entrypoint - - * rather than being left for callers to invoke from their own client entrypoint. Fabric loader - * runs every mod's `main` entrypoint before any mod's `client` entrypoint, so this guarantees - * the screen is registered with [AConfigPlatform] before Catalogue's own client entrypoint - * takes its one-time snapshot of `configFactory` providers. Registering later (e.g. from a - * `client` entrypoint) races that snapshot: depending on unrelated mods' load order, the - * config button would intermittently be missing from Catalogue's mod list. + * This does **not** load the config file or touch the client UI - each of [Common], [Client], + * [Server], and [Startup] overrides this to additionally register the lifecycle event + * (documented on that subclass) that calls [load] at the right time. The client-side settings + * screen is built and registered separately, once every nested spec in the container is + * initialized, by [ConfigContainer.initClient]. Call [ConfigContainer.init], not this method + * directly. */ open fun init() { diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/IConfigSerializer.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/IConfigSerializer.kt index f4d78599c..9edf90a8f 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/IConfigSerializer.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/config/IConfigSerializer.kt @@ -10,10 +10,14 @@ import java.nio.file.StandardCopyOption * Reads and writes a [ConfigSpec] to/from a specific file format (JSON, JSON5, TOML, ...). Built-in * implementations live in `net.kernelpanicsoft.archie.config.serializer`; [ConfigSpec.fileSerializer] * picks one per-platform by default. + * + * [configPath], [load], and [save] all take a [configFolder] that defaults to the platform's shared + * config folder ([Platform.getConfigFolder]); [ConfigSpec] passes its own [ConfigSpec.configFolder] + * instead, which a [ConfigSpec.Server] repoints at the current world's per-save `serverconfig/` folder. */ interface IConfigSerializer { - /** File the given [config] is read from and written to. */ + /** File the given [config] is read from and written to, resolved under [configFolder]. */ fun configPath(config: ConfigSpec, configFolder: Path = Platform.getConfigFolder()): Path /** * Reads [config]'s file if present via [loadString], then always [save]s it back out - this diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gametest/AClientGameTestHarness.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gametest/AClientGameTestHarness.kt index 42b6bb0cc..387a4abed 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gametest/AClientGameTestHarness.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gametest/AClientGameTestHarness.kt @@ -157,6 +157,13 @@ data class AClientGameTestFailure( val rootCause: String, ) +/** + * Synthetic input dispatch for a client GameTest's active [Screen], bypassing GLFW entirely + * (each call feeds the corresponding `mouseClicked`/`keyPressed`/... callback directly). Reached + * via [ClientGameTestContext.getInput] - most tests should prefer the higher-level, node-scoped + * [TestNodeScope] helpers (`click()`, `hover()`, `type()`, `scroll()`) instead of calling this + * directly, since those also resolve the target node's on-screen position first. + */ interface TestInput { fun click(x: Double, y: Double, button: Int = 0) fun keyPress(keyCode: Int, scanCode: Int = 0, modifiers: Int = 0) @@ -180,6 +187,12 @@ interface TestInput { fun clearInputs() } +/** + * Builds a test world from [ClientGameTestContext.worldBuilder]: either a singleplayer world via + * [create]/[withSingleplayer], or a same-JVM dedicated server via [createServer]/[withServer] for + * tests that need a real client↔server boundary (e.g. exercising [ConfigSpec.Server] sync or + * other multiplayer-only codepaths) rather than the integrated server a singleplayer world uses. + */ @Suppress("unused") interface TestWorldBuilder { fun setUseConsistentSettings(useConsistentSettings: Boolean): TestWorldBuilder @@ -209,6 +222,7 @@ interface TestWorldBuilder { } } +/** A running singleplayer test world created by [TestWorldBuilder.create], with server-side access via [server]. Closing (see [TestWorldBuilder.withSingleplayer]) disconnects and waits for the world to unload. */ @Suppress("unused") interface TestSingleplayerContext { val clientContext: ClientGameTestContext @@ -219,6 +233,7 @@ interface TestSingleplayerContext { fun close() } +/** A running same-JVM dedicated server created by [TestWorldBuilder.createServer]. [connect] joins the client to it; closing (see [TestWorldBuilder.withServer]) stops the server process. */ @Suppress("unused") interface TestDedicatedServerContext { val clientContext: ClientGameTestContext @@ -242,6 +257,7 @@ interface TestDedicatedServerContext { fun close() } +/** The client's connection to a [TestDedicatedServerContext], returned by [TestDedicatedServerContext.connect]. */ @Suppress("unused") interface TestServerConnection { val clientContext: ClientGameTestContext @@ -250,6 +266,7 @@ interface TestServerConnection { fun disconnect() } +/** Client-side world-loading waits (chunk download/render), independent of world type - available on both [TestSingleplayerContext.clientWorld] and [TestServerConnection.clientWorld]. */ @Suppress("unused") interface TestClientWorldContext { fun waitForChunksDownload(timeout: Int = ClientGameTestContext.DEFAULT_TIMEOUT): Int @@ -257,6 +274,7 @@ interface TestClientWorldContext { fun waitForChunksRender(waitForDownload: Boolean = true, timeout: Int = ClientGameTestContext.DEFAULT_TIMEOUT): Int } +/** Server-side access for a [TestSingleplayerContext]'s integrated server: run commands or arbitrary code on the server thread. */ @Suppress("unused") interface TestServerContext { fun runCommand(command: String) @@ -1457,6 +1475,7 @@ private class DefaultTestClientWorldContext( } } +/** Aggregate result of an [AClientGameTestHarness.run] invocation. */ data class AClientGameTestSummary( val passed: Int, val failed: Int, @@ -1465,6 +1484,12 @@ data class AClientGameTestSummary( val failedDetails: List = emptyList(), ) +/** + * Runs every [ClientGameTest]-annotated method across [modToClasses] (as collected by + * [AGameTestPlatform.register] via [AGameTestEventObject]/[AEvents.ArchieGameTestBuilder]'s + * `client { }` block) sequentially on the client thread, then returns to the title screen. + * No-ops (returning an all-zero summary) unless [side] is [AGameTestSide.CLIENT]. + */ object AClientGameTestHarness { fun run(modToClasses: Map>>, side: AGameTestSide?): AClientGameTestSummary { if (side != AGameTestSide.CLIENT) return AClientGameTestSummary(passed = 0, failed = 0, skipped = 0) diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/basic/Text.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/basic/Text.kt index e50a6ee57..86e142219 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/basic/Text.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/basic/Text.kt @@ -44,7 +44,7 @@ fun getTextSize( * Text( * text = Component.literal("Hello, Archie!"), * fontScale = 1.5f, - * color = KColor.YELLOW.argb, + * color = KColor.YELLOW, * ) * ``` * diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/KColor.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/KColor.kt index bcc5f1aa3..17a2c9dd4 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/KColor.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/KColor.kt @@ -130,5 +130,6 @@ data class KColor( */ val argb: Int get() = (alpha shl 24) or (red shl 16) or (green shl 8) or blue + /** Converts this to a vanilla [TextColor] (RGB only - [TextColor] carries no alpha channel). */ fun toTextColor(): TextColor = TextColor.fromRgb(rgb) } diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/extension/GuiGraphics.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/extension/GuiGraphics.kt index 6524969cd..ca5742d94 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/extension/GuiGraphics.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/util/extension/GuiGraphics.kt @@ -111,6 +111,12 @@ fun GuiGraphics.drawRectOutline( fill(type, x + width - thickness, y + thickness, x + width, y + height - thickness, color) } +/** + * Runs [block] with this [GuiGraphics]'s [PoseStack][com.mojang.blaze3d.vertex.PoseStack] pushed, + * popping it again afterwards (including when [block] throws). Saves the manual + * `pose().pushPose()` / `pose().popPose()` pairing renderers otherwise need around + * translate/scale/rotate calls. + */ fun GuiGraphics.pose(block: PoseStack.() -> T): T { val pose = pose() @@ -120,6 +126,11 @@ fun GuiGraphics.pose(block: PoseStack.() -> T): T return ret } +/** + * Runs [block] with scissoring enabled to the `[minX, minY, maxX, maxY)` rectangle, disabling + * it again afterwards. Saves the manual `enableScissor(...)` / `disableScissor()` pairing + * renderers otherwise need around clipped content. + */ fun GuiGraphics.scissor(minX: Int, minY: Int, maxX: Int, maxY: Int, block: () -> T): T { enableScissor(minX, minY, maxX, maxY) @@ -128,6 +139,7 @@ fun GuiGraphics.scissor(minX: Int, minY: Int, maxX: Int, maxY: Int, block: ( return ret } +/** Overload of [scissor] taking the clip bounds as an [IntRect]. */ fun GuiGraphics.scissor(rect: IntRect, block: () -> T): T { val (minX: Int, minY: Int, maxX: Int, maxY: Int) = rect @@ -137,4 +149,9 @@ fun GuiGraphics.scissor(rect: IntRect, block: () -> T): T return ret } +/** + * Lets a [GuiGraphics] receiver be invoked like `guiGraphics { ... }`, running [block] with + * `this` as the receiver. Used throughout the built-in composables' `Renderer` implementations + * to avoid repeating the `guiGraphics.` prefix on every draw call. + */ operator fun GuiGraphics.invoke(block: GuiGraphics.() -> Unit): Unit = block() diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/NetworkChannel.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/NetworkChannel.kt index 9e259b37e..c718ffd4d 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/NetworkChannel.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/NetworkChannel.kt @@ -130,6 +130,16 @@ open class NetworkChannel(private val id: ResourceLocation) { serverClasses.add(klass) } + /** + * Registers a server-bound packet type and its handler, inferring the packet class from the + * reified type parameter [T] instead of requiring `T::class` to be passed explicitly. + * + * Equivalent to `serverbound(T::class, handler)`. + * + * @param T The packet data class type. + * @param handler The handler invoked on the receiving side. + * @throws IllegalArgumentException if [T] is not a data class, lacks a serializer, or is already registered. + */ inline fun serverbound(noinline handler: PacketHandler) = serverbound(T::class, handler) /** @@ -150,8 +160,31 @@ open class NetworkChannel(private val id: ResourceLocation) { clientClasses.add(klass) } + /** + * Registers a client-bound packet type and its handler, inferring the packet class from the + * reified type parameter [T] instead of requiring `T::class` to be passed explicitly. + * + * Equivalent to `clientbound(T::class, handler)`. + * + * @param T The packet data class type. + * @param handler The handler invoked on the receiving side. + * @throws IllegalArgumentException if [T] is not a data class, lacks a serializer, or is already registered. + */ inline fun clientbound(noinline handler: PacketHandler) = clientbound(T::class, handler) + /** + * Registers [spec] as a server-bound "save my changes" packet: when the server receives one, + * it is decoded with [spec]'s own [kotlinx.serialization.KSerializer] (via + * [net.kernelpanicsoft.archie.config.ConfigSpec.serializer]) rather than the reflective one + * used for ordinary packet classes, since a `ConfigSpec` singleton isn't itself + * `@Serializable`. The permission check, persistence, and broadcast/rejection of the + * resulting value happen in `decodeDispatchData`, not in the handler registered here (which + * just calls [net.kernelpanicsoft.archie.config.ConfigSpec.save] again for symmetry with + * [configClientbound]). No-op if [spec] is already registered. + * + * Internal: used by [net.kernelpanicsoft.archie.config.ConfigSpec.init] to wire up + * server/client config sync. Not part of the public packet API. + */ internal fun configServerbound(klass: KClass, spec: T) { if (spec in serverConfigs) return @@ -162,6 +195,16 @@ open class NetworkChannel(private val id: ResourceLocation) { internal inline fun configServerbound(spec: T) = configServerbound(spec::class, spec) + /** + * Registers [spec] as a client-bound config-sync packet: when the client receives one, it is + * decoded with [spec]'s own [kotlinx.serialization.KSerializer] and saved locally via + * [net.kernelpanicsoft.archie.config.ConfigSpec.save]. Used to push a + * [net.kernelpanicsoft.archie.config.ConfigSpec.Server] config's values to a joining player. + * No-op if [spec] is already registered. + * + * Internal: used by [net.kernelpanicsoft.archie.config.ConfigSpec.init] to wire up + * server/client config sync. Not part of the public packet API. + */ internal fun configClientbound(klass: KClass, spec: T) { if (spec in clientConfigs) return diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/serializers/BuiltinSerializers.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/serializers/BuiltinSerializers.kt index d0e7b132d..4aa240425 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/serializers/BuiltinSerializers.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/serializers/BuiltinSerializers.kt @@ -121,6 +121,10 @@ object ColorSerializer : KSerializer /** * A [SerializersModule] that registers Archie's Cloth Config-related type serializers * ([ModifierKeyCodeSerializer], [ColorSerializer]) as contextual serializers. + * + * [ModifierKeyCodeSerializer] is only registered on the physical client (via [onClient]), since + * [ModifierKeyCode] is a client-only Cloth Config type that a dedicated server shouldn't + * class-load; [ColorSerializer] is registered on both sides. */ val BuiltInSerializersModule = SerializersModule { onClient { diff --git a/Archie/docs/config.md b/Archie/docs/config.md index a7674713d..8acbe8e2e 100644 --- a/Archie/docs/config.md +++ b/Archie/docs/config.md @@ -181,5 +181,10 @@ You don't build the settings screen yourself. `ConfigContainer.client` lazily bu matching `ClientDataSpec`) that mirrors your spec into a Cloth Config `ConfigBuilder` — one category per enabled entry. Saving routes through `ConfigSpec.save()` for `Common`/`Client`/ `Startup` configs, or over the network via `ConfigSpec.Server`'s channel for `Server` configs. -`ConfigContainer.init()` registers the resulting screen with the platform's mod-list UI — open it -wherever you'd open any mod's config screen, e.g. via Mod Menu or Catalogue. +`ConfigContainer.init()` registers the resulting screen via Architectury's +`Mod.registerConfigurationScreen`, which surfaces it wherever the platform normally exposes a mod's +config screen — Mod Menu's mod list on Fabric, the vanilla mod list's "Config" button on NeoForge. +There's no more Archie-specific Mod Menu/Catalogue entrypoint to register yourself; the dedicated +`ArchieModMenu`/`ArchieCatalogue` bridge classes were retired along with the old +`AConfigPlatform.registerScreenHandler` mechanism they depended on, and Catalogue no longer has a +working integration path as a result. diff --git a/Archie/docs/datagen.md b/Archie/docs/datagen.md new file mode 100644 index 000000000..00c11e6f6 --- /dev/null +++ b/Archie/docs/datagen.md @@ -0,0 +1,297 @@ +# Data Generation + +Archie's datagen system is a Kotlin DSL over vanilla's `DataProvider` machinery (blockstates, +models, languages, recipes, tags) plus a cross-loader condition system for gating datapack +entries. It hooks into Fabric's and NeoForge's separate native datagen entrypoints once, in +platform code, so mod authors write one `ADatagenEventObject` and never touch either loader's +datagen API directly. + +--- + +## Running datagen + +Datagen runs as a separate Gradle run configuration per loader, from inside `Archie/` (or +`Archie-Test/` for the playground mod): + +```bash +./gradlew fabric:runDatagen +./gradlew neoforge:runDatagen +``` + +This sets the `archie.datagen` system property, which `ADataGeneratorPlatform.isDataGen` reads to +decide whether to run datagen registration at all, plus `archie.datagen.client`/ +`archie.datagen.server`, which `ADataGenerator.isClient`/`isServer` read to decide whether +client-only providers (models, languages) or server-only providers (tags, recipes) run in this +particular invocation — a dedicated server datagen run skips client providers and vice versa. + +--- + +## Wiring a mod in + +A mod opts into datagen the same way it opts into [events](events.md) generally: register with +`AEvents`, then subclass `ADatagenEventObject` to hook `AEvents.GATHER_DATA` and call `.init()` +once `ADataGeneratorPlatform.isDataGen` is true. `ADatagenEventObject` is a thin +`ADatagenEventObject`/`AEventObject` binding — see [events.md](events.md) for how the underlying +mod-scoped event plumbing works. + +```kotlin +internal object MyModDatagen : ADatagenEventObject(MyMod.MOD) { + override fun ADataGenerator.handler() { + client { /* models, languages */ } + common { /* tags, recipes */ } + } +} + +object MyMod { + fun init() { + AEvents += MOD + if (ADataGeneratorPlatform.isDataGen) + MyModDatagen.init() + } +} +``` + +This mirrors Archie's own wiring in `Archie.kt` (`if (ADataGeneratorPlatform.isDataGen) +ArchieDatagen.init()`) and the test mod's `ArchieTest.init()`. + +--- + +## The `ADataGenerator` DSL + +`handler()` receives an `ADataGenerator` and organizes providers into `client { }` and +`common { }` scopes, since client-only and server-only providers must be skippable independently +(see [Running datagen](#running-datagen)). Each DSL method registers a provider (gated by +`isClient`/`isServer`) and returns it, so later providers can depend on earlier ones (e.g. item +tags copying from block tags): + +| Scope | Method | Registers | +|---|---|---| +| `client` | `blockStates { }` | `ABlockStateProvider` | +| `client` | `itemModels { }` | `AItemModelProvider` | +| `client` | `blockModels { }` | `ABlockModelProvider` | +| `client` | `languages { }` | `ALanguageProvider` | +| `common` | `blockTags { }` | `ATagsProvider.BlockTagsProvider` | +| `common` | `itemTags { }` | `ATagsProvider.ItemTagsProvider` | +| `common` | `biomeTags { }` | `ATagsProvider.BiomeTagsProvider` | +| `common` | `entityTags { }` | `ATagsProvider.EntityTypeTagsProvider` | +| `common` | `fluidTags { }` | `ATagsProvider.FluidTagsProvider` | +| `common` | `recipes { }` | `ARecipeProvider` | + +For anything not covered above, `addProvider { output -> MyProvider(output) }` (or the +`HolderLookup.Provider`-aware overload) registers an arbitrary `DataProvider`, gated by an +explicit `run: Boolean` you pass yourself. + +Every Archie data provider implements `IADataProvider`, which adds `mod`, `exitOnError`, and the +`modLoc`/`mcLoc` resource-location helpers used throughout the DSL below. When `generate()` throws, +a provider logs the error and continues (or calls `exitProcess(-1)` if `exitOnError` is set) +instead of crashing the whole datagen run. + +--- + +## Client model data + +`ABlockStateProvider` builds `blockstates/*.json`, and owns an embedded `ABlockModelProvider` and +`AItemModelProvider` (reachable via `blockModels { }`/`itemModels { }` inside it) so a block's +state, model, and item model can be declared together. Register variants with `getVariantBuilder` +(a `variants` blockstate) or `getMultipartBuilder` (a `multipart` blockstate); both return a +builder keyed by block property combinations. Vanilla-shape helpers — `simpleBlock`, +`simpleBlockWithItem`, `axisBlock`/`logBlock`, `stairsBlock`, `slabBlock`, `fenceBlock`, +`fenceGateBlock`, `wallBlock`, `paneBlock`, `doorBlock`, `trapdoorBlock`, `buttonBlock`, +`pressurePlateBlock`, `signBlock` — mirror NeoForge's vanilla `BlockStateProvider` datagen helpers +1:1 in name and parameters. Blocks passed in are typically ones registered through a +[registry helper](registries.md). + +```kotlin +client { + blockStates { output -> + object : ABlockStateProvider(output, MyMod.MOD, false) { + override fun generate() { + simpleBlockWithItem(MyBlocks.MY_BLOCK) + stairsBlock(MyBlocks.MY_STAIRS, blockTexture(MyBlocks.MY_BLOCK)) + } + } + } + itemModels { output -> + object : AItemModelProvider(output, MyMod.MOD, false) { + override fun generate() { + withExistingParent("my_item", "item/handheld") + } + } + } +} +``` + +Below the blockstate layer, `AModelProvider` (subclassed as `ABlockModelProvider`/ +`AItemModelProvider`) builds the individual model JSONs via `getBuilder`/`withExistingParent` plus +vanilla-shape helpers (`cubeAll`, `cubeColumn`, `orientable`, `stairs`, `slab`, `fenceGate`, +`trapdoorBottom`, ...), again matching NeoForge's `ModelProvider` 1:1. Each model builder +(`AModelBuilder`, via `ABlockModelBuilder`/`AItemModelBuilder`) configures `parent`, `texture`, +`renderType`, inline `element`s, display `transforms`, and (on Forge-like loaders) a +`customLoader` (`ACustomLoaderBuilder`) replacing vanilla geometry entirely. `AConfiguredModel` +wraps a model reference with rotation/weight/uvlock for use in a blockstate variant. +`AModelFile` is just a resource-location reference to a model, usable as a `parent` or a variant's +model without requiring the model be built by the same provider. `AVariantBlockStateBuilder` +requires every possible `BlockState` of the owning block to be covered before it serializes — +use `forAllStates`/`forAllStatesExcept` to cover every combination at once, or `partialState()` + +`setModels`/`addModels` one combination at a time. `AMultiPartBlockStateBuilder`'s parts apply +their models when their `condition`/`nestedGroup` (AND/OR) clauses match instead. + +Root-level model transforms and the `"TRSR"` transform JSON format used by `rootTransforms` are +backed by `TransformationHelper` (interpolation, quaternion, and Gson-deserializer helpers for +`Transformation`) — most model code never needs to touch it directly. + +--- + +## Language and translations + +`ALanguageProvider` writes `assets//lang/.json`. Call `add` (or the +`Block`/`Item`/`ItemStack`/`MobEffect`/`EntityType` convenience overloads, which translate the +target's vanilla `descriptionId`) inside `generate()`; a duplicate key throws. + +```kotlin +client { + languages { // defaults to "en_us" + add(MyBlocks.MY_BLOCK, "My Block") + add(MyItems.MY_ITEM, "My Item") + add("mymod.some.key", "Some Text") + } +} +``` + +--- + +## Recipes + +`ARecipeProvider` wraps vanilla's `RecipeProvider`. Build recipes with the `shaped`/`shapeless`/ +`smelting`/`blasting`/`smoking`/`cooking` DSL builders (or vanilla's own `RecipeBuilder`s +directly), then `save` into the given `RecipeOutput`. `unlockedBy(ingredient: ItemLike)`/ +`unlockedBy(tag: TagKey)` extension functions add a criterion named after the +ingredient/tag automatically. + +```kotlin +common { + recipes { recipeOutput -> + shaped { + category = RecipeCategory.MISC + result = MyItems.MY_ITEM + count = 4 + pattern { + +"XXX" + +"X X" + +"XXX" + } + key { 'X' to MyItems.INGREDIENT } + }.unlockedBy(MyItems.INGREDIENT).save(recipeOutput) + + shapeless { + category = RecipeCategory.MISC + result = MyItems.OTHER_ITEM + ingredients { + 2 of MyItems.INGREDIENT + 1 of ItemTags.PLANKS + } + }.save(recipeOutput) + } +} +``` + +`IARecipeBuilder.save(recipeOutput, id = null) { ... }` attaches an `IACondition` (built with +`AConditionBuilder` in scope) to the saved recipe — see [Conditions](#conditions). + +### Custom ingredients + +`IACustomIngredient` lets a mod define recipe-matching behavior beyond vanilla `Ingredient` +(ported from Fabric's custom ingredient API to work cross-loader). Implement `test`, +`matchingStacks`, `requiresTesting`, and `serializer`, then convert to a vanilla `Ingredient` via +the `.vanilla` property so it can be used anywhere an `Ingredient` is expected. Archie registers +its own built-ins on init (`ABuiltinIngredients.init()`, called from `Archie.init()`): + +| Type | Matches | +|---|---| +| `AAllIngredient` | Every sub-ingredient matches (AND) | +| `AAnyIngredient` | At least one sub-ingredient matches (OR) | +| `AComponentsIngredient` | A base ingredient, plus a required data-component patch | +| `ACustomDataIngredient` | A base ingredient, plus a partial `minecraft:custom_data` NBT match | + +`ACombinedIngredient` is the shared base that `AAllIngredient`/`AAnyIngredient` build on. + +```kotlin +val onlyStrippedLogs: Ingredient = AAllIngredient.of( + Ingredient.of(ItemTags.LOGS), + Ingredient.of(MyItemTags.STRIPPED), +) +``` + +--- + +## Tags + +`ATagsProvider` wraps vanilla's `TagsProvider`; use the `BlockTagsProvider`/`ItemTagsProvider`/ +`FluidTagsProvider`/`EntityTypeTagsProvider`/`BiomeTagsProvider` subclasses registered via the +`blockTags`/`itemTags`/`fluidTags`/`entityTags`/`biomeTags` DSL methods. An `ItemTagsProvider` +constructed with a `blockTagsProvider` (which the `ADataGenerator.Common.itemTags` DSL wires up +automatically when `blockTags { }` was called first in the same `common { }` block) gets `copy`, +mirroring a block tag into an item tag. + +Inside `generate`, calling a `TagKey` builds (or reuses) its `IATagBuilder` — either +explicitly via `invoke`/`invoke { }`, or through operator shorthand: + +| Operator | Effect | +|---|---| +| `tag += element` / `tag += ResourceKey` / `tag += ResourceLocation` | `add` | +| `tag += otherTag` (`TagKey`) | `addTag` — a nested tag reference | +| `tag *= element` / `tag *= otherTag` | `addOptional`/`addOptionalTag` | + +```kotlin +val MY_LOGS: TagKey = TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath(MyMod.MOD_ID, "my_logs")) +val MY_LOGS_ITEM: TagKey = TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(MyMod.MOD_ID, "my_logs")) + +common { + blockTags { registries -> + MY_LOGS += MyBlocks.MY_LOG + MY_LOGS += BlockTags.LOGS // nests a reference to the vanilla tag, not its elements + } + itemTags { registries -> + copy(MY_LOGS, MY_LOGS_ITEM) + } +} +``` + +`ACommonTags` holds `TagKey` constants for the `c:` (common) convention tags shared across the +modding ecosystem, grouped by registry (`ACommonTags.Blocks`, `.Items`, `.Fluids`, +`.EntityTypes`, `.Biomes`) — use these directly instead of redeclaring the same conventional tags, +e.g. `ACommonTags.Items.INGOTS_COPPER`. + +--- + +## Conditions + +`IACondition` is a cross-loader condition, evaluated at datapack load time, that gates whether the +entry it's attached to (currently: a recipe, via `IARecipeBuilder.save`) is active — mirroring +Fabric's/NeoForge's native condition systems but decoded through one shared `IACondition.CODEC` so +the same condition classes work on both loaders. `AConditionBuilder` is a DSL for building +condition trees with infix combinators; it's the implicit receiver inside a `save { }` condition +block or `buildCondition { }`. + +| Function/operator | Condition | +|---|---| +| `mod("modid", ...)` | `AModLoadedCondition` — every mod id is loaded | +| `registry(registryKey, id, ...)` | `ARegistryCondition` — every id is registered | +| `platform(FABRIC \| NEOFORGE)` | `APlatformCondition` — running loader matches | +| `TRUE` / `FALSE` | `ATrueCondition` / `AFalseCondition` | +| `a and b`, `a or b`, `a xor b`, `a eql b` | `AAndCondition`/`AOrCondition`/`AXorCondition`/`AEqualsCondition` | +| `!a`, `a nand b`, `a nor b`, `a xnor b`, `a neql b` | Negated forms (`ANotCondition` wrapping the above) | + +```kotlin +shaped { + category = RecipeCategory.MISC + result = MyItems.MY_ITEM + pattern { +"X" } + key { 'X' to Items.DIAMOND } +}.save(recipeOutput) { + mod("architectury") and platform(FABRIC) +} +``` + +Register a custom condition type with `IACondition.register(identifier, codec)`, mirroring how +`ABuiltinConditions.init()` registers Archie's own set during `Archie.init()`. diff --git a/Archie/docs/gametest.md b/Archie/docs/gametest.md new file mode 100644 index 000000000..3ecebfa02 --- /dev/null +++ b/Archie/docs/gametest.md @@ -0,0 +1,380 @@ +# GameTest + +Archie has two layers of GameTest support: vanilla server-side `GameTestHelper` tests, registered +through a small mod-scoped wrapper around Architectury's event system, and a from-scratch +**client GameTest DSL** for driving and asserting against Archie's [GUI](gui.md) framework — +clicking, hovering, typing into, and reading state off of Compose screens without vanilla's +GameTest structures (which only run server-side) ever being involved. + +--- + +## Registering tests + +Both layers share one registration mechanism, built on `AEvents.REGISTER_GAME_TEST` (see +[Events](events.md) for `AEventObject`/`ABasicEventObject` in general). Subclass +`AGameTestEventObject` and declare test classes against an `AEvents.ArchieGameTestBuilder`: + +```kotlin +internal object ArchieGameTest : AGameTestEventObject(Archie.MOD) { + override fun AEvents.ArchieGameTestBuilder.handler() = archieGameTests() +} + +internal fun AEvents.ArchieGameTestBuilder.archieGameTests() { + server { + register() + } + client { + register() + } + common { + // registered regardless of side + } +} +``` + +`server { }` and `client { }` only collect their `register()` calls when `AGameTestPlatform.side` +matches (`AGameTestSide.SERVER`/`CLIENT`); `common { }` always collects. Collected classes are +handed to `AGameTestPlatform.register(clazz, mod)`, an `expect object` with Fabric/NeoForge +`actual`s that stash them in a per-mod map for the loader's GameTest bootstrap to pick up. +`AGameTestPlatform.isGameTest`/`side` read Archie-owned system properties set only on its own +`gametest`/`gametestClient` Gradle runs, so they can't leak into a plain `runClient` invocation. + +A mod with zero registered test *functions* for a side crashes vanilla's `GameTestServer` boot +outright (`IllegalArgumentException: No test functions were given!`). `NoOpGameTest` is a +trivially-succeeding placeholder class Archie registers on each loader whenever a mod's suite +comes up empty for the current side — e.g. a client-only test mod's server invocation. + +Every mod's `AEvents.MODS` list is shared JVM-wide, which matters for composite builds: +`AGameTestModFilter.selectMods(mods)` narrows a run down to the mod(s) named by the +`archie.gametest.modid` system property, so `Archie-Test`'s own `runGametest`/`runGametestClient` +doesn't also re-run Archie's entire internal suite in the same process. Both loaders' test +collection call this before iterating registered classes — you don't normally need to touch it +unless you're wiring up a similar composite-build setup yourself. + +--- + +## Server-side tests + +Server-side GameTests are ordinary vanilla `GameTestHelper` tests. Archie's own convention writes +each test as an **extension function on `GameTestHelper`**, not a function taking a helper +parameter — this keeps `succeed()`/`assertEquals()`/etc. callable unqualified: + +```kotlin +@Suppress("unused") +class ArchieItemHandlerTests { + @GameTest(template = EMPTY) + fun GameTestHelper.testInsertRespectsMaxStackSize() { + val storage = ArchieItemStorage(1) + val stone = ItemResource.of(ItemStack(Items.STONE, 1)) + + val inserted = storage.insert(stone, 80, false) + + assertEquals(64L, inserted) + assertEquals(64, storage.get(0).getItem().count) + succeed() + } +} +``` + +(`EMPTY` is `"archie:gametest/empty"`, the empty structure template most tests that don't need +actual world geometry reference.) `assertEquals`/`assertTrue`/`expectThrows` here are Archie's own +`GameTestHelper` extension helpers (`gametest/internal/tests/GameTestAssertions.kt`), not part of +vanilla — they exist purely to make failures read like a normal assertion library instead of +manually calling `fail(...)`. + +A consuming mod writes the same shape. `Archie-Test`'s own suite exercises a real block-entity-backed +menu end to end: + +```kotlin +internal object ArchieTestGameTest : AGameTestEventObject(ArchieTest.MOD) { + override fun AEvents.ArchieGameTestBuilder.handler() = archieTestGameTests() +} + +internal fun AEvents.ArchieGameTestBuilder.archieTestGameTests() { + client { + register() + } +} +``` + +(That particular suite is client-side — see [`TestScreenGameTest`](#lightweight-probe-screens-vs-world-backed-tests) +below.) A server-side suite registers the same way under `server { }`. + +--- + +## Client GameTest DSL + +Vanilla's `GameTest` framework only runs on a dedicated/integrated server — there's no equivalent +for driving a real client `Screen`. Archie's client harness fills that gap: it boots a client +(optionally with a world), executes annotated test methods against a `ClientGameTestContext`, and +reports pass/fail without any pixel-perfect rendering assumptions. + +### Writing a test + +Mark a method `@ClientGameTest`, written as an extension function on `ClientGameTestContext`: + +```kotlin +class InputComponentsGameTest { + @ClientGameTest + fun ClientGameTestContext.testCheckboxHoverAndClickRenderState() { + setScreen { InputComponentsProbeScreen() } + waitForScreen { + waitForLayer(0) { + node("Checkbox") { + assertRenderState(TextureStates.DEFAULT) + + hover() + waitForComposeIdle() + assertRenderState(TextureStates.HOVERED) + + click() + waitForComposeIdle() + assertRenderState(TextureStates.CLICKED_AND_HOVERED) + } + } + } + } +} +``` + +Register the class under `client { register() }` in your +`AGameTestEventObject`, same as any other test class (see [Registering tests](#registering-tests)). +`AClientGameTestHarness.run` collects every `@ClientGameTest` method across the registered +classes and runs them sequentially on the client thread, logging `[ClientGameTest] PASS/FAIL` +lines per test and returning the client to the title screen afterward. + +### `ClientGameTestContext` + +The context passed to (or, idiomatically, received by) every client test method: + +| Member | Purpose | +|---|---| +| `setScreen { MyScreen() }` | Opens a screen and waits for the client to report it active | +| `waitForScreen { ... }` / `waitForScreen(Class)` | Waits for a `LayerManagerProvider` screen of type `S`, then runs a [`ComposeScreenTestContext`](#finding-nodes) block against it | +| `getInput()` | Raw [`TestInput`](#raw-input-testinput) — most tests use [`TestNodeScope`](#interacting-with-a-node) instead | +| `waitForComposeIdle()` | Waits for async Compose recomposition triggered by a prior input action to settle | +| `waitFor { client -> ... }` / `waitTick()` / `waitTicks(n)` | Polls a predicate, or advances the client a fixed number of ticks | +| `computeOnClient { }` / `runOnClient { }` | Runs arbitrary code on the client thread and returns its result | +| `withWorld { }` | Opens a `TestWorldBuilder` for a singleplayer world or dedicated server (see below) | +| `takeScreenshot(name)` / `assertScreenshotEquals(...)` / `assertScreenshotContains(...)` | Pixel screenshot capture/comparison (see [Screenshot comparison](#screenshot-comparison)) | +| `assertTrue` / `assertEquals` / `fail` | Plain assertions, same shape as the server-side helpers | + +### Finding nodes + +Every Archie composable creates a `LayoutNode` (see [GUI](gui.md) for the layout system itself). +Nodes are located by their `name` (e.g. `"Checkbox"`, `"Button"`, `"Column"`) via +`LayoutNode.findNode`/`findAllNodes`, which walk the subtree depth-first. `ComposeScreenTestContext` +(returned into `waitForScreen { ... }`) is the entry point for that lookup: + +```kotlin +waitForScreen { + assertEquals(1, layerCount) // number of layers on the LayerManager stack + val triggers = baseLayer.rootNode { nodes("Button") } + + triggers[0] { click() } + waitFor { _ -> layerCount == 2 } // a modal pushed a second layer + + node("Surface", layer = LayerSelector.Top) { + val buttons = nodes("Button") + buttons[0] { click() } + } +} +``` + +- `node(name, layer = LayerSelector.Top, timeout = ...) { ... }` waits for a descendant named + `name` to appear on the selected layer, then runs the block against it as a `TestNodeScope`. +- `Layer.node(name) { ... }` does the same scoped to an already-resolved `Layer`. +- `layerCount`, `topLayer`, `baseLayer`, `layer(index)`, `waitForLayer(index) { ... }` navigate the + `LayerManager` stack directly — `LayerSelector.Top` is the frontmost layer (a modal if one is + open), `LayerSelector.Base` is always the screen's original layer. +- `hasNode(name)` checks existence without waiting or failing. + +`TestNodeScope.node(name) { ... }` (and `nodes(name)`, for the rare case a subtree has more than +one match — e.g. every "Button" in a dialog's action row) do the same lookup scoped to a node's +own subtree instead of a whole layer, so nested lookups read as plain nesting: + +```kotlin +node("Column") { + node("Row") { + assertChildNames("Box", "Text") + node("Box") { assertHasDescendant("RadioButton") } + } +} +``` + +Both `ComposeScreenTestContext` and `TestNodeScope` also support `someResolvedNode { ... }` — +`operator fun invoke` on an already-resolved `LayoutNode` — for wrapping a node pulled out of +`nodes(name)` without constructing a `TestNodeScope` by hand (as `triggers[0] { click() }` above). + +### Interacting with a node + +`TestNodeScope` wraps one resolved `LayoutNode` plus the enclosing `ClientGameTestContext`: + +| Method | Effect | +|---|---| +| `click(button = 0)` | Clicks the node's on-screen center | +| `hover()` | Moves the cursor to the node's center without clicking | +| `pressKey(keyCode, ...)` / `type(value)` | Dispatches key/char input to the active screen (not scoped to the node — click/hover it first if it needs focus) | +| `scroll(x, y)` | Moves the cursor to the node's center, then scrolls | +| `renderState` / `assertRenderState(expected)` | Reads/asserts the node's [render-state hook](#render-state-assertions) | +| `childNames()` / `assertChildNames(vararg)` | This node's direct children's names, in composition order | +| `hasDescendant(name)` / `assertHasDescendant(name)` | Whether a named descendant exists anywhere in the subtree | +| `assertAllDescendantsSized()` | Fails if this node or any descendant has non-positive width/height | +| `nodes(name)` | Every descendant named `name`, depth-first | +| `describeTree()` | A recursive dump of the subtree, useful in custom failure messages | + +`centerCoords()` (used internally by `click`/`hover`/`scroll`) calls `waitForComposeIdle()` first — +a node's very first interaction right after it's found can otherwise read a transient pre-layout +position and miss it silently. + +### Raw input (`TestInput`) + +`ClientGameTestContext.getInput()` exposes the lower-level primitives `TestNodeScope` builds on: +`click`/`holdMouse`/`releaseMouse`, `pressKey`/`holdKey`/`releaseKey`, `holdControl`/`holdShift`/ +`holdAlt` (and their `release*` counterparts), `charTyped`/`typeChars`, `scroll`, `setCursor`/ +`moveCursor`, and `clearInputs()`. Reach for this directly only when a test needs input that isn't +scoped to a single node — e.g. holding a modifier key across several node interactions, as in: + +```kotlin +node("Slider") { + hover() + context.getInput().holdMouse(0) + waitForComposeIdle() + assertRenderState(TextureStates.CLICKED) + context.getInput().releaseMouse(0) +} +``` + +### Render-state assertions + +Stateful renderers (`Checkbox`, `Switch`, `Radio.kt`'s `RadioButton`, and similar theme-driven +composables) set `UINode.renderState` — a test-only hook — to the `TextureStates` key they most +recently resolved (e.g. `"hovered"`, `"clicked_and_hovered"`) just before drawing. The framework +never reads it back; it exists purely so a test can assert *which visual state a component +resolved to* without a pixel comparison: + +```kotlin +node("Switch") { + assertRenderState(TextureStates.CLICKED) // probe's initial `switched = true` + + click() + waitForComposeIdle() + assertRenderState(TextureStates.DEFAULT) +} +``` + +This is the primary way component tests verify visual/interaction state in this codebase — it's +cheaper and far less flaky than screenshot comparison, and it fails with a readable +`expected/got` message instead of an opaque image diff. + +### Lightweight probe screens vs. world-backed tests + +Most component/widget tests run against a small, purpose-built `ComposeScreen` — a "probe screen" +with no world, menu, or player, just enough composition to exercise the widget under test: + +```kotlin +private class InputComponentsProbeScreen( + private val onButtonClick: () -> Unit = {}, +) : ComposeScreen(Component.literal("Input Components Probe")) { + override fun init() { + super.init() + start { + Theme { + Column { + Checkbox(checked = false, onCheckedChange = {}) + Button(onClick = onButtonClick) { Text(Component.literal("Click me")) } + } + } + } + } +} +``` + +This is enough for anything that doesn't depend on a container menu's slot contents or a block +entity's synced state — hierarchy shape, hover/click/type behavior, render-state transitions, +scroll offsets, modal stacking. + +Slot/menu rendering and block-entity-backed sync genuinely need a world instead: +`ComposeContainerMenu` and +`ComposeContainerScreen, B : BlockEntity>` are hard-typed to a real +`BlockEntity`, so there's no probe-screen shortcut for them. Those tests open a real world and +place a real block: + +```kotlin +class TestScreenGameTest { + @ClientGameTest + fun ClientGameTestContext.testShowcaseScreenOpensAndConfirmDialogRoundTrips() { + withWorld { + withSingleplayer { + val player = waitForPlayer() + val pos = player.blockPosition().above() + placeTileAndWaitForScreen(pos, BlockRegistry.TestBlock.defaultBlockState()) { + node("Button", layer = LayerSelector.Base) { click() } + waitFor { _ -> layerCount == 2 } + node("Button", layer = LayerSelector.Top) { click() } + waitFor { _ -> layerCount == 1 } + } + } + } + } +} +``` + +`withWorld { withSingleplayer { ... } }` opens a real singleplayer world via `TestWorldBuilder`; +`placeTileAndWaitForScreen(pos, state)` (a `TestSingleplayerContext` extension) places the +block, waits for its block entity, opens its menu server-side, and waits for the client screen — +one call replacing what would otherwise be several manual `runOnServer`/`waitFor` steps. + +For a same-JVM dedicated server instead of an integrated singleplayer server (needed for anything +that depends on a real client↔server boundary), use `withWorld { withServer(serverProperties) { ... } }` +— backed by the loader-specific `ADedicatedServerPlatform.start`/`stop`. This is a heavier, +slower path than `withSingleplayer` and only worth it when the integrated server's shortcuts +(same JVM, same thread scheduling) would mask what the test is actually checking. + +### Screenshot comparison + +`ClientGameTestContext.takeScreenshot(name)` captures the client's main render target to +`build/gametests/screenshots/captures/`; `assertScreenshotEquals`/`assertScreenshotContains` +compare it against a template resolved from `build/gametests/screenshots/templates/` via +`ScreenshotManager`, using either exact pixel matching (`ExactScreenshotComparisonAlgorithm`) or +fuzzy matching within a mean-squared-difference tolerance (`MeanSquaredDifferenceAlgorithm`, +`ScreenshotComparer.findInImageFuzzy`). This plumbing is real and functional, but Archie's own +suite doesn't currently ship any committed template images — there's no baseline directory checked +into the repo. In practice, node-tree assertions (`assertRenderState`, `assertChildNames`, +`assertAllDescendantsSized`) are the primary way this codebase verifies UI correctness: they're +deterministic across displays/GUI scales and fail with a specific, readable diff, where a +screenshot diff would just say "doesn't match" and require guessing which pixel region changed. +Reach for screenshot comparison only when a node-tree assertion genuinely can't express what +you're checking (e.g. a custom `Renderer` painting something that isn't a theme-state texture). + +### Test layers, don't conflate + +Client GameTests (`@ClientGameTest`, run via `AClientGameTestHarness`) and server GameTests +(`@GameTest`) both run inside a real launched Minecraft process, under `runGametest`/ +`runGametestClient`. Separately, `common/src/test/kotlin/.../testing/GameTests.kt` and +`GuiClientHarnessTests.kt` are plain JVM-level JUnit 5 tests (`./gradlew test`), a different layer +entirely: + +- `GuiClientHarnessTests` unit-tests pure helper functions (slider normalization, scrollable axis + resolution, and similar) with no client, world, or screen involved at all. +- `GameTests` uses `GameTestRunner.tests(...)` to shell out to the loader's `runGametest`/ + `runGametestClient` Gradle tasks per `loader:side` and report each declared test as its own + JUnit `DynamicTest`, parsed from the launched process's log output — a way to surface the real + in-game suite's pass/fail inside a JUnit run/report. It's disabled by default (opt in with + `-Darchie.junit.gametest=true`) since it boots a full Minecraft process per matrix entry. + +--- + +## Running the tests + +From inside `Archie/` (or `Archie-Test/`, for the playground mod's own suite): + +```bash +./gradlew fabric:runGametest +./gradlew neoforge:runGametest +./gradlew fabric:runGametestClient +./gradlew neoforge:runGametestClient +``` + +`runGametest` runs server-side `@GameTest`s; `runGametestClient` runs `@ClientGameTest`s via +`AClientGameTestHarness`. Both fail the Gradle task if any test fails. diff --git a/Archie/docs/gui.md b/Archie/docs/gui.md index 37e3fbfba..28ab3b70a 100644 --- a/Archie/docs/gui.md +++ b/Archie/docs/gui.md @@ -119,9 +119,11 @@ val tabs = listOf( ) val tabState = rememberTabContainerState(tabs) -TabContainer(tabs = tabs, state = tabState) { selected -> - println("Selected tab: ${selected.id}") -} +TabContainer( + tabs = tabs, + state = tabState, + onTabSelected = { selected -> println("Selected tab: ${selected.id}") }, +) ``` ### Animation helpers @@ -191,6 +193,34 @@ Modifier --- +## Custom rendering helpers + +All built-in composables render via a `Layout(... renderer = object : Renderer { ... })` +callback that receives a `GuiGraphics`. When writing your own `Renderer` (for a custom +composable), a few extension helpers on `GuiGraphics` remove the usual boilerplate: + +```kotlin +renderer = object : Renderer { + override fun render( + node: UINode, x: Int, y: Int, + guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float, + ) = guiGraphics { // GuiGraphics.invoke - `this` is the GuiGraphics below + pose { // pushes/pops the pose stack around the block + translate(x.toDouble(), y.toDouble(), 0.0) + scale(1.5f, 1.5f, 1.5f) + } + scissor(x, y, x + node.width, y + node.height) { // enable/disableScissor around the block + drawString(minecraftClient.font, "Hi", x, y, KColor.WHITE.argb) + } + } +} +``` + +`pose { }` and `scissor(minX, minY, maxX, maxY) { }` (also overloaded to take an `IntRect`) +always restore the previous pose/scissor state afterwards, even if the block throws. + +--- + ## Layer system The layer stack enables floating overlays (modals, dropdowns, tooltips) that render on top @@ -231,33 +261,10 @@ layerManager.modal(transitionSpec = ModalTransitionSpec(durationMillis = 220)) { ## Automated GUI Testing -Archie includes a backport client harness for GUI-focused tests on 1.21.1. - -- Mark client test methods with `@ClientGameTest`, written as an extension function on - `ClientGameTestContext` (not a function taking a context parameter). -- `ClientGameTestContext` gives you `setScreen`, `waitForScreen`, `waitForLayer`, `hasNode`/`node`, - `computeOnClient`, and assertion helpers (`assertTrue`, `assertEquals`, ...). -- Register test classes in `ArchieGameTest.kt`'s `archieGameTests()` under `client { }`. - -```kotlin -class GuiClientHarnessTests { - @ClientGameTest - fun ClientGameTestContext.testSliderClamp() { - assertEquals(1f, normalizeSliderValue(2f)) - } -} -``` - -See `ComposeRenderingTests.kt` for a fuller example that drives an actual screen. - -Run commands: - -```zsh -./gradlew fabric:runGametestClient -./gradlew neoforge:runGametestClient -``` - -The harness logs `[ClientGameTest] PASS/FAIL` lines and fails the run when any client test fails. +Archie has a from-scratch client GameTest harness for driving and asserting against composables +and screens built with this framework — clicking, hovering, typing, and reading component state +back out of a live screen. See [GameTest § Client GameTest DSL](gametest.md#client-gametest-dsl) +for the full guide. --- @@ -271,6 +278,7 @@ val custom = KColor.ofRgb(0xFF8000) val semi = KColor.ofArgb(0x80FF0000L) val hsv = KColor.ofHsv(0.33f, 1f, 0.8f) val argb = custom.argb // Int: 0xAARRGGBB +val text = custom.toTextColor() // net.minecraft.network.chat.TextColor (RGB only, no alpha) ``` ### `HsvColor` diff --git a/Archie/docs/index.md b/Archie/docs/index.md index 1a80e2b61..afdc6a6ee 100644 --- a/Archie/docs/index.md +++ b/Archie/docs/index.md @@ -20,6 +20,7 @@ It targets both **Fabric** and **NeoForge** via [Architectury](https://github.co | **Transfer** | Cross-platform item storage and inventory slot helpers | | **Block Entities** | NBT-backed block entity base class | | **Resource Packs** | Deserialization-based resource reload listeners | +| **GameTest** | Server-side `GameTestHelper` registration plus a client GameTest DSL for driving/asserting Compose screens | --- diff --git a/Archie/docs/networking.md b/Archie/docs/networking.md index 8d89e58d7..1cdfab759 100644 --- a/Archie/docs/networking.md +++ b/Archie/docs/networking.md @@ -30,18 +30,20 @@ data class RequestDataPacket(val id: Int) ### Registering handlers -Register handlers **before** calling `register()`: +Register handlers **before** calling `register()`. The reified `serverbound`/`clientbound` +overloads infer the packet class from the type parameter, so you don't need to pass `::class` +yourself: ```kotlin // Server receives this packet from the client -CHANNEL.serverbound(RequestDataPacket::class) { packet, ctx -> +CHANNEL.serverbound { packet, ctx -> val player = ctx.player as ServerPlayer val data = fetchData(packet.id) CHANNEL.toPlayer(player, SyncEnergyPacket(data.energy, data.pos)) } // Client receives this packet from the server -CHANNEL.clientbound(SyncEnergyPacket::class) { packet, ctx -> +CHANNEL.clientbound { packet, ctx -> ClientEnergyCache.update(packet.pos, packet.energy) } @@ -49,6 +51,21 @@ CHANNEL.clientbound(SyncEnergyPacket::class) { packet, ctx -> CHANNEL.register() ``` +A `KClass`-based overload (`serverbound(RequestDataPacket::class) { ... }`) is also available if +you already have the class reference in hand; both forms register the same way. + +### Validation + +`serverbound`/`clientbound` validate the packet class as soon as you register it, throwing +`IllegalArgumentException` if: + +- the class isn't a Kotlin **data class**, +- the class isn't annotated `@Serializable` (or otherwise has no serializer), or +- that exact class was already registered on that side of the channel. + +Sending an unregistered packet type (via `toServer`/`toPlayer`/etc.) throws `IllegalStateException` +instead, since that failure can only be detected at send time. + ### Sending packets | Method | Description | @@ -92,3 +109,14 @@ data class TeleportPacket( Available contextual serializers: `BlockPos`, `ChunkPos`, `GlobalPos`, `Vec3`, `Vec3i`, `BlockHitResult`, `ResourceLocation`, `ItemStack`, `FriendlyByteBuf` — see [Serialization](serialization.md#minecraft-type-serializers) for the full alias table. + +--- + +## Config sync + +`ConfigSpec.Server` configs (see [Config](config.md)) use a `NetworkChannel` of their own, +internally, to sync per-world server config values to clients — the same channel mechanism +described above, just with the `ConfigSpec` itself as the payload instead of a hand-written +packet class. This is wired up automatically by `ConfigSpec`; you don't register anything with +it yourself. See [Config § Server configs sync over the network](config.md#server-configs-sync-over-the-network) +for how it behaves. diff --git a/Archie/docs/serialization.md b/Archie/docs/serialization.md index 22d135bc7..f085b6f1a 100644 --- a/Archie/docs/serialization.md +++ b/Archie/docs/serialization.md @@ -4,6 +4,11 @@ Archie provides a multi-layered serialization stack built on top of [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) and Mojang's [`Codec`](https://github.com/Mojang/DataFixerUpper) system. +> This page covers the general-purpose NBT/Codec/kotlinx.serialization mechanisms (contextual +> Minecraft-type serializers, the Codec bridge, `NBTHolder`, `@Sync`). Config file persistence +> (`IConfigSerializer` and the JSON/JSON5/TOML/no-op implementations) is a separate, config-specific +> layer documented in [config.md](config.md#formats). + --- ## NBT helpers (`serialization/NBT.kt`) diff --git a/Archie/mkdocs.yml b/Archie/mkdocs.yml index 79df4f43c..dd82aa5d1 100644 --- a/Archie/mkdocs.yml +++ b/Archie/mkdocs.yml @@ -115,7 +115,9 @@ nav: - Serialization: serialization.md - Config System: config.md - GUI Framework: gui.md + - GameTest: gametest.md - Events: events.md + - Data Gen: datagen.md - Transfer & Inventory: transfer.md - Resource Packs: resource-packs.md # !!! EMBEDDED DOKKA START, DO NOT COMMIT !!! #