Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions docs/adr/0002-recipe-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# 2. Recipe rewrite
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initial review is just on the adr - thank you for this by the way, it makes large changes like this much better :)


Date: 2024-11-03
Last update: 2024-11-08

**DO NOT rely on any APIs introduced until we finish the work completely!**

## Status

Phase 1: Work in progress

## Context

Slimefun currently lacks a robust recipe system. Multiblock crafting
does one thing, while electric machines do another, even though some
of them craft the same items.

Slimefun also lacks certain features that vanilla minecraft has, like true
shaped and shapeless recipes, tagged inputs, and the ability to edit recipes
without any code.

## Goals

The goal of this rewrite is to introduce an improved recipe system to
Slimefun, focusing on

- Ease of use: The API should be clean and the system intuitive for
developers to use
- Extensibility: Addons should be able to create and use their own types
of recipes with this system.
- Customizability: Server owners should be able to customize any and all
Slimefun recipes
- Performance: Should not blow up any servers

The new recipe system should also be completely backwards compatible.

## API Additions

### 5 main recipe classes

All recipes are now `Recipe` objects. It is an association between
inputs (see `RecipeInput`) and outputs (see `RecipeOutput`), along with other metadata
for how the recipe should be crafted -- recipe type, energy cost, base crafting duration, etc.

`RecipeInput`s are a list of `RecipeInputItem`s plus a `MatchProcedure` -- how the inputs of
the recipe should be matched to items in a multiblock/machine when crafting. The base ones are:

- Shaped/Shapeless: Exactly the same as vanilla
- Subset: How the current smeltery, etc. craft
- Shaped-flippable: The recipe can be flipped on the Y-axis
- Shaped-rotatable: The recipe can be rotated (currently only 45deg, 3x3)

`RecipeInputItem`s describe a single slot of a recipe and determines what
items match it. There can be a single item that matches (see `RecipeInputSlimefunItem`,
`RecipeInputItemStack`), or a list (tag) of items all of which can be used
in that slot (see `RecipeInputGroup`, `RecipeInputTag`).

`RecipeOutput`s are just a list of `RecipeOutputItem`s, all of which are crafted by the recipe.

An `RecipeOutputItem`s controls how an output is generated when the recipe is
crafted. It can be a single item (see `RecipeOutputItemStack`, `RecipeOutputSlimefunItem`),
or a group of items each with a certain weight of being output (see `RecipeOutputGroup`).

#### Examples (pseudocode)

Here are the inputs and outputs of the recipe for a vanilla torch

```txt
RecipeInput (
{
EMPTY, EMPTY, EMPTY
EMPTY, RecipeInputGroup(COAL, CHARCOAL), EMPTY,
EMPTY, RecipeInputItemStack(STICK), EMPTY
},
SHAPED
)
RecipeOutput (
RecipeOutputItemStack(4 x TORCH)
)
```

Here are the inputs and outputs of a gold pan

```txt
RecipeInput (
{ RecipeOutputItemStack(GRAVEL) },
SUBSET
)
RecipeOutput (
RecipeOutputGroup(
40 RecipeOutputItemStack(FLINT)
5 RecipeOutputItemStack(IRON_NUGGET)
20 RecipeOutputItemStack(CLAY_BALL)
35 RecipeOutputSlimefunItem(SIFTED_ORE)
)
)
```

This would remove the need to use ItemSettings to determine the gold pan weights

### RecipeService

This is the public interface for the recipe system, there are methods here to add,
load, save, and search recipes. It also stores a map of `MatchProcedures` and
`RecipeType` by key for conversions from a string

### JSON Serialization

All recipes are able to be serialized to and deserialized
from JSON. The schemas are shown below.

Here, `key` is the string representation of a namespaced key

`Recipe`

```txt
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't see any like "id" field

"input"?: RecipeInput
"output"?: RecipeOutput
"type": key | key[]
"energy"?: int
"crafting-time"?: int
"permission-node"?: string | string[]
}
```

The recipe deserializer technically needs a `__filename` field, but it is
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting 🤔

inserted when the file is read, so it isn't (and shouldn't) be in the schema

`RecipeInput`

```txt
{
"items": string | string[]
"key": {
[key: string]: RecipeInputItem
}
"match"?: key
}
```

`RecipeOutput`

```txt
{
"items": RecipeOutputItem[]
}
```

`RecipeInputItem`*

```txt
{
"id": key
"amount"?: int
"durability"?: int
} | {
"tag": key
"amount"?: int
"durability"?: int
} | {
"group": RecipeInputItem[]
}
```

`RecipeOutputItem`*

```txt
{
"id": key
"amount"?: int
} | {
"group": RecipeInputItem[]
"weights"?: int[]
}
```

*In addition to those schemas, items can be in short form:

- Single items: `<namespace>:<id>|<amount>`
- Tags: `#<namespace>:<id>|<amount>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh i'm not a big fan of this, i'd rather be more explicit. I can feel this getting unwieldy in the future

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to be clear, the short form is only used for items that are just an id (mc or sf) and amount

i think this would just be a lot cleaner when writing recipes, since most inputs won't have any other fields anyways

for example

{
    "id": "ELECTROMAGNET",
    "input": {
        "items": [
            "123",
            " 4 ",
            "   "
        ],
        "key": {
            "1": "slimefun:nickel_ingot",
            "2": "slimefun:magnet",
            "3": "slimefun:cobalt_ingot",
            "4": "slimefun:battery"
        },
        "match": "slimefun:shaped"
    },
    "output": {
        "items": [
            "slimefun:electromagnet"
        ]
    },
    "type": "slimefun:enhanced_crafting_table"
}

vs

{
    "id": "ELECTROMAGNET",
    "input": {
        "items": [
            "123",
            " 4 ",
            "   "
        ],
        "key": {
            "1": {
                "id": "slimefun:nickel_ingot"
            },
            "2": {
                "id": "slimefun:magnet"
            },
            "3": {
                "id": "slimefun:cobalt_ingot"
            },
            "4": {
                "id": "slimefun:battery"
            }
        },
        "match": "slimefun:shaped"
    },
    "output": {
        "items": [
            {
                "id": "slimefun:electromagnet"
            }
        ]
    },
    "type": "slimefun:enhanced_crafting_table"
}

i forgot to mention the |<amount> bit was optional if its just 1; ill edit that in.


## Extensibility

The 5 main recipe classes are all polymorphic, and subclasses can be used in their
stead, and should not affect the recipe system (as long as the right methods are
overriden, see javadocs)

### Custom serialization/deserialization

The default deserializers recognize subclasses with custom deserializers by
the presence of a `class` field in the json, which should be the key of a
custom deserializer registered with Slimefun's `RecipeService`.
For custom serializers, override the `serialize` method on the subclass,
and ensure they also add the `class` field
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nts: come back to this class field

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i might have another solution: registering a deserializer to work addon-wide, but ig there is still the issue of if addon A wants to depend on addon B and use B's deserializers


## Recipe Lifecycle

### Stage 1a

When Slimefun is enabled, all recipes in the resources folder will be
moved to `plugins/Slimefun/recipes/` (unless a file with its name already exists).

Addons should do the same. (We recommend saving to
`plugins/Slimefun/recipes/<your-addon-name>/` but it's not required).

### Stage 1b

Also on enable, recipes defined in code should be registered. These two steps
can be done in no particular order.

### Stage 2

On the first server tick, all recipes in the `plugins/Slimefun/recipes` folder
are read and added to the `RecipeService`, removing all recipes with the
same filename. This is why recipes should ideally be *defined* in JSON,
to prevent unnecessary work.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err interesting...

any reason to not just make plugins register their recipes? avoids the whole 1 tick thing.
Also ideally we just don't ever depend on a filename, that's not great.

RecipeService.registerRecipes(Path.of(getPluginFolder(), "recipes", "recipes.json"));
(this is reading from addon folder which i also think is fine)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted all recipes to be in a single place for the convenience of server owners; like with Items.yml

With how custom recipes now work, they will have to be loaded on the first tick too since they take precedence over all other addons' recipes


When loading JSON recipes, we also need to be able to tell the difference between
a server owner changing a recipe, and a developer changing a recipe. To do this,
we use a system called Recipe Overrides; it allows for updates to recipes from
developers while also preserving custom recipes by server owners

- Slimefun/addons should tell the recipe service it will apply a recipe
override on enable, **before** any JSON recipes are copied from the resources
folder
- The recipe service checks all recipe overrides that have already run
(in the file `plugins/Slimefun/recipe-overrides`) and if it never received
that override before, it deletes the old files and all recipes inside them.
Then all recipes are loaded as before.

### Stage 3

While the server is running, recipes can be modified in code, saved to disk, or
re-loaded from disk. New recipes can also be added, however not to any existing
file (unless forced, which is not recommended)

### Stage 4

On server shutdown (or `/sf recipe save`), **all** recipes are saved to disk.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this include ones where addons register them without a file? If so, why?
I like owners being able to change but I'd also like to not force that to be the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an addon registers a new recipe in code it will have to provide a filename, otherwise we wouldn't know where to save it and server owners would have no way to override it. Also disallowing owners from overriding a recipe doesn't do much since they can fork the addon and change it there

This means any changes made while the server is running will be overwritten.
Server owners should run `/sf recipe reload <file-name?>` to load new recipes
dynamically from disk.

## Phases

Each phase should be a separate PR

- Phase 1 - Add the new API
- Phase 2 - Migrate Slimefun items/multiblocks/machines toward the new API
- Phase 3 - Update the Slimefun Guide to use the new API

The entire process should be seamless for the end users, and
backwards compatible with addons that haven't yet migrated
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@
<include>biome-maps/*.json</include>

<include>languages/**/*.yml</include>
<include>recipes/**/*.json</include>
</includes>
</resource>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
package io.github.thebusybiscuit.slimefun4.api;

import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Set;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
Expand All @@ -11,6 +23,8 @@
import org.bukkit.plugin.java.JavaPlugin;

import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem;
import io.github.thebusybiscuit.slimefun4.core.services.RecipeService;
import io.github.thebusybiscuit.slimefun4.implementation.Slimefun;

/**
* This is a very basic interface that will be used to identify
Expand Down Expand Up @@ -97,4 +111,78 @@ default boolean hasDependency(@Nonnull String dependency) {
return description.getDepend().contains(dependency) || description.getSoftDepend().contains(dependency);
}

/**
* @return A list of all recipes in the resources folder. Addons
* can override this to filter out certain recipes, if desired.
*/
default Set<String> getResourceRecipeFilenames() {
URL resourceDir = getClass().getResource("/recipes");
if (resourceDir == null) {
return Collections.emptySet();
}
URI resourceUri;
try {
resourceUri = resourceDir.toURI();
} catch (URISyntaxException e) {
return Collections.emptySet();
}
if (!resourceUri.getScheme().equals("jar")) {
return Collections.emptySet();
}
try (FileSystem fs = FileSystems.newFileSystem(resourceUri, Collections.emptyMap())) {
Path recipeDir = fs.getPath("/recipes");
try (Stream<Path> files = Files.walk(recipeDir)) {
var names = files
.filter(file -> file.toString().endsWith(".json"))
.map(file -> {
String filename = recipeDir.relativize(file).toString();
return filename.substring(0, filename.length() - 5);
})
.collect(Collectors.toSet());
return names;
} catch (Exception e) {
return Collections.emptySet();
}
} catch (Exception e) {
return Collections.emptySet();
}
}

/**
* Copies all recipes in the recipes folder of the jar to
* <code>plugins/Slimefun/recipes/[subdirectory]</code>
* This should be done on enable. If you need to add
* any recipe overrides, those should be done before calling
* this method.
* @param subdirectory The subdirectory to copy files to
*/
default void copyResourceRecipes(String subdirectory) {
Set<String> existingRecipes = Slimefun.getRecipeService().getAllRecipeFilenames(subdirectory);
Set<String> resourceNames = getResourceRecipeFilenames();
resourceNames.removeIf(existingRecipes::contains);
for (String name : resourceNames) {
try (InputStream source = getClass().getResourceAsStream("/recipes/" + name + ".json")) {
Path dest = Path.of(RecipeService.SAVED_RECIPE_DIR, subdirectory, name + ".json");
Path parent = dest.getParent();
if (parent != null && !parent.toFile().exists()) {
parent.toFile().mkdirs();
}
Files.copy(source, dest);
} catch (Exception e) {
getLogger().warning("Couldn't copy recipes in resource file '" + name + "': " + e.getLocalizedMessage());
throw new RuntimeException(e);
}
}
}

/**
* Copies all recipes in the recipes folder of the jar to
* plugins/Slimefun/recipes. This should be done on enable.
* If you need to add any recipe overrides, those should
* be done before calling this method.
*/
default void copyResourceRecipes() {
copyResourceRecipes("");
}

}
Loading