diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt index ea5e65a74..91614773c 100644 --- a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt @@ -10,6 +10,7 @@ internal fun AEvents.ArchieGameTestBuilder.archieTestGameTests() register() } server { + register() register() } } diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTestFixtures.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTestFixtures.kt new file mode 100644 index 000000000..5be8f3ea4 --- /dev/null +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTestFixtures.kt @@ -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 diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTests.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTests.kt new file mode 100644 index 000000000..7a4d77cd5 --- /dev/null +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/DataAttachmentTests.kt @@ -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(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() + } +} diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTest.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTest.kt index 029315c1b..5d691e294 100644 --- a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTest.kt +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTest.kt @@ -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 @@ -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. diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ArchieDataAttachmentImpl.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ArchieDataAttachmentImpl.kt new file mode 100644 index 000000000..4696d61a2 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ArchieDataAttachmentImpl.kt @@ -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(private val manager: DataManager) : ArchieDataAttachment +{ + 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) +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/AttachmentRegistry.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/AttachmentRegistry.kt new file mode 100644 index 000000000..47f4dc59c --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/AttachmentRegistry.kt @@ -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 attachment( + serializer: KSerializer, + sync: Boolean = false, + copyOnDeath: Boolean = false, + itemComponent: Boolean = false, + default: () -> T, + ): PropertyDelegateProvider>> = 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 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 AttachmentRegistry.attachment( + sync: Boolean = false, + copyOnDeath: Boolean = false, + itemComponent: Boolean = false, + noinline default: () -> T, +): PropertyDelegateProvider>> = attachment(serializer(), sync, copyOnDeath, itemComponent, default) diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/DataAttachment.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/DataAttachment.kt new file mode 100644 index 000000000..c9fb30ca1 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/DataAttachment.kt @@ -0,0 +1,106 @@ +package net.kernelpanicsoft.archie.serialization + +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * A [DataManager][earth.terrarium.common_storage_lib.data.DataManager]-backed attachment. Unlike + * [NBTHolder], which owns its own per-instance field storage, a single [ArchieDataAttachment] + * instance is stateless and reusable as the delegate for a `var Holder.property by ...` + * extension property on *any* number of holder instances - the holder passed to each method + * (or, via [getValue]/[setValue], the property's receiver) is where the data actually lives. + * Obtain instances via [AttachmentRegistry.attachment]. + * + * ### Usage + * ```kotlin + * object MyAttachments : AttachmentRegistry(MyMod.MOD_ID) { + * val mana by intAttachment(sync = true, default = { 0 }) + * } + * var Entity.mana by MyAttachments.mana + * + * // in mod init, after MyAttachments' properties above have already run: + * MyAttachments.init() + * ``` + * + * ### Supported holder types + * What's supported depends on the platform and on whether the attachment was declared with + * `itemComponent = true`: + * - **Entity / BlockEntity**: a NeoForge attachment / Fabric `AttachmentTarget`, on both platforms. + * - **ServerLevel**: NeoForge only. Fabric's `updateTarget` dispatch has no `Level`/`ServerLevel` + * case at all, so even where the underlying `get`/`set`/`has` calls happen to succeed there, + * `sync = true` will silently never push an update. Don't rely on world-level attachments if + * you need Fabric parity. + * - **ItemStack**: only if declared with `itemComponent = true` (backed by a vanilla + * [net.minecraft.core.component.DataComponentType] instead of an attachment). Calling any + * method here against an `ItemStack` for an attachment that *wasn't* declared with + * `itemComponent = true` throws [NullPointerException] (its backing `DataComponentType` is + * null) rather than [IllegalArgumentException] - `ItemStack` still passes CSL's holder-kind + * check either way, it just has nowhere to actually read/write. + * + * Any other object type throws [IllegalArgumentException] from every method here except + * [getValue]/[setValue], which forward straight into [get]/[set]. + * + * ### `sync` vs. `itemComponent` + * These are two genuinely different mechanisms, not two flavors of one thing: + * - Entity/BlockEntity/ServerLevel sync (`sync = true` on [AttachmentRegistry.attachment]) is a + * reactive push straight out of [set]/[remove] to tracking players, driven by CSL's own + * `DataManagerImpl`/packets. + * - `itemComponent` attachments are **not** covered by that push at all - [set] on an `ItemStack` + * never sends anything itself. They ride vanilla's normal item/component replication instead + * (the same mechanism as vanilla's own `BundleContents`), which isn't reactive the same way. + * + * Declaring `itemComponent = true` without `sync = true` still needs a network codec under the + * hood (vanilla's `DataComponentType` always carries one) - [AttachmentRegistry.attachment] + * handles that for you regardless of what you pass for `sync`. + * + * ### `has()` after `get()` on Entity/BlockEntity/ServerLevel holders + * Confirmed on real Fabric/NeoForge attachment internals, not documented by Common Storage Lib + * itself: [get] on these holder kinds silently creates *and persists* the default value on first + * read (Fabric's `AttachmentTarget.getAttachedOrCreate`, NeoForge's `AttachmentHolder.getData` - + * both write-through on a miss, they don't just compute-and-discard). That means [has] can only + * tell "never touched" apart from "read once" if you call it *before* the first [get] - calling + * [get] first, then [has], will report `true` even though nothing was ever explicitly [set]. + * `ItemStack`/`itemComponent` holders don't have this quirk - `DataComponentHolder.getOrDefault` + * genuinely doesn't persist on read. + */ +interface ArchieDataAttachment : ReadWriteProperty +{ + /** + * Reads [holder]'s current value, falling back to the attachment's default if unset. Never + * throws for an unset value - only for an unsupported [holder]. + * + * On Entity/BlockEntity/ServerLevel holders, an unset read silently creates *and persists* the + * default - see the class-level "`has()` after `get()`" note before relying on [has] afterward. + */ + fun get(holder: Any): T + + /** Reads [holder]'s current value, throwing if it's never been explicitly [set]. The exact exception type (`NullPointerException` vs. `RuntimeException`) differs by platform for `ItemStack` holders - don't match on a specific type for that case. */ + fun getOrThrow(holder: Any): T + + /** Reads [holder]'s current value if [has] is true, otherwise [set]s it to [default] first. Returns the (possibly just-written) current value either way. */ + fun getOrCreate(holder: Any, default: T): T + + /** Writes [value] onto [holder], returning [value]. */ + fun set(holder: Any, value: T): T + + /** + * Removes [holder]'s explicitly-set value, if any, reverting subsequent [get] calls to the + * default. The return value mirrors the removed data 1:1 from the underlying Java API and can + * be a JVM-level null if nothing was set - prefer checking [has] first if you actually need it. + */ + fun remove(holder: Any): T + + /** True if [holder] has an explicitly-[set] value (as opposed to just reading back the default). */ + fun has(holder: Any): Boolean + + /** Reads [holder]'s current value, applies [block], writes the result back, and returns it. */ + fun modify(holder: Any, block: (T) -> T): T + + override operator fun getValue(thisRef: Any?, property: KProperty<*>): T = + get(thisRef ?: throw IllegalStateException("${property.name} has no receiver to read a data attachment from - it can't be a top-level property")) + + override operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) + { + set(thisRef ?: throw IllegalStateException("${property.name} has no receiver to write a data attachment to - it can't be a top-level property"), value) + } +} diff --git a/Archie/docs/index.md b/Archie/docs/index.md index afdc6a6ee..cb3707fdb 100644 --- a/Archie/docs/index.md +++ b/Archie/docs/index.md @@ -12,7 +12,7 @@ It targets both **Fabric** and **NeoForge** via [Architectury](https://github.co |---|---| | **Networking** | Strongly-typed, CBOR-serialized packet channels | | **Registries** | Deferred-register helpers for blocks, items, and creative tabs | -| **Serialization** | Kotlinx serialization + Mojang Codec bridge; NBT holders; Minecraft type serializers | +| **Serialization** | Kotlinx serialization + Mojang Codec bridge; NBT holders; data attachments; Minecraft type serializers | | **Config** | Hierarchical, multi-format config system (JSON5, TOML, JSON) with Cloth Config UI and optional client↔server sync | | **GUI** | Compose-for-Minecraft UI framework with layout, modifiers, composables, and themes | | **Events** | Architectury event wrappers | diff --git a/Archie/docs/serialization.md b/Archie/docs/serialization.md index 53d49e53b..4e63eace9 100644 --- a/Archie/docs/serialization.md +++ b/Archie/docs/serialization.md @@ -169,3 +169,44 @@ Only `@Sync`-annotated fields are included in `NBTHolder.getSyncTag()`. `NBTBloc this to build the tag sent to tracking clients, so annotate exactly the fields a block entity needs on the client (e.g. for rendering or GUI display) — everything else stays server-only and is only persisted via the normal save/load tag. + +--- + +## Data attachments (`AttachmentRegistry`) + +Where `NBTHolder` is per-instance storage you own (a field on your own `BlockEntity`/`ItemStack` +wrapper), `AttachmentRegistry` wraps Common Storage Lib's `DataManager` to attach data to holders +you *don't* own the class of — `Entity`, `BlockEntity`, `ItemStack`, and (NeoForge only) +`ServerLevel` - via a stateless, reusable `ArchieDataAttachment` object rather than a delegate +that owns its own storage: + +```kotlin +object MyAttachments : AttachmentRegistry(MyMod.MOD_ID) { + val mana by intAttachment(sync = true, default = { 0 }) + val label by stringAttachment(itemComponent = true, default = { "" }) +} + +var Entity.mana by MyAttachments.mana + +// mod init, after MyAttachments' properties above have already run: +MyAttachments.init() +``` + +`attachment(serializer, sync, copyOnDeath, itemComponent, default)` (plus a reified variant and +per-primitive-type wrappers - `booleanAttachment`, `intAttachment`, `stringAttachment`, etc. - +mirroring `NBTHolder`'s field helpers) declares one attachment, keyed by the delegated property's +snake_case name. `sync` and `itemComponent` are two genuinely different mechanisms: `sync` is a +reactive push to tracking players on every write (Entity/BlockEntity on both loaders, ServerLevel +NeoForge-only - Fabric silently never syncs world attachments even if a get/set happens to +succeed there), while `itemComponent` backs the attachment with a vanilla `DataComponentType` +instead, riding normal item/component replication rather than a reactive push. + +Once declared, an attachment is used two ways - directly via `ArchieDataAttachment`'s +`get`/`set`/`has`/`remove`/`modify` methods (any holder, e.g. `MyAttachments.mana.get(entity)`), +or as the delegate for an extension property on a specific holder type, as `mana` is above. Watch +out for one real platform quirk: on Entity/BlockEntity/ServerLevel holders, `get()` on an unset +value silently creates *and persists* the default (both Fabric's `getAttachedOrCreate` and +NeoForge's `getData` write through on a miss) - so `has()` can only tell "never touched" apart +from "read once" if you call it *before* the first `get()`. `ItemStack`/`itemComponent` holders +don't have this quirk. See `ArchieDataAttachment`'s KDoc for the full holder-support matrix and +exception behavior for unsupported holder types.