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 @@ -10,6 +10,7 @@ internal fun AEvents.ArchieGameTestBuilder.archieTestGameTests()
register<TestScreenGameTest>()
}
server {
register<DataAttachmentTests>()
register<CapabilityLookupTests>()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package net.kernelpanicsoft.archie.test.gametest

import net.kernelpanicsoft.archie.serialization.AttachmentRegistry
import net.kernelpanicsoft.archie.test.ArchieTest
import net.minecraft.world.entity.Entity

/**
* Attachment fixtures for [DataAttachmentTests], covering both mechanisms
* [net.kernelpanicsoft.archie.serialization.ArchieDataAttachment] offers: reactive
* Entity/BlockEntity sync ([counter], also usable directly against a BlockEntity - a single
* attachment works on any supported holder kind) and vanilla item-component replication
* ([label]).
*
* Declared as a real [AttachmentRegistry], the same way a consuming mod would - its property
* initializers (which queue Common Storage Lib `DeferredRegister` entries) run at class-load
* time, so [AttachmentRegistry.init] (which actually registers them against the mod event bus)
* must be called separately, at real mod-init time. Same underlying reason as
* `CapabilityLookupTestFixtures`: this can't be deferred into the `@GameTest` methods themselves.
*/
internal object DataAttachmentTestFixtures : AttachmentRegistry(ArchieTest.MOD_ID)
{
val counter by intAttachment(sync = true, copyOnDeath = true, default = { 0 })
val label by stringAttachment(itemComponent = true, default = { "" })
}

/** Exercises `ArchieDataAttachment`'s `getValue`/`setValue` property-delegate contract directly, not just its `get`/`set` methods, matching real consuming-mod usage. */
internal var Entity.testCounter by DataAttachmentTestFixtures.counter
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package net.kernelpanicsoft.archie.test.gametest

import net.kernelpanicsoft.archie.gametest.internal.EMPTY
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.world.entity.EntityType
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.entity.ChestBlockEntity

/**
* GameTest coverage for [net.kernelpanicsoft.archie.serialization.ArchieDataAttachment]/
* [net.kernelpanicsoft.archie.serialization.AttachmentRegistry], via [DataAttachmentTestFixtures].
* Uses vanilla entity/block-entity/item types throughout - attachments are generic across any
* supported holder kind, so there's no need for a custom registered fixture type the way
* [CapabilityLookupTests] needed [net.kernelpanicsoft.archie.test.TileRegistry.TestTile].
*/
@Suppress("unused")
class DataAttachmentTests
{
@GameTest(template = EMPTY)
fun GameTestHelper.testGetReturnsDefaultBeforeSet()
{
val pig = spawnWithNoFreeWill(EntityType.PIG, BlockPos(1, 2, 1))

// has() must be checked *before* any get() call on an Entity/BlockEntity holder: Fabric's
// AttachmentTarget.getAttachedOrCreate (what get() calls under the hood there) silently
// creates *and persists* the default on first read, so has() can no longer distinguish
// "never touched" from "read once" afterward. See ArchieDataAttachment's KDoc.
if (DataAttachmentTestFixtures.counter.has(pig)) fail("Expected a freshly-spawned entity to have no explicitly-set value")
if (DataAttachmentTestFixtures.counter.get(pig) != 0) fail("Expected a freshly-spawned entity to read back the default value")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testSetGetHasRemoveRoundTrip()
{
val pig = spawnWithNoFreeWill(EntityType.PIG, BlockPos(2, 2, 1))
val attachment = DataAttachmentTestFixtures.counter

attachment.set(pig, 5)
if (attachment.get(pig) != 5) fail("Expected get() to return the value just set()")
if (!attachment.has(pig)) fail("Expected has() to be true after set()")

attachment.remove(pig)
// Check has() immediately after remove(), before the get() below re-triggers the same
// auto-persist-on-read behavior described above.
if (attachment.has(pig)) fail("Expected has() to be false immediately after remove()")
if (attachment.get(pig) != 0) fail("Expected get() to revert to the default after remove()")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testModifyAppliesFunctionAndPersists()
{
val pig = spawnWithNoFreeWill(EntityType.PIG, BlockPos(3, 2, 1))
val attachment = DataAttachmentTestFixtures.counter

attachment.set(pig, 5)
val result = attachment.modify(pig) { it + 1 }

if (result != 6) fail("Expected modify() to return the new value")
if (attachment.get(pig) != 6) fail("Expected modify() to persist the new value")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testExtensionPropertyDelegateRoundTrip()
{
val pig = spawnWithNoFreeWill(EntityType.PIG, BlockPos(4, 2, 1))

pig.testCounter = 42
if (pig.testCounter != 42) fail("Expected the by-delegated extension property to read back what it just wrote")
if (DataAttachmentTestFixtures.counter.get(pig) != 42) fail("Expected the extension property and the direct get() call to observe the same value")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testBlockEntityAttachmentRoundTrip()
{
val pos = BlockPos(5, 2, 1)
setBlock(pos, Blocks.CHEST)
val chest = getBlockEntity<ChestBlockEntity>(pos)
val attachment = DataAttachmentTestFixtures.counter

if (attachment.has(chest)) fail("Expected a freshly-placed block entity to have no explicitly-set value")
attachment.set(chest, 7)
if (attachment.get(chest) != 7) fail("Expected get() to return the value just set() on a block entity")
if (!attachment.has(chest)) fail("Expected has() to be true after set() on a block entity")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testItemComponentAttachmentRoundTripsOnItemStack()
{
val stack = ItemStack(Items.STONE)
val attachment = DataAttachmentTestFixtures.label

if (attachment.get(stack) != "") fail("Expected a fresh ItemStack to read back the default value")
if (attachment.has(stack)) fail("Expected a fresh ItemStack to have no explicitly-set value")

attachment.set(stack, "hello")
if (attachment.get(stack) != "hello") fail("Expected get() to return the value just set() on an ItemStack")
if (!attachment.has(stack)) fail("Expected has() to be true after set() on an ItemStack")

attachment.remove(stack)
if (attachment.get(stack) != "") fail("Expected get() to revert to the default after remove()")
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testUnexposedItemComponentAttachmentThrowsOnItemStack()
{
// `counter` was declared without itemComponent = true, so it has no DataComponentType to
// read/write - ItemStack still passes CSL's DataComponentHolder check either way, so this
// fails with NullPointerException (a null componentType()), not IllegalArgumentException.
val stack = ItemStack(Items.STONE)
try {
DataAttachmentTestFixtures.counter.get(stack)
fail("Expected NullPointerException reading an ItemStack through an attachment with no itemComponent")
} catch (_: NullPointerException) {
// expected
}
succeed()
}

@GameTest(template = EMPTY)
fun GameTestHelper.testUnsupportedHolderThrowsIllegalArgumentException()
{
// A plain BlockPos is neither an attachment holder nor a DataComponentHolder.
try {
DataAttachmentTestFixtures.counter.get(BlockPos(0, 0, 0))
fail("Expected IllegalArgumentException reading an unsupported holder type")
} catch (_: IllegalArgumentException) {
// expected
}
succeed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import net.kernelpanicsoft.archie.data.ADataGeneratorPlatform
import net.kernelpanicsoft.archie.events.AEvents
import net.kernelpanicsoft.archie.gametest.AGameTestPlatform
import net.kernelpanicsoft.archie.test.gametest.ArchieTestGameTest
import net.kernelpanicsoft.archie.test.gametest.DataAttachmentTestFixtures
import net.kernelpanicsoft.archie.test.gametest.CapabilityLookupTestFixtures
import net.kernelpanicsoft.archie.test.data.ArchieTestDatagen
import net.kernelpanicsoft.archie.util.onClient
Expand Down Expand Up @@ -39,6 +40,8 @@ object ArchieTest
ItemRegistry.init()
TileRegistry.init()
GuiRegistry.init()
if (AGameTestPlatform.isGameTest)
DataAttachmentTestFixtures.init()
// Must come after TileRegistry.init() (needs TestTile to actually exist), but is otherwise
// still normal mod-init timing - see CapabilityLookupTestFixtures's KDoc for why this can't
// be deferred to inside the @GameTest methods themselves.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.kernelpanicsoft.archie.serialization

import earth.terrarium.common_storage_lib.data.DataManager

/**
* Thin wrapper around a single Common Storage Lib [DataManager], backing [AttachmentRegistry.attachment].
* See [ArchieDataAttachment] for the full contract this implements.
*/
internal class ArchieDataAttachmentImpl<T>(private val manager: DataManager<T>) : ArchieDataAttachment<T>
{
override fun get(holder: Any): T = manager.get(holder)
override fun getOrThrow(holder: Any): T = manager.getOrThrow(holder)
override fun getOrCreate(holder: Any, default: T): T = manager.getOrCreate(holder, default)

override fun set(holder: Any, value: T): T
{
manager.set(holder, value)
return value
}

override fun remove(holder: Any): T = manager.remove(holder)
override fun has(holder: Any): Boolean = manager.has(holder)
override fun modify(holder: Any, block: (T) -> T): T = manager.modify(holder, block)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package net.kernelpanicsoft.archie.serialization

import earth.terrarium.common_storage_lib.data.DataManagerRegistry
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.serializer
import net.kernelpanicsoft.archie.config.toSnakeCase
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty

/**
* Base class for declaring a mod's [ArchieDataAttachment]s, wrapping a single Common Storage Lib
* `DataManagerRegistry`. Declare one `object` per mod extending this class, declare attachments
* as delegated properties on it via [attachment] (or one of the primitive convenience wrappers),
* then call [init] once at mod-init time - after those property initializers have already run,
* same ordering as [net.kernelpanicsoft.archie.networking.NetworkChannel]/`Config.init()`.
*
* ```kotlin
* object MyAttachments : AttachmentRegistry(MyMod.MOD_ID) {
* val mana by intAttachment(sync = true, default = { 0 })
* }
*
* // mod init:
* MyAttachments.init()
* ```
*/
abstract class AttachmentRegistry(modId: String)
{
@PublishedApi
internal val registry = DataManagerRegistry(modId)

/**
* Declares an [ArchieDataAttachment] backed by [serializer], keyed by the delegated property's
* snake_case name. See [ArchieDataAttachment] for the full contract, including exactly what
* [sync] and [itemComponent] each do and don't cover.
*
* @param sync Reactively push updates to tracking players on every [ArchieDataAttachment.set]/
* [ArchieDataAttachment.remove] - Entity/BlockEntity on both loaders, ServerLevel on NeoForge
* only.
* @param copyOnDeath Preserve the value across a player's death/respawn. Entity/BlockEntity
* holders only; meaningless (and untested by Archie) for `itemComponent`-only attachments.
* @param itemComponent Additionally back this attachment with a vanilla `DataComponentType`,
* making it usable on `ItemStack` holders too.
* @param default Supplies the value used before a holder has anything explicitly set.
*/
fun <T : Any> attachment(
serializer: KSerializer<T>,
sync: Boolean = false,
copyOnDeath: Boolean = false,
itemComponent: Boolean = false,
default: () -> T,
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, ArchieDataAttachment<T>>> = PropertyDelegateProvider { _, property ->
val builder = registry.builder(default).serialize(serializer.codec)
// itemComponent needs a client codec regardless of `sync`: CSL's own builder unconditionally
// calls `.networkSynchronized(clientCodec)` when building the DataComponentType, and leaves
// clientCodec null unless networkSerializer(...) was called - passing null there breaks at
// registration time. Always supplying our own explicit StreamCodec here (rather than CSL's
// no-arg networkSerializer(), which derives one from the Codec instead) keeps this consistent
// with the rest of Archie's serialization, which encodes over the network via kotlinx CBOR
// directly rather than round-tripping through a Codec.
if (sync || itemComponent) builder.networkSerializer(serializer.streamCodec)
if (copyOnDeath) builder.copyOnDeath()
if (itemComponent) builder.withDataComponent()
// ArchieDataAttachment itself implements ReadWriteProperty (so it can *also* back a `var
// Holder.x by MyAttachments.x` extension property once resolved) - if `attachment(...)`
// returned it directly as the PropertyDelegateProvider's own delegate type, `by attachment(...)`
// here would unwrap straight through to ArchieDataAttachment's getValue() and bind `mana`'s
// type to T, not to ArchieDataAttachment<T> itself. Wrapping it in a plain ReadOnlyProperty
// stops that second unwrap, the same way NBTHolderImpl.itemField/fluidField/energyField wrap
// their storage objects for the exact same reason.
val attachment = ArchieDataAttachmentImpl(builder.buildAndRegister(property.name.toSnakeCase()))
ReadOnlyProperty { _, _ -> attachment }
}

fun booleanAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Boolean = { false }) =
attachment(Boolean.serializer(), sync, copyOnDeath, itemComponent, default)
fun byteAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Byte = { 0 }) =
attachment(Byte.serializer(), sync, copyOnDeath, itemComponent, default)
fun shortAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Short = { 0 }) =
attachment(Short.serializer(), sync, copyOnDeath, itemComponent, default)
fun intAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Int = { 0 }) =
attachment(Int.serializer(), sync, copyOnDeath, itemComponent, default)
fun longAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Long = { 0 }) =
attachment(Long.serializer(), sync, copyOnDeath, itemComponent, default)
fun floatAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Float = { 0.0f }) =
attachment(Float.serializer(), sync, copyOnDeath, itemComponent, default)
fun doubleAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> Double = { 0.0 }) =
attachment(Double.serializer(), sync, copyOnDeath, itemComponent, default)
fun stringAttachment(sync: Boolean = false, copyOnDeath: Boolean = false, itemComponent: Boolean = false, default: () -> String = { "" }) =
attachment(String.serializer(), sync, copyOnDeath, itemComponent, default)

/** Registers every attachment declared through this registry against the mod event bus. Call once, at mod-init time, after all of this object's `by attachment(...)` properties have already run. */
fun init() = registry.init()
}

/** Reified variant of [AttachmentRegistry.attachment] that resolves the [KSerializer] for [T] automatically. */
inline fun <reified T : Any> AttachmentRegistry.attachment(
sync: Boolean = false,
copyOnDeath: Boolean = false,
itemComponent: Boolean = false,
noinline default: () -> T,
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, ArchieDataAttachment<T>>> = attachment(serializer<T>(), sync, copyOnDeath, itemComponent, default)
Loading
Loading