Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -250,13 +266,15 @@ 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

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)
Expand Down Expand Up @@ -1457,6 +1475,7 @@ private class DefaultTestClientWorldContext(
}
}

/** Aggregate result of an [AClientGameTestHarness.run] invocation. */
data class AClientGameTestSummary(
val passed: Int,
val failed: Int,
Expand All @@ -1465,6 +1484,12 @@ data class AClientGameTestSummary(
val failedDetails: List<AClientGameTestFailure> = 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<Mod, List<Class<*>>>, side: AGameTestSide?): AClientGameTestSummary {
if (side != AGameTestSide.CLIENT) return AClientGameTestSummary(passed = 0, failed = 0, skipped = 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ fun getTextSize(
* Text(
* text = Component.literal("Hello, Archie!"),
* fontScale = 1.5f,
* color = KColor.YELLOW.argb,
* color = KColor.YELLOW,
* )
Comment on lines 44 to 48
* ```
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> GuiGraphics.pose(block: PoseStack.() -> T): T
{
val pose = pose()
Expand All @@ -120,6 +126,11 @@ fun <T> GuiGraphics.pose(block: PoseStack.() -> T): T
return ret
Comment on lines 120 to 126
}

/**
* 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 <T> GuiGraphics.scissor(minX: Int, minY: Int, maxX: Int, maxY: Int, block: () -> T): T
{
enableScissor(minX, minY, maxX, maxY)
Expand All @@ -128,6 +139,7 @@ fun <T> GuiGraphics.scissor(minX: Int, minY: Int, maxX: Int, maxY: Int, block: (
return ret
}

/** Overload of [scissor] taking the clip bounds as an [IntRect]. */
fun <T> GuiGraphics.scissor(rect: IntRect, block: () -> T): T
{
val (minX: Int, minY: Int, maxX: Int, maxY: Int) = rect
Expand All @@ -137,4 +149,9 @@ fun <T> 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()
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified T : Any> serverbound(noinline handler: PacketHandler<T>) = serverbound(T::class, handler)

/**
Expand All @@ -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 <reified T : Any> clientbound(noinline handler: PacketHandler<T>) = 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.
*/
Comment on lines +175 to +187
internal fun <T : ConfigSpec> configServerbound(klass: KClass<out T>, spec: T)
{
if (spec in serverConfigs) return
Expand All @@ -162,6 +195,16 @@ open class NetworkChannel(private val id: ResourceLocation) {

internal inline fun <reified T : ConfigSpec> 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 <T : ConfigSpec> configClientbound(klass: KClass<out T>, spec: T)
{
if (spec in clientConfigs) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ object ColorSerializer : KSerializer<Color>
/**
* 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 {
Expand Down
9 changes: 7 additions & 2 deletions Archie/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading