Skip to content

Commit

Permalink
Data Attachment Sync API (#4049)
Browse files Browse the repository at this point in the history
Completes the data attachment API with client-server syncing capabilities.

## Motivation

The existing API works great for attaching data to game objects, be they serverside or clientside, but lacks any ability to synchronize between the two.

A mod that wants to add a "thirst" mechanic can easily do so on the server side by attaching an integer to every player. However, the mod may also want to use this information to render additional HUD elements on the client. Currently, informing the client of this data can only be done manually, which is cumbersome, error-prone, and is much better-suited as an API feature.

## API Changes

The API doesn't change a lot (at least compared to the implementation), with a few new methods and one new class.

One new method has been added to `AttachmentRegistry.Builder`, namely `syncWith`. It declares that an attachment type may be synchronized with some clients, and takes a `PacketCodec` to encode attachment data over the network, as well as an element of the new `AttachmentSyncPredicate` interface.

This interface extends `BiPredicate<AttachmentTarget, ServerPlayerEntity>` to allow for user-defined predicates, and provides some common presets:
* `all()`: attachment data will be synchronized with all clients (that track the target).
* `targetOnly()`: attachment data will only be synchronized with the target it is attached to, when it is a player. If the target is not a player, it won't be synchronized with any client.
* `allButTarget()`: reverse of the above. For non-player targets, attachment data will be synchronized with all clients.

**NOTE**: for a user-defined condition, whether attachment data is synchronized with a client can change at runtime (e.g. "sync only with operators" when a player changes operator status). A specialized method to "refresh" data was considered, but in the end discarded due to complexity. It falls to individual mods to handle these edge cases.

AttachmentType also gets one new `isSynced` method to know whether a given attachment type can be synced.

## Usage

Here is how one could register an attachment for a "thirst" meter like mentioned above.
```java
public static final AttachmentType<Integer> THIRST = AttachmentRegistry.<Integer>builder()
    .initializer(() -> 20) // start with a default value like hunger
    .persistent(Codec.INT) // persist across restarts
    .syncWith(PacketCodecs.VAR_INT.cast(), AttachmentSyncPredicate.targetOnly()) // only the player's own client needs the value for rendering
    .buildAndRegister(Identifier.of("modid", "thirst"));
```
  • Loading branch information
Syst3ms authored and modmuss50 committed Nov 12, 2024
1 parent a9aef6a commit e9d2cfc
Show file tree
Hide file tree
Showing 39 changed files with 1,798 additions and 62 deletions.
7 changes: 5 additions & 2 deletions fabric-data-attachment-api-v1/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ version = getSubprojectVersion(project)
moduleDependencies(project, [
'fabric-api-base',
':fabric-entity-events-v1',
':fabric-object-builder-api-v1'
':fabric-object-builder-api-v1',
':fabric-networking-api-v1'
])

testDependencies(project, [
':fabric-lifecycle-events-v1',
':fabric-biome-api-v1'
':fabric-biome-api-v1',
':fabric-command-api-v2',
':fabric-rendering-v1'
])
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2016, 2017, 2018, 2019 FabricMC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package net.fabricmc.fabric.impl.attachment.client;

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.impl.attachment.sync.AttachmentSync;
import net.fabricmc.fabric.impl.attachment.sync.s2c.AttachmentSyncPayloadS2C;
import net.fabricmc.fabric.impl.attachment.sync.s2c.RequestAcceptedAttachmentsPayloadS2C;

public class AttachmentSyncClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
// config
ClientConfigurationNetworking.registerGlobalReceiver(
RequestAcceptedAttachmentsPayloadS2C.ID,
(payload, context) -> context.responseSender().sendPacket(AttachmentSync.createResponsePayload())
);

// play
ClientPlayNetworking.registerGlobalReceiver(
AttachmentSyncPayloadS2C.ID,
(payload, context) -> payload.attachments().forEach(attachmentChange -> attachmentChange.apply(context.client().world))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
"required": true,
"package": "net.fabricmc.fabric.mixin.attachment.client",
"compatibilityLevel": "JAVA_21",
"mixins": [
],
"injectors": {
"defaultRequire": 1
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import com.mojang.serialization.Codec;
import org.jetbrains.annotations.ApiStatus;

import net.minecraft.network.PacketByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.util.Identifier;

import net.fabricmc.fabric.impl.attachment.AttachmentRegistryImpl;
Expand All @@ -45,9 +47,10 @@ private AttachmentRegistry() {
}

/**
* Creates <i>and registers</i> an attachment, configuring the builder used underneath.
* Creates <i>and registers</i> an attachment using a {@linkplain Builder builder}.
*
* @param id the identifier of this attachment
* @param consumer a lambda that configures a {@link Builder} for this attachment type
* @param <A> the type of attached data
* @return the registered {@link AttachmentType} instance
*/
Expand All @@ -60,7 +63,7 @@ public static <A> AttachmentType<A> create(Identifier id, Consumer<Builder<A>> c
}

/**
* Creates <i>and registers</i> an attachment. The data will not be persisted.
* Creates <i>and registers</i> an attachment. The data will not be persisted or synchronized.
*
* @param id the identifier of this attachment
* @param <A> the type of attached data
Expand Down Expand Up @@ -124,7 +127,7 @@ public interface Builder<A> {
Builder<A> persistent(Codec<A> codec);

/**
* Declares that when a player dies and respawns, the attachments corresponding of this type should remain.
* Declares that when a player dies and respawns, the attachments of this type should remain.
*
* @return the builder
*/
Expand All @@ -138,7 +141,7 @@ public interface Builder<A> {
* <p>It is <i>encouraged</i> for {@link A} to be an immutable data type, such as a primitive type
* or an immutable record.</p>
*
* <p>Otherwise, one must be very careful, as attachments <i>must not share any mutable state</i>.
* <p>Otherwise, it is important to ensure that attachments <i>do not share any mutable state</i>.
* As an example, for a (mutable) list/array attachment type,
* the initializer should create a new independent instance each time it is called.</p>
*
Expand All @@ -147,6 +150,15 @@ public interface Builder<A> {
*/
Builder<A> initializer(Supplier<A> initializer);

/**
* Declares that this attachment type may be automatically synchronized with some clients, as determined by {@code syncPredicate}.
*
* @param packetCodec the codec used to serialize the attachment data over the network
* @param syncPredicate an {@link AttachmentSyncPredicate} determining with which clients to synchronize data
* @return the builder
*/
AttachmentRegistry.Builder<A> syncWith(PacketCodec<PacketByteBuf, A> packetCodec, AttachmentSyncPredicate syncPredicate);

/**
* Builds and registers the {@link AttachmentType}.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2016, 2017, 2018, 2019 FabricMC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package net.fabricmc.fabric.api.attachment.v1;

import java.util.function.BiPredicate;

import org.jetbrains.annotations.ApiStatus;

import net.minecraft.server.network.ServerPlayerEntity;

/**
* A predicate that determines, for a specific attachment type, whether the data should be synchronized with a
* player's client, given the player's {@link ServerPlayerEntity} and the {@linkplain AttachmentTarget} the data is linked to.
*
* <p>The class extends {@link BiPredicate} to allow for custom predicates, outside the ones provided by methods.</p>
*/
@ApiStatus.NonExtendable
@FunctionalInterface
public interface AttachmentSyncPredicate extends BiPredicate<AttachmentTarget, ServerPlayerEntity> {
/**
* @return a predicate that syncs an attachment with all clients
*/
static AttachmentSyncPredicate all() {
return (t, p) -> true;
}

/**
* @return a predicate that syncs an attachment only with the target it is attached to, when that is a player. If the
* target isn't a player, the attachment will be synced with no clients.
*/
static AttachmentSyncPredicate targetOnly() {
return (target, player) -> target == player;
}

/**
* @return a predicate that syncs an attachment with every client except the target it is attached to, when that is a player.
* When the target isn't a player, the attachment will be synced with all clients.
*/
static AttachmentSyncPredicate allButTarget() {
return (target, player) -> target != player;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.jetbrains.annotations.Nullable;

import net.minecraft.entity.Entity;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.Identifier;
import net.minecraft.world.chunk.Chunk;
Expand All @@ -36,7 +37,7 @@
/**
* An attachment allows "attaching" arbitrary data to various game objects (entities, block entities, worlds and chunks at the moment).
* Use the methods provided in {@link AttachmentRegistry} to create and register attachments. Attachments can
* optionally be made to persist between restarts using a provided {@link Codec}.
* optionally be made to persist between restarts using a provided {@link Codec}, and to synchronize with player clients.
*
* <p>While the API places no restrictions on the types of data that can be attached, it is generally encouraged to use
* immutable types. More generally, different attachments <i>must not</i> share mutable state, and it is <i>strongly advised</i>
Expand All @@ -53,6 +54,9 @@
* </p>
*
* @param <A> type of the attached data. It is encouraged for this to be an immutable type.
* @see AttachmentRegistry
* @see AttachmentRegistry.Builder#persistent(Codec)
* @see AttachmentRegistry.Builder#syncWith(PacketCodec, AttachmentSyncPredicate)
*/
@ApiStatus.NonExtendable
@ApiStatus.Experimental
Expand Down Expand Up @@ -93,6 +97,15 @@ default boolean isPersistent() {
@Nullable
Supplier<A> initializer();

/**
* Whether this attachment type can be synchronized with clients. This method returning {@code true} does not in any way
* indicate that the attachment type will synchronize data with any given client, only that it is able to, as per its
* {@link AttachmentSyncPredicate}.
*
* @return whether this attachment type is synced
*/
boolean isSynced();

/**
* @return whether the attachments should persist after an entity dies, for example when a player respawns or
* when a mob is converted (e.g. zombie → drowned)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,48 @@

package net.fabricmc.fabric.impl.attachment;

import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;

import com.mojang.serialization.Codec;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import net.minecraft.network.PacketByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.util.Identifier;

import net.fabricmc.fabric.api.attachment.v1.AttachmentRegistry;
import net.fabricmc.fabric.api.attachment.v1.AttachmentSyncPredicate;
import net.fabricmc.fabric.api.attachment.v1.AttachmentType;
import net.fabricmc.fabric.impl.attachment.sync.AttachmentSync;

public final class AttachmentRegistryImpl {
private static final Logger LOGGER = LoggerFactory.getLogger("fabric-data-attachment-api-v1");
private static final Map<Identifier, AttachmentType<?>> attachmentRegistry = new HashMap<>();
private static final Set<Identifier> syncableAttachments = new HashSet<>();
private static final Set<Identifier> syncableView = Collections.unmodifiableSet(syncableAttachments);

public static <A> void register(Identifier id, AttachmentType<A> attachmentType) {
AttachmentType<?> existing = attachmentRegistry.put(id, attachmentType);

if (existing != null) {
LOGGER.warn("Encountered duplicate type registration for id {}", id);

// Prevent duplicate registration from incorrectly overriding a synced type with a non-synced one or vice-versa
if (existing.isSynced() && !attachmentType.isSynced()) {
syncableAttachments.remove(id);
} else if (!existing.isSynced() && attachmentType.isSynced()) {
syncableAttachments.add(id);
}
} else if (attachmentType.isSynced()) {
syncableAttachments.add(id);
}
}

Expand All @@ -48,6 +66,10 @@ public static AttachmentType<?> get(Identifier id) {
return attachmentRegistry.get(id);
}

public static Set<Identifier> getSyncableAttachments() {
return syncableView;
}

public static <A> AttachmentRegistry.Builder<A> builder() {
return new BuilderImpl<>();
}
Expand All @@ -57,6 +79,10 @@ public static class BuilderImpl<A> implements AttachmentRegistry.Builder<A> {
private Supplier<A> defaultInitializer = null;
@Nullable
private Codec<A> persistenceCodec = null;
@Nullable
private PacketCodec<PacketByteBuf, A> packetCodec = null;
@Nullable
private AttachmentSyncPredicate syncPredicate = null;
private boolean copyOnDeath = false;

@Override
Expand All @@ -81,11 +107,36 @@ public AttachmentRegistry.Builder<A> initializer(Supplier<A> initializer) {
return this;
}

public AttachmentRegistry.Builder<A> syncWith(PacketCodec<PacketByteBuf, A> packetCodec, AttachmentSyncPredicate syncPredicate) {
Objects.requireNonNull(packetCodec, "packet codec cannot be null");
Objects.requireNonNull(syncPredicate, "sync predicate cannot be null");

this.packetCodec = packetCodec;
this.syncPredicate = syncPredicate;
return this;
}

@Override
public AttachmentType<A> buildAndRegister(Identifier id) {
Objects.requireNonNull(id, "identifier cannot be null");

var attachment = new AttachmentTypeImpl<>(id, defaultInitializer, persistenceCodec, copyOnDeath);
if (syncPredicate != null && id.toString().length() > AttachmentSync.MAX_IDENTIFIER_SIZE) {
throw new IllegalArgumentException(
"Identifier length is too long for a synced attachment type (was %d, maximum is %d)".formatted(
id.toString().length(),
AttachmentSync.MAX_IDENTIFIER_SIZE
)
);
}

var attachment = new AttachmentTypeImpl<>(
id,
defaultInitializer,
persistenceCodec,
packetCodec,
syncPredicate,
copyOnDeath
);
register(id, attachment);
return attachment;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public static void serializeAttachmentData(NbtCompound nbt, RegistryWrapper.Wrap
RegistryOps<NbtElement> registryOps = wrapperLookup.getOps(NbtOps.INSTANCE);
codec.encodeStart(registryOps, entry.getValue())
.ifError(partial -> {
LOGGER.warn("Couldn't serialize attachment " + type.identifier() + ", skipping. Error:");
LOGGER.warn("Couldn't serialize attachment {}, skipping. Error:", type.identifier());
LOGGER.warn(partial.message());
})
.ifSuccess(serialized -> compound.put(type.identifier().toString(), serialized));
Expand All @@ -73,7 +73,7 @@ public static IdentityHashMap<AttachmentType<?>, Object> deserializeAttachmentDa
AttachmentType<?> type = AttachmentRegistryImpl.get(Identifier.of(key));

if (type == null) {
LOGGER.warn("Unknown attachment type " + key + " found when deserializing, skipping");
LOGGER.warn("Unknown attachment type {} found when deserializing, skipping", key);
continue;
}

Expand All @@ -83,7 +83,7 @@ public static IdentityHashMap<AttachmentType<?>, Object> deserializeAttachmentDa
RegistryOps<NbtElement> registryOps = wrapperLookup.getOps(NbtOps.INSTANCE);
codec.parse(registryOps, compound.get(key))
.ifError(partial -> {
LOGGER.warn("Couldn't deserialize attachment " + type.identifier() + ", skipping. Error:");
LOGGER.warn("Couldn't deserialize attachment {}, skipping. Error:", type.identifier());
LOGGER.warn(partial.message());
})
.ifSuccess(
Expand Down
Loading

0 comments on commit e9d2cfc

Please sign in to comment.