1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-10-16 14:37:39 +00:00

Compare commits

...

18 Commits

Author SHA1 Message Date
Jonathan Coates
3ebdf7ef5e Bump CC:T to 1.108.3 2023-10-08 15:25:45 +01:00
Jonathan Coates
905d4cb091 Fix crash when joining a dedicated server
We can't use FriendlyByte.readCollection to read to a
pre-allocated/array-backed NonNullList, as that doesn't implement
List.add. Instead, we just need to do a normal loop.

We add a couple of tests to round-trip our recipe specs. Unfortunately
we can't test the recipes themselves as our own registries aren't set
up, so this'll have to do for now.
2023-10-08 15:22:32 +01:00
Jonathan Coates
e7ab05d064 Bump CC:T to 1.108.2 2023-10-08 13:27:24 +01:00
Jonathan Coates
6ec34b42e5 Small cleanup to our web build scripts
- Update to Rollup 4.x
 - Replace terser and postcss with swc and lightningcss. This is
   definitely more code for us to write (maybe I should turn them into
   proper plugins we can depend on), but both speedier and fewer
   dependencies.
 - Drop dependency on glob - we can get away with fs.readdir for what we
   needed it for.
2023-10-08 13:14:02 +01:00
Jonathan Coates
ab785a0906 Fix monitors being warped after a resize
Oh, this was a really nasty bug to reproduce. I'm not sure why - it's
very simple - I guess I've only just seen screenshots of it, and never
sat down to try myself. Reminder to actually report your bugs folks!

In this case:

 1. Place down three down three monitors and then a computer.
 2. Display something on the monitor (monitor left paint a) is my go-to.
 3. Break the middle monitor.

We'd expect the left most monitor to be cleared, however it actually
preserves the monitor contents, resizing (and skewing it) to fit on its
new size!

This is because we clear the server monitor, but never sync that over to
the client, so the client monitor retains the old contents. To fix that,
instead of nulling out the server monitor, we null out the underlying
Terminal. This causes the change to be synced, fixing the bug.
2023-10-03 18:20:44 +01:00
Jonathan Coates
4541decd40 Fix canvas not always being redrawn on term resize
Paint implements its menu slightly differently to edit, in that it takes
control of the event loop until the menu is closed. This means that the
term_resize event is ignored, and so the canvas not redrawn when the
menu is open.
2023-10-03 18:18:33 +01:00
Spongecade
747a5a53b4 Update Minecraft wiki links to new domain (#1601) 2023-10-03 15:55:20 +00:00
Jonathan Coates
c0643fadca Build a web-based emulator for the documentation site (#1597)
Historically we've used copy-cat to provide a web-based emulator for
running example code on our documentation site. However, copy-cat is
often out-of-date with CC:T, which means example snippets fail when you
try to run them!

This commit vendors in copy-cat (or rather an updated version of it)
into CC:T itself, allowing us to ensure the emulator is always in sync
with the mod.

While the ARCHITECTURE.md documentation goes into a little bit more
detail here, the general implementation is as follows

 - In project/src/main we implement the core of the emulator. This
   includes a basic reimplementation of some of CC's classes to work on
   the web (mostly the HTTP API and ComputerThread), and some additional
   code to expose the computers to Javascript.

 - This is all then compiled to Javascript using [TeaVM][1] (we actually
   use a [personal fork of it][2] as there's a couple of changes I've
   not upstreamed yet).

 - The Javascript side then pulls in the these compiled classes (and
   the CC ROM) and hooks them up to [cc-web-term][3] to display the
   actual computer.

 - As we're no longer pulling in copy-cat, we can simplify our bundling
   system a little - we now just compile to ESM modules directly.

[1]: https://github.com/konsoletyper/teavm
[2]: https://github.com/SquidDev/teavm/tree/squid-patches
[3]: https://github.com/squiddev-cc/cc-web-term
2023-10-03 09:19:19 +01:00
Jonathan Coates
0a31de43c2 Run checkstyle on all source sets
Had an issue last week where testFixtures had a couple of issues which I
didn't pick up on, as the pre-commit hooks only check the main and test
source set.

We now add a per-project "checkstyle" task, which dependes on the
per-source-set checkstyle tasks.
2023-10-03 09:06:17 +01:00
Jonathan Coates
96b6947ef2 Flesh out MemoryMount into a writable mount
This moves MemoryMount to the main core module, and converts it to be a
"proper" WritableMount. It's still naively implemented - definitely
would be good to flesh out our tests in the future - but enough for what
we need it for.

We also do the following:
 - Remove the FileEntry.path variable, and instead pass the path around
   as a variable.
 - Clean up BinaryReadableHandle to use ByteBuffers in a more idiomatic
   way.
 - Add a couple more tests to our FS tests. These are in a bit of an odd
   place, where we want both Lua tests (for emulator compliance) and
   Java tests (for testing different implementations) - something to
   think about in the future.
2023-09-29 22:15:23 +01:00
Jonathan Coates
e7a1065bfc Move file transfer API to the code library
This is useful for emulators, which might want to emulate the event.
2023-09-29 21:09:23 +01:00
Jonathan Coates
663eecff0c Relocate our existing web code to subdirectories
- Move the frontend code into src/frontend
 - Move our custom element SSR system into src/htmlTransform.

This is mostly in prep for merging in copy-cat's core, as that's a whole
bunch of extra code.
2023-09-28 21:00:07 +01:00
Jonathan Coates
e6125bcf60 Try to make recipe serialisers more reusable
This attempts to reduce some duplication in recipe serialisation (and
deserialisation) by moving the structure of a recipe (group, category,
ingredients, result) into seprate types.

 - Add ShapedRecipeSpec and ShapelessRecipeSpec, which store the core
   properties of shaped and shapeless recipes. There's a couple of
   additional classes here for handling some of the other shared or
   complex logic.

 - These classes are now used by two new Custom{Shaped,Shapeless}Recipe
   classes, which are (mostly) equivalent to Minecraft's
   shaped/shapeless recipes, just with support for nbt in results.

 - All the other similar recipes now inherit from these base classes,
   which allows us to reuse a lot of this serialisation code. Alas, the
   total code size has still gone up - maybe there's too much
   abstraction here :).

 - Mostly unrelated, but fix the skull recipes using the wrong UUID
   format.

This allows us to remove our mixin for nbt in recipes (as we just use
our custom recipe now) and simplify serialisation a bit - hopefully
making the switch to codecs a little easier.
2023-09-23 18:24:02 +01:00
Jonathan Coates
0d6c6e7ae7 Hoist some ArchiveMount logic into a new superclass
- Add AbstractInMemoryMount, which contains all of ArchiveMount's file
   tree logic, but not the caching functionality.

 - Convert MemoryMount to inherit from AbstractInMemoryMount.

 - Add a helper method to add a file to an AbstractInMemoryMount, and
   use that within {Resource,Jar}Mount.

There's definitely more work to be done here - it might be nice to split
FileEntry into separate Directory and File interfaces, or at least make
them slightly more immutable, but that's definitely a future job.
2023-09-22 07:46:39 +01:00
Jonathan Coates
ae71eb3cae Reduce coupling in websocket code
- Add a new WebsocketClient interface, which WebsocketHandle uses for
   sending messages and closing. This reduces coupling between Websocket
   and WebsocketHandle, which is nice, though admitedly only use for
   copy-cat :).

 - WebsocketHandle now uses Websocket(Client).isClosed(), rather than
   tracking the closed state itself - this makes the class mostly a thin
   Lua wrapper over the client, which is nice.

 - Convert Options into a record.

 - Clarify the behaviour of ws.close() and the websocket_closed event.
   Our previous test was incorrect as it called WebsocketHandle.close
   (rather than WebsocketHandle.doClose), which had slightly different
   semantics in whether the event is queued.
2023-09-21 18:59:15 +01:00
Jonathan Coates
3188197447 Use Preact for static rendering of components
We already use preact for the copy-cat integration, so it makes sense to
use it during the static pass too. This allows us to drop a dependency
on react.
2023-09-20 22:09:58 +01:00
Jonathan Coates
6c8b391dab Some web tooling changes
- Switch to tsx from ts-node, fixing issues on Node 20
 - Update rehype
2023-09-18 17:15:03 +01:00
Jonathan Coates
b1248e4901 Add a tag for blocks wired modems should ignore
Includes wired modems (as before), but can be extended by other mods if
needed.
2023-09-11 21:29:17 +01:00
233 changed files with 7122 additions and 3953 deletions

View File

@@ -44,7 +44,7 @@ repos:
name: Check Java codestyle
files: ".*\\.java$"
language: system
entry: ./gradlew checkstyleMain checkstyleTest
entry: ./gradlew checkstyle
pass_filenames: false
require_serial: true
- id: illuaminate

View File

@@ -10,8 +10,8 @@ Files:
projects/common/src/testMod/resources/data/cctest/structures/*
projects/fabric/src/generated/*
projects/forge/src/generated/*
projects/web/src/export/index.json
projects/web/src/export/items/minecraft/*
projects/web/src/htmlTransform/export/index.json
projects/web/src/htmlTransform/export/items/minecraft/*
Comment: Generated/data files are CC0.
Copyright: The CC: Tweaked Developers
License: CC0-1.0
@@ -37,10 +37,10 @@ Files:
projects/fabric/src/testMod/resources/computercraft-gametest.fabric.mixins.json
projects/fabric/src/testMod/resources/fabric.mod.json
projects/forge/src/client/resources/computercraft-client.forge.mixins.json
projects/web/src/mount/.settings
projects/web/src/mount/example.nfp
projects/web/src/mount/example.nft
projects/web/src/mount/expr_template.lua
projects/web/src/frontend/mount/.settings
projects/web/src/frontend/mount/example.nfp
projects/web/src/frontend/mount/example.nft
projects/web/src/frontend/mount/expr_template.lua
projects/web/tsconfig.json
Comment: Several assets where it's inconvenient to create a .license file.
Copyright: The CC: Tweaked Developers
@@ -56,7 +56,7 @@ Files:
projects/core/src/main/resources/data/computercraft/lua/rom/autorun/.ignoreme
projects/core/src/main/resources/data/computercraft/lua/rom/help/*
projects/core/src/main/resources/data/computercraft/lua/rom/programs/fun/advanced/levels/*
projects/web/src/export/items/computercraft/*
projects/web/src/htmlTransform/export/items/computercraft/*
Comment: Bulk-license original assets as CCPL.
Copyright: 2011 Daniel Ratcliffe
License: LicenseRef-CCPL

View File

@@ -100,7 +100,6 @@ about how you can build on that until you've covered everything!
[new-issue]: https://github.com/cc-tweaked/CC-Tweaked/issues/new/choose "Create a new issue"
[community]: README.md#community "Get in touch with the community."
[Adoptium]: https://adoptium.net/temurin/releases?version=17 "Download OpenJDK 17"
[checkstyle]: https://checkstyle.org/
[illuaminate]: https://github.com/SquidDev/illuaminate/ "Illuaminate on GitHub"
[weblate]: https://i18n.tweaked.cc/projects/cc-tweaked/minecraft/ "CC: Tweaked weblate instance"
[docs]: https://tweaked.cc/ "CC: Tweaked documentation"

View File

@@ -66,6 +66,7 @@ repositories {
includeGroup("me.shedaniel.cloth")
includeGroup("me.shedaniel")
includeGroup("mezz.jei")
includeGroup("org.teavm")
includeModule("com.terraformersmc", "modmenu")
includeModule("me.lucko", "fabric-permissions-api")
}
@@ -99,7 +100,10 @@ sourceSets.all {
check("FutureReturnValueIgnored", CheckSeverity.OFF) // Too many false positives with Netty
check("NullAway", CheckSeverity.ERROR)
option("NullAway:AnnotatedPackages", listOf("dan200.computercraft", "net.fabricmc.fabric.api").joinToString(","))
option(
"NullAway:AnnotatedPackages",
listOf("dan200.computercraft", "cc.tweaked", "net.fabricmc.fabric.api").joinToString(","),
)
option("NullAway:ExcludedFieldAnnotations", listOf("org.spongepowered.asm.mixin.Shadow").joinToString(","))
option("NullAway:CastToNonNullMethod", "dan200.computercraft.core.util.Nullability.assertNonNull")
option("NullAway:CheckOptionalEmptiness")
@@ -174,6 +178,12 @@ project.plugins.withType(CCTweakedPlugin::class.java) {
}
}
tasks.register("checkstyle") {
description = "Run Checkstyle on all sources"
group = LifecycleBasePlugin.VERIFICATION_GROUP
dependsOn(tasks.withType(Checkstyle::class.java))
}
spotless {
encoding = StandardCharsets.UTF_8
lineEndings = LineEnding.UNIX

View File

@@ -4,6 +4,7 @@
package cc.tweaked.gradle
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.AbstractExecTask
import org.gradle.api.tasks.OutputDirectory
@@ -11,5 +12,5 @@ import java.io.File
abstract class ExecToDir : AbstractExecTask<ExecToDir>(ExecToDir::class.java) {
@get:OutputDirectory
abstract val output: Property<File>
abstract val output: DirectoryProperty
}

View File

@@ -5,6 +5,8 @@
package cc.tweaked.gradle
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.file.FileSystemLocation
import org.gradle.api.file.FileSystemLocationProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.JavaExec
@@ -124,3 +126,6 @@ class CloseScope : AutoCloseable {
/** Proxy method to avoid overload ambiguity. */
fun <T> Property<T>.setProvider(provider: Provider<out T>) = set(provider)
/** Short-cut method to get the absolute path of a [FileSystemLocation] provider. */
fun Provider<out FileSystemLocation>.getAbsolutePath(): String = get().asFile.absolutePath

View File

@@ -16,4 +16,10 @@ SPDX-License-Identifier: MPL-2.0
<!-- The commands API is documented in Lua. -->
<suppress checks="SummaryJavadocCheck" files=".*[\\/]CommandAPI.java" />
<!-- Allow putting files in other packages if they look like our TeaVM stubs. -->
<suppress checks="PackageName" files=".*[\\/]T[A-Za-z]+.java" />
<!-- Allow underscores in our test classes. -->
<suppress checks="MethodName" files=".*Contract.java" />
</suppressions>

View File

@@ -29,7 +29,7 @@ for _, file in ipairs(files.getFiles()) do
local size = file.seek("end")
file.seek("set", 0)
print(file.getName() .. " " .. file.getSize())
print(file.getName() .. " " .. size)
end
```

View File

@@ -10,7 +10,7 @@ kotlin.jvm.target.validation.mode=error
# Mod properties
isUnstable=false
modVersion=1.108.1
modVersion=1.108.3
# Minecraft properties: We want to configure this here so we can read it in settings.gradle
mcVersion=1.20.1

View File

@@ -30,7 +30,7 @@ kotlin = "1.8.10"
kotlin-coroutines = "1.6.4"
netty = "4.1.82.Final"
nightConfig = "3.6.7"
slf4j = "1.7.36"
slf4j = "2.0.1"
# Minecraft mods
emi = "1.0.8+1.20.1"
@@ -60,7 +60,7 @@ fabric-loom = "1.3.7"
forgeGradle = "6.0.8"
githubRelease = "2.4.1"
ideaExt = "1.1.7"
illuaminate = "0.1.0-40-g975cbc3"
illuaminate = "0.1.0-44-g9ee0055"
librarian = "1.+"
minotaur = "2.+"
mixinGradle = "0.7.+"
@@ -69,10 +69,12 @@ spotless = "6.21.0"
taskTree = "2.1.1"
vanillaGradle = "0.2.1-SNAPSHOT"
vineflower = "1.11.0"
teavm = "0.9.0-SQUID.1"
[libraries]
# Normal dependencies
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
checkerFramework = { module = "org.checkerframework:checker-qual", version.ref = "checkerFramework" }
cobalt = { module = "org.squiddev:Cobalt", version.ref = "cobalt" }
@@ -138,6 +140,14 @@ librarian = { module = "org.parchmentmc:librarian", version.ref = "librarian" }
minotaur = { module = "com.modrinth.minotaur:Minotaur", version.ref = "minotaur" }
nullAway = { module = "com.uber.nullaway:nullaway", version.ref = "nullAway" }
spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
teavm-classlib = { module = "org.teavm:teavm-classlib", version.ref = "teavm" }
teavm-jso = { module = "org.teavm:teavm-jso", version.ref = "teavm" }
teavm-jso-apis = { module = "org.teavm:teavm-jso-apis", version.ref = "teavm" }
teavm-jso-impl = { module = "org.teavm:teavm-jso-impl", version.ref = "teavm" }
teavm-metaprogramming-api = { module = "org.teavm:teavm-metaprogramming-api", version.ref = "teavm" }
teavm-metaprogramming-impl = { module = "org.teavm:teavm-metaprogramming-impl", version.ref = "teavm" }
teavm-platform = { module = "org.teavm:teavm-platform", version.ref = "teavm" }
teavm-tooling = { module = "org.teavm:teavm-tooling", version.ref = "teavm" }
vanillaGradle = { module = "org.spongepowered:vanillagradle", version.ref = "vanillaGradle" }
vineflower = { module = "io.github.juuxel:loom-vineflower", version.ref = "vineflower" }
@@ -151,6 +161,7 @@ mixinGradle = { id = "org.spongepowered.mixin", version.ref = "mixinGradle" }
taskTree = { id = "com.dorongold.task-tree", version.ref = "taskTree" }
[bundles]
annotations = ["jsr305", "checkerFramework", "jetbrainsAnnotations"]
kotlin = ["kotlin-stdlib", "kotlin-coroutines"]
# Minecraft
@@ -164,3 +175,7 @@ externalMods-fabric-runtime = ["jei-fabric", "modmenu"]
# Testing
test = ["junit-jupiter-api", "junit-jupiter-params", "hamcrest", "jqwik-api"]
testRuntime = ["junit-jupiter-engine", "jqwik-engine"]
# Build tools
teavm-api = [ "teavm-jso", "teavm-jso-apis", "teavm-platform", "teavm-classlib", "teavm-metaprogramming-api" ]
teavm-tooling = [ "teavm-tooling", "teavm-metaprogramming-impl", "teavm-jso-impl" ]

View File

@@ -10,7 +10,7 @@
/projects/core/src/main/resources/data/computercraft/lua/bios.lua
/projects/core/src/main/resources/data/computercraft/lua/rom/
/projects/core/src/test/resources/test-rom
/projects/web/src/mount)
/projects/web/src/frontend/mount)
(doc
; Also defined in projects/web/build.gradle.kts
@@ -23,7 +23,7 @@
(url https://tweaked.cc/)
(source-link https://github.com/cc-tweaked/CC-Tweaked/blob/${commit}/${path}#L${line})
(styles /projects/web/src/styles.css)
(styles /projects/web/build/rollup/index.css)
(scripts /projects/web/build/rollup/index.js)
(head doc/head.html))
@@ -115,4 +115,4 @@
:max sleep write
cct_test describe expect howlci fail it pending stub before_each)))
(at /projects/web/src/mount/expr_template.lua (lint (globals :max __expr__)))
(at /projects/web/src/frontend/mount/expr_template.lua (lint (globals :max __expr__)))

3957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,24 @@
"license": "BSD-3-Clause",
"type": "module",
"dependencies": {
"@squid-dev/cc-web-term": "^2.0.0",
"preact": "^10.5.5",
"setimmediate": "^1.0.5",
"tslib": "^2.0.3"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.0",
"@rollup/plugin-node-resolve": "^15.2.1",
"@rollup/plugin-typescript": "^11.0.0",
"@rollup/plugin-url": "^8.0.1",
"@types/glob": "^8.1.0",
"@types/react-dom": "^18.0.5",
"glob": "^10.3.4",
"react-dom": "^18.1.0",
"react": "^18.1.0",
"rehype-highlight": "^6.0.0",
"rehype-react": "^7.1.1",
"rehype": "^12.0.0",
"requirejs": "^2.3.6",
"rollup": "^3.19.1",
"ts-node": "^10.8.0",
"@swc/core": "^1.3.92",
"@types/node": "^20.8.3",
"lightningcss": "^1.22.0",
"preact-render-to-string": "^6.2.1",
"rehype": "^13.0.0",
"rehype-highlight": "^7.0.0",
"rehype-react": "^8.0.0",
"rollup": "^4.0.0",
"tsx": "^3.12.10",
"typescript": "^5.2.2"
}
}

View File

@@ -46,6 +46,14 @@ public class ComputerCraftTags {
public static final TagKey<Block> WIRED_MODEM = make("wired_modem");
public static final TagKey<Block> MONITOR = make("monitor");
/**
* Blocks which should be ignored by a {@code peripheral_hub} peripheral.
* <p>
* This should include blocks which themselves expose a peripheral hub (such as {@linkplain #WIRED_MODEM wired
* modems}).
*/
public static final TagKey<Block> PERIPHERAL_HUB_IGNORE = make("peripheral_hub_ignore");
/**
* Blocks which can be broken by any turtle tool.
*/

View File

@@ -39,4 +39,6 @@ dependencies {
testModImplementation(testFixtures(project(":core")))
testModImplementation(testFixtures(project(":common")))
testModImplementation(libs.bundles.kotlin)
testFixturesImplementation(testFixtures(project(":core")))
}

View File

@@ -5,6 +5,7 @@
package dan200.computercraft.data;
import com.google.gson.JsonObject;
import com.mojang.authlib.GameProfile;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.api.pocket.PocketUpgradeDataProvider;
import dan200.computercraft.api.turtle.TurtleUpgradeDataProvider;
@@ -25,15 +26,12 @@ import net.minecraft.core.registries.Registries;
import net.minecraft.data.PackOutput;
import net.minecraft.data.recipes.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtUtils;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.tags.TagKey;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.item.DyeItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.*;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapedRecipe;
import net.minecraft.world.item.crafting.SimpleCraftingRecipeSerializer;
import net.minecraft.world.level.ItemLike;
@@ -41,6 +39,7 @@ import net.minecraft.world.level.block.Blocks;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.function.Consumer;
import static dan200.computercraft.api.ComputerCraftTags.Items.COMPUTER;
@@ -443,7 +442,7 @@ class RecipeProvider extends net.minecraft.data.recipes.RecipeProvider {
.requires(ModRegistry.Items.MONITOR_NORMAL.get())
.unlockedBy("has_monitor", inventoryChange(ModRegistry.Items.MONITOR_NORMAL.get()))
.save(
RecipeWrapper.wrap(RecipeSerializer.SHAPELESS_RECIPE, add)
RecipeWrapper.wrap(ModRegistry.RecipeSerializers.SHAPELESS.get(), add)
.withResultTag(playerHead("Cloudhunter", "6d074736-b1e9-4378-a99b-bd8777821c9c")),
new ResourceLocation(ComputerCraftAPI.MOD_ID, "skull_cloudy")
);
@@ -454,7 +453,7 @@ class RecipeProvider extends net.minecraft.data.recipes.RecipeProvider {
.requires(ModRegistry.Items.COMPUTER_ADVANCED.get())
.unlockedBy("has_computer", inventoryChange(ModRegistry.Items.COMPUTER_ADVANCED.get()))
.save(
RecipeWrapper.wrap(RecipeSerializer.SHAPELESS_RECIPE, add)
RecipeWrapper.wrap(ModRegistry.RecipeSerializers.SHAPELESS.get(), add)
.withResultTag(playerHead("dan200", "f3c8d69b-0776-4512-8434-d1b2165909eb")),
new ResourceLocation(ComputerCraftAPI.MOD_ID, "skull_dan200")
);
@@ -513,17 +512,15 @@ class RecipeProvider extends net.minecraft.data.recipes.RecipeProvider {
}
private static CompoundTag playerHead(String name, String uuid) {
var owner = new CompoundTag();
owner.putString("Name", name);
owner.putString("Id", uuid);
var owner = NbtUtils.writeGameProfile(new CompoundTag(), new GameProfile(UUID.fromString(uuid), name));
var tag = new CompoundTag();
tag.put("SkullOwner", owner);
tag.put(PlayerHeadItem.TAG_SKULL_OWNER, owner);
return tag;
}
private static Consumer<JsonObject> family(ComputerFamily family) {
return json -> json.addProperty("family", family.toString());
return json -> json.addProperty("family", family.getSerializedName());
}
private static void addSpecial(Consumer<FinishedRecipe> add, SimpleCraftingRecipeSerializer<?> special) {

View File

@@ -35,6 +35,8 @@ class TagProvider {
tags.tag(ComputerCraftTags.Blocks.WIRED_MODEM).add(ModRegistry.Blocks.CABLE.get(), ModRegistry.Blocks.WIRED_MODEM_FULL.get());
tags.tag(ComputerCraftTags.Blocks.MONITOR).add(ModRegistry.Blocks.MONITOR_NORMAL.get(), ModRegistry.Blocks.MONITOR_ADVANCED.get());
tags.tag(ComputerCraftTags.Blocks.PERIPHERAL_HUB_IGNORE).addTag(ComputerCraftTags.Blocks.WIRED_MODEM);
tags.tag(ComputerCraftTags.Blocks.TURTLE_ALWAYS_BREAKABLE).addTag(BlockTags.LEAVES).add(
Blocks.BAMBOO, Blocks.BAMBOO_SAPLING // Bamboo isn't instabreak for some odd reason.
);

View File

@@ -70,6 +70,10 @@ import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.pocket.peripherals.PocketModem;
import dan200.computercraft.shared.pocket.peripherals.PocketSpeaker;
import dan200.computercraft.shared.pocket.recipes.PocketComputerUpgradeRecipe;
import dan200.computercraft.shared.recipe.CustomShapedRecipe;
import dan200.computercraft.shared.recipe.CustomShapelessRecipe;
import dan200.computercraft.shared.recipe.ImpostorShapedRecipe;
import dan200.computercraft.shared.recipe.ImpostorShapelessRecipe;
import dan200.computercraft.shared.turtle.FurnaceRefuelHandler;
import dan200.computercraft.shared.turtle.blocks.TurtleBlock;
import dan200.computercraft.shared.turtle.blocks.TurtleBlockEntity;
@@ -79,8 +83,6 @@ import dan200.computercraft.shared.turtle.recipes.TurtleOverlayRecipe;
import dan200.computercraft.shared.turtle.recipes.TurtleRecipe;
import dan200.computercraft.shared.turtle.recipes.TurtleUpgradeRecipe;
import dan200.computercraft.shared.turtle.upgrades.*;
import dan200.computercraft.shared.util.ImpostorRecipe;
import dan200.computercraft.shared.util.ImpostorShapelessRecipe;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.synchronization.ArgumentTypeInfo;
import net.minecraft.commands.synchronization.SingletonArgumentInfo;
@@ -359,17 +361,21 @@ public final class ModRegistry {
return REGISTRY.register(name, () -> new SimpleCraftingRecipeSerializer<>(factory));
}
public static final RegistryEntry<RecipeSerializer<CustomShapedRecipe>> SHAPED = REGISTRY.register("shaped", () -> CustomShapedRecipe.serialiser(CustomShapedRecipe::new));
public static final RegistryEntry<RecipeSerializer<CustomShapelessRecipe>> SHAPELESS = REGISTRY.register("shapeless", () -> CustomShapelessRecipe.serialiser(CustomShapelessRecipe::new));
public static final RegistryEntry<RecipeSerializer<ImpostorShapedRecipe>> IMPOSTOR_SHAPED = REGISTRY.register("impostor_shaped", () -> CustomShapedRecipe.serialiser(ImpostorShapedRecipe::new));
public static final RegistryEntry<RecipeSerializer<ImpostorShapelessRecipe>> IMPOSTOR_SHAPELESS = REGISTRY.register("impostor_shapeless", () -> CustomShapelessRecipe.serialiser(ImpostorShapelessRecipe::new));
public static final RegistryEntry<SimpleCraftingRecipeSerializer<ColourableRecipe>> DYEABLE_ITEM = simple("colour", ColourableRecipe::new);
public static final RegistryEntry<SimpleCraftingRecipeSerializer<ClearColourRecipe>> DYEABLE_ITEM_CLEAR = simple("clear_colour", ClearColourRecipe::new);
public static final RegistryEntry<TurtleRecipe.Serializer> TURTLE = REGISTRY.register("turtle", TurtleRecipe.Serializer::new);
public static final RegistryEntry<RecipeSerializer<TurtleRecipe>> TURTLE = REGISTRY.register("turtle", () -> TurtleRecipe.validatingSerialiser(TurtleRecipe::of));
public static final RegistryEntry<SimpleCraftingRecipeSerializer<TurtleUpgradeRecipe>> TURTLE_UPGRADE = simple("turtle_upgrade", TurtleUpgradeRecipe::new);
public static final RegistryEntry<TurtleOverlayRecipe.Serializer> TURTLE_OVERLAY = REGISTRY.register("turtle_overlay", TurtleOverlayRecipe.Serializer::new);
public static final RegistryEntry<RecipeSerializer<TurtleOverlayRecipe>> TURTLE_OVERLAY = REGISTRY.register("turtle_overlay", TurtleOverlayRecipe.Serializer::new);
public static final RegistryEntry<SimpleCraftingRecipeSerializer<PocketComputerUpgradeRecipe>> POCKET_COMPUTER_UPGRADE = simple("pocket_computer_upgrade", PocketComputerUpgradeRecipe::new);
public static final RegistryEntry<SimpleCraftingRecipeSerializer<PrintoutRecipe>> PRINTOUT = simple("printout", PrintoutRecipe::new);
public static final RegistryEntry<SimpleCraftingRecipeSerializer<DiskRecipe>> DISK = simple("disk", DiskRecipe::new);
public static final RegistryEntry<ComputerUpgradeRecipe.Serializer> COMPUTER_UPGRADE = REGISTRY.register("computer_upgrade", ComputerUpgradeRecipe.Serializer::new);
public static final RegistryEntry<ImpostorRecipe.Serializer> IMPOSTOR_SHAPED = REGISTRY.register("impostor_shaped", ImpostorRecipe.Serializer::new);
public static final RegistryEntry<ImpostorShapelessRecipe.Serializer> IMPOSTOR_SHAPELESS = REGISTRY.register("impostor_shapeless", ImpostorShapelessRecipe.Serializer::new);
public static final RegistryEntry<RecipeSerializer<ComputerUpgradeRecipe>> COMPUTER_UPGRADE = REGISTRY.register("computer_upgrade", ComputerUpgradeRecipe.Serializer::new);
}
public static class Permissions {

View File

@@ -4,8 +4,33 @@
package dan200.computercraft.shared.computer.core;
public enum ComputerFamily {
NORMAL,
ADVANCED,
COMMAND
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import net.minecraft.util.GsonHelper;
import net.minecraft.util.StringRepresentable;
public enum ComputerFamily implements StringRepresentable {
NORMAL("normal"),
ADVANCED("advanced"),
COMMAND("command");
private final String name;
ComputerFamily(String name) {
this.name = name;
}
public static ComputerFamily getFamily(JsonObject json, String name) {
var familyName = GsonHelper.getAsString(json, name);
for (var family : values()) {
if (family.getSerializedName().equalsIgnoreCase(familyName)) return family;
}
throw new JsonSyntaxException("Unknown computer family '" + familyName + "' for field " + name);
}
@Override
public String getSerializedName() {
return name;
}
}

View File

@@ -29,8 +29,6 @@ import java.util.Map;
public final class ResourceMount extends ArchiveMount<ResourceMount.FileEntry> {
private static final Logger LOG = LoggerFactory.getLogger(ResourceMount.class);
private static final byte[] TEMP_BUFFER = new byte[8192];
/**
* Maintain a cache of currently loaded resource mounts. This cache is invalidated when currentManager changes.
*/
@@ -60,7 +58,7 @@ public final class ResourceMount extends ArchiveMount<ResourceMount.FileEntry> {
var hasAny = false;
String existingNamespace = null;
var newRoot = new FileEntry("", new ResourceLocation(namespace, subPath));
var newRoot = new FileEntry(new ResourceLocation(namespace, subPath));
for (var file : manager.listResources(subPath, s -> true).keySet()) {
existingNamespace = file.getNamespace();
@@ -68,7 +66,11 @@ public final class ResourceMount extends ArchiveMount<ResourceMount.FileEntry> {
if (!FileSystem.contains(subPath, file.getPath())) continue; // Some packs seem to include the parent?
var localPath = FileSystem.toLocal(file.getPath(), subPath);
create(newRoot, localPath);
try {
getOrCreateChild(newRoot, localPath, this::createEntry);
} catch (ResourceLocationException e) {
LOG.warn("Cannot create resource location for {} ({})", localPath, e.getMessage());
}
hasAny = true;
}
@@ -83,65 +85,24 @@ public final class ResourceMount extends ArchiveMount<ResourceMount.FileEntry> {
}
}
private void create(FileEntry lastEntry, String path) {
var lastIndex = 0;
while (lastIndex < path.length()) {
var nextIndex = path.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = path.length();
var part = path.substring(lastIndex, nextIndex);
if (lastEntry.children == null) lastEntry.children = new HashMap<>();
var nextEntry = lastEntry.children.get(part);
if (nextEntry == null) {
ResourceLocation childPath;
try {
childPath = new ResourceLocation(namespace, subPath + "/" + path);
} catch (ResourceLocationException e) {
LOG.warn("Cannot create resource location for {} ({})", part, e.getMessage());
return;
}
lastEntry.children.put(part, nextEntry = new FileEntry(path, childPath));
}
lastEntry = nextEntry;
lastIndex = nextIndex + 1;
}
private FileEntry createEntry(String path) {
return new FileEntry(new ResourceLocation(namespace, subPath + "/" + path));
}
@Override
public long getSize(FileEntry file) {
protected byte[] getFileContents(String path, FileEntry file) throws IOException {
var resource = manager.getResource(file.identifier).orElse(null);
if (resource == null) return 0;
try (var stream = resource.open()) {
int total = 0, read = 0;
do {
total += read;
read = stream.read(TEMP_BUFFER);
} while (read > 0);
return total;
} catch (IOException e) {
return 0;
}
}
@Override
public byte[] getContents(FileEntry file) throws IOException {
var resource = manager.getResource(file.identifier).orElse(null);
if (resource == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
if (resource == null) throw new FileOperationException(path, NO_SUCH_FILE);
try (var stream = resource.open()) {
return stream.readAllBytes();
}
}
protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
protected static final class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
final ResourceLocation identifier;
FileEntry(String path, ResourceLocation identifier) {
super(path);
FileEntry(ResourceLocation identifier) {
this.identifier = identifier;
}
}

View File

@@ -4,7 +4,12 @@
package dan200.computercraft.shared.computer.menu;
import dan200.computercraft.shared.computer.upload.*;
import dan200.computercraft.core.apis.handles.ByteBufferChannel;
import dan200.computercraft.core.apis.transfer.TransferredFile;
import dan200.computercraft.core.apis.transfer.TransferredFiles;
import dan200.computercraft.shared.computer.upload.FileSlice;
import dan200.computercraft.shared.computer.upload.FileUpload;
import dan200.computercraft.shared.computer.upload.UploadResult;
import dan200.computercraft.shared.network.client.UploadResultMessage;
import dan200.computercraft.shared.platform.PlatformHelper;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
@@ -18,7 +23,6 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* The default concrete implementation of {@link ServerInputHandler}.
@@ -150,8 +154,14 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
}
}
computer.queueEvent("file_transfer", new Object[]{
new TransferredFiles(player, owner, toUpload.stream().map(x -> new TransferredFile(x.getName(), x.getBytes())).collect(Collectors.toList())),
computer.queueEvent(TransferredFiles.EVENT, new Object[]{
new TransferredFiles(
toUpload.stream().map(x -> new TransferredFile(x.getName(), new ByteBufferChannel(x.getBytes()))).toList(),
() -> {
if (player.isAlive() && player.containerMenu == owner) {
PlatformHelper.get().sendToPlayer(UploadResultMessage.consumed(owner), player);
}
}),
});
return UploadResultMessage.queued(owner);
}

View File

@@ -5,31 +5,20 @@
package dan200.computercraft.shared.computer.recipe;
import dan200.computercraft.shared.computer.items.IComputerItem;
import net.minecraft.core.NonNullList;
import dan200.computercraft.shared.recipe.CustomShapedRecipe;
import dan200.computercraft.shared.recipe.ShapedRecipeSpec;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.ShapedRecipe;
import net.minecraft.world.level.Level;
/**
* Represents a recipe which converts a computer from one form into another.
* A recipe which converts a computer from one form into another.
*/
public abstract class ComputerConvertRecipe extends ShapedRecipe {
private final String group;
private final ItemStack result;
public ComputerConvertRecipe(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result) {
super(identifier, group, category, width, height, ingredients, result);
this.group = group;
this.result = result;
}
public ItemStack getResultItem() {
return result;
public abstract class ComputerConvertRecipe extends CustomShapedRecipe {
public ComputerConvertRecipe(ResourceLocation identifier, ShapedRecipeSpec recipe) {
super(identifier, recipe);
}
protected abstract ItemStack convert(IComputerItem item, ItemStack stack);
@@ -55,9 +44,4 @@ public abstract class ComputerConvertRecipe extends ShapedRecipe {
return ItemStack.EMPTY;
}
@Override
public String getGroup() {
return group;
}
}

View File

@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: 2018 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.computer.recipe;
import com.google.gson.JsonObject;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.util.RecipeUtil;
import net.minecraft.core.NonNullList;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
public abstract class ComputerFamilyRecipe extends ComputerConvertRecipe {
private final ComputerFamily family;
public ComputerFamilyRecipe(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family) {
super(identifier, group, category, width, height, ingredients, result);
this.family = family;
}
public ComputerFamily getFamily() {
return family;
}
public abstract static class Serializer<T extends ComputerFamilyRecipe> implements RecipeSerializer<T> {
protected abstract T create(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family);
@Override
public T fromJson(ResourceLocation identifier, JsonObject json) {
var group = GsonHelper.getAsString(json, "group", "");
var category = CraftingBookCategory.CODEC.byName(GsonHelper.getAsString(json, "category", null), CraftingBookCategory.MISC);
var family = RecipeUtil.getFamily(json, "family");
var template = RecipeUtil.getTemplate(json);
var result = itemStackFromJson(GsonHelper.getAsJsonObject(json, "result"));
return create(identifier, group, category, template.width(), template.height(), template.ingredients(), result, family);
}
@Override
public T fromNetwork(ResourceLocation identifier, FriendlyByteBuf buf) {
var width = buf.readVarInt();
var height = buf.readVarInt();
var group = buf.readUtf();
var category = buf.readEnum(CraftingBookCategory.class);
var ingredients = NonNullList.withSize(width * height, Ingredient.EMPTY);
for (var i = 0; i < ingredients.size(); i++) ingredients.set(i, Ingredient.fromNetwork(buf));
var result = buf.readItem();
var family = buf.readEnum(ComputerFamily.class);
return create(identifier, group, category, width, height, ingredients, result, family);
}
@Override
public void toNetwork(FriendlyByteBuf buf, T recipe) {
buf.writeVarInt(recipe.getWidth());
buf.writeVarInt(recipe.getHeight());
buf.writeUtf(recipe.getGroup());
buf.writeEnum(recipe.category());
for (var ingredient : recipe.getIngredients()) ingredient.toNetwork(buf);
buf.writeItem(recipe.getResultItem());
buf.writeEnum(recipe.getFamily());
}
}
}

View File

@@ -4,35 +4,57 @@
package dan200.computercraft.shared.computer.recipe;
import com.google.gson.JsonObject;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.items.IComputerItem;
import net.minecraft.core.NonNullList;
import dan200.computercraft.shared.recipe.ShapedRecipeSpec;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
public final class ComputerUpgradeRecipe extends ComputerFamilyRecipe {
private ComputerUpgradeRecipe(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family) {
super(identifier, group, category, width, height, ingredients, result, family);
/**
* A recipe which "upgrades" a {@linkplain IComputerItem computer}, converting it from one {@linkplain ComputerFamily
* family} to another.
*/
public final class ComputerUpgradeRecipe extends ComputerConvertRecipe {
private final ComputerFamily family;
private ComputerUpgradeRecipe(ResourceLocation identifier, ShapedRecipeSpec recipe, ComputerFamily family) {
super(identifier, recipe);
this.family = family;
}
@Override
protected ItemStack convert(IComputerItem item, ItemStack stack) {
return item.withFamily(stack, getFamily());
return item.withFamily(stack, family);
}
@Override
public RecipeSerializer<?> getSerializer() {
public RecipeSerializer<ComputerUpgradeRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.COMPUTER_UPGRADE.get();
}
public static class Serializer extends ComputerFamilyRecipe.Serializer<ComputerUpgradeRecipe> {
public static class Serializer implements RecipeSerializer<ComputerUpgradeRecipe> {
@Override
protected ComputerUpgradeRecipe create(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family) {
return new ComputerUpgradeRecipe(identifier, group, category, width, height, ingredients, result, family);
public ComputerUpgradeRecipe fromJson(ResourceLocation identifier, JsonObject json) {
var recipe = ShapedRecipeSpec.fromJson(json);
var family = ComputerFamily.getFamily(json, "family");
return new ComputerUpgradeRecipe(identifier, recipe, family);
}
@Override
public ComputerUpgradeRecipe fromNetwork(ResourceLocation identifier, FriendlyByteBuf buf) {
var recipe = ShapedRecipeSpec.fromNetwork(buf);
var family = buf.readEnum(ComputerFamily.class);
return new ComputerUpgradeRecipe(identifier, recipe, family);
}
@Override
public void toNetwork(FriendlyByteBuf buf, ComputerUpgradeRecipe recipe) {
recipe.toSpec().toNetwork(buf);
buf.writeEnum(recipe.family);
}
}
}

View File

@@ -61,7 +61,7 @@ public class ItemDetails {
/*
* Used to hide some data from ItemStack tooltip.
* @see https://minecraft.gamepedia.com/Tutorials/Command_NBT_tags
* @see https://minecraft.wiki/w/Tutorials/Command_NBT_tags
* @see ItemStack#getTooltip
*/
var hideFlags = tag != null ? tag.getInt("HideFlags") : 0;

View File

@@ -4,8 +4,8 @@
package dan200.computercraft.shared.peripheral.modem.wired;
import dan200.computercraft.api.ComputerCraftTags;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ServerContext;
import dan200.computercraft.shared.platform.ComponentAccess;
import dan200.computercraft.shared.platform.PlatformHelper;
@@ -124,8 +124,7 @@ public final class WiredModemLocalPeripheral {
private IPeripheral getPeripheralFrom(Level world, BlockPos pos, Direction direction) {
var offset = pos.relative(direction);
var block = world.getBlockState(offset).getBlock();
if (block == ModRegistry.Blocks.WIRED_MODEM_FULL.get() || block == ModRegistry.Blocks.CABLE.get()) return null;
if (world.getBlockState(offset).is(ComputerCraftTags.Blocks.PERIPHERAL_HUB_IGNORE)) return null;
var peripheral = peripherals.get((ServerLevel) world, pos, direction);
return peripheral instanceof WiredModemPeripheral ? null : peripheral;

View File

@@ -349,13 +349,15 @@ public class MonitorBlockEntity extends BlockEntity {
// Either delete the current monitor or sync a new one.
if (needsTerminal) {
if (serverMonitor == null) serverMonitor = new ServerMonitor(advanced, this);
} else {
serverMonitor = null;
}
// Update the terminal's width and height and rebuild it. This ensures the monitor
// is consistent when syncing it to other monitors.
if (serverMonitor != null) serverMonitor.rebuild();
// Update the terminal's width and height and rebuild it. This ensures the monitor
// is consistent when syncing it to other monitors.
serverMonitor.rebuild();
} else {
// Remove the terminal from the serverMonitor, but keep it around - this ensures that we sync
// the (now blank) monitor to the client.
if (serverMonitor != null) serverMonitor.reset();
}
// Update the other monitors, setting coordinates, dimensions and the server terminal
var pos = getBlockPos();

View File

@@ -55,6 +55,12 @@ public class ServerMonitor {
}
}
synchronized void reset() {
if (terminal == null) return;
terminal = null;
markChanged();
}
private void markChanged() {
if (!changed.getAndSet(true)) TickScheduler.schedule(origin.tickToken);
}

View File

@@ -188,7 +188,7 @@ public abstract class SpeakerPeripheral implements IPeripheral {
* {@literal false}.
* <p>
* ### Valid instruments
* The speaker supports [all of Minecraft's noteblock instruments](https://minecraft.fandom.com/wiki/Note_Block#Instruments).
* The speaker supports [all of Minecraft's noteblock instruments](https://minecraft.wiki/w/Note_Block#Instruments).
* These are:
* <p>
* {@code "harp"}, {@code "basedrum"}, {@code "snare"}, {@code "hat"}, {@code "bass"}, {@code "flute"},
@@ -228,7 +228,7 @@ public abstract class SpeakerPeripheral implements IPeripheral {
/**
* Plays a Minecraft sound through the speaker.
* <p>
* This takes the [name of a Minecraft sound](https://minecraft.fandom.com/wiki/Sounds.json), such as
* This takes the [name of a Minecraft sound](https://minecraft.wiki/w/Sounds.json), such as
* {@code "minecraft:block.note_block.harp"}, as well as an optional volume and pitch.
* <p>
* Only one sound can be played at once. This function will return {@literal false} if another sound was started

View File

@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.mojang.serialization.DataResult;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.Util;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapedRecipe;
/**
* A custom version of {@link ShapedRecipe}, which can be converted to and from a {@link ShapedRecipeSpec}.
* <p>
* This recipe may both be used as a normal recipe (behaving mostly the same as {@link ShapedRecipe}, with
* {@linkplain RecipeUtil#itemStackFromJson(JsonObject) support for putting nbt on the result}), or subclassed to
* customise the crafting behaviour.
*/
public class CustomShapedRecipe extends ShapedRecipe {
private final ItemStack result;
public CustomShapedRecipe(ResourceLocation id, ShapedRecipeSpec recipe) {
super(
id,
recipe.properties().group(), recipe.properties().category(),
recipe.template().width(), recipe.template().height(), recipe.template().ingredients(),
recipe.result()
);
this.result = recipe.result();
}
public final ShapedRecipeSpec toSpec() {
return new ShapedRecipeSpec(RecipeProperties.of(this), ShapedTemplate.of(this), result);
}
@Override
public RecipeSerializer<? extends CustomShapedRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.SHAPED.get();
}
public interface Factory<R> {
R create(ResourceLocation id, ShapedRecipeSpec recipe);
}
public static <T extends CustomShapedRecipe> RecipeSerializer<T> serialiser(CustomShapedRecipe.Factory<T> factory) {
return new Serialiser<>((id, r) -> DataResult.success(factory.create(id, r)));
}
public static <T extends CustomShapedRecipe> RecipeSerializer<T> validatingSerialiser(CustomShapedRecipe.Factory<DataResult<T>> factory) {
return new Serialiser<>(factory);
}
private record Serialiser<T extends CustomShapedRecipe>(
Factory<DataResult<T>> factory
) implements RecipeSerializer<T> {
private Serialiser(Factory<DataResult<T>> factory) {
this.factory = (id, r) -> factory.create(id, r).flatMap(x -> {
if (x.getSerializer() != this) {
return DataResult.error(() -> "Expected serialiser to be " + this + ", but was " + x.getSerializer());
}
return DataResult.success(x);
});
}
@Override
public T fromJson(ResourceLocation id, JsonObject json) {
return Util.getOrThrow(factory.create(id, ShapedRecipeSpec.fromJson(json)), JsonParseException::new);
}
@Override
public T fromNetwork(ResourceLocation id, FriendlyByteBuf buffer) {
return Util.getOrThrow(factory.create(id, ShapedRecipeSpec.fromNetwork(buffer)), IllegalStateException::new);
}
@Override
public void toNetwork(FriendlyByteBuf buffer, T recipe) {
recipe.toSpec().toNetwork(buffer);
}
}
}

View File

@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.mojang.serialization.DataResult;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.Util;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapelessRecipe;
/**
* A custom version of {@link ShapelessRecipe}, which can be converted to and from a {@link ShapelessRecipeSpec}.
* <p>
* This recipe may both be used as a normal recipe (behaving mostly the same as {@link ShapelessRecipe}, with
* {@linkplain RecipeUtil#itemStackFromJson(JsonObject) support for putting nbt on the result}), or subclassed to
* customise the crafting behaviour.
*/
public class CustomShapelessRecipe extends ShapelessRecipe {
private final ItemStack result;
public CustomShapelessRecipe(ResourceLocation id, ShapelessRecipeSpec recipe) {
super(id, recipe.properties().group(), recipe.properties().category(), recipe.result(), recipe.ingredients());
this.result = recipe.result();
}
public final ShapelessRecipeSpec toSpec() {
return new ShapelessRecipeSpec(RecipeProperties.of(this), getIngredients(), result);
}
@Override
public RecipeSerializer<? extends CustomShapelessRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.SHAPELESS.get();
}
public interface Factory<R> {
R create(ResourceLocation id, ShapelessRecipeSpec recipe);
}
public static <T extends CustomShapelessRecipe> RecipeSerializer<T> serialiser(Factory<T> factory) {
return new CustomShapelessRecipe.Serialiser<>((id, r) -> DataResult.success(factory.create(id, r)));
}
public static <T extends CustomShapelessRecipe> RecipeSerializer<T> validatingSerialiser(Factory<DataResult<T>> factory) {
return new CustomShapelessRecipe.Serialiser<>(factory);
}
private record Serialiser<T extends CustomShapelessRecipe>(
Factory<DataResult<T>> factory
) implements RecipeSerializer<T> {
private Serialiser(Factory<DataResult<T>> factory) {
this.factory = (id, r) -> factory.create(id, r).flatMap(x -> {
if (x.getSerializer() != this) {
return DataResult.error(() -> "Expected serialiser to be " + this + ", but was " + x.getSerializer());
}
return DataResult.success(x);
});
}
@Override
public T fromJson(ResourceLocation id, JsonObject json) {
return Util.getOrThrow(factory.create(id, ShapelessRecipeSpec.fromJson(json)), JsonParseException::new);
}
@Override
public T fromNetwork(ResourceLocation id, FriendlyByteBuf buffer) {
return Util.getOrThrow(factory.create(id, ShapelessRecipeSpec.fromNetwork(buffer)), IllegalStateException::new);
}
@Override
public void toNetwork(FriendlyByteBuf buffer, T recipe) {
recipe.toSpec().toNetwork(buffer);
}
}
}

View File

@@ -0,0 +1,41 @@
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
//
// SPDX-License-Identifier: LicenseRef-CCPL
package dan200.computercraft.shared.recipe;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapedRecipe;
import net.minecraft.world.level.Level;
/**
* A fake {@link ShapedRecipe}, which appears in the recipe book (and other recipe mods), but cannot be crafted.
* <p>
* This is used to represent examples for our {@link CustomRecipe}s.
*/
public final class ImpostorShapedRecipe extends CustomShapedRecipe {
public ImpostorShapedRecipe(ResourceLocation id, ShapedRecipeSpec recipe) {
super(id, recipe);
}
@Override
public boolean matches(CraftingContainer inv, Level world) {
return false;
}
@Override
public ItemStack assemble(CraftingContainer inventory, RegistryAccess registryAccess) {
return ItemStack.EMPTY;
}
@Override
public RecipeSerializer<ImpostorShapedRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.IMPOSTOR_SHAPED.get();
}
}

View File

@@ -0,0 +1,41 @@
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
//
// SPDX-License-Identifier: LicenseRef-CCPL
package dan200.computercraft.shared.recipe;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapelessRecipe;
import net.minecraft.world.level.Level;
/**
* A fake {@link ShapelessRecipe}, which appears in the recipe book (and other recipe mods), but cannot be crafted.
* <p>
* This is used to represent examples for our {@link CustomRecipe}s.
*/
public final class ImpostorShapelessRecipe extends CustomShapelessRecipe {
public ImpostorShapelessRecipe(ResourceLocation id, ShapelessRecipeSpec recipe) {
super(id, recipe);
}
@Override
public boolean matches(CraftingContainer inv, Level world) {
return false;
}
@Override
public ItemStack assemble(CraftingContainer inventory, RegistryAccess access) {
return ItemStack.EMPTY;
}
@Override
public RecipeSerializer<ImpostorShapelessRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.IMPOSTOR_SHAPELESS.get();
}
}

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.TagParser;
/**
* Additional codecs for working with recipes.
*/
public class MoreCodecs {
/**
* A codec for {@link CompoundTag}s, which either accepts a NBT-string or a JSON object.
*/
public static final Codec<CompoundTag> TAG = Codec.either(Codec.STRING, CompoundTag.CODEC).flatXmap(
either -> either.map(MoreCodecs::parseTag, DataResult::success),
nbtCompound -> DataResult.success(Either.left(nbtCompound.getAsString()))
);
private static DataResult<CompoundTag> parseTag(String contents) {
try {
return DataResult.success(TagParser.parseTag(contents));
} catch (CommandSyntaxException e) {
return DataResult.error(e::getMessage);
}
}
}

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.CraftingRecipe;
/**
* Common properties that appear in all {@link CraftingRecipe}s.
*
* @param group The (optional) group of the recipe, see {@link CraftingRecipe#getGroup()}.
* @param category The category the recipe appears in, see {@link CraftingRecipe#category()}.
*/
public record RecipeProperties(String group, CraftingBookCategory category) {
public static RecipeProperties of(CraftingRecipe recipe) {
return new RecipeProperties(recipe.getGroup(), recipe.category());
}
public static RecipeProperties fromJson(JsonObject json) {
var group = GsonHelper.getAsString(json, "group", "");
var category = CraftingBookCategory.CODEC.byName(GsonHelper.getAsString(json, "category", null), CraftingBookCategory.MISC);
return new RecipeProperties(group, category);
}
public static RecipeProperties fromNetwork(FriendlyByteBuf buffer) {
var group = buffer.readUtf();
var category = buffer.readEnum(CraftingBookCategory.class);
return new RecipeProperties(group, category);
}
public void toNetwork(FriendlyByteBuf buffer) {
buffer.writeUtf(group());
buffer.writeEnum(category());
}
}

View File

@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2017 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSyntaxException;
import com.mojang.serialization.JsonOps;
import net.minecraft.Util;
import net.minecraft.core.NonNullList;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.ShapedRecipe;
public final class RecipeUtil {
private RecipeUtil() {
}
public static NonNullList<Ingredient> readIngredients(FriendlyByteBuf buffer) {
var count = buffer.readVarInt();
var ingredients = NonNullList.withSize(count, Ingredient.EMPTY);
for (var i = 0; i < ingredients.size(); i++) ingredients.set(i, Ingredient.fromNetwork(buffer));
return ingredients;
}
public static void writeIngredients(FriendlyByteBuf buffer, NonNullList<Ingredient> ingredients) {
buffer.writeCollection(ingredients, (a, b) -> b.toNetwork(a));
}
public static NonNullList<Ingredient> readShapelessIngredients(JsonObject json) {
NonNullList<Ingredient> ingredients = NonNullList.create();
var ingredientsList = GsonHelper.getAsJsonArray(json, "ingredients");
for (var i = 0; i < ingredientsList.size(); ++i) {
var ingredient = Ingredient.fromJson(ingredientsList.get(i));
if (!ingredient.isEmpty()) ingredients.add(ingredient);
}
if (ingredients.isEmpty()) throw new JsonParseException("No ingredients for shapeless recipe");
if (ingredients.size() > 9) {
throw new JsonParseException("Too many ingredients for shapeless recipe the max is 9");
}
return ingredients;
}
/**
* Extends {@link ShapedRecipe#itemStackFromJson(JsonObject)} with support for the {@code nbt} field.
*
* @param json The json to extract the item from.
* @return The parsed item stack.
*/
public static ItemStack itemStackFromJson(JsonObject json) {
var item = ShapedRecipe.itemFromJson(json);
if (json.has("data")) throw new JsonParseException("Disallowed data tag found");
var count = GsonHelper.getAsInt(json, "count", 1);
if (count < 1) throw new JsonSyntaxException("Invalid output count: " + count);
var stack = new ItemStack(item, count);
var nbt = json.get("nbt");
if (nbt != null) {
stack.setTag(Util.getOrThrow(MoreCodecs.TAG.parse(JsonOps.INSTANCE, nbt), JsonParseException::new));
}
return stack;
}
}

View File

@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.ShapedRecipe;
/**
* A description of a {@link ShapedRecipe}.
* <p>
* This is meant to be used in conjunction with {@link CustomShapedRecipe} for more reusable serialisation and
* deserialisation of {@link ShapedRecipe}-like recipes.
*
* @param properties The common properties of this recipe.
* @param template The shaped template of the recipe.
* @param result The result of the recipe.
*/
public record ShapedRecipeSpec(RecipeProperties properties, ShapedTemplate template, ItemStack result) {
public static ShapedRecipeSpec fromJson(JsonObject json) {
var properties = RecipeProperties.fromJson(json);
var template = ShapedTemplate.fromJson(json);
var result = RecipeUtil.itemStackFromJson(GsonHelper.getAsJsonObject(json, "result"));
return new ShapedRecipeSpec(properties, template, result);
}
public static ShapedRecipeSpec fromNetwork(FriendlyByteBuf buffer) {
var properties = RecipeProperties.fromNetwork(buffer);
var template = ShapedTemplate.fromNetwork(buffer);
var result = buffer.readItem();
return new ShapedRecipeSpec(properties, template, result);
}
public void toNetwork(FriendlyByteBuf buffer) {
properties().toNetwork(buffer);
template().toNetwork(buffer);
buffer.writeItem(result());
}
}

View File

@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import net.minecraft.core.NonNullList;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.ShapedRecipe;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* The template for {@linkplain ShapedRecipe shaped recipes}. This largely exists for parsing shaped recipes from JSON.
*
* @param width The width of the recipe, see {@link ShapedRecipe#getWidth()}.
* @param height The height of the recipe, see {@link ShapedRecipe#getHeight()}.
* @param ingredients The ingredients in the recipe, see {@link ShapedRecipe#getIngredients()}
*/
public record ShapedTemplate(int width, int height, NonNullList<Ingredient> ingredients) {
public static ShapedTemplate of(ShapedRecipe recipe) {
return new ShapedTemplate(recipe.getWidth(), recipe.getHeight(), recipe.getIngredients());
}
public static ShapedTemplate fromJson(JsonObject json) {
Map<Character, Ingredient> key = new HashMap<>();
for (var entry : GsonHelper.getAsJsonObject(json, "key").entrySet()) {
if (entry.getKey().length() != 1) {
throw new JsonSyntaxException("Invalid key entry: '" + entry.getKey() + "' is an invalid symbol (must be 1 character only).");
}
if (" ".equals(entry.getKey())) {
throw new JsonSyntaxException("Invalid key entry: ' ' is a reserved symbol.");
}
key.put(entry.getKey().charAt(0), Ingredient.fromJson(entry.getValue()));
}
var patternList = GsonHelper.getAsJsonArray(json, "pattern");
if (patternList.size() == 0) {
throw new JsonSyntaxException("Invalid pattern: empty pattern not allowed");
}
var pattern = new String[patternList.size()];
for (var x = 0; x < pattern.length; x++) {
var line = GsonHelper.convertToString(patternList.get(x), "pattern[" + x + "]");
if (x > 0 && pattern[0].length() != line.length()) {
throw new JsonSyntaxException("Invalid pattern: each row must be the same width");
}
pattern[x] = line;
}
var width = pattern[0].length();
var height = pattern.length;
var ingredients = NonNullList.withSize(width * height, Ingredient.EMPTY);
Set<Character> missingKeys = new HashSet<>(key.keySet());
var ingredientIdx = 0;
for (var line : pattern) {
for (var x = 0; x < line.length(); x++) {
var chr = line.charAt(x);
var ing = chr == ' ' ? Ingredient.EMPTY : key.get(chr);
if (ing == null) {
throw new JsonSyntaxException("Pattern references symbol '" + chr + "' but it's not defined in the key");
}
ingredients.set(ingredientIdx++, ing);
missingKeys.remove(chr);
}
}
if (!missingKeys.isEmpty()) {
throw new JsonSyntaxException("Key defines symbols that aren't used in pattern: " + missingKeys);
}
return new ShapedTemplate(width, height, ingredients);
}
public static ShapedTemplate fromNetwork(FriendlyByteBuf buffer) {
var width = buffer.readVarInt();
var height = buffer.readVarInt();
var ingredients = NonNullList.withSize(width * height, Ingredient.EMPTY);
for (var i = 0; i < ingredients.size(); ++i) ingredients.set(i, Ingredient.fromNetwork(buffer));
return new ShapedTemplate(width, height, ingredients);
}
public void toNetwork(FriendlyByteBuf buffer) {
buffer.writeVarInt(width());
buffer.writeVarInt(height());
for (var ingredient : ingredients) ingredient.toNetwork(buffer);
}
}

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import com.google.gson.JsonObject;
import net.minecraft.core.NonNullList;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.ShapelessRecipe;
/**
* A description of a {@link ShapelessRecipe}.
* <p>
* This is meant to be used in conjunction with {@link CustomShapelessRecipe} for more reusable serialisation and
* deserialisation of {@link ShapelessRecipe}-like recipes.
*
* @param properties The common properties of this recipe.
* @param ingredients The ingredients of the recipe.
* @param result The result of the recipe.
*/
public record ShapelessRecipeSpec(RecipeProperties properties, NonNullList<Ingredient> ingredients, ItemStack result) {
public static ShapelessRecipeSpec fromJson(JsonObject json) {
var properties = RecipeProperties.fromJson(json);
var ingredients = RecipeUtil.readShapelessIngredients(json);
var result = RecipeUtil.itemStackFromJson(GsonHelper.getAsJsonObject(json, "result"));
return new ShapelessRecipeSpec(properties, ingredients, result);
}
public static ShapelessRecipeSpec fromNetwork(FriendlyByteBuf buffer) {
var properties = RecipeProperties.fromNetwork(buffer);
var ingredients = RecipeUtil.readIngredients(buffer);
var result = buffer.readItem();
return new ShapelessRecipeSpec(properties, ingredients, result);
}
public void toNetwork(FriendlyByteBuf buffer) {
properties().toNetwork(buffer);
RecipeUtil.writeIngredients(buffer, ingredients());
buffer.writeItem(result());
}
}

View File

@@ -4,32 +4,30 @@
package dan200.computercraft.shared.turtle.recipes;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import dan200.computercraft.api.turtle.TurtleSide;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.recipe.CustomShapelessRecipe;
import dan200.computercraft.shared.recipe.ShapelessRecipeSpec;
import dan200.computercraft.shared.turtle.items.TurtleItem;
import net.minecraft.core.NonNullList;
import net.minecraft.core.RegistryAccess;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapelessRecipe;
/**
* A {@link ShapelessRecipe} which sets the {@linkplain TurtleItem#getOverlay(ItemStack)} turtle's overlay} instead.
*/
public class TurtleOverlayRecipe extends ShapelessRecipe {
public class TurtleOverlayRecipe extends CustomShapelessRecipe {
private final ResourceLocation overlay;
private final ItemStack result;
public TurtleOverlayRecipe(ResourceLocation id, String group, CraftingBookCategory category, ItemStack result, NonNullList<Ingredient> ingredients, ResourceLocation overlay) {
super(id, group, category, result, ingredients);
public TurtleOverlayRecipe(ResourceLocation id, ShapelessRecipeSpec spec, ResourceLocation overlay) {
super(id, spec);
this.overlay = overlay;
this.result = result;
}
private static ItemStack make(ItemStack stack, ResourceLocation overlay) {
@@ -56,63 +54,29 @@ public class TurtleOverlayRecipe extends ShapelessRecipe {
}
@Override
public RecipeSerializer<?> getSerializer() {
public RecipeSerializer<TurtleOverlayRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.TURTLE_OVERLAY.get();
}
public static class Serializer implements RecipeSerializer<TurtleOverlayRecipe> {
@Override
public TurtleOverlayRecipe fromJson(ResourceLocation id, JsonObject json) {
var group = GsonHelper.getAsString(json, "group", "");
var category = CraftingBookCategory.CODEC.byName(GsonHelper.getAsString(json, "category", null), CraftingBookCategory.MISC);
var ingredients = readIngredients(GsonHelper.getAsJsonArray(json, "ingredients"));
if (ingredients.isEmpty()) throw new JsonParseException("No ingredients for shapeless recipe");
if (ingredients.size() > 9) {
throw new JsonParseException("Too many ingredients for shapeless recipe the max is 9");
}
var recipe = ShapelessRecipeSpec.fromJson(json);
var overlay = new ResourceLocation(GsonHelper.getAsString(json, "overlay"));
// We could derive this from the ingredients, but we want to avoid evaluating the ingredients too early, so
// it's easier to do this.
var result = make(ShapedRecipe.itemStackFromJson(GsonHelper.getAsJsonObject(json, "result")), overlay);
return new TurtleOverlayRecipe(id, group, category, result, ingredients, overlay);
}
private NonNullList<Ingredient> readIngredients(JsonArray arrays) {
NonNullList<Ingredient> items = NonNullList.create();
for (var i = 0; i < arrays.size(); ++i) {
var ingredient = Ingredient.fromJson(arrays.get(i));
if (!ingredient.isEmpty()) items.add(ingredient);
}
return items;
return new TurtleOverlayRecipe(id, recipe, overlay);
}
@Override
public TurtleOverlayRecipe fromNetwork(ResourceLocation id, FriendlyByteBuf buffer) {
var group = buffer.readUtf();
var category = buffer.readEnum(CraftingBookCategory.class);
var count = buffer.readVarInt();
var items = NonNullList.withSize(count, Ingredient.EMPTY);
for (var j = 0; j < items.size(); j++) items.set(j, Ingredient.fromNetwork(buffer));
var result = buffer.readItem();
var recipe = ShapelessRecipeSpec.fromNetwork(buffer);
var overlay = buffer.readResourceLocation();
return new TurtleOverlayRecipe(id, group, category, result, items, overlay);
return new TurtleOverlayRecipe(id, recipe, overlay);
}
@Override
public void toNetwork(FriendlyByteBuf buffer, TurtleOverlayRecipe recipe) {
buffer.writeUtf(recipe.getGroup());
buffer.writeEnum(recipe.category());
buffer.writeVarInt(recipe.getIngredients().size());
for (var ingredient : recipe.getIngredients()) ingredient.toNetwork(buffer);
buffer.writeItem(recipe.result);
recipe.toSpec().toNetwork(buffer);
buffer.writeResourceLocation(recipe.overlay);
}
}

View File

@@ -4,26 +4,33 @@
package dan200.computercraft.shared.turtle.recipes;
import com.mojang.serialization.DataResult;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.items.IComputerItem;
import dan200.computercraft.shared.computer.recipe.ComputerFamilyRecipe;
import dan200.computercraft.shared.computer.recipe.ComputerConvertRecipe;
import dan200.computercraft.shared.recipe.ShapedRecipeSpec;
import dan200.computercraft.shared.turtle.items.TurtleItem;
import net.minecraft.core.NonNullList;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
public final class TurtleRecipe extends ComputerFamilyRecipe {
public TurtleRecipe(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family) {
super(identifier, group, category, width, height, ingredients, result, family);
/**
* The recipe which crafts a turtle from an existing computer item.
*/
public final class TurtleRecipe extends ComputerConvertRecipe {
private final TurtleItem turtle;
private TurtleRecipe(ResourceLocation id, ShapedRecipeSpec recipe, TurtleItem turtle) {
super(id, recipe);
this.turtle = turtle;
}
@Override
public RecipeSerializer<?> getSerializer() {
return ModRegistry.RecipeSerializers.TURTLE.get();
public static DataResult<TurtleRecipe> of(ResourceLocation id, ShapedRecipeSpec recipe) {
if (!(recipe.result().getItem() instanceof TurtleItem turtle)) {
return DataResult.error(() -> recipe.result().getItem() + " is not a turtle item");
}
return DataResult.success(new TurtleRecipe(id, recipe, turtle));
}
@Override
@@ -31,13 +38,11 @@ public final class TurtleRecipe extends ComputerFamilyRecipe {
var computerID = item.getComputerID(stack);
var label = item.getLabel(stack);
return TurtleItem.create(computerID, label, -1, getFamily(), null, null, 0, null);
return turtle.create(computerID, label, -1, null, null, 0, null);
}
public static class Serializer extends ComputerFamilyRecipe.Serializer<TurtleRecipe> {
@Override
protected TurtleRecipe create(ResourceLocation identifier, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result, ComputerFamily family) {
return new TurtleRecipe(identifier, group, category, width, height, ingredients, result, family);
}
@Override
public RecipeSerializer<TurtleRecipe> getSerializer() {
return ModRegistry.RecipeSerializers.TURTLE.get();
}
}

View File

@@ -1,88 +0,0 @@
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
//
// SPDX-License-Identifier: LicenseRef-CCPL
package dan200.computercraft.shared.util;
import com.google.gson.JsonObject;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.core.NonNullList;
import net.minecraft.core.RegistryAccess;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.ShapedRecipe;
import net.minecraft.world.level.Level;
public final class ImpostorRecipe extends ShapedRecipe {
private final String group;
private final ItemStack result;
private ImpostorRecipe(ResourceLocation id, String group, CraftingBookCategory category, int width, int height, NonNullList<Ingredient> ingredients, ItemStack result) {
super(id, group, category, width, height, ingredients, result);
this.group = group;
this.result = result;
}
@Override
public String getGroup() {
return group;
}
ItemStack getResultItem() {
return result;
}
@Override
public boolean matches(CraftingContainer inv, Level world) {
return false;
}
@Override
public ItemStack assemble(CraftingContainer inventory, RegistryAccess registryAccess) {
return ItemStack.EMPTY;
}
@Override
public RecipeSerializer<?> getSerializer() {
return ModRegistry.RecipeSerializers.IMPOSTOR_SHAPED.get();
}
public static class Serializer implements RecipeSerializer<ImpostorRecipe> {
@Override
public ImpostorRecipe fromJson(ResourceLocation identifier, JsonObject json) {
var group = GsonHelper.getAsString(json, "group", "");
var category = CraftingBookCategory.CODEC.byName(GsonHelper.getAsString(json, "category", null), CraftingBookCategory.MISC);
var recipe = RecipeSerializer.SHAPED_RECIPE.fromJson(identifier, json);
var result = ShapedRecipe.itemStackFromJson(GsonHelper.getAsJsonObject(json, "result"));
return new ImpostorRecipe(identifier, group, category, recipe.getWidth(), recipe.getHeight(), recipe.getIngredients(), result);
}
@Override
public ImpostorRecipe fromNetwork(ResourceLocation identifier, FriendlyByteBuf buf) {
var width = buf.readVarInt();
var height = buf.readVarInt();
var group = buf.readUtf(Short.MAX_VALUE);
var category = buf.readEnum(CraftingBookCategory.class);
var items = NonNullList.withSize(width * height, Ingredient.EMPTY);
for (var k = 0; k < items.size(); k++) items.set(k, Ingredient.fromNetwork(buf));
var result = buf.readItem();
return new ImpostorRecipe(identifier, group, category, width, height, items, result);
}
@Override
public void toNetwork(FriendlyByteBuf buf, ImpostorRecipe recipe) {
buf.writeVarInt(recipe.getWidth());
buf.writeVarInt(recipe.getHeight());
buf.writeUtf(recipe.getGroup());
buf.writeEnum(recipe.category());
for (var ingredient : recipe.getIngredients()) ingredient.toNetwork(buf);
buf.writeItem(recipe.getResultItem());
}
}
}

View File

@@ -1,104 +0,0 @@
// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
//
// SPDX-License-Identifier: LicenseRef-CCPL
package dan200.computercraft.shared.util;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import dan200.computercraft.shared.ModRegistry;
import net.minecraft.core.NonNullList;
import net.minecraft.core.RegistryAccess;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.*;
import net.minecraft.world.level.Level;
public final class ImpostorShapelessRecipe extends ShapelessRecipe {
private final String group;
private final ItemStack result;
private ImpostorShapelessRecipe(ResourceLocation id, String group, CraftingBookCategory category, ItemStack result, NonNullList<Ingredient> ingredients) {
super(id, group, category, result, ingredients);
this.group = group;
this.result = result;
}
@Override
public String getGroup() {
return group;
}
ItemStack getResultItem() {
return result;
}
@Override
public boolean matches(CraftingContainer inv, Level world) {
return false;
}
@Override
public ItemStack assemble(CraftingContainer inventory, RegistryAccess access) {
return ItemStack.EMPTY;
}
@Override
public RecipeSerializer<?> getSerializer() {
return ModRegistry.RecipeSerializers.IMPOSTOR_SHAPELESS.get();
}
public static final class Serializer implements RecipeSerializer<ImpostorShapelessRecipe> {
@Override
public ImpostorShapelessRecipe fromJson(ResourceLocation id, JsonObject json) {
var group = GsonHelper.getAsString(json, "group", "");
var category = CraftingBookCategory.CODEC.byName(GsonHelper.getAsString(json, "category", null), CraftingBookCategory.MISC);
var ingredients = readIngredients(GsonHelper.getAsJsonArray(json, "ingredients"));
if (ingredients.isEmpty()) throw new JsonParseException("No ingredients for shapeless recipe");
if (ingredients.size() > 9) {
throw new JsonParseException("Too many ingredients for shapeless recipe the max is 9");
}
var result = ShapedRecipe.itemStackFromJson(GsonHelper.getAsJsonObject(json, "result"));
return new ImpostorShapelessRecipe(id, group, category, result, ingredients);
}
private NonNullList<Ingredient> readIngredients(JsonArray arrays) {
NonNullList<Ingredient> items = NonNullList.create();
for (var i = 0; i < arrays.size(); ++i) {
var ingredient = Ingredient.fromJson(arrays.get(i));
if (!ingredient.isEmpty()) items.add(ingredient);
}
return items;
}
@Override
public ImpostorShapelessRecipe fromNetwork(ResourceLocation id, FriendlyByteBuf buffer) {
var group = buffer.readUtf();
var category = buffer.readEnum(CraftingBookCategory.class);
var count = buffer.readVarInt();
var items = NonNullList.withSize(count, Ingredient.EMPTY);
for (var j = 0; j < items.size(); j++) items.set(j, Ingredient.fromNetwork(buffer));
var result = buffer.readItem();
return new ImpostorShapelessRecipe(id, group, category, result, items);
}
@Override
public void toNetwork(FriendlyByteBuf buffer, ImpostorShapelessRecipe recipe) {
buffer.writeUtf(recipe.getGroup());
buffer.writeEnum(recipe.category());
buffer.writeVarInt(recipe.getIngredients().size());
for (var ingredient : recipe.getIngredients()) ingredient.toNetwork(buffer);
buffer.writeItem(recipe.getResultItem());
}
}
}

View File

@@ -1,94 +0,0 @@
// SPDX-FileCopyrightText: 2017 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.util;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import net.minecraft.core.NonNullList;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.item.crafting.Ingredient;
import java.util.Map;
import java.util.Set;
// TODO: Replace some things with Forge??
public final class RecipeUtil {
private RecipeUtil() {
}
public record ShapedTemplate(int width, int height, NonNullList<Ingredient> ingredients) {
}
public static ShapedTemplate getTemplate(JsonObject json) {
Map<Character, Ingredient> ingMap = Maps.newHashMap();
for (var entry : GsonHelper.getAsJsonObject(json, "key").entrySet()) {
if (entry.getKey().length() != 1) {
throw new JsonSyntaxException("Invalid key entry: '" + entry.getKey() + "' is an invalid symbol (must be 1 character only).");
}
if (" ".equals(entry.getKey())) {
throw new JsonSyntaxException("Invalid key entry: ' ' is a reserved symbol.");
}
ingMap.put(entry.getKey().charAt(0), Ingredient.fromJson(entry.getValue()));
}
ingMap.put(' ', Ingredient.EMPTY);
var patternJ = GsonHelper.getAsJsonArray(json, "pattern");
if (patternJ.size() == 0) {
throw new JsonSyntaxException("Invalid pattern: empty pattern not allowed");
}
var pattern = new String[patternJ.size()];
for (var x = 0; x < pattern.length; x++) {
var line = GsonHelper.convertToString(patternJ.get(x), "pattern[" + x + "]");
if (x > 0 && pattern[0].length() != line.length()) {
throw new JsonSyntaxException("Invalid pattern: each row must be the same width");
}
pattern[x] = line;
}
var width = pattern[0].length();
var height = pattern.length;
var ingredients = NonNullList.withSize(width * height, Ingredient.EMPTY);
Set<Character> missingKeys = Sets.newHashSet(ingMap.keySet());
missingKeys.remove(' ');
var ingredientIdx = 0;
for (var line : pattern) {
for (var i = 0; i < line.length(); i++) {
var chr = line.charAt(i);
var ing = ingMap.get(chr);
if (ing == null) {
throw new JsonSyntaxException("Pattern references symbol '" + chr + "' but it's not defined in the key");
}
ingredients.set(ingredientIdx++, ing);
missingKeys.remove(chr);
}
}
if (!missingKeys.isEmpty()) {
throw new JsonSyntaxException("Key defines symbols that aren't used in pattern: " + missingKeys);
}
return new ShapedTemplate(width, height, ingredients);
}
public static ComputerFamily getFamily(JsonObject json, String name) {
var familyName = GsonHelper.getAsString(json, name);
for (var family : ComputerFamily.values()) {
if (family.name().equalsIgnoreCase(familyName)) return family;
}
throw new JsonSyntaxException("Unknown computer family '" + familyName + "' for field " + name);
}
}

View File

@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import dan200.computercraft.test.shared.MinecraftArbitraries;
import it.unimi.dsi.fastutil.ints.IntIntImmutablePair;
import net.jqwik.api.Arbitraries;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.Combinators;
import net.minecraft.core.NonNullList;
import net.minecraft.world.item.crafting.CraftingBookCategory;
import net.minecraft.world.item.crafting.Ingredient;
/**
* {@link Arbitrary} implementations for recipes.
*/
public final class RecipeArbitraries {
public static Arbitrary<RecipeProperties> recipeProperties() {
return Combinators.combine(
Arbitraries.strings().ofMinLength(1).withChars("abcdefghijklmnopqrstuvwxyz_"),
Arbitraries.of(CraftingBookCategory.values())
).as(RecipeProperties::new);
}
public static Arbitrary<ShapelessRecipeSpec> shapelessRecipeSpec() {
return Combinators.combine(
recipeProperties(),
MinecraftArbitraries.ingredient().array(Ingredient[].class).ofMinSize(1).map(x -> NonNullList.of(Ingredient.EMPTY, x)),
MinecraftArbitraries.nonEmptyItemStack()
).as(ShapelessRecipeSpec::new);
}
public static Arbitrary<ShapedTemplate> shapedTemplate() {
return Combinators.combine(Arbitraries.integers().between(1, 3), Arbitraries.integers().between(1, 3))
.as(IntIntImmutablePair::new)
.flatMap(x -> MinecraftArbitraries.ingredient().array(Ingredient[].class).ofSize(x.leftInt() * x.rightInt())
.map(i -> new ShapedTemplate(x.leftInt(), x.rightInt(), NonNullList.of(Ingredient.EMPTY, i)))
);
}
public static Arbitrary<ShapedRecipeSpec> shapedRecipeSpec() {
return Combinators.combine(
recipeProperties(),
shapedTemplate(),
MinecraftArbitraries.nonEmptyItemStack()
).as(ShapedRecipeSpec::new);
}
}

View File

@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import dan200.computercraft.test.core.StructuralEquality;
import dan200.computercraft.test.shared.MinecraftEqualities;
/**
* {@link StructuralEquality} implementations for recipes.
*/
public final class RecipeEqualities {
private RecipeEqualities() {
}
public static final StructuralEquality<ShapelessRecipeSpec> shapelessRecipeSpec = StructuralEquality.all(
StructuralEquality.at("properties", ShapelessRecipeSpec::properties),
StructuralEquality.at("ingredients", ShapelessRecipeSpec::ingredients, MinecraftEqualities.ingredient.list()),
StructuralEquality.at("result", ShapelessRecipeSpec::result, MinecraftEqualities.itemStack)
);
public static final StructuralEquality<ShapedTemplate> shapedTemplate = StructuralEquality.all(
StructuralEquality.at("width", ShapedTemplate::width),
StructuralEquality.at("height", ShapedTemplate::height),
StructuralEquality.at("ingredients", ShapedTemplate::ingredients, MinecraftEqualities.ingredient.list())
);
public static final StructuralEquality<ShapedRecipeSpec> shapedRecipeSpec = StructuralEquality.all(
StructuralEquality.at("properties", ShapedRecipeSpec::properties),
StructuralEquality.at("ingredients", ShapedRecipeSpec::template, shapedTemplate),
StructuralEquality.at("result", ShapedRecipeSpec::result, MinecraftEqualities.itemStack)
);
}

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import dan200.computercraft.test.shared.NetworkSupport;
import dan200.computercraft.test.shared.WithMinecraft;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.ForAll;
import net.jqwik.api.Property;
import net.jqwik.api.Provide;
import static org.hamcrest.MatcherAssert.assertThat;
@WithMinecraft
public class ShapedRecipeSpecTest {
static {
WithMinecraft.Setup.bootstrap(); // @Property doesn't run test lifecycle methods.
}
@Property
public void testRoundTrip(@ForAll("recipe") ShapedRecipeSpec spec) {
var converted = NetworkSupport.roundTrip(spec, ShapedRecipeSpec::toNetwork, ShapedRecipeSpec::fromNetwork);
assertThat("Recipes are equal", converted, RecipeEqualities.shapedRecipeSpec.asMatcher(ShapedRecipeSpec.class, spec));
}
@Provide
Arbitrary<ShapedRecipeSpec> recipe() {
return RecipeArbitraries.shapedRecipeSpec();
}
}

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.recipe;
import dan200.computercraft.test.shared.NetworkSupport;
import dan200.computercraft.test.shared.WithMinecraft;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.ForAll;
import net.jqwik.api.Property;
import net.jqwik.api.Provide;
import static org.hamcrest.MatcherAssert.assertThat;
@WithMinecraft
public class ShapelessRecipeSpecTest {
static {
WithMinecraft.Setup.bootstrap(); // @Property doesn't run test lifecycle methods.
}
@Property
public void testRoundTrip(@ForAll("recipe") ShapelessRecipeSpec spec) {
var converted = NetworkSupport.roundTrip(spec, ShapelessRecipeSpec::toNetwork, ShapelessRecipeSpec::fromNetwork);
assertThat("Recipes are equal", converted, RecipeEqualities.shapelessRecipeSpec.asMatcher(ShapelessRecipeSpec.class, spec));
}
@Provide
Arbitrary<ShapelessRecipeSpec> recipe() {
return RecipeArbitraries.shapelessRecipeSpec();
}
}

View File

@@ -8,16 +8,13 @@ import dan200.computercraft.api.turtle.ITurtleUpgrade;
import dan200.computercraft.api.turtle.TurtleToolDurability;
import dan200.computercraft.test.core.StructuralEquality;
import dan200.computercraft.test.shared.MinecraftArbitraries;
import dan200.computercraft.test.shared.MinecraftEqualities;
import dan200.computercraft.test.shared.NetworkSupport;
import dan200.computercraft.test.shared.WithMinecraft;
import io.netty.buffer.Unpooled;
import net.jqwik.api.*;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.item.ItemStack;
import org.hamcrest.Description;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@WithMinecraft
class TurtleToolSerialiserTest {
@@ -32,11 +29,9 @@ class TurtleToolSerialiserTest {
*/
@Property
public void testRoundTrip(@ForAll("tool") TurtleTool tool) {
var buffer = new FriendlyByteBuf(Unpooled.directBuffer());
TurtleToolSerialiser.INSTANCE.toNetwork(buffer, tool);
var converted = TurtleToolSerialiser.INSTANCE.fromNetwork(tool.getUpgradeID(), buffer);
assertEquals(buffer.readableBytes(), 0, "Whole packet was read");
var converted = NetworkSupport.roundTripSerialiser(
tool.getUpgradeID(), tool, TurtleToolSerialiser.INSTANCE::toNetwork, TurtleToolSerialiser.INSTANCE::fromNetwork
);
if (!equality.equals(tool, converted)) {
System.out.println("Break");
@@ -58,22 +53,10 @@ class TurtleToolSerialiserTest {
).as(TurtleTool::new);
}
private static final StructuralEquality<ItemStack> stackEquality = new StructuralEquality<>() {
@Override
public boolean equals(ItemStack left, ItemStack right) {
return ItemStack.isSameItemSameTags(left, right) && left.getCount() == right.getCount();
}
@Override
public void describe(Description description, ItemStack object) {
description.appendValue(object).appendValue(object.getTag());
}
};
private static final StructuralEquality<TurtleTool> equality = StructuralEquality.all(
StructuralEquality.at("id", ITurtleUpgrade::getUpgradeID),
StructuralEquality.at("craftingItem", ITurtleUpgrade::getCraftingItem, stackEquality),
StructuralEquality.at("tool", x -> x.item, stackEquality),
StructuralEquality.at("craftingItem", ITurtleUpgrade::getCraftingItem, MinecraftEqualities.itemStack),
StructuralEquality.at("tool", x -> x.item, MinecraftEqualities.itemStack),
StructuralEquality.at("damageMulitiplier", x -> x.damageMulitiplier),
StructuralEquality.at("allowEnchantments", x -> x.allowEnchantments),
StructuralEquality.at("consumeDurability", x -> x.consumeDurability),

View File

@@ -17,6 +17,7 @@ import net.minecraft.tags.TagKey;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.crafting.Ingredient;
import java.util.List;
@@ -44,6 +45,10 @@ public final class MinecraftArbitraries {
return Arbitraries.oneOf(List.of(Arbitraries.just(ItemStack.EMPTY), nonEmptyItemStack()));
}
public static Arbitrary<Ingredient> ingredient() {
return nonEmptyItemStack().list().ofMinSize(1).map(x -> Ingredient.of(x.stream()));
}
public static Arbitrary<BlockPos> blockPos() {
// BlockPos has a maximum range that can be sent over the network - use those.
var xz = Arbitraries.integers().between(-3_000_000, -3_000_000);

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.shared;
import dan200.computercraft.test.core.StructuralEquality;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.Ingredient;
import org.hamcrest.Description;
/**
* {@link StructuralEquality} implementations for Minecraft types.
*/
public class MinecraftEqualities {
public static final StructuralEquality<ItemStack> itemStack = new StructuralEquality<>() {
@Override
public boolean equals(ItemStack left, ItemStack right) {
return ItemStack.isSameItemSameTags(left, right) && left.getCount() == right.getCount();
}
@Override
public void describe(Description description, ItemStack object) {
description.appendValue(object).appendValue(object.getTag());
}
};
public static final StructuralEquality<Ingredient> ingredient = new StructuralEquality<>() {
@Override
public boolean equals(Ingredient left, Ingredient right) {
return left.toJson().equals(right.toJson());
}
@Override
public void describe(Description description, Ingredient object) {
description.appendValue(object.toJson());
}
};
}

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.shared;
import io.netty.buffer.Unpooled;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.crafting.RecipeSerializer;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Support methods for working with Minecraft's networking code.
*/
public final class NetworkSupport {
private NetworkSupport() {
}
/**
* Attempt to serialise and then deserialise a value.
*
* @param value The value to serialise.
* @param write Serialise this value to a buffer.
* @param read Deserialise this value from a buffer.
* @param <T> The type of the value to round trip.
* @return The converted value, for checking equivalency.
*/
public static <T> T roundTrip(T value, BiConsumer<T, FriendlyByteBuf> write, Function<FriendlyByteBuf, T> read) {
var buffer = new FriendlyByteBuf(Unpooled.directBuffer());
write.accept(value, buffer);
var converted = read.apply(buffer);
assertEquals(buffer.readableBytes(), 0, "Whole packet was read");
return converted;
}
/**
* Attempt to serialise and then deserialise a value from a {@link RecipeSerializer}-like interface.
*
* @param id The id of this value.
* @param value The value to serialise.
* @param write Serialise this value to a buffer.
* @param read Deserialise this value from a buffer.
* @param <T> The type of the value to round trip.
* @return The converted value, for checking equivalency.
*/
public static <T> T roundTripSerialiser(ResourceLocation id, T value, BiConsumer<FriendlyByteBuf, T> write, BiFunction<ResourceLocation, FriendlyByteBuf, T> read) {
return roundTrip(value, (x, b) -> write.accept(b, x), b -> read.apply(id, b));
}
}

View File

@@ -4,6 +4,7 @@
package dan200.computercraft.gametest
import com.mojang.authlib.GameProfile
import dan200.computercraft.gametest.api.Structures
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.shared.ModRegistry
@@ -11,6 +12,7 @@ import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.NbtUtils
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.AbstractContainerMenu
import net.minecraft.world.inventory.MenuType
@@ -41,11 +43,10 @@ class Recipe_Test {
val result = recipe.get().assemble(container, context.level.registryAccess())
val owner = CompoundTag()
owner.putString("Name", "dan200")
owner.putString("Id", "f3c8d69b-0776-4512-8434-d1b2165909eb")
val profile = GameProfile(UUID.fromString("f3c8d69b-0776-4512-8434-d1b2165909eb"), "dan200")
val tag = CompoundTag()
tag.put("SkullOwner", owner)
tag.put("SkullOwner", NbtUtils.writeGameProfile(CompoundTag(), profile))
assertEquals(tag, result.tag, "Expected NBT tags to be the same")
}

View File

@@ -18,9 +18,7 @@ val docApi by configurations.registering {
}
dependencies {
compileOnlyApi(libs.jsr305)
compileOnlyApi(libs.checkerFramework)
compileOnlyApi(libs.jetbrainsAnnotations)
compileOnlyApi(libs.bundles.annotations)
"docApi"(project(":common-api"))
}

View File

@@ -12,25 +12,30 @@ import java.time.Instant;
/**
* A simple version of {@link BasicFileAttributes}, which provides what information a {@link Mount} already exposes.
*
* @param isDirectory Whether this filesystem entry is a directory.
* @param size The size of the file.
* @param isDirectory Whether this filesystem entry is a directory.
* @param size The size of the file.
* @param creationTime The time the file was created.
* @param lastModifiedTime The time the file was last modified.
*/
public record FileAttributes(boolean isDirectory, long size) implements BasicFileAttributes {
public record FileAttributes(
boolean isDirectory, long size, FileTime creationTime, FileTime lastModifiedTime
) implements BasicFileAttributes {
private static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
@Override
public FileTime lastModifiedTime() {
return EPOCH;
/**
* Create a new {@link FileAttributes} instance with the {@linkplain #creationTime() creation time} and
* {@linkplain #lastModifiedTime() last modified time} set to the Unix epoch.
*
* @param isDirectory Whether the filesystem entry is a directory.
* @param size The size of the file.
*/
public FileAttributes(boolean isDirectory, long size) {
this(isDirectory, size, EPOCH, EPOCH);
}
@Override
public FileTime lastAccessTime() {
return EPOCH;
}
@Override
public FileTime creationTime() {
return EPOCH;
return lastModifiedTime();
}
@Override

View File

@@ -17,7 +17,6 @@ import dan200.computercraft.core.filesystem.FileSystemException;
import dan200.computercraft.core.metrics.Metrics;
import javax.annotation.Nullable;
import java.nio.file.attribute.FileTime;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@@ -488,9 +487,9 @@ public class FSAPI implements ILuaAPI {
try (var ignored = environment.time(Metrics.FS_OPS)) {
var attributes = getFileSystem().getAttributes(path);
Map<String, Object> result = new HashMap<>();
result.put("modification", getFileTime(attributes.lastModifiedTime()));
result.put("modified", getFileTime(attributes.lastModifiedTime()));
result.put("created", getFileTime(attributes.creationTime()));
result.put("modification", attributes.lastModifiedTime().toMillis());
result.put("modified", attributes.lastModifiedTime().toMillis());
result.put("created", attributes.creationTime().toMillis());
result.put("size", attributes.isDirectory() ? 0 : attributes.size());
result.put("isDir", attributes.isDirectory());
result.put("isReadOnly", getFileSystem().isReadOnly(path));
@@ -499,8 +498,4 @@ public class FSAPI implements ILuaAPI {
throw new LuaException(e.getMessage());
}
}
private static long getFileTime(@Nullable FileTime time) {
return time == null ? 0 : time.toMillis();
}
}

View File

@@ -12,6 +12,7 @@ import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.apis.http.*;
import dan200.computercraft.core.apis.http.request.HttpRequest;
import dan200.computercraft.core.apis.http.websocket.Websocket;
import dan200.computercraft.core.apis.http.websocket.WebsocketClient;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
@@ -165,7 +166,7 @@ public class HTTPAPI implements ILuaAPI {
var timeout = getTimeout(timeoutArg);
try {
var uri = Websocket.checkUri(address);
var uri = WebsocketClient.parseUri(address);
if (!new Websocket(websockets, apiEnvironment, uri, address, headers, timeout).queue(Websocket::connect)) {
throw new LuaException("Too many websockets already open");
}

View File

@@ -49,7 +49,7 @@ import java.util.List;
* end
* }</pre>
* <p>
* [comparator]: https://minecraft.gamepedia.com/Redstone_Comparator#Subtract_signal_strength "Redstone Comparator on
* [comparator]: https://minecraft.wiki/w/Redstone_Comparator#Subtract_signal_strength "Redstone Comparator on
* the Minecraft wiki."
* @cc.module redstone
*/

View File

@@ -77,12 +77,10 @@ public class BinaryReadableHandle extends HandleGeneric {
var buffer = ByteBuffer.allocate(BUFFER_SIZE);
var read = channel.read(buffer);
if (read < 0) return null;
buffer.flip();
// If we failed to read "enough" here, let's just abort
if (read >= count || read < BUFFER_SIZE) {
buffer.flip();
return new Object[]{ buffer };
}
if (read >= count || read < BUFFER_SIZE) return new Object[]{ buffer };
// Build up an array of ByteBuffers. Hopefully this means we can perform less allocation
// than doubling up the buffer each time.
@@ -90,11 +88,13 @@ public class BinaryReadableHandle extends HandleGeneric {
List<ByteBuffer> parts = new ArrayList<>(4);
parts.add(buffer);
while (read >= BUFFER_SIZE && totalRead < count) {
buffer = ByteBuffer.allocate(Math.min(BUFFER_SIZE, count - totalRead));
buffer = ByteBuffer.allocateDirect(Math.min(BUFFER_SIZE, count - totalRead));
read = channel.read(buffer);
if (read < 0) break;
buffer.flip();
totalRead += read;
assert read == buffer.remaining();
parts.add(buffer);
}
@@ -102,9 +102,11 @@ public class BinaryReadableHandle extends HandleGeneric {
var bytes = new byte[totalRead];
var pos = 0;
for (var part : parts) {
System.arraycopy(part.array(), 0, bytes, pos, part.position());
pos += part.position();
int length = part.remaining();
part.get(bytes, pos, length);
pos += length;
}
assert pos == totalRead;
return new Object[]{ bytes };
}
} else {

View File

@@ -134,7 +134,7 @@ public final class NetworkUtils {
*/
public static Options getOptions(String host, InetSocketAddress address) throws HTTPRequestException {
var options = AddressRule.apply(CoreConfig.httpRules, host, address);
if (options.action == Action.DENY) throw new HTTPRequestException("Domain not permitted");
if (options.action() == Action.DENY) throw new HTTPRequestException("Domain not permitted");
return options;
}
@@ -150,7 +150,7 @@ public final class NetworkUtils {
* @throws HTTPRequestException If a proxy is required but not configured correctly.
*/
public static @Nullable Consumer<SocketChannel> getProxyHandler(Options options, int timeout) throws HTTPRequestException {
if (!options.useProxy) return null;
if (!options.useProxy()) return null;
var type = CoreConfig.httpProxyType;
var host = CoreConfig.httpProxyHost;

View File

@@ -6,20 +6,13 @@ package dan200.computercraft.core.apis.http.options;
/**
* Options about a specific domain.
* Options for a given HTTP request or websocket, which control its resource constraints.
*
* @param action Whether to {@link Action#ALLOW} or {@link Action#DENY} this request.
* @param maxUpload The maximum size of the HTTP request.
* @param maxDownload The maximum size of the HTTP response.
* @param websocketMessage The maximum size of a websocket message (outgoing and incoming).
* @param useProxy Whether to use the configured proxy.
*/
public final class Options {
public final Action action;
public final long maxUpload;
public final long maxDownload;
public final int websocketMessage;
public final boolean useProxy;
Options(Action action, long maxUpload, long maxDownload, int websocketMessage, boolean useProxy) {
this.action = action;
this.maxUpload = maxUpload;
this.maxDownload = maxDownload;
this.websocketMessage = websocketMessage;
this.useProxy = useProxy;
}
public record Options(Action action, long maxUpload, long maxDownload, int websocketMessage, boolean useProxy) {
}

View File

@@ -34,7 +34,7 @@ public final class PartialOptions {
this.useProxy = useProxy;
}
Options toOptions() {
public Options toOptions() {
if (options != null) return options;
return options = new Options(

View File

@@ -130,7 +130,7 @@ public class HttpRequest extends Resource<HttpRequest> {
if (isClosed()) return;
var requestBody = getHeaderSize(headers) + postBuffer.capacity();
if (options.maxUpload != 0 && requestBody > options.maxUpload) {
if (options.maxUpload() != 0 && requestBody > options.maxUpload()) {
failure("Request body is too large");
return;
}

View File

@@ -136,7 +136,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<HttpOb
var partial = content.content();
if (partial.isReadable()) {
// If we've read more than we're allowed to handle, abort as soon as possible.
if (options.maxDownload != 0 && responseBody.readableBytes() + partial.readableBytes() > options.maxDownload) {
if (options.maxDownload() != 0 && responseBody.readableBytes() + partial.readableBytes() > options.maxDownload()) {
closed = true;
ctx.close();

View File

@@ -16,8 +16,8 @@ import java.net.URI;
* A version of {@link WebSocketClientHandshaker13} which doesn't add the {@link HttpHeaderNames#ORIGIN} header to the
* original HTTP request.
*/
public class NoOriginWebSocketHandshaker extends WebSocketClientHandshaker13 {
public NoOriginWebSocketHandshaker(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
class NoOriginWebSocketHandshaker extends WebSocketClientHandshaker13 {
NoOriginWebSocketHandshaker(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
super(webSocketURL, version, subprotocol, allowExtensions, customHeaders, maxFramePayloadLength);
}

View File

@@ -12,8 +12,9 @@ import dan200.computercraft.core.apis.http.NetworkUtils;
import dan200.computercraft.core.apis.http.Resource;
import dan200.computercraft.core.apis.http.ResourceGroup;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.util.IoUtil;
import dan200.computercraft.core.metrics.Metrics;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
@@ -23,21 +24,22 @@ import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;
/**
* Provides functionality to verify and connect to a remote websocket.
*/
public class Websocket extends Resource<Websocket> {
public class Websocket extends Resource<Websocket> implements WebsocketClient {
private static final Logger LOG = LoggerFactory.getLogger(Websocket.class);
/**
@@ -46,14 +48,8 @@ public class Websocket extends Resource<Websocket> {
*/
public static final int MAX_MESSAGE_SIZE = 1 << 30;
static final String SUCCESS_EVENT = "websocket_success";
static final String FAILURE_EVENT = "websocket_failure";
static final String CLOSE_EVENT = "websocket_closed";
static final String MESSAGE_EVENT = "websocket_message";
private @Nullable Future<?> executorFuture;
private @Nullable ChannelFuture connectFuture;
private @Nullable WeakReference<WebsocketHandle> websocketHandle;
private @Nullable ChannelFuture channelFuture;
private final IAPIEnvironment environment;
private final URI uri;
@@ -70,38 +66,6 @@ public class Websocket extends Resource<Websocket> {
this.timeout = timeout;
}
public static URI checkUri(String address) throws HTTPRequestException {
URI uri = null;
try {
uri = new URI(address);
} catch (URISyntaxException ignored) {
// Fall through to the case below
}
if (uri == null || uri.getHost() == null) {
try {
uri = new URI("ws://" + address);
} catch (URISyntaxException ignored) {
// Fall through to the case below
}
}
if (uri == null || uri.getHost() == null) throw new HTTPRequestException("URL malformed");
var scheme = uri.getScheme();
if (scheme == null) {
try {
uri = new URI("ws://" + uri);
} catch (URISyntaxException e) {
throw new HTTPRequestException("URL malformed");
}
} else if (!scheme.equalsIgnoreCase("wss") && !scheme.equalsIgnoreCase("ws")) {
throw new HTTPRequestException("Invalid scheme '" + scheme + "'");
}
return uri;
}
public void connect() {
if (isClosed()) return;
executorFuture = NetworkUtils.EXECUTOR.submit(this::doConnect);
@@ -122,7 +86,7 @@ public class Websocket extends Resource<Websocket> {
// getAddress may have a slight delay, so let's perform another cancellation check.
if (isClosed()) return;
connectFuture = new Bootstrap()
channelFuture = new Bootstrap()
.group(NetworkUtils.LOOP_GROUP)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@@ -133,7 +97,7 @@ public class Websocket extends Resource<Websocket> {
var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL);
var handshaker = new NoOriginWebSocketHandshaker(
uri, WebSocketVersion.V13, subprotocol, true, headers,
options.websocketMessage <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage
options.websocketMessage() <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage()
);
var p = ch.pipeline();
@@ -162,12 +126,12 @@ public class Websocket extends Resource<Websocket> {
}
}
void success(Channel channel, Options options) {
void success(Options options) {
if (isClosed()) return;
var handle = new WebsocketHandle(this, options, channel);
var handle = new WebsocketHandle(environment, address, this, options);
environment().queueEvent(SUCCESS_EVENT, address, handle);
websocketHandle = createOwnerReference(handle);
createOwnerReference(handle);
checkClosed();
}
@@ -189,19 +153,35 @@ public class Websocket extends Resource<Websocket> {
super.dispose();
executorFuture = closeFuture(executorFuture);
connectFuture = closeChannel(connectFuture);
var websocketHandleRef = websocketHandle;
var websocketHandle = websocketHandleRef == null ? null : websocketHandleRef.get();
IoUtil.closeQuietly(websocketHandle);
this.websocketHandle = null;
channelFuture = closeChannel(channelFuture);
}
public IAPIEnvironment environment() {
IAPIEnvironment environment() {
return environment;
}
public String address() {
String address() {
return address;
}
private @Nullable Channel channel() {
var channel = channelFuture;
return channel == null ? null : channel.channel();
}
@Override
public void sendText(String message) {
environment.observe(Metrics.WEBSOCKET_OUTGOING, message.length());
var channel = channel();
if (channel != null) channel.writeAndFlush(new TextWebSocketFrame(message));
}
@Override
public void sendBinary(ByteBuffer message) {
environment.observe(Metrics.WEBSOCKET_OUTGOING, message.remaining());
var channel = channel();
if (channel != null) channel.writeAndFlush(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(message)));
}
}

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.apis.http.websocket;
import dan200.computercraft.core.apis.http.HTTPRequestException;
import java.io.Closeable;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
/**
* A client-side websocket, which can be used to send messages to a remote server.
* <p>
* {@link WebsocketHandle} wraps this into a Lua-compatible interface.
*/
public interface WebsocketClient extends Closeable {
String SUCCESS_EVENT = "websocket_success";
String FAILURE_EVENT = "websocket_failure";
String CLOSE_EVENT = "websocket_closed";
String MESSAGE_EVENT = "websocket_message";
/**
* Determine whether this websocket is closed.
*
* @return Whether this websocket is closed.
*/
boolean isClosed();
/**
* Close this websocket.
*/
@Override
void close();
/**
* Send a text websocket frame.
*
* @param message The message to send.
*/
void sendText(String message);
/**
* Send a binary websocket frame.
*
* @param message The message to send.
*/
void sendBinary(ByteBuffer message);
/**
* Parse an address, ensuring it is a valid websocket URI.
*
* @param address The address to parse.
* @return The parsed URI.
* @throws HTTPRequestException If the address is not valid.
*/
static URI parseUri(String address) throws HTTPRequestException {
URI uri = null;
try {
uri = new URI(address);
} catch (URISyntaxException ignored) {
// Fall through to the case below
}
if (uri == null || uri.getHost() == null) {
try {
uri = new URI("ws://" + address);
} catch (URISyntaxException ignored) {
// Fall through to the case below
}
}
if (uri == null || uri.getHost() == null) throw new HTTPRequestException("URL malformed");
var scheme = uri.getScheme();
if (scheme == null) {
try {
uri = new URI("ws://" + uri);
} catch (URISyntaxException e) {
throw new HTTPRequestException("URL malformed");
}
} else if (!scheme.equalsIgnoreCase("wss") && !scheme.equalsIgnoreCase("ws")) {
throw new HTTPRequestException("Invalid scheme '" + scheme + "'");
}
return uri;
}
}

View File

@@ -6,40 +6,34 @@ package dan200.computercraft.core.apis.http.websocket;
import com.google.common.base.Objects;
import dan200.computercraft.api.lua.*;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.metrics.Metrics;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import javax.annotation.Nullable;
import java.io.Closeable;
import java.util.Arrays;
import java.util.Optional;
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
import static dan200.computercraft.core.apis.IAPIEnvironment.TIMER_EVENT;
import static dan200.computercraft.core.apis.http.websocket.Websocket.CLOSE_EVENT;
import static dan200.computercraft.core.apis.http.websocket.Websocket.MESSAGE_EVENT;
import static dan200.computercraft.core.apis.http.websocket.WebsocketClient.CLOSE_EVENT;
import static dan200.computercraft.core.apis.http.websocket.WebsocketClient.MESSAGE_EVENT;
/**
* A websocket, which can be used to send an receive messages with a web server.
* A websocket, which can be used to send and receive messages with a web server.
*
* @cc.module http.Websocket
* @see dan200.computercraft.core.apis.HTTPAPI#websocket On how to open a websocket.
*/
public class WebsocketHandle implements Closeable {
private final Websocket websocket;
public class WebsocketHandle {
private final IAPIEnvironment environment;
private final String address;
private final WebsocketClient websocket;
private final Options options;
private boolean closed = false;
private @Nullable Channel channel;
public WebsocketHandle(Websocket websocket, Options options, Channel channel) {
public WebsocketHandle(IAPIEnvironment environment, String address, WebsocketClient websocket, Options options) {
this.environment = environment;
this.address = address;
this.websocket = websocket;
this.options = options;
this.channel = channel;
}
/**
@@ -58,7 +52,7 @@ public class WebsocketHandle implements Closeable {
public final MethodResult receive(Optional<Double> timeout) throws LuaException {
checkOpen();
var timeoutId = timeout.isPresent()
? websocket.environment().startTimer(Math.round(checkFinite(0, timeout.get()) / 0.05))
? environment.startTimer(Math.round(checkFinite(0, timeout.get()) / 0.05))
: -1;
return new ReceiveCallback(timeoutId).pull;
@@ -78,17 +72,14 @@ public class WebsocketHandle implements Closeable {
checkOpen();
var text = message.value();
if (options.websocketMessage != 0 && text.length() > options.websocketMessage) {
if (options.websocketMessage() != 0 && text.length() > options.websocketMessage()) {
throw new LuaException("Message is too large");
}
websocket.environment().observe(Metrics.WEBSOCKET_OUTGOING, text.length());
var channel = this.channel;
if (channel != null) {
channel.writeAndFlush(binary.orElse(false)
? new BinaryWebSocketFrame(Unpooled.wrappedBuffer(LuaValues.encode(text)))
: new TextWebSocketFrame(text));
if (binary.orElse(false)) {
websocket.sendBinary(LuaValues.encode(text));
} else {
websocket.sendText(text);
}
}
@@ -96,25 +87,13 @@ public class WebsocketHandle implements Closeable {
* Close this websocket. This will terminate the connection, meaning messages can no longer be sent or received
* along it.
*/
@LuaFunction("close")
public final void doClose() {
close();
@LuaFunction
public final void close() {
websocket.close();
}
private void checkOpen() throws LuaException {
if (closed) throw new LuaException("attempt to use a closed file");
}
@Override
public void close() {
closed = true;
var channel = this.channel;
if (channel != null) {
channel.close();
this.channel = null;
}
if (websocket.isClosed()) throw new LuaException("attempt to use a closed file");
}
private final class ReceiveCallback implements ILuaCallback {
@@ -127,9 +106,9 @@ public class WebsocketHandle implements Closeable {
@Override
public MethodResult resume(Object[] event) {
if (event.length >= 3 && Objects.equal(event[0], MESSAGE_EVENT) && Objects.equal(event[1], websocket.address())) {
if (event.length >= 3 && Objects.equal(event[0], MESSAGE_EVENT) && Objects.equal(event[1], address)) {
return MethodResult.of(Arrays.copyOfRange(event, 2, event.length));
} else if (event.length >= 2 && Objects.equal(event[0], CLOSE_EVENT) && Objects.equal(event[1], websocket.address()) && closed) {
} else if (event.length >= 2 && Objects.equal(event[0], CLOSE_EVENT) && Objects.equal(event[1], address) && websocket.isClosed()) {
// If the socket is closed abort.
return MethodResult.of();
} else if (event.length >= 2 && timeoutId != -1 && Objects.equal(event[0], TIMER_EVENT)

View File

@@ -13,14 +13,14 @@ import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil;
import static dan200.computercraft.core.apis.http.websocket.Websocket.MESSAGE_EVENT;
import static dan200.computercraft.core.apis.http.websocket.WebsocketClient.MESSAGE_EVENT;
public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
private final Websocket websocket;
private final Options options;
private boolean handshakeComplete = false;
public WebsocketHandler(Websocket websocket, Options options) {
WebsocketHandler(Websocket websocket, Options options) {
this.websocket = websocket;
this.options = options;
}
@@ -32,9 +32,9 @@ public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) {
websocket.success(ctx.channel(), options);
websocket.success(options);
handshakeComplete = true;
} else if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_TIMEOUT) {
websocket.failure("Timed out");

View File

@@ -2,14 +2,13 @@
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.computer.upload;
package dan200.computercraft.core.apis.transfer;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.apis.handles.BinaryReadableHandle;
import dan200.computercraft.core.apis.handles.ByteBufferChannel;
import dan200.computercraft.core.methods.ObjectSource;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.util.Collections;
import java.util.Optional;
@@ -26,9 +25,9 @@ public class TransferredFile implements ObjectSource {
private final String name;
private final BinaryReadableHandle handle;
public TransferredFile(String name, ByteBuffer contents) {
public TransferredFile(String name, SeekableByteChannel contents) {
this.name = name;
handle = BinaryReadableHandle.of(new ByteBufferChannel(contents));
handle = BinaryReadableHandle.of(contents);
}
/**

View File

@@ -2,13 +2,9 @@
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.computer.upload;
package dan200.computercraft.core.apis.transfer;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.shared.network.client.UploadResultMessage;
import dan200.computercraft.shared.platform.PlatformHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.AbstractContainerMenu;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -19,16 +15,16 @@ import java.util.concurrent.atomic.AtomicBoolean;
* @cc.module [kind=event] file_transfer.TransferredFiles
*/
public class TransferredFiles {
private final ServerPlayer player;
private final AbstractContainerMenu container;
public static final String EVENT = "file_transfer";
private final AtomicBoolean consumed = new AtomicBoolean(false);
private final Runnable onConsumed;
private final List<TransferredFile> files;
public TransferredFiles(ServerPlayer player, AbstractContainerMenu container, List<TransferredFile> files) {
this.player = player;
this.container = container;
public TransferredFiles(List<TransferredFile> files, Runnable onConsumed) {
this.files = files;
this.onConsumed = onConsumed;
}
/**
@@ -44,9 +40,6 @@ public class TransferredFiles {
private void consumed() {
if (consumed.getAndSet(true)) return;
if (player.isAlive() && player.containerMenu == container) {
PlatformHelper.get().sendToPlayer(UploadResultMessage.consumed(container), player);
}
onConsumed.run();
}
}

View File

@@ -20,7 +20,7 @@ import java.util.Objects;
* This is used by {@link ComputerContext} to construct {@linkplain ComputerContext#peripheralMethods() the context-wide
* method supplier}. It should not be used directly.
*/
public class PeripheralMethodSupplier {
public final class PeripheralMethodSupplier {
private static final Generator<PeripheralMethod> GENERATOR = new Generator<>(PeripheralMethod.class, List.of(ILuaContext.class, IComputerAccess.class),
m -> (target, context, computer, args) -> {
var escArgs = args.escapes();
@@ -31,6 +31,9 @@ public class PeripheralMethodSupplier {
method -> (instance, context, computer, args) -> ((IDynamicPeripheral) instance).callMethod(computer, context, method, args)
);
private PeripheralMethodSupplier() {
}
public static MethodSupplier<PeripheralMethod> create(List<GenericMethod> genericMethods) {
return new MethodSupplierImpl<>(genericMethods, GENERATOR, DYNAMIC, x -> x instanceof IDynamicPeripheral dynamic
? Objects.requireNonNull(dynamic.getMethodNames(), "Dynamic methods cannot be null")

View File

@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileAttributes;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* An abstract mount which stores its file tree in memory.
*
* @param <T> The type of file.
*/
public abstract class AbstractInMemoryMount<T extends AbstractInMemoryMount.FileEntry<T>> implements Mount {
protected static final String NO_SUCH_FILE = "No such file";
@Nullable
protected T root;
protected final @Nullable T get(String path) {
var lastEntry = root;
var lastIndex = 0;
while (lastEntry != null && lastIndex < path.length()) {
var nextIndex = path.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = path.length();
lastEntry = lastEntry.children == null ? null : lastEntry.children.get(path.substring(lastIndex, nextIndex));
lastIndex = nextIndex + 1;
}
return lastEntry;
}
@Override
public final boolean exists(String path) {
return get(path) != null;
}
@Override
public final boolean isDirectory(String path) {
var file = get(path);
return file != null && file.isDirectory();
}
@Override
public final void list(String path, List<String> contents) throws IOException {
var file = get(path);
if (file == null || file.children == null) throw new FileOperationException(path, "Not a directory");
contents.addAll(file.children.keySet());
}
@Override
public final long getSize(String path) throws IOException {
var file = get(path);
if (file == null) throw new FileOperationException(path, NO_SUCH_FILE);
return getSize(path, file);
}
/**
* Get the size of a file.
*
* @param path The file path, for error messages.
* @param file The file to get the size of.
* @return The size of the file. This should be 0 for directories, and equal to {@code openForRead(_).size()} for files.
* @throws IOException If the size could not be read.
*/
protected abstract long getSize(String path, T file) throws IOException;
@Override
public final SeekableByteChannel openForRead(String path) throws IOException {
var file = get(path);
if (file == null || file.isDirectory()) throw new FileOperationException(path, NO_SUCH_FILE);
return openForRead(path, file);
}
/**
* Open a file for reading.
*
* @param path The file path, for error messages.
* @param file The file to read. This will not be a directory.
* @return The channel for this file.
*/
protected abstract SeekableByteChannel openForRead(String path, T file) throws IOException;
@Override
public final BasicFileAttributes getAttributes(String path) throws IOException {
var file = get(path);
if (file == null) throw new FileOperationException(path, NO_SUCH_FILE);
return getAttributes(path, file);
}
/**
* Get all attributes of the file.
*
* @param path The file path, for error messages.
* @param file The file to compute attributes for.
* @return The file's attributes.
* @throws IOException If the attributes could not be read.
*/
protected BasicFileAttributes getAttributes(String path, T file) throws IOException {
return new FileAttributes(file.isDirectory(), getSize(path, file));
}
protected T getOrCreateChild(T lastEntry, String localPath, Function<String, T> factory) {
var lastIndex = 0;
while (lastIndex < localPath.length()) {
var nextIndex = localPath.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = localPath.length();
var part = localPath.substring(lastIndex, nextIndex);
if (lastEntry.children == null) lastEntry.children = new HashMap<>(0);
var nextEntry = lastEntry.children.get(part);
if (nextEntry == null || !nextEntry.isDirectory()) {
lastEntry.children.put(part, nextEntry = factory.apply(localPath.substring(0, nextIndex)));
}
lastEntry = nextEntry;
lastIndex = nextIndex + 1;
}
return lastEntry;
}
protected static class FileEntry<T extends FileEntry<T>> {
@Nullable
public Map<String, T> children;
public boolean isDirectory() {
return children != null;
}
}
}

View File

@@ -6,25 +6,21 @@ package dan200.computercraft.core.filesystem;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import dan200.computercraft.api.filesystem.FileAttributes;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* An abstract mount based on some archive of files, such as a Zip or Minecraft's resources.
* <p>
* We assume that we cannot create {@link SeekableByteChannel}s directly from the archive, and so maintain a (shared)
* cache of recently read files and their contents.
*
* @param <T> The type of file.
*/
public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> implements Mount {
public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> extends AbstractInMemoryMount<T> {
protected static final String NO_SUCH_FILE = "No such file";
/**
@@ -44,128 +40,51 @@ public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> implemen
.<FileEntry<?>, byte[]>weigher((k, v) -> v.length)
.build();
@Nullable
protected T root;
private @Nullable T get(String path) {
var lastEntry = root;
var lastIndex = 0;
while (lastEntry != null && lastIndex < path.length()) {
var nextIndex = path.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = path.length();
lastEntry = lastEntry.children == null ? null : lastEntry.children.get(path.substring(lastIndex, nextIndex));
lastIndex = nextIndex + 1;
}
return lastEntry;
}
@Override
public final boolean exists(String path) {
return get(path) != null;
}
@Override
public final boolean isDirectory(String path) {
var file = get(path);
return file != null && file.isDirectory();
}
@Override
public final void list(String path, List<String> contents) throws IOException {
var file = get(path);
if (file == null || !file.isDirectory()) throw new FileOperationException(path, "Not a directory");
file.list(contents);
}
@Override
public final long getSize(String path) throws IOException {
var file = get(path);
if (file == null) throw new FileOperationException(path, NO_SUCH_FILE);
return getCachedSize(file);
}
private long getCachedSize(T file) throws IOException {
protected final long getSize(String path, T file) throws IOException {
if (file.size != -1) return file.size;
if (file.isDirectory()) return file.size = 0;
var contents = CONTENTS_CACHE.getIfPresent(file);
if (contents != null) return file.size = contents.length;
return file.size = getSize(file);
return file.size = contents != null ? contents.length : getFileSize(path, file);
}
/**
* Get the size of a file.
* <p>
* This should only be called once per file, as the result is cached in {@link #getSize(String)}.
* Get the size of the file by reading it (or its metadata) from disk.
*
* @param file The file to compute the size of. This will not be a directory.
* @return The size of the file.
* @throws IOException If the size could not be read.
* @param path The file path, for error messages.
* @param file The file to get the size of.
* @return The file's size.
* @throws IOException If the size could not be computed.
*/
protected abstract long getSize(T file) throws IOException;
protected long getFileSize(String path, T file) throws IOException {
return getContents(path, file).length;
}
@Override
public SeekableByteChannel openForRead(String path) throws IOException {
var file = get(path);
if (file == null || file.isDirectory()) throw new FileOperationException(path, NO_SUCH_FILE);
protected final SeekableByteChannel openForRead(String path, T file) throws IOException {
return new ArrayByteChannel(getContents(path, file));
}
private byte[] getContents(String path, T file) throws IOException {
var cachedContents = CONTENTS_CACHE.getIfPresent(file);
if (cachedContents != null) return new ArrayByteChannel(cachedContents);
if (cachedContents != null) return cachedContents;
var contents = getContents(file);
var contents = getFileContents(path, file);
CONTENTS_CACHE.put(file, contents);
return new ArrayByteChannel(contents);
return contents;
}
/**
* Read the entirety of a file into memory.
*
* @param path The file path, for error messages.
* @param file The file to read into memory. This will not be a directory.
* @return The contents of the file.
*/
protected abstract byte[] getContents(T file) throws IOException;
@Override
public final BasicFileAttributes getAttributes(String path) throws IOException {
var file = get(path);
if (file == null) throw new FileOperationException(path, NO_SUCH_FILE);
return getAttributes(file);
}
/**
* Get all attributes of the file.
*
* @param file The file to compute attributes for. This will not be a directory.
* @return The file's attributes.
* @throws IOException If the attributes could not be read.
*/
protected BasicFileAttributes getAttributes(T file) throws IOException {
return new FileAttributes(file.isDirectory(), getCachedSize(file));
}
protected static class FileEntry<T extends ArchiveMount.FileEntry<T>> {
public final String path;
@Nullable
public Map<String, T> children;
protected abstract byte[] getFileContents(String path, T file) throws IOException;
protected static class FileEntry<T extends FileEntry<T>> extends AbstractInMemoryMount.FileEntry<T> {
long size = -1;
protected FileEntry(String path) {
this.path = path;
}
protected boolean isDirectory() {
return children != null;
}
protected void list(List<String> contents) {
if (children != null) contents.addAll(children.keySet());
}
}
}

View File

@@ -4,8 +4,8 @@
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileAttributes;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.core.util.Nullability;
import javax.annotation.Nullable;
import java.io.Closeable;
@@ -22,7 +22,7 @@ import java.util.zip.ZipFile;
/**
* A mount which reads zip/jar files.
*/
public class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closeable {
public final class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closeable {
private final ZipFile zip;
public JarMount(File jarFile, String subPath) throws IOException {
@@ -42,7 +42,7 @@ public class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closea
}
// Read in all the entries
root = new FileEntry("");
var root = this.root = new FileEntry();
var zipEntries = zip.entries();
while (zipEntries.hasMoreElements()) {
var entry = zipEntries.nextElement();
@@ -51,55 +51,33 @@ public class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closea
if (!entryPath.startsWith(subPath)) continue;
var localPath = FileSystem.toLocal(entryPath, subPath);
create(entry, localPath);
getOrCreateChild(root, localPath, x -> new FileEntry()).setup(entry);
}
}
private void create(ZipEntry entry, String localPath) {
var lastEntry = Nullability.assertNonNull(root);
var lastIndex = 0;
while (lastIndex < localPath.length()) {
var nextIndex = localPath.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = localPath.length();
var part = localPath.substring(lastIndex, nextIndex);
if (lastEntry.children == null) lastEntry.children = new HashMap<>(0);
var nextEntry = lastEntry.children.get(part);
if (nextEntry == null || !nextEntry.isDirectory()) {
lastEntry.children.put(part, nextEntry = new FileEntry(localPath.substring(0, nextIndex)));
}
lastEntry = nextEntry;
lastIndex = nextIndex + 1;
}
lastEntry.setup(entry);
}
@Override
protected long getSize(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
protected long getFileSize(String path, FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileOperationException(path, NO_SUCH_FILE);
return file.zipEntry.getSize();
}
@Override
protected byte[] getContents(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
protected byte[] getFileContents(String path, FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileOperationException(path, NO_SUCH_FILE);
try (var stream = zip.getInputStream(file.zipEntry)) {
return stream.readAllBytes();
} catch (IOException e) {
// Mask other IO exceptions as a non-existent file.
throw new FileOperationException(file.path, NO_SUCH_FILE);
throw new FileOperationException(path, NO_SUCH_FILE);
}
}
@Override
public BasicFileAttributes getAttributes(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
return new ZipEntryAttributes(file.zipEntry);
protected BasicFileAttributes getAttributes(String path, FileEntry file) throws IOException {
return file.zipEntry == null ? super.getAttributes(path, file) : new FileAttributes(
file.isDirectory(), getSize(path, file), orEpoch(file.zipEntry.getCreationTime()), orEpoch(file.zipEntry.getLastModifiedTime())
);
}
@Override
@@ -107,79 +85,19 @@ public class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closea
zip.close();
}
protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
protected static final class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
@Nullable
ZipEntry zipEntry;
protected FileEntry(String path) {
super(path);
}
void setup(ZipEntry entry) {
zipEntry = entry;
size = entry.getSize();
if (children == null && entry.isDirectory()) children = new HashMap<>(0);
}
}
private static class ZipEntryAttributes implements BasicFileAttributes {
private final ZipEntry entry;
private static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
ZipEntryAttributes(ZipEntry entry) {
this.entry = entry;
}
@Override
public FileTime lastModifiedTime() {
return orEpoch(entry.getLastModifiedTime());
}
@Override
public FileTime lastAccessTime() {
return orEpoch(entry.getLastAccessTime());
}
@Override
public FileTime creationTime() {
var time = entry.getCreationTime();
return time == null ? lastModifiedTime() : time;
}
@Override
public boolean isRegularFile() {
return !entry.isDirectory();
}
@Override
public boolean isDirectory() {
return entry.isDirectory();
}
@Override
public boolean isSymbolicLink() {
return false;
}
@Override
public boolean isOther() {
return false;
}
@Override
public long size() {
return entry.getSize();
}
@Nullable
@Override
public Object fileKey() {
return null;
}
private static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
private static FileTime orEpoch(@Nullable FileTime time) {
return time == null ? EPOCH : time;
}
private static FileTime orEpoch(@Nullable FileTime time) {
return time == null ? EPOCH : time;
}
}

View File

@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileAttributes;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.core.util.Nullability;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.util.*;
import static dan200.computercraft.core.filesystem.WritableFileMount.MINIMUM_FILE_SIZE;
/**
* A basic {@link Mount} which stores files and directories in-memory.
*/
public final class MemoryMount extends AbstractInMemoryMount<MemoryMount.FileEntry> implements WritableMount {
private static final byte[] EMPTY = new byte[0];
private static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
private final long capacity;
/**
* Create a memory mount with a 1GB capacity.
*/
public MemoryMount() {
this(1000_000_000);
}
/**
* Create a memory mount with a custom capacity. Note, this is only used in calculations for {@link #getCapacity()}
* and {@link #getRemainingSpace()}, it is not checked when creating or writing files.
*
* @param capacity The capacity of this mount.
*/
public MemoryMount(long capacity) {
this.capacity = capacity;
root = new FileEntry();
root.children = new HashMap<>();
}
public MemoryMount addFile(String file, byte[] contents, FileTime created, FileTime modified) {
var entry = getOrCreateChild(Nullability.assertNonNull(root), file, x -> new FileEntry());
entry.contents = contents;
entry.length = contents.length;
entry.created = created;
entry.modified = modified;
return this;
}
public MemoryMount addFile(String file, String contents, FileTime created, FileTime modified) {
return addFile(file, contents.getBytes(StandardCharsets.UTF_8), created, modified);
}
public MemoryMount addFile(String file, byte[] contents) {
return addFile(file, contents, EPOCH, EPOCH);
}
public MemoryMount addFile(String file, String contents) {
return addFile(file, contents, EPOCH, EPOCH);
}
@Override
protected long getSize(String path, FileEntry file) {
return file.length;
}
@Override
protected SeekableByteChannel openForRead(String path, FileEntry file) throws IOException {
if (file.contents == null) throw new FileOperationException(path, "File is a directory");
return new EntryChannel(file, 0);
}
@Override
protected BasicFileAttributes getAttributes(String path, FileEntry file) throws IOException {
return new FileAttributes(file.isDirectory(), file.length, file.created, file.modified);
}
private @Nullable ParentAndName getParentAndName(String path) {
if (path.isEmpty()) throw new IllegalArgumentException("Path is empty");
var index = path.lastIndexOf('/');
if (index == -1) {
return new ParentAndName(Nullability.assertNonNull(Nullability.assertNonNull(root).children), path);
}
var entry = get(path.substring(0, index));
return entry == null || entry.children == null
? null
: new ParentAndName(entry.children, path.substring(index + 1));
}
@Override
public void makeDirectory(String path) throws IOException {
if (path.isEmpty()) return;
var lastEntry = Nullability.assertNonNull(root);
var lastIndex = 0;
while (lastIndex < path.length()) {
if (lastEntry.children == null) throw new NullPointerException("children is null");
var nextIndex = path.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = path.length();
var part = path.substring(lastIndex, nextIndex);
var nextEntry = lastEntry.children.get(part);
if (nextEntry == null) {
lastEntry.children.put(part, nextEntry = FileEntry.newDir());
} else if (nextEntry.children == null) {
throw new FileOperationException(path, "File exists");
}
lastEntry = nextEntry;
lastIndex = nextIndex + 1;
}
}
@Override
public void delete(String path) throws IOException {
if (path.isEmpty()) throw new AccessDeniedException("Access denied");
var node = getParentAndName(path);
if (node != null) node.parent().remove(node.name());
}
@Override
public void rename(String source, String dest) throws IOException {
if (dest.startsWith(source)) throw new FileOperationException(source, "Cannot move a directory inside itself");
var sourceParent = getParentAndName(source);
if (sourceParent == null || !sourceParent.exists()) throw new FileOperationException(source, "No such file");
var destParent = getParentAndName(dest);
if (destParent == null) throw new FileOperationException(dest, "Parent directory does not exist");
if (destParent.exists()) throw new FileOperationException(dest, "File exists");
destParent.put(sourceParent.parent().remove(sourceParent.name()));
}
private FileEntry getForWrite(String path) throws FileOperationException {
if (path.isEmpty()) throw new FileOperationException(path, "Cannot write to directory");
var parent = getParentAndName(path);
if (parent == null) throw new FileOperationException(path, "Parent directory does not exist");
var file = parent.get();
if (file != null && file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory");
if (file == null) parent.put(file = FileEntry.newFile());
return file;
}
@Override
public SeekableByteChannel openForWrite(String path) throws IOException {
var file = getForWrite(path);
// Truncate the file.
file.contents = EMPTY;
file.length = 0;
return new EntryChannel(file, 0);
}
@Override
public SeekableByteChannel openForAppend(String path) throws IOException {
var file = getForWrite(path);
return new EntryChannel(file, file.length);
}
@Override
public long getRemainingSpace() {
return capacity - computeUsedSpace();
}
@Override
public long getCapacity() {
return capacity;
}
private long computeUsedSpace() {
Queue<FileEntry> queue = new ArrayDeque<>();
queue.add(root);
long size = 0;
FileEntry entry;
while ((entry = queue.poll()) != null) {
if (entry.children == null) {
size += Math.max(MINIMUM_FILE_SIZE, Nullability.assertNonNull(entry.contents).length);
} else {
size += MINIMUM_FILE_SIZE;
queue.addAll(entry.children.values());
}
}
return size - MINIMUM_FILE_SIZE; // Subtract one file for the root.
}
protected static final class FileEntry extends AbstractInMemoryMount.FileEntry<FileEntry> {
FileTime created = EPOCH;
FileTime modified = EPOCH;
@Nullable
byte[] contents;
int length;
static FileEntry newFile() {
var entry = new FileEntry();
entry.contents = EMPTY;
entry.created = entry.modified = FileTime.from(Instant.now());
return entry;
}
static FileEntry newDir() {
var entry = new FileEntry();
entry.children = new HashMap<>();
entry.created = entry.modified = FileTime.from(Instant.now());
return entry;
}
}
private record ParentAndName(Map<String, FileEntry> parent, String name) {
boolean exists() {
return parent.containsKey(name);
}
@Nullable
FileEntry get() {
return parent.get(name);
}
void put(FileEntry entry) {
assert !parent.containsKey(name);
parent.put(name, entry);
}
}
private static final class EntryChannel implements SeekableByteChannel {
private final FileEntry entry;
private long position;
private boolean isOpen = true;
private void checkClosed() throws ClosedChannelException {
if (!isOpen()) throw new ClosedChannelException();
}
private EntryChannel(FileEntry entry, int position) {
this.entry = entry;
this.position = position;
}
@Override
public int read(ByteBuffer destination) throws IOException {
checkClosed();
var backing = Nullability.assertNonNull(entry.contents);
if (position >= backing.length) return -1;
var remaining = Math.min(backing.length - (int) position, destination.remaining());
destination.put(backing, (int) position, remaining);
position += remaining;
return remaining;
}
private byte[] ensureCapacity(int capacity) {
var contents = Nullability.assertNonNull(entry.contents);
if (capacity >= entry.length) {
var newCapacity = Math.max(capacity, contents.length << 1);
contents = entry.contents = Arrays.copyOf(contents, newCapacity);
}
return contents;
}
@Override
public int write(ByteBuffer src) throws IOException {
var toWrite = src.remaining();
var endPosition = position + toWrite;
if (endPosition > 1 << 30) throw new IOException("File is too large");
var contents = ensureCapacity((int) endPosition);
src.get(contents, (int) position, toWrite);
position = endPosition;
if (endPosition > entry.length) entry.length = (int) endPosition;
return toWrite;
}
@Override
public long position() throws IOException {
checkClosed();
return position;
}
@Override
public SeekableByteChannel position(long newPosition) throws IOException {
checkClosed();
if (newPosition < 0) throw new IllegalArgumentException("Position out of bounds");
this.position = newPosition;
return this;
}
@Override
public long size() throws IOException {
checkClosed();
return entry.length;
}
@Override
public SeekableByteChannel truncate(long size) {
throw new UnsupportedOperationException();
}
@Override
public boolean isOpen() {
return isOpen && entry.contents != null;
}
@Override
public void close() throws IOException {
checkClosed();
isOpen = false;
}
}
}

View File

@@ -27,7 +27,7 @@ import java.util.Set;
public class WritableFileMount extends FileMount implements WritableMount {
private static final Logger LOG = LoggerFactory.getLogger(WritableFileMount.class);
private static final long MINIMUM_FILE_SIZE = 500;
static final long MINIMUM_FILE_SIZE = 500;
private static final Set<OpenOption> WRITE_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
private static final Set<OpenOption> APPEND_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND);

View File

@@ -687,7 +687,7 @@ settings.define("paint.default_extension", {
settings.define("list.show_hidden", {
default = false,
description = [[Show hidden files (those starting with "." in the Lua REPL).]],
description = [[Whether the list program show hidden files (those starting with ".").]],
type = "boolean",
})

View File

@@ -17,7 +17,7 @@ instance, `commands.say("Hi!")` is equivalent to `commands.exec("say Hi!")`.
commands. `commands.async.say("Hi!")` is equivalent to
`commands.execAsync("say Hi!")`.
[mc]: https://minecraft.gamepedia.com/Commands
[mc]: https://minecraft.wiki/w/Commands
@module commands
@usage Set the block above this computer to stone:

View File

@@ -96,7 +96,7 @@ end
--
-- If this returns true, you will can [play][`disk.playAudio`] the record.
--
-- [disk]: https://minecraft.gamepedia.com/Music_Disc
-- [disk]: https://minecraft.wiki/w/Music_Disc
--
-- @tparam string name The name of the disk drive.
-- @treturn boolean If the disk is present and has audio saved on it.

View File

@@ -722,7 +722,7 @@ do
- `parse_empty_array`: When false, empty arrays will be parsed as a new table.
By default (or when this value is true), they are parsed as [`empty_json_array`].
[nbt]: https://minecraft.gamepedia.com/NBT_format
[nbt]: https://minecraft.wiki/w/NBT_format
@return[1] The deserialised object
@treturn[2] nil If the object could not be deserialised.
@treturn string A message describing why the JSON string is invalid.

View File

@@ -1,3 +1,17 @@
# New features in CC: Tweaked 1.108.3
Several bug fixes:
* Fix disconnect when joining a dedicated server.
# New features in CC: Tweaked 1.108.2
* Add a tag for which blocks wired modems should ignore.
Several bug fixes:
* Fix monitors sometimes being warped after resizing.
* Fix the skull recipes using the wrong UUID format.
* Fix paint canvas not always being redrawn after a term resize.
# New features in CC: Tweaked 1.108.1
Several bug fixes:

View File

@@ -12,4 +12,4 @@ commands.give( "dan200", "minecraft:diamond", 64 )
This works with any command. Use "commands.async" instead of "commands" to execute asynchronously.
The commands API is only available on Command Computers.
Visit http://minecraft.gamepedia.com/Commands for documentation on all commands.
Visit https://minecraft.wiki/w/Commands for documentation on all commands.

View File

@@ -6,4 +6,4 @@ if sEvent == "key" and nKey == keys.enter then
-- Do something
end
See http://www.minecraftwiki.net/wiki/Key_codes, or the source code, for a complete reference.
See https://www.minecraft.wiki/w/Key_codes, or the source code, for a complete reference.

View File

@@ -1,7 +1,6 @@
New features in CC: Tweaked 1.108.1
New features in CC: Tweaked 1.108.3
Several bug fixes:
* Prevent no-opped players breaking or placing command computers.
* Allow using `@LuaFunction`-annotated methods on classes defined in child classloaders.
* Fix disconnect when joining a dedicated server.
Type "help changelog" to see the full version history.

View File

@@ -275,6 +275,12 @@ local function drawCanvas()
end
end
local function termResize()
w, h = term.getSize()
drawCanvas()
drawInterface()
end
local menu_choices = {
Save = function()
if bReadOnly then
@@ -376,6 +382,8 @@ local function accessMenu()
nMenuPosEnd = nMenuPosEnd + 1
nMenuPosStart = nMenuPosEnd
end
elseif id == "term_resize" then
termResize()
end
end
end
@@ -434,9 +442,7 @@ local function handleEvents()
drawInterface()
end
elseif id == "term_resize" then
w, h = term.getSize()
drawCanvas()
drawInterface()
termResize()
end
end
end

View File

@@ -23,8 +23,8 @@ public class AddressRuleTest {
Action.ALLOW.toPartial()
));
assertEquals(apply(rules, "localhost", 8080).action, Action.ALLOW);
assertEquals(apply(rules, "localhost", 8081).action, Action.DENY);
assertEquals(apply(rules, "localhost", 8080).action(), Action.ALLOW);
assertEquals(apply(rules, "localhost", 8081).action(), Action.DENY);
}
@ParameterizedTest
@@ -43,7 +43,7 @@ public class AddressRuleTest {
"169.254.169.254", // AWS, Digital Ocean, GCP, etc..
})
public void blocksLocalDomains(String domain) {
assertEquals(apply(CoreConfig.httpRules, domain, 80).action, Action.DENY);
assertEquals(apply(CoreConfig.httpRules, domain, 80).action(), Action.DENY);
}
@ParameterizedTest
@@ -52,7 +52,7 @@ public class AddressRuleTest {
"100.63.255.255", "100.128.0.0"
})
public void allowsNonLocalDomains(String domain) {
assertEquals(apply(CoreConfig.httpRules, domain, 80).action, Action.ALLOW);
assertEquals(apply(CoreConfig.httpRules, domain, 80).action(), Action.ALLOW);
}
private Options apply(Iterable<AddressRule> rules, String host, int port) {

View File

@@ -12,10 +12,9 @@ import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.computer.mainthread.MainThread;
import dan200.computercraft.core.computer.mainthread.MainThreadConfig;
import dan200.computercraft.core.filesystem.MemoryMount;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.test.core.computer.BasicEnvironment;
import dan200.computercraft.test.core.filesystem.MemoryMount;
import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount;
import org.junit.jupiter.api.Assertions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,7 +36,7 @@ public class ComputerBootstrap {
.addFile("test.lua", program)
.addFile("startup.lua", "assertion.assert(pcall(loadfile('test.lua', nil, _ENV))) os.shutdown()");
run(new ReadOnlyWritableMount(mount), setup, maxTimes);
run(mount, setup, maxTimes);
}
public static void run(String program, int maxTimes) {

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.test.core.filesystem.MountContract;
import dan200.computercraft.test.core.filesystem.WritableMountContract;
import org.opentest4j.TestAbortedException;
public class MemoryMountTest implements MountContract, WritableMountContract {
@Override
public Mount createSkeleton() {
var mount = new MemoryMount();
mount.addFile("f.lua", "");
mount.addFile("dir/file.lua", "print('testing')", EPOCH, MODIFY_TIME);
return mount;
}
@Override
public MountAccess createMount(long capacity) {
var mount = new MemoryMount(capacity);
return new MountAccess() {
@Override
public WritableMount mount() {
return mount;
}
@Override
public void makeReadOnly(String path) {
throw new TestAbortedException("Not supported for MemoryMount");
}
@Override
public void ensuresExist() {
}
@Override
public long computeRemainingSpace() {
return mount.getRemainingSpace();
}
};
}
}

View File

@@ -27,7 +27,7 @@ object HttpServer {
const val URL: String = "http://127.0.0.1:$PORT"
const val WS_URL: String = "ws://127.0.0.1:$PORT/ws"
fun runServer(run: () -> Unit) {
fun runServer(run: (stop: () -> Unit) -> Unit) {
val workerGroup: EventLoopGroup = NioEventLoopGroup(2)
try {
val ch = ServerBootstrap()
@@ -48,7 +48,7 @@ object HttpServer {
},
).bind(PORT).sync().channel()
try {
run()
run { workerGroup.shutdownGracefully() }
} finally {
ch.close().sync()
}

View File

@@ -5,6 +5,7 @@
package dan200.computercraft.core.apis.http
import dan200.computercraft.api.lua.Coerced
import dan200.computercraft.api.lua.LuaException
import dan200.computercraft.api.lua.ObjectArguments
import dan200.computercraft.core.CoreConfig
import dan200.computercraft.core.apis.HTTPAPI
@@ -22,7 +23,9 @@ import org.hamcrest.Matchers.*
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.*
import kotlin.time.Duration.Companion.milliseconds
class TestHttpApi {
companion object {
@@ -79,12 +82,36 @@ class TestHttpApi {
websocket.close()
val closeEvent = pullEventOrTimeout(500.milliseconds, "websocket_closed")
assertThat("No event was queued", closeEvent, equalTo(null))
}
}
}
@Test
fun `Queues an event when the socket is externally closed`() {
runServer { stop ->
LuaTaskRunner.runTest {
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(WS_URL)), array(equalTo(true)))
val connectEvent = pullEvent()
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(WS_URL), isA(WebsocketHandle::class.java)))
val websocket = connectEvent[2] as WebsocketHandle
stop()
val closeEvent = pullEvent("websocket_closed")
assertThat(
"Websocket was closed",
closeEvent,
array(equalTo("websocket_closed"), equalTo(WS_URL), equalTo("Connection closed"), equalTo(null)),
)
assertThrows<LuaException>("Throws an exception when sending") {
websocket.send(Coerced("hello"), Optional.of(false))
}
}
}
}

View File

@@ -10,10 +10,10 @@ import java.util.Deque;
import java.util.Objects;
/**
* An {@link AutoCloseable} implementation which can be used to combine other [AutoCloseable] instances.
* An {@link AutoCloseable} implementation which can be used to combine other {@link AutoCloseable} instances.
* <p>
* Values which implement {@link AutoCloseable} can be dynamically registered with [CloseScope.add]. When the scope is
* closed, each value is closed in the opposite order.
* Values which implement {@link AutoCloseable} can be dynamically registered with {@link CloseScope#add(AutoCloseable)}.
* When the scope is closed, each value is closed in the opposite order.
* <p>
* This is largely intended for cases where it's not appropriate to nest try-with-resources blocks, for instance when
* nested would be too deep or when objects are dynamically created.

View File

@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.core;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import java.lang.reflect.Method;
/**
* A {@link DisplayNameGenerator} which replaces underscores with spaces. This is equivalent to
* {@link DisplayNameGenerator.ReplaceUnderscores}, but excludes the parameter types.
*
* @see DisplayNameGeneration
*/
public class ReplaceUnderscoresDisplayNameGenerator extends DisplayNameGenerator.ReplaceUnderscores {
@Override
public String generateDisplayNameForMethod(Class<?> testClass, Method testMethod) {
return testMethod.getName().replace('_', ' ');
}
}

View File

@@ -100,6 +100,29 @@ final class StructuralEqualities {
}
}
record ListEquality<T>(StructuralEquality<T> equality) implements StructuralEquality<List<T>> {
@Override
public boolean equals(List<T> left, List<T> right) {
if (left.size() != right.size()) return false;
for (var i = 0; i < left.size(); i++) {
if (!equality.equals(left.get(i), right.get(i))) return false;
}
return true;
}
@Override
public void describe(Description description, List<T> object) {
description.appendText("[");
var separator = false;
for (var value : object) {
if (separator) description.appendText(", ");
separator = true;
equality.describe(description, value);
}
description.appendText("]");
}
}
static final class EqualityMatcher<T> extends TypeSafeMatcher<T> {
private final StructuralEquality<T> equality;
private final T equalTo;

View File

@@ -36,6 +36,15 @@ public interface StructuralEquality<T> {
*/
void describe(Description description, T object);
/**
* Lift this equality to a list of values.
*
* @return A equality for a list of values.
*/
default StructuralEquality<List<T>> list() {
return new StructuralEqualities.ListEquality<>(this);
}
/**
* Convert this equality instance to a {@link Matcher}.
*

View File

@@ -11,10 +11,9 @@ import dan200.computercraft.core.computer.ComputerEnvironment;
import dan200.computercraft.core.computer.GlobalEnvironment;
import dan200.computercraft.core.filesystem.FileMount;
import dan200.computercraft.core.filesystem.JarMount;
import dan200.computercraft.core.filesystem.MemoryMount;
import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.test.core.filesystem.MemoryMount;
import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount;
import java.io.File;
import java.io.IOException;
@@ -32,7 +31,7 @@ public class BasicEnvironment implements ComputerEnvironment, GlobalEnvironment,
private final WritableMount mount;
public BasicEnvironment() {
this(new ReadOnlyWritableMount(new MemoryMount()));
this(new MemoryMount());
}
public BasicEnvironment(WritableMount mount) {

View File

@@ -1,59 +0,0 @@
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* A read-only mount {@link Mount} which provides a list of in-memory set of files.
*/
public class MemoryMount implements Mount {
private final Map<String, byte[]> files = new HashMap<>();
private final Set<String> directories = new HashSet<>();
public MemoryMount() {
directories.add("");
}
@Override
public boolean exists(String path) {
return files.containsKey(path) || directories.contains(path);
}
@Override
public boolean isDirectory(String path) {
return directories.contains(path);
}
@Override
public void list(String path, List<String> files) {
for (var file : this.files.keySet()) {
if (file.startsWith(path)) files.add(file.substring(path.length() + 1));
}
}
@Override
public long getSize(String path) {
throw new RuntimeException("Not implemented");
}
@Override
public SeekableByteChannel openForRead(String path) throws FileOperationException {
var file = files.get(path);
if (file == null) throw new FileOperationException(path, "File not found");
return new ArrayByteChannel(file);
}
public MemoryMount addFile(String file, String contents) {
files.put(file, contents.getBytes(StandardCharsets.UTF_8));
return this;
}
}

View File

@@ -34,7 +34,10 @@ public interface MountContract {
* Create a skeleton mount. This should contain the following files:
*
* <ul>
* <li>{@code dir/file.lua}, containing {@code print('testing')}.</li>
* <li>
* {@code dir/file.lua}, containing {@code print('testing')}. If {@linkplain #hasFileTimes() file times are
* supported}, it should have a modification time of {@link #MODIFY_TIME}.
* </li>
* <li>{@code f.lua}, containing nothing.</li>
* </ul>
*

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.WritableMount;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.charset.StandardCharsets;
/**
* Utility functions for working with mounts.
*/
public final class Mounts {
private Mounts() {
}
/**
* Write a file to this mount.
*
* @param mount The mount to modify.
* @param path The path to write to.
* @param contents The contents of this path.
* @throws IOException If writing fails.
*/
public static void writeFile(WritableMount mount, String path, String contents) throws IOException {
try (var handle = Channels.newWriter(mount.openForWrite(path), StandardCharsets.UTF_8)) {
handle.write(contents);
}
}
}

View File

@@ -1,91 +0,0 @@
// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.filesystem.WritableMount;
import java.io.IOException;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
/**
* Wraps a {@link Mount} into a read-only {@link WritableMount}.
*
* @param mount The original read-only mount we're wrapping.
*/
public record ReadOnlyWritableMount(Mount mount) implements WritableMount {
@Override
public boolean exists(String path) throws IOException {
return mount.exists(path);
}
@Override
public boolean isDirectory(String path) throws IOException {
return mount.isDirectory(path);
}
@Override
public void list(String path, List<String> contents) throws IOException {
mount.list(path, contents);
}
@Override
public long getSize(String path) throws IOException {
return mount.getSize(path);
}
@Override
public SeekableByteChannel openForRead(String path) throws IOException {
return mount.openForRead(path);
}
@Override
public BasicFileAttributes getAttributes(String path) throws IOException {
return mount.getAttributes(path);
}
@Override
public void makeDirectory(String path) throws IOException {
throw new FileOperationException(path, "Access denied");
}
@Override
public void delete(String path) throws IOException {
throw new FileOperationException(path, "Access denied");
}
@Override
public void rename(String source, String dest) throws IOException {
throw new FileOperationException(source, "Access denied");
}
@Override
public SeekableByteChannel openForWrite(String path) throws IOException {
throw new FileOperationException(path, "Access denied");
}
@Override
public SeekableByteChannel openForAppend(String path) throws IOException {
throw new FileOperationException(path, "Access denied");
}
@Override
public long getRemainingSpace() {
return Integer.MAX_VALUE;
}
@Override
public long getCapacity() {
return Integer.MAX_VALUE;
}
@Override
public boolean isReadOnly(String path) {
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More