1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-10-23 09:57:39 +00:00

Compare commits

...

20 Commits

Author SHA1 Message Date
Jonathan Coates
0a8d505323 Bump CC:T to 1.115.0 2025-02-14 20:20:30 +00:00
Jonathan Coates
237a0ac3bb Expose printout contents to the API
Closes #2099
2025-02-14 18:13:20 +00:00
Jonathan Coates
b185d088b3 Suggest alternative table keys on nil errors (#2097)
We now suggest alternative table keys when code errors with "attempt
to index/call 'foo' (a nil value)". For example: "redstone.getinput()",
will now suggest "Did you mean: getInput".

This is a bit tricky to get right! In the above example, our code reads
like:

   1    GETTABUP 0 0 0 ; r0 := _ENV["redstone"]
   2    GETFIELD 0 0 1 ; r0 := r0["getinput"]
   3    CALL 0 1 1     ; r0()

Note, that when we get to the problematic line, we don't have access to
the original table that we attempted to index. In order to do this, we
borrow ideas from Lua's getobjname — we effectively write an evaluator
that walks back over the code and tries to reconstruct the expression
that resulted in nil.

For example, in the above case:
 - We know an instruction happened at pc=3, so we try to find the
   expression that computed r0.
 - We know this was set at pc=2, so we step back one. This is a GETFIELD
   instruction, so we check the key (it's a constant, so worth
   reporting), and then try to evaluate the table.
 - This version of r0 was set at pc=1, so we step back again. It's a
   GETTABUP instruction, so we can just evaluate that directly.

We then use this information (indexing _ENV.redstone with "getinput") to
find alternative keys (e.g. getInput, getOutput, etc...) and then pick
some likely suggestions with Damerau-Levenshtein/OSD.

I'm not entirely thrilled by the implementation here. The core
interpretation logic is implemented in Java. Which is *fine*, but a)
feels a little cheaty and b) means we're limited to what Lua bytecode
can provide (for instance, we can't inspect outer functions, or list all
available names in scope). We obviously can expand the bytecode if
needed, but something we'd want to be careful with.

The alternative approach would be to handle all the parsing in
Lua. Unfortunately, this is quite hard to get right — I think we'd need
some lazy parsing strategy to avoid constructing the whole AST, while
still retaining all the scope information we need.

I don't know. We really could make this as complex as we like, and I
don't know what the right balance is. It'd be cool to detect patterns
like the following, but is it *useful*?

    local monitor = peripheral.wrap("left")
    monitor.write("Hello")
        -- ^ monitor is nil. Is there a peripheral to the left of the
        -- computer?

For now, the current approach feels the easiest, and should allow us to
prototype things and see what does/doesn't work.
2025-02-13 21:57:29 +00:00
Jonathan Coates
051c70a731 Propagate exceptions from parallel where possible (#2095)
In the original implementation of our prettier runtime errors (#1320), we
wrapped the errors thrown within parallel functions into an exception
object. This means the call-stack is available to the catching-code, and
so is able to report a pretty exception message.

Unfortunately, this was a breaking change, and so we had to roll that
back. Some people were pcalling the parallel function, and matching on
the result of the error.

This is a second attempt at this, using a technique I've affectionately
dubbed "magic throws". The parallel API is now aware of whether it is
being pcalled or not, and thus able to decide whether to wrap the error
into an exception or not:

 - Add a new `cc.internal.tiny_require` module. This is a tiny
   reimplementation of require, for use in our global APIs.

 - Add a new (global, in the debug registry) `cc_try_barrier` function.
   This acts as a marker function, and is used to store additional
   information about the current coroutine.

   Currently this stores the parent coroutine (used to walk the full call
   stack) and a cache of whether any `pcall`-like function is on the
   stack.

   Both `parallel` and `cc.internal.exception.try` add this function to
   the root of the call stack.

 - When an error occurs within `parallel`, we walk up the call stack,
   using `cc_try_barrier` to traverse up the parent coroutine's stack
   too. If we do not find any `pcall`-like functions, then we know the
   error is never intercepted by user code, and so its safe to throw a
   full exception.
2025-02-13 17:38:57 +00:00
Jonathan Coates
2e2f308ff3 Support placing pocket computers on lecterns (#2098)
This allows shift+clicking a pocket computer on to a lectern. These
computers can be right clicked, opening the no-term computer GUI.
Terminal contents is rendered in-world, and broadcast to everyone in
range.

 - Add a new lectern PocketHolder.
 - Refactor some of the `PocketItemComputer` code to allow ticking pocket
   computers from a non-player/entity source.
 - Add a new model for pocket computers. This requires several new
   textures (somewhat mirroring the item ones), which is a little
   unfortunate, but looks much better than reusing the map renderer or
   item form.
2025-02-13 17:31:08 +00:00
Jonathan Coates
0f123b5efd Ignore unrepresentable characters when typing
In 94ad6dab0e, we changed it so typing
characters outside of CC's codepage were replaced with '?' rather than
ignored. This can be quite annoying for non-European users (where latin1
isn't very helpful!), so it makes sense to revert this change.

See discussion in #860 for more context.
2025-02-12 18:45:40 +00:00
Jonathan Coates
88cb03be6b Clean up the parallel API
- Store the filter alongside the coroutine rather than in a separate
   table (like we do in multishell).

 - Remove the redudant (I think!) second loop that checks for dead
   coroutines. We already check for dead coroutines in the main loop.

 - Rename some variables to be a bit more consistent. This makes this
   commit look noisier than it is. Sorry!
2025-02-09 16:53:59 +00:00
Jonathan Coates
4360485880 Bump CC:T to 1.114.4 2025-01-31 21:09:57 +00:00
Jonathan Coates
b69a44a927 Redo turtle move checks
Oh, this is so broken, and really has been since the 1.13 update, if not
earlier.

 - Fix call to isUnobstructed using the bounding box of the
   *destination* block rather than the turtle. This is almost always
   air, so the box is empty.

 - Because the above check has been wrong for so many years, we now
   significantly relax the "can push" checks for entities. We now allow
   pushing entities in any direction.

   We also remove the "isUnobstructed" check for the destination entity
   pos. This causes problems (if two entities are standing on a turtle,
   they'll obstruct each other), and given pistons don't perform such a
   check, I don't think we need it.

 - Also do a bit of cleanup around air/liquid checks. We often ended up
   reading the block state multiple times, which is a little ugly.
2025-01-27 22:45:52 +00:00
Jonathan Coates
7d8f609c49 literally sobbing rn 2025-01-26 22:27:50 +00:00
Jonathan Coates
e7f56c4d25 Remove ComponentMap
I'd originally kept this around to prevent mods that use CC internals,
but fa2140d00b has probably broken those
anyway, so let's not worry too much.
2025-01-26 15:13:24 +00:00
Jonathan Coates
fa2140d00b Clean up container data interface
- Remove ContainerData.open.

 - Change PlatformHelper.openMenu to take a separate display name and
   MenuConstructor, rather than a MenuProvider. This makes the interface
   slightly easier to use in the common case, where we want to use
   lambdas instead.
2025-01-26 14:56:09 +00:00
Jonathan Coates
03388149b1 Fix command computers being exposed as peripherals
- Check whether the computer is a command computer before registering
   the capability.

 - Add tests to check what is/isn't a peripheral. See also #2020, where
   we forgot to register a peripheral on NeoForge 1.21.1.

Fixes #2070.
2025-01-26 11:13:52 +00:00
Jonathan Coates
f212861370 Mark command computers as onlyOpCanSetNbt
This isn't required in vanilla, as the command computer is a
GameMasterBlock, and so isn't placeable in the first place.

*However*, this is a problem with Create contraptions — with those it's
possible to "place" a command computer complete with NBT. We override
onlyOpCanSetNbt to prevent this [^1].

[^1]: 7a7993deb8/src/main/java/com/simibubi/create/foundation/utility/NBTProcessors.java (L179)
2025-01-21 20:41:55 +00:00
Jonathan Coates
55edced9de Move GUI sprites to the sprites/ folder
This is where vanilla will read the sprites from in future versions, so
means we have a consistent layout between versions.

Also move the turtle "selected slot" texture to a sprite sheet. It would
be good to do more of these in the future (e.g. printer progress, maybe
bits of printouts).

Sorry to resource pack artists for causing trouble again.
2025-01-20 20:21:22 +00:00
Jonathan Coates
dc969c5a78 Configure ServerComputer via a Properties builder
We currently need to pass a whole bunch of arguments to a ServerComputer
in order to construct it, and if we implement #1814, this will get a
whole lot worse. Instead, we now pass most parameters (computer id,
family, label, term size, components) via a separate Properties class,
much like Minecraft does for blocks and items.

I'm not wild about the design of the API here, but I think it's a step
in the right direction.
2025-01-20 19:47:18 +00:00
Jonathan Coates
94ad6dab0e Map Unicode to CC's charset for char/paste events
We now convert uncode characters from "char" and "paste" events to CC's
charset[^1], rather than just leaving them unconverted. This means you
can paste in special characters like "♠" or "🮙" and they will be
converted correctly. Characters outside that range will be replaced with
"?", as before.

It would be nice to make this a bi-directional mapping, and do this for
Lua methods too (e.g. os.setComputerLabel). However, that has much wider
ramifications (and more likelyhood of breaking something), so avoiding
that for now.

 - Remove the generic "queue event" client->server message, and replace
   it with separate char/terminate/paste messages. This allows us to
   delete a chunk of code (all the NBT<->Object conversion), and makes
   server-side validation of events possible.

 - Fix os.setComputerLabel accepting the section sign — this is treated
   as special by Minecraft's formatting code. Sorry, no fun allowed.

 - Convert paste/char codepoints to CC's charset. Sadly MC's char hook
   splits the codepoint into surrogate pairs, which we *don't* attempt
   to reconstruct, so you can't currently use unicode input for block
   characters — you can paste them though!

[^1]: I'm referring this to the "terminal charset" within the code. I've
flip-flopped between "CraftOS", "terminal", "ComputerCraft", but feel
especially great.
2025-01-19 11:07:29 +00:00
Jonathan Coates
938eb38ad5 Move computer events to a single point
This abstraction never made much sense on InputHandler, as we only leave
the default methods on ServerComputer.

We now add a new class (ComputerEvents), which has a series of *static*
methods, that can queue an event on a ComputerEvents.Receiver object.
This is a bit of an odd indirection (why not just make them instance
methods on Receiver?!), but I don't really want those methods leaking
everywhere.
2025-01-18 19:26:10 +00:00
Jonathan Coates
6739c4c6c0 Wait for computers to run each tick in gametests 2025-01-17 18:43:19 +00:00
Jonathan Coates
d6749f8461 Set issue type in the templates
We already have the label, so it's not quite clear if it's worth it, but
let make our issue board look even more like a tube of smarties.
2025-01-17 17:25:39 +00:00
108 changed files with 2058 additions and 790 deletions

View File

@@ -1,6 +1,7 @@
name: Bug report
description: Report some misbehaviour in the mod
labels: [ bug ]
type: bug
body:
- type: dropdown
id: mc-version
@@ -29,3 +30,5 @@ body:
Description of the bug. Please include the following:
- Logs: These will be located in the `logs/` directory of your Minecraft instance. This is always useful, even if it doesn't include errors, so please upload this!
- Detailed reproduction steps: sometimes I can spot a bug pretty easily, but often it's much more obscure. The more information I have to help reproduce it, the quicker it'll get fixed.
![A gif of burning text reading "Upload your logs!!!"](https://tweaked.cc/images/logs.gif)

View File

@@ -2,6 +2,7 @@
name: Feature request
about: Suggest an idea or improvement
labels: enhancement
type: feature
---
<!--

View File

@@ -10,9 +10,7 @@ import org.gradle.api.GradleException
import org.gradle.api.NamedDomainObjectProvider
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.artifacts.Dependency
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Provider
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.SourceSet
@@ -168,7 +166,7 @@ abstract class CCTweakedExtension(private val project: Project) {
jacoco.applyTo(this)
extensions.configure(JacocoTaskExtension::class.java) {
includes = listOf("dan200.computercraft.*")
includes = listOf("dan200.computercraft.*")
excludes = listOf(
"dan200.computercraft.mixin.*", // Exclude mixins, as they're not executed at runtime.
"dan200.computercraft.shared.Capabilities$*", // Exclude capability tokens, as Forge rewrites them.

View File

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

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.api.media;
import dan200.computercraft.impl.ComputerCraftAPIService;
import net.minecraft.world.item.ItemStack;
import javax.annotation.Nullable;
import java.util.stream.Stream;
/**
* The contents of a page (or book) created by a ComputerCraft printer.
*
* @since 1.115
*/
@Nullable
public interface PrintoutContents {
/**
* Get the (possibly empty) title for this printout.
*
* @return The title of this printout.
*/
String getTitle();
/**
* Get the text contents of this printout, as a sequence of lines.
* <p>
* The lines in the printout may include blank lines at the end of the document, as well as trailing spaces on each
* line.
*
* @return The text contents of this printout.
*/
Stream<String> getTextLines();
/**
* Get the printout contents for a particular stack.
*
* @param stack The stack to get the contents for.
* @return The printout contents, or {@code null} if this is not a printout item.
*/
static @Nullable PrintoutContents get(ItemStack stack) {
return ComputerCraftAPIService.get().getPrintoutContents(stack);
}
}

View File

@@ -19,8 +19,8 @@ import dan200.computercraft.api.ComputerCraftAPI;
*/
public interface WiredElement extends WiredSender {
/**
* Called when objects on the network change. This may occur when network nodes are added or removed, or when
* peripherals change.
* Called when peripherals on the network change. This may occur when network nodes are added or removed, or when
* peripherals are attached or detached from a modem.
*
* @param change The change which occurred.
* @see WiredNetworkChange

View File

@@ -12,6 +12,7 @@ import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import dan200.computercraft.api.media.MediaProvider;
import dan200.computercraft.api.media.PrintoutContents;
import dan200.computercraft.api.network.PacketNetwork;
import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.network.wired.WiredNode;
@@ -75,6 +76,9 @@ public interface ComputerCraftAPIService {
DetailRegistry<BlockReference> getBlockInWorldDetailRegistry();
@Nullable
PrintoutContents getPrintoutContents(ItemStack stack);
final class Instance {
static final @Nullable ComputerCraftAPIService INSTANCE;
static final @Nullable Throwable ERROR;

View File

@@ -10,10 +10,10 @@ import dan200.computercraft.shared.computer.menu.ComputerMenu;
import dan200.computercraft.shared.network.server.ComputerActionServerMessage;
import dan200.computercraft.shared.network.server.KeyEventServerMessage;
import dan200.computercraft.shared.network.server.MouseEventServerMessage;
import dan200.computercraft.shared.network.server.QueueEventServerMessage;
import dan200.computercraft.shared.network.server.PasteEventComputerMessage;
import net.minecraft.world.inventory.AbstractContainerMenu;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
/**
* An {@link InputHandler} for use on the client.
@@ -27,6 +27,11 @@ public final class ClientInputHandler implements InputHandler {
this.menu = menu;
}
@Override
public void terminate() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.TERMINATE));
}
@Override
public void turnOn() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.TURN_ON));
@@ -42,11 +47,6 @@ public final class ClientInputHandler implements InputHandler {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.REBOOT));
}
@Override
public void queueEvent(String event, @Nullable Object[] arguments) {
ClientNetworking.sendToServer(new QueueEventServerMessage(menu, event, arguments));
}
@Override
public void keyDown(int key, boolean repeat) {
ClientNetworking.sendToServer(new KeyEventServerMessage(menu, repeat ? KeyEventServerMessage.Action.REPEAT : KeyEventServerMessage.Action.DOWN, key));
@@ -57,6 +57,16 @@ public final class ClientInputHandler implements InputHandler {
ClientNetworking.sendToServer(new KeyEventServerMessage(menu, KeyEventServerMessage.Action.UP, key));
}
@Override
public void charTyped(byte chr) {
ClientNetworking.sendToServer(new KeyEventServerMessage(menu, KeyEventServerMessage.Action.CHAR, chr));
}
@Override
public void paste(ByteBuffer contents) {
ClientNetworking.sendToServer(new PasteEventComputerMessage(menu, contents));
}
@Override
public void mouseClick(int button, int x, int y) {
ClientNetworking.sendToServer(new MouseEventServerMessage(menu, MouseEventServerMessage.Action.CLICK, button, x, y));

View File

@@ -32,10 +32,13 @@ public final class GuiSprites extends TextureAtlasHolder {
public static final ComputerTextures COMPUTER_COMMAND = computer("command", false, true);
public static final ComputerTextures COMPUTER_COLOUR = computer("colour", true, false);
public static final ResourceLocation TURTLE_NORMAL_SELECTED_SLOT = new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/sprites/turtle_normal_selected_slot");
public static final ResourceLocation TURTLE_ADVANCED_SELECTED_SLOT = new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/sprites/turtle_advanced_selected_slot");
private static ButtonTextures button(String name) {
return new ButtonTextures(
new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/buttons/" + name),
new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/buttons/" + name + "_hover")
new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/sprites/buttons/" + name),
new ResourceLocation(ComputerCraftAPI.MOD_ID, "gui/sprites/buttons/" + name + "_hover")
);
}

View File

@@ -54,9 +54,9 @@ public class TurtleScreen extends AbstractComputerScreen<TurtleMenu> {
if (slot >= 0) {
var slotX = slot % 4;
var slotY = slot / 4;
graphics.blit(texture,
leftPos + TURTLE_START_X - 2 + slotX * 18, topPos + PLAYER_START_Y - 2 + slotY * 18, 0,
0, 217, 24, 24, FULL_TEX_SIZE, FULL_TEX_SIZE
graphics.blit(
leftPos + TURTLE_START_X - 2 + slotX * 18, topPos + PLAYER_START_Y - 2 + slotY * 18, 0, 22, 22,
GuiSprites.get(advanced ? GuiSprites.TURTLE_ADVANCED_SELECTED_SLOT : GuiSprites.TURTLE_NORMAL_SELECTED_SLOT)
);
}

View File

@@ -55,7 +55,7 @@ public final class ComputerSidebar {
add.accept(new DynamicImageButton(
x, y, ICON_WIDTH, ICON_HEIGHT,
GuiSprites.TERMINATE::get,
b -> input.queueEvent("terminate"),
b -> input.terminate(),
new HintedMessage(
Component.translatable("gui.computercraft.tooltip.terminate"),
Component.translatable("gui.computercraft.tooltip.terminate.key")

View File

@@ -71,11 +71,8 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean charTyped(char ch, int modifiers) {
if (ch >= 32 && ch <= 126 || ch >= 160 && ch <= 255) {
// Queue the char event for any printable chars in byte range
computer.queueEvent("char", new Object[]{ Character.toString(ch) });
}
var terminalChar = StringUtil.unicodeToTerminal(ch);
if (StringUtil.isTypableChar(terminalChar)) computer.charTyped((byte) terminalChar);
return true;
}
@@ -112,8 +109,8 @@ public class TerminalWidget extends AbstractWidget {
}
private void paste() {
var clipboard = StringUtil.normaliseClipboardString(Minecraft.getInstance().keyboardHandler.getClipboard());
if (!clipboard.isEmpty()) computer.queueEvent("paste", new Object[]{ clipboard });
var clipboard = StringUtil.getClipboardString(Minecraft.getInstance().keyboardHandler.getClipboard());
if (clipboard.remaining() > 0) computer.paste(clipboard);
}
@Override
@@ -222,7 +219,7 @@ public class TerminalWidget extends AbstractWidget {
public void update() {
if (terminateTimer >= 0 && terminateTimer < TERMINATE_TIME && (terminateTimer += 0.05f) > TERMINATE_TIME) {
computer.queueEvent("terminate");
computer.terminate();
}
if (shutdownTimer >= 0 && shutdownTimer < TERMINATE_TIME && (shutdownTimer += 0.05f) > TERMINATE_TIME) {

View File

@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.client.model;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.client.pocket.PocketComputerData;
import dan200.computercraft.client.render.CustomLecternRenderer;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.renderer.LightTexture;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.resources.model.Material;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.FastColor;
import net.minecraft.world.inventory.InventoryMenu;
/**
* A model for {@linkplain PocketComputerItem pocket computers} placed on a lectern.
*
* @see CustomLecternRenderer
*/
public class LecternPocketModel {
public static final ResourceLocation TEXTURE_NORMAL = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_normal");
public static final ResourceLocation TEXTURE_ADVANCED = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_advanced");
public static final ResourceLocation TEXTURE_COLOUR = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_colour");
public static final ResourceLocation TEXTURE_FRAME = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_frame");
public static final ResourceLocation TEXTURE_LIGHT = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_light");
private static final Material MATERIAL_NORMAL = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_NORMAL);
private static final Material MATERIAL_ADVANCED = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_ADVANCED);
private static final Material MATERIAL_COLOUR = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_COLOUR);
private static final Material MATERIAL_FRAME = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_FRAME);
private static final Material MATERIAL_LIGHT = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_LIGHT);
// The size of the terminal within the model.
public static final float TERM_WIDTH = 12.0f / 32.0f;
public static final float TERM_HEIGHT = 14.0f / 32.0f;
// The size of the texture. The texture is 36x36, but is at 2x resolution.
private static final int TEXTURE_WIDTH = 36 / 2;
private static final int TEXTURE_HEIGHT = 36 / 2;
private final ModelPart root;
public LecternPocketModel() {
root = buildPages();
}
private static ModelPart buildPages() {
var mesh = new MeshDefinition();
var parts = mesh.getRoot();
parts.addOrReplaceChild(
"root",
CubeListBuilder.create().texOffs(0, 0).addBox(0f, -5.0f, -4.0f, 1f, 10.0f, 8.0f),
PartPose.ZERO
);
return mesh.getRoot().bake(TEXTURE_WIDTH, TEXTURE_HEIGHT);
}
private void render(PoseStack poseStack, VertexConsumer buffer, int packedLight, int packedOverlay, int colour) {
int red = FastColor.ARGB32.red(colour), green = FastColor.ARGB32.green(colour), blue = FastColor.ARGB32.blue(colour), alpha = FastColor.ARGB32.alpha(colour);
root.render(poseStack, buffer, packedLight, packedOverlay, red / 255.0f, green / 255.0f, blue / 255.0f, alpha / 255.0f);
}
/**
* Render the pocket computer model.
*
* @param poseStack The current pose stack.
* @param bufferSource The buffer source to draw to.
* @param packedLight The current light level.
* @param packedOverlay The overlay texture (used for entity hurt animation).
* @param family The computer family.
* @param frameColour The pocket computer's {@linkplain PocketComputerItem#getColour(ItemStack) colour}.
* @param lightColour The pocket computer's {@linkplain PocketComputerData#getLightState() light colour}.
*/
public void render(PoseStack poseStack, MultiBufferSource bufferSource, int packedLight, int packedOverlay, ComputerFamily family, int frameColour, int lightColour) {
if (frameColour != -1) {
root.render(poseStack, MATERIAL_FRAME.buffer(bufferSource, RenderType::entityCutout), packedLight, packedOverlay, 1, 1, 1, 1);
render(poseStack, MATERIAL_COLOUR.buffer(bufferSource, RenderType::entityCutout), packedLight, packedOverlay, frameColour);
} else {
var buffer = (family == ComputerFamily.ADVANCED ? MATERIAL_ADVANCED : MATERIAL_NORMAL).buffer(bufferSource, RenderType::entityCutout);
root.render(poseStack, buffer, packedLight, packedOverlay, 1, 1, 1, 1);
}
render(poseStack, MATERIAL_LIGHT.buffer(bufferSource, RenderType::entityCutout), LightTexture.FULL_BRIGHT, packedOverlay, lightColour);
}
}

View File

@@ -6,15 +6,28 @@ package dan200.computercraft.client.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dan200.computercraft.client.model.LecternPocketModel;
import dan200.computercraft.client.model.LecternPrintoutModel;
import dan200.computercraft.client.pocket.ClientPocketComputers;
import dan200.computercraft.client.render.text.FixedWidthFontRenderer;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.Colour;
import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.ARGB32;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.LecternRenderer;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.phys.Vec3;
import static dan200.computercraft.client.render.ComputerBorderRenderer.MARGIN;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_HEIGHT;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_WIDTH;
/**
* A block entity renderer for our {@linkplain CustomLecternBlockEntity lectern}.
@@ -22,10 +35,17 @@ import net.minecraft.world.level.block.LecternBlock;
* This largely follows {@link LecternRenderer}, but with support for multiple types of item.
*/
public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternBlockEntity> {
private static final int POCKET_TERMINAL_RENDER_DISTANCE = 32;
private final BlockEntityRenderDispatcher berDispatcher;
private final LecternPrintoutModel printoutModel;
private final LecternPocketModel pocketModel;
public CustomLecternRenderer(BlockEntityRendererProvider.Context context) {
berDispatcher = context.getBlockEntityRenderDispatcher();
printoutModel = new LecternPrintoutModel();
pocketModel = new LecternPocketModel();
}
@Override
@@ -44,8 +64,46 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
} else {
printoutModel.renderPages(poseStack, vertexConsumer, packedLight, packedOverlay, PrintoutItem.getPageCount(item));
}
} else if (item.getItem() instanceof PocketComputerItem pocket) {
var computer = ClientPocketComputers.get(item);
pocketModel.render(
poseStack, buffer, packedLight, packedOverlay, pocket.getFamily(), pocket.getColour(item),
ARGB32.opaque(computer == null || computer.getLightState() == -1 ? Colour.BLACK.getHex() : computer.getLightState())
);
// Jiggle the terminal about a bit, so (0, 0) is in the top left of the model's terminal hole.
poseStack.mulPose(Axis.YP.rotationDegrees(90f));
poseStack.translate(-0.5 * LecternPocketModel.TERM_WIDTH, 0.5 * LecternPocketModel.TERM_HEIGHT + 1f / 32.0f, 1 / 16.0f);
poseStack.mulPose(Axis.XP.rotationDegrees(180));
// Either render the terminal or a black screen, depending on how close we are.
var terminal = computer == null ? null : computer.getTerminal();
var quadEmitter = FixedWidthFontRenderer.toVertexConsumer(poseStack, buffer.getBuffer(RenderTypes.TERMINAL));
if (terminal != null && Vec3.atCenterOf(lectern.getBlockPos()).closerThan(berDispatcher.camera.getPosition(), POCKET_TERMINAL_RENDER_DISTANCE)) {
renderPocketTerminal(poseStack, quadEmitter, terminal);
} else {
FixedWidthFontRenderer.drawEmptyTerminal(quadEmitter, 0, 0, LecternPocketModel.TERM_WIDTH, LecternPocketModel.TERM_HEIGHT);
}
}
poseStack.popPose();
}
private static void renderPocketTerminal(PoseStack poseStack, FixedWidthFontRenderer.QuadEmitter quadEmitter, Terminal terminal) {
var width = terminal.getWidth() * FONT_WIDTH;
var height = terminal.getHeight() * FONT_HEIGHT;
// Scale the terminal down to fit in the available space.
var scaleX = LecternPocketModel.TERM_WIDTH / (width + MARGIN * 2);
var scaleY = LecternPocketModel.TERM_HEIGHT / (height + MARGIN * 2);
var scale = Math.min(scaleX, scaleY);
poseStack.scale(scale, scale, -1.0f);
// Convert the model dimensions to terminal space, then find out how large the margin should be.
var marginX = ((LecternPocketModel.TERM_WIDTH / scale) - width) / 2;
var marginY = ((LecternPocketModel.TERM_HEIGHT / scale) - height) / 2;
FixedWidthFontRenderer.drawTerminal(quadEmitter, marginX, marginY, terminal, marginY, marginY, marginX, marginX);
}
}

View File

@@ -38,8 +38,8 @@ public final class PocketItemRenderer extends ItemMapLikeRenderer {
int termWidth, termHeight;
if (terminal == null) {
termWidth = Config.pocketTermWidth;
termHeight = Config.pocketTermHeight;
termWidth = Config.DEFAULT_POCKET_TERM_WIDTH;
termHeight = Config.DEFAULT_POCKET_TERM_HEIGHT;
} else {
termWidth = terminal.getWidth();
termHeight = terminal.getHeight();

View File

@@ -43,7 +43,7 @@ public final class FixedWidthFontRenderer {
static final float BACKGROUND_END = (WIDTH - 4.0f) / WIDTH;
private static final int BLACK = FastColor.ARGB32.color(255, byteColour(Colour.BLACK.getR()), byteColour(Colour.BLACK.getR()), byteColour(Colour.BLACK.getR()));
private static final float Z_OFFSET = 1e-3f;
private static final float Z_OFFSET = 1e-4f;
private FixedWidthFontRenderer() {
}

View File

@@ -6,6 +6,7 @@ package dan200.computercraft.data;
import com.mojang.serialization.Codec;
import dan200.computercraft.client.gui.GuiSprites;
import dan200.computercraft.client.model.LecternPocketModel;
import dan200.computercraft.client.model.LecternPrintoutModel;
import dan200.computercraft.shared.turtle.inventory.UpgradeSlot;
import net.minecraft.client.renderer.texture.atlas.SpriteSource;
@@ -54,9 +55,12 @@ public final class DataProviders {
out.accept(new ResourceLocation("blocks"), makeSprites(Stream.of(
UpgradeSlot.LEFT_UPGRADE,
UpgradeSlot.RIGHT_UPGRADE,
LecternPrintoutModel.TEXTURE
LecternPrintoutModel.TEXTURE,
LecternPocketModel.TEXTURE_NORMAL, LecternPocketModel.TEXTURE_ADVANCED,
LecternPocketModel.TEXTURE_COLOUR, LecternPocketModel.TEXTURE_FRAME, LecternPocketModel.TEXTURE_LIGHT
)));
out.accept(GuiSprites.SPRITE_SHEET, makeSprites(
Stream.of(GuiSprites.TURTLE_NORMAL_SELECTED_SLOT, GuiSprites.TURTLE_ADVANCED_SELECTED_SLOT),
// Buttons
GuiSprites.TURNED_OFF.textures(),
GuiSprites.TURNED_ON.textures(),

View File

@@ -1,11 +1,13 @@
{
"sources": [
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/turned_off"},
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/turned_off_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/turned_on"},
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/turned_on_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/terminate"},
{"type": "minecraft:single", "resource": "computercraft:gui/buttons/terminate_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/turtle_normal_selected_slot"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/turtle_advanced_selected_slot"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/turned_off"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/turned_off_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/turned_on"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/turned_on_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/terminate"},
{"type": "minecraft:single", "resource": "computercraft:gui/sprites/buttons/terminate_hover"},
{"type": "minecraft:single", "resource": "computercraft:gui/border_normal"},
{"type": "minecraft:single", "resource": "computercraft:gui/pocket_bottom_normal"},
{"type": "minecraft:single", "resource": "computercraft:gui/sidebar_normal"},

View File

@@ -2,6 +2,11 @@
"sources": [
{"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_left"},
{"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_right"},
{"type": "minecraft:single", "resource": "computercraft:entity/printout"}
{"type": "minecraft:single", "resource": "computercraft:entity/printout"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_normal"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_advanced"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_colour"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_frame"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_light"}
]
}

View File

@@ -12,6 +12,7 @@ import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import dan200.computercraft.api.media.MediaProvider;
import dan200.computercraft.api.media.PrintoutContents;
import dan200.computercraft.api.network.PacketNetwork;
import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.network.wired.WiredNode;
@@ -26,6 +27,7 @@ import dan200.computercraft.shared.computer.core.ResourceMount;
import dan200.computercraft.shared.computer.core.ServerContext;
import dan200.computercraft.shared.details.BlockDetails;
import dan200.computercraft.shared.details.ItemDetails;
import dan200.computercraft.shared.media.items.PrintoutItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.Registry;
@@ -39,6 +41,7 @@ import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.stream.Stream;
public abstract class AbstractComputerCraftAPI implements ComputerCraftAPIService {
private final DetailRegistry<ItemStack> itemStackDetails = new DetailRegistryImpl<>(ItemDetails::fillBasic);
@@ -134,4 +137,21 @@ public abstract class AbstractComputerCraftAPI implements ComputerCraftAPIServic
public final DetailRegistry<BlockReference> getBlockInWorldDetailRegistry() {
return blockDetails;
}
@Override
public @Nullable PrintoutContents getPrintoutContents(ItemStack stack) {
return stack.getItem() instanceof PrintoutItem ? new PrintoutContentsImpl(stack) : null;
}
public record PrintoutContentsImpl(ItemStack stack) implements PrintoutContents {
@Override
public String getTitle() {
return PrintoutItem.getTitle(stack);
}
@Override
public Stream<String> getTextLines() {
return Stream.of(PrintoutItem.getText(stack));
}
}
}

View File

@@ -28,6 +28,7 @@ import dan200.computercraft.shared.computer.blocks.CommandComputerBlock;
import dan200.computercraft.shared.computer.blocks.ComputerBlock;
import dan200.computercraft.shared.computer.blocks.ComputerBlockEntity;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.inventory.ComputerMenuWithoutInventory;
import dan200.computercraft.shared.computer.items.CommandComputerItem;
import dan200.computercraft.shared.computer.items.ComputerItem;
@@ -89,7 +90,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.ComponentMap;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.synchronization.ArgumentTypeInfo;
import net.minecraft.commands.synchronization.SingletonArgumentInfo;
@@ -471,7 +471,7 @@ public final class ModRegistry {
ComputerCraftAPI.registerAPIFactory(computer -> {
var turtle = computer.getComponent(ComputerComponents.TURTLE);
var metrics = Objects.requireNonNull(computer.getComponent(ComponentMap.METRICS));
var metrics = Objects.requireNonNull(computer.getComponent(ServerComputer.METRICS));
return turtle == null ? null : new TurtleAPI(metrics, (TurtleAccessInternal) turtle);
});

View File

@@ -24,15 +24,13 @@ import dan200.computercraft.shared.computer.metrics.basic.AggregatedMetric;
import dan200.computercraft.shared.computer.metrics.basic.BasicComputerMetricsObserver;
import dan200.computercraft.shared.computer.metrics.basic.ComputerMetrics;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.platform.PlatformHelper;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.RelativeMovement;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
@@ -260,18 +258,11 @@ public final class CommandComputerCraft {
* @return The constant {@code 1}.
*/
private static int view(CommandSourceStack source, ServerComputer computer) throws CommandSyntaxException {
var player = source.getPlayerOrException();
new ComputerContainerData(computer, ItemStack.EMPTY).open(player, new MenuProvider() {
@Override
public Component getDisplayName() {
return Component.translatable("gui.computercraft.view_computer");
}
@Override
public AbstractContainerMenu createMenu(int id, Inventory player, Player entity) {
return new ComputerMenuWithoutInventory(ModRegistry.Menus.COMPUTER.get(), id, player, p -> true, computer);
}
});
PlatformHelper.get().openMenu(
source.getPlayerOrException(), Component.translatable("gui.computercraft.view_computer"),
(id, player, entity) -> new ComputerMenuWithoutInventory(ModRegistry.Menus.COMPUTER.get(), id, player, p -> true, computer),
new ComputerContainerData(computer, ItemStack.EMPTY)
);
return 1;
}

View File

@@ -9,6 +9,7 @@ import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.shared.common.IBundledRedstoneBlock;
import dan200.computercraft.shared.computer.items.IComputerItem;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.platform.PlatformHelper;
import dan200.computercraft.shared.platform.RegistryEntry;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
@@ -161,7 +162,7 @@ public abstract class AbstractComputerBlock<T extends AbstractComputerBlockEntit
var serverComputer = computer.createServerComputer();
serverComputer.turnOn();
new ComputerContainerData(serverComputer, getItem(computer)).open(player, computer);
PlatformHelper.get().openMenu(player, computer.getName(), computer, new ComputerContainerData(serverComputer, getItem(computer)));
}
return InteractionResult.sidedSuccess(level.isClientSide);
}

View File

@@ -26,9 +26,10 @@ import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.LockCode;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.Nameable;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.MenuConstructor;
import net.minecraft.world.level.block.GameMasterBlock;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
@@ -38,7 +39,7 @@ import javax.annotation.Nullable;
import java.util.Objects;
import java.util.UUID;
public abstract class AbstractComputerBlockEntity extends BlockEntity implements Nameable, MenuProvider {
public abstract class AbstractComputerBlockEntity extends BlockEntity implements Nameable, MenuConstructor {
private static final String NBT_ID = "ComputerId";
private static final String NBT_LABEL = "Label";
private static final String NBT_ON = "On";
@@ -310,6 +311,10 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
return label;
}
public final boolean isAdminOnly() {
return getBlockState().getBlock() instanceof GameMasterBlock;
}
public final void setComputerID(int id) {
if (getLevel().isClientSide || computerID == id) return;
@@ -417,4 +422,9 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
public Component getDisplayName() {
return Nameable.super.getDisplayName();
}
@Override
public boolean onlyOpCanSetNbt() {
return isAdminOnly();
}
}

View File

@@ -11,8 +11,7 @@ import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ComputerState;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.inventory.ComputerMenuWithoutInventory;
import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.util.ComponentMap;
import dan200.computercraft.shared.config.ConfigSpec;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
@@ -33,10 +32,9 @@ public class ComputerBlockEntity extends AbstractComputerBlockEntity {
@Override
protected ServerComputer createComputer(int id) {
return new ServerComputer(
(ServerLevel) getLevel(), getBlockPos(), id, label,
getFamily(), Config.computerTermWidth, Config.computerTermHeight,
ComponentMap.empty()
return new ServerComputer((ServerLevel) getLevel(), getBlockPos(), ServerComputer.properties(id, getFamily())
.label(getLabel())
.terminalSize(ConfigSpec.computerTermWidth.get(), ConfigSpec.computerTermHeight.get())
);
}

View File

@@ -11,7 +11,6 @@ import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.apis.ComputerAccess;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.computer.ApiLifecycle;
import dan200.computercraft.shared.util.ComponentMap;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -26,11 +25,11 @@ import java.util.Map;
final class ComputerSystem extends ComputerAccess implements IComputerSystem, ApiLifecycle {
private final ServerComputer computer;
private final IAPIEnvironment environment;
private final ComponentMap components;
private final Map<ComputerComponent<?>, Object> components;
private boolean active;
ComputerSystem(ServerComputer computer, IAPIEnvironment environment, ComponentMap components) {
ComputerSystem(ServerComputer computer, IAPIEnvironment environment, Map<ComputerComponent<?>, Object> components) {
super(environment);
this.computer = computer;
this.environment = environment;
@@ -95,7 +94,8 @@ final class ComputerSystem extends ComputerAccess implements IComputerSystem, Ap
}
@Override
@SuppressWarnings("unchecked")
public <T> @Nullable T getComponent(ComputerComponent<T> component) {
return components.get(component);
return (T) components.get(component);
}
}

View File

@@ -6,44 +6,33 @@ package dan200.computercraft.shared.computer.core;
import dan200.computercraft.shared.computer.menu.ServerInputHandler;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
/**
* Handles user-provided input, forwarding it to a computer. This is used
* Handles user-provided input, forwarding it to a computer. This describes the "shape" of both the client-and
* server-side input handlers.
*
* @see ServerInputHandler
* @see ServerComputer
*/
public interface InputHandler {
void queueEvent(String event, @Nullable Object[] arguments);
void keyDown(int key, boolean repeat);
default void queueEvent(String event) {
queueEvent(event, null);
}
void keyUp(int key);
default void keyDown(int key, boolean repeat) {
queueEvent("key", new Object[]{ key, repeat });
}
void charTyped(byte chr);
default void keyUp(int key) {
queueEvent("key_up", new Object[]{ key });
}
void paste(ByteBuffer contents);
default void mouseClick(int button, int x, int y) {
queueEvent("mouse_click", new Object[]{ button, x, y });
}
void mouseClick(int button, int x, int y);
default void mouseUp(int button, int x, int y) {
queueEvent("mouse_up", new Object[]{ button, x, y });
}
void mouseUp(int button, int x, int y);
default void mouseDrag(int button, int x, int y) {
queueEvent("mouse_drag", new Object[]{ button, x, y });
}
void mouseDrag(int button, int x, int y);
default void mouseScroll(int direction, int x, int y) {
queueEvent("mouse_scroll", new Object[]{ direction, x, y });
}
void mouseScroll(int direction, int x, int y);
void terminate();
void shutdown();

View File

@@ -6,12 +6,14 @@ package dan200.computercraft.shared.computer.core;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.api.component.AdminComputer;
import dan200.computercraft.api.component.ComputerComponent;
import dan200.computercraft.api.component.ComputerComponents;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.api.peripheral.WorkMonitor;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.computer.ComputerEnvironment;
import dan200.computercraft.core.computer.ComputerEvents;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.impl.ApiFactories;
@@ -23,18 +25,21 @@ import dan200.computercraft.shared.network.NetworkMessage;
import dan200.computercraft.shared.network.client.ClientNetworkContext;
import dan200.computercraft.shared.network.client.ComputerTerminalClientMessage;
import dan200.computercraft.shared.network.server.ServerNetworking;
import dan200.computercraft.shared.util.ComponentMap;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
public class ServerComputer implements InputHandler, ComputerEnvironment {
public class ServerComputer implements ComputerEnvironment, ComputerEvents.Receiver {
public static final ComputerComponent<MetricsObserver> METRICS = ComputerComponent.create("computercraft", "metrics");
private final int instanceID;
private final UUID instanceUUID = UUID.randomUUID();
@@ -50,30 +55,25 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
private int ticksSincePing;
public ServerComputer(
ServerLevel level, BlockPos position, int computerID, @Nullable String label, ComputerFamily family, int terminalWidth, int terminalHeight,
ComponentMap baseComponents
) {
public ServerComputer(ServerLevel level, BlockPos position, Properties properties) {
this.level = level;
this.position = position;
this.family = family;
this.family = properties.family;
var context = ServerContext.get(level.getServer());
instanceID = context.registry().getUnusedInstanceID();
terminal = new NetworkedTerminal(terminalWidth, terminalHeight, family != ComputerFamily.NORMAL, this::markTerminalChanged);
terminal = new NetworkedTerminal(properties.terminalWidth, properties.terminalHeight, family != ComputerFamily.NORMAL, this::markTerminalChanged);
metrics = context.metrics().createMetricObserver(this);
var componentBuilder = ComponentMap.builder();
componentBuilder.add(ComponentMap.METRICS, metrics);
properties.addComponent(METRICS, metrics);
if (family == ComputerFamily.COMMAND) {
componentBuilder.add(ComputerComponents.ADMIN_COMPUTER, new AdminComputer() {
properties.addComponent(ComputerComponents.ADMIN_COMPUTER, new AdminComputer() {
});
}
componentBuilder.add(baseComponents);
var components = componentBuilder.build();
var components = Map.copyOf(properties.components);
computer = new Computer(context.computerContext(), this, terminal, computerID);
computer.setLabel(label);
computer = new Computer(context.computerContext(), this, terminal, properties.computerID);
computer.setLabel(properties.label);
// Load in the externally registered APIs.
for (var factory : ApiFactories.getAll()) {
@@ -86,24 +86,24 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
}
}
public ComputerFamily getFamily() {
public final ComputerFamily getFamily() {
return family;
}
public ServerLevel getLevel() {
public final ServerLevel getLevel() {
return level;
}
public BlockPos getPosition() {
public final BlockPos getPosition() {
return position;
}
public void setPosition(ServerLevel level, BlockPos pos) {
public final void setPosition(ServerLevel level, BlockPos pos) {
this.level = level;
position = pos.immutable();
}
protected void markTerminalChanged() {
protected final void markTerminalChanged() {
terminalChanged.set(true);
}
@@ -117,11 +117,11 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
sendToAllInteracting(c -> new ComputerTerminalClientMessage(c, getTerminalState()));
}
public TerminalState getTerminalState() {
public final TerminalState getTerminalState() {
return TerminalState.create(terminal);
}
public void keepAlive() {
public final void keepAlive() {
ticksSincePing = 0;
}
@@ -134,7 +134,7 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
*
* @return What sides on the computer have changed.
*/
public int pollRedstoneChanges() {
public final int pollRedstoneChanges() {
return computer.pollRedstoneChanges();
}
@@ -147,7 +147,7 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
computer.unload();
}
public void close() {
public final void close() {
unload();
ServerContext.get(level.getServer()).registry().remove(this);
}
@@ -167,7 +167,7 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
var server = level.getServer();
for (var player : server.getPlayerList().getPlayers()) {
if (player.containerMenu instanceof ComputerMenu && ((ComputerMenu) player.containerMenu).getComputer() == this) {
if (player.containerMenu instanceof ComputerMenu menu && menu.getComputer() == this) {
ServerNetworking.sendToPlayer(createPacket.apply(player.containerMenu), player);
}
}
@@ -176,97 +176,135 @@ public class ServerComputer implements InputHandler, ComputerEnvironment {
protected void onRemoved() {
}
public int getInstanceID() {
public final int getInstanceID() {
return instanceID;
}
public UUID getInstanceUUID() {
public final UUID getInstanceUUID() {
return instanceUUID;
}
public int getID() {
public final int getID() {
return computer.getID();
}
public @Nullable String getLabel() {
public final @Nullable String getLabel() {
return computer.getLabel();
}
public boolean isOn() {
public final boolean isOn() {
return computer.isOn();
}
public ComputerState getState() {
public final ComputerState getState() {
if (!computer.isOn()) return ComputerState.OFF;
return computer.isBlinking() ? ComputerState.BLINKING : ComputerState.ON;
}
@Override
public void turnOn() {
public final void turnOn() {
computer.turnOn();
}
@Override
public void shutdown() {
public final void shutdown() {
computer.shutdown();
}
@Override
public void reboot() {
public final void reboot() {
computer.reboot();
}
@Override
public void queueEvent(String event, @Nullable Object[] arguments) {
public final void queueEvent(String event, @Nullable Object[] arguments) {
computer.queueEvent(event, arguments);
}
public int getRedstoneOutput(ComputerSide side) {
public final void queueEvent(String event) {
queueEvent(event, null);
}
public final int getRedstoneOutput(ComputerSide side) {
return computer.isOn() ? computer.getRedstone().getExternalOutput(side) : 0;
}
public void setRedstoneInput(ComputerSide side, int level, int bundledState) {
public final void setRedstoneInput(ComputerSide side, int level, int bundledState) {
computer.getRedstone().setInput(side, level, bundledState);
}
public int getBundledRedstoneOutput(ComputerSide side) {
public final int getBundledRedstoneOutput(ComputerSide side) {
return computer.isOn() ? computer.getRedstone().getExternalBundledOutput(side) : 0;
}
public void setPeripheral(ComputerSide side, @Nullable IPeripheral peripheral) {
public final void setPeripheral(ComputerSide side, @Nullable IPeripheral peripheral) {
computer.getEnvironment().setPeripheral(side, peripheral);
}
@Nullable
public IPeripheral getPeripheral(ComputerSide side) {
public final IPeripheral getPeripheral(ComputerSide side) {
return computer.getEnvironment().getPeripheral(side);
}
public void setLabel(@Nullable String label) {
public final void setLabel(@Nullable String label) {
computer.setLabel(label);
}
@Override
public double getTimeOfDay() {
public final double getTimeOfDay() {
return (level.getDayTime() + 6000) % 24000 / 1000.0;
}
@Override
public int getDay() {
public final int getDay() {
return (int) ((level.getDayTime() + 6000) / 24000) + 1;
}
@Override
public MetricsObserver getMetrics() {
public final MetricsObserver getMetrics() {
return metrics;
}
public WorkMonitor getMainThreadMonitor() {
public final WorkMonitor getMainThreadMonitor() {
return computer.getMainThreadMonitor();
}
@Override
public @Nullable WritableMount createRootMount() {
public final WritableMount createRootMount() {
return ComputerCraftAPI.createSaveDirMount(level.getServer(), "computer/" + computer.getID(), Config.computerSpaceLimit);
}
public static Properties properties(int computerID, ComputerFamily family) {
return new Properties(computerID, family);
}
public static final class Properties {
private final int computerID;
private @Nullable String label;
private final ComputerFamily family;
private int terminalWidth = Config.DEFAULT_COMPUTER_TERM_WIDTH;
private int terminalHeight = Config.DEFAULT_COMPUTER_TERM_HEIGHT;
private final Map<ComputerComponent<?>, Object> components = new HashMap<>();
private Properties(int computerID, ComputerFamily family) {
this.computerID = computerID;
this.family = family;
}
public Properties label(@Nullable String label) {
this.label = label;
return this;
}
public Properties terminalSize(int width, int height) {
if (width <= 0 || height <= 0) throw new IllegalArgumentException("Terminal size must be positive");
this.terminalWidth = width;
this.terminalHeight = height;
return this;
}
public <T> Properties addComponent(ComputerComponent<T> component, T value) {
if (components.containsKey(component)) throw new IllegalArgumentException(component + " is already set");
components.put(component, value);
return this;
}
}
}

View File

@@ -7,6 +7,8 @@ package dan200.computercraft.shared.computer.menu;
import dan200.computercraft.core.apis.handles.ByteBufferChannel;
import dan200.computercraft.core.apis.transfer.TransferredFile;
import dan200.computercraft.core.apis.transfer.TransferredFiles;
import dan200.computercraft.core.computer.ComputerEvents;
import dan200.computercraft.core.util.StringUtil;
import dan200.computercraft.shared.computer.upload.FileSlice;
import dan200.computercraft.shared.computer.upload.FileUpload;
import dan200.computercraft.shared.computer.upload.UploadResult;
@@ -21,6 +23,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.UUID;
@@ -48,21 +51,33 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
this.owner = owner;
}
@Override
public void queueEvent(String event, @Nullable Object[] arguments) {
owner.getComputer().queueEvent(event, arguments);
}
@Override
public void keyDown(int key, boolean repeat) {
keysDown.add(key);
owner.getComputer().keyDown(key, repeat);
ComputerEvents.keyDown(owner.getComputer(), key, repeat);
}
@Override
public void keyUp(int key) {
keysDown.remove(key);
owner.getComputer().keyUp(key);
ComputerEvents.keyUp(owner.getComputer(), key);
}
@Override
public void charTyped(byte chr) {
if (StringUtil.isTypableChar(chr)) ComputerEvents.charTyped(owner.getComputer(), chr);
}
@Override
public void paste(ByteBuffer contents) {
if (contents.remaining() > 0 && isValidClipboard(contents)) ComputerEvents.paste(owner.getComputer(), contents);
}
private static boolean isValidClipboard(ByteBuffer buffer) {
for (int i = buffer.remaining(), max = buffer.limit(); i < max; i++) {
if (!StringUtil.isTypableChar(buffer.get(i))) return false;
}
return true;
}
@Override
@@ -71,7 +86,7 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
lastMouseY = y;
lastMouseDown = button;
owner.getComputer().mouseClick(button, x, y);
ComputerEvents.mouseClick(owner.getComputer(), button, x, y);
}
@Override
@@ -80,7 +95,7 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
lastMouseY = y;
lastMouseDown = -1;
owner.getComputer().mouseUp(button, x, y);
ComputerEvents.mouseUp(owner.getComputer(), button, x, y);
}
@Override
@@ -89,7 +104,7 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
lastMouseY = y;
lastMouseDown = button;
owner.getComputer().mouseDrag(button, x, y);
ComputerEvents.mouseDrag(owner.getComputer(), button, x, y);
}
@Override
@@ -97,7 +112,12 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
lastMouseX = x;
lastMouseY = y;
owner.getComputer().mouseScroll(direction, x, y);
ComputerEvents.mouseScroll(owner.getComputer(), direction, x, y);
}
@Override
public void terminate() {
owner.getComputer().queueEvent("terminate");
}
@Override
@@ -169,9 +189,9 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
public void close() {
var computer = owner.getComputer();
var keys = keysDown.iterator();
while (keys.hasNext()) computer.keyUp(keys.nextInt());
while (keys.hasNext()) ComputerEvents.keyUp(computer, keys.nextInt());
if (lastMouseDown != -1) computer.mouseUp(lastMouseDown, lastMouseX, lastMouseY);
if (lastMouseDown != -1) ComputerEvents.mouseUp(computer, lastMouseDown, lastMouseX, lastMouseY);
keysDown.clear();
lastMouseDown = -1;

View File

@@ -32,14 +32,14 @@ public final class Config {
public static int advancedTurtleFuelLimit = 100000;
public static boolean turtlesCanPush = true;
public static int computerTermWidth = 51;
public static int computerTermHeight = 19;
public static final int DEFAULT_COMPUTER_TERM_WIDTH = 51;
public static final int DEFAULT_COMPUTER_TERM_HEIGHT = 19;
public static final int turtleTermWidth = 39;
public static final int turtleTermHeight = 13;
public static final int TURTLE_TERM_WIDTH = 39;
public static final int TURTLE_TERM_HEIGHT = 13;
public static int pocketTermWidth = 26;
public static int pocketTermHeight = 20;
public static final int DEFAULT_POCKET_TERM_WIDTH = 26;
public static final int DEFAULT_POCKET_TERM_HEIGHT = 20;
public static int monitorWidth = 8;
public static int monitorHeight = 6;

View File

@@ -344,13 +344,13 @@ public final class ConfigSpec {
.push("term_sizes");
builder.comment("Terminal size of computers.").push("computer");
computerTermWidth = builder.comment("Width of computer terminal").defineInRange("width", Config.computerTermWidth, 1, 255);
computerTermHeight = builder.comment("Height of computer terminal").defineInRange("height", Config.computerTermHeight, 1, 255);
computerTermWidth = builder.comment("Width of computer terminal").defineInRange("width", Config.DEFAULT_COMPUTER_TERM_WIDTH, 1, 255);
computerTermHeight = builder.comment("Height of computer terminal").defineInRange("height", Config.DEFAULT_COMPUTER_TERM_HEIGHT, 1, 255);
builder.pop();
builder.comment("Terminal size of pocket computers.").push("pocket_computer");
pocketTermWidth = builder.comment("Width of pocket computer terminal").defineInRange("width", Config.pocketTermWidth, 1, 255);
pocketTermHeight = builder.comment("Height of pocket computer terminal").defineInRange("height", Config.pocketTermHeight, 1, 255);
pocketTermWidth = builder.comment("Width of pocket computer terminal").defineInRange("width", Config.DEFAULT_POCKET_TERM_WIDTH, 1, 255);
pocketTermHeight = builder.comment("Height of pocket computer terminal").defineInRange("height", Config.DEFAULT_POCKET_TERM_HEIGHT, 1, 255);
builder.pop();
builder.comment("Maximum size of monitors (in blocks).").push("monitor");
@@ -437,10 +437,6 @@ public final class ConfigSpec {
Config.turtlesCanPush = turtlesCanPush.get();
// Terminal size
Config.computerTermWidth = computerTermWidth.get();
Config.computerTermHeight = computerTermHeight.get();
Config.pocketTermWidth = pocketTermWidth.get();
Config.pocketTermHeight = pocketTermHeight.get();
Config.monitorWidth = monitorWidth.get();
Config.monitorHeight = monitorHeight.get();
}

View File

@@ -6,6 +6,7 @@ package dan200.computercraft.shared.lectern;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.stats.Stats;
@@ -14,6 +15,7 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.context.UseOnContext;
@@ -21,8 +23,12 @@ import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/**
* Extends {@link LecternBlock} with support for {@linkplain PrintoutItem printouts}.
@@ -55,6 +61,27 @@ public class CustomLecternBlock extends LecternBlock {
if (level.getBlockEntity(pos) instanceof CustomLecternBlockEntity be) be.setItem(item.split(1));
}
/**
* A default implementation of {@link Item#useOn(UseOnContext)} for items that can be placed on a lectern.
*
* @param context The context of this item usage action.
* @return Whether the item was placed or not.
*/
public static InteractionResult defaultUseItemOn(UseOnContext context) {
var level = context.getLevel();
var blockPos = context.getClickedPos();
var blockState = level.getBlockState(blockPos);
if (blockState.is(Blocks.LECTERN) && !blockState.getValue(LecternBlock.HAS_BOOK)) {
// If we have an empty lectern, place our book into it.
if (!level.isClientSide) {
CustomLecternBlock.replaceLectern(level, blockPos, blockState, context.getItemInHand());
}
return InteractionResult.sidedSuccess(level.isClientSide);
} else {
return InteractionResult.PASS;
}
}
/**
* Remove a custom lectern and replace it with an empty vanilla one.
*
@@ -131,7 +158,7 @@ public class CustomLecternBlock extends LecternBlock {
clearLectern(level, pos, state);
} else {
// Otherwise open the screen.
player.openMenu(lectern);
lectern.openMenu(player);
}
player.awardStat(Stats.INTERACT_WITH_LECTERN);
@@ -139,4 +166,11 @@ public class CustomLecternBlock extends LecternBlock {
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override
public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
return level.isClientSide ? null : BlockEntityHelpers.createTickerHelper(type, ModRegistry.BlockEntities.LECTERN.get(), serverTicker);
}
private static final BlockEntityTicker<CustomLecternBlockEntity> serverTicker = (level, pos, state, lectern) -> lectern.tick();
}

View File

@@ -9,27 +9,25 @@ import dan200.computercraft.shared.container.BasicContainer;
import dan200.computercraft.shared.container.SingleContainerData;
import dan200.computercraft.shared.media.PrintoutMenu;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.pocket.core.PocketHolder;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.Tag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.util.Mth;
import net.minecraft.world.Container;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerData;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.LecternBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import org.jetbrains.annotations.Nullable;
import java.util.AbstractList;
import java.util.List;
@@ -39,7 +37,7 @@ import java.util.List;
*
* @see LecternBlockEntity
*/
public final class CustomLecternBlockEntity extends BlockEntity implements MenuProvider {
public final class CustomLecternBlockEntity extends BlockEntity {
private static final String NBT_ITEM = "Item";
private static final String NBT_PAGE = "Page";
@@ -81,6 +79,12 @@ public final class CustomLecternBlockEntity extends BlockEntity implements MenuP
}
}
void tick() {
if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.tick(item, new PocketHolder.LecternHolder(this), false);
}
}
/**
* Set the current page, emitting a redstone pulse if needed.
*
@@ -123,24 +127,17 @@ public final class CustomLecternBlockEntity extends BlockEntity implements MenuP
return tag;
}
@Nullable
@Override
public AbstractContainerMenu createMenu(int containerId, Inventory playerInventory, Player player) {
void openMenu(Player player) {
var item = getItem();
if (item.getItem() instanceof PrintoutItem) {
return new PrintoutMenu(
containerId, new LecternContainer(), 0,
p -> Container.stillValidBlockEntity(this, player, Container.DEFAULT_DISTANCE_LIMIT),
player.openMenu(new SimpleMenuProvider((id, inventory, entity) -> new PrintoutMenu(
id, new LecternContainer(), 0,
p -> Container.stillValidBlockEntity(this, p, Container.DEFAULT_DISTANCE_LIMIT),
new PrintoutContainerData()
);
), getItem().getDisplayName()));
} else if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.open(player, item, new PocketHolder.LecternHolder(this), true);
}
return null;
}
@Override
public Component getDisplayName() {
return getItem().getDisplayName();
}
/**

View File

@@ -19,8 +19,6 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import javax.annotation.Nullable;
import java.util.List;
@@ -56,18 +54,7 @@ public class PrintoutItem extends Item {
@Override
public InteractionResult useOn(UseOnContext context) {
var level = context.getLevel();
var blockPos = context.getClickedPos();
var blockState = level.getBlockState(blockPos);
if (blockState.is(Blocks.LECTERN) && !blockState.getValue(LecternBlock.HAS_BOOK)) {
// If we have an empty lectern, place our book into it.
if (!level.isClientSide) {
CustomLecternBlock.replaceLectern(level, blockPos, blockState, context.getItemInHand());
}
return InteractionResult.sidedSuccess(level.isClientSide);
} else {
return InteractionResult.PASS;
}
return CustomLecternBlock.defaultUseItemOn(context);
}
@Override

View File

@@ -27,9 +27,9 @@ public final class NetworkMessages {
private static final List<MessageType<? extends NetworkMessage<ClientNetworkContext>>> clientMessages = new ArrayList<>();
public static final MessageType<ComputerActionServerMessage> COMPUTER_ACTION = registerServerbound(0, "computer_action", ComputerActionServerMessage.class, ComputerActionServerMessage::new);
public static final MessageType<QueueEventServerMessage> QUEUE_EVENT = registerServerbound(1, "queue_event", QueueEventServerMessage.class, QueueEventServerMessage::new);
public static final MessageType<KeyEventServerMessage> KEY_EVENT = registerServerbound(2, "key_event", KeyEventServerMessage.class, KeyEventServerMessage::new);
public static final MessageType<MouseEventServerMessage> MOUSE_EVENT = registerServerbound(3, "mouse_event", MouseEventServerMessage.class, MouseEventServerMessage::new);
public static final MessageType<KeyEventServerMessage> KEY_EVENT = registerServerbound(1, "key_event", KeyEventServerMessage.class, KeyEventServerMessage::new);
public static final MessageType<MouseEventServerMessage> MOUSE_EVENT = registerServerbound(2, "mouse_event", MouseEventServerMessage.class, MouseEventServerMessage::new);
public static final MessageType<PasteEventComputerMessage> PASTE_EVENT = registerServerbound(3, "paste_event", PasteEventComputerMessage.class, PasteEventComputerMessage::new);
public static final MessageType<UploadFileMessage> UPLOAD_FILE = registerServerbound(4, "upload_file", UploadFileMessage.class, UploadFileMessage::new);
public static final MessageType<ChatTableClientMessage> CHAT_TABLE = registerClientbound(10, "chat_table", ChatTableClientMessage.class, ChatTableClientMessage::new);

View File

@@ -7,9 +7,7 @@ package dan200.computercraft.shared.network.container;
import dan200.computercraft.shared.network.NetworkMessage;
import dan200.computercraft.shared.platform.PlatformHelper;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.MenuType;
@@ -21,16 +19,6 @@ import java.util.function.Function;
public interface ContainerData {
void toBytes(FriendlyByteBuf buf);
/**
* Open a menu for a specific player using this data.
*
* @param player The player to open the menu for.
* @param menu The underlying menu provider.
*/
default void open(Player player, MenuProvider menu) {
PlatformHelper.get().openMenu(player, menu, this);
}
static <C extends AbstractContainerMenu, T extends ContainerData> MenuType<C> toType(Function<FriendlyByteBuf, T> reader, Factory<C, T> factory) {
return PlatformHelper.get().createMenuType(reader, factory);
}

View File

@@ -33,6 +33,7 @@ public class ComputerActionServerMessage extends ComputerServerMessage {
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
switch (action) {
case TERMINATE -> container.getInput().terminate();
case TURN_ON -> container.getInput().turnOn();
case REBOOT -> container.getInput().reboot();
case SHUTDOWN -> container.getInput().shutdown();
@@ -45,6 +46,7 @@ public class ComputerActionServerMessage extends ComputerServerMessage {
}
public enum Action {
TERMINATE,
TURN_ON,
SHUTDOWN,
REBOOT

View File

@@ -35,8 +35,8 @@ public abstract class ComputerServerMessage implements NetworkMessage<ServerNetw
@Override
public void handle(ServerNetworkContext context) {
Player player = context.getSender();
if (player.containerMenu.containerId == containerId && player.containerMenu instanceof ComputerMenu) {
handle(context, (ComputerMenu) player.containerMenu);
if (player.containerMenu.containerId == containerId && player.containerMenu instanceof ComputerMenu menu) {
handle(context, menu);
}
}

View File

@@ -37,10 +37,11 @@ public class KeyEventServerMessage extends ComputerServerMessage {
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
var input = container.getInput();
if (type == Action.UP) {
input.keyUp(key);
} else {
input.keyDown(key, type == Action.REPEAT);
switch (type) {
case UP -> input.keyUp(key);
case DOWN -> input.keyDown(key, false);
case REPEAT -> input.keyDown(key, true);
case CHAR -> input.charTyped((byte) key);
}
}
@@ -50,6 +51,6 @@ public class KeyEventServerMessage extends ComputerServerMessage {
}
public enum Action {
DOWN, REPEAT, UP
DOWN, REPEAT, UP, CHAR
}
}

View File

@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.network.server;
import dan200.computercraft.core.util.StringUtil;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.menu.ComputerMenu;
import dan200.computercraft.shared.computer.menu.ServerInputHandler;
import dan200.computercraft.shared.network.MessageType;
import dan200.computercraft.shared.network.NetworkMessages;
import io.netty.handler.codec.DecoderException;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.inventory.AbstractContainerMenu;
import java.nio.ByteBuffer;
/**
* Paste a string on a {@link ServerComputer}.
*
* @see ServerInputHandler#paste(ByteBuffer)
*/
public class PasteEventComputerMessage extends ComputerServerMessage {
private final ByteBuffer text;
public PasteEventComputerMessage(AbstractContainerMenu menu, ByteBuffer text) {
super(menu);
this.text = text;
}
public PasteEventComputerMessage(FriendlyByteBuf buf) {
super(buf);
var length = buf.readVarInt();
if (length > StringUtil.MAX_PASTE_LENGTH) {
throw new DecoderException("ByteArray with size " + length + " is bigger than allowed " + StringUtil.MAX_PASTE_LENGTH);
}
var text = new byte[length];
buf.readBytes(text);
this.text = ByteBuffer.wrap(text);
}
@Override
public void write(FriendlyByteBuf buf) {
super.write(buf);
buf.writeVarInt(text.remaining());
buf.writeBytes(text.duplicate());
}
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
container.getInput().paste(text);
}
@Override
public MessageType<PasteEventComputerMessage> type() {
return NetworkMessages.PASTE_EVENT;
}
}

View File

@@ -1,57 +0,0 @@
// SPDX-FileCopyrightText: 2018 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.network.server;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.menu.ComputerMenu;
import dan200.computercraft.shared.computer.menu.ServerInputHandler;
import dan200.computercraft.shared.network.MessageType;
import dan200.computercraft.shared.network.NetworkMessages;
import dan200.computercraft.shared.util.NBTUtil;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.inventory.AbstractContainerMenu;
import javax.annotation.Nullable;
/**
* Queue an event on a {@link ServerComputer}.
*
* @see ServerInputHandler#queueEvent(String)
*/
public class QueueEventServerMessage extends ComputerServerMessage {
private final String event;
private final @Nullable Object[] args;
public QueueEventServerMessage(AbstractContainerMenu menu, String event, @Nullable Object[] args) {
super(menu);
this.event = event;
this.args = args;
}
public QueueEventServerMessage(FriendlyByteBuf buf) {
super(buf);
event = buf.readUtf(Short.MAX_VALUE);
var args = buf.readNbt();
this.args = args == null ? null : NBTUtil.decodeObjects(args);
}
@Override
public void write(FriendlyByteBuf buf) {
super.write(buf);
buf.writeUtf(event);
buf.writeNbt(args == null ? null : NBTUtil.encodeObjects(args));
}
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
container.getInput().queueEvent(event, args);
}
@Override
public MessageType<QueueEventServerMessage> type() {
return NetworkMessages.QUEUE_EVENT;
}
}

View File

@@ -18,6 +18,7 @@ import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
@@ -237,7 +238,7 @@ public class MonitorBlockEntity extends BlockEntity {
getLevel().setBlock(getBlockPos(), getBlockState()
.setValue(MonitorBlock.STATE, MonitorEdgeState.fromConnections(
yIndex < height - 1, yIndex > 0,
xIndex > 0, xIndex < width - 1)), 2);
xIndex > 0, xIndex < width - 1)), Block.UPDATE_CLIENTS);
}
// region Sizing and placement stuff

View File

@@ -20,6 +20,7 @@ import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.Registry;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.resources.ResourceKey;
@@ -29,11 +30,15 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerPlayerGameMode;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.tags.TagKey;
import net.minecraft.world.*;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.WorldlyContainer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.inventory.MenuConstructor;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.DyeColor;
@@ -159,10 +164,11 @@ public interface PlatformHelper extends dan200.computercraft.impl.PlatformHelper
* Open a container using a specific {@link ContainerData}.
*
* @param player The player to open the menu for.
* @param owner The underlying menu provider.
* @param menu The menu data.
* @param title The title for this menu.
* @param menu The underlying menu constructor.
* @param data The menu data.
*/
void openMenu(Player player, MenuProvider owner, ContainerData menu);
void openMenu(Player player, Component title, MenuConstructor menu, ContainerData data);
/**
* Create a new {@link MessageType}.

View File

@@ -10,7 +10,7 @@ import dan200.computercraft.api.pocket.IPocketUpgrade;
import dan200.computercraft.api.upgrades.UpgradeData;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.shared.common.IColouredItem;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.network.client.PocketComputerDataMessage;
import dan200.computercraft.shared.network.server.ServerNetworking;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
@@ -44,8 +44,8 @@ public final class PocketBrain implements IPocketAccess {
private int colour = -1;
private int lightColour = -1;
public PocketBrain(PocketHolder holder, int computerID, @Nullable String label, ComputerFamily family, @Nullable UpgradeData<IPocketUpgrade> upgrade) {
this.computer = new PocketServerComputer(this, holder, computerID, label, family);
public PocketBrain(PocketHolder holder, @Nullable UpgradeData<IPocketUpgrade> upgrade, ServerComputer.Properties properties) {
this.computer = new PocketServerComputer(this, holder, properties);
this.holder = holder;
this.position = holder.pos();
this.upgrade = UpgradeData.copyOf(upgrade);

View File

@@ -5,7 +5,9 @@
package dan200.computercraft.shared.pocket.core;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
@@ -51,6 +53,15 @@ public sealed interface PocketHolder {
*/
void setChanged();
/**
* Whether the terminal is visible to all players in range, and so should be broadcast to everyone.
*
* @return Whether to send the terminal.
*/
default boolean isTerminalAlwaysVisible() {
return false;
}
/**
* An {@link Entity} holding a pocket computer.
*/
@@ -112,4 +123,41 @@ public sealed interface PocketHolder {
entity.setItem(entity.getItem().copy());
}
}
/**
* A pocket computer in a {@link CustomLecternBlockEntity}.
*
* @param lectern The lectern holding this item.
*/
record LecternHolder(CustomLecternBlockEntity lectern) implements PocketHolder {
@Override
public ServerLevel level() {
return (ServerLevel) lectern.getLevel();
}
@Override
public Vec3 pos() {
return Vec3.atCenterOf(lectern.getBlockPos());
}
@Override
public BlockPos blockPos() {
return lectern.getBlockPos();
}
@Override
public boolean isValid(ServerComputer computer) {
return !lectern().isRemoved() && PocketComputerItem.isServerComputer(computer, lectern.getItem());
}
@Override
public void setChanged() {
BlockEntityHelpers.updateBlock(lectern());
}
@Override
public boolean isTerminalAlwaysVisible() {
return true;
}
}
}

View File

@@ -5,15 +5,13 @@
package dan200.computercraft.shared.pocket.core;
import dan200.computercraft.api.component.ComputerComponents;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ComputerState;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.config.ConfigSpec;
import dan200.computercraft.shared.network.client.PocketComputerDataMessage;
import dan200.computercraft.shared.network.client.PocketComputerDeletedClientMessage;
import dan200.computercraft.shared.network.server.ServerNetworking;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.ComponentMap;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.ChunkPos;
@@ -41,10 +39,10 @@ public final class PocketServerComputer extends ServerComputer {
private Set<ServerPlayer> tracking = Set.of();
PocketServerComputer(PocketBrain brain, PocketHolder holder, int computerID, @Nullable String label, ComputerFamily family) {
super(
holder.level(), holder.blockPos(), computerID, label, family, Config.pocketTermWidth, Config.pocketTermHeight,
ComponentMap.builder().add(ComputerComponents.POCKET, brain).build()
PocketServerComputer(PocketBrain brain, PocketHolder holder, ServerComputer.Properties properties) {
super(holder.level(), holder.blockPos(), properties
.terminalSize(ConfigSpec.pocketTermWidth.get(), ConfigSpec.pocketTermHeight.get())
.addComponent(ComputerComponents.POCKET, brain)
);
this.brain = brain;
}
@@ -74,7 +72,7 @@ public final class PocketServerComputer extends ServerComputer {
// Broadcast the state to new players.
var added = newTracking.stream().filter(x -> !tracking.contains(x)).toList();
if (!added.isEmpty()) {
ServerNetworking.sendToPlayers(new PocketComputerDataMessage(this, false), added);
ServerNetworking.sendToPlayers(new PocketComputerDataMessage(this, brain.holder().isTerminalAlwaysVisible()), added);
}
}
@@ -85,9 +83,15 @@ public final class PocketServerComputer extends ServerComputer {
protected void onTerminalChanged() {
super.onTerminalChanged();
if (brain.holder() instanceof PocketHolder.PlayerHolder holder && holder.isValid(this)) {
// Broadcast the terminal to the current player.
ServerNetworking.sendToPlayer(new PocketComputerDataMessage(this, true), holder.entity());
var holder = brain.holder() instanceof PocketHolder.PlayerHolder h && h.isValid(this) ? h.entity() : null;
if (brain.holder().isTerminalAlwaysVisible() && !tracking.isEmpty()) {
// If the terminal is always visible, send it to all players *and* the holder.
var packet = new PocketComputerDataMessage(this, true);
ServerNetworking.sendToPlayers(packet, tracking);
if (holder != null && !tracking.contains(holder)) ServerNetworking.sendToPlayer(packet, holder);
} else if (holder != null) {
// Otherwise just send it to the holder.
ServerNetworking.sendToPlayer(new PocketComputerDataMessage(this, true), holder);
}
}

View File

@@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.pocket.inventory;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.inventory.ComputerMenuWithoutInventory;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.item.ItemStack;
import javax.annotation.Nullable;
import static dan200.computercraft.core.util.Nullability.assertNonNull;
public class PocketComputerMenuProvider implements MenuProvider {
private final ServerComputer computer;
private final Component name;
private final PocketComputerItem item;
private final InteractionHand hand;
private final boolean isTypingOnly;
public PocketComputerMenuProvider(ServerComputer computer, ItemStack stack, PocketComputerItem item, InteractionHand hand, boolean isTypingOnly) {
this.computer = computer;
name = stack.getHoverName();
this.item = item;
this.hand = hand;
this.isTypingOnly = isTypingOnly;
}
@Override
public Component getDisplayName() {
return name;
}
@Nullable
@Override
public AbstractContainerMenu createMenu(int id, Inventory inventory, Player entity) {
return new ComputerMenuWithoutInventory(
isTypingOnly ? ModRegistry.Menus.POCKET_COMPUTER_NO_TERM.get() : ModRegistry.Menus.COMPUTER.get(), id, inventory,
p -> {
var stack = p.getItemInHand(hand);
return stack.getItem() == item && PocketComputerItem.getServerComputer(assertNonNull(entity.level().getServer()), stack) == computer;
},
computer
);
}
}

View File

@@ -12,18 +12,21 @@ import dan200.computercraft.api.pocket.IPocketUpgrade;
import dan200.computercraft.api.upgrades.UpgradeData;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.impl.PocketUpgrades;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.common.IColouredItem;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.core.ServerComputerRegistry;
import dan200.computercraft.shared.computer.core.ServerContext;
import dan200.computercraft.shared.computer.inventory.ComputerMenuWithoutInventory;
import dan200.computercraft.shared.computer.items.IComputerItem;
import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.lectern.CustomLecternBlock;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.platform.PlatformHelper;
import dan200.computercraft.shared.pocket.core.PocketBrain;
import dan200.computercraft.shared.pocket.core.PocketHolder;
import dan200.computercraft.shared.pocket.core.PocketServerComputer;
import dan200.computercraft.shared.pocket.inventory.PocketComputerMenuProvider;
import dan200.computercraft.shared.util.IDAssigner;
import dan200.computercraft.shared.util.InventoryUtil;
import dan200.computercraft.shared.util.NBTUtil;
@@ -42,6 +45,7 @@ import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import javax.annotation.Nullable;
@@ -79,12 +83,20 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
/**
* Tick a pocket computer.
*
* @param stack The current pocket computer stack.
* @param holder The entity holding the pocket item.
* @param brain The pocket computer brain.
* @param stack The current pocket computer stack.
* @param holder The entity holding the pocket item.
* @param passive If set, the pocket computer will not be created if it doesn't exist, and will not be kept alive.
*/
private void tick(ItemStack stack, PocketHolder holder, PocketBrain brain) {
brain.updateHolder(holder);
public void tick(ItemStack stack, PocketHolder holder, boolean passive) {
PocketBrain brain;
if (passive) {
var computer = getServerComputer(holder.level().getServer(), stack);
if (computer == null) return;
brain = computer.getBrain();
} else {
brain = getOrCreateBrain(holder.level(), holder, stack);
brain.computer().keepAlive();
}
// Update pocket upgrade
var upgrade = brain.getUpgrade();
@@ -137,11 +149,7 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
if (slot < 0) return;
// If we're in the inventory, create a computer and keep it alive.
var holder = new PocketHolder.PlayerHolder(player, slot);
var brain = getOrCreateBrain((ServerLevel) world, holder, stack);
brain.computer().keepAlive();
tick(stack, holder, brain);
tick(stack, new PocketHolder.PlayerHolder(player, slot), false);
}
@ForgeOverride
@@ -151,12 +159,16 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
// If we're an item entity, tick an already existing computer (as to update the position), but do not keep the
// computer alive.
var computer = getServerComputer(level.getServer(), stack);
if (computer != null) tick(stack, new PocketHolder.ItemEntityHolder(entity), computer.getBrain());
tick(stack, new PocketHolder.ItemEntityHolder(entity), true);
return false;
}
@Override
public InteractionResult useOn(UseOnContext context) {
return CustomLecternBlock.defaultUseItemOn(context);
}
@Override
public InteractionResultHolder<ItemStack> use(Level world, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
@@ -169,20 +181,39 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
var stop = false;
var upgrade = getUpgrade(stack);
if (upgrade != null) {
brain.updateHolder(holder);
stop = upgrade.onRightClick(world, brain, computer.getPeripheral(ComputerSide.BACK));
// Sync back just in case. We don't need to setChanged, as we'll return the item anyway.
updateItem(stack, brain);
}
if (!stop) {
var isTypingOnly = hand == InteractionHand.OFF_HAND;
new ComputerContainerData(computer, stack).open(player, new PocketComputerMenuProvider(computer, stack, this, hand, isTypingOnly));
}
if (!stop) openImpl(player, stack, holder, hand == InteractionHand.OFF_HAND, computer);
}
return new InteractionResultHolder<>(InteractionResult.sidedSuccess(world.isClientSide), stack);
}
/**
* Open a container for this pocket computer.
*
* @param player The player to show the menu for.
* @param stack The pocket computer stack.
* @param holder The holder of the pocket computer.
* @param isTypingOnly Open the off-hand pocket screen (only supporting typing, with no visible terminal).
*/
public void open(Player player, ItemStack stack, PocketHolder holder, boolean isTypingOnly) {
var brain = getOrCreateBrain(holder.level(), holder, stack);
var computer = brain.computer();
computer.turnOn();
openImpl(player, stack, holder, isTypingOnly, computer);
}
private static void openImpl(Player player, ItemStack stack, PocketHolder holder, boolean isTypingOnly, ServerComputer computer) {
PlatformHelper.get().openMenu(player, stack.getHoverName(), (id, inventory, entity) -> new ComputerMenuWithoutInventory(
isTypingOnly ? ModRegistry.Menus.POCKET_COMPUTER_NO_TERM.get() : ModRegistry.Menus.COMPUTER.get(), id, inventory,
p -> holder.isValid(computer),
computer
), new ComputerContainerData(computer, stack));
}
@Override
public Component getName(ItemStack stack) {
var baseString = getDescriptionId(stack);
@@ -226,7 +257,11 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
var registry = ServerContext.get(level.getServer()).registry();
{
var computer = getServerComputer(registry, stack);
if (computer != null) return computer.getBrain();
if (computer != null) {
var brain = computer.getBrain();
brain.updateHolder(holder);
return brain;
}
}
var computerID = getComputerID(stack);
@@ -235,15 +270,17 @@ public class PocketComputerItem extends Item implements IComputerItem, IMedia, I
setComputerID(stack, computerID);
}
var brain = new PocketBrain(holder, getComputerID(stack), getLabel(stack), getFamily(), getUpgradeWithData(stack));
var brain = new PocketBrain(
holder, getUpgradeWithData(stack),
ServerComputer.properties(getComputerID(stack), getFamily()).label(getLabel(stack))
);
var computer = brain.computer();
var tag = stack.getOrCreateTag();
tag.putInt(NBT_SESSION, registry.getSessionID());
tag.putUUID(NBT_INSTANCE, computer.register());
// Only turn on when initially creating the computer, rather than each tick.
if (isMarkedOn(stack) && holder instanceof PocketHolder.PlayerHolder) computer.turnOn();
if (isMarkedOn(stack)) computer.turnOn();
updateItem(stack, brain);

View File

@@ -20,7 +20,6 @@ import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.container.BasicContainer;
import dan200.computercraft.shared.turtle.core.TurtleBrain;
import dan200.computercraft.shared.turtle.inventory.TurtleMenu;
import dan200.computercraft.shared.util.ComponentMap;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.NonNullList;
@@ -74,10 +73,10 @@ public class TurtleBlockEntity extends AbstractComputerBlockEntity implements Ba
@Override
protected ServerComputer createComputer(int id) {
var computer = new ServerComputer(
(ServerLevel) getLevel(), getBlockPos(), id, label,
getFamily(), Config.turtleTermWidth, Config.turtleTermHeight,
ComponentMap.builder().add(ComputerComponents.TURTLE, brain).build()
var computer = new ServerComputer((ServerLevel) getLevel(), getBlockPos(), ServerComputer.properties(id, getFamily())
.label(getLabel())
.terminalSize(Config.TURTLE_TERM_WIDTH, Config.TURTLE_TERM_HEIGHT)
.addComponent(ComputerComponents.TURTLE, brain)
);
brain.setupComputer(computer);
return computer;

View File

@@ -36,6 +36,7 @@ import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.MoverType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
@@ -280,7 +281,7 @@ public class TurtleBrain implements TurtleAccessInternal {
try {
// We use Block.UPDATE_CLIENTS here to ensure that neighbour updates caused in Block.updateNeighbourShapes
// are sent to the client. We want to avoid doing a full block update until the turtle state is copied over.
if (world.setBlock(pos, newState, 2)) {
if (world.setBlock(pos, newState, Block.UPDATE_CLIENTS)) {
var block = world.getBlockState(pos).getBlock();
if (block == oldBlock.getBlock()) {
var newTile = world.getBlockEntity(pos);
@@ -691,7 +692,7 @@ public class TurtleBrain implements TurtleAccessInternal {
}
var aabb = new AABB(minX, minY, minZ, maxX, maxY, maxZ);
var list = world.getEntitiesOfClass(Entity.class, aabb, TurtleBrain::canPush);
var list = world.getEntities((Entity) null, aabb, TurtleBrain::canPush);
if (!list.isEmpty()) {
double pushStep = 1.0f / ANIM_DURATION;
var pushStepX = moveDir.getStepX() * pushStep;

View File

@@ -22,11 +22,10 @@ public class TurtleDetectCommand implements TurtleCommand {
var direction = this.direction.toWorldDir(turtle);
// Check if thing in front is air or not
var world = turtle.getLevel();
var oldPosition = turtle.getPosition();
var newPosition = oldPosition.relative(direction);
var level = turtle.getLevel();
var pos = turtle.getPosition().relative(direction);
return !WorldUtil.isLiquidBlock(world, newPosition) && !world.isEmptyBlock(newPosition)
return !WorldUtil.isEmptyBlock(level.getBlockState(pos))
? TurtleCommandResult.success()
: TurtleCommandResult.failure();
}

View File

@@ -13,9 +13,9 @@ import dan200.computercraft.shared.util.WorldUtil;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
public class TurtleMoveCommand implements TurtleCommand {
private final MoveDirection direction;
@@ -30,57 +30,32 @@ public class TurtleMoveCommand implements TurtleCommand {
var direction = this.direction.toWorldDir(turtle);
// Check if we can move
var oldWorld = (ServerLevel) turtle.getLevel();
var level = (ServerLevel) turtle.getLevel();
var oldPosition = turtle.getPosition();
var newPosition = oldPosition.relative(direction);
var turtlePlayer = TurtlePlayer.getWithPosition(turtle, oldPosition, direction);
var canEnterResult = canEnter(turtlePlayer, oldWorld, newPosition);
if (!canEnterResult.isSuccess()) {
return canEnterResult;
}
var canEnterResult = canEnter(turtlePlayer, level, newPosition);
if (!canEnterResult.isSuccess()) return canEnterResult;
// Check existing block is air or replaceable
var state = oldWorld.getBlockState(newPosition);
if (!oldWorld.isEmptyBlock(newPosition) &&
!WorldUtil.isLiquidBlock(oldWorld, newPosition) &&
!state.canBeReplaced()) {
// Check existing block is air or replaceable.
var existingState = level.getBlockState(newPosition);
if (!(WorldUtil.isEmptyBlock(existingState) || existingState.canBeReplaced())) {
return TurtleCommandResult.failure("Movement obstructed");
}
// Check there isn't anything in the way
var collision = state.getCollisionShape(oldWorld, oldPosition).move(
newPosition.getX(),
newPosition.getY(),
newPosition.getZ()
);
if (!oldWorld.isUnobstructed(null, collision)) {
if (!Config.turtlesCanPush || this.direction == MoveDirection.UP || this.direction == MoveDirection.DOWN) {
return TurtleCommandResult.failure("Movement obstructed");
}
// Check there is space for all the pushable entities to be pushed
var list = oldWorld.getEntitiesOfClass(Entity.class, getBox(collision), x -> x != null && x.isAlive() && x.blocksBuilding);
for (var entity : list) {
var pushedBB = entity.getBoundingBox().move(
direction.getStepX(),
direction.getStepY(),
direction.getStepZ()
);
if (!oldWorld.isUnobstructed(null, Shapes.create(pushedBB))) {
return TurtleCommandResult.failure("Movement obstructed");
}
}
// Check there isn't an entity in the way.
var turtleShape = level.getBlockState(oldPosition).getCollisionShape(level, oldPosition)
.move(newPosition.getX(), newPosition.getY(), newPosition.getZ());
if (!level.isUnobstructed(null, turtleShape) && !canPushEntities(level, turtleShape.bounds())) {
return TurtleCommandResult.failure("Movement obstructed");
}
// Check fuel level
if (turtle.isFuelNeeded() && turtle.getFuelLevel() < 1) {
return TurtleCommandResult.failure("Out of fuel");
}
if (turtle.isFuelNeeded() && turtle.getFuelLevel() < 1) return TurtleCommandResult.failure("Out of fuel");
// Move
if (!turtle.teleportTo(oldWorld, newPosition)) return TurtleCommandResult.failure("Movement failed");
if (!turtle.teleportTo(level, newPosition)) return TurtleCommandResult.failure("Movement failed");
// Consume fuel
turtle.consumeFuel(1);
@@ -114,9 +89,20 @@ public class TurtleMoveCommand implements TurtleCommand {
return TurtleCommandResult.success();
}
private static AABB getBox(VoxelShape shape) {
return shape.isEmpty() ? EMPTY_BOX : shape.bounds();
}
private static final AABB EMPTY_BOX = new AABB(0, 0, 0, 0, 0, 0);
/**
* Determine if all entities in the given bounds can be pushed by the turtle.
*
* @param level The current level.
* @param bounds The bounding box.
* @return Whether all entities can be pushed.
*/
private boolean canPushEntities(Level level, AABB bounds) {
if (!Config.turtlesCanPush) return false;
// Check there is space for all the pushable entities to be pushed
return level.getEntities((Entity) null, bounds, e -> e.isAlive()
&& !e.isSpectator() && e.blocksBuilding && e.getPistonPushReaction() == PushReaction.IGNORE
).isEmpty();
}
}

View File

@@ -1,66 +0,0 @@
// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.util;
import dan200.computercraft.api.component.ComputerComponent;
import dan200.computercraft.core.metrics.MetricsObserver;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* An immutable map of components.
*/
public final class ComponentMap {
public static final ComputerComponent<MetricsObserver> METRICS = ComputerComponent.create("computercraft", "metrics");
private static final ComponentMap EMPTY = new ComponentMap(Map.of());
private final Map<ComputerComponent<?>, Object> components;
private ComponentMap(Map<ComputerComponent<?>, Object> components) {
this.components = components;
}
@SuppressWarnings("unchecked")
public <T> @Nullable T get(ComputerComponent<T> component) {
return (T) components.get(component);
}
public static ComponentMap empty() {
return EMPTY;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private final Map<ComputerComponent<?>, Object> components = new HashMap<>();
private Builder() {
}
public <T> Builder add(ComputerComponent<T> component, T value) {
addImpl(component, value);
return this;
}
public Builder add(ComponentMap components) {
for (var component : components.components.entrySet()) addImpl(component.getKey(), component.getValue());
return this;
}
private void addImpl(ComputerComponent<?> component, Object value) {
if (components.containsKey(component)) throw new IllegalArgumentException(component + " is already set");
components.put(component, value);
}
public ComponentMap build() {
return new ComponentMap(Map.copyOf(components));
}
}
}

View File

@@ -62,125 +62,33 @@ public final class NBTUtil {
return childTag != null && childTag.getId() == Tag.TAG_COMPOUND ? (CompoundTag) childTag : emptyTag();
}
private static @Nullable Tag toNBTTag(@Nullable Object object) {
if (object == null) return null;
if (object instanceof Boolean) return ByteTag.valueOf((byte) ((boolean) (Boolean) object ? 1 : 0));
if (object instanceof Number) return DoubleTag.valueOf(((Number) object).doubleValue());
if (object instanceof String) return StringTag.valueOf(object.toString());
if (object instanceof Map<?, ?> m) {
var nbt = new CompoundTag();
var i = 0;
for (Map.Entry<?, ?> entry : m.entrySet()) {
var key = toNBTTag(entry.getKey());
var value = toNBTTag(entry.getKey());
if (key != null && value != null) {
nbt.put("k" + i, key);
nbt.put("v" + i, value);
i++;
}
}
nbt.putInt("len", m.size());
return nbt;
}
return null;
}
public static @Nullable CompoundTag encodeObjects(@Nullable Object[] objects) {
if (objects == null || objects.length == 0) return null;
var nbt = new CompoundTag();
nbt.putInt("len", objects.length);
for (var i = 0; i < objects.length; i++) {
var child = toNBTTag(objects[i]);
if (child != null) nbt.put(Integer.toString(i), child);
}
return nbt;
}
private static @Nullable Object fromNBTTag(@Nullable Tag tag) {
if (tag == null) return null;
switch (tag.getId()) {
case Tag.TAG_BYTE:
return ((ByteTag) tag).getAsByte() > 0;
case Tag.TAG_DOUBLE:
return ((DoubleTag) tag).getAsDouble();
default:
case Tag.TAG_STRING:
return tag.getAsString();
case Tag.TAG_COMPOUND: {
var c = (CompoundTag) tag;
var len = c.getInt("len");
Map<Object, Object> map = new HashMap<>(len);
for (var i = 0; i < len; i++) {
var key = fromNBTTag(c.get("k" + i));
var value = fromNBTTag(c.get("v" + i));
if (key != null && value != null) map.put(key, value);
}
return map;
}
}
}
public static @Nullable Object toLua(@Nullable Tag tag) {
if (tag == null) return null;
switch (tag.getId()) {
case Tag.TAG_BYTE:
case Tag.TAG_SHORT:
case Tag.TAG_INT:
case Tag.TAG_LONG:
return ((NumericTag) tag).getAsLong();
case Tag.TAG_FLOAT:
case Tag.TAG_DOUBLE:
return ((NumericTag) tag).getAsDouble();
case Tag.TAG_STRING: // String
return tag.getAsString();
case Tag.TAG_COMPOUND: { // Compound
return switch (tag.getId()) {
case Tag.TAG_BYTE, Tag.TAG_SHORT, Tag.TAG_INT, Tag.TAG_LONG -> ((NumericTag) tag).getAsLong();
case Tag.TAG_FLOAT, Tag.TAG_DOUBLE -> ((NumericTag) tag).getAsDouble();
case Tag.TAG_STRING -> tag.getAsString();
case Tag.TAG_COMPOUND -> {
var compound = (CompoundTag) tag;
Map<String, Object> map = new HashMap<>(compound.size());
for (var key : compound.getAllKeys()) {
var value = toLua(compound.get(key));
if (value != null) map.put(key, value);
}
return map;
yield map;
}
case Tag.TAG_LIST: {
var list = (ListTag) tag;
List<Object> map = new ArrayList<>(list.size());
for (var value : list) map.add(toLua(value));
return map;
}
case Tag.TAG_BYTE_ARRAY: {
case Tag.TAG_LIST -> ((ListTag) tag).stream().map(NBTUtil::toLua).toList();
case Tag.TAG_BYTE_ARRAY -> {
var array = ((ByteArrayTag) tag).getAsByteArray();
List<Byte> map = new ArrayList<>(array.length);
for (var b : array) map.add(b);
return map;
yield map;
}
case Tag.TAG_INT_ARRAY: {
var array = ((IntArrayTag) tag).getAsIntArray();
List<Integer> map = new ArrayList<>(array.length);
for (var j : array) map.add(j);
return map;
}
default:
return null;
}
}
public static @Nullable Object[] decodeObjects(CompoundTag tag) {
var len = tag.getInt("len");
if (len <= 0) return null;
var objects = new Object[len];
for (var i = 0; i < len; i++) {
var key = Integer.toString(i);
if (tag.contains(key)) {
objects[i] = fromNBTTag(tag.get(key));
}
}
return objects;
case Tag.TAG_INT_ARRAY -> Arrays.stream(((IntArrayTag) tag).getAsIntArray()).boxed().toList();
case Tag.TAG_LONG_ARRAY -> Arrays.stream(((LongArrayTag) tag).getAsLongArray()).boxed().toList();
default -> null;
};
}
@Nullable

View File

@@ -29,9 +29,14 @@ import net.minecraft.world.phys.shapes.VoxelShape;
import javax.annotation.Nullable;
public final class WorldUtil {
@SuppressWarnings("deprecation")
public static boolean isLiquidBlock(Level world, BlockPos pos) {
if (!world.isInWorldBounds(pos)) return false;
return world.getBlockState(pos).liquid();
return world.isInWorldBounds(pos) && world.getBlockState(pos).liquid();
}
@SuppressWarnings("deprecation")
public static boolean isEmptyBlock(BlockState state) {
return state.isAir() || state.liquid();
}
public static boolean isVecInside(VoxelShape shape, Vec3 vec) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -25,6 +25,7 @@ import net.minecraft.core.Direction;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundCustomPayloadPacket;
@@ -35,11 +36,11 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.TagKey;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.CraftingContainer;
import net.minecraft.world.inventory.MenuConstructor;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
@@ -140,7 +141,7 @@ public class TestPlatformHelper extends AbstractComputerCraftAPI implements Plat
}
@Override
public void openMenu(Player player, MenuProvider owner, ContainerData menu) {
public void openMenu(Player player, Component title, MenuConstructor menu, ContainerData data) {
throw new UnsupportedOperationException("Cannot open menu inside tests");
}

View File

@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.mixin.gametest;
import com.mojang.datafixers.DataFixer;
import dan200.computercraft.gametest.core.TestHooks;
import net.minecraft.gametest.framework.GameTestServer;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.Services;
import net.minecraft.server.WorldStem;
import net.minecraft.server.level.progress.ChunkProgressListenerFactory;
import net.minecraft.server.packs.repository.PackRepository;
import net.minecraft.world.level.storage.LevelStorageSource;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
import org.spongepowered.asm.mixin.Shadow;
import java.net.Proxy;
import java.util.concurrent.locks.LockSupport;
@Mixin(GameTestServer.class)
abstract class GameTestServerMixin extends MinecraftServer {
GameTestServerMixin(Thread serverThread, LevelStorageSource.LevelStorageAccess storageSource, PackRepository packRepository, WorldStem worldStem, Proxy proxy, DataFixer fixerUpper, Services services, ChunkProgressListenerFactory progressListenerFactory) {
super(serverThread, storageSource, packRepository, worldStem, proxy, fixerUpper, services, progressListenerFactory);
}
/**
* Overwrite {@link GameTestServer#waitUntilNextTick()} to wait for all computers to finish executing.
* <p>
* This is a little dangerous (breaks async behaviour of computers), but it forces tests to be deterministic.
*
* @reason See above. This is only in the test mod, so no risk of collision.
* @author SquidDev.
*/
@Overwrite
@Override
public void waitUntilNextTick() {
while (true) {
runAllTasks();
if (!haveTestsStarted() || TestHooks.areComputersIdle(this)) break;
LockSupport.parkNanos(100_000);
}
}
@Shadow
private boolean haveTestsStarted() {
throw new AssertionError("Stub.");
}
}

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.assertNoPeripheral
import dan200.computercraft.gametest.api.assertPeripheral
import dan200.computercraft.gametest.api.immediate
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.platform.ComponentAccess
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestHelper
import java.util.*
/**
* Checks that we expose [ComponentAccess] for various blocks/block entities
*/
class Component_Test {
@GameTest(template = "default")
fun Peripheral(context: GameTestHelper) = context.immediate {
val pos = BlockPos(2, 2, 2)
// We fetch peripherals from the NORTH, as that is the default direction for modems. This is a bit of a hack,
// but avoids having to override the block state.
val side = Direction.NORTH
for ((block, type) in mapOf(
// Computers
ModRegistry.Blocks.COMPUTER_NORMAL to Optional.of("computer"),
ModRegistry.Blocks.COMPUTER_ADVANCED to Optional.of("computer"),
ModRegistry.Blocks.COMPUTER_COMMAND to Optional.empty(),
// Turtles
ModRegistry.Blocks.TURTLE_NORMAL to Optional.of("turtle"),
ModRegistry.Blocks.TURTLE_ADVANCED to Optional.of("turtle"),
// Peripherals
ModRegistry.Blocks.SPEAKER to Optional.of("speaker"),
ModRegistry.Blocks.DISK_DRIVE to Optional.of("drive"),
ModRegistry.Blocks.PRINTER to Optional.of("printer"),
ModRegistry.Blocks.MONITOR_NORMAL to Optional.of("monitor"),
ModRegistry.Blocks.MONITOR_ADVANCED to Optional.of("monitor"),
ModRegistry.Blocks.WIRELESS_MODEM_NORMAL to Optional.of("modem"),
ModRegistry.Blocks.WIRELESS_MODEM_ADVANCED to Optional.of("modem"),
ModRegistry.Blocks.WIRED_MODEM_FULL to Optional.of("modem"),
ModRegistry.Blocks.REDSTONE_RELAY to Optional.of("redstone_relay"),
)) {
context.setBlock(pos, block.get())
if (type.isPresent) {
context.assertPeripheral(pos, side, type.get())
} else {
context.assertNoPeripheral(pos, side)
}
}
}
}

View File

@@ -95,17 +95,6 @@ class Computer_Test {
}
}
/**
* Check computers and turtles expose peripherals.
*/
@GameTest
fun Computer_peripheral(context: GameTestHelper) = context.sequence {
thenExecute {
context.assertPeripheral(BlockPos(3, 2, 2), type = "computer")
context.assertPeripheral(BlockPos(1, 2, 2), type = "turtle")
}
}
/**
* Check chest peripherals are reattached with a new size.
*/

View File

@@ -642,6 +642,27 @@ class Turtle_Test {
}
}
/**
* Test turtles can push entities.
*/
@GameTest
fun Move_push_entity(helper: GameTestHelper) = helper.sequence {
thenOnComputer { turtle.up().await().assertArrayEquals(true) }
thenIdle(9)
thenExecute {
// The turtle has moved up
helper.assertBlockPresent(ModRegistry.Blocks.TURTLE_NORMAL.get(), BlockPos(2, 3, 2))
// As has the villager
val pos = BlockPos(2, 4, 2)
helper.assertEntityPresent(EntityType.VILLAGER, pos)
val villager = helper.getEntity(EntityType.VILLAGER)
val expectedY = helper.absolutePos(pos).y - 0.125
if (villager.y < expectedY) helper.fail("Expected villager at y>=$expectedY, but at ${villager.y}", pos)
}
}
/**
* Test a turtle can attack an entity and capture its drops.
*/

View File

@@ -127,6 +127,14 @@ fun GameTestHelper.sequence(run: GameTestSequence.() -> Unit) {
sequence.thenSucceed()
}
/**
* Run a function immediately, and then succeed.
*/
fun GameTestHelper.immediate(run: () -> Unit) {
run()
succeed()
}
/**
* A custom instance of [GameTestAssertPosException] which allows for longer error messages.
*/
@@ -232,15 +240,17 @@ private fun GameTestHelper.getPeripheralAt(pos: BlockPos, direction: Direction):
fun GameTestHelper.assertPeripheral(pos: BlockPos, direction: Direction = Direction.UP, type: String) {
val peripheral = getPeripheralAt(pos, direction)
val block = getBlockState(pos).block.name.string
when {
peripheral == null -> fail("No peripheral at position", pos)
peripheral.type != type -> fail("Peripheral is of type ${peripheral.type}, expected $type", pos)
peripheral == null -> fail("No peripheral for '$block'", pos)
peripheral.type != type -> fail("Peripheral for '$block' is of type ${peripheral.type}, expected $type", pos)
}
}
fun GameTestHelper.assertNoPeripheral(pos: BlockPos, direction: Direction = Direction.UP) {
val peripheral = getPeripheralAt(pos, direction)
if (peripheral != null) fail("Expected no peripheral, got a ${peripheral.type}", pos)
val block = getBlockState(pos).block.name
if (peripheral != null) fail("Expected no peripheral for '$block', got a ${peripheral.type}", pos)
}
fun GameTestHelper.assertExactlyItems(vararg expected: ItemStack, message: String? = null) {

View File

@@ -36,6 +36,10 @@ object ManagedComputers : ILuaMachine.Factory {
private val LOGGER = LoggerFactory.getLogger(ManagedComputers::class.java)
private val computers: MutableMap<String, Queue<suspend LuaTaskContext.() -> Unit>> = mutableMapOf()
internal fun reset() {
computers.clear()
}
internal fun enqueue(test: GameTestInfo, label: String, task: suspend LuaTaskContext.() -> Unit): Monitor {
val monitor = Monitor(test, label)
computers.computeIfAbsent(label) { ConcurrentLinkedDeque() }.add {

View File

@@ -5,6 +5,8 @@
package dan200.computercraft.gametest.core
import dan200.computercraft.api.ComputerCraftAPI
import dan200.computercraft.core.ComputerContext
import dan200.computercraft.core.computer.computerthread.ComputerThread
import dan200.computercraft.gametest.*
import dan200.computercraft.gametest.api.ClientGameTest
import dan200.computercraft.gametest.api.TestTags
@@ -23,6 +25,8 @@ import net.minecraft.world.phys.Vec3
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.lang.invoke.MethodHandle
import java.lang.invoke.MethodHandles
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
@@ -69,6 +73,8 @@ object TestHooks {
LOG.info("Cleaning up after last run")
GameTestRunner.clearAllTests(server.overworld(), BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200)
ManagedComputers.reset()
// Delete server context and add one with a mutable machine factory. This allows us to set the factory for
// specific test batches without having to reset all computers.
for (computer in ServerContext.get(server).registry().computers) {
@@ -80,7 +86,11 @@ object TestHooks {
CCTestCommand.importFiles(server)
}
@JvmStatic
fun areComputersIdle(server: MinecraftServer) = ComputerThreadReflection.isFullyIdle(ServerContext.get(server))
private val testClasses = listOf(
Component_Test::class.java,
Computer_Test::class.java,
CraftOs_Test::class.java,
Disk_Drive_Test::class.java,
@@ -116,14 +126,6 @@ object TestHooks {
}
}
private val isCi = System.getenv("CI") != null
/**
* Adjust the timeout of a test. This makes it 1.5 times longer when run under CI, as CI servers are less powerful
* than our own.
*/
private fun adjustTimeout(timeout: Int): Int = if (isCi) timeout + (timeout / 2) else timeout
private fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer<Method>) {
val className = testClass.simpleName.lowercase()
val testName = className + "." + method.name.lowercase()
@@ -135,7 +137,7 @@ object TestHooks {
TestFunction(
testInfo.batch, testName, testInfo.template.ifEmpty { testName },
StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps),
adjustTimeout(testInfo.timeoutTicks),
testInfo.timeoutTicks,
testInfo.setupTicks,
testInfo.required, testInfo.requiredSuccesses, testInfo.attempts,
) { value -> safeInvoke(method, value) },
@@ -152,7 +154,7 @@ object TestHooks {
testName,
testName,
testInfo.template.ifEmpty { testName },
adjustTimeout(testInfo.timeoutTicks),
testInfo.timeoutTicks,
0,
true,
) { value -> safeInvoke(method, value) },
@@ -200,3 +202,31 @@ object TestHooks {
return false
}
}
/**
* Nasty reflection to determine if computers are fully idle.
*
* This is horribly nasty, and should not be used as a model for any production code!
*
* @see [ComputerThread.isFullyIdle]
* @see [dan200.computercraft.mixin.gametest.GameTestServerMixin]
*/
private object ComputerThreadReflection {
private val lookup = MethodHandles.lookup()
@JvmField
val computerContext: MethodHandle = lookup.unreflectGetter(
ServerContext::class.java.getDeclaredField("context").also { it.isAccessible = true },
)
@JvmField
val isFullyIdle: MethodHandle = lookup.unreflect(
ComputerThread::class.java.getDeclaredMethod("isFullyIdle").also { it.isAccessible = true },
)
fun isFullyIdle(context: ServerContext): Boolean {
val computerContext = computerContext.invokeExact(context) as ComputerContext
val computerThread = computerContext.computerScheduler() as ComputerThread
return isFullyIdle.invokeExact(computerThread) as Boolean
}
}

View File

@@ -35,7 +35,7 @@ class MultiTestReporter(private val reporters: List<TestReporter>) : TestReporte
* Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination
* directory exists.
*/
class JunitTestReporter constructor(destination: File) : JUnitLikeTestReporter(destination) {
class JunitTestReporter(destination: File) : JUnitLikeTestReporter(destination) {
override fun save(file: File) {
try {
Files.createDirectories(file.toPath().parent)

View File

@@ -11,6 +11,7 @@
"GameTestInfoAccessor",
"GameTestSequenceAccessor",
"GameTestSequenceMixin",
"GameTestServerMixin",
"TestCommandAccessor"
],
"client": [

View File

@@ -1,5 +1,5 @@
{
DataVersion: 2975,
DataVersion: 3465,
size: [5, 5, 5],
data: [
{pos: [0, 0, 0], state: "minecraft:polished_andesite"},
@@ -33,19 +33,19 @@
{pos: [0, 1, 3], state: "minecraft:air"},
{pos: [0, 1, 4], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 2], state: "computercraft:turtle_advanced{facing:south,waterlogged:false}", nbt: {Fuel: 0, Items: [], On: 0b, Owner: {LowerId: -6876936588741668278L, Name: "Dev", UpperId: 4039158846114182220L}, Slot: 0, id: "computercraft:turtle_advanced"}},
{pos: [1, 1, 3], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:white_stained_glass"},
{pos: [1, 1, 2], state: "minecraft:white_stained_glass"},
{pos: [1, 1, 3], state: "minecraft:white_stained_glass"},
{pos: [1, 1, 4], state: "minecraft:air"},
{pos: [2, 1, 0], state: "minecraft:air"},
{pos: [2, 1, 1], state: "minecraft:air"},
{pos: [2, 1, 2], state: "minecraft:air"},
{pos: [2, 1, 3], state: "minecraft:air"},
{pos: [2, 1, 1], state: "minecraft:white_stained_glass"},
{pos: [2, 1, 2], state: "computercraft:turtle_normal{facing:west,waterlogged:false}", nbt: {ComputerId: 1, Label: "turtle_test.move_push_entity", Fuel: 80, Items: [], On: 1b, Slot: 0, id: "computercraft:turtle_normal"}},
{pos: [2, 1, 3], state: "minecraft:white_stained_glass"},
{pos: [2, 1, 4], state: "minecraft:air"},
{pos: [3, 1, 0], state: "minecraft:air"},
{pos: [3, 1, 1], state: "minecraft:air"},
{pos: [3, 1, 2], state: "computercraft:computer_advanced{facing:north,state:off}", nbt: {On: 0b, id: "computercraft:computer_advanced"}},
{pos: [3, 1, 3], state: "minecraft:air"},
{pos: [3, 1, 1], state: "minecraft:white_stained_glass"},
{pos: [3, 1, 2], state: "minecraft:white_stained_glass"},
{pos: [3, 1, 3], state: "minecraft:white_stained_glass"},
{pos: [3, 1, 4], state: "minecraft:air"},
{pos: [4, 1, 0], state: "minecraft:air"},
{pos: [4, 1, 1], state: "minecraft:air"},
@@ -58,19 +58,19 @@
{pos: [0, 2, 3], state: "minecraft:air"},
{pos: [0, 2, 4], state: "minecraft:air"},
{pos: [1, 2, 0], state: "minecraft:air"},
{pos: [1, 2, 1], state: "minecraft:air"},
{pos: [1, 2, 2], state: "minecraft:air"},
{pos: [1, 2, 3], state: "minecraft:air"},
{pos: [1, 2, 1], state: "minecraft:white_stained_glass"},
{pos: [1, 2, 2], state: "minecraft:white_stained_glass"},
{pos: [1, 2, 3], state: "minecraft:white_stained_glass"},
{pos: [1, 2, 4], state: "minecraft:air"},
{pos: [2, 2, 0], state: "minecraft:air"},
{pos: [2, 2, 1], state: "minecraft:air"},
{pos: [2, 2, 1], state: "minecraft:white_stained_glass"},
{pos: [2, 2, 2], state: "minecraft:air"},
{pos: [2, 2, 3], state: "minecraft:air"},
{pos: [2, 2, 3], state: "minecraft:white_stained_glass"},
{pos: [2, 2, 4], state: "minecraft:air"},
{pos: [3, 2, 0], state: "minecraft:air"},
{pos: [3, 2, 1], state: "minecraft:air"},
{pos: [3, 2, 2], state: "minecraft:air"},
{pos: [3, 2, 3], state: "minecraft:air"},
{pos: [3, 2, 1], state: "minecraft:white_stained_glass"},
{pos: [3, 2, 2], state: "minecraft:white_stained_glass"},
{pos: [3, 2, 3], state: "minecraft:white_stained_glass"},
{pos: [3, 2, 4], state: "minecraft:air"},
{pos: [4, 2, 0], state: "minecraft:air"},
{pos: [4, 2, 1], state: "minecraft:air"},
@@ -83,19 +83,19 @@
{pos: [0, 3, 3], state: "minecraft:air"},
{pos: [0, 3, 4], state: "minecraft:air"},
{pos: [1, 3, 0], state: "minecraft:air"},
{pos: [1, 3, 1], state: "minecraft:air"},
{pos: [1, 3, 2], state: "minecraft:air"},
{pos: [1, 3, 3], state: "minecraft:air"},
{pos: [1, 3, 1], state: "minecraft:white_stained_glass"},
{pos: [1, 3, 2], state: "minecraft:white_stained_glass"},
{pos: [1, 3, 3], state: "minecraft:white_stained_glass"},
{pos: [1, 3, 4], state: "minecraft:air"},
{pos: [2, 3, 0], state: "minecraft:air"},
{pos: [2, 3, 1], state: "minecraft:air"},
{pos: [2, 3, 1], state: "minecraft:white_stained_glass"},
{pos: [2, 3, 2], state: "minecraft:air"},
{pos: [2, 3, 3], state: "minecraft:air"},
{pos: [2, 3, 3], state: "minecraft:white_stained_glass"},
{pos: [2, 3, 4], state: "minecraft:air"},
{pos: [3, 3, 0], state: "minecraft:air"},
{pos: [3, 3, 1], state: "minecraft:air"},
{pos: [3, 3, 2], state: "minecraft:air"},
{pos: [3, 3, 3], state: "minecraft:air"},
{pos: [3, 3, 1], state: "minecraft:white_stained_glass"},
{pos: [3, 3, 2], state: "minecraft:white_stained_glass"},
{pos: [3, 3, 3], state: "minecraft:white_stained_glass"},
{pos: [3, 3, 4], state: "minecraft:air"},
{pos: [4, 3, 0], state: "minecraft:air"},
{pos: [4, 3, 1], state: "minecraft:air"},
@@ -108,19 +108,19 @@
{pos: [0, 4, 3], state: "minecraft:air"},
{pos: [0, 4, 4], state: "minecraft:air"},
{pos: [1, 4, 0], state: "minecraft:air"},
{pos: [1, 4, 1], state: "minecraft:air"},
{pos: [1, 4, 2], state: "minecraft:air"},
{pos: [1, 4, 3], state: "minecraft:air"},
{pos: [1, 4, 1], state: "minecraft:white_stained_glass"},
{pos: [1, 4, 2], state: "minecraft:white_stained_glass"},
{pos: [1, 4, 3], state: "minecraft:white_stained_glass"},
{pos: [1, 4, 4], state: "minecraft:air"},
{pos: [2, 4, 0], state: "minecraft:air"},
{pos: [2, 4, 1], state: "minecraft:air"},
{pos: [2, 4, 1], state: "minecraft:white_stained_glass"},
{pos: [2, 4, 2], state: "minecraft:air"},
{pos: [2, 4, 3], state: "minecraft:air"},
{pos: [2, 4, 3], state: "minecraft:white_stained_glass"},
{pos: [2, 4, 4], state: "minecraft:air"},
{pos: [3, 4, 0], state: "minecraft:air"},
{pos: [3, 4, 1], state: "minecraft:air"},
{pos: [3, 4, 2], state: "minecraft:air"},
{pos: [3, 4, 3], state: "minecraft:air"},
{pos: [3, 4, 1], state: "minecraft:white_stained_glass"},
{pos: [3, 4, 2], state: "minecraft:white_stained_glass"},
{pos: [3, 4, 3], state: "minecraft:white_stained_glass"},
{pos: [3, 4, 4], state: "minecraft:air"},
{pos: [4, 4, 0], state: "minecraft:air"},
{pos: [4, 4, 1], state: "minecraft:air"},
@@ -128,11 +128,13 @@
{pos: [4, 4, 3], state: "minecraft:air"},
{pos: [4, 4, 4], state: "minecraft:air"}
],
entities: [],
entities: [
{blockPos: [2, 1, 2], pos: [2.5d, 1.875d, 2.5d], nbt: {AbsorptionAmount: 0.0f, Age: 0, Air: 300s, ArmorDropChances: [0.085f, 0.085f, 0.085f, 0.085f], ArmorItems: [{}, {}, {}, {}], Attributes: [{Base: 0.5d, Name: "minecraft:generic.movement_speed"}, {Base: 48.0d, Modifiers: [{Amount: -0.01165046535152748d, Name: "Random spawn bonus", Operation: 1, UUID: [I; 1412502412, 1522745411, -1211155694, 2103054347]}], Name: "minecraft:generic.follow_range"}], Brain: {memories: {}}, CanPickUpLoot: 1b, DeathTime: 0s, FallDistance: 0.0f, FallFlying: 0b, Fire: -1s, FoodLevel: 0b, ForcedAge: 0, Gossips: [], HandDropChances: [0.085f, 0.085f], HandItems: [{}, {}], Health: 20.0f, HurtByTimestamp: 0, HurtTime: 0s, Inventory: [], Invulnerable: 0b, LastGossipDecay: 52357L, LastRestock: 0L, LeftHanded: 0b, Motion: [0.0d, -0.0784000015258789d, 0.0d], OnGround: 1b, PersistenceRequired: 0b, PortalCooldown: 0, Pos: [-33.5d, 58.875d, -21.5d], RestocksToday: 0, Rotation: [-102.704926f, 0.0f], UUID: [I; 164071932, -867285780, -1817215456, -2129864016], VillagerData: {level: 1, profession: "minecraft:none", type: "minecraft:desert"}, Xp: 0, id: "minecraft:villager"}}
],
palette: [
"minecraft:polished_andesite",
"minecraft:white_stained_glass",
"minecraft:air",
"computercraft:turtle_advanced{facing:south,waterlogged:false}",
"computercraft:computer_advanced{facing:north,state:off}"
"computercraft:turtle_normal{facing:west,waterlogged:false}"
]
}

View File

@@ -69,7 +69,7 @@ public class WebsocketHandle {
* Send a websocket message to the connected server.
*
* @param message The message to send.
* @param binary Whether this message should be treated as a
* @param binary Whether this message should be treated as a binary message.
* @throws LuaException If the message is too large.
* @throws LuaException If the websocket has been closed.
* @cc.changed 1.81.0 Added argument for binary mode.

View File

@@ -31,7 +31,7 @@ import java.util.concurrent.atomic.AtomicLong;
* <li>Passes main thread tasks to the {@link MainThreadScheduler.Executor}.</li>
* </ul>
*/
public class Computer {
public class Computer implements ComputerEvents.Receiver {
private static final int START_DELAY = 50;
// Various properties of the computer
@@ -114,6 +114,7 @@ public class Computer {
executor.queueStop(false, true);
}
@Override
public void queueEvent(String event, @Nullable Object[] args) {
executor.queueEvent(event, args);
}

View File

@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.computer;
import dan200.computercraft.core.util.StringUtil;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
/**
* Built-in events that can be queued on a computer.
*/
public final class ComputerEvents {
private ComputerEvents() {
}
public static void keyDown(Receiver receiver, int key, boolean repeat) {
receiver.queueEvent("key", new Object[]{ key, repeat });
}
public static void keyUp(Receiver receiver, int key) {
receiver.queueEvent("key_up", new Object[]{ key });
}
/**
* Type a character on the computer.
*
* @param receiver The computer to queue the event on.
* @param chr The character to type.
* @see StringUtil#isTypableChar(byte)
*/
public static void charTyped(Receiver receiver, byte chr) {
receiver.queueEvent("char", new Object[]{ new byte[]{ chr } });
}
/**
* Paste a string.
*
* @param receiver The computer to queue the event on.
* @param contents The string to paste.
* @see StringUtil#getClipboardString(String)
*/
public static void paste(Receiver receiver, ByteBuffer contents) {
receiver.queueEvent("paste", new Object[]{ contents });
}
public static void mouseClick(Receiver receiver, int button, int x, int y) {
receiver.queueEvent("mouse_click", new Object[]{ button, x, y });
}
public static void mouseUp(Receiver receiver, int button, int x, int y) {
receiver.queueEvent("mouse_up", new Object[]{ button, x, y });
}
public static void mouseDrag(Receiver receiver, int button, int x, int y) {
receiver.queueEvent("mouse_drag", new Object[]{ button, x, y });
}
public static void mouseScroll(Receiver receiver, int direction, int x, int y) {
receiver.queueEvent("mouse_scroll", new Object[]{ direction, x, y });
}
/**
* An object that can receive computer events.
*/
@FunctionalInterface
public interface Receiver {
void queueEvent(String event, @Nullable Object[] arguments);
}
}

View File

@@ -433,6 +433,16 @@ public final class ComputerThread implements ComputerScheduler {
return computerQueueSize() > idleWorkers.get();
}
/**
* Determine if no work is queued, and all workers are idle.
*
* @return If the threads are fully idle.
*/
@VisibleForTesting
boolean isFullyIdle() {
return computerQueueSize() == 0 && idleWorkers.get() >= workerCount();
}
private void workerFinished(WorkerThread worker) {
// We should only shut down a worker once! This should only happen if we fail to abort a worker and then the
// worker finishes normally.

View File

@@ -11,6 +11,7 @@ import dan200.computercraft.api.lua.ILuaFunction;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.computer.TimeoutState;
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
import dan200.computercraft.core.methods.LuaMethod;
import dan200.computercraft.core.methods.MethodSupplier;
import dan200.computercraft.core.util.LuaUtil;
@@ -77,6 +78,7 @@ public class CobaltLuaMachine implements ILuaMachine {
var globals = state.globals();
CoreLibraries.debugGlobals(state);
Bit32Lib.add(state, globals);
ErrorInfoLib.add(state);
globals.rawset("_HOST", ValueFactory.valueOf(environment.hostString()));
globals.rawset("_CC_DEFAULT_SETTINGS", ValueFactory.valueOf(CoreConfig.defaultComputerSettings));

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2009-2011 Luaj.org, 2015-2020 SquidDev
//
// SPDX-License-Identifier: MIT
package dan200.computercraft.core.lua.errorinfo;
import org.squiddev.cobalt.Prototype;
import static org.squiddev.cobalt.Lua.*;
/**
* Extracted parts of Cobalt's {@link org.squiddev.cobalt.debug.DebugHelpers}.
*/
final class DebugHelpers {
private DebugHelpers() {
}
private static int filterPc(int pc, int jumpTarget) {
return pc < jumpTarget ? -1 : pc;
}
/**
* Find the PC where a register was last set.
* <p>
* This makes some assumptions about the structure of the bytecode, namely that there are no back edges within the
* CFG. As a result, this is only valid for temporary values, and not locals.
*
* @param pt The function prototype.
* @param lastPc The PC to work back from.
* @param reg The register.
* @return The last instruction where the register was set, or {@code -1} if not defined.
*/
static int findSetReg(Prototype pt, int lastPc, int reg) {
var lastInsn = -1; // Last instruction that changed "reg";
var jumpTarget = 0; // Any code before this address is conditional
for (var pc = 0; pc < lastPc; pc++) {
var i = pt.code[pc];
var op = GET_OPCODE(i);
var a = GETARG_A(i);
switch (op) {
case OP_LOADNIL -> {
var b = GETARG_B(i);
if (a <= reg && reg <= a + b) lastInsn = filterPc(pc, jumpTarget);
}
case OP_TFORCALL -> {
if (a >= a + 2) lastInsn = filterPc(pc, jumpTarget);
}
case OP_CALL, OP_TAILCALL -> {
if (reg >= a) lastInsn = filterPc(pc, jumpTarget);
}
case OP_JMP -> {
var dest = pc + 1 + GETARG_sBx(i);
// If jump is forward and doesn't skip lastPc, update jump target
if (pc < dest && dest <= lastPc && dest > jumpTarget) jumpTarget = dest;
}
default -> {
if (testAMode(op) && reg == a) lastInsn = filterPc(pc, jumpTarget);
}
}
}
return lastInsn;
}
}

View File

@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.lua.errorinfo;
import com.google.common.annotations.VisibleForTesting;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.debug.DebugFrame;
import org.squiddev.cobalt.function.LuaFunction;
import org.squiddev.cobalt.function.RegisteredFunction;
import javax.annotation.Nullable;
import java.util.Objects;
import static org.squiddev.cobalt.Lua.*;
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_ANY_HOOK;
/**
* Provides additional info about an error.
* <p>
* This is currently an internal and deeply unstable module. It's not clear if doing this via bytecode (rather than an
* AST) is the correct approach and/or, what the correct design is.
*/
public class ErrorInfoLib {
private static final int MAX_DEPTH = 8;
private static final RegisteredFunction[] functions = new RegisteredFunction[]{
RegisteredFunction.ofV("info_for_nil", ErrorInfoLib::getInfoForNil),
};
public static void add(LuaState state) throws LuaError {
state.registry().getSubTable(Constants.LOADED).rawset("cc.internal.error_info", RegisteredFunction.bind(functions));
}
private static Varargs getInfoForNil(LuaState state, Varargs args) throws LuaError {
var thread = args.arg(1).checkThread();
var level = args.arg(2).checkInteger();
var context = getInfoForNil(state, thread, level);
return context == null ? Constants.NIL : ValueFactory.varargsOf(
ValueFactory.valueOf(context.op()), ValueFactory.valueOf(context.source().isGlobal()),
context.source().table(), context.source().key()
);
}
/**
* Get some additional information about an {@code attempt to $OP (a nil value)} error. This often occurs as a
* result of a misspelled local, global or table index, and so we attempt to detect those cases.
*
* @param state The current Lua state.
* @param thread The thread which has errored.
* @param level The level where the error occurred. We currently expect this to always be 0.
* @return Some additional information about the error, where available.
*/
@VisibleForTesting
static @Nullable NilInfo getInfoForNil(LuaState state, LuaThread thread, int level) {
var frame = thread.getDebugState().getFrame(level);
if (frame == null || frame.closure == null || (frame.flags & FLAG_ANY_HOOK) != 0) return null;
var prototype = frame.closure.getPrototype();
var pc = frame.pc;
var insn = prototype.code[pc];
// Find what operation we're doing that errored.
return switch (GET_OPCODE(insn)) {
case OP_CALL, OP_TAILCALL ->
NilInfo.of("call", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
case OP_GETTABLE, OP_SETTABLE, OP_SELF ->
NilInfo.of("index", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
default -> null;
};
}
/**
* Information about an {@code attempt to $OP (a nil value)} error.
*
* @param op The operation we tried to perform.
* @param source The expression that resulted in a nil value.
*/
@VisibleForTesting
record NilInfo(String op, ValueSource source) {
public static @Nullable NilInfo of(String op, @Nullable ValueSource values) {
return values == null ? null : new NilInfo(op, values);
}
}
/**
* A partially-reconstructed Lua expression. This currently only is used for table indexing ({@code table[key]}.
*
* @param isGlobal Whether this is a global table access. This is a best-effort guess, and does not distinguish between
* {@code foo} and {@code _ENV.foo}.
* @param table The table being indexed.
* @param key The key we tried to index.
*/
@VisibleForTesting
record ValueSource(boolean isGlobal, LuaValue table, LuaString key) {
}
/**
* Attempt to partially reconstruct a Lua expression from the current debug state.
*
* @param state The current Lua state.
* @param frame The current debug frame.
* @param prototype The current function.
* @param pc The current program counter.
* @param register The register where this value was stored.
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
* @return The reconstructed expression, or {@code null} if not available.
*/
@SuppressWarnings("NullTernary")
private static @Nullable ValueSource resolveValueSource(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
if (depth > MAX_DEPTH) return null;
if (prototype.getLocalName(register + 1, pc) != null) return null;
// Find where this register was set. If unknown, then abort.
pc = DebugHelpers.findSetReg(prototype, pc, register);
if (pc == -1) return null;
var insn = prototype.code[pc];
return switch (GET_OPCODE(insn)) {
case OP_MOVE -> {
var a = GETARG_A(insn);
var b = GETARG_B(insn); // move from `b' to `a'
yield b < a ? resolveValueSource(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b' .
}
case OP_GETTABUP, OP_GETTABLE, OP_SELF -> {
var tableIndex = GETARG_B(insn);
var keyIndex = GETARG_C(insn);
// We're only interested in expressions of the form "foo.bar". Showing a "did you mean" hint for
// "foo[i]" isn't very useful!
if (!ISK(keyIndex)) yield null;
var key = prototype.constants[INDEXK(keyIndex)];
if (key.type() != Constants.TSTRING) yield null;
var table = GET_OPCODE(insn) == OP_GETTABUP
? frame.closure.getUpvalue(tableIndex).getValue()
: evaluate(state, frame, prototype, pc, tableIndex, depth);
if (table == null) yield null;
var isGlobal = GET_OPCODE(insn) == OP_GETTABUP && Objects.equals(prototype.getUpvalueName(tableIndex), Constants.ENV);
yield new ValueSource(isGlobal, table, (LuaString) key);
}
default -> null;
};
}
/**
* Attempt to reconstruct the value of a register.
*
* @param state The current Lua state.
* @param frame The current debug frame.
* @param prototype The current function
* @param pc The PC to evaluate at.
* @param register The register to evaluate.
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
* @return The reconstructed value, or {@code null} if unavailable.
*/
@SuppressWarnings("NullTernary")
private static @Nullable LuaValue evaluate(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
if (depth >= MAX_DEPTH) return null;
// If this is a local, then return its contents.
if (prototype.getLocalName(register + 1, pc) != null) return frame.stack[register];
// Otherwise find where this register was set. If unknown, then abort.
pc = DebugHelpers.findSetReg(prototype, pc, register);
if (pc == -1) return null;
var insn = prototype.code[pc];
var opcode = GET_OPCODE(insn);
return switch (opcode) {
case OP_MOVE -> {
var a = GETARG_A(insn);
var b = GETARG_B(insn); // move from `b' to `a'
yield b < a ? evaluate(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b'.
}
// Load constants
case OP_LOADK -> prototype.constants[GETARG_Bx(insn)];
case OP_LOADKX -> prototype.constants[GETARG_Ax(prototype.code[pc + 1])];
case OP_LOADBOOL -> GETARG_B(insn) == 0 ? Constants.FALSE : Constants.TRUE;
case OP_LOADNIL -> Constants.NIL;
// Upvalues and tables.
case OP_GETUPVAL -> frame.closure.getUpvalue(GETARG_B(insn)).getValue();
case OP_GETTABLE, OP_GETTABUP -> {
var table = opcode == OP_GETTABUP
? frame.closure.getUpvalue(GETARG_B(insn)).getValue()
: evaluate(state, frame, prototype, pc, GETARG_B(insn), depth + 1);
if (table == null) yield null;
var key = evaluateK(state, frame, prototype, pc, GETARG_C(insn), depth + 1);
yield key == null ? null : safeIndex(state, table, key);
}
default -> null;
};
}
private static @Nullable LuaValue evaluateK(LuaState state, DebugFrame frame, Prototype prototype, int pc, int registerOrConstant, int depth) {
return ISK(registerOrConstant) ? prototype.constants[INDEXK(registerOrConstant)] : evaluate(state, frame, prototype, pc, registerOrConstant, depth + 1);
}
private static @Nullable LuaValue safeIndex(LuaState state, LuaValue table, LuaValue key) {
var loop = 0;
do {
LuaValue metatable;
if (table instanceof LuaTable tbl) {
var res = tbl.rawget(key);
if (!res.isNil() || (metatable = tbl.metatag(state, CachedMetamethod.INDEX)).isNil()) return res;
} else if ((metatable = table.metatag(state, CachedMetamethod.INDEX)).isNil()) {
return null;
}
if (metatable instanceof LuaFunction) return null;
table = metatable;
}
while (++loop < Constants.MAXTAGLOOP);
return null;
}
}

View File

@@ -4,52 +4,118 @@
package dan200.computercraft.core.util;
import dan200.computercraft.core.computer.ComputerEvents;
import java.nio.ByteBuffer;
public final class StringUtil {
public static final int MAX_PASTE_LENGTH = 512;
private StringUtil() {
}
private static boolean isAllowed(char c) {
return (c >= ' ' && c <= '~') || (c >= 161 && c <= 172) || (c >= 174 && c <= 255);
}
private static String removeSpecialCharacters(String text, int length) {
var builder = new StringBuilder(length);
for (var i = 0; i < length; i++) {
var c = text.charAt(i);
builder.append(isAllowed(c) ? c : '?');
/**
* Convert a Unicode character to a terminal one.
*
* @param chr The Unicode character.
* @return The terminal character. This is either in the range [0, 255] (if a valid character) or {@code -1} if
* it cannot be mapped to CC's charset.
*/
public static int unicodeToTerminal(int chr) {
// ASCII and latin1 map to themselves
if (chr == 0 || chr == '\t' || chr == '\n' || chr == '\r' || (chr >= ' ' && chr <= '~') || (chr >= 160 && chr <= 255)) {
return chr;
}
return builder.toString();
// Teletext block mosaics are *fairly* contiguous.
if (chr >= 0x1FB00 && chr <= 0x1FB13) return chr + (129 - 0x1fb00);
if (chr >= 0x1FB14 && chr <= 0x1FB1D) return chr + (150 - 0x1fb14);
// Everything else is just a manual lookup. For now, we just use a big switch statement, which we spin into a
// separate function to hopefully avoid inlining it here.
return unicodeToCraftOsFallback(chr);
}
public static String normaliseLabel(String text) {
return removeSpecialCharacters(text, Math.min(32, text.length()));
private static int unicodeToCraftOsFallback(int c) {
return switch (c) {
case 0x263A -> 1;
case 0x263B -> 2;
case 0x2665 -> 3;
case 0x2666 -> 4;
case 0x2663 -> 5;
case 0x2660 -> 6;
case 0x2022 -> 7;
case 0x25D8 -> 8;
case 0x2642 -> 11;
case 0x2640 -> 12;
case 0x266A -> 14;
case 0x266B -> 15;
case 0x25BA -> 16;
case 0x25C4 -> 17;
case 0x2195 -> 18;
case 0x203C -> 19;
case 0x25AC -> 22;
case 0x21A8 -> 23;
case 0x2191 -> 24;
case 0x2193 -> 25;
case 0x2192 -> 26;
case 0x2190 -> 27;
case 0x221F -> 28;
case 0x2194 -> 29;
case 0x25B2 -> 30;
case 0x25BC -> 31;
case 0x1FB99 -> 127;
case 0x258C -> 149;
default -> -1;
};
}
/**
* Normalise a string from the clipboard, suitable for pasting into a computer.
* Check if a character is capable of being input and passed to a {@linkplain ComputerEvents#charTyped(ComputerEvents.Receiver, byte)
* "char" event}.
*
* @param chr The character to check.
* @return Whether this character can be typed.
*/
public static boolean isTypableChar(int chr) {
return chr >= 0 && chr <= 255 && chr != 0 && chr != '\r' && chr != '\n';
}
private static boolean isAllowedInLabel(char c) {
// Limit to ASCII and latin1, excluding '§' (Minecraft's formatting character).
return (c >= ' ' && c <= '~') || (c >= 161 && c <= 255 && c != 167);
}
public static String normaliseLabel(String text) {
var length = Math.min(32, text.length());
var builder = new StringBuilder(length);
for (var i = 0; i < length; i++) {
var c = text.charAt(i);
builder.append(isAllowedInLabel(c) ? c : '?');
}
return builder.toString();
}
/**
* Convert a Java string to a Lua one (using the terminal charset), suitable for pasting into a computer.
* <p>
* This removes special characters and strips to the first line of text.
*
* @param clipboard The text from the clipboard.
* @return The normalised clipboard text.
* @return The encoded clipboard text.
*/
public static String normaliseClipboardString(String clipboard) {
// Clip to the first occurrence of \r or \n
var newLineIndex1 = clipboard.indexOf('\r');
var newLineIndex2 = clipboard.indexOf('\n');
public static ByteBuffer getClipboardString(String clipboard) {
var output = new byte[Math.min(MAX_PASTE_LENGTH, clipboard.length())];
var idx = 0;
int length;
if (newLineIndex1 >= 0 && newLineIndex2 >= 0) {
length = Math.min(newLineIndex1, newLineIndex2);
} else if (newLineIndex1 >= 0) {
length = newLineIndex1;
} else if (newLineIndex2 >= 0) {
length = newLineIndex2;
} else {
length = clipboard.length();
var iterator = clipboard.codePoints().iterator();
while (iterator.hasNext() && idx <= output.length) {
var chr = unicodeToTerminal(iterator.next());
if (chr < 0) continue; // Strip out unconvertible characters
if (!isTypableChar(chr)) break; // Stop at untypable ones.
output[idx++] = (byte) chr;
}
return removeSpecialCharacters(clipboard, Math.min(length, 512));
return ByteBuffer.wrap(output, 0, idx).asReadOnlyBuffer();
}
}

View File

@@ -47,12 +47,22 @@ local function sortCoords(startX, startY, endX, endY)
return minX, maxX, minY, maxY
end
--- Parses an image from a multi-line string
--
-- @tparam string image The string containing the raw-image data.
-- @treturn table The parsed image data, suitable for use with
-- [`paintutils.drawImage`].
-- @since 1.80pr1
--[=[- Parses an image from a multi-line string
@tparam string image The string containing the raw-image data.
@treturn table The parsed image data, suitable for use with [`paintutils.drawImage`].
@usage Parse an image from a string, and draw it.
local image = paintutils.parseImage([[
e e
e e
eeee
]])
paintutils.drawImage(image, term.getCursorPos())
@since 1.80pr1
]=]
function parseImage(image)
expect(1, image, "string")
local tImage = {}

View File

@@ -39,60 +39,55 @@ the other.
@since 1.2
]]
local exception = dofile("rom/modules/main/cc/internal/tiny_require.lua")("cc.internal.exception")
local function create(...)
local tFns = table.pack(...)
local tCos = {}
for i = 1, tFns.n, 1 do
local fn = tFns[i]
local barrier_ctx = { co = coroutine.running() }
local functions = table.pack(...)
local threads = {}
for i = 1, functions.n, 1 do
local fn = functions[i]
if type(fn) ~= "function" then
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
end
tCos[i] = coroutine.create(fn)
threads[i] = { co = coroutine.create(function() return exception.try_barrier(barrier_ctx, fn) end), filter = nil }
end
return tCos
return threads
end
local function runUntilLimit(_routines, _limit)
local count = #_routines
local function runUntilLimit(threads, limit)
local count = #threads
if count < 1 then return 0 end
local living = count
local tFilters = {}
local eventData = { n = 0 }
local event = { n = 0 }
while true do
for n = 1, count do
local r = _routines[n]
if r then
if tFilters[r] == nil or tFilters[r] == eventData[1] or eventData[1] == "terminate" then
local ok, param = coroutine.resume(r, table.unpack(eventData, 1, eventData.n))
if not ok then
error(param, 0)
else
tFilters[r] = param
end
if coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
for i = 1, count do
local thread = threads[i]
if thread and (thread.filter == nil or thread.filter == event[1] or event[1] == "terminate") then
local ok, param = coroutine.resume(thread.co, table.unpack(event, 1, event.n))
if ok then
thread.filter = param
elseif type(param) == "string" and exception.can_wrap_errors() then
error(exception.make_exception(param, thread.co))
else
error(param, 0)
end
if coroutine.status(thread.co) == "dead" then
threads[i] = false
living = living - 1
if living <= limit then
return i
end
end
end
end
for n = 1, count do
local r = _routines[n]
if r and coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
end
end
eventData = table.pack(os.pullEventRaw())
event = table.pack(os.pullEventRaw())
end
end
@@ -120,8 +115,8 @@ from the [`parallel.waitForAny`] call.
print("Everything done!")
]]
function waitForAny(...)
local routines = create(...)
return runUntilLimit(routines, #routines - 1)
local threads = create(...)
return runUntilLimit(threads, #threads - 1)
end
--[[- Switches between execution of the functions, until all of them are
@@ -144,6 +139,6 @@ from the [`parallel.waitForAll`] call.
print("Everything done!")
]]
function waitForAll(...)
local routines = create(...)
return runUntilLimit(routines, 0)
local threads = create(...)
return runUntilLimit(threads, 0)
end

View File

@@ -7,9 +7,7 @@
-- @module textutils
-- @since 1.2
local pgk_env = setmetatable({}, { __index = _ENV })
pgk_env.require = dofile("rom/modules/main/cc/require.lua").make(pgk_env, "rom/modules/main")
local require = pgk_env.require
local require = dofile("rom/modules/main/cc/internal/tiny_require.lua")
local expect = require("cc.expect")
local expect, field = expect.expect, expect.field

View File

@@ -1,3 +1,22 @@
# New features in CC: Tweaked 1.115.0
* Support placing pocket computers on lecterns.
* Suggest alternative table keys on `nil` errors.
* Errors from inside `parallel` functions now have source information attached.
* Expose printout contents to the Java API.
Several bug fixes:
* Ignore unrepresentable characters in `char`/`paste` events.
# New features in CC: Tweaked 1.114.4
* Allow typing/pasting any character in the CC charset.
Several bug fixes:
* Fix command computers being exposed as peripherals (Forge only).
* Fix command computers having NBT set when placed in a Create contraption.
* Use correct bounding box when checking for entities in turtle movement.
# New features in CC: Tweaked 1.114.3
* `wget` now prints the error that occurred, rather than a generic "Failed" (tizu69).

View File

@@ -1,10 +1,11 @@
New features in CC: Tweaked 1.114.3
New features in CC: Tweaked 1.115.0
* `wget` now prints the error that occurred, rather than a generic "Failed" (tizu69).
* Update several translations.
* Support placing pocket computers on lecterns.
* Suggest alternative table keys on `nil` errors.
* Errors from inside `parallel` functions now have source information attached.
* Expose printout contents to the Java API.
Several bug fixes:
* Fix `fs.isDriveRoot` returning true for non-existent files.
* Fix possible memory leak when sending terminal contents.
* Ignore unrepresentable characters in `char`/`paste` events.
Type "help changelog" to see the full version history.

View File

@@ -0,0 +1,167 @@
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
--
-- SPDX-License-Identifier: MPL-2.0
--[[- Internal tools for diagnosing errors and suggesting fixes.
> [!DANGER]
> This is an internal module and SHOULD NOT be used in your own code. It may
> be removed or changed at any time.
@local
]]
local debug, type, rawget = debug, type, rawget
local sub, lower, find, min, abs = string.sub, string.lower, string.find, math.min, math.abs
--[[- Compute the Optimal String Distance between two strings.
@tparam string str_a The first string.
@tparam string str_b The second string.
@treturn number|nil The distance between two strings, or nil if they are two far
apart.
]]
local function osa_distance(str_a, str_b, threshold)
local len_a, len_b = #str_a, #str_b
-- If the two strings are too different in length, then bail now.
if abs(len_a - len_b) > threshold then return end
-- Zero-initialise our distance table.
local d = {}
for i = 1, (len_a + 1) * (len_b + 1) do d[i] = 0 end
-- Then fill the first row and column
local function idx(a, b) return a * (len_a + 1) + b + 1 end
for i = 0, len_a do d[idx(i, 0)] = i end
for j = 0, len_b do d[idx(0, j)] = j end
-- Then compute our distance
for i = 1, len_a do
local char_a = sub(str_a, i, i)
for j = 1, len_b do
local char_b = sub(str_b, j, j)
local sub_cost
if char_a == char_b then
sub_cost = 0
elseif lower(char_a) == lower(char_b) then
sub_cost = 0.5
else
sub_cost = 1
end
local new_cost = min(
d[idx(i - 1, j)] + 1, -- Deletion
d[idx(i, j - 1)] + 1, -- Insertion,
d[idx(i - 1, j - 1)] + sub_cost -- Substitution
)
-- Transposition
if i > 1 and j > 1 and char_a == sub(str_b, j - 1, j - 1) and char_b == sub(str_a, i - 1, i - 1) then
local trans_cost = d[idx(i - 2, j - 2)] + 1
if trans_cost < new_cost then new_cost = trans_cost end
end
d[idx(i, j)] = new_cost
end
end
local result = d[idx(len_a, len_b)]
if result <= threshold then return result else return nil end
end
--- Check whether this suggestion is useful.
local function useful_suggestion(str)
local len = #str
return len > 0 and len < 32 and find(str, "^[%a_]%w*$")
end
local function get_suggestions(is_global, value, key, thread, frame_offset)
if not useful_suggestion(key) then return end
-- Pick a maximum number of edits. We're more lenient on longer strings, but
-- still only allow two mistakes.
local threshold = #key >= 5 and 2 or 1
-- Find all items in the table, and see if they seem similar.
local suggestions = {}
local function process_suggestion(k)
if type(k) ~= "string" or not useful_suggestion(k) then return end
local distance = osa_distance(k, key, threshold)
if distance then
if distance < threshold then
-- If this is better than any existing match, then prefer it.
suggestions = { k }
threshold = distance
else
-- Otherwise distance==threshold, and so just add it.
suggestions[#suggestions + 1] = k
end
end
end
while type(value) == "table" do
for k in next, value do process_suggestion(k) end
local mt = debug.getmetatable(value)
if mt == nil then break end
value = rawget(mt, "__index")
end
-- If we're attempting to lookup a global, then also suggest any locals and
-- upvalues. Our upvalues will be incomplete, but maybe a little useful?
if is_global then
for i = 1, 200 do
local name = debug.getlocal(thread, frame_offset, i)
if not name then break end
process_suggestion(name)
end
local func = debug.getinfo(thread, frame_offset, "f").func
for i = 1, 255 do
local name = debug.getupvalue(func, i)
if not name then break end
process_suggestion(name)
end
end
table.sort(suggestions)
return suggestions
end
--[[- Get a tip to display at the end of an error.
@tparam string err The error message.
@tparam coroutine thread The current thread.
@tparam number frame_offset The offset into the thread where the current frame exists
@return An optional message to append to the error.
]]
local function get_tip(err, thread, frame_offset)
local nil_op = err:match("^attempt to (%l+) .* %(a nil value%)")
if not nil_op then return end
local has_error_info, error_info = pcall(require, "cc.internal.error_info")
if not has_error_info then return end
local op, is_global, table, key = error_info.info_for_nil(thread, frame_offset)
if op == nil or op ~= nil_op then return end
local suggestions = get_suggestions(is_global, table, key, thread, frame_offset)
if not suggestions or next(suggestions) == nil then return end
local pretty = require "cc.pretty"
local msg = "Did you mean: "
local n_suggestions = min(3, #suggestions)
for i = 1, n_suggestions do
if i > 1 then
if i == n_suggestions then msg = msg .. " or " else msg = msg .. ", " end
end
msg = msg .. pretty.text(suggestions[i], colours.lightGrey)
end
return msg .. "?"
end
return { get_tip = get_tip }

View File

@@ -12,7 +12,7 @@
]]
local expect = require "cc.expect".expect
local error_printer = require "cc.internal.error_printer"
local type, debug, coroutine = type, debug, coroutine
local function find_frame(thread, file, line)
-- Scan the first 16 frames for something interesting.
@@ -21,14 +21,14 @@ local function find_frame(thread, file, line)
if not frame then break end
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
return frame
return offset, frame
end
end
end
--[[- Check whether this error is an exception.
Currently we don't provide a stable API for throwing (and propogating) rich
Currently we don't provide a stable API for throwing (and propagating) rich
errors, like those supported by this module. In lieu of that, we describe the
exception protocol, which may be used by user-written coroutine managers to
throw exceptions which are pretty-printed by the shell:
@@ -64,6 +64,86 @@ local function is_exception(exn)
return mt and mt.__name == "exception" and type(rawget(exn, "message")) == "string" and type(rawget(exn, "thread")) == "thread"
end
local exn_mt = {
__name = "exception",
__tostring = function(self) return self.message end,
}
--[[- Create a new exception from a message and thread.
@tparam string message The exception message.
@tparam coroutine thread The coroutine the error occurred on.
@return The constructed exception.
]]
local function make_exception(message, thread)
return setmetatable({ message = message, thread = thread }, exn_mt)
end
--[[- A marker function for [`try`] and the wider exception machinery.
This function is typically the first function on the call stack. It acts as both
a signifier that this function is exception aware, and allows us to store
additional information for the exception machinery on the call stack.
@see can_wrap_errors
]]
local try_barrier = debug.getregistry().cc_try_barrier
if not try_barrier then
-- We define an extra "bounce" function to prevent f(...) being treated as a
-- tail call, and so ensure the barrier remains on the stack.
local function bounce(...) return ... end
--- @tparam { co = coroutine, can_wrap ?= boolean } parent The parent coroutine.
-- @tparam function f The function to call.
-- @param ... The arguments to this function.
try_barrier = function(parent, f, ...) return bounce(f(...)) end
debug.getregistry().cc_try_barrier = try_barrier
end
-- Functions that act as a barrier for exceptions.
local pcall_functions = { [pcall] = true, [xpcall] = true, [load] = true }
--[[- Check to see whether we can wrap errors into an exception.
This scans the current thread (up to a limit), and any parent threads, to
determine if there is a pcall anywhere on the callstack. If not, then we know
the error message is not observed by user code, and so may be wrapped into an
exception.
@tparam[opt] coroutine The thread to check. Defaults to the current thread.
@treturn boolean Whether we can wrap errors into exceptions.
]]
local function can_wrap_errors(thread)
if not thread then thread = coroutine.running() end
for offset = 0, 31 do
local frame = debug.getinfo(thread, offset, "f")
if not frame then return false end
local func = frame.func
if func == try_barrier then
-- If we've a try barrier, then extract the parent coroutine and
-- check if it can wrap errors.
local _, parent = debug.getlocal(thread, offset, 1)
if type(parent) ~= "table" or type(parent.co) ~= "thread" then return false end
local result = parent.can_wrap
if result == nil then
result = can_wrap_errors(parent.co)
parent.can_wrap = result
end
return result
elseif pcall_functions[func] then
-- If we're a pcall, then abort.
return false
end
end
return false
end
--[[- Attempt to call the provided function `func` with the provided arguments.
@tparam function func The function to call.
@@ -79,8 +159,8 @@ end
local function try(func, ...)
expect(1, func, "function")
local co = coroutine.create(func)
local result = table.pack(coroutine.resume(co, ...))
local co = coroutine.create(try_barrier)
local result = table.pack(coroutine.resume(co, { co = co, can_wrap = true }, func, ...))
while coroutine.status(co) ~= "dead" do
local event = table.pack(os.pullEventRaw(result[2]))
@@ -111,11 +191,11 @@ local function report(err, thread, source_map)
if type(err) ~= "string" then return end
local file, line = err:match("^([^:]+):(%d+):")
local file, line, err = err:match("^([^:]+):(%d+): (.*)")
if not file then return end
line = tonumber(line)
local frame = find_frame(thread, file, line)
local frame_offset, frame = find_frame(thread, file, line)
if not frame or not frame.currentcolumn then return end
local column = frame.currentcolumn
@@ -152,16 +232,22 @@ local function report(err, thread, source_map)
-- Could not determine the line. Bail.
if not line_contents or #line_contents == "" then return end
error_printer({
require("cc.internal.error_printer")({
get_pos = function() return line, column end,
get_line = function() return line_contents end,
}, {
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
})
end
return {
make_exception = make_exception,
try_barrier = try_barrier,
can_wrap_errors = can_wrap_errors,
try = try,
report = report,
}

View File

@@ -0,0 +1,37 @@
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
--
-- SPDX-License-Identifier: MPL-2.0
--[[- A minimal implementation of require.
This is intended for use with APIs, and other internal code which is not run in
the [`shell`] environment. This allows us to avoid some of the overhead of
loading the full [`cc.require`] module.
> [!DANGER]
> This is an internal module and SHOULD NOT be used in your own code. It may
> be removed or changed at any time.
@local
@tparam string name The module to require.
@return The required module.
]]
local loaded = {}
local env = setmetatable({}, { __index = _G })
local function require(name)
local result = loaded[name]
if result then return result end
local path = "rom/modules/main/" .. name:gsub("%.", "/")
if fs.exists(path .. ".lua") then
result = assert(loadfile(path .. ".lua", nil, env))()
else
result = assert(loadfile(path .. "/init.lua", nil, env))()
end
loaded[name] = result
return result
end
env.require = require
return require

View File

@@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
do
local make_package = require "cc.require".make
local dir = shell.dir()
_ENV.require, _ENV.package = make_package(_ENV, dir)
tEnv.require, tEnv.package = make_package(tEnv, dir)
end
if term.isColour() then

View File

@@ -413,7 +413,7 @@ public class ComputerTestDelegate {
var wholeMessage = new StringBuilder();
if (message != null) wholeMessage.append(message);
if (trace != null) {
if (wholeMessage.length() != 0) wholeMessage.append('\n');
if (!wholeMessage.isEmpty()) wholeMessage.append('\n');
wholeMessage.append(trace);
}

View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.lua.errorinfo;
import org.intellij.lang.annotations.Language;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.compiler.CompileException;
import org.squiddev.cobalt.compiler.LoadState;
import org.squiddev.cobalt.lib.CoreLibraries;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ErrorInfoLibTest {
@Test
public void testNilInfoForUnknownLibFunction() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "string.forma()");
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(false, state.globals().rawget("string"), ValueFactory.valueOf("forma"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
@Test
public void testNilInfoForUnknownGlobal() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "pront()");
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(true, state.globals(), ValueFactory.valueOf("pront"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
@Test
public void testNilInfoForComplexExpression() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "x = { { y = 1 } }; for i = 1, #x do x[i].z() end");
var inner = ((LuaTable) state.globals().rawget("x")).rawget(1);
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(false, inner, ValueFactory.valueOf("z"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
private static LuaState newState() throws LuaError {
var state = new LuaState();
CoreLibraries.standardGlobals(state);
return state;
}
private static LuaThread captureError(LuaState state, @Language("lua") String contents) throws CompileException, LuaError {
var fn = LoadState.load(state, new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)), "=in.lua", state.globals());
var thread = new LuaThread(state, fn);
Assertions.assertThrows(LuaError.class, () -> LuaThread.run(thread, Constants.NIL));
return thread;
}
}

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