diff --git a/docs/capabilities.md b/docs/capabilities.md index e0843c9..0e78252 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -90,10 +90,12 @@ is checked every tick, keep a NeoForge `BlockCapabilityCache` instead. ## Notes -- **Planned: side configuration per resource group.** The GUI design configures sides per - group of slots or tanks (item input, item output, a tank, energy), Industrial Foregoing - style. Today a `SideConfig` applies to a whole storage; views will gain a per-slot (and - per-tank) mode so each group can have its own `SideConfig` without changing slot numbering. +- **Side configuration per resource group.** The screen's `` view edits one + `SideConfig` per group (item input, item output, a tank, energy), Industrial Foregoing style; + the block entity returns them from `ScreenHost.screenSides()`. A view still takes one mode per + face for the whole storage, so the mod's `sides` function decides how groups combine. When + groups differ by `Role`, take insert from the input group and extract from the output group. + A per-slot mode in the views, which would remove that step, is not implemented. - `ItemStorage` keeps the slot count given to its constructor. Loading a save that has more slots drops the stacks that no longer fit. `FluidStorage` loads its tanks by index. diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 7a3e0e8..cf989d7 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -71,8 +71,13 @@ show empty filters. No real items move through this path. Bind the block's `block-right-click` event to `mmdlib:open_menu`. This handler first looks for an XML screen matching the clicked block entity's type; otherwise it uses the block's own vanilla -menu provider. It consumes matching clicks on both sides and opens only on the server. A direct -code caller may use `ScreenRuntime.open(player, blockEntity)`. +menu provider. It consumes matching clicks on both sides and opens only on the server. A click +holding a fluid container on a face that has a fluid handler is left alone, so the block's own +item use can fill or empty the tank; MMDLib's `MaterialEntityBlock` does not do that itself. A +direct code caller may use `ScreenRuntime.open(player, blockEntity)`. + +`screenSides()` keys must equal the element `id`s named in ``; a group +with no entry in the map is listed, but shows no modes and ignores clicks. Click extension methods use event type `gui-click` and signature `public static void method(ScreenClick click)`. The context supplies the player, menu, element, mouse button, Shift/Control/Alt state, and selected @@ -160,10 +165,11 @@ a compact meter, a few controls, an upgrade tab, and a side-configuration view: - - - - + + + + + diff --git a/docs/roadmap.md b/docs/roadmap.md index 89bab32..c053480 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,6 +55,13 @@ These are flagged for consideration, not committed work. +### Machine module + +Machine ingredients and recipes across items, fluids and energy, transactions, a processing +loop, then machines defined entirely in JSON; also the plan for NeoForge 26.1 and Fabric 26.3 +builds. Stage 1 is built (internals without GUI, and ingredients, groups, transactions and +matching). + ### KubeJS and CraftTweaker recipe integrations Optional polish over the generic support above, each loaded only when that mod is present: a diff --git a/gradle/verification.gradle b/gradle/verification.gradle index fafccf4..2e48d76 100644 --- a/gradle/verification.gradle +++ b/gradle/verification.gradle @@ -32,6 +32,7 @@ def builderChecks = [ verifyMethodEvents : ['MethodEventsTest'], verifyDefinitionLoader : ['DefinitionLoaderTest'], verifyScreens : ['zone.moddev.mc.lib.gui.ScreenLoaderTest'], + verifyMachineCore : ['zone.moddev.mc.lib.machine.MachineCoreTest'], verifyVanillaExamples : ['VanillaExamplesTest', ['mmdlib.resources': resources, 'mmdlib.generated': file('src/generated/resources').absolutePath]], ] @@ -58,6 +59,20 @@ def verifyReleaseClasses = tasks.register('verifyReleaseClasses') { } tasks.named('check') { dependsOn verifyReleaseClasses } +// The machine module's core stays loader-neutral: only machine/neoforge may import NeoForge. +def verifyMachineImports = tasks.register('verifyMachineImports') { + group = 'verification' + def root = file('src/main/java/zone/moddev/mc/lib/machine') + def sources = fileTree(root) { include '**/*.java'; exclude 'neoforge/**' } + inputs.files(sources) + doLast { + def leaked = sources.files.findAll { it.text =~ /(?m)^import\s+(static\s+)?net\.neoforged\./ } + if (!leaked.isEmpty()) + throw new GradleException('Machine core files import NeoForge: ' + leaked.collect { root.relativePath(it) }) + } +} +tasks.named('check') { dependsOn verifyMachineImports } + def verifyStaticAssets = tasks.register('verifyStaticAssets', Exec) { group = 'verification' dependsOn tasks.named('classes') diff --git a/scripts/runtime-smoke.init.gradle b/scripts/runtime-smoke.init.gradle index a0a3a1b..5cb642f 100644 --- a/scripts/runtime-smoke.init.gradle +++ b/scripts/runtime-smoke.init.gradle @@ -12,6 +12,13 @@ online-mode=true level-name=runtime-smoke spawn-protection=0 ''' + // A real datapack in the smoke world, so reload listeners read files the way packs ship them. + def datapack = new File(smokeDir, 'runtime-smoke/datapacks/mmdlib_smoke') + project.delete(datapack) + project.copy { + from file('src/runtimeTest/datapack') + into datapack + } runs.server.workingDirectory = smokeDir // NeoGradle creates run tasks in a later evaluation callback. tasks.configureEach { task -> diff --git a/src/builderTest/java/zone/moddev/mc/lib/common/builders/DefinitionLoaderTest.java b/src/builderTest/java/zone/moddev/mc/lib/common/builders/DefinitionLoaderTest.java index 867a60e..e5d7b60 100644 --- a/src/builderTest/java/zone/moddev/mc/lib/common/builders/DefinitionLoaderTest.java +++ b/src/builderTest/java/zone/moddev/mc/lib/common/builders/DefinitionLoaderTest.java @@ -291,7 +291,9 @@ private static void expectFailure(Runnable action, String... fragments) { try { action.run(); } catch (IllegalArgumentException ex) { - for (var fragment : fragments) require(ex.getMessage().contains(fragment)); + // Messages name file-system paths, which use backslashes on Windows. + var message = ex.getMessage().replace('\\', '/'); + for (var fragment : fragments) require(message.contains(fragment)); return; } throw new AssertionError("Bad definitions accepted"); diff --git a/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java b/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java new file mode 100644 index 0000000..3c35f44 --- /dev/null +++ b/src/builderTest/java/zone/moddev/mc/lib/machine/MachineCoreTest.java @@ -0,0 +1,485 @@ +package zone.moddev.mc.lib.machine; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.Set; +import java.util.function.Function; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +import zone.moddev.mc.lib.common.types.MaterialProperties; +import zone.moddev.mc.lib.machine.expr.Expr; +import zone.moddev.mc.lib.machine.ingredient.AmountMatch; +import zone.moddev.mc.lib.machine.internals.InternalsDefinition; +import zone.moddev.mc.lib.machine.internals.MachineParts; +import zone.moddev.mc.lib.machine.internals.PartDefinition; +import zone.moddev.mc.lib.machine.internals.PartSubject; +import zone.moddev.mc.lib.machine.internals.SwapRule; +import zone.moddev.mc.lib.machine.recipe.InputClaims; +import zone.moddev.mc.lib.machine.stat.MachineStats; +import zone.moddev.mc.lib.machine.stat.StatDefinition; +import zone.moddev.mc.lib.machine.stat.StatModifier; + +/** Checks the machine module's core types, which need no running game. */ +public final class MachineCoreTest { + public static void main(String[] args) { + expressions(); + stats(); + internalsDefinition(); + parts(); + amountMatch(); + inputClaims(); + System.out.println( + "Machine expressions, stats, internals definitions, part files, amount matching and input" + + " claims passed"); + } + + private static void expressions() { + require( + near(Expr.parse("0.08 * ($material.hardness - 3)").eval(v("material.hardness", 6)), .24)); + require(Expr.parse("-2 + 3 * 4").eval(none()) == 10); + require(Expr.parse("(1 + 2) / 4").eval(none()) == .75); + require(Expr.parse("10 - 4 - 3").eval(none()) == 3); + require(Expr.parse("2 * -(1 + 1)").eval(none()) == -4); + require(Expr.parse("$a.b").variables().equals(Set.of("a.b"))); + require(Expr.parse("1 + 2").variables().isEmpty()); + require(Double.isInfinite(Expr.parse("1 / $x").eval(v("x", 0)))); + require(Expr.constant(3).eval(none()) == 3); + expectFailure(() -> Expr.parse("2 *")); + expectFailure(() -> Expr.parse("2 $")); + expectFailure(() -> Expr.parse("(1")); + expectFailure(() -> Expr.parse("")); + expectFailure(() -> Expr.parse("$missing").eval(none())); + expectFailure(() -> Expr.parse("$a..b")); + expectFailure(() -> Expr.parse("$a.")); + expectFailure(() -> Expr.parse("$.a")); + require( + Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(12)).getOrThrow().eval(none()) == 12); + require( + Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive("$x * 2")).getOrThrow().eval(v("x", 4)) + == 8); + require(Expr.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive("2 *")).error().isPresent()); + // Encoding round-trips, including numbers Double.toString would write in E notation. + var big = Expr.parse("10000000 * $x + 0.0001"); + var encoded = Expr.CODEC.encodeStart(JsonOps.INSTANCE, big).getOrThrow(); + require( + near( + Expr.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow().eval(v("x", 2)), + 20000000.0001)); + } + + private static void stats() { + var defs = + Map.of( + "speed", new StatDefinition(1, OptionalDouble.of(.25), OptionalDouble.of(8)), + "energy_per_tick", + new StatDefinition(40, OptionalDouble.of(1), OptionalDouble.empty())); + // The worked table in machine-ui.md, with example hardness iron 4 and invar 6. + var iron = List.of(base("speed", .08), base("energy_per_tick", .12)); + var invar = List.of(base("speed", .24), base("energy_per_tick", .18)); + var motor = List.of(total("speed", .15), value("energy_per_tick", 8)); + var upgrade = List.of(total("speed", .5), total("energy_per_tick", .5)); + var twoIron = MachineStats.compute(defs, concat(iron, iron)); + require(near(twoIron.get("speed"), 1.16) && near(twoIron.get("energy_per_tick"), 49.6)); + var twoInvar = MachineStats.compute(defs, concat(invar, invar)); + require(near(twoInvar.get("speed"), 1.48) && near(twoInvar.get("energy_per_tick"), 54.4)); + var withMotor = MachineStats.compute(defs, concat(invar, invar, motor)); + require(near(withMotor.get("speed"), 1.702) && near(withMotor.get("energy_per_tick"), 65.28)); + var all = concat(invar, invar, motor, upgrade); + var full = MachineStats.compute(defs, all); + require(near(full.get("speed"), 2.553) && near(full.get("energy_per_tick"), 97.92)); + var shuffled = new ArrayList<>(all); + Collections.reverse(shuffled); + require(MachineStats.compute(defs, shuffled).values().equals(full.values())); + require(MachineStats.compute(defs, List.of()).get("speed") == 1); + require(MachineStats.compute(defs, List.of(total("speed", 10))).get("speed") == 8); + require(MachineStats.compute(defs, List.of(value("speed", -5))).get("speed") == .25); + var undeclared = MachineStats.compute(defs, List.of(value("max_temperature", 400))); + require(undeclared.values().size() == 2 && undeclared.get("speed") == 1); + expectFailure(() -> MachineStats.EMPTY.get("speed")); + expectFailure(() -> value("speed", Double.NaN)); + var parsed = parseStat("{\"base\":1,\"min\":0.25,\"max\":8}"); + require(parsed.equals(defs.get("speed"))); + require(parseStat("{\"base\":900}").min().isEmpty()); + expectFailure(() -> parseStat("{\"base\":1,\"min\":5,\"max\":2}")); + expectFailure(() -> parseStat("{\"min\":1}")); + } + + /** The internals example from machine-ui.md, "Sockets and stats". */ + static final String INTERNALS = + """ + { + "open_with": "#c:tools/wrench", + "swap": "when_idle", + "sockets": { + "gears": { "count": 2, "accepts": "#c:gears" }, + "motor": { "count": 1, "accepts": "#example:motors" }, + "coil": { "count": 1, "accepts": "#example:heating_coils", "optional": true } + }, + "stats": { + "speed": { "base": 1.0, "min": 0.25, "max": 8 }, + "energy_per_tick": { "base": 40, "min": 1 }, + "max_temperature": { "base": 900 } + } + } + """; + + private static void internalsDefinition() { + var internals = parseInternals(INTERNALS); + require(internals.swap() == SwapRule.WHEN_IDLE); + require(List.copyOf(internals.sockets().keySet()).equals(List.of("coil", "gears", "motor"))); + require(internals.slotCount() == 4); + require(internals.slots("coil").equals(List.of(0))); + require(internals.slots("gears").equals(List.of(1, 2))); + require(internals.slots("motor").equals(List.of(3))); + require( + internals.sockets().get("coil").optional() && !internals.sockets().get("gears").optional()); + require( + internals + .openWith() + .orElseThrow() + .value() + .left() + .orElseThrow() + .location() + .equals(ResourceLocation.parse("c:tools/wrench"))); + require(internals.stats().get("max_temperature").base() == 900); + var plain = + parseInternals("{\"sockets\":{\"motor\":{\"accepts\":\"example:motor\"}},\"stats\":{}}"); + require(plain.swap() == SwapRule.WHEN_IDLE && plain.openWith().isEmpty()); + require(plain.sockets().get("motor").count() == 1); + require( + plain + .sockets() + .get("motor") + .accepts() + .orElseThrow() + .value() + .right() + .orElseThrow() + .equals(ResourceLocation.parse("example:motor"))); + require(SwapRule.WHEN_IDLE.allows(false) && !SwapRule.WHEN_IDLE.allows(true)); + require(SwapRule.ALWAYS.allows(true) && !SwapRule.NEVER.allows(false)); + expectFailure(() -> parseInternals(INTERNALS.replace("\"count\": 2", "\"count\": 0"))); + expectFailure(() -> parseInternals(INTERNALS.replace("when_idle", "sometimes"))); + expectFailure(() -> parseInternals(INTERNALS.replace("#c:gears", "#"))); + expectFailure(() -> parseInternals(INTERNALS.replace("#c:gears", "Not An Id"))); + expectFailure(() -> internals.slots("pump")); + require(internals.socketAt(0).equals("coil") && internals.socketAt(3).equals("motor")); + expectFailure(() -> internals.socketAt(-1)); + expectFailure(() -> internals.socketAt(4)); + } + + /** The gear template from machine-ui.md, "Part files". */ + static final String GEAR_TEMPLATE = + """ +{ + "part": "mmdlib:gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_base", "amount": "0.08 * ($material.hardness - 3)" }, + { "stat": "energy_per_tick", "op": "add_multiplied_base", "amount": "0.03 * $material.hardness" } + ], + "durability": "500 * $material.hardness" +} +"""; + + private static void parts() { + var subjects = + Map.of( + id("test:iron_gear"), gear("test:iron_gear", 4, Map.of()), + id("test:invar_gear"), gear("test:invar_gear", 6, Map.of("grip", 2.0)), + id("test:soft_gear"), gear("test:soft_gear", 0, Map.of()), + id("test:diamond_coated_gear"), gear("test:diamond_coated_gear", 10, Map.of()), + id("test:plain_rod"), + new PartSubject( + id("test:plain_rod"), Optional.of(id("mmdlib:rod")), Optional.empty()), + id("test:plain_pipe"), + new PartSubject(id("test:plain_pipe"), Optional.empty(), Optional.empty())); + var files = new HashMap(); + files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); + files.put( + id("test:diamond_coated_gear"), + JsonParser.parseString( + """ + { + "item": "test:diamond_coated_gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_total", "amount": 0.4 }, + { "stat": "energy_per_tick", "op": "add_value", "amount": 12 } + ] + } + """)); + // A second template for the same part: the lower resource ID (test:gear) wins. + files.put( + id("test:zz_gear"), + JsonParser.parseString(GEAR_TEMPLATE.replace("\"gears\"", "\"other\""))); + // An unknown variable: logged and skipped, the rest still load. + files.put( + id("test:broken"), + JsonParser.parseString( + GEAR_TEMPLATE + .replace("mmdlib:gear", "mmdlib:plate") + .replace("$material.hardness - 3", "$material.colour"))); + files.put( + id("test:pipe"), + JsonParser.parseString( + """ +{"item": "test:plain_pipe", "socket": "pipes", + "modifiers": [{"stat": "speed", "op": "add_value", "amount": "$material.special.grip + 1"}]} +""")); + files.put( + id("test:grip"), + JsonParser.parseString( + """ + {"item": "test:invar_gear", "socket": "gears", + "modifiers": [{"stat": "grip", "op": "add_value", + "amount": "$material.special.grip + $material.special.none"}]} + """)); + var parts = MachineParts.parse(files, item -> Optional.ofNullable(subjects.get(item))); + var iron = parts.resolve(id("test:iron_gear")).orElseThrow(); + require(iron.socket().equals("gears")); + require(near(iron.modifiers().get(0).amount(), .08)); + require(near(iron.modifiers().get(1).amount(), .12)); + require(iron.modifiers().get(0).op() == AttributeModifier.Operation.ADD_MULTIPLIED_BASE); + require(iron.durability().equals(OptionalInt.of(2000))); + // An item file beats the template; missing special keys count as 0. + var invar = parts.resolve(id("test:invar_gear")).orElseThrow(); + require(invar.modifiers().size() == 1 && invar.modifiers().get(0).amount() == 2); + require(invar.durability().isEmpty()); + var coated = parts.resolve(id("test:diamond_coated_gear")).orElseThrow(); + require(coated.modifiers().get(0).op() == AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL); + require(coated.modifiers().get(0).amount() == .4 && coated.durability().isEmpty()); + // Hardness 0: the template's durability is 0, so this gear is not a part. + require(parts.resolve(id("test:soft_gear")).isEmpty()); + // An item without a material can't use $material. + require(parts.resolve(id("test:plain_pipe")).isEmpty()); + require(parts.resolve(id("test:plain_rod")).isEmpty()); + require(parts.resolve(id("test:unknown")).isEmpty()); + require(parts.resolve(id("test:iron_gear")).orElseThrow() == iron); // cached + // A non-finite amount (10 / hardness 0) makes the item not a part. + var rods = + MachineParts.parse( + Map.of( + id("test:rod"), + JsonParser.parseString( + """ +{"part": "mmdlib:rod", "socket": "shafts", + "modifiers": [{"stat": "speed", "op": "add_value", "amount": "10 / $material.hardness"}]} +""")), + item -> + Optional.of( + new PartSubject( + item, + Optional.of(id("mmdlib:rod")), + gear(item.toString(), item.getPath().equals("zero_rod") ? 0 : 5, Map.of()) + .material()))); + require(rods.resolve(id("test:zero_rod")).isEmpty()); + require(rods.resolve(id("test:steel_rod")).orElseThrow().modifiers().get(0).amount() == 2); + expectFailure(() -> parsePart("{\"socket\":\"gears\",\"modifiers\":[]}")); + expectFailure( + () -> + parsePart( + "{\"part\":\"mmdlib:gear\",\"item\":\"test:x\",\"socket\":\"gears\",\"modifiers\":[]}")); + expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("$material.hardness - 3", "$speed"))); + // A misspelt key is an error, not a part that quietly never wears. + expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("\"durability\"", "\"durabilty\""))); + expectFailure(() -> parsePart(GEAR_TEMPLATE.replace("\"stat\"", "\"note\": 1, \"stat\""))); + checkParts(); + int generation = MachineParts.current().generation(); + MachineParts.install(parts); + require(MachineParts.current() == parts && parts.generation() > generation); + MachineParts.install(MachineParts.NONE); + } + + /** Files that decode but can't do anything are reported by check, which also resolves early. */ + private static void checkParts() { + var files = new HashMap(); + files.put(id("test:gear"), JsonParser.parseString(GEAR_TEMPLATE)); + files.put( + id("test:typo_part"), + JsonParser.parseString(GEAR_TEMPLATE.replace("mmdlib:gear", "mmdlib:gaer"))); + files.put( + id("test:typo_item"), + JsonParser.parseString( + "{\"item\": \"test:iron_geer\", \"socket\": \"gears\", \"modifiers\": []}")); + files.put( + id("test:special"), + JsonParser.parseString( + """ + {"item": "test:invar_gear", "socket": "gears", + "modifiers": [{"stat": "grip", "op": "add_value", + "amount": "$material.special.grip + $material.special.gripp"}]} + """)); + var subjects = + Map.of( + id("test:iron_gear"), gear("test:iron_gear", 4, Map.of()), + id("test:invar_gear"), gear("test:invar_gear", 6, Map.of("grip", 2.0)), + id("test:soft_gear"), gear("test:soft_gear", 0, Map.of())); + var lookups = new int[1]; + var parts = + MachineParts.parse( + files, + item -> { + lookups[0]++; + return Optional.ofNullable(subjects.get(item)); + }); + var problems = parts.check(subjects.keySet()); + require(problems.size() == 3); + require(mentions(problems, "test:typo_part", "mmdlib:gaer")); + require(mentions(problems, "test:typo_item", "test:iron_geer")); + require(mentions(problems, "test:special", "$material.special.gripp")); + // Every item was resolved, so template errors are already logged and nothing looks up again. + int after = lookups[0]; + require(parts.resolve(id("test:iron_gear")).isPresent()); + require(parts.resolve(id("test:soft_gear")).isEmpty()); + require(lookups[0] == after); + } + + private static boolean mentions(List problems, String... words) { + return problems.stream() + .anyMatch(problem -> List.of(words).stream().allMatch(problem::contains)); + } + + private static void amountMatch() { + require(AmountMatch.EXACT.test(5, 5) && !AmountMatch.EXACT.test(5, 6)); + require(AmountMatch.AT_LEAST.test(5, 6) && AmountMatch.AT_LEAST.test(5, 5)); + require(!AmountMatch.AT_LEAST.test(5, 4)); + require(AmountMatch.ANY_AMOUNT.test(5, 0)); + require(AmountMatch.AT_LEAST.test(3_000_000_000L, 3_000_000_001L)); + require(!AmountMatch.AT_LEAST.test(3_000_000_000L, 2_999_999_999L)); + } + + private static void inputClaims() { + // A broad part listed first moves off the only entry a later part accepts. + var two = new InputClaims(new long[] {1, 1}); + require(two.claim(1, any -> true) == 0); + require(two.claim(1, entry -> entry == 0) == 0); + require(two.claimed(0) == 1 && two.claimed(1) == 1); + // Listing them the other way round gives the same answer. + var swapped = new InputClaims(new long[] {1, 1}); + require(swapped.claim(1, entry -> entry == 0) == 0); + require(swapped.claim(1, any -> true) == 0); + require(swapped.claimed(0) == 1 && swapped.claimed(1) == 1); + // A chain: the third part moves the second, which moves the first. + var chain = new InputClaims(new long[] {1, 1, 1}); + require(chain.claim(1, entry -> entry != 1) == 0); + require(chain.claim(1, entry -> entry != 2) == 0); + require(chain.claim(1, entry -> entry == 0) == 0); + require(chain.claimed(0) == 1 && chain.claimed(1) == 1 && chain.claimed(2) == 1); + // Part of a stack moves, and the rest stays. + var stacks = new InputClaims(new long[] {3, 3}); + require(stacks.claim(4, any -> true) == 0); + require(stacks.claim(2, entry -> entry == 0) == 0); + require(stacks.claimed(0) == 3 && stacks.claimed(1) == 3); + // Nothing counts twice, and a claim that can't be met reports how much it's short. + var shared = new InputClaims(new long[] {3}); + require(shared.claim(2, any -> true) == 0); + require(shared.claim(2, any -> true) == 1); + require(shared.claimed(0) == 3); + // Two parts that only accept one entry can't both be met by moving. + var stuck = new InputClaims(new long[] {1, 1}); + require(stuck.claim(1, entry -> entry == 0) == 0); + require(stuck.claim(1, entry -> entry == 0) == 1); + require(stuck.claimed(0) == 1 && stuck.claimed(1) == 0); + // Empty entries aren't asked about, so an ingredient never tests an empty stack. + var empty = new InputClaims(new long[] {0, 2}); + require( + empty.claim( + 1, + entry -> { + require(entry == 1); + return true; + }) + == 0); + // Energy amounts don't fit an int. + var energy = new InputClaims(new long[] {3_000_000_000L}); + require(energy.claim(2_999_999_999L, any -> true) == 0); + require(energy.claim(2, any -> true) == 1); + } + + private static PartSubject gear(String item, double hardness, Map special) { + return new PartSubject( + id(item), + Optional.of(id("mmdlib:gear")), + Optional.of(new MaterialProperties("metal", -1, hardness, 1, 0, 0, Set.of(), special))); + } + + private static PartDefinition parsePart(String json) { + return PartDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); + } + + private static ResourceLocation id(String value) { + return ResourceLocation.parse(value); + } + + static InternalsDefinition parseInternals(String json) { + return InternalsDefinition.CODEC + .parse(JsonOps.INSTANCE, JsonParser.parseString(json)) + .getOrThrow(); + } + + private static StatDefinition parseStat(String json) { + return StatDefinition.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow(); + } + + private static StatModifier value(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_VALUE, amount); + } + + private static StatModifier base(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_BASE, amount); + } + + private static StatModifier total(String stat, double amount) { + return new StatModifier(stat, AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, amount); + } + + @SafeVarargs + private static List concat(List... lists) { + var all = new ArrayList(); + for (var list : lists) all.addAll(list); + return all; + } + + private static Function v(String name, double value) { + var values = Map.of(name, value); + return key -> + values.containsKey(key) ? OptionalDouble.of(values.get(key)) : OptionalDouble.empty(); + } + + private static Function none() { + return name -> OptionalDouble.empty(); + } + + static boolean near(double a, double b) { + return Math.abs(a - b) < 1e-9; + } + + static void require(boolean condition) { + if (!condition) throw new AssertionError("Machine core assertion failed"); + } + + static void expectFailure(Runnable action) { + try { + action.run(); + } catch (IllegalArgumentException | IllegalStateException ex) { + return; + } + throw new AssertionError("Invalid machine input accepted"); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/MMDLib.java b/src/main/java/zone/moddev/mc/lib/MMDLib.java index bfb8ef7..3f475b0 100644 --- a/src/main/java/zone/moddev/mc/lib/MMDLib.java +++ b/src/main/java/zone/moddev/mc/lib/MMDLib.java @@ -112,6 +112,9 @@ public MMDLib(IEventBus modBus, ModContainer container) { MODID, MaterialExtensions.GLOBAL, modBus); zone.moddev.mc.lib.common.recipes.CrushingRecipe.TYPES.register(modBus); zone.moddev.mc.lib.common.recipes.CrushingRecipe.SERIALIZERS.register(modBus); + zone.moddev.mc.lib.machine.neoforge.MachineComponents.TYPES.register(modBus); + NeoForge.EVENT_BUS.addListener( + zone.moddev.mc.lib.machine.neoforge.MachinePartsLoader::register); modBus.addListener(zone.moddev.mc.lib.util.Registries::register); modBus.addListener(this::publishDefinitions); modBus.addListener((FMLConstructModEvent event) -> event.enqueueWork(this::loadDefinitions)); diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java index a1b9837..b50087a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java @@ -11,6 +11,10 @@ /** Implement on a block entity to expose its inventory and side groups to a data-defined menu. */ public interface ScreenHost { + /** + * The inventory behind the screen's {@code } elements, numbered as in the XML; + * returning an {@code ItemStorage} keeps its output restrictions and filters for players. + */ IItemHandlerModifiable screenItems(); /** Ordered destinations for a player inventory stack; storage still decides what it accepts. */ @@ -23,6 +27,10 @@ default int[] screenRecipeSlots(net.minecraft.resources.ResourceLocation categor return new int[0]; } + /** + * The side configuration of each group, keyed by the element ids in {@code }; a group missing from the map shows no modes and ignores clicks. + */ default Map screenSides() { return Map.of(); } @@ -32,6 +40,7 @@ default boolean setScreenGhost(Player player, int slot, ItemStack filter) { return false; } + /** The authoritative filter shown in ghost slot {@code slot}; empty for none. */ default ItemStack screenGhost(int slot) { return ItemStack.EMPTY; } diff --git a/src/main/java/zone/moddev/mc/lib/machine/expr/Expr.java b/src/main/java/zone/moddev/mc/lib/machine/expr/Expr.java new file mode 100644 index 0000000..4f0bcf5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/expr/Expr.java @@ -0,0 +1,235 @@ +package zone.moddev.mc.lib.machine.expr; + +import java.math.BigDecimal; +import java.util.OptionalDouble; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +/** + * A small arithmetic expression over named variables, such as {@code 0.08 * ($material.hardness - + * 3)}: numbers, {@code + - * /}, parentheses, unary minus, and variables written {@code + * $name(.segment)*}. Variable names are passed to {@link #eval} without the {@code $}. + */ +public sealed interface Expr { + /** + * An expression from JSON: a number, or a string holding an expression. A bad string is a decode + * error. + */ + Codec CODEC = + Codec.either(Codec.DOUBLE, Codec.STRING) + .comapFlatMap( + value -> + value.map( + number -> DataResult.success(constant(number)), + text -> { + try { + return DataResult.success(parse(text)); + } catch (IllegalArgumentException ex) { + return DataResult.error(ex::getMessage); + } + }), + expr -> + expr instanceof Constant constant + ? Either.left(constant.value()) + : Either.right(expr.toString())); + + /** + * The value of this expression. + * + * @throws IllegalArgumentException if a variable has no value in {@code vars} + */ + double eval(Function vars); + + /** The names of every variable used, without {@code $}. */ + default Set variables() { + var names = new TreeSet(); + collect(this, names); + return Set.copyOf(names); + } + + /** An expression that is always {@code value}. */ + static Expr constant(double value) { + return new Constant(value); + } + + /** + * Parses {@code text}. + * + * @throws IllegalArgumentException naming the 0-based position of the first character that + * doesn't fit the grammar + */ + static Expr parse(String text) { + return new Parser(text).parseAll(); + } + + private static void collect(Expr expr, Set names) { + switch (expr) { + case Variable variable -> names.add(variable.name()); + case Negate negate -> collect(negate.operand(), names); + case Binary binary -> { + collect(binary.left(), names); + collect(binary.right(), names); + } + case Constant constant -> {} + } + } + + /** A number. */ + record Constant(double value) implements Expr { + @Override + public double eval(Function vars) { + return value; + } + + /** Plain decimal notation, which {@link #parse} reads back (it has no exponents). */ + @Override + public String toString() { + return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); + } + } + + /** A variable, named without {@code $}. */ + record Variable(String name) implements Expr { + @Override + public double eval(Function vars) { + return vars.apply(name) + .orElseThrow(() -> new IllegalArgumentException("No value for $" + name)); + } + + @Override + public String toString() { + return "$" + name; + } + } + + /** {@code -operand}. */ + record Negate(Expr operand) implements Expr { + @Override + public double eval(Function vars) { + return -operand.eval(vars); + } + + @Override + public String toString() { + return "-(" + operand + ")"; + } + } + + /** {@code left op right}, where {@code op} is one of {@code + - * /}. */ + record Binary(char op, Expr left, Expr right) implements Expr { + @Override + public double eval(Function vars) { + double a = left.eval(vars), b = right.eval(vars); + return switch (op) { + case '+' -> a + b; + case '-' -> a - b; + case '*' -> a * b; + default -> a / b; + }; + } + + @Override + public String toString() { + return "(" + left + " " + op + " " + right + ")"; + } + } + + /** + * Recursive descent: sum := product (('+'|'-') product)*, product := unary (('*'|'/') unary)*. + */ + final class Parser { + private final String text; + private int pos; + + private Parser(String text) { + this.text = text; + } + + private Expr parseAll() { + var expr = sum(); + skipSpaces(); + if (pos < text.length()) throw error("Unexpected '" + text.charAt(pos) + "'"); + return expr; + } + + private Expr sum() { + var expr = product(); + for (char op = peek(); op == '+' || op == '-'; op = peek()) { + pos++; + expr = new Binary(op, expr, product()); + } + return expr; + } + + private Expr product() { + var expr = unary(); + for (char op = peek(); op == '*' || op == '/'; op = peek()) { + pos++; + expr = new Binary(op, expr, unary()); + } + return expr; + } + + private Expr unary() { + char next = peek(); + if (next == '-') { + pos++; + return new Negate(unary()); + } + if (next == '(') { + pos++; + var expr = sum(); + if (peek() != ')') throw error("Expected ')'"); + pos++; + return expr; + } + if (next == '$') return variable(); + if (Character.isDigit(next) || next == '.') return number(); + throw error(pos < text.length() ? "Unexpected '" + next + "'" : "Expression ends early"); + } + + private Expr variable() { + int start = ++pos; + while (pos < text.length()) { + char c = text.charAt(pos); + boolean segmentEnd = c == '.' && pos > start && text.charAt(pos - 1) != '.'; + if (Character.isLetterOrDigit(c) || c == '_' || segmentEnd) pos++; + else break; + } + if (pos == start || text.charAt(pos - 1) == '.') throw error("Bad variable name"); + return new Variable(text.substring(start, pos)); + } + + private Expr number() { + int start = pos; + while (pos < text.length() + && (Character.isDigit(text.charAt(pos)) || text.charAt(pos) == '.')) pos++; + try { + return new Constant(Double.parseDouble(text.substring(start, pos))); + } catch (NumberFormatException ex) { + pos = start; + throw error("Bad number"); + } + } + + /** The next non-space character, without consuming it; 0 at the end. */ + private char peek() { + skipSpaces(); + return pos < text.length() ? text.charAt(pos) : 0; + } + + private void skipSpaces() { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) pos++; + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException( + message + " at position " + pos + " in expression '" + text + "'"); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/AmountMatch.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/AmountMatch.java new file mode 100644 index 0000000..1ae0471 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/AmountMatch.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.lib.machine.ingredient; + +/** + * How an amount held compares with an amount required, read from the recipe's side. Recipes use + * {@link #AT_LEAST}; the others are for code asking narrower questions. + */ +public enum AmountMatch { + /** Held equals required. */ + EXACT, + /** Held is at least required. */ + AT_LEAST, + /** Only the kind matters; any amount, even none. */ + ANY_AMOUNT; + + /** Whether {@code available} satisfies {@code required} under this rule. */ + public boolean test(long required, long available) { + return switch (this) { + case EXACT -> available == required; + case AT_LEAST -> available >= required; + case ANY_AMOUNT -> true; + }; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java new file mode 100644 index 0000000..b39f524 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.lib.machine.ingredient; + +import java.util.Objects; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.core.Holder; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.Fluids; + +/** + * A fluid and an amount in mB, owned by the machine core so it means the same on every loader. + * JSON: {@code {"id": "minecraft:water", "amount": 250, "components": {...}}}, with {@code + * components} optional. + */ +public record FluidAmount(Holder fluid, DataComponentPatch components, int amount) { + /** No fluid. */ + public static final FluidAmount EMPTY = + new FluidAmount(Fluids.EMPTY.builtInRegistryHolder(), DataComponentPatch.EMPTY, 0); + + /** The JSON form; {@code amount} is at least 1. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + BuiltInRegistries.FLUID + .holderByNameCodec() + .fieldOf("id") + .forGetter(FluidAmount::fluid), + DataComponentPatch.CODEC + .optionalFieldOf("components", DataComponentPatch.EMPTY) + .forGetter(FluidAmount::components), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("amount") + .forGetter(FluidAmount::amount)) + .apply(instance, FluidAmount::new)); + + /** + * @throws IllegalArgumentException if {@code amount} is negative + */ + public FluidAmount { + Objects.requireNonNull(fluid, "fluid"); + Objects.requireNonNull(components, "components"); + if (amount < 0) throw new IllegalArgumentException("Fluid amount is negative: " + amount); + } + + /** Whether there is no fluid or no amount. */ + public boolean isEmpty() { + return amount == 0 || fluid.value() == Fluids.EMPTY; + } + + /** Whether {@code other} is the same fluid with the same components, whatever the amounts. */ + public boolean sameFluid(FluidAmount other) { + return fluid.value() == other.fluid.value() && components.equals(other.components); + } + + /** This fluid with a different amount. */ + public FluidAmount withAmount(int amount) { + return new FluidAmount(fluid, components, amount); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java new file mode 100644 index 0000000..f7ffbe0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java @@ -0,0 +1,135 @@ +package zone.moddev.mc.lib.machine.ingredient; + +import java.util.List; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.core.HolderSet; +import net.minecraft.core.RegistryCodecs; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.material.Fluid; + +/** + * Some amount of something a machine consumes: items, fluid or energy. JSON is one of {@code + * {"items": , "count": 1}}, {@code {"fluids": "minecraft:water", "amount": 250}} or + * {@code {"energy": 4000}}. + */ +public sealed interface MachineIngredient + permits MachineIngredient.ItemPart, MachineIngredient.FluidPart, MachineIngredient.EnergyPart { + /** The amount: an item count, mB, or FE. */ + long amount(); + + /** + * The JSON form, picked by which of {@code items}, {@code fluids} or {@code energy} is present. + */ + Codec CODEC = new Dispatch().codec(); + + /** + * Items matching a vanilla {@link Ingredient} (an item, a tag or a custom ingredient). + * + * @param count how many, at least 1 + */ + record ItemPart(Ingredient items, int count) implements MachineIngredient { + static final MapCodec CODEC = + RecordCodecBuilder.mapCodec( + instance -> + instance + .group( + Ingredient.CODEC_NONEMPTY.fieldOf("items").forGetter(ItemPart::items), + Codec.intRange(1, Integer.MAX_VALUE) + .optionalFieldOf("count", 1) + .forGetter(ItemPart::count)) + .apply(instance, ItemPart::new)); + + @Override + public long amount() { + return count; + } + } + + /** + * Fluid from a set: one ID, a {@code #tag}, or a list. + * + * @param millibuckets the amount in mB, at least 1; JSON {@code amount} + */ + record FluidPart(HolderSet fluids, int millibuckets) implements MachineIngredient { + static final MapCodec CODEC = + RecordCodecBuilder.mapCodec( + instance -> + instance + .group( + RegistryCodecs.homogeneousList(Registries.FLUID) + .fieldOf("fluids") + .forGetter(FluidPart::fluids), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("amount") + .forGetter(FluidPart::millibuckets)) + .apply(instance, FluidPart::new)); + + @Override + public long amount() { + return millibuckets; + } + } + + /** + * Energy in FE; a {@code long}, so costs above 2,147,483,647 FE work. + * + * @param amount FE, at least 1 + */ + record EnergyPart(long amount) implements MachineIngredient { + static final MapCodec CODEC = + Codec.LONG + .validate( + value -> + value >= 1 + ? DataResult.success(value) + : DataResult.error(() -> "Energy must be at least 1: " + value)) + .fieldOf("energy") + .xmap(EnergyPart::new, EnergyPart::amount); + } + + /** Picks the record by its key; none, or more than one, is an error naming the three keys. */ + final class Dispatch extends MapCodec { + private static final List KEYS = List.of("items", "fluids", "energy"); + + private Dispatch() {} + + @Override + public java.util.stream.Stream keys(DynamicOps ops) { + return KEYS.stream().map(ops::createString); + } + + @Override + public DataResult decode(DynamicOps ops, MapLike input) { + var present = KEYS.stream().filter(key -> input.get(key) != null).toList(); + if (present.size() != 1) + return DataResult.error( + () -> + "A machine ingredient needs exactly one of 'items', 'fluids' or 'energy'; found " + + present); + return switch (present.getFirst()) { + case "items" -> ItemPart.CODEC.decode(ops, input).map(part -> part); + case "fluids" -> FluidPart.CODEC.decode(ops, input).map(part -> part); + default -> EnergyPart.CODEC.decode(ops, input).map(part -> part); + }; + } + + @Override + public RecordBuilder encode( + MachineIngredient input, DynamicOps ops, RecordBuilder prefix) { + return switch (input) { + case ItemPart part -> ItemPart.CODEC.encode(part, ops, prefix); + case FluidPart part -> FluidPart.CODEC.encode(part, ops, prefix); + case EnergyPart part -> EnergyPart.CODEC.encode(part, ops, prefix); + }; + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/InternalsDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/internals/InternalsDefinition.java new file mode 100644 index 0000000..954d298 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/InternalsDefinition.java @@ -0,0 +1,96 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import zone.moddev.mc.lib.machine.stat.StatDefinition; + +/** + * A machine's internals: the sockets parts go into, and the stats they change. A mod puts {@link + * #CODEC} inside its block entity's settings (for example as {@code factory_data.internals}), so a + * bad definition fails at startup. JSON keys: {@code open_with}, {@code swap} (default {@code + * when_idle}), {@code sockets}, {@code stats}. + * + *

Slots are laid out socket by socket in name order: with sockets {@code coil} (1), {@code + * gears} (2) and {@code motor} (1), {@code coil} is slot 0, {@code gears} slots 1 and 2, {@code + * motor} slot 3. + * + * @param openWith what a player must hold to open the engine view; empty for bare hands + */ +public record InternalsDefinition( + Optional openWith, + SwapRule swap, + SortedMap sockets, + Map stats) { + /** The JSON form. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + ItemMatch.CODEC + .optionalFieldOf("open_with") + .forGetter(InternalsDefinition::openWith), + SwapRule.CODEC + .optionalFieldOf("swap", SwapRule.WHEN_IDLE) + .forGetter(InternalsDefinition::swap), + Codec.unboundedMap(Codec.STRING, SocketDefinition.CODEC) + .>xmap(TreeMap::new, Map::copyOf) + .optionalFieldOf("sockets", Collections.emptySortedMap()) + .forGetter(InternalsDefinition::sockets), + Codec.unboundedMap(Codec.STRING, StatDefinition.CODEC) + .optionalFieldOf("stats", Map.of()) + .forGetter(InternalsDefinition::stats)) + .apply(instance, InternalsDefinition::new)); + + /** Copies the maps; sockets are kept sorted by name. */ + public InternalsDefinition { + sockets = Collections.unmodifiableSortedMap(new TreeMap<>(sockets)); + stats = Map.copyOf(stats); + } + + /** The number of slots across every socket. */ + public int slotCount() { + int count = 0; + for (var socket : sockets.values()) count += socket.count(); + return count; + } + + /** + * The slot indexes of {@code socket}'s positions. + * + * @throws IllegalArgumentException if there is no such socket + */ + public List slots(String socket) { + int first = 0; + for (var entry : sockets.entrySet()) { + if (entry.getKey().equals(socket)) { + var slots = new ArrayList(); + for (int i = 0; i < entry.getValue().count(); i++) slots.add(first + i); + return List.copyOf(slots); + } + first += entry.getValue().count(); + } + throw new IllegalArgumentException("Machine has no socket " + socket); + } + + /** The socket that slot {@code slot} belongs to. */ + public String socketAt(int slot) { + if (slot >= 0) { + int first = 0; + for (var entry : sockets.entrySet()) { + first += entry.getValue().count(); + if (slot < first) return entry.getKey(); + } + } + throw new IllegalArgumentException("Machine has no socket slot " + slot); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/ItemMatch.java b/src/main/java/zone/moddev/mc/lib/machine/internals/ItemMatch.java new file mode 100644 index 0000000..1654f97 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/ItemMatch.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.machine.internals; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Which items fit somewhere, written as one string: {@code #ns:path} for an item tag, otherwise an + * item ID. Decoding needs no registry access, so it can run at startup. + */ +public record ItemMatch(Either, ResourceLocation> value) { + /** The string form; an empty or malformed ID is a decode error. */ + public static final Codec CODEC = + Codec.STRING.comapFlatMap(ItemMatch::read, ItemMatch::toString); + + /** Whether {@code stack} is in the tag, or is the item. */ + public boolean test(ItemStack stack) { + return value.map( + stack::is, + id -> !stack.isEmpty() && BuiltInRegistries.ITEM.getKey(stack.getItem()).equals(id)); + } + + @Override + public String toString() { + return value.map(tag -> "#" + tag.location(), ResourceLocation::toString); + } + + private static DataResult read(String text) { + boolean tag = text.startsWith("#"); + var id = tag ? text.substring(1) : text; + if (id.isEmpty()) return DataResult.error(() -> "Empty item or tag ID: '" + text + "'"); + return ResourceLocation.read(id) + .map( + location -> + new ItemMatch( + tag + ? Either.left(TagKey.create(Registries.ITEM, location)) + : Either.right(location))); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/MachineParts.java b/src/main/java/zone/moddev/mc/lib/machine/internals/MachineParts.java new file mode 100644 index 0000000..ab66fae --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/MachineParts.java @@ -0,0 +1,222 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import com.google.gson.JsonElement; + +import com.mojang.logging.LogUtils; +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.slf4j.Logger; + +import zone.moddev.mc.lib.machine.stat.StatModifier; + +/** + * Every loaded machine part file, and what each item is as a part. Built from the datapack on each + * reload and {@linkplain #install installed}; machines read {@link #current()}. + * + *

An {@code item} file beats a {@code part} template for that item. When two files claim the + * same item or part, the lower resource ID wins and an error names both. A file that doesn't decode + * or has an unknown key is logged and skipped. An item whose amounts can't be worked out (no + * material for {@code $material.*}, a result that isn't finite, a durability below 1) is not a + * part, logged once. {@link #check} reports the rest when a datapack loads. + */ +public final class MachineParts { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final AtomicInteger GENERATIONS = new AtomicInteger(); + private static final String SPECIAL = "material.special."; + + /** No part files. */ + public static final MachineParts NONE = + new MachineParts(Map.of(), Map.of(), item -> Optional.empty()); + + private static volatile MachineParts current = NONE; + + private final Map byItem; + private final Map byPart; + private final Function> subjects; + private final Map> resolved = new ConcurrentHashMap<>(); + private volatile int generation; + + private record Located(ResourceLocation file, PartDefinition definition) {} + + private MachineParts( + Map byItem, + Map byPart, + Function> subjects) { + this.byItem = byItem; + this.byPart = byPart; + this.subjects = subjects; + } + + /** + * Decodes part files, by resource ID. + * + * @param subjects what each item is; an item it doesn't know is treated as having no part and no + * material + */ + public static MachineParts parse( + Map files, + Function> subjects) { + var byItem = new HashMap(); + var byPart = new HashMap(); + new TreeMap<>(files) + .forEach( + (file, json) -> + PartDefinition.CODEC + .parse(JsonOps.INSTANCE, json) + .ifError( + error -> + LOGGER.error( + "Skipping machine part file {}: {}", file, error.message())) + .ifSuccess( + definition -> { + var located = new Located(file, definition); + var target = definition.target(); + var index = target.left().isPresent() ? byPart : byItem; + var key = target.map(part -> part, item -> item); + var old = index.putIfAbsent(key, located); + if (old != null) + LOGGER.error( + "Machine part files {} and {} both define {}; using {}", + old.file(), + file, + key, + old.file()); + })); + return new MachineParts(Map.copyOf(byItem), Map.copyOf(byPart), subjects); + } + + /** + * Resolves every item now, so an item whose amounts can't be worked out is logged at load instead + * of at first use, and logs what would otherwise do nothing: a file naming an item that doesn't + * exist or a part no item is made from, and a {@code $material.special.} no material defines + * (which counts as 0, so this is only a warning). + * + * @param items every item ID in the game + * @return the messages logged for files, for tests + */ + public List check(Collection items) { + var madeParts = new HashSet(); + var specialKeys = new HashSet(); + for (var item : items) { + resolve(item); + subjects + .apply(item) + .ifPresent( + subject -> { + subject.part().ifPresent(madeParts::add); + subject + .material() + .ifPresent(material -> specialKeys.addAll(material.special().keySet())); + }); + } + var problems = new ArrayList(); + byItem.forEach( + (item, located) -> { + if (items.contains(item)) return; + var problem = "Machine part file " + located.file() + " names unknown item " + item; + LOGGER.error(problem); + problems.add(problem); + }); + byPart.forEach( + (part, located) -> { + if (madeParts.contains(part)) return; + var problem = + "Machine part file " + located.file() + " names part " + part + " but no item is one"; + LOGGER.error(problem); + problems.add(problem); + }); + var files = new ArrayList<>(byItem.values()); + files.addAll(byPart.values()); + for (var located : files) + for (var name : located.definition().variables()) { + if (!name.startsWith(SPECIAL) || specialKeys.contains(name.substring(SPECIAL.length()))) + continue; + var problem = + "Machine part file " + located.file() + " uses $" + name + ", which no material has"; + LOGGER.warn(problem); + problems.add(problem); + } + return problems; + } + + /** The installed part files; {@link #NONE} until the first datapack load. */ + public static MachineParts current() { + return current; + } + + /** Makes {@code parts} current, with a new {@link #generation()}. */ + public static void install(MachineParts parts) { + parts.generation = GENERATIONS.incrementAndGet(); + current = parts; + } + + /** Changes on every {@link #install}, so machines know to recompute their stats. */ + public int generation() { + return generation; + } + + /** What {@code item} is as a part, or empty if it isn't one. */ + public Optional resolve(ResourceLocation item) { + return resolved.computeIfAbsent(item, this::compute); + } + + private Optional compute(ResourceLocation item) { + var subject = + subjects.apply(item).orElse(new PartSubject(item, Optional.empty(), Optional.empty())); + var located = byItem.get(item); + if (located == null && subject.part().isPresent()) located = byPart.get(subject.part().get()); + if (located == null) return Optional.empty(); + var definition = located.definition(); + Function vars = name -> variable(subject, name); + try { + var modifiers = new ArrayList(); + for (var modifier : definition.modifiers()) + modifiers.add( + new StatModifier(modifier.stat(), modifier.op(), modifier.amount().eval(vars))); + var durability = OptionalInt.empty(); + if (definition.durability().isPresent()) { + double points = definition.durability().get().eval(vars); + if (!Double.isFinite(points) || points < 1) + throw new IllegalArgumentException("durability " + points + " is below 1"); + durability = OptionalInt.of((int) Math.min(Integer.MAX_VALUE, Math.floor(points))); + } + return Optional.of(new ResolvedPart(definition.socket(), modifiers, durability)); + } catch (IllegalArgumentException ex) { + LOGGER.error( + "Machine part file {} can't apply to {}: {}", located.file(), item, ex.getMessage()); + return Optional.empty(); + } + } + + private static OptionalDouble variable(PartSubject subject, String name) { + if (subject.material().isEmpty()) return OptionalDouble.empty(); + var material = subject.material().get(); + return switch (name) { + case "material.hardness" -> OptionalDouble.of(material.hardness()); + case "material.strength" -> OptionalDouble.of(material.strength()); + case "material.magic" -> OptionalDouble.of(material.magic()); + case "material.damage" -> OptionalDouble.of(material.damage()); + default -> + name.startsWith(SPECIAL) + ? OptionalDouble.of( + material.special().getOrDefault(name.substring(SPECIAL.length()), 0D)) + : OptionalDouble.empty(); + }; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/PartDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/internals/PartDefinition.java new file mode 100644 index 0000000..e0187be --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/PartDefinition.java @@ -0,0 +1,113 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.machine.expr.Expr; + +/** + * A machine part file from {@code data//mmdlib/machine_part/}. It names either a material part + * ({@code "part": "mmdlib:gear"}, a template for that part of every material) or one item ({@code + * "item": "example:diamond_coated_gear"}), the socket the part goes into, its stat modifiers, and + * optionally a durability. Amounts may use {@code $material.hardness}, {@code strength}, {@code + * magic}, {@code damage} and {@code $material.special.}. + * + * @param target the material part ID on the left, or the item ID on the right + * @param durability wear points before the part breaks; empty for a part that never wears + */ +public record PartDefinition( + Either target, + String socket, + List modifiers, + Optional durability) { + private static final Set MATERIAL_VARIABLES = + Set.of("material.hardness", "material.strength", "material.magic", "material.damage"); + private static final String SPECIAL = "material.special."; + + /** + * The JSON form; exactly one of {@code part} or {@code item}, only material variables, and no + * other keys. + */ + public static final Codec CODEC = + StrictKeys.of( + RecordCodecBuilder.create( + instance -> + instance + .group( + ResourceLocation.CODEC.optionalFieldOf("part").forGetter(Raw::part), + ResourceLocation.CODEC.optionalFieldOf("item").forGetter(Raw::item), + Codec.STRING.fieldOf("socket").forGetter(Raw::socket), + PartModifier.CODEC + .listOf() + .fieldOf("modifiers") + .forGetter(Raw::modifiers), + Expr.CODEC.optionalFieldOf("durability").forGetter(Raw::durability)) + .apply(instance, Raw::new)) + .comapFlatMap(Raw::toDefinition, Raw::of), + "part", + "item", + "socket", + "modifiers", + "durability"); + + /** Copies the modifier list. */ + public PartDefinition { + modifiers = List.copyOf(modifiers); + } + + /** Every variable the amounts and durability use, without {@code $}. */ + public Set variables() { + var names = new TreeSet(); + modifiers.forEach(modifier -> names.addAll(modifier.amount().variables())); + durability.ifPresent(expr -> names.addAll(expr.variables())); + return names; + } + + private record Raw( + Optional part, + Optional item, + String socket, + List modifiers, + Optional durability) { + static Raw of(PartDefinition definition) { + return new Raw( + definition.target.left(), + definition.target.right(), + definition.socket, + definition.modifiers, + definition.durability); + } + + DataResult toDefinition() { + if (part.isPresent() == item.isPresent()) + return DataResult.error(() -> "A machine part file needs exactly one of 'part' or 'item'"); + if (socket.isBlank()) return DataResult.error(() -> "A machine part file needs a socket"); + var definition = + new PartDefinition( + part.>map(Either::left) + .orElseGet(() -> Either.right(item.orElseThrow())), + socket, + modifiers, + durability); + for (var name : definition.variables()) + if (!MATERIAL_VARIABLES.contains(name) + && !(name.startsWith(SPECIAL) && name.length() > SPECIAL.length())) + return DataResult.error( + () -> + "Unknown variable $" + + name + + "; part files may use $material.hardness, strength, magic, damage and" + + " special."); + return DataResult.success(definition); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/PartModifier.java b/src/main/java/zone/moddev/mc/lib/machine/internals/PartModifier.java new file mode 100644 index 0000000..a0b4db8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/PartModifier.java @@ -0,0 +1,31 @@ +package zone.moddev.mc.lib.machine.internals; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +import zone.moddev.mc.lib.machine.expr.Expr; + +/** + * One stat change in a part file, with an amount that may depend on the part's material. JSON: + * {@code {"stat": "speed", "op": "add_multiplied_base", "amount": "0.08 * $material.hardness"}}. + */ +public record PartModifier(String stat, AttributeModifier.Operation op, Expr amount) { + /** The JSON form; {@code op} uses vanilla's attribute operation names. Other keys are errors. */ + public static final Codec CODEC = + StrictKeys.of( + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.STRING.fieldOf("stat").forGetter(PartModifier::stat), + AttributeModifier.Operation.CODEC + .fieldOf("op") + .forGetter(PartModifier::op), + Expr.CODEC.fieldOf("amount").forGetter(PartModifier::amount)) + .apply(instance, PartModifier::new)), + "stat", + "op", + "amount"); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/PartSubject.java b/src/main/java/zone/moddev/mc/lib/machine/internals/PartSubject.java new file mode 100644 index 0000000..6fb4dcc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/PartSubject.java @@ -0,0 +1,16 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.common.types.MaterialProperties; + +/** + * What part files need to know about an item: which material part it is (for templates) and its + * material's stats (for {@code $material.*}). Both are empty for an item MMDLib didn't make. + */ +public record PartSubject( + ResourceLocation item, + Optional part, + Optional material) {} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/ResolvedPart.java b/src/main/java/zone/moddev/mc/lib/machine/internals/ResolvedPart.java new file mode 100644 index 0000000..5f52e58 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/ResolvedPart.java @@ -0,0 +1,18 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.List; +import java.util.OptionalInt; + +import zone.moddev.mc.lib.machine.stat.StatModifier; + +/** + * A part file applied to one item, with every amount worked out from its material. + * + * @param durability wear points before it breaks, at least 1; empty if it never wears + */ +public record ResolvedPart(String socket, List modifiers, OptionalInt durability) { + /** Copies the modifier list. */ + public ResolvedPart { + modifiers = List.copyOf(modifiers); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/SocketDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/internals/SocketDefinition.java new file mode 100644 index 0000000..ad8c420 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/SocketDefinition.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.Optional; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +/** + * A named place for parts inside a machine, such as {@code gears}. JSON: {@code {"count": 2, + * "accepts": "#c:gears", "optional": false}}; {@code count} defaults to 1, {@code accepts} to any + * part made for this socket. + * + * @param count how many parts it holds, 1 to 64 + * @param accepts which items it takes, on top of the part file naming this socket + * @param optional whether the machine runs with positions left empty + */ +public record SocketDefinition(int count, Optional accepts, boolean optional) { + /** The JSON form. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.intRange(1, 64) + .optionalFieldOf("count", 1) + .forGetter(SocketDefinition::count), + ItemMatch.CODEC + .optionalFieldOf("accepts") + .forGetter(SocketDefinition::accepts), + Codec.BOOL + .optionalFieldOf("optional", false) + .forGetter(SocketDefinition::optional)) + .apply(instance, SocketDefinition::new)); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/StrictKeys.java b/src/main/java/zone/moddev/mc/lib/machine/internals/StrictKeys.java new file mode 100644 index 0000000..a01d4dd --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/StrictKeys.java @@ -0,0 +1,40 @@ +package zone.moddev.mc.lib.machine.internals; + +import java.util.List; +import java.util.TreeSet; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Decoder; +import com.mojang.serialization.DynamicOps; + +/** Makes a key a codec doesn't read a decode error, so a misspelt optional key can't go unseen. */ +final class StrictKeys { + private StrictKeys() {} + + /** {@code codec}, refusing any JSON object with a key outside {@code keys}. */ + static Codec of(Codec codec, String... keys) { + var known = new TreeSet<>(List.of(keys)); + return Codec.of( + codec, + new Decoder<>() { + @Override + public DataResult> decode(DynamicOps ops, O input) { + var map = ops.getMap(input).result(); + if (map.isPresent()) { + var unknown = + map.get() + .entries() + .map(entry -> ops.getStringValue(entry.getFirst()).result().orElse("?")) + .filter(key -> !known.contains(key)) + .sorted() + .toList(); + if (!unknown.isEmpty()) + return DataResult.error(() -> "Unknown keys " + unknown + "; expected " + known); + } + return codec.decode(ops, input); + } + }); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/internals/SwapRule.java b/src/main/java/zone/moddev/mc/lib/machine/internals/SwapRule.java new file mode 100644 index 0000000..1787b29 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/internals/SwapRule.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.lib.machine.internals; + +import com.mojang.serialization.Codec; + +import net.minecraft.util.StringRepresentable; + +/** When a player may install or remove parts. */ +public enum SwapRule implements StringRepresentable { + /** Any time, even while the machine runs; progress is kept. */ + ALWAYS("always"), + /** Only while the machine is not running. The default. */ + WHEN_IDLE("when_idle"), + /** Never: parts are fixed once installed. */ + NEVER("never"); + + /** The JSON form: {@code always}, {@code when_idle} or {@code never}. */ + public static final Codec CODEC = StringRepresentable.fromEnum(SwapRule::values); + + private final String name; + + SwapRule(String name) { + this.name = name; + } + + /** Whether a swap is allowed in this state. */ + public boolean allows(boolean running) { + return switch (this) { + case ALWAYS -> true; + case WHEN_IDLE -> !running; + case NEVER -> false; + }; + } + + @Override + public String getSerializedName() { + return name; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java new file mode 100644 index 0000000..518b378 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/EnergyGroup.java @@ -0,0 +1,16 @@ +package zone.moddev.mc.lib.machine.inventory; + +/** An energy buffer in a {@link MachineInventory}; amounts in FE, as {@code long}. */ +public non-sealed interface EnergyGroup extends SlotGroup { + /** The energy stored. */ + long stored(); + + /** The most it can store. */ + long capacity(); + + /** Takes up to {@code amount}; returns how much was taken. */ + long extract(long amount, MachineTx tx); + + /** Stores up to {@code amount}; returns how much was stored. */ + long insert(long amount, MachineTx tx); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java new file mode 100644 index 0000000..fc5dbf4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.List; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; + +/** Fluid tanks in a {@link MachineInventory}; amounts in mB. */ +public non-sealed interface FluidGroup extends SlotGroup { + /** The number of tanks. */ + int tanks(); + + /** What {@code tank} holds; {@link FluidAmount#EMPTY} outside the group. */ + FluidAmount peek(int tank); + + /** Drains up to {@code amount} mB from {@code tank}; returns how much was drained. */ + int extract(int tank, int amount, MachineTx tx); + + /** Fills {@code fluid}, into tanks of that fluid first and then empty ones; returns mB filled. */ + int insert(FluidAmount fluid, MachineTx tx); + + /** Whether all of {@code fluids} would fit together. Changes nothing. */ + boolean fits(List fluids); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java new file mode 100644 index 0000000..fc7c2e9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.List; + +import net.minecraft.world.item.ItemStack; + +/** Item slots in a {@link MachineInventory}. */ +public non-sealed interface ItemGroup extends SlotGroup { + /** The number of slots. */ + int size(); + + /** A copy of the stack in {@code slot}; empty outside the group. */ + ItemStack peek(int slot); + + /** Takes up to {@code count} items from {@code slot}; returns how many were taken. */ + int extract(int slot, int count, MachineTx tx); + + /** + * Inserts {@code stack}, onto matching stacks first and then into empty slots; returns how many + * fitted. {@code stack} itself is not changed. + */ + int insert(ItemStack stack, MachineTx tx); + + /** Whether all of {@code stacks} would fit together. Works on copies; changes nothing. */ + boolean fits(List stacks); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineInventory.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineInventory.java new file mode 100644 index 0000000..64b3e78 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineInventory.java @@ -0,0 +1,70 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * A machine's storages, grouped under the keys its recipes use. Recipes join on keys: a recipe + * naming a key the machine lacks never matches it. Immutable once built. + * + *

{@code
+ * var inventory = MachineInventory.builder()
+ *     .group("input", StorageGroups.items(items, 0))
+ *     .group("output", StorageGroups.items(items, 1, 2))
+ *     .group("energy", StorageGroups.energy(buffer))
+ *     .build();
+ * }
+ */ +public final class MachineInventory { + private final Map groups; + + private MachineInventory(Map groups) { + this.groups = Collections.unmodifiableMap(new LinkedHashMap<>(groups)); + } + + /** A builder with no groups. */ + public static Builder builder() { + return new Builder(); + } + + /** The group under {@code key}, if any. */ + public Optional group(String key) { + return Optional.ofNullable(groups.get(key)); + } + + /** Every group key, in the order added. */ + public Set keys() { + return groups.keySet(); + } + + /** Starts a transaction for changes to these groups. */ + public MachineTx begin() { + return new SnapshotTx(); + } + + /** Collects groups by key. */ + public static final class Builder { + private final Map groups = new LinkedHashMap<>(); + + private Builder() {} + + /** + * Adds {@code group} under {@code key}. + * + * @throws IllegalArgumentException if {@code key} is already used + */ + public Builder group(String key, SlotGroup group) { + if (groups.putIfAbsent(key, group) != null) + throw new IllegalArgumentException("Duplicate machine group " + key); + return this; + } + + /** The inventory. */ + public MachineInventory build() { + return new MachineInventory(groups); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineTx.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineTx.java new file mode 100644 index 0000000..8b427a1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineTx.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.function.Supplier; + +/** + * Changes to a machine's groups that land together or not at all. Closing without {@link #commit()} + * undoes everything done under this transaction: + * + *
{@code
+ * try (MachineTx tx = inventory.begin()) {
+ *   if (output.insert(result, tx) < result.getCount()) return; // closed without commit: undone
+ *   tx.commit();
+ * }
+ * }
+ */ +public interface MachineTx extends AutoCloseable { + /** + * Keeps every change made under this transaction. + * + * @throws IllegalStateException if already committed or closed + */ + void commit(); + + /** Ends the transaction, undoing its changes unless it was committed. Safe to call twice. */ + @Override + void close(); + + /** + * Called by a group before it changes the state identified by {@code key} (one slot, tank or + * buffer). The first time a key is seen, {@code capture} runs and returns how to restore that + * state; later calls for the same key do nothing. + * + * @throws IllegalStateException if already committed or closed + */ + void track(Object key, Supplier capture); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotGroup.java new file mode 100644 index 0000000..be7a711 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/SlotGroup.java @@ -0,0 +1,9 @@ +package zone.moddev.mc.lib.machine.inventory; + +/** + * Some of a machine's slots, tanks or energy, under one key that recipes name ({@code input}, + * {@code water}, {@code energy}). Groups work as the machine itself: slot roles and face modes, + * which limit automation, don't apply. Indexes are 0-based within the group; an index outside it + * takes or inserts nothing. + */ +public sealed interface SlotGroup permits ItemGroup, FluidGroup, EnergyGroup {} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/SnapshotTx.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/SnapshotTx.java new file mode 100644 index 0000000..166edb6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/SnapshotTx.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A {@link MachineTx} that copies each slot, tank or buffer the first time it is touched and puts + * the copies back, newest first, if closed without commit. Exact for storages the machine owns. Not + * nested and not thread-safe. + */ +public final class SnapshotTx implements MachineTx { + private final Map undo = new LinkedHashMap<>(); + private boolean open = true; + private boolean committed; + + @Override + public void commit() { + checkOpen(); + committed = true; + open = false; + undo.clear(); + } + + @Override + public void close() { + if (!open) return; + open = false; + if (committed) return; + var steps = new ArrayList<>(undo.values()); + undo.clear(); + for (int i = steps.size() - 1; i >= 0; i--) steps.get(i).run(); + } + + @Override + public void track(Object key, Supplier capture) { + checkOpen(); + if (!undo.containsKey(key)) undo.put(key, capture.get()); + } + + private void checkOpen() { + if (!open) + throw new IllegalStateException( + committed ? "Machine transaction already committed" : "Machine transaction closed"); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineComponents.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineComponents.java new file mode 100644 index 0000000..36bf21c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineComponents.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.function.Supplier; + +import com.mojang.serialization.Codec; + +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.codec.ByteBufCodecs; + +import net.neoforged.neoforge.registries.DeferredRegister; + +import zone.moddev.mc.lib.MMDLib; + +/** + * The machine module's item data components; {@link #TYPES} is attached to the mod bus by MMDLib. + */ +public final class MachineComponents { + /** MMDLib's machine data-component register. */ + public static final DeferredRegister.DataComponents TYPES = + DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MMDLib.MODID); + + /** + * {@code mmdlib:part_wear}: wear points an installed part has taken. The part breaks when this + * reaches its part file's durability. + */ + public static final Supplier> PART_WEAR = + TYPES.registerComponentType( + "part_wear", + builder -> builder.persistent(Codec.INT).networkSynchronized(ByteBufCodecs.VAR_INT)); + + private MachineComponents() {} +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInternals.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInternals.java new file mode 100644 index 0000000..74192e0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInternals.java @@ -0,0 +1,281 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.Containers; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.internals.InternalsDefinition; +import zone.moddev.mc.lib.machine.internals.MachineParts; +import zone.moddev.mc.lib.machine.internals.ResolvedPart; +import zone.moddev.mc.lib.machine.stat.MachineStats; +import zone.moddev.mc.lib.machine.stat.StatModifier; + +/** + * A machine's internals at runtime: the parts in its sockets and the stats they give. Owned by the + * block entity, which saves it with {@link #save} and {@link #load} and passes whether the machine + * is running to the swap methods. + * + *

{@link #sockets()} has one slot per socket position, in {@link InternalsDefinition}'s layout. + * Each slot holds one part that resolves to that socket and matches its {@code accepts}; automation + * can't reach them ({@link Role#MANUAL}). Items already installed stay installed when part files + * change; stats only count those that still resolve to their socket. + */ +public final class MachineInternals { + private static final String SOCKETS = "sockets"; + private static final String ORPHANS = "orphans"; + + private final InternalsDefinition definition; + private final Supplier parts; + private final ItemStorage sockets; + private final List orphans = new ArrayList<>(); + private Runnable onChange = () -> {}; + private MachineStats stats; + private MachineParts statsParts; + private int statsGeneration; + private boolean loading; + + /** + * Empty internals for {@code definition}. + * + * @param parts the part files to read, normally {@code MachineParts::current} + */ + public MachineInternals(InternalsDefinition definition, Supplier parts) { + this.definition = definition; + this.parts = parts; + sockets = new ItemStorage(definition.slotCount()); + for (int slot = 0; slot < sockets.getSlots(); slot++) { + int position = slot; + sockets.role(Role.MANUAL, slot).limit(1, slot).filter(stack -> fits(position, stack), slot); + } + sockets.onChange(this::changed); + } + + /** Runs after every change, typically the block entity's {@code setChanged}. */ + public MachineInternals onChange(Runnable onChange) { + this.onChange = onChange; + return this; + } + + /** The socket slots, for a menu; the block entity works through the methods below instead. */ + public ItemStorage sockets() { + return sockets; + } + + /** The definition these internals were built for. */ + public InternalsDefinition definition() { + return definition; + } + + /** The stats from the installed parts; recomputed after a change or a part-file reload. */ + public MachineStats stats() { + var current = parts.get(); + if (stats == null || statsParts != current || statsGeneration != current.generation()) { + stats = MachineStats.compute(definition.stats(), modifiers(current)); + statsParts = current; + statsGeneration = current.generation(); + } + return stats; + } + + /** The stats from the installed parts plus {@code extra}, such as upgrade cards. */ + public MachineStats stats(Collection extra) { + var all = modifiers(parts.get()); + all.addAll(extra); + return MachineStats.compute(definition.stats(), all); + } + + /** The first required socket, in slot order, with an empty position; empty when ready. */ + public Optional missing() { + for (var entry : definition.sockets().entrySet()) { + if (entry.getValue().optional()) continue; + for (int slot : definition.slots(entry.getKey())) + if (sockets.getStackInSlot(slot).isEmpty()) return Optional.of(entry.getKey()); + } + return Optional.empty(); + } + + /** Whether a player holding {@code held} may open the engine view. */ + public boolean canOpen(ItemStack held) { + return definition.openWith().map(match -> match.test(held)).orElse(true); + } + + /** Whether parts may be installed or removed now. */ + public boolean canSwap(boolean running) { + return definition.swap().allows(running); + } + + /** + * Installs one item of {@code stack} at position {@code index} of {@code socket}. Returns what + * was not installed: all of it if swapping isn't allowed, the position doesn't exist or is taken, + * or the item isn't a part for this socket. + */ + public ItemStack install(String socket, int index, ItemStack stack, boolean running) { + int slot = slot(socket, index); + if (slot < 0 || !canSwap(running)) return stack; + return sockets.insertItem(slot, stack, false); + } + + /** Takes the part out of position {@code index} of {@code socket}; empty if refused or empty. */ + public ItemStack remove(String socket, int index, boolean running) { + int slot = slot(socket, index); + if (slot < 0 || !canSwap(running)) return ItemStack.EMPTY; + return sockets.extractItem(slot, 1, false); + } + + /** + * Adds {@code points} of wear to every part in {@code socket} that has a durability. A part whose + * wear reaches its durability is destroyed and its position left empty. + * + * @return how many parts broke + * @throws IllegalArgumentException if {@code points} is negative + */ + public int wear(String socket, int points) { + if (points < 0) throw new IllegalArgumentException("Negative wear: " + points); + int broke = 0; + var current = parts.get(); + for (int slot : definition.slots(socket)) { + var stack = sockets.getStackInSlot(slot); + if (stack.isEmpty()) continue; + var durability = + resolve(current, stack) + .flatMap( + part -> + part.durability().isPresent() + ? Optional.of(part.durability().getAsInt()) + : Optional.empty()); + if (durability.isEmpty()) continue; + int wear = stack.getOrDefault(MachineComponents.PART_WEAR.get(), 0) + points; + if (wear >= durability.get()) { + sockets.setStackInSlot(slot, ItemStack.EMPTY); + broke++; + } else { + var worn = stack.copy(); + worn.set(MachineComponents.PART_WEAR.get(), wear); + sockets.setStackInSlot(slot, worn); + } + } + return broke; + } + + /** Parts from a save that no longer have a position; kept until {@link #dropAll}. */ + public List orphans() { + return List.copyOf(orphans); + } + + /** + * Saves the parts by socket name ({@code {"sockets": {"gears": [part, ...]}, "orphans": [...]}}), + * so a later change to the socket layout can't move a part into the wrong socket. + */ + public CompoundTag save(HolderLookup.Provider provider) { + var saved = new CompoundTag(); + for (var socket : definition.sockets().keySet()) { + var list = new ListTag(); + for (int slot : definition.slots(socket)) + list.add(saveStack(provider, sockets.getStackInSlot(slot))); + saved.put(socket, list); + } + var tag = new CompoundTag(); + tag.put(SOCKETS, saved); + if (!orphans.isEmpty()) { + var list = new ListTag(); + orphans.forEach(stack -> list.add(saveStack(provider, stack))); + tag.put(ORPHANS, list); + } + return tag; + } + + /** + * Loads parts saved by {@link #save}. Parts whose socket is gone, or whose position is past the + * socket's count, become orphans instead of being lost. + */ + public void load(HolderLookup.Provider provider, CompoundTag tag) { + loading = true; + try { + for (int slot = 0; slot < sockets.getSlots(); slot++) + sockets.setStackInSlot(slot, ItemStack.EMPTY); + orphans.clear(); + var saved = tag.getCompound(SOCKETS); + for (var socket : saved.getAllKeys()) { + var list = saved.getList(socket, Tag.TAG_COMPOUND); + var slots = + definition.sockets().containsKey(socket) + ? definition.slots(socket) + : List.of(); + for (int i = 0; i < list.size(); i++) { + var stack = ItemStack.parseOptional(provider, list.getCompound(i)); + if (stack.isEmpty()) continue; + if (i < slots.size()) sockets.setStackInSlot(slots.get(i), stack); + else orphans.add(stack); + } + } + var list = tag.getList(ORPHANS, Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + var stack = ItemStack.parseOptional(provider, list.getCompound(i)); + if (!stack.isEmpty()) orphans.add(stack); + } + } finally { + loading = false; + } + changed(); + } + + /** Drops every part and orphan at {@code pos} and empties the internals. */ + public void dropAll(Level level, BlockPos pos) { + sockets.dropAll(level, pos); + for (var stack : orphans) + Containers.dropItemStack(level, pos.getX(), pos.getY(), pos.getZ(), stack); + orphans.clear(); + } + + private boolean fits(int slot, ItemStack stack) { + var socket = definition.socketAt(slot); + var accepts = definition.sockets().get(socket).accepts(); + return resolve(parts.get(), stack).filter(part -> part.socket().equals(socket)).isPresent() + && accepts.map(match -> match.test(stack)).orElse(true); + } + + private List modifiers(MachineParts current) { + var modifiers = new ArrayList(); + for (int slot = 0; slot < sockets.getSlots(); slot++) { + var socket = definition.socketAt(slot); + resolve(current, sockets.getStackInSlot(slot)) + .filter(part -> part.socket().equals(socket)) + .ifPresent(part -> modifiers.addAll(part.modifiers())); + } + return modifiers; + } + + private static Optional resolve(MachineParts current, ItemStack stack) { + if (stack.isEmpty()) return Optional.empty(); + return current.resolve(BuiltInRegistries.ITEM.getKey(stack.getItem())); + } + + private int slot(String socket, int index) { + if (!definition.sockets().containsKey(socket)) return -1; + var slots = definition.slots(socket); + return index >= 0 && index < slots.size() ? slots.get(index) : -1; + } + + private void changed() { + stats = null; + if (!loading) onChange.run(); + } + + private static Tag saveStack(HolderLookup.Provider provider, ItemStack stack) { + return stack.isEmpty() ? new CompoundTag() : stack.save(provider); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachinePartsLoader.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachinePartsLoader.java new file mode 100644 index 0000000..a0637d3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachinePartsLoader.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.Map; +import java.util.Optional; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; + +import net.neoforged.neoforge.event.AddReloadListenerEvent; + +import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.common.types.MMDItem; +import zone.moddev.mc.lib.common.types.MMDMaterial; +import zone.moddev.mc.lib.machine.internals.MachineParts; +import zone.moddev.mc.lib.machine.internals.PartSubject; + +/** + * Loads machine part files from {@code data//mmdlib/machine_part/*.json} on every datapack load + * and installs them as {@link MachineParts#current()}. + */ +public final class MachinePartsLoader extends SimpleJsonResourceReloadListener { + private MachinePartsLoader() { + super(new Gson(), "mmdlib/machine_part"); + } + + /** Adds the loader to server datapack reloads; registered on the game bus by MMDLib. */ + public static void register(AddReloadListenerEvent event) { + event.addListener(new MachinePartsLoader()); + } + + /** + * What MMDLib knows about {@code item}: its material part (the item template, such as {@code + * mmdlib:gear}) and its material's stats. Both are empty for items MMDLib didn't make. + */ + public static Optional subject(ResourceLocation item) { + return Optional.of( + new PartSubject( + item, + Optional.ofNullable(MMDLib.itemDefinitions().get(item)).map(MMDItem::template), + Optional.ofNullable(MMDLib.itemMaterials().get(item)).map(MMDMaterial::properties))); + } + + @Override + protected void apply( + Map files, ResourceManager manager, ProfilerFiller profiler) { + var parts = MachineParts.parse(files, MachinePartsLoader::subject); + parts.check(BuiltInRegistries.ITEM.keySet()); + MachineParts.install(parts); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java new file mode 100644 index 0000000..334b3b4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -0,0 +1,247 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.world.item.ItemStack; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.EnergyGroup; +import zone.moddev.mc.lib.machine.inventory.FluidGroup; +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineTx; + +/** + * Machine groups over MMDLib's storages. They work as the block entity does: items through {@link + * ItemStorage#insertResult} and the storage's own extract (so slot roles and filters don't apply), + * fluids through {@link FluidStorage#tank}, energy ignoring the buffer's transfer rates but not its + * capacity. Transactions copy a slot, tank or buffer the first time they change it. + */ +public final class StorageGroups { + private StorageGroups() {} + + /** Slots {@code slots} of {@code storage}, as group slots 0, 1, ... */ + public static ItemGroup items(ItemStorage storage, int... slots) { + for (int slot : slots) + if (slot < 0 || slot >= storage.getSlots()) + throw new IndexOutOfBoundsException( + "No slot " + slot + " in a storage of " + storage.getSlots()); + return new ItemSlots(storage, slots.clone()); + } + + /** Tanks {@code tanks} of {@code storage}, as group tanks 0, 1, ... */ + public static FluidGroup fluids(FluidStorage storage, int... tanks) { + for (int tank : tanks) + if (tank < 0 || tank >= storage.getTanks()) + throw new IndexOutOfBoundsException( + "No tank " + tank + " in a storage of " + storage.getTanks()); + return new FluidTanks(storage, tanks.clone()); + } + + /** All of {@code buffer}. */ + public static EnergyGroup energy(EnergyBuffer buffer) { + return new EnergyStore(buffer); + } + + /** A transaction key for one slot or tank of one storage; storages compare by identity. */ + private record Key(Object storage, int index) {} + + private record ItemSlots(ItemStorage storage, int[] slots) implements ItemGroup { + @Override + public int size() { + return slots.length; + } + + @Override + public ItemStack peek(int slot) { + return valid(slot) ? storage.getStackInSlot(slots[slot]).copy() : ItemStack.EMPTY; + } + + @Override + public int extract(int slot, int count, MachineTx tx) { + if (!valid(slot) || count <= 0) return 0; + int index = slots[slot]; + if (storage.getStackInSlot(index).isEmpty()) return 0; + track(index, tx); + return storage.extractItem(index, count, false).getCount(); + } + + @Override + public int insert(ItemStack stack, MachineTx tx) { + var remaining = stack.copy(); + for (boolean merging : new boolean[] {true, false}) + for (int index : slots) { + if (remaining.isEmpty()) break; + var held = storage.getStackInSlot(index); + if (merging == held.isEmpty()) continue; + int fits = remaining.getCount() - storage.insertResult(index, remaining, true).getCount(); + if (fits <= 0) continue; + track(index, tx); + remaining = storage.insertResult(index, remaining, false); + } + return stack.getCount() - remaining.getCount(); + } + + @Override + public boolean fits(List stacks) { + var held = new ArrayList(); + for (int index : slots) held.add(storage.getStackInSlot(index).copy()); + for (var stack : stacks) { + int left = stack.getCount(); + for (boolean merging : new boolean[] {true, false}) + for (int i = 0; i < slots.length && left > 0; i++) { + var slot = held.get(i); + if (merging == slot.isEmpty()) continue; + if (merging && !ItemStack.isSameItemSameComponents(slot, stack)) continue; + int limit = Math.min(storage.getSlotLimit(slots[i]), stack.getMaxStackSize()); + int moved = Math.min(left, limit - slot.getCount()); + if (moved <= 0) continue; + if (slot.isEmpty()) held.set(i, stack.copyWithCount(moved)); + else slot.grow(moved); + left -= moved; + } + if (left > 0) return false; + } + return true; + } + + private boolean valid(int slot) { + return slot >= 0 && slot < slots.length; + } + + private void track(int index, MachineTx tx) { + tx.track( + new Key(storage, index), + () -> { + var before = storage.getStackInSlot(index).copy(); + return () -> storage.setStackInSlot(index, before); + }); + } + } + + private record FluidTanks(FluidStorage storage, int[] indexes) implements FluidGroup { + @Override + public int tanks() { + return indexes.length; + } + + @Override + public FluidAmount peek(int tank) { + return valid(tank) ? amount(storage.tank(indexes[tank]).getFluid()) : FluidAmount.EMPTY; + } + + @Override + public int extract(int tank, int amount, MachineTx tx) { + if (!valid(tank) || amount <= 0) return 0; + var handler = storage.tank(indexes[tank]); + if (handler.isEmpty()) return 0; + track(indexes[tank], tx); + return handler.drain(amount, FluidAction.EXECUTE).getAmount(); + } + + @Override + public int insert(FluidAmount fluid, MachineTx tx) { + if (fluid.isEmpty()) return 0; + var remaining = stack(fluid); + for (boolean merging : new boolean[] {true, false}) + for (int index : indexes) { + if (remaining.isEmpty()) break; + var handler = storage.tank(index); + if (merging == handler.isEmpty()) continue; + if (handler.fill(remaining, FluidAction.SIMULATE) <= 0) continue; + track(index, tx); + remaining.shrink(handler.fill(remaining, FluidAction.EXECUTE)); + } + return fluid.amount() - remaining.getAmount(); + } + + @Override + public boolean fits(List fluids) { + var held = new ArrayList(); + for (int index : indexes) held.add(storage.tank(index).getFluid().copy()); + for (var fluid : fluids) { + if (fluid.isEmpty()) continue; + var stack = stack(fluid); + int left = fluid.amount(); + for (boolean merging : new boolean[] {true, false}) + for (int i = 0; i < indexes.length && left > 0; i++) { + var tank = held.get(i); + if (merging == tank.isEmpty()) continue; + if (merging && !FluidStack.isSameFluidSameComponents(tank, stack)) continue; + if (!merging && !storage.tank(indexes[i]).isFluidValid(stack)) continue; + int moved = Math.min(left, storage.tank(indexes[i]).getCapacity() - tank.getAmount()); + if (moved <= 0) continue; + if (tank.isEmpty()) held.set(i, stack.copyWithAmount(moved)); + else tank.grow(moved); + left -= moved; + } + if (left > 0) return false; + } + return true; + } + + private boolean valid(int tank) { + return tank >= 0 && tank < indexes.length; + } + + private void track(int index, MachineTx tx) { + tx.track( + new Key(storage, index), + () -> { + var before = storage.tank(index).getFluid().copy(); + return () -> storage.tank(index).setFluid(before); + }); + } + + private static FluidStack stack(FluidAmount fluid) { + return new FluidStack(fluid.fluid(), fluid.amount(), fluid.components()); + } + + private static FluidAmount amount(FluidStack stack) { + return stack.isEmpty() + ? FluidAmount.EMPTY + : new FluidAmount(stack.getFluidHolder(), stack.getComponentsPatch(), stack.getAmount()); + } + } + + private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { + @Override + public long stored() { + return buffer.stored(); + } + + @Override + public long capacity() { + return buffer.capacity(); + } + + @Override + public long extract(long amount, MachineTx tx) { + if (buffer.extract(amount, true) <= 0) return 0; + track(tx); + return buffer.extract(amount, false); + } + + @Override + public long insert(long amount, MachineTx tx) { + if (buffer.insert(amount, true) <= 0) return 0; + track(tx); + return buffer.insert(amount, false); + } + + private void track(MachineTx tx) { + tx.track( + buffer, + () -> { + long before = buffer.stored(); + return () -> buffer.setStored(before); + }); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/package-info.java b/src/main/java/zone/moddev/mc/lib/machine/package-info.java new file mode 100644 index 0000000..292f016 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/package-info.java @@ -0,0 +1,9 @@ +/** + * The machine module: internals (parts in sockets that change a machine's stats), machine + * ingredients across items, fluids and energy, slot groups with transactions, and recipe matching. + * + *

Everything here except {@code machine.neoforge} imports only vanilla, DataFixerUpper and Gson, + * so it can be shared by every loader build; {@code verifyMachineImports} enforces that. {@code + * machine.neoforge} adapts MMDLib's storages and registers the module's game objects. + */ +package zone.moddev.mc.lib.machine; diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java new file mode 100644 index 0000000..56f1bce --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java @@ -0,0 +1,75 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntPredicate; + +/** + * Shares what a group's slots or tanks hold between a recipe's ingredients. Each new claim may move + * an earlier claim onto another entry it also accepts, so a claim only falls short when no way of + * sharing could meet every claim so far, and the order of the ingredients doesn't matter. + */ +public final class InputClaims { + private final long[] held; + private final long[] free; + private final List accepts = new ArrayList<>(); + private final List claims = new ArrayList<>(); + + /** + * @param held what each entry holds + */ + public InputClaims(long[] held) { + this.held = held.clone(); + free = held.clone(); + } + + /** + * Claims up to {@code need} from entries {@code matches} accepts. + * + * @return what's still needed; 0 when the claim is met + */ + public long claim(long need, IntPredicate matches) { + var accepted = new boolean[held.length]; + for (int entry = 0; entry < held.length; entry++) + accepted[entry] = held[entry] > 0 && matches.test(entry); + accepts.add(accepted); + claims.add(new long[held.length]); + int part = claims.size() - 1; + while (need > 0) { + long found = find(part, need, new boolean[held.length]); + if (found == 0) break; + need -= found; + } + return need; + } + + /** The total claimed from {@code entry}. */ + public long claimed(int entry) { + return held[entry] - free[entry]; + } + + /** + * Finds up to {@code amount} for {@code part} on one entry, from what's free there and by moving + * other parts' claims elsewhere; returns how much it found. + */ + private long find(int part, long amount, boolean[] visited) { + for (int entry = 0; entry < held.length; entry++) { + if (!accepts.get(part)[entry] || visited[entry]) continue; + visited[entry] = true; + long found = Math.min(free[entry], amount); + free[entry] -= found; + for (int other = 0; other < claims.size() && found < amount; other++) { + long claimed = claims.get(other)[entry]; + if (other == part || claimed == 0) continue; + long moved = find(other, Math.min(amount - found, claimed), visited); + claims.get(other)[entry] -= moved; + found += moved; + } + if (found > 0) { + claims.get(part)[entry] += found; + return found; + } + } + return 0; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java new file mode 100644 index 0000000..9faccbe --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java @@ -0,0 +1,107 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.List; +import java.util.stream.Stream; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.MapLike; +import com.mojang.serialization.RecordBuilder; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; + +/** + * Something a recipe produces, possibly only some of the time. JSON is {@code {"item": {"id": ..., + * "count": 2}, "chance": 0.25}} or {@code {"fluid": {"id": ..., "amount": 250}}}; {@code chance} + * defaults to 1 and must be above 0 and at most 1. + */ +public sealed interface MachineProduct + permits MachineProduct.ItemProduct, MachineProduct.FluidProduct { + /** The chance this product is made on a run, in (0, 1]. */ + float chance(); + + /** The JSON form: exactly one of {@code item} or {@code fluid}. */ + Codec CODEC = new Dispatch().codec(); + + /** An item stack. */ + record ItemProduct(ItemStack stack, float chance) implements MachineProduct { + static final MapCodec CODEC = + RecordCodecBuilder.mapCodec( + instance -> + instance + .group( + ItemStack.CODEC.fieldOf("item").forGetter(ItemProduct::stack), + chanceField().forGetter(ItemProduct::chance)) + .apply(instance, ItemProduct::new)); + + /** Copies the stack. */ + public ItemProduct { + stack = stack.copy(); + } + + /** A copy of the stack. */ + @Override + public ItemStack stack() { + return stack.copy(); + } + } + + /** A fluid amount. */ + record FluidProduct(FluidAmount fluid, float chance) implements MachineProduct { + static final MapCodec CODEC = + RecordCodecBuilder.mapCodec( + instance -> + instance + .group( + FluidAmount.CODEC.fieldOf("fluid").forGetter(FluidProduct::fluid), + chanceField().forGetter(FluidProduct::chance)) + .apply(instance, FluidProduct::new)); + } + + private static MapCodec chanceField() { + return Codec.FLOAT + .validate( + chance -> + chance > 0 && chance <= 1 + ? DataResult.success(chance) + : DataResult.error(() -> "Product chance must be in (0, 1]: " + chance)) + .optionalFieldOf("chance", 1F); + } + + /** Picks the record by its key; none, or both, is an error naming the two keys. */ + final class Dispatch extends MapCodec { + private static final List KEYS = List.of("item", "fluid"); + + private Dispatch() {} + + @Override + public Stream keys(DynamicOps ops) { + return KEYS.stream().map(ops::createString); + } + + @Override + public DataResult decode(DynamicOps ops, MapLike input) { + var present = KEYS.stream().filter(key -> input.get(key) != null).toList(); + if (present.size() != 1) + return DataResult.error( + () -> "A machine product needs exactly one of 'item' or 'fluid'; found " + present); + return present.getFirst().equals("item") + ? ItemProduct.CODEC.decode(ops, input).map(product -> product) + : FluidProduct.CODEC.decode(ops, input).map(product -> product); + } + + @Override + public RecordBuilder encode( + MachineProduct input, DynamicOps ops, RecordBuilder prefix) { + return switch (input) { + case ItemProduct product -> ItemProduct.CODEC.encode(product, ops, prefix); + case FluidProduct product -> FluidProduct.CODEC.encode(product, ops, prefix); + }; + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java new file mode 100644 index 0000000..ef45fbc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; + +/** + * What a machine consumes and produces, by group key, and how long it takes. JSON: {@code + * {"inputs": {"input": [...], "energy": [{"energy": 4000}]}, "outputs": {"output": [...]}, "time": + * 200}}. Registered as a recipe type in a later phase; for now the data shape and matching only. + * + * @param time ticks, at least 1 + */ +public record MachineRecipe( + Map> inputs, + Map> outputs, + int time) { + /** The JSON form; at least one input is required. */ + public static final MapCodec CODEC = + RecordCodecBuilder.mapCodec( + instance -> + instance + .group( + Codec.unboundedMap(Codec.STRING, MachineIngredient.CODEC.listOf()) + .fieldOf("inputs") + .forGetter(MachineRecipe::inputs), + Codec.unboundedMap(Codec.STRING, MachineProduct.CODEC.listOf()) + .optionalFieldOf("outputs", Map.of()) + .forGetter(MachineRecipe::outputs), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("time") + .forGetter(MachineRecipe::time)) + .apply(instance, MachineRecipe::new)) + .validate( + recipe -> + recipe.inputs.values().stream().allMatch(List::isEmpty) + ? DataResult.error(() -> "A machine recipe needs at least one input") + : DataResult.success(recipe)); + + /** Copies the maps and lists. */ + public MachineRecipe { + inputs = copy(inputs); + outputs = copy(outputs); + } + + private static Map> copy(Map> map) { + var copy = new LinkedHashMap>(); + map.forEach((key, list) -> copy.put(key, List.copyOf(list))); + return Collections.unmodifiableMap(copy); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MatchResult.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MatchResult.java new file mode 100644 index 0000000..48bb953 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MatchResult.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.lib.machine.recipe; + +import javax.annotation.Nullable; + +/** + * Whether a recipe can run in a machine, and if not, where it failed, for screens and debugging. + * + * @param group the group key that failed; null when {@link #ok()} + * @param index the position in that group's input or output list; -1 when not about one entry + */ +public record MatchResult(Reason reason, @Nullable String group, int index) { + /** The recipe can run. */ + public static final MatchResult OK = new MatchResult(Reason.OK, null, -1); + + /** Why a recipe can or can't run. */ + public enum Reason { + /** Everything is present and every product has room. */ + OK, + /** The recipe names a group the machine doesn't have. */ + NO_GROUP, + /** The group holds a different kind: items asked of a fluid group, and so on. */ + WRONG_KIND, + /** The group doesn't hold enough of an input. */ + MISSING_INPUT, + /** The group has no room for a product. */ + NO_ROOM + } + + /** Whether the recipe can run. */ + public boolean ok() { + return reason == Reason.OK; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java new file mode 100644 index 0000000..f67b9ad --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -0,0 +1,195 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.EnergyPart; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.FluidPart; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.ItemPart; +import zone.moddev.mc.lib.machine.inventory.EnergyGroup; +import zone.moddev.mc.lib.machine.inventory.FluidGroup; +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.inventory.MachineTx; +import zone.moddev.mc.lib.machine.inventory.SlotGroup; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.FluidProduct; +import zone.moddev.mc.lib.machine.recipe.MachineProduct.ItemProduct; + +/** + * Matches {@link MachineRecipe}s against a {@link MachineInventory}, and runs their inputs and + * outputs all or nothing. Groups are joined by key. + * + *

    + *
  • Item and fluid parts in one group share its slots and tanks through {@link InputClaims}: + * nothing counts twice, and a recipe matches whenever some sharing meets every part, whatever + * order they're listed in. + *
  • Fluids are totalled across the group's tanks of fluids in the part's set. + *
  • Energy only needs one tick's share to start, {@code ceil(amount / time)}, because it is + * drawn while the recipe runs. + *
+ */ +public final class RecipeMatcher { + private RecipeMatcher() {} + + /** + * Whether {@code recipe} can start: every input present and room for every product, counting + * chance products as certain. Changes nothing. + */ + public static MatchResult check(MachineRecipe recipe, MachineInventory inventory) { + for (var entry : recipe.inputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); + var result = checkInputs(key, group, entry.getValue(), recipe.time()); + if (!result.ok()) return result; + } + for (var entry : recipe.outputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); + var result = checkOutputs(key, group, entry.getValue()); + if (!result.ok()) return result; + } + return MatchResult.OK; + } + + /** + * Takes the item and fluid inputs in one transaction, all or nothing. Energy is not taken; it is + * drawn per tick while the recipe runs. + * + * @return whether every input was taken + */ + public static boolean consume(MachineRecipe recipe, MachineInventory inventory) { + try (var tx = inventory.begin()) { + for (var entry : recipe.inputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + if (group == null) return false; + if (!take(group, entry.getValue(), tx)) return false; + } + tx.commit(); + return true; + } + } + + /** + * Rolls each product's chance and inserts the hits in one transaction, all or nothing. + * + * @return whether everything rolled fitted; if not, nothing was inserted + */ + public static boolean produce( + MachineRecipe recipe, MachineInventory inventory, RandomSource random) { + try (var tx = inventory.begin()) { + for (var entry : recipe.outputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + if (group == null) return false; + for (var product : entry.getValue()) { + if (product.chance() < 1 && random.nextFloat() >= product.chance()) continue; + if (!put(group, product, tx)) return false; + } + } + tx.commit(); + return true; + } + } + + private static MatchResult checkInputs( + String key, SlotGroup group, List ingredients, int time) { + var claims = new InputClaims(available(group)); + for (int i = 0; i < ingredients.size(); i++) { + long need = claim(claims, group, ingredients.get(i), time); + if (need < 0) return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); + if (need > 0) return new MatchResult(MatchResult.Reason.MISSING_INPUT, key, i); + } + return MatchResult.OK; + } + + private static MatchResult checkOutputs( + String key, SlotGroup group, List products) { + var stacks = new ArrayList(); + var fluids = new ArrayList(); + for (int i = 0; i < products.size(); i++) { + boolean fits; + if (products.get(i) instanceof ItemProduct item && group instanceof ItemGroup items) { + stacks.add(item.stack()); + fits = items.fits(stacks); + } else if (products.get(i) instanceof FluidProduct fluid + && group instanceof FluidGroup tanks) { + fluids.add(fluid.fluid()); + fits = tanks.fits(fluids); + } else return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); + if (!fits) return new MatchResult(MatchResult.Reason.NO_ROOM, key, i); + } + return MatchResult.OK; + } + + /** What each slot or tank of {@code group} holds, or the stored energy as one entry. */ + private static long[] available(SlotGroup group) { + return switch (group) { + case ItemGroup items -> { + var counts = new long[items.size()]; + for (int slot = 0; slot < counts.length; slot++) counts[slot] = items.peek(slot).getCount(); + yield counts; + } + case FluidGroup fluids -> { + var amounts = new long[fluids.tanks()]; + for (int tank = 0; tank < amounts.length; tank++) + amounts[tank] = fluids.peek(tank).amount(); + yield amounts; + } + case EnergyGroup energy -> new long[] {energy.stored()}; + }; + } + + /** Adds {@code ingredient} to {@code claims}; returns what's still needed, -1 if wrong kind. */ + private static long claim( + InputClaims claims, SlotGroup group, MachineIngredient ingredient, int time) { + return switch (ingredient) { + case ItemPart part when group instanceof ItemGroup items -> + claims.claim(part.count(), slot -> part.items().test(items.peek(slot))); + case FluidPart part when group instanceof FluidGroup fluids -> + claims.claim( + part.millibuckets(), tank -> part.fluids().contains(fluids.peek(tank).fluid())); + case EnergyPart part when group instanceof EnergyGroup -> + claims.claim(Math.ceilDiv(part.amount(), time), any -> true); + default -> -1; + }; + } + + /** Takes what {@code ingredients} claim from {@code group}. Energy groups give nothing here. */ + private static boolean take(SlotGroup group, List ingredients, MachineTx tx) { + if (group instanceof EnergyGroup) + return ingredients.stream().allMatch(EnergyPart.class::isInstance); + long[] held = available(group); + var claims = new InputClaims(held); + for (var ingredient : ingredients) if (claim(claims, group, ingredient, 1) != 0) return false; + for (int entry = 0; entry < held.length; entry++) { + int amount = (int) claims.claimed(entry); + if (amount == 0) continue; + int taken = + switch (group) { + case ItemGroup items -> items.extract(entry, amount, tx); + case FluidGroup fluids -> fluids.extract(entry, amount, tx); + case EnergyGroup energy -> 0; + }; + if (taken != amount) return false; + } + return true; + } + + private static boolean put(SlotGroup group, MachineProduct product, MachineTx tx) { + return switch (product) { + case ItemProduct item when group instanceof ItemGroup items -> { + var stack = item.stack(); + yield items.insert(stack, tx) == stack.getCount(); + } + case FluidProduct fluid when group instanceof FluidGroup tanks -> + tanks.insert(fluid.fluid(), tx) == fluid.fluid().amount(); + default -> false; + }; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/stat/MachineStats.java b/src/main/java/zone/moddev/mc/lib/machine/stat/MachineStats.java new file mode 100644 index 0000000..7ec1347 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/stat/MachineStats.java @@ -0,0 +1,81 @@ +package zone.moddev.mc.lib.machine.stat; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * A machine's stat values after every modifier, worked out by {@link #compute}. Immutable. + * + *

For each stat, starting from its base: add every {@code add_value} amount; add that result + * times the sum of every {@code add_multiplied_base} amount; multiply by (1 + amount) for each + * {@code add_multiplied_total} amount; clamp. Amounts are combined in sorted order, so the result + * never depends on the order modifiers arrive in. + */ +public final class MachineStats { + /** No stats at all. */ + public static final MachineStats EMPTY = new MachineStats(Map.of()); + + private final Map values; + + private MachineStats(Map values) { + this.values = Collections.unmodifiableMap(values); + } + + /** Applies {@code modifiers} to {@code stats}; modifiers for undeclared stats are ignored. */ + public static MachineStats compute( + Map stats, Collection modifiers) { + var values = new TreeMap(); + stats.forEach( + (name, definition) -> { + var add = new ArrayList(); + var base = new ArrayList(); + var total = new ArrayList(); + for (var modifier : modifiers) { + if (!modifier.stat().equals(name)) continue; + switch (modifier.op()) { + case ADD_VALUE -> add.add(modifier.amount()); + case ADD_MULTIPLIED_BASE -> base.add(modifier.amount()); + case ADD_MULTIPLIED_TOTAL -> total.add(modifier.amount()); + } + } + double value = definition.base() + sum(add); + value += value * sum(base); + Collections.sort(total); + for (double amount : total) value *= 1 + amount; + values.put(name, definition.clamp(value)); + }); + return new MachineStats(values); + } + + /** + * The value of {@code stat}. + * + * @throws IllegalArgumentException if the machine doesn't declare {@code stat} + */ + public double get(String stat) { + var value = values.get(stat); + if (value == null) throw new IllegalArgumentException("Machine has no stat " + stat); + return value; + } + + /** Every stat's value, by name. */ + public Map values() { + return values; + } + + private static double sum(List amounts) { + Collections.sort(amounts); + double sum = 0; + for (double amount : amounts) sum += amount; + return sum; + } + + @Override + public String toString() { + return "MachineStats" + values; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/stat/StatDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/stat/StatDefinition.java new file mode 100644 index 0000000..66b2e6b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/stat/StatDefinition.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.lib.machine.stat; + +import java.util.Optional; +import java.util.OptionalDouble; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +/** + * One stat a machine declares, such as {@code speed}: its value with nothing installed, and the + * range modifiers can move it in. JSON: {@code {"base": 1.0, "min": 0.25, "max": 8}}, with {@code + * min} and {@code max} optional. + */ +public record StatDefinition(double base, OptionalDouble min, OptionalDouble max) { + /** The JSON form; a non-finite value or {@code min > max} is a decode error. */ + public static final Codec CODEC = + RecordCodecBuilder.create( + instance -> + instance + .group( + Codec.DOUBLE.fieldOf("base").forGetter(StatDefinition::base), + Codec.DOUBLE.optionalFieldOf("min").forGetter(stat -> boxed(stat.min())), + Codec.DOUBLE.optionalFieldOf("max").forGetter(stat -> boxed(stat.max()))) + .apply( + instance, + (base, min, max) -> new StatDefinition(base, unboxed(min), unboxed(max)))) + .validate(StatDefinition::check); + + /** The value clamped to {@link #min} and {@link #max}. */ + public double clamp(double value) { + if (min.isPresent()) value = Math.max(min.getAsDouble(), value); + if (max.isPresent()) value = Math.min(max.getAsDouble(), value); + return value; + } + + private static DataResult check(StatDefinition stat) { + if (!Double.isFinite(stat.base) + || !Double.isFinite(stat.min.orElse(0)) + || !Double.isFinite(stat.max.orElse(0))) + return DataResult.error(() -> "Stat values must be finite: " + stat); + if (stat.min.isPresent() + && stat.max.isPresent() + && stat.min.getAsDouble() > stat.max.getAsDouble()) + return DataResult.error(() -> "Stat min is above its max: " + stat); + return DataResult.success(stat); + } + + private static Optional boxed(OptionalDouble value) { + return value.isPresent() ? Optional.of(value.getAsDouble()) : Optional.empty(); + } + + private static OptionalDouble unboxed(Optional value) { + return value.map(OptionalDouble::of).orElse(OptionalDouble.empty()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/stat/StatModifier.java b/src/main/java/zone/moddev/mc/lib/machine/stat/StatModifier.java new file mode 100644 index 0000000..1bbcdd3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/stat/StatModifier.java @@ -0,0 +1,21 @@ +package zone.moddev.mc.lib.machine.stat; + +import java.util.Objects; + +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +/** + * A change to one stat from an installed part, an upgrade or a card, using vanilla's attribute + * modifier operations. + */ +public record StatModifier(String stat, AttributeModifier.Operation op, double amount) { + /** + * @throws IllegalArgumentException if {@code amount} is not finite + */ + public StatModifier { + Objects.requireNonNull(stat, "stat"); + Objects.requireNonNull(op, "op"); + if (!Double.isFinite(amount)) + throw new IllegalArgumentException("Modifier amount for " + stat + " is not finite"); + } +} diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/broken.json b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/broken.json new file mode 100644 index 0000000..abb4203 --- /dev/null +++ b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/broken.json @@ -0,0 +1,7 @@ +{ + "item": "minecraft:piston", + "socket": "motor", + "modifiers": [ + { "stat": "speed", "op": "multiply_everything", "amount": 2 } + ] +} diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/gear.json b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/gear.json new file mode 100644 index 0000000..9457b47 --- /dev/null +++ b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/gear.json @@ -0,0 +1,8 @@ +{ + "part": "mmdlib:gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_base", "amount": "0.5 * $material.hardness" } + ], + "durability": "10 * $material.hardness" +} diff --git a/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json new file mode 100644 index 0000000..15f3880 --- /dev/null +++ b/src/runtimeTest/datapack/data/mmdlib_smoke/mmdlib/machine_part/unknown_item.json @@ -0,0 +1,5 @@ +{ + "item": "mmdlib_smoke:no_such_item", + "socket": "motor", + "modifiers": [] +} diff --git a/src/runtimeTest/datapack/pack.mcmeta b/src/runtimeTest/datapack/pack.mcmeta new file mode 100644 index 0000000..c98bb4b --- /dev/null +++ b/src/runtimeTest/datapack/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 48, + "description": "MMDLib runtime smoke test data" + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInternalsSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInternalsSmoke.java new file mode 100644 index 0000000..d1b2b0b --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInternalsSmoke.java @@ -0,0 +1,231 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.Map; +import java.util.Optional; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.machine.internals.InternalsDefinition; +import zone.moddev.mc.lib.machine.internals.MachineParts; +import zone.moddev.mc.lib.machine.neoforge.MachineComponents; +import zone.moddev.mc.lib.machine.neoforge.MachineInternals; +import zone.moddev.mc.lib.machine.neoforge.MachinePartsLoader; + +/** Checks machine internals with MMDLib's own gears on a running server. */ +final class MachineInternalsSmoke { + private static final String INTERNALS = + """ + { + "open_with": "#c:tools/wrench", + "swap": "when_idle", + "sockets": { + "gears": { "count": 2, "accepts": "#c:gears" }, + "motor": { "count": 1 }, + "coil": { "count": 1, "optional": true } + }, + "stats": { + "speed": { "base": 1.0, "min": 0.25, "max": 8 }, + "energy_per_tick": { "base": 40, "min": 1 }, + "max_temperature": { "base": 900 } + } + } + """; + + private static final String GEAR = + """ +{ + "part": "mmdlib:gear", + "socket": "gears", + "modifiers": [ + { "stat": "speed", "op": "add_multiplied_base", "amount": "0.08 * ($material.hardness - 3)" }, + { "stat": "energy_per_tick", "op": "add_multiplied_base", "amount": "0.03 * $material.hardness" }, + { "stat": "max_temperature", "op": "add_value", "amount": 50 } + ], + "durability": "3" +} +"""; + + private static final String MOTOR = + """ + {"item": "minecraft:piston", "socket": "motor", + "modifiers": [{"stat": "speed", "op": "add_multiplied_total", "amount": 0.15}]} + """; + + private MachineInternalsSmoke() {} + + static void check(ServerLevel level) { + var previous = MachineParts.current(); + try { + run(level); + } finally { + MachineParts.install(previous); + } + } + + private static void run(ServerLevel level) { + var provider = level.registryAccess(); + var gearId = ResourceLocation.parse("mmdlib:iron_gear"); + require( + MachinePartsLoader.subject(gearId) + .orElseThrow() + .part() + .orElseThrow() + .getPath() + .equals("gear"), + "iron gear is the mmdlib:gear part"); + double hardness = MMDLib.itemMaterials().get(gearId).properties().hardness(); + double perGear = .08 * (hardness - 3); + installParts(GEAR); + var definition = internals(INTERNALS); + var changes = new int[1]; + var internals = + new MachineInternals(definition, MachineParts::current).onChange(() -> changes[0]++); + require(internals.sockets().getSlots() == 4, "one slot per socket position"); + require(internals.missing().equals(Optional.of("gears")), "empty gears reported"); + require(internals.stats().get("speed") == 1, "base speed"); + + require(internals.install("gears", 0, gear(2), false).getCount() == 1, "one gear per position"); + require(internals.install("gears", 1, gear(1), false).isEmpty(), "second gear"); + require(changes[0] > 0, "changes reported"); + require(near(internals.stats().get("speed"), 1 + 2 * perGear), "gear speed"); + require(internals.stats().get("max_temperature") == 1000, "gear temperature"); + require(internals.missing().equals(Optional.of("motor")), "motor still missing"); + require(internals.install("gears", 0, gear(1), false).getCount() == 1, "position taken"); + var stone = new ItemStack(Items.STONE); + require(internals.install("motor", 0, stone, false).getCount() == 1, "stone is no part"); + require(internals.install("motor", 0, gear(1), false).getCount() == 1, "gear is no motor"); + require( + internals.install("coil", 0, new ItemStack(Items.PISTON), false).getCount() == 1, + "motor is no coil"); + require(internals.install("motor", 0, new ItemStack(Items.PISTON), false).isEmpty(), "motor"); + require(internals.missing().isEmpty(), "every required socket filled"); + require(near(internals.stats().get("speed"), (1 + 2 * perGear) * 1.15), "motor speed"); + require(internals.install("gears", 9, gear(1), false).getCount() == 1, "no such position"); + + require(internals.remove("motor", 0, true).isEmpty(), "no swap while running"); + require(!internals.canSwap(true) && internals.canSwap(false), "when_idle"); + require(internals.remove("motor", 0, false).is(Items.PISTON), "swap while idle"); + var always = + new MachineInternals( + internals(INTERNALS.replace("when_idle", "always")), MachineParts::current); + require( + always.install("motor", 0, new ItemStack(Items.PISTON), true).isEmpty(), "always swaps"); + require(!internals.canOpen(new ItemStack(Items.STICK)), "needs a wrench"); + var bare = + new MachineInternals( + internals(INTERNALS.replace("\"open_with\": \"#c:tools/wrench\",", "")), + MachineParts::current); + require(bare.canOpen(ItemStack.EMPTY), "bare hands"); + + // Wear: durability 3, so the third point breaks both gears. + require(internals.wear("gears", 1) == 0, "first wear"); + var worn = internals.sockets().getStackInSlot(definition.slots("gears").get(0)); + require( + worn.getOrDefault(MachineComponents.PART_WEAR.get(), 0) == 1, "wear stored on the part"); + require(internals.wear("gears", 1) == 0 && internals.wear("gears", 1) == 2, "gears break"); + require( + internals.missing().equals(Optional.of("gears")), "broken gears leave the socket empty"); + require( + internals.install("motor", 0, new ItemStack(Items.PISTON), false).isEmpty(), "motor back"); + require(internals.wear("motor", 5) == 0, "parts without durability never wear"); + boolean refused = false; + try { + internals.wear("gears", -1); + } catch (IllegalArgumentException ex) { + refused = true; + } + require(refused, "negative wear refused"); + require(internals.missing().equals(Optional.of("gears")), "motor survives wear"); + + // Saved by socket name; parts with no room left are kept as orphans, across saves too. + internals.install("gears", 0, gear(1), false); + internals.install("gears", 1, gear(1), false); + var saved = internals.save(provider); + var smaller = internals(INTERNALS.replace("\"count\": 2", "\"count\": 1")); + var loaded = new MachineInternals(smaller, MachineParts::current); + loaded.load(provider, saved); + require( + loaded.sockets().getStackInSlot(smaller.slots("gears").get(0)).is(gear(1).getItem()), + "gear kept"); + require(loaded.orphans().size() == 1, "extra gear kept as an orphan"); + var reloaded = new MachineInternals(smaller, MachineParts::current); + reloaded.load(provider, loaded.save(provider)); + require(reloaded.orphans().size() == 1, "orphans survive another save"); + var same = new MachineInternals(definition, MachineParts::current); + same.load(provider, saved); + require(same.orphans().isEmpty() && same.missing().isEmpty(), "same layout loads in place"); + require(near(same.stats().get("speed"), (1 + 2 * perGear) * 1.15), "loaded stats"); + var loads = new int[1]; + new MachineInternals(definition, MachineParts::current) + .onChange(() -> loads[0]++) + .load(provider, saved); + require(loads[0] == 1, "load reports one change, got " + loads[0]); + + // A reload changes the numbers without touching the sockets. + installParts(GEAR.replace("0.08 *", "0.16 *")); + require(near(same.stats().get("speed"), (1 + 4 * perGear) * 1.15), "stats follow a reload"); + + // A machine that only declares speed ignores the gear's other modifiers. + var speedOnly = + new MachineInternals( + internals("{\"sockets\":{\"gears\":{\"count\":1}},\"stats\":{\"speed\":{\"base\":1}}}"), + MachineParts::current); + speedOnly.install("gears", 0, gear(1), false); + require(speedOnly.stats().values().size() == 1, "undeclared stats ignored"); + require(near(speedOnly.stats().get("speed"), 1 + 2 * perGear), "declared stat applied"); + + // A socket may be called anything, including the key orphans are saved under. + var odd = internals("{\"sockets\":{\"orphans\":{\"count\":1}},\"stats\":{}}"); + var named = new MachineInternals(odd, MachineParts::current); + require( + named.install("orphans", 0, new ItemStack(Items.OBSERVER), false).isEmpty(), "odd socket"); + var back = new MachineInternals(odd, MachineParts::current); + back.load(provider, named.save(provider)); + require( + back.sockets().getStackInSlot(0).is(Items.OBSERVER) && back.orphans().isEmpty(), + "odd socket saved"); + } + + private static void installParts(String gear) { + MachineParts.install( + MachineParts.parse( + Map.of( + ResourceLocation.parse("smoke:gear"), + JsonParser.parseString(gear), + ResourceLocation.parse("smoke:motor"), + JsonParser.parseString(MOTOR), + ResourceLocation.parse("smoke:observer"), + JsonParser.parseString( + "{\"item\": \"minecraft:observer\", \"socket\": \"orphans\", \"modifiers\":" + + " []}")), + MachinePartsLoader::subject)); + } + + private static InternalsDefinition internals(String json) { + return InternalsDefinition.CODEC + .parse(JsonOps.INSTANCE, JsonParser.parseString(json)) + .getOrThrow(); + } + + private static ItemStack gear(int count) { + return new ItemStack( + BuiltInRegistries.ITEM.get(ResourceLocation.parse("mmdlib:iron_gear")), count); + } + + private static boolean near(double a, double b) { + return Math.abs(a - b) < 1e-9; + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Machine internals smoke failed: " + what); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInventorySmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInventorySmoke.java new file mode 100644 index 0000000..70c62ff --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineInventorySmoke.java @@ -0,0 +1,158 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.List; + +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.Fluids; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.EnergyGroup; +import zone.moddev.mc.lib.machine.inventory.FluidGroup; +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; + +/** Checks slot groups over MMDLib's storages and snapshot transactions. */ +final class MachineInventorySmoke { + private MachineInventorySmoke() {} + + static void check(ServerLevel level) { + var changes = new int[1]; + var items = + new ItemStorage(4) + .onChange(() -> changes[0]++) + .role(Role.INPUT, 0) + .role(Role.OUTPUT, 1, 2, 3); + var fluids = new FluidStorage().onChange(() -> changes[0]++); + fluids.addTank(4_000, Role.INPUT); + var energy = new EnergyBuffer(10_000_000_000L, 100).onChange(() -> changes[0]++); + var inventory = + MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)) + .group("energy", StorageGroups.energy(energy)) + .build(); + var input = (ItemGroup) inventory.group("input").orElseThrow(); + var output = (ItemGroup) inventory.group("output").orElseThrow(); + var byproduct = (ItemGroup) inventory.group("byproduct").orElseThrow(); + var water = (FluidGroup) inventory.group("water").orElseThrow(); + var power = (EnergyGroup) inventory.group("energy").orElseThrow(); + require(inventory.keys().size() == 5 && inventory.group("steam").isEmpty(), "keys"); + require(output.size() == 2 && water.tanks() == 1, "group sizes"); + + // The machine inserts into OUTPUT slots, which automation can't. + try (var tx = inventory.begin()) { + require(output.insert(new ItemStack(Items.RAW_COPPER, 70), tx) == 70, "owner bypasses roles"); + tx.commit(); + } + require( + items.getStackInSlot(1).getCount() == 64 && items.getStackInSlot(2).getCount() == 6, + "spread"); + + // Without commit, everything the tx touched goes back. + try (var tx = inventory.begin()) { + require(output.insert(new ItemStack(Items.RAW_COPPER, 5), tx) == 5, "insert in tx"); + require(water.insert(water(1_000), tx) == 1_000, "fill in tx"); + } + require( + items.getStackInSlot(2).getCount() == 6 && fluids.getFluidInTank(0).isEmpty(), + "rolled back"); + + try (var tx = inventory.begin()) { + require(water.insert(water(1_000), tx) == 1_000, "fill"); + tx.commit(); + } + require(water.peek(0).amount() == 1_000 && water.peek(0).sameFluid(water(1)), "committed fill"); + try (var tx = inventory.begin()) { + require(water.extract(0, 250, tx) == 250, "drain"); + require( + water.insert( + new FluidAmount( + Fluids.LAVA.builtInRegistryHolder(), DataComponentPatch.EMPTY, 10), + tx) + == 0, + "other fluid refused"); + } + require(water.peek(0).amount() == 1_000, "drain rolled back"); + + // Energy above 2^31 and above the buffer's transfer rate of 100. + try (var tx = inventory.begin()) { + require(power.insert(5_000_000_000L, tx) == 5_000_000_000L, "big insert"); + tx.commit(); + } + require(energy.stored() == 5_000_000_000L, "big energy stored"); + try (var tx = inventory.begin()) { + require(power.extract(4_000_000_000L, tx) == 4_000_000_000L, "big extract"); + require(power.insert(9_000_000_000L, tx) == 9_000_000_000L, "insert up to capacity"); + require(power.insert(1, tx) == 0, "full"); + } + require( + power.stored() == 5_000_000_000L && power.capacity() == 10_000_000_000L, + "energy rolled back"); + + // fits() works on copies and never touches the storages. + items.insertResult(3, new ItemStack(Items.COBBLESTONE, 64), false); + int before = changes[0]; + require(!byproduct.fits(List.of(new ItemStack(Items.GRAVEL))), "full slot"); + require(byproduct.fits(List.of()), "nothing always fits"); + require(output.fits(List.of(new ItemStack(Items.RAW_COPPER, 58))), "room left"); + require(!output.fits(List.of(new ItemStack(Items.RAW_COPPER, 59))), "one too many"); + require( + !output.fits(List.of(new ItemStack(Items.RAW_COPPER, 50), new ItemStack(Items.GRAVEL))), + "no free slot"); + require(water.fits(List.of(water(3_000))) && !water.fits(List.of(water(3_001))), "tank room"); + require(changes[0] == before, "fits changed nothing"); + + // The machine may take from INPUT slots; bad indexes take nothing. + items.insertResult(0, new ItemStack(Items.COPPER_ORE, 3), false); + try (var tx = inventory.begin()) { + require(input.extract(0, 2, tx) == 2, "owner takes input"); + require(input.extract(9, 1, tx) == 0 && input.extract(-1, 1, tx) == 0, "out of range"); + require(water.extract(3, 10, tx) == 0, "no such tank"); + tx.commit(); + } + require(items.getStackInSlot(0).getCount() == 1, "input taken"); + input.peek(0).setCount(50); + require(items.getStackInSlot(0).getCount() == 1, "peek is a copy"); + + var closed = inventory.begin(); + closed.close(); + expectState(() -> output.insert(new ItemStack(Items.RAW_COPPER), closed), "insert after close"); + expectState(closed::commit, "commit after close"); + var committed = inventory.begin(); + committed.commit(); + committed.close(); + expectState(() -> power.extract(1, committed), "extract after commit"); + try { + MachineInventory.builder().group("a", input).group("a", output); + throw new AssertionError("Machine inventory smoke failed: duplicate key accepted"); + } catch (IllegalArgumentException expected) { + } + } + + private static FluidAmount water(int amount) { + return new FluidAmount(Fluids.WATER.builtInRegistryHolder(), DataComponentPatch.EMPTY, amount); + } + + private static void expectState(Runnable action, String what) { + try { + action.run(); + } catch (IllegalStateException expected) { + return; + } + throw new AssertionError("Machine inventory smoke failed: " + what); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Machine inventory smoke failed: " + what); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java new file mode 100644 index 0000000..06bc4b3 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachinePartsLoaderSmoke.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; + +import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.machine.internals.MachineParts; + +/** + * Checks that part files in a real datapack reach {@link MachineParts#current()} through the reload + * listener. The smoke init script copies {@code src/runtimeTest/datapack} into the smoke world. + */ +final class MachinePartsLoaderSmoke { + private MachinePartsLoaderSmoke() {} + + static void check() { + var parts = MachineParts.current(); + var gearId = ResourceLocation.parse("mmdlib:iron_gear"); + var gear = parts.resolve(gearId); + require(gear.isPresent(), "the datapack's gear file was loaded"); + double hardness = MMDLib.itemMaterials().get(gearId).properties().hardness(); + var part = gear.get(); + require(part.socket().equals("gears"), "socket from the file"); + require(part.modifiers().size() == 1, "one modifier"); + var modifier = part.modifiers().getFirst(); + require( + modifier.stat().equals("speed") + && modifier.op() == AttributeModifier.Operation.ADD_MULTIPLIED_BASE + && Math.abs(modifier.amount() - .5 * hardness) < 1e-9, + "modifier worked out with the material, got " + modifier); + require( + part.durability().orElse(-1) == (int) Math.floor(10 * hardness), + "durability worked out with the material, got " + part.durability()); + require( + parts.resolve(ResourceLocation.parse("minecraft:piston")).isEmpty(), + "a file that doesn't decode is skipped"); + var problems = parts.check(BuiltInRegistries.ITEM.keySet()); + require( + problems.size() == 1 && problems.getFirst().contains("mmdlib_smoke:no_such_item"), + "only the file naming a missing item is reported, got " + problems); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Machine parts loader smoke failed: " + what); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java new file mode 100644 index 0000000..8afbe3c --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java @@ -0,0 +1,349 @@ +package zone.moddev.mc.lib.smoke; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.material.Fluids; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MatchResult; +import zone.moddev.mc.lib.machine.recipe.MatchResult.Reason; +import zone.moddev.mc.lib.machine.recipe.RecipeMatcher; + +/** Checks recipe matching, consuming and producing with the crusher recipe from recipes.md. */ +final class MachineRecipeSmoke { + private static final String CRUSHER = + """ + { + "inputs": { + "input": [{ "items": { "tag": "c:ores/copper" }, "count": 1 }], + "water": [{ "fluids": "minecraft:water", "amount": 250 }], + "energy": [{ "energy": 4000 }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:raw_copper", "count": 2 } }], + "byproduct": [{ "item": { "id": "minecraft:gravel" }, "chance": 0.25 }] + }, + "time": 200 + } + """; + + private MachineRecipeSmoke() {} + + /** A crusher's storages: ore in slot 0, output in 1 and 2, byproduct in 3. */ + private static final class Crusher { + final int[] changes = new int[1]; + final ItemStorage items = + new ItemStorage(4) + .onChange(() -> changes[0]++) + .role(Role.INPUT, 0) + .role(Role.OUTPUT, 1, 2, 3); + final FluidStorage fluids = new FluidStorage().onChange(() -> changes[0]++); + final EnergyBuffer energy = + new EnergyBuffer(10_000_000_000L, 1_000).onChange(() -> changes[0]++); + final MachineInventory inventory; + + Crusher(int ore, int water, long fe, boolean withWater) { + fluids.addTank(4_000, Role.INPUT); + items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); + fluids + .tank(0) + .fill( + new net.neoforged.neoforge.fluids.FluidStack(Fluids.WATER, water), + net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction.EXECUTE); + energy.setStored(fe); + var builder = + MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("energy", StorageGroups.energy(energy)); + if (withWater) builder.group("water", StorageGroups.fluids(fluids, 0)); + inventory = builder.build(); + } + } + + static void check(ServerLevel level) { + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + var crusher = recipe(ops, CRUSHER); + require( + crusher.time() == 200 && crusher.inputs().size() == 3 && crusher.outputs().size() == 2, + "decoded"); + + require( + RecipeMatcher.check(crusher, new Crusher(3, 1_000, 20, true).inventory).ok(), + "matches on one tick's energy"); + expect( + RecipeMatcher.check(crusher, new Crusher(3, 1_000, 19, true).inventory), + Reason.MISSING_INPUT, + "energy", + 0); + expect( + RecipeMatcher.check(crusher, new Crusher(0, 1_000, 20, true).inventory), + Reason.MISSING_INPUT, + "input", + 0); + expect( + RecipeMatcher.check(crusher, new Crusher(3, 249, 20, true).inventory), + Reason.MISSING_INPUT, + "water", + 0); + expect( + RecipeMatcher.check(crusher, new Crusher(3, 1_000, 20, false).inventory), + Reason.NO_GROUP, + "water", + -1); + + var full = new Crusher(3, 1_000, 20, true); + full.items.insertResult(3, new ItemStack(Items.COBBLESTONE, 64), false); + int before = full.changes[0]; + expect(RecipeMatcher.check(crusher, full.inventory), Reason.NO_ROOM, "byproduct", 0); + require(full.changes[0] == before, "check changed nothing"); + + var run = new Crusher(3, 1_000, 20, true); + require(RecipeMatcher.consume(crusher, run.inventory), "consume"); + require(run.items.getStackInSlot(0).getCount() == 2, "one ore taken"); + require(run.fluids.getFluidInTank(0).getAmount() == 750, "250 mB taken"); + require(run.energy.stored() == 20, "energy not taken by consume"); + require( + !RecipeMatcher.consume(crusher, new Crusher(0, 1_000, 20, true).inventory), + "nothing to consume"); + var partial = new Crusher(3, 100, 20, true); + require(!RecipeMatcher.consume(crusher, partial.inventory), "not enough water"); + require(partial.items.getStackInSlot(0).getCount() == 3, "ore put back when water is short"); + + // A full byproduct slot and a hit on the 25%: nothing is produced at all. + full.items.setStackInSlot(3, new ItemStack(Items.COBBLESTONE, 64)); + require(!RecipeMatcher.produce(crusher, full.inventory, fixed(0F)), "blocked"); + require( + full.items.getStackInSlot(1).isEmpty() && full.items.getStackInSlot(2).isEmpty(), + "rolled back"); + // A miss: only the copper. + require(RecipeMatcher.produce(crusher, full.inventory, fixed(.99F)), "produced on a miss"); + require( + full.items.getStackInSlot(1).is(Items.RAW_COPPER) + && full.items.getStackInSlot(1).getCount() == 2, + "copper"); + require(full.items.getStackInSlot(3).is(Items.COBBLESTONE), "byproduct untouched"); + require(RecipeMatcher.produce(crusher, run.inventory, fixed(0F)), "produced on a hit"); + require(run.items.getStackInSlot(3).is(Items.GRAVEL), "gravel on a hit"); + + // Items are claimed once: 2 iron ingots and 2 #c:ingots need 4 ingots, not 3. + var claims = + recipe( + ops, + """ + {"inputs": {"input": [{"items": {"item": "minecraft:iron_ingot"}, "count": 2}, + {"items": {"tag": "c:ingots"}, "count": 2}]}, "time": 10} + """); + var ingots = new Crusher(0, 0, 0, true); + ingots.items.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 3)); + expect(RecipeMatcher.check(claims, ingots.inventory), Reason.MISSING_INPUT, "input", 1); + ingots.items.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 4)); + require(RecipeMatcher.check(claims, ingots.inventory).ok(), "four ingots are enough"); + sharedInputs(ops); + + // Energy costs above 2^31: 6,000,000,000 FE over 2 ticks needs 3,000,000,000 to start. + var big = recipe(ops, "{\"inputs\": {\"energy\": [{\"energy\": 6000000000}]}, \"time\": 2}"); + require( + RecipeMatcher.check(big, new Crusher(0, 0, 3_000_000_000L, true).inventory).ok(), + "big energy"); + expect( + RecipeMatcher.check(big, new Crusher(0, 0, 2_999_999_999L, true).inventory), + Reason.MISSING_INPUT, + "energy", + 0); + + var wrong = + recipe( + ops, + "{\"inputs\": {\"water\": [{\"items\": {\"item\": \"minecraft:stone\"}}]}, \"time\":" + + " 1}"); + expect( + RecipeMatcher.check(wrong, new Crusher(0, 0, 0, true).inventory), + Reason.WRONG_KIND, + "water", + 0); + var fluidOut = + recipe( + ops, + "{\"inputs\": {\"energy\": [{\"energy\": 1}]}, \"outputs\": {\"water\": [{\"fluid\":" + + " {\"id\": \"minecraft:lava\", \"amount\": 10}}]}, \"time\": 1}"); + expect( + RecipeMatcher.check(fluidOut, new Crusher(0, 500, 1, true).inventory), + Reason.NO_ROOM, + "water", + 0); + require( + RecipeMatcher.check(fluidOut, new Crusher(0, 0, 1, true).inventory).ok(), + "lava fits an empty tank"); + + var both = + MachineRecipe.CODEC + .codec() + .parse( + ops, + JsonParser.parseString( + "{\"inputs\": {\"input\": [{\"items\": {\"item\": \"minecraft:stone\"}," + + " \"energy\": 5}]}, \"time\": 1}")); + require( + both.error() + .map(error -> error.message().contains("'items', 'fluids' or 'energy'")) + .orElse(false), + "both keys refused"); + var bothOut = + MachineRecipe.CODEC + .codec() + .parse( + ops, + JsonParser.parseString( + "{\"inputs\": {\"energy\": [{\"energy\": 1}]}, \"outputs\": {\"output\":" + + " [{\"item\": {\"id\": \"minecraft:stone\"}, \"fluid\": {\"id\":" + + " \"minecraft:water\", \"amount\": 10}}]}, \"time\": 1}")); + require( + bothOut.error().map(error -> error.message().contains("'item' or 'fluid'")).orElse(false), + "item and fluid on one output refused, got " + bothOut); + var neitherOut = + MachineRecipe.CODEC + .codec() + .parse( + ops, + JsonParser.parseString( + "{\"inputs\": {\"energy\": [{\"energy\": 1}]}, \"outputs\": {\"output\":" + + " [{\"chance\": 0.5}]}, \"time\": 1}")); + require( + neitherOut + .error() + .map(error -> error.message().contains("'item' or 'fluid'")) + .orElse(false), + "output with neither key refused, got " + neitherOut); + mixedOutputs(ops); + require( + MachineRecipe.CODEC + .codec() + .parse(ops, JsonParser.parseString("{\"inputs\": {}, \"time\": 1}")) + .error() + .isPresent(), + "no inputs refused"); + require( + new FluidAmount(Fluids.WATER.builtInRegistryHolder(), DataComponentPatch.EMPTY, 1) + .sameFluid( + new FluidAmount(Fluids.WATER.builtInRegistryHolder(), DataComponentPatch.EMPTY, 9)), + "same fluid"); + } + + /** A broad ingredient listed first must not use up what only a later ingredient can take. */ + private static void sharedInputs(RegistryOps ops) { + var logs = + recipe( + ops, + """ + {"inputs": {"input": [{"items": {"tag": "minecraft:logs"}}, + {"items": {"item": "minecraft:oak_log"}}]}, "time": 10} + """); + var items = new ItemStorage(2); + items.setStackInSlot(0, new ItemStack(Items.OAK_LOG)); + items.setStackInSlot(1, new ItemStack(Items.BIRCH_LOG)); + var logInventory = + MachineInventory.builder().group("input", StorageGroups.items(items, 0, 1)).build(); + require(RecipeMatcher.check(logs, logInventory).ok(), "logs can share oak and birch"); + require(RecipeMatcher.consume(logs, logInventory), "logs consumed"); + require(items.getStackInSlot(0).isEmpty() && items.getStackInSlot(1).isEmpty(), "logs taken"); + // Two birch can't fill the oak ingredient, and nothing is taken. + items.setStackInSlot(0, new ItemStack(Items.BIRCH_LOG)); + items.setStackInSlot(1, new ItemStack(Items.BIRCH_LOG)); + expect(RecipeMatcher.check(logs, logInventory), Reason.MISSING_INPUT, "input", 1); + require(!RecipeMatcher.consume(logs, logInventory), "no oak to consume"); + require(items.getStackInSlot(0).is(Items.BIRCH_LOG), "birch left alone"); + + var liquids = + recipe( + ops, + """ + {"inputs": {"tanks": [{"fluids": ["minecraft:water", "minecraft:lava"], "amount": 100}, + {"fluids": "minecraft:water", "amount": 100}]}, "time": 10} + """); + var fluids = new FluidStorage(); + fluids.addTank(1_000, Role.INPUT); + fluids.addTank(1_000, Role.INPUT); + var execute = net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction.EXECUTE; + fluids.tank(0).fill(new net.neoforged.neoforge.fluids.FluidStack(Fluids.WATER, 100), execute); + fluids.tank(1).fill(new net.neoforged.neoforge.fluids.FluidStack(Fluids.LAVA, 100), execute); + var tanks = + MachineInventory.builder().group("tanks", StorageGroups.fluids(fluids, 0, 1)).build(); + require(RecipeMatcher.check(liquids, tanks).ok(), "fluids can share water and lava"); + require(RecipeMatcher.consume(liquids, tanks), "fluids consumed"); + require( + fluids.getFluidInTank(0).isEmpty() && fluids.getFluidInTank(1).isEmpty(), "fluids taken"); + } + + /** One recipe that makes an item and a fluid: both land, or neither does. */ + private static void mixedOutputs(RegistryOps ops) { + var mixed = + recipe( + ops, + """ + { + "inputs": { "input": [{ "items": { "item": "minecraft:copper_ore" } }] }, + "outputs": { + "output": [{ "item": { "id": "minecraft:raw_copper", "count": 2 } }], + "water": [{ "fluid": { "id": "minecraft:water", "amount": 100 } }] + }, + "time": 10 + } + """); + var run = new Crusher(1, 1_000, 0, true); + require(RecipeMatcher.check(mixed, run.inventory).ok(), "item and fluid outputs match"); + require(RecipeMatcher.produce(mixed, run.inventory, fixed(.5F)), "item and fluid produced"); + require( + run.items.getStackInSlot(1).is(Items.RAW_COPPER) + && run.items.getStackInSlot(1).getCount() == 2, + "item output"); + require(run.fluids.getFluidInTank(0).getAmount() == 1_100, "fluid output"); + + // The tank is full: the item would fit, but nothing is made. + var full = new Crusher(1, 4_000, 0, true); + expect(RecipeMatcher.check(mixed, full.inventory), Reason.NO_ROOM, "water", 0); + require(!RecipeMatcher.produce(mixed, full.inventory, fixed(.5F)), "blocked by the tank"); + require(full.items.getStackInSlot(1).isEmpty(), "item rolled back when the fluid can't fit"); + require(full.fluids.getFluidInTank(0).getAmount() == 4_000, "tank untouched"); + } + + private static MachineRecipe recipe(RegistryOps ops, String json) { + return MachineRecipe.CODEC.codec().parse(ops, JsonParser.parseString(json)).getOrThrow(); + } + + /** A random whose nextFloat() is always {@code value}: 0 always hits a chance, 0.99 misses. */ + private static LegacyRandomSource fixed(float value) { + return new LegacyRandomSource(0) { + @Override + public float nextFloat() { + return value; + } + }; + } + + private static void expect(MatchResult result, Reason reason, String group, int index) { + require( + result.reason() == reason && group.equals(result.group()) && result.index() == index, + "expected " + reason + " " + group + "[" + index + "], got " + result); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Machine recipe smoke failed: " + what); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java index d8171db..22ec14b 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/RuntimeSmoke.java @@ -192,6 +192,10 @@ public static void started(ServerStartedEvent event) { } CapabilitySmoke.check(level); MachineSmoke.check(level); + MachinePartsLoaderSmoke.check(); + MachineInternalsSmoke.check(level); + MachineInventorySmoke.check(level); + MachineRecipeSmoke.check(level); if (projectiles != 14 || fluids != 15 || placed != 59) throw new AssertionError( "Wrong smoke counts: " + placed + "/" + projectiles + "/" + fluids);