package com.xfestudio.xfeservermanager.core.player;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Online-only inventory editor with slot revisions and compare-and-swap persistence. */
public final class PlayerInventoryService {
private final InventoryPort port;
private final Set<String> allowedStructuredKeys;
private final java.util.function.BooleanSupplier rawEditingEnabled;
public PlayerInventoryService(
InventoryPort port, Set<String> allowedStructuredKeys, boolean rawEditingEnabled) {
this(port, allowedStructuredKeys, () -> rawEditingEnabled);
}
public PlayerInventoryService(
InventoryPort port, Set<String> allowedStructuredKeys,
java.util.function.BooleanSupplier rawEditingEnabled) {
this.port = Objects.requireNonNull(port, "port");
this.allowedStructuredKeys = Set.copyOf(allowedStructuredKeys);
this.rawEditingEnabled = Objects.requireNonNull(rawEditingEnabled, "rawEditingEnabled");
}
public InventoryUpdateResult update(
UUID playerId,
ContainerKind container,
InventoryChangeRequest request,
boolean owner) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(container, "container");
Objects.requireNonNull(request, "request");
if (request.reason().isBlank() || request.reason().length() > 1_000) {
throw new PlayerOperationException("reason_required", "a concise operation reason is required");
}
InventorySnapshot current = port.read(playerId, container);
if (!current.online()) {
throw new PlayerOperationException("player_offline", "only online inventories may be edited");
}
if (current.revision() != request.expectedRevision()) {
throw new PlayerOperationException(
"inventory_revision_conflict", "inventory changed after it was read");
}
Map<Integer, SlotState> next = new LinkedHashMap<>(current.slots());
List<SlotDiff> diffs = new ArrayList<>();
for (Map.Entry<Integer, SlotState> update : request.updates().entrySet()) {
int slot = update.getKey();
SlotState after = update.getValue();
if (slot != after.slot()) {
throw new PlayerOperationException("slot_mismatch", "slot map key and value disagree");
}
validateSlot(after, request.rawEditingRequested(), owner);
SlotState before = next.put(slot, after);
if (!Objects.equals(before, after)) {
diffs.add(new SlotDiff(slot, before, after));
}
}
if (diffs.isEmpty()) {
return new InventoryUpdateResult(current.revision(), List.of());
}
CompareAndSetResult result = port.compareAndSet(
playerId, container, current.revision(), Map.copyOf(next));
if (result == CompareAndSetResult.OFFLINE) {
throw new PlayerOperationException("player_offline", "player disconnected during inventory update");
}
if (result == CompareAndSetResult.REVISION_MISMATCH) {
throw new PlayerOperationException(
"inventory_revision_conflict", "inventory changed during update");
}
return new InventoryUpdateResult(current.revision() + 1, diffs);
}
private void validateSlot(SlotState state, boolean rawRequested, boolean owner) {
if (!allowedStructuredKeys.containsAll(state.structuredData().keySet())) {
Set<String> unknown = new java.util.LinkedHashSet<>(state.structuredData().keySet());
unknown.removeAll(allowedStructuredKeys);
throw new PlayerOperationException(
"unsafe_item_component", "item contains unsupported structured keys " + unknown);
}
boolean hasRaw = state.rawData().length != 0;
if (hasRaw && (!rawRequested || !rawEditingEnabled.getAsBoolean() || !owner)) {
throw new PlayerOperationException(
"raw_item_editing_disabled", "raw NBT/data-component editing is owner-only and disabled by default");
}
}
public interface InventoryPort {
/** Must obtain the snapshot on the Minecraft server thread. */
InventorySnapshot read(UUID playerId, ContainerKind container);
/** Must run atomically on the Minecraft server thread. */
CompareAndSetResult compareAndSet(
UUID playerId,
ContainerKind container,
long expectedRevision,
Map<Integer, SlotState> replacement);
}
public enum ContainerKind {
INVENTORY,
ENDER_CHEST
}
public enum CompareAndSetResult {
APPLIED,
REVISION_MISMATCH,
OFFLINE
}
public record InventorySnapshot(
UUID playerId,
ContainerKind container,
long revision,
boolean online,
Map<Integer, SlotState> slots) {
public InventorySnapshot {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(container, "container");
if (revision < 0) throw new IllegalArgumentException("revision must be non-negative");
slots = Map.copyOf(slots);
}
}
public record SlotState(
int slot,
String itemId,
int count,
Map<String, String> structuredData,
byte[] rawData) {
public SlotState {
if (slot < 0 || slot > 255) throw new IllegalArgumentException("slot must be between 0 and 255");
if (itemId == null || itemId.isBlank()) throw new IllegalArgumentException("itemId is required");
itemId = itemId.contains(":")
? itemId.toLowerCase(java.util.Locale.ROOT)
: "minecraft:" + itemId.toLowerCase(java.util.Locale.ROOT);
if (count < 0 || count > 127) throw new IllegalArgumentException("count must be between 0 and 127");
structuredData = Map.copyOf(structuredData);
rawData = rawData == null ? new byte[0] : rawData.clone();
}
@Override
public byte[] rawData() {
return rawData.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof SlotState that)) return false;
return slot == that.slot
&& count == that.count
&& itemId.equals(that.itemId)
&& structuredData.equals(that.structuredData)
&& java.util.Arrays.equals(rawData, that.rawData);
}
@Override
public int hashCode() {
int result = Objects.hash(slot, itemId, count, structuredData);
return 31 * result + java.util.Arrays.hashCode(rawData);
}
}
public record InventoryChangeRequest(
long expectedRevision,
Map<Integer, SlotState> updates,
String reason,
boolean rawEditingRequested) {
public InventoryChangeRequest {
if (expectedRevision < 0) throw new IllegalArgumentException("expectedRevision must be non-negative");
updates = Map.copyOf(updates);
if (updates.isEmpty()) throw new IllegalArgumentException("at least one slot update is required");
reason = reason == null ? "" : reason.strip();
}
}
public record SlotDiff(int slot, SlotState before, SlotState after) { }
public record InventoryUpdateResult(long revision, List<SlotDiff> diffs) {
public InventoryUpdateResult {
if (revision < 0) throw new IllegalArgumentException("revision must be non-negative");
diffs = List.copyOf(diffs);
}
}
}
package com.xfestudio.xfeservermanager.core.player;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Online-only inventory editor with slot revisions and compare-and-swap persistence. */
public final class PlayerInventoryService {
private final InventoryPort port;
private final Set<String> allowedStructuredKeys;
private final java.util.function.BooleanSupplier rawEditingEnabled;
public PlayerInventoryService(
InventoryPort port, Set<String> allowedStructuredKeys, boolean rawEditingEnabled) {
this(port, allowedStructuredKeys, () -> rawEditingEnabled);
}
public PlayerInventoryService(
InventoryPort port, Set<String> allowedStructuredKeys,
java.util.function.BooleanSupplier rawEditingEnabled) {
this.port = Objects.requireNonNull(port, "port");
this.allowedStructuredKeys = Set.copyOf(allowedStructuredKeys);
this.rawEditingEnabled = Objects.requireNonNull(rawEditingEnabled, "rawEditingEnabled");
}
public InventoryUpdateResult update(
UUID playerId,
ContainerKind container,
InventoryChangeRequest request,
boolean owner) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(container, "container");
Objects.requireNonNull(request, "request");
if (request.reason().isBlank() || request.reason().length() > 1_000) {
throw new PlayerOperationException("reason_required", "a concise operation reason is required");
}
InventorySnapshot current = port.read(playerId, container);
if (!current.online()) {
throw new PlayerOperationException("player_offline", "only online inventories may be edited");
}
if (current.revision() != request.expectedRevision()) {
throw new PlayerOperationException(
"inventory_revision_conflict", "inventory changed after it was read");
}
Map<Integer, SlotState> next = new LinkedHashMap<>(current.slots());
List<SlotDiff> diffs = new ArrayList<>();
for (Map.Entry<Integer, SlotState> update : request.updates().entrySet()) {
int slot = update.getKey();
SlotState after = update.getValue();
if (slot != after.slot()) {
throw new PlayerOperationException("slot_mismatch", "slot map key and value disagree");
}
validateSlot(after, request.rawEditingRequested(), owner);
SlotState before = next.put(slot, after);
if (!Objects.equals(before, after)) {
diffs.add(new SlotDiff(slot, before, after));
}
}
if (diffs.isEmpty()) {
return new InventoryUpdateResult(current.revision(), List.of());
}
CompareAndSetResult result = port.compareAndSet(
playerId, container, current.revision(), Map.copyOf(next));
if (result == CompareAndSetResult.OFFLINE) {
throw new PlayerOperationException("player_offline", "player disconnected during inventory update");
}
if (result == CompareAndSetResult.REVISION_MISMATCH) {
throw new PlayerOperationException(
"inventory_revision_conflict", "inventory changed during update");
}
return new InventoryUpdateResult(current.revision() + 1, diffs);
}
private void validateSlot(SlotState state, boolean rawRequested, boolean owner) {
if (!allowedStructuredKeys.containsAll(state.structuredData().keySet())) {
Set<String> unknown = new java.util.LinkedHashSet<>(state.structuredData().keySet());
unknown.removeAll(allowedStructuredKeys);
throw new PlayerOperationException(
"unsafe_item_component", "item contains unsupported structured keys " + unknown);
}
boolean hasRaw = state.rawData().length != 0;
if (hasRaw && (!rawRequested || !rawEditingEnabled.getAsBoolean() || !owner)) {
throw new PlayerOperationException(
"raw_item_editing_disabled", "raw NBT/data-component editing is owner-only and disabled by default");
}
}
public interface InventoryPort {
/** Must obtain the snapshot on the Minecraft server thread. */
InventorySnapshot read(UUID playerId, ContainerKind container);
/** Must run atomically on the Minecraft server thread. */
CompareAndSetResult compareAndSet(
UUID playerId,
ContainerKind container,
long expectedRevision,
Map<Integer, SlotState> replacement);
}
public enum ContainerKind {
INVENTORY,
ENDER_CHEST
}
public enum CompareAndSetResult {
APPLIED,
REVISION_MISMATCH,
OFFLINE
}
public record InventorySnapshot(
UUID playerId,
ContainerKind container,
long revision,
boolean online,
Map<Integer, SlotState> slots) {
public InventorySnapshot {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(container, "container");
if (revision < 0) throw new IllegalArgumentException("revision must be non-negative");
slots = Map.copyOf(slots);
}
}
public record SlotState(
int slot,
String itemId,
int count,
Map<String, String> structuredData,
byte[] rawData) {
public SlotState {
if (slot < 0 || slot > 255) throw new IllegalArgumentException("slot must be between 0 and 255");
if (itemId == null || itemId.isBlank()) throw new IllegalArgumentException("itemId is required");
itemId = itemId.contains(":")
? itemId.toLowerCase(java.util.Locale.ROOT)
: "minecraft:" + itemId.toLowerCase(java.util.Locale.ROOT);
if (count < 0 || count > 127) throw new IllegalArgumentException("count must be between 0 and 127");
structuredData = Map.copyOf(structuredData);
rawData = rawData == null ? new byte[0] : rawData.clone();
}
@Override
public byte[] rawData() {
return rawData.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof SlotState that)) return false;
return slot == that.slot
&& count == that.count
&& itemId.equals(that.itemId)
&& structuredData.equals(that.structuredData)
&& java.util.Arrays.equals(rawData, that.rawData);
}
@Override
public int hashCode() {
int result = Objects.hash(slot, itemId, count, structuredData);
return 31 * result + java.util.Arrays.hashCode(rawData);
}
}
public record InventoryChangeRequest(
long expectedRevision,
Map<Integer, SlotState> updates,
String reason,
boolean rawEditingRequested) {
public InventoryChangeRequest {
if (expectedRevision < 0) throw new IllegalArgumentException("expectedRevision must be non-negative");
updates = Map.copyOf(updates);
if (updates.isEmpty()) throw new IllegalArgumentException("at least one slot update is required");
reason = reason == null ? "" : reason.strip();
}
}
public record SlotDiff(int slot, SlotState before, SlotState after) { }
public record InventoryUpdateResult(long revision, List<SlotDiff> diffs) {
public InventoryUpdateResult {
if (revision < 0) throw new IllegalArgumentException("revision must be non-negative");
diffs = List.copyOf(diffs);
}
}
}