diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CapacityProfile.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CapacityProfile.java new file mode 100644 index 00000000..03d4dd95 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CapacityProfile.java @@ -0,0 +1,143 @@ +package org.cyclops.colossalchests2.storage; + +/** + * Determines how many items fit in a single slot of a {@link ChestStorage}. + * Depth is uniform across all slots. + * Profiles are built with a {@link Builder}: the structure sets the base depth, + * and each installed upgrade applies its own modifiers, so the profile knows nothing about upgrade types. + * @param depth Stacks per slot for stackable items. + * @param maxItemsPerSlot Technical cap per slot. + * @param acceptNonStackables If items with a max stack size of 1 are accepted at all. + * @param nonStackableCapacity Items per slot for non-stackable items, independent of depth. + * @author rubensworks + */ +public record CapacityProfile(long depth, long maxItemsPerSlot, boolean acceptNonStackables, long nonStackableCapacity) { + + public static final long DEFAULT_MAX_ITEMS_PER_SLOT = Integer.MAX_VALUE; + + public CapacityProfile { + if (depth < 0 || maxItemsPerSlot < 0 || nonStackableCapacity < 0) { + throw new IllegalArgumentException("Invalid capacity profile"); + } + } + + /** + * @param depth Stacks per slot. + * @return A profile without modifiers and with default limits. + */ + public static CapacityProfile ofDepth(long depth) { + return builder(depth).build(); + } + + /** + * @param baseDepth Stacks per slot from the structure size. + * @return A builder to apply modifiers on. + */ + public static Builder builder(long baseDepth) { + return new Builder(baseDepth); + } + + public CapacityProfile withDepth(long depth) { + return new CapacityProfile(depth, maxItemsPerSlot, acceptNonStackables, nonStackableCapacity); + } + + public CapacityProfile withMaxItemsPerSlot(long maxItemsPerSlot) { + return new CapacityProfile(depth, maxItemsPerSlot, acceptNonStackables, nonStackableCapacity); + } + + public CapacityProfile withAcceptNonStackables(boolean acceptNonStackables) { + return new CapacityProfile(depth, maxItemsPerSlot, acceptNonStackables, nonStackableCapacity); + } + + public CapacityProfile withNonStackableCapacity(long nonStackableCapacity) { + return new CapacityProfile(depth, maxItemsPerSlot, acceptNonStackables, nonStackableCapacity); + } + + /** + * @param maxStackSize The vanilla max stack size of an item type. + * @return Capacity per slot for that item type. + */ + public long capacityFor(int maxStackSize) { + if (maxStackSize <= 1) { + return acceptNonStackables ? Math.min(nonStackableCapacity, maxItemsPerSlot) : 0; + } + return Math.min(saturatedMultiply(depth, maxStackSize), maxItemsPerSlot); + } + + static long saturatedMultiply(long a, long b) { + long high = Math.multiplyHigh(a, b); + long low = a * b; + if ((high == 0 && low >= 0) || (high == -1 && low < 0)) { + return low; + } + return (a < 0) == (b < 0) ? Long.MAX_VALUE : Long.MIN_VALUE; + } + + static long saturatedAdd(long a, long b) { + long sum = a + b; + if (((a ^ sum) & (b ^ sum)) < 0) { + return a < 0 ? Long.MIN_VALUE : Long.MAX_VALUE; + } + return sum; + } + + /** + * Collects modifiers from the structure and installed upgrades. + * Non-stackable capacity is (product of multipliers) * (1 + sum of factors), so for example + * Bundling multiplies by 2 per level and Depth adds 1 per upgrade. + * All math saturates instead of overflowing. + */ + public static final class Builder { + + private long depth; + private long nonStackableMultiplier = 1; + private long nonStackableFactor = 1; + private long maxItemsPerSlot = DEFAULT_MAX_ITEMS_PER_SLOT; + private boolean acceptNonStackables = true; + + private Builder(long baseDepth) { + if (baseDepth < 0) { + throw new IllegalArgumentException("Negative depth: " + baseDepth); + } + this.depth = baseDepth; + } + + public Builder multiplyDepth(long factor) { + this.depth = saturatedMultiply(depth, requirePositive(factor)); + return this; + } + + public Builder multiplyNonStackable(long factor) { + this.nonStackableMultiplier = saturatedMultiply(nonStackableMultiplier, requirePositive(factor)); + return this; + } + + public Builder addNonStackableFactor(long amount) { + this.nonStackableFactor = saturatedAdd(nonStackableFactor, requirePositive(amount)); + return this; + } + + public Builder maxItemsPerSlot(long maxItemsPerSlot) { + this.maxItemsPerSlot = maxItemsPerSlot; + return this; + } + + public Builder acceptNonStackables(boolean acceptNonStackables) { + this.acceptNonStackables = acceptNonStackables; + return this; + } + + public CapacityProfile build() { + return new CapacityProfile(depth, maxItemsPerSlot, acceptNonStackables, + saturatedMultiply(nonStackableMultiplier, nonStackableFactor)); + } + + private static long requirePositive(long value) { + if (value < 1) { + throw new IllegalArgumentException("Modifiers must be positive: " + value); + } + return value; + } + } + +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ChestStorage.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ChestStorage.java new file mode 100644 index 00000000..d606518e --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ChestStorage.java @@ -0,0 +1,469 @@ +package org.cyclops.colossalchests2.storage; + +import com.google.common.collect.Lists; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; +import java.util.Optional; +import java.util.function.IntConsumer; + +/** + * Deep-slot storage engine: a fixed number of slots, each holding one item type with a long count. + * Capacity is determined by a {@link CapacityProfile}. + * Slots holding more than their capacity are extract-only. + * This ensures that lowering capacity never deletes items. + * @author rubensworks + */ +public class ChestStorage { + + public static final Codec CODEC = Contents.CODEC; + + private DeepSlot[] slots; + private CapacityProfile profile; + private final BitSet dirty = new BitSet(); + private final List listeners = Lists.newArrayList(); + private int state; + + public ChestStorage(int slotCount, CapacityProfile profile) { + if (slotCount < 0) { + throw new IllegalArgumentException("Negative slot count: " + slotCount); + } + this.slots = new DeepSlot[slotCount]; + Arrays.fill(this.slots, DeepSlot.EMPTY); + this.profile = profile; + } + + public int getSlotCount() { + return slots.length; + } + + public CapacityProfile getProfile() { + return profile; + } + + public DeepSlot getSlot(int slot) { + return slots[slot]; + } + + /** + * @return A value that changes whenever the contents or capacities change. + */ + public int getState() { + return state; + } + + /** + * @param type An item type. + * @return The capacity of any slot for the given type. + */ + public long getCapacity(ItemStack type) { + return profile.capacityFor(type.getMaxStackSize()); + } + + /** + * @param slot A slot index. + * @return The capacity of the slot for its current type, or for a full-stack type if empty. + */ + public long getCapacity(int slot) { + DeepSlot deepSlot = slots[slot]; + return deepSlot.isEmpty() ? profile.capacityFor(Item.DEFAULT_MAX_STACK_SIZE) : getCapacity(deepSlot.getPrototype()); + } + + /** + * @param slot A slot index. + * @return If the slot holds more than its capacity, so it only allows extraction. + */ + public boolean isExtractOnly(int slot) { + DeepSlot deepSlot = slots[slot]; + return !deepSlot.isEmpty() && deepSlot.getCount() > getCapacity(deepSlot.getPrototype()); + } + + /** + * @param slot A slot index. + * @param type An item type. + * @return If the type may go into the slot, ignoring how full it is. + */ + public boolean canAccept(int slot, ItemStack type) { + if (type.isEmpty() || getCapacity(type) <= 0) { + return false; + } + DeepSlot deepSlot = slots[slot]; + return deepSlot.isEmpty() || deepSlot.matches(type); + } + + /** + * @param type An item type. + * @return If an existing slot holds this type or a free slot could take it. + */ + public boolean canFit(ItemStack type) { + return insert(type, 1, true) == 1; + } + + /** + * Insert into a specific slot. + * @param slot A slot index. + * @param type The item type, its count is ignored. + * @param amount The amount to insert. + * @param simulate If the storage must not change. + * @return The amount that was (or would be) inserted. + */ + public long insert(int slot, ItemStack type, long amount, boolean simulate) { + if (amount <= 0 || !canAccept(slot, type)) { + return 0; + } + DeepSlot deepSlot = slots[slot]; + long space = Math.max(0, getCapacity(type) - deepSlot.getCount()); + long inserted = Math.min(amount, space); + if (!simulate && inserted > 0) { + setSlot(slot, deepSlot.isEmpty() ? DeepSlot.of(type, inserted) : deepSlot.withCount(deepSlot.getCount() + inserted)); + } + return inserted; + } + + /** + * Insert anywhere: first into slots of the same type, then into free unlocked slots. + * @param type The item type, its count is ignored. + * @param amount The amount to insert. + * @param simulate If the storage must not change. + * @return The amount that was (or would be) inserted. + */ + public long insert(ItemStack type, long amount, boolean simulate) { + long remaining = amount; + for (int slot = 0; slot < slots.length && remaining > 0; slot++) { + if (slots[slot].matches(type)) { + remaining -= insert(slot, type, remaining, simulate); + } + } + for (int slot = 0; slot < slots.length && remaining > 0; slot++) { + if (slots[slot].isEmpty()) { + remaining -= insert(slot, type, remaining, simulate); + } + } + return amount - remaining; + } + + /** + * Extract from a specific slot, also from extract-only slots. + * @param slot A slot index. + * @param amount The maximum amount to extract. + * @param simulate If the storage must not change. + * @return The amount that was (or would be) extracted. + */ + public long extract(int slot, long amount, boolean simulate) { + DeepSlot deepSlot = slots[slot]; + long extracted = Math.min(Math.max(0, amount), deepSlot.getCount()); + if (!simulate && extracted > 0) { + setSlot(slot, deepSlot.withCount(deepSlot.getCount() - extracted)); + } + return extracted; + } + + /** + * Extract a type from any slot holding it. + * @param type The item type. + * @param amount The maximum amount to extract. + * @param simulate If the storage must not change. + * @return The amount that was (or would be) extracted. + */ + public long extract(ItemStack type, long amount, boolean simulate) { + long remaining = amount; + for (int slot = 0; slot < slots.length && remaining > 0; slot++) { + if (slots[slot].matches(type)) { + remaining -= extract(slot, remaining, simulate); + } + } + return amount - remaining; + } + + /** + * Lock or unlock a slot. Empty slots without a type cannot be locked. + * @param slot A slot index. + * @param locked The new lock state. + * @return If the slot now has the requested lock state. + */ + public boolean setLocked(int slot, boolean locked) { + DeepSlot deepSlot = slots[slot]; + if (deepSlot.isEmpty()) { + return !locked; + } + if (deepSlot.isLocked() != locked) { + setSlot(slot, deepSlot.withLocked(locked)); + } + return true; + } + + /** + * Reserve an empty slot for a type at zero count, or lock a slot already holding that type. + * @param slot A slot index. + * @param type The item type to reserve. + * @return If the slot was locked to the type. + */ + public boolean lockTo(int slot, ItemStack type) { + DeepSlot deepSlot = slots[slot]; + if (type.isEmpty()) { + return false; + } + if (deepSlot.matches(type)) { + return setLocked(slot, true); + } + if (deepSlot.getCount() > 0) { + return false; + } + setSlot(slot, DeepSlot.of(type, 0, true, null)); + return true; + } + + /** + * Lock all slots that hold items. + * @return The number of newly locked slots. + */ + public int lockAllFilled() { + int count = 0; + for (int slot = 0; slot < slots.length; slot++) { + if (!slots[slot].isEmpty() && !slots[slot].isLocked()) { + setLocked(slot, true); + count++; + } + } + return count; + } + + /** + * Unlock all slots. Contents are untouched, empty reserved slots become free. + */ + public void clearLocks() { + for (int slot = 0; slot < slots.length; slot++) { + if (slots[slot].isLocked()) { + setLocked(slot, false); + } + } + } + + /** + * @param slot A slot index. + * @param form The item of the compression form to extract in, or null for the default. + */ + public void setCompressionForm(int slot, @Nullable Item form) { + DeepSlot deepSlot = slots[slot]; + if (!deepSlot.isEmpty() && !deepSlot.getCompressionForm().equals(Optional.ofNullable(form))) { + setSlot(slot, deepSlot.withCompressionForm(form)); + } + } + + /** + * @param slotCount A new slot count. + * @return Filled slots that would be dropped. + */ + public ResizeResult validateSlotCount(int slotCount) { + List offending = Lists.newArrayList(); + for (int slot = Math.max(0, slotCount); slot < slots.length; slot++) { + if (slots[slot].getCount() > 0) { + offending.add(slot); + } + } + return ResizeResult.of(offending); + } + + /** + * Change the slot count if no filled slot would be dropped. + * @param slotCount A new slot count. + * @return The validation result, the storage is unchanged if not ok. + */ + public ResizeResult setSlotCount(int slotCount) { + ResizeResult result = validateSlotCount(slotCount); + if (result.isOk() && slotCount != slots.length) { + int oldCount = slots.length; + slots = Arrays.copyOf(slots, slotCount); + for (int slot = oldCount; slot < slotCount; slot++) { + slots[slot] = DeepSlot.EMPTY; + } + dirty.clear(slotCount, Math.max(slotCount, oldCount)); + changed(-1); + } + return result; + } + + /** + * @param newProfile A new capacity profile. + * @return Slots whose contents would exceed their new capacity. + */ + public ResizeResult validateProfile(CapacityProfile newProfile) { + List offending = Lists.newArrayList(); + for (int slot = 0; slot < slots.length; slot++) { + DeepSlot deepSlot = slots[slot]; + if (!deepSlot.isEmpty() && deepSlot.getCount() > newProfile.capacityFor(deepSlot.getPrototype().getMaxStackSize())) { + offending.add(slot); + } + } + return ResizeResult.of(offending); + } + + /** + * Change the capacity profile if all contents still fit. + * @param newProfile A new capacity profile. + * @return The validation result, the storage is unchanged if not ok. + */ + public ResizeResult setProfile(CapacityProfile newProfile) { + ResizeResult result = validateProfile(newProfile); + if (result.isOk()) { + forceProfile(newProfile); + } + return result; + } + + /** + * Change the capacity profile unconditionally, used when the structure re-forms smaller or + * config values were lowered. Over-capacity slots become extract-only. + * @param newProfile A new capacity profile. + * @return The slots that are now extract-only. + */ + public ResizeResult forceProfile(CapacityProfile newProfile) { + ResizeResult result = validateProfile(newProfile); + if (!newProfile.equals(this.profile)) { + this.profile = newProfile; + changed(-1); + } + return result; + } + + /** + * @param listener Called with a slot index after a slot changed, or -1 after the layout or capacity changed. + */ + public void addListener(IntConsumer listener) { + listeners.add(listener); + } + + public void removeListener(IntConsumer listener) { + listeners.remove(listener); + } + + /** + * @return If slots changed since the last {@link #drainDirtySlots()}. + */ + public boolean hasDirtySlots() { + return !dirty.isEmpty(); + } + + /** + * @return The slots that changed since the last call, in ascending order. + */ + public int[] drainDirtySlots() { + int[] result = dirty.stream().toArray(); + dirty.clear(); + return result; + } + + /** + * Mark all slots dirty, for a full resync. + */ + public void markAllDirty() { + dirty.set(0, slots.length); + } + + protected void setSlot(int slot, DeepSlot deepSlot) { + if (!slots[slot].equals(deepSlot)) { + slots[slot] = deepSlot; + dirty.set(slot); + changed(slot); + } + } + + protected void changed(int slot) { + state++; + for (IntConsumer listener : listeners) { + listener.accept(slot); + } + } + + /** + * @return A sparse snapshot of the stored contents, for serialization. + */ + public Contents toContents() { + List entries = Lists.newArrayList(); + for (int slot = 0; slot < slots.length; slot++) { + DeepSlot deepSlot = slots[slot]; + if (!deepSlot.isEmpty()) { + entries.add(new Contents.Entry(slot, deepSlot.getPrototype(), deepSlot.getCount(), + deepSlot.isLocked(), deepSlot.getCompressionForm())); + } + } + return new Contents(slots.length, entries); + } + + /** + * Replace all contents. The slot count grows if entries lie beyond it, so nothing is ever dropped. + * @param contents A snapshot. + */ + public void loadContents(Contents contents) { + int slotCount = contents.slotCount(); + for (Contents.Entry entry : contents.entries()) { + slotCount = Math.max(slotCount, entry.slot() + 1); + } + slots = new DeepSlot[slotCount]; + Arrays.fill(slots, DeepSlot.EMPTY); + for (Contents.Entry entry : contents.entries()) { + slots[entry.slot()] = DeepSlot.of(entry.item(), entry.count(), entry.locked(), entry.form().orElse(null)); + } + dirty.clear(); + markAllDirty(); + changed(-1); + } + + /** + * Sparse serialized form: only filled or reserved slots are written. + * Entries that cannot be decoded, for example because their item's mod was removed, are skipped + * instead of failing the whole storage. + * @param slotCount The slot count. + * @param entries The non-empty slots. + */ + public record Contents(int slotCount, List entries) { + + private static final Codec> CODEC_ENTRIES = new Codec<>() { + @Override + public DataResult, T>> decode(DynamicOps ops, T input) { + return ops.getList(input).map(elements -> { + List entries = Lists.newArrayList(); + elements.accept(element -> Entry.CODEC.parse(ops, element).ifSuccess(entries::add)); + return Pair.of(entries, input); + }); + } + + @Override + public DataResult encode(List input, DynamicOps ops, T prefix) { + return Entry.CODEC.listOf().encode(input, ops, prefix); + } + }; + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.intRange(0, Integer.MAX_VALUE).fieldOf("slot_count").forGetter(Contents::slotCount), + CODEC_ENTRIES.optionalFieldOf("slots", List.of()).forGetter(Contents::entries) + ).apply(i, Contents::new)); + + public record Entry(int slot, ItemStack item, long count, boolean locked, Optional form) { + // Unknown form items decode as no form, which falls back to the default form. + private static final Codec> CODEC_FORM = ResourceLocation.CODEC.xmap( + BuiltInRegistries.ITEM::getOptional, + form -> BuiltInRegistries.ITEM.getKey(form.orElseThrow())); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.intRange(0, Integer.MAX_VALUE).fieldOf("slot").forGetter(Entry::slot), + ItemStack.SINGLE_ITEM_CODEC.fieldOf("item").forGetter(Entry::item), + Codec.LONG.fieldOf("count").forGetter(Entry::count), + Codec.BOOL.optionalFieldOf("locked", false).forGetter(Entry::locked), + CODEC_FORM.optionalFieldOf("form", Optional.empty()).forGetter(Entry::form) + ).apply(i, Entry::new)); + } + } + +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamilies.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamilies.java new file mode 100644 index 00000000..3d7c5f57 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamilies.java @@ -0,0 +1,60 @@ +package org.cyclops.colossalchests2.storage; + +import com.google.common.collect.Maps; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Lookup of {@link CompressionFamily}s by item. + * @author rubensworks + */ +public class CompressionFamilies { + + private final Map byItem = Maps.newIdentityHashMap(); + private final Set families = new LinkedHashSet<>(); + + /** + * Register a family. An item can only belong to one family. + * @param family A family. + */ + public void register(CompressionFamily family) { + for (CompressionFamily.Form form : family.forms()) { + if (byItem.containsKey(form.item())) { + throw new IllegalArgumentException("Item " + form.item() + " is already in a compression family"); + } + } + for (CompressionFamily.Form form : family.forms()) { + byItem.put(form.item(), family); + } + families.add(family); + } + + public Optional find(Item item) { + return Optional.ofNullable(byItem.get(item)); + } + + /** + * @param stack An item stack. + * @return The family of a plain stack. + */ + public Optional find(ItemStack stack) { + return stack.isEmpty() || !stack.getComponentsPatch().isEmpty() ? Optional.empty() : find(stack.getItem()); + } + + public Collection getFamilies() { + return Collections.unmodifiableSet(families); + } + + public void clear() { + byItem.clear(); + families.clear(); + } + +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamily.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamily.java new file mode 100644 index 00000000..eeb4abda --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/CompressionFamily.java @@ -0,0 +1,158 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +/** + * An ordered set of item forms that convert into each other, such as block, ingot and nugget. + * Counts are converted through base units: the amount of the smallest form one item represents. + * @param forms The forms, from largest to smallest. The smallest form has 1 base unit. + * @author rubensworks + */ +public record CompressionFamily(List
forms) { + + public CompressionFamily { + forms = List.copyOf(forms); + if (forms.size() < 2) { + throw new IllegalArgumentException("A compression family needs at least two forms"); + } + if (forms.get(forms.size() - 1).baseUnits() != 1) { + throw new IllegalArgumentException("The smallest form must be worth 1 base unit"); + } + for (int i = 1; i < forms.size(); i++) { + Form larger = forms.get(i - 1); + Form smaller = forms.get(i); + if (larger.baseUnits() <= smaller.baseUnits() || larger.baseUnits() % smaller.baseUnits() != 0) { + throw new IllegalArgumentException("Each form must be a whole multiple of the next smaller form"); + } + for (int j = 0; j < i; j++) { + if (forms.get(j).item() == smaller.item()) { + throw new IllegalArgumentException("Duplicate form " + smaller.item()); + } + } + } + } + + /** + * Create a family from the largest form down, with the ratio between neighbouring forms. + * For example (block, 9, ingot, 9, nugget). + * @param largest The largest form. + * @param rest Alternating ratio (Integer) and next smaller form (Item). + * @return The family. + */ + public static CompressionFamily of(Item largest, Object... rest) { + if (rest.length % 2 != 0) { + throw new IllegalArgumentException("Expected alternating ratios and items"); + } + int n = rest.length / 2 + 1; + Item[] items = new Item[n]; + long[] ratios = new long[n - 1]; + items[0] = largest; + for (int i = 0; i < rest.length; i += 2) { + ratios[i / 2] = ((Number) rest[i]).longValue(); + items[i / 2 + 1] = (Item) rest[i + 1]; + } + Form[] forms = new Form[n]; + long units = 1; + for (int i = n - 1; i >= 0; i--) { + forms[i] = new Form(items[i], units); + if (i > 0) { + units = CapacityProfile.saturatedMultiply(units, ratios[i - 1]); + } + } + return new CompressionFamily(List.of(forms)); + } + + public int size() { + return forms.size(); + } + + public Form largest() { + return forms.get(0); + } + + public Form smallest() { + return forms.get(forms.size() - 1); + } + + public Form get(int form) { + return forms.get(form); + } + + /** + * @param item An item. + * @return The form index of the item, or -1. + */ + public int indexOf(Item item) { + for (int i = 0; i < forms.size(); i++) { + if (forms.get(i).item() == item) { + return i; + } + } + return -1; + } + + /** + * Only plain stacks without extra components can be compressed. + * @param stack An item stack. + * @return The form index of the stack, or -1. + */ + public int indexOf(ItemStack stack) { + if (stack.isEmpty() || !stack.getComponentsPatch().isEmpty()) { + return -1; + } + return indexOf(stack.getItem()); + } + + /** + * @param form A form index. + * @param count An amount of that form. + * @return The amount in base units, saturated at Long.MAX_VALUE. + */ + public long toBaseUnits(int form, long count) { + return CapacityProfile.saturatedMultiply(count, forms.get(form).baseUnits()); + } + + /** + * @param form A form index. + * @param baseUnits An amount of base units. + * @return How many whole items of the form the base units make. + */ + public long fromBaseUnits(int form, long baseUnits) { + return baseUnits / forms.get(form).baseUnits(); + } + + /** + * @param form A form index. + * @param baseUnits An amount of base units. + * @return The base units left over after converting to whole items of the form. + */ + public long remainderBaseUnits(int form, long baseUnits) { + return baseUnits % forms.get(form).baseUnits(); + } + + /** + * Convert between two forms, rounding down. + * @param fromForm The source form index. + * @param count The amount in the source form. + * @param toForm The target form index. + * @return The whole amount in the target form. + */ + public long convert(int fromForm, long count, int toForm) { + return fromBaseUnits(toForm, toBaseUnits(fromForm, count)); + } + + /** + * @param item The item of the form. + * @param baseUnits How many of the smallest form one item of this form is worth. + */ + public record Form(Item item, long baseUnits) { + public Form { + if (baseUnits < 1) { + throw new IllegalArgumentException("Base units must be positive"); + } + } + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DeepSlot.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DeepSlot.java new file mode 100644 index 00000000..7466a5be --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DeepSlot.java @@ -0,0 +1,126 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.Optional; + +/** + * An immutable slot of a {@link ChestStorage}: one item type with a long count. + * A slot with a prototype and a zero count only exists while it is locked. + * @author rubensworks + */ +public final class DeepSlot { + + public static final DeepSlot EMPTY = new DeepSlot(ItemStack.EMPTY, 0, false, null); + + private final ItemStack prototype; + private final long count; + private final boolean locked; + @Nullable + private final Item compressionForm; + + private DeepSlot(ItemStack prototype, long count, boolean locked, @Nullable Item compressionForm) { + this.prototype = prototype; + this.count = count; + this.locked = locked; + this.compressionForm = compressionForm; + } + + /** + * Create a normalized slot. + * @param prototype The item type, its count is ignored. + * @param count The amount of items. + * @param locked If the slot is locked to its type. + * @param compressionForm The item of the compression form to extract in, or null (or air) for the default. + * @return The slot, {@link #EMPTY} if nothing is stored and nothing is reserved. + */ + public static DeepSlot of(ItemStack prototype, long count, boolean locked, @Nullable Item compressionForm) { + if (count < 0) { + throw new IllegalArgumentException("Negative count: " + count); + } + if (prototype.isEmpty() || (count == 0 && !locked)) { + return EMPTY; + } + return new DeepSlot(prototype.copyWithCount(1), count, locked, compressionForm == Items.AIR ? null : compressionForm); + } + + public static DeepSlot of(ItemStack prototype, long count) { + return of(prototype, count, false, null); + } + + /** + * @return The item type with count 1. Do not modify. + */ + public ItemStack getPrototype() { + return prototype; + } + + public long getCount() { + return count; + } + + public boolean isLocked() { + return locked; + } + + /** + * The form, as an item, that the player picked to extract this slot's contents in. + * Stored as an item rather than a position in the compression family, so it survives datapack changes to + * the family. If the item is no longer a form of the slot's family, the largest form should be used. + * @return The picked form, or empty for the default (the largest form). + */ + public Optional getCompressionForm() { + return Optional.ofNullable(compressionForm); + } + + /** + * @return If there is no item type in this slot. + */ + public boolean isEmpty() { + return prototype.isEmpty(); + } + + /** + * @param type An item type. + * @return If the given type is the type of this slot. + */ + public boolean matches(ItemStack type) { + return !isEmpty() && ItemStack.isSameItemSameComponents(prototype, type); + } + + public DeepSlot withCount(long count) { + return of(prototype, count, locked, compressionForm); + } + + public DeepSlot withLocked(boolean locked) { + return of(prototype, count, locked, compressionForm); + } + + public DeepSlot withCompressionForm(@Nullable Item compressionForm) { + return of(prototype, count, locked, compressionForm); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof DeepSlot that + && this.count == that.count + && this.locked == that.locked + && Objects.equals(this.compressionForm, that.compressionForm) + && ItemStack.isSameItemSameComponents(this.prototype, that.prototype); + } + + @Override + public int hashCode() { + return Objects.hash(ItemStack.hashItemAndComponents(prototype), count, locked, compressionForm); + } + + @Override + public String toString() { + return "DeepSlot{" + (isEmpty() ? "empty" : prototype.getItem() + " x" + count) + + (locked ? ", locked" : "") + (compressionForm != null ? ", form=" + compressionForm : "") + "}"; + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ResizeResult.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ResizeResult.java new file mode 100644 index 00000000..53fde8f8 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/ResizeResult.java @@ -0,0 +1,26 @@ +package org.cyclops.colossalchests2.storage; + +import java.util.List; + +/** + * The outcome of validating a resize of a {@link ChestStorage}. + * @param offendingSlots Slots whose contents would not fit, empty if the resize is allowed. + * @author rubensworks + */ +public record ResizeResult(List offendingSlots) { + + public static final ResizeResult OK = new ResizeResult(List.of()); + + public ResizeResult { + offendingSlots = List.copyOf(offendingSlots); + } + + public static ResizeResult of(List offendingSlots) { + return offendingSlots.isEmpty() ? OK : new ResizeResult(offendingSlots); + } + + public boolean isOk() { + return offendingSlots.isEmpty(); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/BootstrapTest.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/BootstrapTest.java new file mode 100644 index 00000000..eabc5c1e --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/BootstrapTest.java @@ -0,0 +1,19 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; + +/** + * Base class for tests that need vanilla registries. + * @author rubensworks + */ +public abstract class BootstrapTest { + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCapacityProfile.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCapacityProfile.java new file mode 100644 index 00000000..f1185b50 --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCapacityProfile.java @@ -0,0 +1,109 @@ +package org.cyclops.colossalchests2.storage; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * @author rubensworks + */ +public class TestCapacityProfile { + + /** + * Capacity rules for Depth and Bundling upgrades: Depth doubles depth and adds 1 to the + * non-stackable factor, each Bundling level doubles non-stackable capacity. + */ + static CapacityProfile.Builder upgraded(long baseDepth, int depthUpgrades, int bundlingLevel) { + CapacityProfile.Builder builder = CapacityProfile.builder(baseDepth); + for (int i = 0; i < depthUpgrades; i++) { + builder.multiplyDepth(2).addNonStackableFactor(1); + } + for (int i = 0; i < bundlingLevel; i++) { + builder.multiplyNonStackable(2); + } + return builder; + } + + @Test + public void testOfDepth() { + CapacityProfile profile = CapacityProfile.ofDepth(16); + assertEquals(new CapacityProfile(16, CapacityProfile.DEFAULT_MAX_ITEMS_PER_SLOT, true, 1), profile); + } + + @Test + public void testDepthModifiers() { + assertEquals(128, upgraded(16, 3, 0).build().depth()); + } + + @Test + public void testDepthSaturates() { + assertEquals(Long.MAX_VALUE, upgraded(Long.MAX_VALUE / 2, 4, 0).build().depth()); + } + + @Test + public void testCapacityStackable() { + CapacityProfile profile = CapacityProfile.ofDepth(16); + assertEquals(1024, profile.capacityFor(64)); + assertEquals(256, profile.capacityFor(16)); + } + + @Test + public void testCapacityClampedToMaxItemsPerSlot() { + assertEquals(Integer.MAX_VALUE, upgraded(262144, 7, 0).build().capacityFor(64)); + assertEquals(100, upgraded(262144, 7, 0).maxItemsPerSlot(100).build().capacityFor(64)); + } + + @Test + public void testCapacityNonStackable() { + assertEquals(1, upgraded(262144, 0, 0).build().capacityFor(1)); + assertEquals(2, upgraded(262144, 1, 0).build().capacityFor(1)); + assertEquals(16, upgraded(262144, 0, 4).build().capacityFor(1)); + assertEquals(112, upgraded(262144, 6, 4).build().capacityFor(1)); + assertEquals(0, upgraded(262144, 6, 4).acceptNonStackables(false).build().capacityFor(1)); + assertEquals(10, upgraded(262144, 0, 4).maxItemsPerSlot(10).build().capacityFor(1)); + } + + @Test + public void testNonStackableCapacityIgnoresBaseDepth() { + assertEquals(upgraded(4, 2, 1).build().nonStackableCapacity(), upgraded(65536, 2, 1).build().nonStackableCapacity()); + } + + @Test + public void testNonStackableSaturates() { + CapacityProfile.Builder builder = CapacityProfile.builder(1).addNonStackableFactor(Long.MAX_VALUE); + assertEquals(Long.MAX_VALUE, builder.multiplyNonStackable(4).build().nonStackableCapacity()); + } + + @Test + public void testWithers() { + CapacityProfile profile = CapacityProfile.ofDepth(4) + .withDepth(8).withNonStackableCapacity(3).withMaxItemsPerSlot(99).withAcceptNonStackables(false); + assertEquals(new CapacityProfile(8, 99, false, 3), profile); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalid() { + new CapacityProfile(-1, 1, true, 1); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidBaseDepth() { + CapacityProfile.builder(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidModifier() { + CapacityProfile.builder(1).multiplyDepth(0); + } + + @Test + public void testSaturatedMath() { + assertEquals(6, CapacityProfile.saturatedMultiply(2, 3)); + assertEquals(Long.MAX_VALUE, CapacityProfile.saturatedMultiply(Long.MAX_VALUE, 2)); + assertEquals(Long.MIN_VALUE, CapacityProfile.saturatedMultiply(Long.MAX_VALUE, -2)); + assertEquals(5, CapacityProfile.saturatedAdd(2, 3)); + assertEquals(Long.MAX_VALUE, CapacityProfile.saturatedAdd(Long.MAX_VALUE, 1)); + assertEquals(Long.MIN_VALUE, CapacityProfile.saturatedAdd(Long.MIN_VALUE, -1)); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestChestStorage.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestChestStorage.java new file mode 100644 index 00000000..2a86cadd --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestChestStorage.java @@ -0,0 +1,520 @@ +package org.cyclops.colossalchests2.storage; + +import com.google.common.collect.Lists; +import com.mojang.serialization.JsonOps; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * @author rubensworks + */ +public class TestChestStorage extends BootstrapTest { + + private static final ItemStack STONE = new ItemStack(Items.STONE); + private static final ItemStack DIRT = new ItemStack(Items.DIRT); + private static final ItemStack PEARL = new ItemStack(Items.ENDER_PEARL); + private static final ItemStack PICKAXE = new ItemStack(Items.IRON_PICKAXE); + + private ChestStorage storage; + private List changes; + + @Before + public void setUp() { + storage = new ChestStorage(3, CapacityProfile.ofDepth(4)); + changes = Lists.newArrayList(); + storage.addListener(changes::add); + } + + // Construction and accessors + + @Test + public void testConstruction() { + assertEquals(3, storage.getSlotCount()); + assertEquals(CapacityProfile.ofDepth(4), storage.getProfile()); + assertSame(DeepSlot.EMPTY, storage.getSlot(0)); + assertEquals(0, storage.getState()); + } + + @Test(expected = IllegalArgumentException.class) + public void testNegativeSlotCount() { + new ChestStorage(-1, CapacityProfile.ofDepth(4)); + } + + // Capacity + + @Test + public void testCapacity() { + assertEquals(256, storage.getCapacity(STONE)); + assertEquals(64, storage.getCapacity(PEARL)); + assertEquals(1, storage.getCapacity(PICKAXE)); + assertEquals(256, storage.getCapacity(0)); + storage.insert(1, PEARL, 1, false); + assertEquals(64, storage.getCapacity(1)); + } + + @Test + public void testNonStackableCapacityWithUpgrades() { + storage.forceProfile(TestCapacityProfile.upgraded(4, 1, 2).build()); + assertEquals(8, storage.insert(0, PICKAXE, 100, false)); + assertEquals(8, storage.getSlot(0).getCount()); + } + + @Test + public void testNonStackablesRejectedWhenDisabled() { + storage.forceProfile(storage.getProfile().withAcceptNonStackables(false)); + assertFalse(storage.canAccept(0, PICKAXE)); + assertEquals(0, storage.insert(PICKAXE, 1, false)); + } + + // Insert + + @Test + public void testInsertIntoEmpty() { + assertEquals(100, storage.insert(0, STONE, 100, false)); + assertTrue(storage.getSlot(0).matches(STONE)); + assertEquals(100, storage.getSlot(0).getCount()); + } + + @Test + public void testInsertIntoMatching() { + storage.insert(0, STONE, 100, false); + assertEquals(50, storage.insert(0, STONE, 50, false)); + assertEquals(150, storage.getSlot(0).getCount()); + } + + @Test + public void testInsertIntoFull() { + storage.insert(0, STONE, 256, false); + assertEquals(0, storage.insert(0, STONE, 1, false)); + } + + @Test + public void testInsertPartiallyFits() { + storage.insert(0, STONE, 200, false); + assertEquals(56, storage.insert(0, STONE, 100, false)); + assertEquals(256, storage.getSlot(0).getCount()); + } + + @Test + public void testInsertIntoMismatched() { + storage.insert(0, STONE, 1, false); + assertFalse(storage.canAccept(0, DIRT)); + assertEquals(0, storage.insert(0, DIRT, 10, false)); + } + + @Test + public void testInsertIntoLockedOtherType() { + storage.lockTo(0, STONE); + assertEquals(0, storage.insert(0, DIRT, 10, false)); + assertEquals(10, storage.insert(0, STONE, 10, false)); + } + + @Test + public void testInsertDistinctComponentsAreDistinctTypes() { + ItemStack named = STONE.copy(); + named.set(DataComponents.CUSTOM_NAME, Component.literal("Named")); + storage.insert(0, STONE, 1, false); + assertEquals(0, storage.insert(0, named, 1, false)); + assertEquals(1, storage.insert(named, 1, false)); + assertTrue(storage.getSlot(1).matches(named)); + } + + @Test + public void testInsertEmptyOrNonPositive() { + assertEquals(0, storage.insert(0, ItemStack.EMPTY, 1, false)); + assertEquals(0, storage.insert(0, STONE, 0, false)); + assertEquals(0, storage.insert(0, STONE, -5, false)); + } + + @Test + public void testInsertSimulate() { + assertEquals(100, storage.insert(0, STONE, 100, true)); + assertSame(DeepSlot.EMPTY, storage.getSlot(0)); + assertEquals(0, storage.getState()); + } + + @Test + public void testInsertAnywherePrefersMatching() { + storage.insert(2, STONE, 10, false); + assertEquals(20, storage.insert(STONE, 20, false)); + assertEquals(30, storage.getSlot(2).getCount()); + assertTrue(storage.getSlot(0).isEmpty()); + } + + @Test + public void testInsertAnywhereSpillsIntoFreeSlots() { + assertEquals(600, storage.insert(STONE, 600, false)); + assertEquals(256, storage.getSlot(0).getCount()); + assertEquals(256, storage.getSlot(1).getCount()); + assertEquals(88, storage.getSlot(2).getCount()); + } + + @Test + public void testInsertAnywhereSimulateMatchesExecute() { + storage.insert(1, DIRT, 1, false); + long simulated = storage.insert(STONE, 1000, true); + assertEquals(512, simulated); + assertEquals(simulated, storage.insert(STONE, 1000, false)); + } + + @Test + public void testInsertAnywhereSkipsLockedSlots() { + storage.lockTo(0, DIRT); + storage.insert(STONE, 1, false); + assertTrue(storage.getSlot(1).matches(STONE)); + } + + @Test + public void testCanFit() { + assertTrue(storage.canFit(STONE)); + storage.insert(STONE, 768, false); + assertFalse(storage.canFit(STONE)); + assertFalse(storage.canFit(DIRT)); + } + + // Extract + + @Test + public void testExtractPartial() { + storage.insert(0, STONE, 100, false); + assertEquals(30, storage.extract(0, 30, false)); + assertEquals(70, storage.getSlot(0).getCount()); + } + + @Test + public void testExtractFull() { + storage.insert(0, STONE, 100, false); + assertEquals(100, storage.extract(0, 100, false)); + assertSame(DeepSlot.EMPTY, storage.getSlot(0)); + } + + @Test + public void testExtractMoreThanPresent() { + storage.insert(0, STONE, 100, false); + assertEquals(100, storage.extract(0, 1000, false)); + assertEquals(0, storage.extract(0, 1, false)); + assertEquals(0, storage.extract(1, 1, false)); + assertEquals(0, storage.extract(0, -1, false)); + } + + @Test + public void testExtractSimulate() { + storage.insert(0, STONE, 100, false); + int state = storage.getState(); + assertEquals(40, storage.extract(0, 40, true)); + assertEquals(100, storage.getSlot(0).getCount()); + assertEquals(state, storage.getState()); + } + + @Test + public void testExtractLockedKeepsType() { + storage.insert(0, STONE, 10, false); + storage.setLocked(0, true); + storage.extract(0, 10, false); + assertTrue(storage.getSlot(0).matches(STONE)); + assertEquals(0, storage.getSlot(0).getCount()); + } + + @Test + public void testExtractFromExtractOnly() { + storage.insert(0, STONE, 256, false); + storage.forceProfile(CapacityProfile.ofDepth(2)); + assertTrue(storage.isExtractOnly(0)); + assertEquals(0, storage.insert(0, STONE, 1, false)); + assertEquals(100, storage.extract(0, 100, false)); + assertTrue(storage.isExtractOnly(0)); + assertEquals(28, storage.extract(0, 28, false)); + assertFalse(storage.isExtractOnly(0)); + assertEquals(0, storage.insert(0, STONE, 1, false)); + storage.extract(0, 1, false); + assertEquals(1, storage.insert(0, STONE, 1, false)); + } + + @Test + public void testExtractByType() { + storage.insert(0, STONE, 10, false); + storage.insert(1, DIRT, 10, false); + storage.insert(2, STONE, 10, false); + assertEquals(15, storage.extract(STONE, 15, false)); + assertTrue(storage.getSlot(0).isEmpty()); + assertEquals(5, storage.getSlot(2).getCount()); + assertEquals(10, storage.getSlot(1).getCount()); + } + + // Extract-only + + @Test + public void testIsExtractOnly() { + assertFalse(storage.isExtractOnly(0)); + storage.insert(0, STONE, 256, false); + assertFalse(storage.isExtractOnly(0)); + storage.forceProfile(CapacityProfile.ofDepth(1)); + assertTrue(storage.isExtractOnly(0)); + } + + // Locks + + @Test + public void testSetLocked() { + assertTrue(storage.setLocked(0, false)); + assertFalse(storage.setLocked(0, true)); + storage.insert(0, STONE, 1, false); + assertTrue(storage.setLocked(0, true)); + assertTrue(storage.getSlot(0).isLocked()); + assertTrue(storage.setLocked(0, false)); + assertFalse(storage.getSlot(0).isLocked()); + } + + @Test + public void testLockTo() { + assertTrue(storage.lockTo(0, STONE)); + assertTrue(storage.getSlot(0).isLocked()); + assertTrue(storage.getSlot(0).matches(STONE)); + assertTrue(storage.lockTo(0, DIRT)); + assertTrue(storage.getSlot(0).matches(DIRT)); + assertFalse(storage.lockTo(1, ItemStack.EMPTY)); + storage.insert(1, STONE, 5, false); + assertFalse(storage.lockTo(1, DIRT)); + assertTrue(storage.lockTo(1, STONE)); + assertEquals(5, storage.getSlot(1).getCount()); + } + + @Test + public void testLockAllFilledAndClearLocks() { + storage.insert(0, STONE, 5, false); + storage.insert(2, DIRT, 5, false); + assertEquals(2, storage.lockAllFilled()); + assertEquals(0, storage.lockAllFilled()); + assertTrue(storage.getSlot(0).isLocked()); + assertFalse(storage.getSlot(1).isLocked()); + storage.extract(2, 5, false); + storage.clearLocks(); + assertFalse(storage.getSlot(0).isLocked()); + assertSame(DeepSlot.EMPTY, storage.getSlot(2)); + } + + @Test + public void testSetCompressionForm() { + storage.setCompressionForm(0, Items.IRON_INGOT); + assertSame(DeepSlot.EMPTY, storage.getSlot(0)); + storage.insert(0, STONE, 5, false); + storage.setCompressionForm(0, Items.IRON_INGOT); + assertEquals(Optional.of(Items.IRON_INGOT), storage.getSlot(0).getCompressionForm()); + int state = storage.getState(); + storage.setCompressionForm(0, Items.IRON_INGOT); + assertEquals(state, storage.getState()); + storage.setCompressionForm(0, null); + assertEquals(Optional.empty(), storage.getSlot(0).getCompressionForm()); + } + + // Resize + + @Test + public void testValidateSlotCount() { + storage.insert(2, STONE, 1, false); + storage.lockTo(1, DIRT); + assertTrue(storage.validateSlotCount(5).isOk()); + assertTrue(storage.validateSlotCount(3).isOk()); + assertEquals(List.of(2), storage.validateSlotCount(1).offendingSlots()); + } + + @Test + public void testSetSlotCountGrow() { + storage.insert(0, STONE, 1, false); + assertTrue(storage.setSlotCount(5).isOk()); + assertEquals(5, storage.getSlotCount()); + assertSame(DeepSlot.EMPTY, storage.getSlot(4)); + assertEquals(1, storage.getSlot(0).getCount()); + assertTrue(changes.contains(-1)); + } + + @Test + public void testSetSlotCountShrinkRefused() { + storage.insert(2, STONE, 1, false); + int state = storage.getState(); + assertFalse(storage.setSlotCount(2).isOk()); + assertEquals(3, storage.getSlotCount()); + assertEquals(state, storage.getState()); + } + + @Test + public void testSetSlotCountShrinkAllowed() { + storage.insert(0, STONE, 1, false); + assertTrue(storage.setSlotCount(1).isOk()); + assertEquals(1, storage.getSlotCount()); + } + + @Test + public void testValidateProfileBothDirections() { + storage.insert(0, STONE, 200, false); + storage.insert(1, PEARL, 30, false); + assertTrue(storage.validateProfile(CapacityProfile.ofDepth(64)).isOk()); + assertEquals(List.of(0), storage.validateProfile(CapacityProfile.ofDepth(3)).offendingSlots()); + assertEquals(List.of(0, 1), storage.validateProfile(CapacityProfile.ofDepth(1)).offendingSlots()); + } + + @Test + public void testSetProfile() { + storage.insert(0, STONE, 200, false); + assertFalse(storage.setProfile(CapacityProfile.ofDepth(3)).isOk()); + assertEquals(CapacityProfile.ofDepth(4), storage.getProfile()); + assertTrue(storage.setProfile(CapacityProfile.ofDepth(8)).isOk()); + assertEquals(CapacityProfile.ofDepth(8), storage.getProfile()); + } + + @Test + public void testForceProfile() { + storage.insert(0, STONE, 200, false); + ResizeResult result = storage.forceProfile(CapacityProfile.ofDepth(1)); + assertEquals(List.of(0), result.offendingSlots()); + assertEquals(CapacityProfile.ofDepth(1), storage.getProfile()); + assertEquals(200, storage.getSlot(0).getCount()); + } + + // Listeners, dirty tracking and state + + @Test + public void testListenersAndState() { + storage.insert(1, STONE, 1, false); + storage.insert(1, STONE, 1, true); + assertEquals(List.of(1), changes); + assertEquals(1, storage.getState()); + storage.forceProfile(CapacityProfile.ofDepth(8)); + assertEquals(List.of(1, -1), changes); + assertEquals(2, storage.getState()); + } + + @Test + public void testRemoveListener() { + storage.removeListener(changes::add); + List other = Lists.newArrayList(); + java.util.function.IntConsumer listener = other::add; + storage.addListener(listener); + storage.removeListener(listener); + storage.insert(0, STONE, 1, false); + assertTrue(other.isEmpty()); + } + + @Test + public void testStateUnchangedWithoutChange() { + storage.insert(0, STONE, 256, false); + int state = storage.getState(); + storage.insert(0, STONE, 1, false); + storage.setLocked(1, false); + storage.forceProfile(storage.getProfile()); + assertEquals(state, storage.getState()); + } + + @Test + public void testDirtySlots() { + assertFalse(storage.hasDirtySlots()); + storage.insert(2, STONE, 1, false); + storage.insert(0, STONE, 1, false); + assertTrue(storage.hasDirtySlots()); + assertArrayEquals(new int[]{0, 2}, storage.drainDirtySlots()); + assertFalse(storage.hasDirtySlots()); + assertArrayEquals(new int[0], storage.drainDirtySlots()); + storage.markAllDirty(); + assertArrayEquals(new int[]{0, 1, 2}, storage.drainDirtySlots()); + } + + // Serialization + + @Test + public void testContentsAreSparse() { + storage.insert(1, STONE, 5, false); + ChestStorage.Contents contents = storage.toContents(); + assertEquals(3, contents.slotCount()); + assertEquals(1, contents.entries().size()); + assertEquals(1, contents.entries().get(0).slot()); + } + + @Test + public void testSerializationRoundTrip() { + ChestStorage big = new ChestStorage(81, TestCapacityProfile.upgraded(262144, 8, 0).maxItemsPerSlot(Long.MAX_VALUE).build()); + ItemStack named = STONE.copy(); + named.set(DataComponents.CUSTOM_NAME, Component.literal("Named")); + ItemStack damaged = PICKAXE.copy(); + damaged.setDamageValue(7); + big.insert(0, STONE, Integer.MAX_VALUE, false); + big.insert(5, DIRT, Integer.MAX_VALUE + 1000L, false); + big.insert(40, named, 3, false); + big.insert(41, damaged, 1, false); + big.lockTo(80, PEARL); + big.setCompressionForm(5, Items.IRON_NUGGET); + // Lower the technical cap: slot 5 is now above it and extract-only. + big.forceProfile(big.getProfile().withMaxItemsPerSlot(Integer.MAX_VALUE)); + assertTrue(big.isExtractOnly(5)); + + Tag tag = ChestStorage.CODEC.encodeStart(NbtOps.INSTANCE, big.toContents()).getOrThrow(); + ChestStorage loaded = new ChestStorage(0, big.getProfile()); + loaded.loadContents(ChestStorage.CODEC.parse(NbtOps.INSTANCE, tag).getOrThrow()); + + assertEquals(81, loaded.getSlotCount()); + for (int slot = 0; slot < 81; slot++) { + assertEquals("slot " + slot, big.getSlot(slot), loaded.getSlot(slot)); + } + assertEquals(Integer.MAX_VALUE, loaded.getSlot(0).getCount()); + assertEquals(Integer.MAX_VALUE + 1000L, loaded.getSlot(5).getCount()); + assertTrue(loaded.isExtractOnly(5)); + assertFalse(loaded.isExtractOnly(0)); + assertTrue(loaded.getSlot(80).isLocked()); + assertEquals(Optional.of(Items.IRON_NUGGET), loaded.getSlot(5).getCompressionForm()); + } + + @Test + public void testSerializationJson() { + storage.insert(2, STONE, 42, false); + var json = ChestStorage.CODEC.encodeStart(JsonOps.INSTANCE, storage.toContents()).getOrThrow(); + ChestStorage loaded = new ChestStorage(0, storage.getProfile()); + loaded.loadContents(ChestStorage.CODEC.parse(JsonOps.INSTANCE, json).getOrThrow()); + assertEquals(storage.getSlot(2), loaded.getSlot(2)); + } + + @Test + public void testUnknownCompressionFormLoadsAsDefault() { + var json = com.google.gson.JsonParser.parseString( + "{\"slot_count\": 1, \"slots\": [{\"slot\": 0, \"item\": {\"id\": \"minecraft:stone\"}, \"count\": 3, \"form\": \"othermod:missing\"}]}"); + storage.loadContents(ChestStorage.CODEC.parse(JsonOps.INSTANCE, json).getOrThrow()); + assertEquals(3, storage.getSlot(0).getCount()); + assertEquals(Optional.empty(), storage.getSlot(0).getCompressionForm()); + } + + @Test + public void testUndecodableEntrySkipped() { + var json = com.google.gson.JsonParser.parseString( + "{\"slot_count\": 2, \"slots\": [{\"slot\": 0, \"item\": {\"id\": \"othermod:missing\"}, \"count\": 3}," + + " {\"slot\": 1, \"item\": {\"id\": \"minecraft:stone\"}, \"count\": 4}]}"); + storage.loadContents(ChestStorage.CODEC.parse(JsonOps.INSTANCE, json).getOrThrow()); + assertSame(DeepSlot.EMPTY, storage.getSlot(0)); + assertEquals(4, storage.getSlot(1).getCount()); + } + + @Test + public void testLoadContentsNeverDropsEntries() { + ChestStorage.Contents contents = new ChestStorage.Contents(2, List.of( + new ChestStorage.Contents.Entry(4, STONE, 7, false, Optional.empty()))); + storage.loadContents(contents); + assertEquals(5, storage.getSlotCount()); + assertEquals(7, storage.getSlot(4).getCount()); + } + + @Test + public void testLoadContentsMarksDirtyAndNotifies() { + storage.loadContents(new ChestStorage.Contents(2, List.of())); + assertArrayEquals(new int[]{0, 1}, storage.drainDirtySlots()); + assertEquals(List.of(-1), changes); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamilies.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamilies.java new file mode 100644 index 00000000..c02b6791 --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamilies.java @@ -0,0 +1,44 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * @author rubensworks + */ +public class TestCompressionFamilies extends BootstrapTest { + + @Test + public void testRegisterAndFind() { + CompressionFamilies families = new CompressionFamilies(); + CompressionFamily iron = TestCompressionFamily.iron(); + families.register(iron); + assertEquals(Optional.of(iron), families.find(Items.IRON_NUGGET)); + assertEquals(Optional.of(iron), families.find(new ItemStack(Items.IRON_BLOCK))); + assertEquals(Optional.empty(), families.find(Items.GOLD_INGOT)); + assertEquals(Optional.empty(), families.find(ItemStack.EMPTY)); + assertEquals(1, families.getFamilies().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void testOverlapRejected() { + CompressionFamilies families = new CompressionFamilies(); + families.register(TestCompressionFamily.iron()); + families.register(CompressionFamily.of(Items.IRON_INGOT, 9, Items.GOLD_NUGGET)); + } + + @Test + public void testClear() { + CompressionFamilies families = new CompressionFamilies(); + families.register(TestCompressionFamily.iron()); + families.clear(); + assertTrue(families.getFamilies().isEmpty()); + assertEquals(Optional.empty(), families.find(Items.IRON_INGOT)); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamily.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamily.java new file mode 100644 index 00000000..f5a2c032 --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestCompressionFamily.java @@ -0,0 +1,99 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * @author rubensworks + */ +public class TestCompressionFamily extends BootstrapTest { + + static CompressionFamily iron() { + return CompressionFamily.of(Items.IRON_BLOCK, 9, Items.IRON_INGOT, 9, Items.IRON_NUGGET); + } + + @Test + public void testOf() { + CompressionFamily family = iron(); + assertEquals(3, family.size()); + assertEquals(new CompressionFamily.Form(Items.IRON_BLOCK, 81), family.largest()); + assertEquals(new CompressionFamily.Form(Items.IRON_INGOT, 9), family.get(1)); + assertEquals(new CompressionFamily.Form(Items.IRON_NUGGET, 1), family.smallest()); + } + + @Test + public void testIndexOf() { + CompressionFamily family = iron(); + assertEquals(0, family.indexOf(Items.IRON_BLOCK)); + assertEquals(2, family.indexOf(Items.IRON_NUGGET)); + assertEquals(-1, family.indexOf(Items.GOLD_INGOT)); + assertEquals(1, family.indexOf(new ItemStack(Items.IRON_INGOT, 5))); + assertEquals(-1, family.indexOf(ItemStack.EMPTY)); + ItemStack named = new ItemStack(Items.IRON_INGOT); + named.set(DataComponents.CUSTOM_NAME, Component.literal("Named")); + assertEquals(-1, family.indexOf(named)); + } + + @Test + public void testConversionAcrossThreeForms() { + CompressionFamily family = iron(); + assertEquals(81, family.toBaseUnits(0, 1)); + assertEquals(18, family.toBaseUnits(1, 2)); + assertEquals(5, family.toBaseUnits(2, 5)); + + long base = family.toBaseUnits(0, 1234) + family.toBaseUnits(1, 5) + family.toBaseUnits(2, 7); + assertEquals(1234, family.fromBaseUnits(0, base)); + assertEquals(1234 * 9 + 5, family.fromBaseUnits(1, base)); + assertEquals(base, family.fromBaseUnits(2, base)); + assertEquals(52, family.remainderBaseUnits(0, base)); + assertEquals(7, family.remainderBaseUnits(1, base)); + assertEquals(0, family.remainderBaseUnits(2, base)); + + assertEquals(11106, family.convert(0, 1234, 1)); + assertEquals(1, family.convert(1, 17, 0)); + assertEquals(90, family.convert(1, 10, 2)); + } + + @Test + public void testToBaseUnitsSaturates() { + assertEquals(Long.MAX_VALUE, iron().toBaseUnits(0, Long.MAX_VALUE / 2)); + } + + @Test(expected = IllegalArgumentException.class) + public void testTooFewForms() { + new CompressionFamily(List.of(new CompressionFamily.Form(Items.IRON_NUGGET, 1))); + } + + @Test(expected = IllegalArgumentException.class) + public void testSmallestMustBeOne() { + new CompressionFamily(List.of(new CompressionFamily.Form(Items.IRON_BLOCK, 18), new CompressionFamily.Form(Items.IRON_INGOT, 2))); + } + + @Test(expected = IllegalArgumentException.class) + public void testNotWholeMultiple() { + new CompressionFamily(List.of(new CompressionFamily.Form(Items.IRON_BLOCK, 10), new CompressionFamily.Form(Items.IRON_INGOT, 3), new CompressionFamily.Form(Items.IRON_NUGGET, 1))); + } + + @Test(expected = IllegalArgumentException.class) + public void testDuplicateForm() { + new CompressionFamily(List.of(new CompressionFamily.Form(Items.IRON_INGOT, 9), new CompressionFamily.Form(Items.IRON_INGOT, 1))); + } + + @Test(expected = IllegalArgumentException.class) + public void testOfOddArguments() { + CompressionFamily.of(Items.IRON_BLOCK, 9); + } + + @Test(expected = IllegalArgumentException.class) + public void testFormNonPositive() { + new CompressionFamily.Form(Items.IRON_INGOT, 0); + } + +} diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDeepSlot.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDeepSlot.java new file mode 100644 index 00000000..4d164471 --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDeepSlot.java @@ -0,0 +1,79 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * @author rubensworks + */ +public class TestDeepSlot extends BootstrapTest { + + @Test + public void testNormalizesToEmpty() { + assertSame(DeepSlot.EMPTY, DeepSlot.of(ItemStack.EMPTY, 10)); + assertSame(DeepSlot.EMPTY, DeepSlot.of(new ItemStack(Items.STONE), 0)); + assertTrue(DeepSlot.EMPTY.isEmpty()); + } + + @Test + public void testLockedAtZeroKeepsType() { + DeepSlot slot = DeepSlot.of(new ItemStack(Items.STONE), 0, true, null); + assertFalse(slot.isEmpty()); + assertTrue(slot.isLocked()); + assertEquals(0, slot.getCount()); + assertSame(DeepSlot.EMPTY, slot.withLocked(false)); + } + + @Test + public void testPrototypeHasCountOne() { + DeepSlot slot = DeepSlot.of(new ItemStack(Items.STONE, 32), 1000); + assertEquals(1, slot.getPrototype().getCount()); + assertEquals(1000, slot.getCount()); + } + + @Test + public void testMatches() { + DeepSlot slot = DeepSlot.of(new ItemStack(Items.STONE), 5); + assertTrue(slot.matches(new ItemStack(Items.STONE, 64))); + assertFalse(slot.matches(new ItemStack(Items.DIRT))); + assertFalse(DeepSlot.EMPTY.matches(new ItemStack(Items.STONE))); + ItemStack damaged = new ItemStack(Items.IRON_PICKAXE); + damaged.setDamageValue(3); + assertFalse(DeepSlot.of(new ItemStack(Items.IRON_PICKAXE), 1).matches(damaged)); + } + + @Test + public void testWithers() { + DeepSlot slot = DeepSlot.of(new ItemStack(Items.STONE), 5); + assertEquals(7, slot.withCount(7).getCount()); + assertEquals(Optional.of(Items.IRON_INGOT), slot.withCompressionForm(Items.IRON_INGOT).getCompressionForm()); + assertEquals(Optional.empty(), slot.withCompressionForm(Items.IRON_INGOT).withCompressionForm(null).getCompressionForm()); + assertEquals(Optional.empty(), slot.withCompressionForm(Items.AIR).getCompressionForm()); + assertTrue(slot.withLocked(true).isLocked()); + } + + @Test + public void testEqualsAndHashCode() { + DeepSlot a = DeepSlot.of(new ItemStack(Items.STONE), 5); + DeepSlot b = DeepSlot.of(new ItemStack(Items.STONE, 3), 5); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, a.withCount(6)); + assertNotEquals(a, a.withLocked(true)); + assertNotEquals(a, DeepSlot.of(new ItemStack(Items.DIRT), 5)); + assertNotEquals(a, a.withCompressionForm(Items.IRON_INGOT)); + assertTrue(a.toString().contains("x5")); + assertTrue(a.withCompressionForm(Items.IRON_INGOT).toString().contains("form=")); + } + + @Test(expected = IllegalArgumentException.class) + public void testNegativeCount() { + DeepSlot.of(new ItemStack(Items.STONE), -1); + } + +}