1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2026-05-20 04:22:07 +00:00

Merge branch 'mc-1.21.x' into mc-1.21.y

There's definitely some more work to be done here — I need a datafixer
to move pocket upgrades from the bottom to the top — but it otherwise
seems to work.
This commit is contained in:
Jonathan Coates
2025-12-24 19:05:09 +00:00
92 changed files with 2275 additions and 1206 deletions
@@ -4,7 +4,10 @@
/** Default configuration for Fabric projects. */
import cc.tweaked.gradle.*
import cc.tweaked.gradle.CCTweakedExtension
import cc.tweaked.gradle.CCTweakedPlugin
import cc.tweaked.gradle.DependencyCheck
import cc.tweaked.gradle.MinecraftConfigurations
plugins {
`java-library`
@@ -44,11 +47,12 @@ dependencies {
loom.layered {
officialMojangMappings()
parchment(
project.dependencies.create(
group = "org.parchmentmc.data",
name = "parchment-${libs.findVersion("parchmentMc").get()}",
version = libs.findVersion("parchment").get().toString(),
ext = "zip",
dependencyFactory.create(
"org.parchmentmc.data",
"parchment-${libs.findVersion("parchmentMc").get()}",
libs.findVersion("parchment").get().toString(),
null,
"zip",
),
)
},
@@ -60,9 +64,3 @@ dependencies {
// Depend on error prone annotations to silence a lot of compile warnings.
compileOnlyApi(libs.findLibrary("errorProne.annotations").get())
}
tasks.named("checkDependencyConsistency", DependencyCheck::class.java) {
val libs = project.extensions.getByType<VersionCatalogsExtension>().named("libs")
// Minecraft depends on asm, but Fabric forces it to a more recent version
override(libs.findLibrary("asm").get(), "9.9")
}
@@ -77,13 +77,8 @@ class IlluaminatePlugin : Plugin<Project> {
else -> error("Unsupported architecture '$osArch' for illuaminate")
}
return project.dependencies.create(
mapOf(
"group" to "cc.squiddev",
"name" to "illuaminate",
"version" to version,
"ext" to "$os-$arch$suffix",
),
return project.dependencyFactory.create(
"cc.squiddev", "illuaminate", version, null, "$os-$arch$suffix",
)
}
}
+46 -63
View File
@@ -9,85 +9,68 @@ SPDX-License-Identifier: MPL-2.0
-->
# Setting up GPS
The [`gps`] API allows computers and turtles to find their current position using wireless modems.
The [`gps`] API allows a computer to find its current position using a [wireless modem][`modem`]. This works by
communicating with other computers (called *GPS hosts*) that already know their position, finding the distance to those
computers (with [`modem_message`]), and using that to derive its position from theirs (with a process known as
[trilateration](https://en.wikipedia.org/wiki/Trilateration).
In order to use GPS, you'll need to set up multiple *GPS hosts*. These are computers running the special `gps host`
program, which tell other computers the host's position. Several hosts running together are known as a *GPS
constellation*.
In order for this to work, we need our GPS hosts set up in a specific pattern, each one differing in position on at
least one axis. This guide takes you through the process of setting up a *constellation* of GPS hosts, and using them to
determine a computer's position.
In order to give the best results, a GPS constellation needs at least four computers. More than four GPS hosts per
constellation is redundant, but it does not cause problems.
## Prerequisites
You will need:
## Building a GPS constellation
<img alt="An example GPS constellation." src="../images/gps-constellation-example.png" class="big-image" />
- Four computers.
- Four Ender Modems. Normal Wireless Modems maybe be used, but the range of the GPS constellation will be severely
limited.
We are going to build our GPS constellation as shown in the image above. You will need 4 computers and either 4 wireless
modems or 4 ender modems. Try not to mix ender and wireless modems together as you might get some odd behavior when your
requesting computers are out of range.
Additionally, you will need another computer and a wireless modem, in order to test GPS works!
> [Ender modems vs wireless modems][!TIP]
> Ender modems have a very large range, which makes them very useful for setting up GPS hosts. If you do this then you
> will likely only need one GPS constellation for the whole dimension (such as the Overworld or Nether).
>
> If you do use wireless modems then you may find that you need multiple GPS constellations to cover your needs.
>
> A computer needs a wireless or ender modem and to be in range of a GPS constellation that is in the same dimension as
> it to use the GPS API. The reason for this is that ComputerCraft mimics real-life GPS by making use of the distance
> parameter of [modem messages][`modem_message`] and some maths.
## Picking an area
First, choose a place to build your GPS constellation. This should be a 10x10x10 cube, though you can make this smaller
if needed. The larger a constellation is, the more accurate it is over large distances, but even a 5x5x5 constellation
should serve a several thousand block radius.
Locate where you want to place your GPS constellation. You will need an area at least 6 blocks high, 6 blocks wide, and
6 blocks deep (6x6x6). If you are using wireless modems then you may want to build your constellation as high as you can
because high altitude boosts modem message range and thus the radius that your constellation covers.
Every computer must be loaded in order for other computers to use GPS, so it is recommended to build your GPS
constellation in a single chunk that will always be loaded. You may want to choose an area in an already chunk-loaded
part of your base, or in the [spawn chunks][spawn chunks]. You can use <kbd>F3+G</kbd> to view the chunk boundaries if
needed.
The GPS constellation will only work when it is in a loaded chunk. If you want your constellation to always be
accessible, you may want to permanently load the chunk using a vanilla or modded chunk loader. Make sure that your 6x6x6
area fits in a single chunk to reduce the number of chunks that need to be kept loaded.
[spawn chunks]: https://minecraft.wiki/w/Spawn_chunk "Spawn Chunk — Minecraft Wiki"
Let's get started building the constellation! Place your first computer in one of the corners of your 6x6x6. Remember
which computer this is as other computers need to be placed relative to it. Place the second computer 4 blocks above the
first. Go back to your first computer and place your third computer 5 blocks in front of your first computer, leaving 4
blocks of air between them. Finally for the fourth computer, go back to your first computer and place it 5 blocks right
of your first computer, leaving 4 blocks of air between them.
This is the example area we will be building our constellation in:
With all four computers placed within the 6x6x6, place one modem on top of each computer. You should have 4 modems and 4
computers all within your 6x6x6 where each modem is attached to a computer and each computer has a modem.
<img alt="An empty 10x10x10 area, with the axis marked with smooth stone." src="../images/gps-constellation-area.png" class="big-image" />
Currently your GPS constellation will not work, that's because each host is not aware that it's a GPS host. We will fix
this in the next section.
## Building the constellation
1. Place down your first computer in a corner of your area, and put a modem on top.
2. Head to the two adjacent corners of your area, place down another two computers and put a modem on top of each.
3. Pillar up above the first computer to the top of your cube, and place the final computer. Place a modem on the
computer.
You should now have something like this:
<img alt="The same area as before, but with a computer in each corner." src="../images/gps-constellation-built.png" class="big-image" />
## Configuring the constellation
Now that the structure of your constellation is built, we need to configure each host in it.
Go back to the first computer that you placed and create a startup file, by running `edit startup`.
1. Press <kbd>F3</kbd> to open Minecraft's debug screen.
2. Go back to the first computer and look at it. On the right of the screen about halfway down you should see an entry
labelled `Targeted Block`, the numbers correspond to the position of the block that you are looking at. Write these
numbers down.
3. Open the computer's UI, and run `edit startup.lua`.
4. Type the following code into the file, replacing `x`, `y`, and `z` with the coordinates you just wrote down.
Type the following code into the file:
```lua
shell.run("gps", "host", x, y, z)
```
```lua
shell.run("gps", "host", x, y, z)
```
5. Save the file, and then reboot the computer (hold <kbd>Ctrl+R</kbd> or run the `reboot` program) to run the startup
program.
Escape from the computer GUI and then press <kbd>F3</kbd> to open Minecraft's debug screen and then look at the computer
(without opening the GUI). On the right of the screen about halfway down you should see an entry labeled `Targeted
Block`, the numbers correspond to the position of the block that you are looking at. Replace `x` with the first number,
`y` with the second number, and `z` with the third number.
For example, if I had a computer at x = 59, y = 5, z = -150, then my <kbd>F3</kbd> debug screen entry would be `Target
Block: 59, 5, -150` and I would change my startup file to this `shell.run("gps", "host", 59, 5, -150)`.
To hide Minecraft's debug screen, press <kbd>F3</kbd> again.
Create similar startup files for the other computers in your constellation, making sure to input the each computer's own
coordinates.
> [Modem messages come from the computer's position, not the modem's][!WARNING]
> Wireless modems transmit from the block that they are attached to *not* the block space that they occupy, the
> coordinates that you input into your GPS host should be the position of the computer and not the position of the modem.
Repeat this process for the other three computers.
Congratulations, your constellation is now fully set up! You can test it by placing another computer close by, placing a
wireless modem on it, and running the `gps locate` program (or calling the [`gps.locate`] function).
> [Why use Minecraft's coordinates?][!INFO]
> CC doesn't care if you use Minecraft's coordinate system, so long as all of the GPS hosts with overlapping ranges use
> the same reference point (requesting computers will get confused if hosts have different reference points). However,
> using MC's coordinate system does provide a nice standard to adopt server-wide. It also is consistent with how command
> computers get their location, they use MC's command system to get their block which returns that in MC's coordinate
> system.
Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

+99
View File
@@ -0,0 +1,99 @@
---
module: [kind=reference] block_details
since: 1.64
changed: 1.76 Added block state.
changed: 1.117.0 Added map colour.
---
<!--
SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
SPDX-License-Identifier: MPL-2.0
-->
# Block details
Several functions in CC: Tweaked, such as [`turtle.inspect`] and [`commands.getBlockInfo`] provide a way to get
information about a block in the world. This page details information about blocks that CC: Tweaked may return.
## Basic information
Block information will *always* contain:
- `name: string`: The namespaced ID for this block, e.g. `minecraft:dirt`. See [the Minecraft wiki][block ids] for a
list of vanilla block IDs.
- `state: { [string] = any}`: A table containing the block state of the block.
### Example
A fully hydrated block of farmland:
```lua {data-no-run=1}
{
name = "minecraft:farmland",
state = {
moisture = 7
}
}
```
An extended piston, facing upwards:
```lua {data-no-run=1}
{
name = "minecraft:piston",
state = {
facing = "up",
extended = true
}
}
```
## Block tags
The [tags][block tags] a block has.
- `tags: { [string] = boolean }`: The set of tags for this block. This is a mapping of tag name to `true`.
While the representation of tags is a little more complicated then a single list, this makes it very easy to check if a
block has a certain tag:
```lua
--- Check if the block in front of the turtle is a log.
local function is_log()
local ok, block = turtle.inspect()
return ok and block.tags["minecraft:logs"]
end
```
### Example
A fully hydrated block of farmland:
```lua {data-no-run=1}
{
name = "minecraft:farmland",
state = { ... },
tags = {
["minecraft:mineable/shovel"] = true,
}
}
```
## Map colour
The colour the block will appear on the map, if specified.
- `mapColour?: number`: The colour of the block, as an RGB hex value.
- `mapColor?: number`: The color of the block, as an RGB hex value.
The map colour is just returned as a plain number (e.g. `9923917` for farmland). It can either be displayed in hex with
[`string.format`], or converted to individual RGB values with [`colors.unpackRGB`].
### Example
A fully hydrated block of farmland:
```lua {data-no-run=1}
{
name = "minecraft:farmland",
state = { ... },
mapColour = 9923917,
mapColor = 9923917,
}
```
[block ids]: https://minecraft.wiki/w/Java_Edition_data_values#Blocks "Java Edition data values on the Minecraft Wiki"
[block tags]:https://minecraft.wiki/w/Block_tag_%28Java_Edition%29 "Block tags on the Minecraft Wiki"
+267
View File
@@ -0,0 +1,267 @@
---
module: [kind=reference] item_details
since: 1.64
changed: 1.94.0 Add NBT hash, item tags, lore, enchantment and unbreakable flag.
changed: 1.100.9 Add item groups.
changed: 1.117.0 Added map colour and potion effects.
---
<!--
SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
SPDX-License-Identifier: MPL-2.0
-->
# Item details
Several functions in CC: Tweaked, such as [`turtle.getItemDetail`] and [`inventory.getItemDetail`] provide a way to get
information about an item stack. This page details information about items that CC: Tweaked may return.
Some methods (such as [`inventory.list`] and [`turtle.getItemDetail`] without the `detailed` flag), will only return
the "Basic information" about the item.
## Basic information
Item information will *always* contain:
- `name: string`: The namespaced ID for this item, e.g. `minecraft:dirt`. See [the Minecraft wiki][item ids] for a
list of vanilla item IDs.
- `count: number`: The number of items in the stack.
- `nbt?: string`: A hash of the NBT in the stack. While this does not expose any information about the item's NBT, it
can be used as a way to compare items. If two items have the same `name` and `nbt`, then all other properties
(e.g. durability, enchantment) will be the same.
### Example
A stack of 32 Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
}
```
A turtle with an upgrade attached:
```lua {data-no-run=1}
{
name = "computercraft:turtle_normal",
count = 1,
nbt = "a33095c2eb17c10e12f2b970c4e1b2bb",
}
```
## Display information
Common information shown in the item's tooltip:
- `displayName: string`: The translated display name of the item. This uses the *server's* language. This will
typically be English on multi-player servers, and your current language on single player.
- `lore: { string... }`: Additional lore about this item, as a list of strings.
### Example
A stack of Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
displayName = "Stripped Acacia Log",
}
```
## Max count
The maximum number of items this item can stack to:
- `maxCount: number`: The max possible size of the item stack.
### Example
A stack of Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
maxCount = 64,
}
```
## Item tags
The [tags][item tags] an item has.
- `tags: { [string] = boolean }`: The set of tags for this item. This is a mapping of tag name to `true`.
While the representation of tags is a little more complicated then a single list, this makes it very easy to check if an
item has a certain tag:
```lua
--- Check if the item in the turtle's inventory is a log.
local function is_log(slot)
local ok, block = turtle.getItemDetail(slot, true)
return ok and block.tags["minecraft:logs"]
end
```
### Example
A stack of Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
tags = {
["minecraft:acacia_logs"] = true,
["minecraft:logs"] = true,
["minecraft:logs_that_burn"] = true,
}
}
```
## Item groups
The creative tabs this item appears on:
- `itemGroups: { { id = string, displayName = string }... }`: The item groups this item appears on. Each item group is
stored as a table, containing its id and display name.
> [Version differences][!INFO]
> This information is not available on Minecraft 1.19.3 to 1.20.3. This field is present, but empty on those versions.
### Example
A stack of Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
itemGroups = {
{
id = "minecraft:building_blocks",
displayName = "Building Blocks"
}
}
}
```
## Damage and durability
If this item can be damaged (e.g. a pickaxe), then its damage and durability will be available:
- `damage: number`: The amount of damage this item has taken.
- `maxDamage: number`: The maximum amount of damage this item has taken.
- `durability?: number`: If this item is damaged (i.e. the durability bar is visible), the percentage left on the
durability bar, between 0 and 1 (inclusive).
- `unbreakable?: boolean`: `true`, if the item is nubreakable
### Example
An unused diamond pickaxe:
```lua {data-no-run=1}
{
name = "minecraft:diamond_pickaxe",
count = 1,
damage = 0,
maxDamage = 1561,
}
```
A half-used wooden pickaxe:
```lua {data-no-run=1}
{
name = "minecraft:wooden_pickaxe",
count = 1,
damage = 21,
maxDamage = 59,
durability = 0.615,
}
```
## Enchantments
The enchantments this item has. This includes both tools and enchanted books.
- `enchantments: { table... }`: The enchantments this item has. Each enchantment is a table containing several
properties:
- `name: string`: The namespaced ID for this enchantment, e.g. `minecraft:efficiency`. See [the Minecraft
wiki][enchantment ids] for a list of vanilla enchantment IDs.
- `displayName: string`: The translated display name for this enchantment.
- `level: number`: The level for this enchantment.
### Example
A diamond pickaxe with Efficiency V:
```lua {data-no-run=1}
{
name = "minecraft:diamond_pickaxe",
count = 1,
enchantments = {
{
name = "minecraft:efficiency",
level = 5,
displayName = "Efficiency V",
}
}
}
```
## Potion effects
The effects this potion (or potion-embued item, such as a tipped arrow) has:
- `potionEffects: { table... }`: The effects this item has. Each potion effect is a table containing several
properties:
- `name: string`: The namespaced ID for this effect, e.g. `minecraft:regeneration`. See [the Minecraft wiki][effect
ids] for a list of vanilla effect IDs.
- `displayName: string`: The translated display name for this potion effect.
- `duration?: number`: The duration this effect will last for (if not instant), in seconds.
- `potency?: number`: The potency of this potion.
### Example
A basic Potion of Healing:
```lua {data-no-run=1}
{
name = "minecraft:potion",
displayName = "Potion of Healing",
potionEffects = {
{
name = "minecraft:instant_health",
displayName = "Instant Health",
},
},
}
```
An upgraded Potion of Regeneration:
```lua {data-no-run=1}
{
name = "minecraft:potion",
displayName = "Potion of Regeneration",
potionEffects = {
{
name = "minecraft:regeneration",
displayName = "Regeneration II",
duration = 22.5,
potency = 2,
},
},
}
```
## Map colour
The colour the item's block form will appear on the map, if specified.
- `mapColour?: number`: The colour of the block, as an RGB hex value.
- `mapColor?: number`: The color of the block, as an RGB hex value.
The map colour is just returned as a plain number (e.g. `9923917` for dirt). It can either be displayed in hex with
[`string.format`], or converted to individual RGB values with [`colors.unpackRGB`].
### Example
A stack of Stripped Acacia Logs:
```lua {data-no-run=1}
{
name = "minecraft:stripped_acacia_log",
count = 32,
mapColour = 14188339,
mapColor = 14188339,
}
```
[item ids]: https://minecraft.wiki/w/Java_Edition_data_values#Items "Java Edition data values on the Minecraft Wiki"
[item tags]:https://minecraft.wiki/w/Item_tag_%28Java_Edition%29 "Item tags on the Minecraft Wiki"
[effect ids]: https://minecraft.wiki/w/Java_Edition_data_values#Effects "Java Edition data values on the Minecraft Wiki"
[enchantment ids]: https://minecraft.wiki/w/Java_Edition_data_values#Enchantments "Java Edition data values on the Minecraft Wiki"
+1 -1
View File
@@ -12,7 +12,7 @@ neogradle.subsystems.conventions.runs.enabled=false
# Mod properties
isUnstable=true
modVersion=1.116.2
modVersion=1.117.0
# Minecraft properties: We want to configure this here so we can read it in settings.gradle
mcVersion=1.21.11
+3 -3
View File
@@ -58,7 +58,7 @@ jmh = "1.37"
# Build tools
cctJavadoc = "1.8.5"
checkstyle = "12.1.1"
errorProne-core = "2.43.0"
errorProne-core = "2.45.0"
errorProne-plugin = "4.3.0"
fabric-loom = "1.14.5"
githubRelease = "2.5.2"
@@ -67,10 +67,10 @@ ideaExt = "1.3"
illuaminate = "0.1.0-83-g1131f68"
lwjgl = "3.3.6"
minotaur = "2.8.7"
modDevGradle = "2.0.122"
modDevGradle = "2.0.124"
nullAway = "0.12.14"
spotless = "8.0.0"
teavm = "0.13.0-SQUID.2"
teavm = "0.14.0-SQUID.1"
vanillaExtract = "0.3.1"
versionCatalogUpdate = "1.0.1"
+278 -275
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -9,14 +9,15 @@
"@squid-dev/cc-web-term": "^2.0.0",
"preact": "^10.5.5",
"setimmediate": "^1.0.5",
"tslib": "^2.0.3"
"tslib": "^2.0.3",
"wasm-feature-detect": "^1.8.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.0.0",
"@rollup/plugin-url": "^8.0.1",
"@swc/core": "^1.3.92",
"@types/node": "^24.0.0",
"@types/node": "^25.0.0",
"lightningcss": "^1.22.0",
"preact-render-to-string": "^6.2.1",
"rehype": "^13.0.0",
@@ -98,6 +98,7 @@ public final class ClientRegistry {
public static void registerMenuScreens(RegisterMenuScreen register) {
register.<AbstractComputerMenu, ComputerScreen<AbstractComputerMenu>>register(ModRegistry.Menus.COMPUTER.get(), ComputerScreen::new);
register.<AbstractComputerMenu, NoTermComputerScreen<AbstractComputerMenu>>register(ModRegistry.Menus.POCKET_COMPUTER_NO_TERM.get(), NoTermComputerScreen::new);
register.register(ModRegistry.Menus.POCKET_COMPUTER_LECTERN.get(), PocketComputerLecternScreen::new);
register.register(ModRegistry.Menus.TURTLE.get(), TurtleScreen::new);
register.register(ModRegistry.Menus.PRINTER.get(), PrinterScreen::new);
@@ -8,9 +8,9 @@ import dan200.computercraft.client.gui.widgets.ComputerSidebar;
import dan200.computercraft.client.gui.widgets.DynamicImageButton;
import dan200.computercraft.client.gui.widgets.TerminalWidget;
import dan200.computercraft.client.network.ClientNetworking;
import dan200.computercraft.core.input.UserComputerInput;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.InputHandler;
import dan200.computercraft.shared.computer.inventory.AbstractComputerMenu;
import dan200.computercraft.shared.computer.upload.FileUpload;
import dan200.computercraft.shared.computer.upload.UploadResult;
@@ -58,7 +58,8 @@ public abstract class AbstractComputerScreen<T extends AbstractComputerMenu> ext
protected @Nullable TerminalWidget terminal;
protected Terminal terminalData;
protected final ComputerFamily family;
protected final InputHandler input;
protected final UserComputerInput computerInput;
protected final ClientComputerActions computerActions;
protected final int sidebarYOffset;
@@ -72,7 +73,8 @@ public abstract class AbstractComputerScreen<T extends AbstractComputerMenu> ext
family = container.getFamily();
displayStack = container.getDisplayStack();
uploadMaxSize = container.getUploadMaxSize();
input = new ClientInputHandler(menu);
computerInput = new UserComputerInput(new ClientComputerInput(menu), menu.getTerminal());
computerActions = new ClientComputerActions(menu);
this.sidebarYOffset = sidebarYOffset;
}
@@ -88,7 +90,7 @@ public abstract class AbstractComputerScreen<T extends AbstractComputerMenu> ext
super.init();
terminal = addRenderableWidget(createTerminal());
ComputerSidebar.addButtons(menu::isOn, input, this::addRenderableWidget, leftPos, topPos + sidebarYOffset);
ComputerSidebar.addButtons(menu::isOn, computerActions, this::addRenderableWidget, leftPos, topPos + sidebarYOffset);
setFocused(terminal);
}
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.client.gui;
import dan200.computercraft.client.network.ClientNetworking;
import dan200.computercraft.shared.network.server.ComputerActionServerMessage;
import net.minecraft.world.inventory.AbstractContainerMenu;
/**
* Actions that can be applied to a computer.
*
* @see ComputerActionServerMessage
*/
public final class ClientComputerActions {
private final AbstractContainerMenu menu;
public ClientComputerActions(AbstractContainerMenu menu) {
this.menu = menu;
}
public void terminate() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.TERMINATE));
}
public void turnOn() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.TURN_ON));
}
public void shutdown() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.SHUTDOWN));
}
public void reboot() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.REBOOT));
}
}
@@ -5,9 +5,8 @@
package dan200.computercraft.client.gui;
import dan200.computercraft.client.network.ClientNetworking;
import dan200.computercraft.shared.computer.core.InputHandler;
import dan200.computercraft.core.input.ComputerInput;
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.PasteEventComputerMessage;
@@ -16,37 +15,17 @@ import net.minecraft.world.inventory.AbstractContainerMenu;
import java.nio.ByteBuffer;
/**
* An {@link InputHandler} for use on the client.
* An {@link ComputerInput} for use on the client.
* <p>
* This queues events on the remote player's open {@link ComputerMenu}.
* This queues events on the player's open {@link ComputerMenu}.
*/
public final class ClientInputHandler implements InputHandler {
public final class ClientComputerInput implements ComputerInput {
private final AbstractContainerMenu menu;
public ClientInputHandler(AbstractContainerMenu menu) {
public ClientComputerInput(AbstractContainerMenu menu) {
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));
}
@Override
public void shutdown() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.SHUTDOWN));
}
@Override
public void reboot() {
ClientNetworking.sendToServer(new ComputerActionServerMessage(menu, ComputerActionServerMessage.Action.REBOOT));
}
@Override
public void keyDown(int key, boolean repeat) {
ClientNetworking.sendToServer(new KeyEventServerMessage(menu, repeat ? KeyEventServerMessage.Action.REPEAT : KeyEventServerMessage.Action.DOWN, key));
@@ -32,7 +32,10 @@ public final class ComputerScreen<T extends AbstractComputerMenu> extends Abstra
@Override
protected TerminalWidget createTerminal() {
return new TerminalWidget(terminalData, input, leftPos + AbstractComputerMenu.SIDEBAR_WIDTH + BORDER, topPos + BORDER);
return new TerminalWidget(
terminalData, computerInput, computerActions,
leftPos + AbstractComputerMenu.SIDEBAR_WIDTH + BORDER, topPos + BORDER
);
}
@Override
@@ -5,7 +5,7 @@
package dan200.computercraft.client.gui;
import dan200.computercraft.client.gui.widgets.TerminalWidget;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.input.UserComputerInput;
import dan200.computercraft.shared.computer.inventory.AbstractComputerMenu;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.ScrollWheelHandler;
@@ -29,7 +29,8 @@ import static dan200.computercraft.core.util.Nullability.assertNonNull;
*/
public class NoTermComputerScreen<T extends AbstractComputerMenu> extends Screen implements MenuAccess<T> {
private final T menu;
private final Terminal terminalData;
protected final UserComputerInput computerInput;
protected final ClientComputerActions computerActions;
private @Nullable TerminalWidget terminal;
private final ScrollWheelHandler scrollHandler = new ScrollWheelHandler();
@@ -37,7 +38,8 @@ public class NoTermComputerScreen<T extends AbstractComputerMenu> extends Screen
public NoTermComputerScreen(T menu, Inventory player, Component title) {
super(title);
this.menu = menu;
terminalData = menu.getTerminal();
computerInput = new UserComputerInput(new ClientComputerInput(menu), menu.getTerminal());
computerActions = new ClientComputerActions(menu);
}
@Override
@@ -55,7 +57,7 @@ public class NoTermComputerScreen<T extends AbstractComputerMenu> extends Screen
super.init();
terminal = addWidget(new TerminalWidget(terminalData, new ClientInputHandler(menu), 0, 0));
terminal = addWidget(new TerminalWidget(menu.getTerminal(), computerInput, computerActions, 0, 0));
terminal.visible = false;
terminal.active = false;
setFocused(terminal);
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.client.gui;
import com.mojang.blaze3d.vertex.PoseStack;
import dan200.computercraft.client.render.CustomLecternRenderer;
import dan200.computercraft.shared.lectern.PocketComputerLecternMenu;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
import org.joml.Matrix4f;
import org.joml.Vector2i;
import org.joml.Vector2ic;
import org.joml.Vector3f;
import org.jspecify.annotations.Nullable;
import java.util.Objects;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_HEIGHT;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_WIDTH;
/**
* The screen for computers on lecterns.
* <p>
* This extends {@link NoTermComputerScreen}, but with support for interacting with the lectern's pocket computer.
*/
public final class PocketComputerLecternScreen extends NoTermComputerScreen<PocketComputerLecternMenu> {
public PocketComputerLecternScreen(PocketComputerLecternMenu menu, Inventory player, Component title) {
super(menu, player, title);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
var position = getMousePosition();
if (position != null) {
computerInput.mouseClick(event.button() + 1, position.x() + 1, position.y() + 1);
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
var position = getMousePosition();
if (position != null) {
computerInput.mouseDrag(event.button() + 1, position.x() + 1, position.y() + 1);
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
var position = getMousePosition();
if (position != null) {
computerInput.mouseUp(event.button() + 1, position.x() + 1, position.y() + 1);
return true;
}
return super.mouseReleased(event);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double deltaX, double deltaY) {
var position = getMousePosition();
if (position != null && deltaY != 0) {
computerInput.mouseScroll(deltaY < 0 ? 1 : -1, position.x(), position.y());
return true;
}
return super.mouseScrolled(mouseX, mouseY, deltaX, deltaY);
}
/**
* Get the position of the mouse on the pocket terminal computer.
*
* @return The cursor position, or {@code null} if the mouse is out-of-bounds.
*/
private @Nullable Vector2ic getMousePosition() {
var minecraft = Objects.requireNonNull(this.minecraft);
if (minecraft.level == null || minecraft.player == null || minecraft.hitResult == null) return null;
var lecternPos = getMenu().lectern();
var terminal = getMenu().getTerminal();
// First ensure we're looking at the lectern block.
if (minecraft.hitResult.getType() != HitResult.Type.BLOCK || !((BlockHitResult) minecraft.hitResult).getBlockPos().equals(lecternPos)) {
return null;
}
// Build the same pose stack that we use for rendering pocket computers.
var poseStack = new PoseStack();
CustomLecternRenderer.applyLecternTransform(poseStack, minecraft.level.getBlockState(lecternPos));
CustomLecternRenderer.applyPocketComputerTerminalTransform(poseStack);
CustomLecternRenderer.applyScaledPocketComputerTerminalTransform(poseStack, terminal);
// Then take the inverse matrix, and use it to map the player's position and look vector to terminal space.
var inverseTransform = poseStack.last().pose().invert(new Matrix4f());
var startPosition = minecraft.player.getEyePosition();
var transformedStartPosition = inverseTransform.transformPosition(
(float) (startPosition.x() - lecternPos.getX()),
(float) (startPosition.y() - lecternPos.getY()),
(float) (startPosition.z() - lecternPos.getZ()),
new Vector3f()
);
var transformedLookVector = inverseTransform.transformDirection(minecraft.player.getLookAngle().toVector3f());
// Compute the intersection of our plane with the look vector. This is trivial, as the terminal is at
// (0, 0, 0), with a normal of (0, 0, 1).
if (transformedLookVector.z() == 0) return null;
var intersection = transformedStartPosition.add(
transformedLookVector.mul(-transformedStartPosition.z() / transformedLookVector.z())
);
// Then map back to actual terminal coordinates, and check we're still in bounds.
var positionX = (int) (intersection.x() / FONT_WIDTH);
var positionY = (int) (intersection.y() / FONT_HEIGHT);
return positionX >= 0 && positionX < terminal.getWidth() && positionY >= 0 && positionY < terminal.getHeight()
? new Vector2i(positionX, positionY)
: null;
}
}
@@ -43,7 +43,10 @@ public class TurtleScreen extends AbstractComputerScreen<TurtleMenu> {
@Override
protected TerminalWidget createTerminal() {
return new TerminalWidget(terminalData, input, leftPos + BORDER + AbstractComputerMenu.SIDEBAR_WIDTH, topPos + BORDER);
return new TerminalWidget(
terminalData, computerInput, computerActions,
leftPos + BORDER + AbstractComputerMenu.SIDEBAR_WIDTH, topPos + BORDER
);
}
@Override
@@ -4,9 +4,9 @@
package dan200.computercraft.client.gui.widgets;
import dan200.computercraft.client.gui.ClientComputerActions;
import dan200.computercraft.client.gui.GuiSprites;
import dan200.computercraft.client.gui.widgets.DynamicImageButton.HintedMessage;
import dan200.computercraft.shared.computer.core.InputHandler;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.network.chat.Component;
@@ -29,7 +29,7 @@ public final class ComputerSidebar {
private ComputerSidebar() {
}
public static void addButtons(BooleanSupplier isOn, InputHandler input, Consumer<AbstractWidget> add, int x, int y) {
public static void addButtons(BooleanSupplier isOn, ClientComputerActions actions, Consumer<AbstractWidget> add, int x, int y) {
x += CORNERS_BORDER + 1;
y += CORNERS_BORDER + ICON_MARGIN;
@@ -41,7 +41,7 @@ public final class ComputerSidebar {
add.accept(new DynamicImageButton(
x, y, ICON_WIDTH, ICON_HEIGHT,
h -> isOn.getAsBoolean() ? GuiSprites.TURNED_ON.get(h) : GuiSprites.TURNED_OFF.get(h),
b -> toggleComputer(isOn, input),
b -> toggleComputer(isOn, actions),
() -> isOn.getAsBoolean() ? turnOff : turnOn
));
@@ -50,7 +50,7 @@ public final class ComputerSidebar {
add.accept(new DynamicImageButton(
x, y, ICON_WIDTH, ICON_HEIGHT,
GuiSprites.TERMINATE::get,
b -> input.terminate(),
b -> actions.terminate(),
new HintedMessage(
Component.translatable("gui.computercraft.tooltip.terminate"),
Component.translatable("gui.computercraft.tooltip.terminate.key")
@@ -58,11 +58,11 @@ public final class ComputerSidebar {
));
}
private static void toggleComputer(BooleanSupplier isOn, InputHandler input) {
private static void toggleComputer(BooleanSupplier isOn, ClientComputerActions actions) {
if (isOn.getAsBoolean()) {
input.shutdown();
actions.shutdown();
} else {
input.turnOn();
actions.turnOn();
}
}
}
@@ -8,11 +8,12 @@ import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.textures.FilterMode;
import com.mojang.blaze3d.vertex.VertexConsumer;
import dan200.computercraft.client.gui.ClientComputerActions;
import dan200.computercraft.client.gui.ClientComputerInput;
import dan200.computercraft.client.gui.KeyConverter;
import dan200.computercraft.client.render.text.FixedWidthFontRenderer;
import dan200.computercraft.core.input.UserComputerInput;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.StringUtil;
import dan200.computercraft.shared.computer.core.InputHandler;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.AbstractWidget;
@@ -32,8 +33,6 @@ import org.joml.Matrix4f;
import org.jspecify.annotations.Nullable;
import org.lwjgl.glfw.GLFW;
import java.util.BitSet;
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;
@@ -42,7 +41,7 @@ import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FON
* A widget which renders a computer terminal and handles input events (keyboard, mouse, clipboard) and computer
* shortcuts (terminate/shutdown/reboot).
*
* @see dan200.computercraft.client.gui.ClientInputHandler The input handler typically used with this class.
* @see ClientComputerInput The input handler typically used with this class.
*/
public class TerminalWidget extends AbstractWidget {
private static final Component DESCRIPTION = Component.translatable("gui.computercraft.terminal");
@@ -51,7 +50,8 @@ public class TerminalWidget extends AbstractWidget {
private static final float KEY_SUPPRESS_DELAY = 0.2f;
private final Terminal terminal;
private final InputHandler computer;
private final UserComputerInput computerInput;
private final ClientComputerActions computerActions;
// The positions of the actual terminal
private final int innerX;
@@ -63,17 +63,12 @@ public class TerminalWidget extends AbstractWidget {
private float rebootTimer = -1;
private float shutdownTimer = -1;
private int lastMouseButton = -1;
private int lastMouseX = -1;
private int lastMouseY = -1;
private final BitSet keysDown = new BitSet(256);
public TerminalWidget(Terminal terminal, InputHandler computer, int x, int y) {
public TerminalWidget(Terminal terminal, UserComputerInput computerInput, ClientComputerActions computerActions, int x, int y) {
super(x, y, terminal.getWidth() * FONT_WIDTH + MARGIN * 2, terminal.getHeight() * FONT_HEIGHT + MARGIN * 2, DESCRIPTION);
this.terminal = terminal;
this.computer = computer;
this.computerInput = computerInput;
this.computerActions = computerActions;
innerX = x + MARGIN;
innerY = y + MARGIN;
@@ -83,8 +78,7 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean charTyped(CharacterEvent event) {
var terminalChar = StringUtil.unicodeToTerminal(event.codepoint());
if (StringUtil.isTypableChar(terminalChar)) computer.charTyped((byte) terminalChar);
computerInput.codepointTyped(event.codepoint());
return true;
}
@@ -111,27 +105,19 @@ public class TerminalWidget extends AbstractWidget {
}
if (event.key() >= 0 && terminateTimer < KEY_SUPPRESS_DELAY && rebootTimer < KEY_SUPPRESS_DELAY && shutdownTimer < KEY_SUPPRESS_DELAY) {
// Queue the "key" event and add to the down set
var repeat = keysDown.get(event.key());
keysDown.set(event.key());
computer.keyDown(event.key(), repeat);
computerInput.keyDown(event.key());
}
return true;
}
private void paste() {
var clipboard = StringUtil.getClipboardString(Minecraft.getInstance().keyboardHandler.getClipboard());
if (clipboard.remaining() > 0) computer.paste(clipboard);
computerInput.paste(Minecraft.getInstance().keyboardHandler.getClipboard());
}
@Override
public boolean keyReleased(KeyEvent event) {
// Queue the "key_up" event and remove from the down set
if (event.key() >= 0 && keysDown.get(event.key())) {
keysDown.set(event.key(), false);
computer.keyUp(event.key());
}
computerInput.keyUp(event.key());
switch (KeyConverter.physicalToActual(event.key(), event.scancode())) {
case GLFW.GLFW_KEY_T -> terminateTimer = -1;
@@ -147,18 +133,10 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (!inTermRegion(event.x(), event.y())) return false;
if (!hasMouseSupport() || event.button() < 0 || event.button() > 2) return false;
var charX = (int) ((event.x() - innerX) / FONT_WIDTH);
var charY = (int) ((event.y() - innerY) / FONT_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
computer.mouseClick(event.button() + 1, charX + 1, charY + 1);
lastMouseButton = event.button();
lastMouseX = charX;
lastMouseY = charY;
computerInput.mouseClick(event.button() + 1, charX + 1, charY + 1);
return true;
}
@@ -166,20 +144,10 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (!inTermRegion(event.x(), event.y())) return false;
if (!hasMouseSupport() || event.button() < 0 || event.button() > 2) return false;
var charX = (int) ((event.x() - innerX) / FONT_WIDTH);
var charY = (int) ((event.y() - innerY) / FONT_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
if (lastMouseButton == event.button()) {
computer.mouseUp(lastMouseButton + 1, charX + 1, charY + 1);
lastMouseButton = -1;
}
lastMouseX = charX;
lastMouseY = charY;
computerInput.mouseUp(event.button() + 1, charX + 1, charY + 1);
return true;
}
@@ -187,36 +155,21 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean mouseDragged(MouseButtonEvent event, double v2, double v3) {
if (!inTermRegion(event.x(), event.y())) return false;
if (!hasMouseSupport() || event.button() < 0 || event.button() > 2) return false;
var charX = (int) ((event.x() - innerX) / FONT_WIDTH);
var charY = (int) ((event.y() - innerY) / FONT_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
if (event.button() == lastMouseButton && (charX != lastMouseX || charY != lastMouseY)) {
computer.mouseDrag(event.button() + 1, charX + 1, charY + 1);
lastMouseX = charX;
lastMouseY = charY;
}
computerInput.mouseDrag(event.button() + 1, charX + 1, charY + 1);
return true;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double deltaX, double deltaY) {
if (!inTermRegion(mouseX, mouseY)) return false;
if (!hasMouseSupport() || deltaY == 0) return false;
if (deltaY == 0) return false;
var charX = (int) ((mouseX - innerX) / FONT_WIDTH);
var charY = (int) ((mouseY - innerY) / FONT_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
computer.mouseScroll(deltaY < 0 ? 1 : -1, charX + 1, charY + 1);
lastMouseX = charX;
lastMouseY = charY;
computerInput.mouseScroll(deltaY < 0 ? 1 : -1, charX + 1, charY + 1);
return true;
}
@@ -225,21 +178,17 @@ public class TerminalWidget extends AbstractWidget {
return active && visible && mouseX >= innerX && mouseY >= innerY && mouseX < innerX + innerWidth && mouseY < innerY + innerHeight;
}
private boolean hasMouseSupport() {
return terminal.isColour();
}
public void update() {
if (terminateTimer >= 0 && terminateTimer < TERMINATE_TIME && (terminateTimer += 0.05f) > TERMINATE_TIME) {
computer.terminate();
computerActions.terminate();
}
if (shutdownTimer >= 0 && shutdownTimer < TERMINATE_TIME && (shutdownTimer += 0.05f) > TERMINATE_TIME) {
computer.shutdown();
computerActions.shutdown();
}
if (rebootTimer >= 0 && rebootTimer < TERMINATE_TIME && (rebootTimer += 0.05f) > TERMINATE_TIME) {
computer.reboot();
computerActions.reboot();
}
}
@@ -248,18 +197,7 @@ public class TerminalWidget extends AbstractWidget {
super.setFocused(focused);
if (!focused) {
// When blurring, we should make all keys go up
for (var key = 0; key < keysDown.size(); key++) {
if (keysDown.get(key)) computer.keyUp(key);
}
keysDown.clear();
// When blurring, we should make the last mouse button go up
if (lastMouseButton >= 0) {
computer.mouseUp(lastMouseButton + 1, lastMouseX + 1, lastMouseY + 1);
lastMouseButton = -1;
}
computerInput.releaseInputs();
shutdownTimer = terminateTimer = rebootTimer = -1;
}
}
@@ -33,7 +33,9 @@ import net.minecraft.util.ARGB;
import net.minecraft.util.Unit;
import net.minecraft.world.item.component.DyedItemColor;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector2f;
import org.jspecify.annotations.Nullable;
import static dan200.computercraft.client.render.ComputerBorderRenderer.MARGIN;
@@ -65,6 +67,19 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
return new State();
}
/**
* Update our {@link PoseStack} for rendering the lectern's contents.
*
* @param poseStack The pose stack to update.
* @param state The lectern block state.
*/
public static void applyLecternTransform(PoseStack poseStack, BlockState state) {
poseStack.translate(0.5f, 1.0625f, 0.5f);
poseStack.mulPose(Axis.YP.rotationDegrees(-state.getValue(LecternBlock.FACING).getClockWise().toYRot()));
poseStack.mulPose(Axis.ZP.rotationDegrees(67.5f));
poseStack.translate(0, -0.125f, 0);
}
@Override
public void extractRenderState(CustomLecternBlockEntity lectern, State state, float f, Vec3 camera, ModelFeatureRenderer.@Nullable CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(lectern, state, f, camera, overlay);
@@ -89,10 +104,7 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
@Override
public void submit(State state, PoseStack poseStack, SubmitNodeCollector collector, CameraRenderState cameraRenderState) {
poseStack.pushPose();
poseStack.translate(0.5f, 1.0625f, 0.5f);
poseStack.mulPose(Axis.YP.rotationDegrees(-state.blockState.getValue(LecternBlock.FACING).getClockWise().toYRot()));
poseStack.mulPose(Axis.ZP.rotationDegrees(67.5f));
poseStack.translate(0, -0.125f, 0);
applyLecternTransform(poseStack, state.blockState);
if (state.type == Type.PRINTOUT) {
if (state.isBook) {
@@ -111,10 +123,7 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
} else if (state.type == Type.POCKET_COMPUTER) {
pocketModel.submit(poseStack, collector, materials, state.lightCoords, state.pocketFamily, state.pocketColour, state.pocketLight);
// 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));
applyPocketComputerTerminalTransform(poseStack);
// Either render the terminal or a black screen.
if (state.pocketTerminal != null) {
@@ -127,7 +136,27 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
poseStack.popPose();
}
private static void renderPocketTerminal(PoseStack poseStack, SubmitNodeCollector collector, Terminal terminal) {
/**
* Update our {@link PoseStack} for rendering the pocket computer terminal. This jiggles the terminal about a bit,
* so {@code (0, 0)} is in the top left of the model's terminal hole.
*
* @param poseStack The pose stack to update.
*/
public static void applyPocketComputerTerminalTransform(PoseStack poseStack) {
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));
}
/**
* Update our {@link PoseStack} for rendering the pocket computer terminal itself. This scales us to work in
* terminal space, and translates so that {@code (0, 0)} is the top left of the pocket terminal.
*
* @param poseStack The pose stack to update.
* @param terminal The current terminal.
* @return The margins of the terminal.
*/
public static Vector2f applyScaledPocketComputerTerminalTransform(PoseStack poseStack, Terminal terminal) {
var width = terminal.getWidth() * FONT_WIDTH;
var height = terminal.getHeight() * FONT_HEIGHT;
@@ -140,9 +169,15 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
// 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;
poseStack.translate(marginX, marginY, 0);
return new Vector2f(marginX, marginY);
}
private static void renderPocketTerminal(PoseStack poseStack, SubmitNodeCollector collector, Terminal terminal) {
var margin = applyScaledPocketComputerTerminalTransform(poseStack, terminal);
collector.submitCustomGeometry(poseStack, FixedWidthFontRenderer.TERMINAL_TEXT, (pose, buffer) ->
FixedWidthFontRenderer.drawTerminal(pose.pose(), buffer, marginX, marginY, terminal, marginY, marginY, marginX, marginX));
FixedWidthFontRenderer.drawTerminal(pose.pose(), buffer, 0, 0, terminal, margin.y(), margin.y(), margin.x(), margin.x()));
}
private enum Type {
@@ -46,6 +46,7 @@ import dan200.computercraft.shared.details.ItemDetails;
import dan200.computercraft.shared.integration.PermissionRegistry;
import dan200.computercraft.shared.lectern.CustomLecternBlock;
import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.lectern.PocketComputerLecternMenu;
import dan200.computercraft.shared.media.MountMedia;
import dan200.computercraft.shared.media.PrintoutMenu;
import dan200.computercraft.shared.media.items.*;
@@ -500,6 +501,9 @@ public final class ModRegistry {
public static final RegistryEntry<MenuType<ComputerMenuWithoutInventory>> POCKET_COMPUTER_NO_TERM = REGISTRY.register("pocket_computer_no_term",
() -> ContainerData.toType(ComputerContainerData.STREAM_CODEC, (id, inv, data) -> new ComputerMenuWithoutInventory(Menus.POCKET_COMPUTER_NO_TERM.get(), id, inv, data)));
public static final RegistryEntry<MenuType<PocketComputerLecternMenu>> POCKET_COMPUTER_LECTERN = REGISTRY.register("pocket_computer_lectern",
() -> ContainerData.toType(PocketComputerLecternMenu.Data.STREAM_CODEC, PocketComputerLecternMenu::new));
public static final RegistryEntry<MenuType<TurtleMenu>> TURTLE = REGISTRY.register("turtle",
() -> ContainerData.toType(ComputerContainerData.STREAM_CODEC, TurtleMenu::ofMenuData));
@@ -255,9 +255,8 @@ public class CommandAPI implements ILuaAPI {
/**
* Get some basic information about a block.
* <p>
* The returned table contains the current name, metadata and block state (as
* with [`turtle.inspect`]). If there is a block entity for that block, its NBT
* will also be returned.
* The returned table contains the the same information as listed in [`block_details`]. If there is a block entity
* for that block, its NBT will also be returned.
*
* @param x The x position of the block to query.
* @param y The y position of the block to query.
@@ -4,6 +4,7 @@
package dan200.computercraft.shared.computer.blocks;
import dan200.computercraft.annotations.ForgeOverride;
import dan200.computercraft.shared.common.IBundledRedstoneBlock;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.platform.PlatformHelper;
@@ -128,6 +129,12 @@ public abstract class AbstractComputerBlock<T extends AbstractComputerBlockEntit
return super.useWithoutItem(state, level, pos, player, hit);
}
@ForgeOverride
public final void onNeighborChange(BlockState state, LevelReader world, BlockPos pos, BlockPos neighbour) {
var be = world.getBlockEntity(pos);
if (be instanceof AbstractComputerBlockEntity computer) computer.neighborBlockEntityChanged(neighbour);
}
@Override
protected void neighborChanged(BlockState blockState, Level level, BlockPos blockPos, Block block, @Nullable Orientation orientation, boolean isMoving) {
if (level.getBlockEntity(blockPos) instanceof AbstractComputerBlockEntity computer) computer.neighborChanged();
@@ -300,6 +300,27 @@ public abstract class AbstractComputerBlockEntity extends BlockEntity implements
invalidSides = DirectionUtil.ALL_SIDES; // Mark all peripherals as dirty.
}
/**
* Called when a neighbour block entity changes.
* <p>
* This is only required for MoreRed, which does not fire block updates when bundled redstone changes, see
* <a href="https://github.com/cc-tweaked/CC-Tweaked/issues/2316">#2316</a>
*
* @param neighbour The position of the neighbour block.
*/
public void neighborBlockEntityChanged(BlockPos neighbour) {
var computer = getServerComputer();
if (computer == null) return;
for (var dir : DirectionUtil.FACINGS) {
var offset = getBlockPos().relative(dir);
if (offset.equals(neighbour)) {
updateRedstoneInput(computer, dir, offset);
return;
}
}
}
/**
* Called when a neighbour block's shape changes.
* <p>
@@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.computer.core;
import dan200.computercraft.shared.computer.menu.ServerInputHandler;
import java.nio.ByteBuffer;
/**
* 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 keyDown(int key, boolean repeat);
void keyUp(int key);
void charTyped(byte chr);
void paste(ByteBuffer contents);
void mouseClick(int button, int x, int y);
void mouseUp(int button, int x, int y);
void mouseDrag(int button, int x, int y);
void mouseScroll(int direction, int x, int y);
void terminate();
void shutdown();
void turnOn();
void reboot();
}
@@ -13,8 +13,9 @@ 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.input.EventComputerInput;
import dan200.computercraft.core.input.UserComputerInput;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.impl.ApiFactories;
import dan200.computercraft.shared.computer.menu.ComputerMenu;
@@ -38,7 +39,7 @@ import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
public class ServerComputer implements ComputerEnvironment, ComputerEvents.Receiver {
public class ServerComputer implements ComputerEnvironment {
public static final ComputerComponent<MetricsObserver> METRICS = ComputerComponent.create("computercraft", "metrics");
private final UUID instanceUUID = UUID.randomUUID();
@@ -212,7 +213,6 @@ public class ServerComputer implements ComputerEnvironment, ComputerEvents.Recei
computer.reboot();
}
@Override
public final void queueEvent(String event, @Nullable Object @Nullable [] arguments) {
computer.queueEvent(event, arguments);
}
@@ -221,6 +221,10 @@ public class ServerComputer implements ComputerEnvironment, ComputerEvents.Recei
queueEvent(event, null);
}
public final UserComputerInput createComputerInput() {
return new UserComputerInput(new EventComputerInput(computer), terminal);
}
public final int getRedstoneOutput(ComputerSide side) {
return computer.isOn() ? computer.getRedstone().getExternalOutput(side) : 0;
}
@@ -34,7 +34,7 @@ public abstract class AbstractComputerMenu extends AbstractContainerMenu impleme
private final ContainerData data;
private final @Nullable ServerComputer computer;
private final @Nullable ServerInputState<AbstractComputerMenu> input;
private final @Nullable ServerInputState input;
private final @Nullable NetworkedTerminal terminal;
@@ -51,7 +51,7 @@ public abstract class AbstractComputerMenu extends AbstractContainerMenu impleme
addDataSlots(data);
this.computer = computer;
input = computer == null ? null : new ServerInputState<>(this);
input = computer == null ? null : new ServerInputState(this, computer);
terminal = containerData == null ? null : containerData.terminal().create();
displayStack = containerData == null ? ItemStack.EMPTY : containerData.displayStack();
uploadMaxSize = containerData == null ? Config.uploadMaxSize : containerData.uploadMaxSize();
@@ -4,7 +4,7 @@
package dan200.computercraft.shared.computer.menu;
import dan200.computercraft.shared.computer.core.InputHandler;
import dan200.computercraft.core.input.ComputerInput;
import dan200.computercraft.shared.computer.upload.FileSlice;
import dan200.computercraft.shared.computer.upload.FileUpload;
import dan200.computercraft.shared.network.server.ComputerServerMessage;
@@ -14,13 +14,20 @@ import java.util.List;
import java.util.UUID;
/**
* An {@link InputHandler} which operates on the server, receiving data from the client over the network.
* An {@link ComputerInput} which operates on the server, receiving data from the client over the network.
*
* @see ServerInputState The default implementation of this interface.
* @see ComputerServerMessage Packets which consume this interface.
* @see ComputerMenu
*/
public interface ServerInputHandler extends InputHandler {
public interface ServerInputHandler {
/**
* Get a {@link ComputerInput} that handles events for this computer.
*
* @return The computer input.
*/
ComputerInput getComputerInput();
/**
* Start a file upload into this container.
*
@@ -7,15 +7,14 @@ 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.core.input.ComputerInput;
import dan200.computercraft.core.input.UserComputerInput;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.upload.FileSlice;
import dan200.computercraft.shared.computer.upload.FileUpload;
import dan200.computercraft.shared.computer.upload.UploadResult;
import dan200.computercraft.shared.network.client.UploadResultMessage;
import dan200.computercraft.shared.network.server.ServerNetworking;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.AbstractContainerMenu;
@@ -23,7 +22,6 @@ import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.UUID;
@@ -31,113 +29,31 @@ import java.util.UUID;
* The default concrete implementation of {@link ServerInputHandler}.
* <p>
* This keeps track of the current key and mouse state, and releases them when the container is closed.
*
* @param <T> The type of container this server input belongs to.
*/
public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> implements ServerInputHandler {
public class ServerInputState implements ServerInputHandler {
private static final Logger LOG = LoggerFactory.getLogger(ServerInputState.class);
private final T owner;
private final IntSet keysDown = new IntOpenHashSet(4);
private int lastMouseX;
private int lastMouseY;
private int lastMouseDown = -1;
private final AbstractContainerMenu owner;
private final ServerComputer computer;
private final UserComputerInput input;
private @Nullable UUID toUploadId;
private @Nullable List<FileUpload> toUpload;
public ServerInputState(T owner) {
public ServerInputState(AbstractContainerMenu owner, ServerComputer computer) {
this.owner = owner;
this.computer = computer;
this.input = computer.createComputerInput();
}
@Override
public void keyDown(int key, boolean repeat) {
keysDown.add(key);
ComputerEvents.keyDown(owner.getComputer(), key, repeat);
public ComputerInput getComputerInput() {
return input;
}
@Override
public void keyUp(int key) {
keysDown.remove(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.position(), max = buffer.limit(); i < max; i++) {
if (!StringUtil.isTypableChar(buffer.get(i))) return false;
}
return true;
}
@Override
public void mouseClick(int button, int x, int y) {
lastMouseX = x;
lastMouseY = y;
lastMouseDown = button;
ComputerEvents.mouseClick(owner.getComputer(), button, x, y);
}
@Override
public void mouseUp(int button, int x, int y) {
lastMouseX = x;
lastMouseY = y;
lastMouseDown = -1;
ComputerEvents.mouseUp(owner.getComputer(), button, x, y);
}
@Override
public void mouseDrag(int button, int x, int y) {
lastMouseX = x;
lastMouseY = y;
lastMouseDown = button;
ComputerEvents.mouseDrag(owner.getComputer(), button, x, y);
}
@Override
public void mouseScroll(int direction, int x, int y) {
lastMouseX = x;
lastMouseY = y;
ComputerEvents.mouseScroll(owner.getComputer(), direction, x, y);
}
@Override
public void terminate() {
owner.getComputer().queueEvent("terminate");
}
@Override
public void shutdown() {
owner.getComputer().shutdown();
}
@Override
public void turnOn() {
owner.getComputer().turnOn();
}
@Override
public void reboot() {
owner.getComputer().reboot();
}
@Override
public void startUpload(UUID uuid, List<FileUpload> files) {
toUploadId = uuid;
public void startUpload(UUID uploadId, List<FileUpload> files) {
toUploadId = uploadId;
toUpload = files;
}
@@ -162,7 +78,6 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
}
private UploadResultMessage finishUpload(ServerPlayer player) {
var computer = owner.getComputer();
if (toUpload == null) {
return UploadResultMessage.error(owner, UploadResult.COMPUTER_OFF_MSG);
}
@@ -187,13 +102,6 @@ public class ServerInputState<T extends AbstractContainerMenu & ComputerMenu> im
}
public void close() {
var computer = owner.getComputer();
var keys = keysDown.iterator();
while (keys.hasNext()) ComputerEvents.keyUp(computer, keys.nextInt());
if (lastMouseDown != -1) ComputerEvents.mouseUp(computer, lastMouseDown, lastMouseX, lastMouseY);
keysDown.clear();
lastMouseDown = -1;
input.releaseInputs();
}
}
@@ -27,6 +27,7 @@ public class BlockDetails {
public static void fill(Map<? super String, Object> data, BlockReference block) {
data.put("tags", DetailHelpers.getTags(block.state().getTags()));
DetailHelpers.fillMapColour(data, block.level(), block.pos(), block.state());
}
@SuppressWarnings({ "unchecked", "rawtypes" })
@@ -5,9 +5,14 @@
package dan200.computercraft.shared.details;
import dan200.computercraft.shared.util.RegistryHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.tags.TagKey;
import net.minecraft.util.ARGB;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.MapColor;
import java.util.Map;
import java.util.stream.Collectors;
@@ -28,4 +33,13 @@ public final class DetailHelpers {
public static <T> String getId(Registry<T> registry, T entry) {
return RegistryHelper.getKeyOrThrow(registry, entry).toString();
}
public static void fillMapColour(Map<? super String, Object> data, BlockGetter level, BlockPos pos, BlockState state) {
var mapColour = state.getMapColor(level, pos);
if (mapColour == MapColor.NONE) return;
var colour = ARGB.transparent(mapColour.col);
data.put("mapColor", colour);
data.put("mapColour", colour);
}
}
@@ -5,6 +5,7 @@
package dan200.computercraft.shared.details;
import dan200.computercraft.shared.util.NBTUtil;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.component.DataComponentPatch;
import net.minecraft.core.component.DataComponents;
@@ -12,18 +13,19 @@ import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.NbtOps;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.RegistryOps;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.CreativeModeTabs;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.*;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.ItemEnchantments;
import net.minecraft.world.level.EmptyBlockGetter;
import net.minecraft.world.level.block.Block;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.StreamSupport;
/**
* Data providers for items.
@@ -69,8 +71,13 @@ public class ItemDetails {
var enchants = getAllEnchants(stack);
if (!enchants.isEmpty()) data.put("enchantments", enchants);
var effects = getAllEffects(stack);
if (!effects.isEmpty()) data.put("potionEffects", effects);
var unbreakable = stack.get(DataComponents.UNBREAKABLE);
if (unbreakable != null) data.put("unbreakable", true);
DetailHelpers.fillMapColour(data, EmptyBlockGetter.INSTANCE, BlockPos.ZERO, Block.byItem(stack.getItem()).defaultBlockState());
}
/**
@@ -130,4 +137,51 @@ public class ItemDetails {
enchants.add(enchant);
}
}
/**
* Retrieve all potions from given stack.
*
* @param stack Stack to analyse.
* @return A filled list that contain all visible potions.
*/
private static List<Map<String, Object>> getAllEffects(ItemStack stack) {
var effects = stack.get(DataComponents.POTION_CONTENTS);
if (effects == null) return List.of();
return StreamSupport.stream(effects.getAllEffects().spliterator(), false).map(p -> {
Map<String, Object> potion = new HashMap<>(4);
potion.put("name", p.getEffect().getRegisteredName());
var displayName = Component.translatable(p.getDescriptionId());
if (p.getAmplifier() > 0) {
displayName = Component.translatable(
"potion.withAmplifier", displayName, Component.translatable("potion.potency." + p.getAmplifier())
);
}
potion.put("displayName", displayName.getString());
// Expose the roman numerals (e.g. Instant Health II), rather than the raw amplifier value.
if (p.getAmplifier() > 0) potion.put("potency", p.getAmplifier() + 1);
if (p.isInfiniteDuration()) {
potion.put("duration", Double.POSITIVE_INFINITY);
} else if (p.getDuration() > 1) {
potion.put("duration", p.getDuration() / 20.0 * getPotionDurationMultiplier(stack));
}
return potion;
}).toList();
}
/**
* Get the potion duration multiplier for an item, to handle items which have a shorter duration than a normal
* potion.
*
* @param stack The current stack.
* @return The duration multiplier.
*/
private static double getPotionDurationMultiplier(ItemStack stack) {
if (stack.getItem() instanceof LingeringPotionItem) return 0.25;
if (stack.getItem() instanceof TippedArrowItem) return 0.125;
return 1.0;
}
}
@@ -4,8 +4,10 @@
package dan200.computercraft.shared.lectern;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.shared.ModRegistry;
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;
@@ -21,12 +23,14 @@ import net.minecraft.world.item.Items;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
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.level.redstone.Orientation;
import net.minecraft.world.phys.BlockHitResult;
import org.jspecify.annotations.Nullable;
@@ -102,6 +106,14 @@ public class CustomLecternBlock extends LecternBlock {
return new ItemStack(Items.LECTERN);
}
@Override
protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighborBlock, @Nullable Orientation orientation, boolean movedByPiston) {
if (!level.isClientSide() && level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern &&
lectern.getItem().getItem() instanceof PocketComputerItem) {
lectern.markRefreshPeripheral();
}
}
@Override
public void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// If we've no lectern, remove it.
@@ -122,6 +134,12 @@ public class CustomLecternBlock extends LecternBlock {
var entity = new ItemEntity(level, pos.getX() + 0.5 + dx, pos.getY() + 1, pos.getZ() + 0.5 + dz, stack);
entity.setDefaultPickUpDelay();
level.addFreshEntity(entity);
// If we're a pocket computer, update the holder and clear the peripheral.
if (stack.getItem() instanceof PocketComputerItem pocket) {
var brain = pocket.getBrain(new PocketHolder.ItemEntityHolder(entity), stack);
if (brain != null) brain.computer().setPeripheral(ComputerSide.BOTTOM, null);
}
}
@Override
@@ -130,6 +148,7 @@ public class CustomLecternBlock extends LecternBlock {
}
@Override
@Deprecated
protected int getAnalogOutputSignal(BlockState blockState, Level level, BlockPos pos, Direction direction) {
return level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern ? lectern.getRedstoneSignal() : 0;
}
@@ -4,16 +4,23 @@
package dan200.computercraft.shared.lectern;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.container.BasicContainer;
import dan200.computercraft.shared.container.SingleContainerData;
import dan200.computercraft.shared.media.PrintoutMenu;
import dan200.computercraft.shared.media.items.PrintoutData;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.platform.ComponentAccess;
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.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
@@ -33,6 +40,7 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -55,10 +63,21 @@ public final class CustomLecternBlockEntity extends BlockEntity {
private ItemStack item = ItemStack.EMPTY;
private int page, pageCount;
private final PocketHolder.LecternHolder pocketHolder = new PocketHolder.LecternHolder(this);
private @Nullable PocketBrain activePocketBrain;
private final ComponentAccess<IPeripheral> peripherals = PlatformHelper.get().createPeripheralAccess(this, d -> markRefreshPeripheral());
private boolean refreshPeripheral;
public CustomLecternBlockEntity(BlockPos pos, BlockState blockState) {
super(ModRegistry.BlockEntities.LECTERN.get(), pos, blockState);
}
@Override
public void clearRemoved() {
refreshPeripheral = true;
super.clearRemoved();
}
public ItemStack getItem() {
return item;
}
@@ -88,11 +107,27 @@ public final class CustomLecternBlockEntity extends BlockEntity {
} else {
pageCount = page = 0;
}
activePocketBrain = null;
}
void markRefreshPeripheral() {
refreshPeripheral = true;
}
void tick() {
if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.tick(item, new PocketHolder.LecternHolder(this), false);
// Get our pocket computer, and tick it.
var brain = pocket.getOrCreateBrain(pocketHolder, item);
brain.computer().keepAlive();
pocket.tick(item, pocketHolder, brain);
// Update the peripheral if the peripheral or brain has changed.
if (refreshPeripheral || brain != activePocketBrain) {
refreshPeripheral = false;
activePocketBrain = brain;
brain.computer().setPeripheral(ComputerSide.BOTTOM, peripherals.get(Direction.DOWN));
}
}
}
@@ -154,7 +189,13 @@ public final class CustomLecternBlockEntity extends BlockEntity {
new PrintoutContainerData()
), getItem().getDisplayName()));
} else if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.open(player, item, new PocketHolder.LecternHolder(this), true);
var computer = pocket.getOrCreateBrain(pocketHolder, item).computer();
computer.turnOn();
PlatformHelper.get().openMenu(
player, item.getHoverName(),
(id, inv, entity) -> new PocketComputerLecternMenu(id, inv, pocketHolder, computer),
new PocketComputerLecternMenu.Data(new ComputerContainerData(computer, item), getBlockPos())
);
}
}
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.lectern;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.inventory.ComputerMenuWithoutInventory;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import dan200.computercraft.shared.network.container.ContainerData;
import dan200.computercraft.shared.pocket.core.PocketHolder;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.entity.player.Inventory;
/**
* The menu for opening a {@linkplain PocketComputerItem pocket computer} on a
* {@linkplain CustomLecternBlockEntity lectern}.
* <p>
* This contains the lectern's position, so that the client is able to map the look/hit vector back to a position on the
* computer's terminal.
*/
public final class PocketComputerLecternMenu extends ComputerMenuWithoutInventory {
private final BlockPos lectern;
public PocketComputerLecternMenu(int id, Inventory player, PocketHolder.LecternHolder holder, ServerComputer computer) {
super(ModRegistry.Menus.POCKET_COMPUTER_LECTERN.get(), id, player, p -> holder.isValid(computer), computer);
this.lectern = holder.blockPos();
}
public PocketComputerLecternMenu(int id, Inventory player, Data menuData) {
super(ModRegistry.Menus.POCKET_COMPUTER_LECTERN.get(), id, player, menuData.computer());
this.lectern = menuData.lectern();
}
public BlockPos lectern() {
return lectern;
}
public record Data(ComputerContainerData computer, BlockPos lectern) implements ContainerData {
public static final StreamCodec<RegistryFriendlyByteBuf, Data> STREAM_CODEC = StreamCodec.composite(
ComputerContainerData.STREAM_CODEC, Data::computer,
BlockPos.STREAM_CODEC, Data::lectern,
Data::new
);
@Override
public void toBytes(RegistryFriendlyByteBuf buf) {
STREAM_CODEC.encode(buf, this);
}
}
}
@@ -37,10 +37,10 @@ public final 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();
case TERMINATE -> container.getComputer().queueEvent("terminate");
case TURN_ON -> container.getComputer().turnOn();
case REBOOT -> container.getComputer().reboot();
case SHUTDOWN -> container.getComputer().shutdown();
}
}
@@ -39,7 +39,7 @@ public final class KeyEventServerMessage extends ComputerServerMessage {
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
var input = container.getInput();
var input = container.getInput().getComputerInput();
switch (action) {
case UP -> input.keyUp(key);
case DOWN -> input.keyDown(key, false);
@@ -45,7 +45,7 @@ public final class MouseEventServerMessage extends ComputerServerMessage {
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
var input = container.getInput();
var input = container.getInput().getComputerInput();
switch (action) {
case CLICK -> input.mouseClick(arg, x, y);
case DRAG -> input.mouseDrag(arg, x, y);
@@ -4,10 +4,10 @@
package dan200.computercraft.shared.network.server;
import dan200.computercraft.core.input.ComputerInput;
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.NetworkMessages;
import dan200.computercraft.shared.network.codec.MoreStreamCodecs;
import net.minecraft.network.RegistryFriendlyByteBuf;
@@ -21,7 +21,7 @@ import java.nio.ByteBuffer;
/**
* Paste a string on a {@link ServerComputer}.
*
* @see ServerInputHandler#paste(ByteBuffer)
* @see ComputerInput#paste(ByteBuffer)
*/
public class PasteEventComputerMessage extends ComputerServerMessage {
public static final StreamCodec<RegistryFriendlyByteBuf, PasteEventComputerMessage> STREAM_CODEC = StreamCodec.composite(
@@ -43,7 +43,7 @@ public class PasteEventComputerMessage extends ComputerServerMessage {
@Override
protected void handle(ServerNetworkContext context, ComputerMenu container) {
container.getInput().paste(text);
container.getInput().getComputerInput().paste(text);
}
@Override
@@ -5,7 +5,6 @@
package dan200.computercraft.shared.peripheral.generic.methods;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.peripheral.GenericPeripheral;
@@ -46,9 +45,8 @@ public abstract class AbstractInventoryMethods<T> implements GenericPeripheral {
/**
* List all items in this inventory. This returns a table, with an entry for each slot.
* <p>
* Each item in the inventory is represented by a table containing some basic information, much like
* {@link dan200.computercraft.shared.turtle.apis.TurtleAPI#getItemDetail(ILuaContext, Optional, Optional)}
* includes. More information can be fetched with {@link #getItemDetail}. The table contains the item `name`, the
* Each item in the inventory is represented by a table containing [some basic information][`item_details`]. More
* information can be fetched with {@link #getItemDetail}. The table contains the item `name`, the
* `count` and an a (potentially nil) hash of the item's `nbt.` This NBT data doesn't contain anything useful, but
* allows you to distinguish identical items.
* <p>
@@ -66,23 +64,13 @@ public abstract class AbstractInventoryMethods<T> implements GenericPeripheral {
* print(("%d x %s in slot %d"):format(item.count, item.name, slot))
* end
* }</pre>
* @cc.see item_details
*/
@LuaFunction(mainThread = true)
public abstract Map<Integer, Map<String, ?>> list(T inventory);
/**
* Get detailed information about an item.
* <p>
* The returned information contains the same information as each item in
* {@link #list}, as well as additional details like the display name
* (`displayName`), item groups (`itemGroups`), which are the creative tabs
* an item will appear under, and item and item durability (`damage`,
* `maxDamage`, `durability`).
* <p>
* Some items include more information (such as enchantments) - it is
* recommended to print it out using [`textutils.serialize`] or in the Lua
* REPL, to explore what is available.
* <p>
* Get [detailed information][`item_details`] about an item.
*
* @param inventory The current inventory.
* @param slot The slot to get information about.
@@ -102,6 +90,7 @@ public abstract class AbstractInventoryMethods<T> implements GenericPeripheral {
* print(("Damage: %d/%d"):format(item.damage, item.maxDamage))
* end
* }</pre>
* @cc.see item_details
*/
@Nullable
@LuaFunction(mainThread = true)
@@ -113,7 +113,7 @@ public class CableBlockEntity extends BlockEntity {
void queueRefreshPeripheral() {
refreshPeripheral = true;
TickScheduler.schedule(tickToken);
getLevel().scheduleTick(getBlockPos(), getBlockState().getBlock(), 0);
}
InteractionResult use(Player player) {
@@ -181,7 +181,7 @@ public class CableBlockEntity extends BlockEntity {
void scheduleConnectionsChanged() {
refreshConnections = true;
TickScheduler.schedule(tickToken);
getLevel().scheduleTick(getBlockPos(), getBlockState().getBlock(), 0);
}
void connectionsChanged() {
@@ -115,7 +115,7 @@ public class WiredModemFullBlockEntity extends BlockEntity {
void queueRefreshPeripheral(Direction facing) {
invalidSides |= 1 << facing.ordinal();
TickScheduler.schedule(tickToken);
getLevel().scheduleTick(getBlockPos(), getBlockState().getBlock(), 0);
}
public InteractionResult use(Player player) {
@@ -190,7 +190,7 @@ public class WiredModemFullBlockEntity extends BlockEntity {
private void scheduleConnectionsChanged() {
refreshConnections = true;
TickScheduler.schedule(tickToken);
getLevel().scheduleTick(getBlockPos(), getBlockState().getBlock(), 0);
}
private void connectionsChanged() {
@@ -4,6 +4,7 @@
package dan200.computercraft.shared.peripheral.redstone;
import com.mojang.serialization.MapCodec;
import dan200.computercraft.annotations.ForgeOverride;
import dan200.computercraft.shared.common.IBundledRedstoneBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
@@ -12,6 +13,7 @@ import net.minecraft.util.RandomSource;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
@@ -80,6 +82,13 @@ public final class RedstoneRelayBlock extends HorizontalDirectionalBlock impleme
return level.getBlockEntity(pos) instanceof RedstoneRelayBlockEntity relay ? relay.getBundledRedstoneOutput(side) : 0;
}
@ForgeOverride
public void onNeighborChange(BlockState state, LevelReader world, BlockPos pos, BlockPos neighbour) {
// This is only required for MoreRed, which does not fire block updates when bundled redstone changes, see
// <a href="https://github.com/cc-tweaked/CC-Tweaked/issues/2316">#2316</a>.
if (world.getBlockEntity(pos) instanceof RedstoneRelayBlockEntity relay) relay.neighborChanged();
}
@Override
protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbourBlock, @Nullable Orientation orientation, boolean isMoving) {
if (level.getBlockEntity(pos) instanceof RedstoneRelayBlockEntity relay) relay.neighborChanged();
@@ -22,6 +22,7 @@ public final class RedstoneRelayBlockEntity extends BlockEntity {
private final RedstoneState redstoneState = new RedstoneState(() -> TickScheduler.schedule(tickToken));
private final RedstoneRelayPeripheral peripheral = new RedstoneRelayPeripheral(redstoneState);
private boolean updateAll = false;
public RedstoneRelayBlockEntity(BlockPos pos, BlockState blockState) {
super(ModRegistry.BlockEntities.REDSTONE_RELAY.get(), pos, blockState);
@@ -30,17 +31,18 @@ public final class RedstoneRelayBlockEntity extends BlockEntity {
@Override
public void clearRemoved() {
super.clearRemoved();
updateAll = true;
TickScheduler.schedule(tickToken);
}
void update() {
var changes = redstoneState.updateOutput();
if (changes != 0) {
for (var direction : DirectionUtil.FACINGS) {
if ((changes & (1 << mapSide(direction).ordinal())) != 0) updateRedstoneTo(direction);
}
for (var direction : DirectionUtil.FACINGS) {
if (updateAll || (changes & (1 << mapSide(direction).ordinal())) != 0) updateRedstoneTo(direction);
}
updateAll = false;
if (redstoneState.pollInputChanged()) peripheral.queueRedstoneEvent();
}
@@ -61,7 +63,7 @@ public final class RedstoneRelayBlockEntity extends BlockEntity {
// If the input has changed, and we're not currently in update(), then schedule a new tick so we can queue a
// redstone event.
if (changed && !ticking) TickScheduler.schedule(tickToken);
if (changed && !ticking) getLevel().scheduleTick(getBlockPos(), getBlockState().getBlock(), 0);
}
private ComputerSide mapSide(Direction globalSide) {
@@ -51,23 +51,14 @@ public class PocketComputerItem extends Item {
/**
* Tick a pocket computer.
*
* @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.
* @param stack The current pocket computer stack.
* @param holder The entity holding the pocket item.
* @param brain The pocket brain.
*/
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();
}
public void tick(ItemStack stack, PocketHolder holder, PocketBrain brain) {
brain.tick();
// Sync pocket state back to the item
if (updateItem(stack, brain)) holder.setChanged();
}
@@ -102,9 +93,17 @@ public class PocketComputerItem extends Item {
public void inventoryTick(ItemStack stack, ServerLevel level, Entity entity, @Nullable EquipmentSlot slot) {
if (entity instanceof ServerPlayer player) {
var invSlot = InventoryUtil.findItemInInventory(player.getInventory(), stack);
if (invSlot != -1) tick(stack, new PocketHolder.PlayerHolder(player, invSlot), false);
if (invSlot < 0) return;
// If we're in the inventory, create a computer and keep it alive.
var holder = new PocketHolder.PlayerHolder(player, invSlot);
var brain = getOrCreateBrain(holder, stack);
brain.computer().keepAlive();
tick(stack, holder, brain);
} else if (slot != null && entity instanceof LivingEntity living) {
tick(stack, new PocketHolder.LivingEntityHolder(living, slot), true);
var holder = new PocketHolder.LivingEntityHolder(living, slot);
var brain = getBrain(holder, stack);
if (brain != null) tick(stack, holder, brain);
}
}
@@ -115,7 +114,9 @@ public class PocketComputerItem extends Item {
// If we're an item entity, tick an already existing computer (as to update the position), but do not keep the
// computer alive.
tick(stack, new PocketHolder.ItemEntityHolder(entity), true);
var holder = new PocketHolder.ItemEntityHolder(entity);
var brain = getBrain(holder, stack);
if (brain != null) tick(stack, holder, brain);
return false;
}
@@ -125,32 +126,17 @@ public class PocketComputerItem extends Item {
var stack = player.getItemInHand(hand);
if (!world.isClientSide()) {
var holder = new PocketHolder.PlayerHolder((ServerPlayer) player, InventoryUtil.getHandSlot(player, hand));
var brain = getOrCreateBrain((ServerLevel) world, holder, stack);
var brain = getOrCreateBrain(holder, stack);
var computer = brain.computer();
computer.turnOn();
var stop = brain.onRightClick((ServerLevel) world);
if (!stop) openImpl(player, stack, holder, hand == InteractionHand.OFF_HAND, computer);
if (!stop) openMenu(player, stack, holder, hand == InteractionHand.OFF_HAND, computer);
}
return InteractionResult.SUCCESS;
}
/**
* 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) {
private static void openMenu(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),
@@ -169,8 +155,16 @@ public class PocketComputerItem extends Item {
return PocketUpgrades.instance().getOwner(getUpgradeWithData(stack, PocketSide.BACK), getUpgradeWithData(stack, PocketSide.BOTTOM));
}
private PocketBrain getOrCreateBrain(ServerLevel level, PocketHolder holder, ItemStack stack) {
var registry = ServerContext.get(level.getServer()).registry();
/**
* Get (or create) the pocket brain and turn it on, ready for the player to interact with.
*
* @param stack The pocket computer stack.
* @param holder The holder of the pocket computer.
* @return The pocket brain.
*/
public PocketBrain getOrCreateBrain(PocketHolder holder, ItemStack stack) {
var server = holder.level().getServer();
var registry = ServerContext.get(server).registry();
{
var computer = getServerComputer(registry, stack);
if (computer != null) {
@@ -180,14 +174,14 @@ public class PocketComputerItem extends Item {
}
}
var computerID = NonNegativeId.getOrCreate(level.getServer(), stack, ModRegistry.DataComponents.COMPUTER_ID.get(), NonNegativeId.Computer::new, IDAssigner.COMPUTER);
var computerID = NonNegativeId.getOrCreate(server, stack, ModRegistry.DataComponents.COMPUTER_ID.get(), NonNegativeId.Computer::new, IDAssigner.COMPUTER);
var brain = new PocketBrain(holder, ServerComputer.properties(computerID, getFamily())
.label(getLabel(stack))
.storageCapacity(StorageCapacity.getOrDefault(stack.get(ModRegistry.DataComponents.STORAGE_CAPACITY.get()), -1))
.terminalSize(stack.getOrDefault(
ModRegistry.DataComponents.TERMINAL_SIZE.get(),
new TerminalSize(ConfigSpec.pocketTermWidth.get(), ConfigSpec.pocketTermHeight.get())
))
ModRegistry.DataComponents.TERMINAL_SIZE.get(),
new TerminalSize(ConfigSpec.pocketTermWidth.get(), ConfigSpec.pocketTermHeight.get())
))
);
brain.setUpgrades(getUpgradeWithData(stack, PocketSide.BACK), getUpgradeWithData(stack, PocketSide.BOTTOM));
var computer = brain.computer();
@@ -203,18 +197,25 @@ public class PocketComputerItem extends Item {
return brain;
}
public @Nullable PocketBrain getBrain(PocketHolder holder, ItemStack stack) {
var computer = getServerComputer(holder.level().getServer(), stack);
if (computer == null) return null;
var brain = computer.getBrain();
brain.updateHolder(holder);
return brain;
}
public static boolean isServerComputer(ServerComputer computer, ItemStack stack) {
return stack.getItem() instanceof PocketComputerItem
&& getServerComputer(computer.getLevel().getServer(), stack) == computer;
}
@Nullable
public static PocketServerComputer getServerComputer(ServerComputerRegistry registry, ItemStack stack) {
private static @Nullable PocketServerComputer getServerComputer(ServerComputerRegistry registry, ItemStack stack) {
return (PocketServerComputer) ServerComputerReference.get(stack, registry);
}
@Nullable
public static PocketServerComputer getServerComputer(MinecraftServer server, ItemStack stack) {
private static @Nullable PocketServerComputer getServerComputer(MinecraftServer server, ItemStack stack) {
return getServerComputer(ServerContext.get(server).registry(), stack);
}
@@ -11,7 +11,6 @@ import dan200.computercraft.api.turtle.TurtleCommandResult;
import dan200.computercraft.api.turtle.TurtleSide;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.shared.peripheral.generic.methods.AbstractInventoryMethods;
import dan200.computercraft.shared.turtle.core.*;
import org.jspecify.annotations.Nullable;
@@ -691,11 +690,11 @@ public class TurtleAPI implements ILuaAPI {
/**
* Get the upgrade currently equipped on the left of the turtle.
* <p>
* This returns information about the currently equipped item, in the same form as
* {@link #getItemDetail(ILuaContext, Optional, Optional)}.
* This returns [information about the currently equipped item][`item_details`].
*
* @return Information about the currently equipped item, or {@code nil} if no upgrade is equipped.
* @cc.since 1.116.0
* @cc.see item_details
* @see #equipLeft()
*/
@LuaFunction(mainThread = true)
@@ -707,11 +706,11 @@ public class TurtleAPI implements ILuaAPI {
/**
* Get the upgrade currently equipped on the right of the turtle.
* <p>
* This returns information about the currently equipped item, in the same form as
* {@link #getItemDetail(ILuaContext, Optional, Optional)}.
* This returns [information about the currently equipped item][`item_details`].
*
* @return Information about the currently equipped item, or {@code nil} if no upgrade is equipped.
* @cc.since 1.116.0
* @cc.see item_details
* @see #equipRight()
*/
@LuaFunction(mainThread = true)
@@ -721,13 +720,14 @@ public class TurtleAPI implements ILuaAPI {
}
/**
* Get information about the block in front of the turtle.
* Get [information about the block][`block_details`] in front of the turtle.
*
* @return The turtle command result.
* @cc.treturn boolean Whether there is a block in front of the turtle.
* @cc.treturn table|string Information about the block in front, or a message explaining that there is no block.
* @cc.since 1.64
* @cc.changed 1.76 Added block state to return value.
* @cc.see block_details
* @cc.usage <pre>{@code
* local has_block, data = turtle.inspect()
* if has_block then
@@ -747,12 +747,13 @@ public class TurtleAPI implements ILuaAPI {
}
/**
* Get information about the block above the turtle.
* Get [information about the block][`block_details`] above the turtle.
*
* @return The turtle command result.
* @cc.treturn boolean Whether there is a block above the turtle.
* @cc.treturn table|string Information about the above below, or a message explaining that there is no block.
* @cc.treturn table|string Information about the block above, or a message explaining that there is no block.
* @cc.since 1.64
* @cc.see block_details
*/
@LuaFunction
public final MethodResult inspectUp() {
@@ -760,12 +761,13 @@ public class TurtleAPI implements ILuaAPI {
}
/**
* Get information about the block below the turtle.
* Get [information about the block][`block_details`] below the turtle.
*
* @return The turtle command result.
* @cc.treturn boolean Whether there is a block below the turtle.
* @cc.treturn table|string Information about the block below, or a message explaining that there is no block.
* @cc.since 1.64
* @cc.see block_details
*/
@LuaFunction
public final MethodResult inspectDown() {
@@ -773,7 +775,7 @@ public class TurtleAPI implements ILuaAPI {
}
/**
* Get detailed information about the items in the given slot.
* Get [information about the items][`item_details`] in the given slot.
*
* @param context The Lua context
* @param slot The slot to get information about. Defaults to the {@link #select selected slot}.
@@ -793,7 +795,7 @@ public class TurtleAPI implements ILuaAPI {
* -- count = 13,
* -- }
* }</pre>
* @see AbstractInventoryMethods#getItemDetail Describes the information returned by a detailed query.
* @cc.see item_details
*/
@LuaFunction
public final MethodResult getItemDetail(ILuaContext context, Optional<Integer> slot, Optional<Boolean> detailed) throws LuaException {
@@ -146,6 +146,7 @@ public final class TurtlePlayer {
// Load up the fake inventory
inventory.setSelectedSlot(0);
inventory.clearContent();
for (var i = 0; i < slots; i++) {
inventory.setItem(i, turtleInventory.getItem((currentSlot + i) % slots));
}
@@ -171,7 +172,8 @@ public final class TurtlePlayer {
TurtleUtil.storeItemOrDrop(turtle, inventory.getItem(i));
}
inventory.setChanged();
inventory.clearContent();
turtleInventory.setChanged();
}
public boolean isBlockProtected(ServerLevel level, BlockPos pos) {
@@ -26,6 +26,10 @@ import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
* We use this when modems and other peripherals change a block in a different thread.
*/
public final class TickScheduler {
// FIXME: We also use this to schedule ticks in {@link BlockEntity#clearRemoved()}, as the chunk is not fully
// loaded at this point ({@link LevelChunk#registerTickContainerInLevel(ServerLevel)} has not been called). This
// delays this a tick, which works in practice, but relies on us winning a race condition.
// It might be worth using Forge's BlockEntity.onLoad or having some custom hook based on chunk load.
private TickScheduler() {
}
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.details;
import dan200.computercraft.api.detail.VanillaDetailRegistries;
import dan200.computercraft.test.core.CustomMatchers;
import dan200.computercraft.test.shared.WithMinecraft;
import net.minecraft.data.registries.VanillaRegistries;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.alchemy.PotionContents;
import net.minecraft.world.item.alchemy.Potions;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.contains;
@WithMinecraft
class ItemDetailsTest {
@BeforeAll
public static void setup() {
VanillaDetailRegistries.ITEM_STACK.addProvider(ItemDetails::fill);
}
/**
* Test that all potion-imbued items (potions, throwables and arrows) have the correct duration.
*/
@Test
public void testPotionDurations() {
var registries = VanillaRegistries.createLookup();
assertThat(
VanillaDetailRegistries.ITEM_STACK.getDetails(registries, PotionContents.createItemStack(Items.POTION, Potions.LONG_NIGHT_VISION)),
containsEntryWith("potionEffects", contains(allOf(containsEntry("name", "minecraft:night_vision"), containsEntry("duration", 480.0))))
);
assertThat(
VanillaDetailRegistries.ITEM_STACK.getDetails(registries, PotionContents.createItemStack(Items.LINGERING_POTION, Potions.LONG_NIGHT_VISION)),
containsEntryWith("potionEffects", contains(allOf(containsEntry("name", "minecraft:night_vision"), containsEntry("duration", 120.0))))
);
assertThat(
VanillaDetailRegistries.ITEM_STACK.getDetails(registries, PotionContents.createItemStack(Items.SPLASH_POTION, Potions.LONG_NIGHT_VISION)),
containsEntryWith("potionEffects", contains(allOf(containsEntry("name", "minecraft:night_vision"), containsEntry("duration", 480.0))))
);
assertThat(
VanillaDetailRegistries.ITEM_STACK.getDetails(registries, PotionContents.createItemStack(Items.TIPPED_ARROW, Potions.LONG_NIGHT_VISION)),
containsEntryWith("potionEffects", contains(allOf(containsEntry("name", "minecraft:night_vision"), containsEntry("duration", 60.0))))
);
}
private static Matcher<Map<? extends String, ?>> containsEntry(String key, Object value) {
return CustomMatchers.containsEntry(key, value);
}
@SuppressWarnings("unchecked")
private static Matcher<Map<? extends String, ?>> containsEntryWith(String key, Matcher<?> value) {
return CustomMatchers.containsEntryWith(key, (Matcher<? super Object>) value);
}
}
@@ -9,6 +9,7 @@ import dan200.computercraft.gametest.api.GameTest
import dan200.computercraft.gametest.api.assertBlockHas
import dan200.computercraft.gametest.api.modifyBlock
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.peripheral.redstone.RedstoneRelayBlockEntity
import dan200.computercraft.shared.peripheral.redstone.RedstoneRelayPeripheral
import net.minecraft.core.BlockPos
@@ -17,6 +18,7 @@ import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.LeverBlock
import net.minecraft.world.level.block.RedstoneLampBlock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
class Relay_Test {
/**
@@ -95,4 +97,24 @@ class Relay_Test {
thenIdle(2)
thenExecute { assertEquals(false, relay().getInput(ComputerSide.BACK), "Input should be off") }
}
/**
* Check redstone input is detected when placed/loaded
*
* @see [#2175](https://github.com/cc-tweaked/CC-Tweaked/issues/2175)
*/
@GameTest(template = "default")
fun Initial_input(context: GameTestHelper) = context.sequence {
thenExecute {
context.setBlock(1, 1, 2, Blocks.REDSTONE_BLOCK)
context.setBlock(2, 1, 2, ModRegistry.Blocks.REDSTONE_RELAY.get())
}
thenIdle(1)
thenExecute {
val relay = context.getBlockEntity(BlockPos(2, 1, 2), RedstoneRelayBlockEntity::class.java)
.peripheral() as RedstoneRelayPeripheral
assertTrue(relay.getInput(ComputerSide.LEFT), "Input should be on")
}
}
}
@@ -451,7 +451,7 @@ class Turtle_Test {
}
/**
* Checks turtles can use IDetailProviders by getting details for a printed page.
* Checks turtles can use [VanillaDetailRegistries.ITEM_STACK] by getting details for a printed page.
*/
@GameTest
fun Item_detail_provider(helper: GameTestHelper) = helper.sequence {
@@ -326,15 +326,15 @@ public class OSAPI implements ILuaAPI {
* * If called with {@code local}, returns the number of days since 1
* January 1970 in the server's local timezone.
*
* @param args The locale to get the day for. Defaults to {@code ingame} if not set.
* @param locale The locale to get the day for. Defaults to {@code ingame} if not set.
* @return The day depending on the selected locale.
* @throws LuaException If an invalid locale is passed.
* @cc.since 1.48
* @cc.changed 1.82.0 Arguments are now case insensitive.
*/
@LuaFunction
public final int day(Optional<String> args) throws LuaException {
return switch (args.orElse("ingame").toLowerCase(Locale.ROOT)) {
public final int day(Optional<String> locale) throws LuaException {
return switch (locale.orElse("ingame").toLowerCase(Locale.ROOT)) {
case "utc" -> getDayForCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
case "local" -> getDayForCalendar(Calendar.getInstance());
case "ingame" -> day;
@@ -359,7 +359,7 @@ public class OSAPI implements ILuaAPI {
* > milliseconds. If you wish to convert this value to real time, divide by 72000; to
* > convert to ticks (where a day is 24000 ticks), divide by 3600.
*
* @param args The locale to get the milliseconds for. Defaults to {@code ingame} if not set.
* @param locale The locale to get the milliseconds for. Defaults to {@code ingame} if not set.
* @return The milliseconds since the epoch depending on the selected locale.
* @throws LuaException If an invalid locale is passed.
* @cc.since 1.80pr1
@@ -372,26 +372,13 @@ public class OSAPI implements ILuaAPI {
* }</pre>
*/
@LuaFunction
public final long epoch(Optional<String> args) throws LuaException {
switch (args.orElse("ingame").toLowerCase(Locale.ROOT)) {
case "utc": {
// Get utc epoch
var c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
return getEpochForCalendar(c);
}
case "local": {
// Get local epoch
var c = Calendar.getInstance();
return getEpochForCalendar(c);
}
case "ingame":
// Get in-game epoch
synchronized (alarms) {
return day * 86400000L + (long) (time * 3600000.0);
}
default:
throw new LuaException("Unsupported operation");
}
public final long epoch(Optional<String> locale) throws LuaException {
return switch (locale.orElse("ingame").toLowerCase(Locale.ROOT)) {
case "utc" -> getEpochForCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC"))); // Get utc epoch
case "local" -> getEpochForCalendar(Calendar.getInstance()); // Get local epoch
case "ingame" -> day * 86400000L + (long) (time * 3600000.0); // Get in-game epoch
default -> throw new LuaException("Unsupported operation");
};
}
/**
@@ -18,6 +18,8 @@ import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException;
import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException;
import io.netty.handler.proxy.HttpProxyHandler;
import io.netty.handler.proxy.Socks4ProxyHandler;
@@ -246,6 +248,10 @@ public final class NetworkUtils {
return "Timed out";
} else if (cause instanceof SSLHandshakeException || (cause instanceof DecoderException && cause.getCause() instanceof SSLHandshakeException)) {
return "Could not create a secure connection";
} else if (cause instanceof CorruptedWebSocketFrameException e) {
return e.closeStatus() == WebSocketCloseStatus.MESSAGE_TOO_BIG
? "Received a too-large message"
: "Corrupted websocket message";
} else {
return "Could not connect";
}
@@ -5,19 +5,23 @@
package dan200.computercraft.core.apis.http.websocket;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker13;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import org.jspecify.annotations.Nullable;
import java.net.URI;
/**
* A version of {@link WebSocketClientHandshaker13} which doesn't add the {@link HttpHeaderNames#ORIGIN} header to the
* original HTTP request.
* A version of {@link WebSocketClientHandshaker13} which retains the response headers, and doesn't add the
* {@link HttpHeaderNames#ORIGIN} header to the original HTTP request.
*/
class NoOriginWebSocketHandshaker extends WebSocketClientHandshaker13 {
NoOriginWebSocketHandshaker(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
class CustomWebSocketHandshaker extends WebSocketClientHandshaker13 {
private @Nullable HttpHeaders responseHeaders;
CustomWebSocketHandshaker(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
super(webSocketURL, version, subprotocol, allowExtensions, customHeaders, maxFramePayloadLength);
}
@@ -28,4 +32,14 @@ class NoOriginWebSocketHandshaker extends WebSocketClientHandshaker13 {
if (!customHeaders.contains(HttpHeaderNames.ORIGIN)) headers.remove(HttpHeaderNames.ORIGIN);
return request;
}
@Override
protected void verify(FullHttpResponse response) {
super.verify(response);
responseHeaders = response.headers();
}
public @Nullable HttpHeaders getResponseHeaders() {
return responseHeaders;
}
}
@@ -31,6 +31,8 @@ import org.slf4j.LoggerFactory;
import java.net.URI;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
@@ -96,7 +98,7 @@ public class Websocket extends Resource<Websocket> implements WebsocketClient {
NetworkUtils.initChannel(ch, uri, socketAddress, sslContext, proxy, timeout);
var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL);
var handshaker = new NoOriginWebSocketHandshaker(
var handshaker = new CustomWebSocketHandshaker(
uri, WebSocketVersion.V13, subprotocol, true, headers,
options.websocketMessage() <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage()
);
@@ -107,7 +109,7 @@ public class Websocket extends Resource<Websocket> implements WebsocketClient {
new HttpObjectAggregator(8192),
WebsocketCompressionHandler.INSTANCE,
new WebSocketClientProtocolHandler(handshaker, false, timeout),
new WebsocketHandler(Websocket.this, options)
new WebsocketHandler(Websocket.this, handshaker, options)
);
}
})
@@ -127,10 +129,16 @@ public class Websocket extends Resource<Websocket> implements WebsocketClient {
}
}
void success(Options options) {
void success(HttpHeaders responseHeaders, Options options) {
if (isClosed()) return;
var handle = new WebsocketHandle(environment, address, this, options);
Map<String, String> headers = new HashMap<>();
for (var header : responseHeaders) {
headers.compute(header.getKey(), (k, existing) -> existing == null ? header.getValue() : existing + "," + header.getValue());
}
var handle = new WebsocketHandle(environment, address, this, headers, options);
environment().queueEvent(SUCCESS_EVENT, address, handle);
createOwnerReference(handle);
@@ -5,6 +5,7 @@
package dan200.computercraft.core.apis.http.websocket;
import dan200.computercraft.api.lua.*;
import dan200.computercraft.core.apis.HTTPAPI;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.options.Options;
import org.jspecify.annotations.Nullable;
@@ -15,6 +16,7 @@ import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -35,12 +37,14 @@ public class WebsocketHandle {
private final IAPIEnvironment environment;
private final String address;
private final WebsocketClient websocket;
private final Map<String, String> responseHeaders;
private final Options options;
public WebsocketHandle(IAPIEnvironment environment, String address, WebsocketClient websocket, Options options) {
public WebsocketHandle(IAPIEnvironment environment, String address, WebsocketClient websocket, Map<String, String> responseHeaders, Options options) {
this.environment = environment;
this.address = address;
this.websocket = websocket;
this.responseHeaders = responseHeaders;
this.options = options;
}
@@ -53,8 +57,11 @@ public class WebsocketHandle {
* @cc.treturn [1] string The received message.
* @cc.treturn boolean If this was a binary message.
* @cc.treturn [2] nil If the websocket was closed while waiting, or if we timed out.
* @cc.treturn [2] string The reason we failed to receive a message. Either the reason the websocket was closed
* (as returned by [`websocket_closed`], or the string {@code "Timed out"}.
* @cc.changed 1.80pr1.13 Added return value indicating whether the message was binary.
* @cc.changed 1.87.0 Added timeout argument.
* @cc.changed 1.117.0 Added return value indicating why receiving the message failed.
*/
@LuaFunction
public final MethodResult receive(Optional<Double> timeout) throws LuaException {
@@ -105,6 +112,30 @@ public class WebsocketHandle {
websocket.close();
}
/**
* Get a table containing the headers from the handshake response, in a format similar to that required by
* {@link HTTPAPI#request}. If multiple headers are sent with the same name, they will be combined with a comma.
*
* @return The response's headers.
* @cc.usage Make a websocket connection to [example.tweaked.cc](https://example.tweaked.cc), and print the
* returned headers.
* <pre>{@code
* local ws = http.websocket("wss://example.tweaked.cc/echo")
* print(textutils.serialize(ws.getResponseHeaders()))
* -- => {
* -- Connection = "Upgrade",
* -- Upgrade = "websocket",
* -- ...
* -- }
* ws.close()
* }</pre>
* @since 1.107.0
*/
@LuaFunction
public final Map<String, String> getResponseHeaders() {
return responseHeaders;
}
private void checkOpen() throws LuaException {
if (websocket.isClosed()) throw new LuaException("attempt to use a closed file");
}
@@ -127,11 +158,11 @@ public class WebsocketHandle {
} else if (event.length >= 2 && Objects.equals(event[0], CLOSE_EVENT) && Objects.equals(event[1], address) && websocket.isClosed()) {
// If the socket is closed abort.
environment.cancelTimer(timeoutId);
return MethodResult.of();
return MethodResult.of(null, event.length > 2 ? event[2] : "Connection closed");
} else if (event.length >= 2 && timeoutId != -1 && Objects.equals(event[0], TIMER_EVENT)
&& event[1] instanceof Number id && id.intValue() == timeoutId) {
// If we received a matching timer event then abort.
return MethodResult.of();
return MethodResult.of(null, "Timed out");
}
return pull;
@@ -18,10 +18,12 @@ import static dan200.computercraft.core.apis.http.websocket.WebsocketClient.MESS
class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
private final Websocket websocket;
private final Options options;
private final CustomWebSocketHandshaker handshaker;
private boolean handshakeComplete = false;
WebsocketHandler(Websocket websocket, Options options) {
WebsocketHandler(Websocket websocket, CustomWebSocketHandshaker handshaker, Options options) {
this.websocket = websocket;
this.handshaker = handshaker;
this.options = options;
}
@@ -34,7 +36,9 @@ class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) {
websocket.success(options);
var headers = handshaker.getResponseHeaders();
if (headers == null) throw new NullPointerException("Headers cannot be null once handshake is complete");
websocket.success(headers, options);
handshakeComplete = true;
} else if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_TIMEOUT) {
websocket.failure("Timed out");
@@ -67,9 +71,8 @@ class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
fail(NetworkUtils.toFriendlyError(cause));
ctx.close();
}
private void fail(String message) {
@@ -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 implements ComputerEvents.Receiver {
public class Computer {
private static final int START_DELAY = 50;
// Various properties of the computer
@@ -114,7 +114,6 @@ public class Computer implements ComputerEvents.Receiver {
executor.queueStop(false, true);
}
@Override
public void queueEvent(String event, @Nullable Object @Nullable [] args) {
executor.queueEvent(event, args);
}
@@ -1,72 +0,0 @@
// 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 org.jspecify.annotations.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 @Nullable [] arguments);
}
}
@@ -352,7 +352,7 @@ public class FileSystem {
return sanitizePath(path, false);
}
private static final Pattern threeDotsPattern = Pattern.compile("^\\.{3,}$");
private static final Pattern manyDotsPattern = Pattern.compile("^[. ]+$");
// IMPORTANT: Both arrays are sorted by ASCII value.
private static final char[] specialChars = new char[]{ '"', '*', ':', '<', '>', '?', '|' };
@@ -376,27 +376,19 @@ public class FileSystem {
for (var fullPart : Splitter.on('/').split(path)) {
var part = fullPart.strip();
if (part.isEmpty() || part.equals(".") || threeDotsPattern.matcher(part).matches()) {
// . is redundant
// ... and more are treated as .
continue;
}
// Limit part length to 255.
if (part.length() > 255) part = part.substring(0, 255).strip();
if (part.equals("..")) {
// .. can cancel out the last folder entered
if (!outputParts.isEmpty()) {
var top = outputParts.peekLast();
if (!top.equals("..")) {
outputParts.removeLast();
} else {
outputParts.addLast("..");
}
} else {
if (outputParts.isEmpty() || outputParts.peekLast().equals("..")) {
outputParts.addLast("..");
} else {
outputParts.removeLast();
}
} else if (part.length() >= 255) {
// If part length > 255 and it is the last part
outputParts.addLast(part.substring(0, 255).strip());
} else if (part.isEmpty() || (part.startsWith(".") && manyDotsPattern.matcher(part).matches())) {
// Skip empty paths, ".", or any other sequence of "[. ]+" (as this is also treated as "." on Windows).
continue;
} else {
// Anything else we add to the stack
outputParts.addLast(part);
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.input;
import dan200.computercraft.core.util.StringUtil;
import java.nio.ByteBuffer;
/**
* Input events that can be performed on a computer.
*
* @see EventComputerInput
* @see UserComputerInput
*/
public interface ComputerInput {
/**
* Queue a {@code key} event.
*
* @param key The key that was pressed.
* @param repeat Whether this is a repeat input.
*/
void keyDown(int key, boolean repeat);
/**
* Queue a {@code key_up} event.
*
* @param key The key that was released.
*/
void keyUp(int key);
/**
* Type a character on the computer.
*
* @param chr The character to type.
* @see StringUtil#isTypableChar(byte)
*/
void charTyped(byte chr);
/**
* Paste a string.
*
* @param contents The string to paste.
* @see StringUtil#getClipboardString(String)
*/
void paste(ByteBuffer contents);
/**
* Queue a {@code mouse_click} event.
*
* @param button The mouse button that was pressed, between 1 and 3 (inclusive).
* @param x The x coordinate of the mouse, between 1 and the terminal width (inclusive).
* @param y The y coordinate of the mouse, between 1 and the terminal height (inclusive).
*/
void mouseClick(int button, int x, int y);
/**
* Queue a {@code mouse_up} event.
*
* @param button The mouse button that was released, between 1 and 3 (inclusive).
* @param x The x coordinate of the mouse, between 1 and the terminal width (inclusive).
* @param y The y coordinate of the mouse, between 1 and the terminal height (inclusive).
*/
void mouseUp(int button, int x, int y);
/**
* Queue a {@code mouse_drag} event.
*
* @param button The mouse button that is being pressed, between 1 and 3 (inclusive).
* @param x The x coordinate of the mouse, between 1 and the terminal width (inclusive).
* @param y The y coordinate of the mouse, between 1 and the terminal height (inclusive).
*/
void mouseDrag(int button, int x, int y);
/**
* Queue a {@code mouse_scroll} event.
*
* @param direction The direction of the scroll, where negative values are up and positive ones are down.
* @param x The x coordinate of the mouse, between 1 and the terminal width (inclusive).
* @param y The y coordinate of the mouse, between 1 and the terminal height (inclusive).
*/
void mouseScroll(int direction, int x, int y);
}
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.input;
import dan200.computercraft.core.computer.Computer;
import org.jspecify.annotations.Nullable;
import java.nio.ByteBuffer;
/**
* A {@link ComputerInput} that queues events on the computer.
*/
public final class EventComputerInput implements ComputerInput {
private final QueueEvent receiver;
public EventComputerInput(QueueEvent receiver) {
this.receiver = receiver;
}
public EventComputerInput(Computer computer) {
this(computer::queueEvent);
}
@Override
public void keyDown(int key, boolean repeat) {
receiver.queueEvent("key", new Object[]{ key, repeat });
}
@Override
public void keyUp(int key) {
receiver.queueEvent("key_up", new Object[]{ key });
}
@Override
public void charTyped(byte chr) {
receiver.queueEvent("char", new Object[]{ new byte[]{ chr } });
}
@Override
public void paste(ByteBuffer contents) {
receiver.queueEvent("paste", new Object[]{ contents });
}
@Override
public void mouseClick(int button, int x, int y) {
receiver.queueEvent("mouse_click", new Object[]{ button, x, y });
}
@Override
public void mouseUp(int button, int x, int y) {
receiver.queueEvent("mouse_up", new Object[]{ button, x, y });
}
@Override
public void mouseDrag(int button, int x, int y) {
receiver.queueEvent("mouse_drag", new Object[]{ button, x, y });
}
@Override
public void mouseScroll(int direction, int x, int y) {
receiver.queueEvent("mouse_scroll", new Object[]{ direction, x, y });
}
/**
* A function to queue events.
*/
@FunctionalInterface
public interface QueueEvent {
void queueEvent(String event, @Nullable Object @Nullable [] arguments);
}
}
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.input;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.StringUtil;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import java.nio.ByteBuffer;
/**
* A {@link ComputerInput} that wraps an existing {@link ComputerInput}. This both validates any user inputs (e.g.
* ensuring mouse presses only happen on an advanced terminal), and supports {@linkplain #releaseInputs()
* releasing any held inputs} (e.g. for when the computer loses focus).
*/
public final class UserComputerInput implements ComputerInput {
private final ComputerInput delegate;
private final boolean mouseSupport;
private final int termWidth;
private final int termHeight;
private final IntSet keysDown = new IntOpenHashSet(4);
private int lastMouseX;
private int lastMouseY;
private int lastMouseDown = -1;
public UserComputerInput(ComputerInput delegate, boolean mouseSupport, int termWidth, int termHeight) {
this.delegate = delegate;
this.mouseSupport = mouseSupport;
this.termWidth = termWidth;
this.termHeight = termHeight;
}
public UserComputerInput(ComputerInput delegate, Terminal terminal) {
this(delegate, terminal.isColour(), terminal.getWidth(), terminal.getHeight());
}
@Override
public void keyDown(int key, boolean repeat) {
if (key < 0) return;
keysDown.add(key);
delegate.keyDown(key, repeat);
}
/**
* Queue a {@code key} event on the computer. This behaves the same as {@link #keyDown(int, boolean)}, but infers
* the {@code "repeat} state from the currently held keys.
*
* @param key The key to press.
*/
public void keyDown(int key) {
keyDown(key, keysDown.contains(key));
}
@Override
public void keyUp(int key) {
if (key < 0) return;
keysDown.remove(key);
delegate.keyUp(key);
}
@Override
public void charTyped(byte chr) {
delegate.charTyped(chr);
}
public void codepointTyped(int codepoint) {
var terminalChar = StringUtil.unicodeToTerminal(codepoint);
if (StringUtil.isTypableChar(terminalChar)) charTyped((byte) terminalChar);
}
private static boolean isValidClipboard(ByteBuffer buffer) {
for (int i = buffer.position(), max = buffer.limit(); i < max; i++) {
if (!StringUtil.isTypableChar(buffer.get(i))) return false;
}
return true;
}
@Override
public void paste(ByteBuffer contents) {
if (contents.remaining() > 0 && isValidClipboard(contents)) delegate.paste(contents);
}
/**
* Paste a string.
*
* @param contents The string to paste.
*/
public void paste(String contents) {
paste(StringUtil.getClipboardString(contents));
}
@Override
public void mouseClick(int button, int x, int y) {
if (!mouseSupport || button < 1 || button > 3) return;
var clampedX = lastMouseX = Math.min(Math.max(x, 1), termWidth);
var clampedY = lastMouseY = Math.min(Math.max(y, 1), termHeight);
delegate.mouseClick(button, clampedX, clampedY);
lastMouseDown = button;
}
/**
* Queue a {@code mouse_click} event on the computer. This behaves the same as {@link #mouseClick(int, int, int)},
* but infers the mouse position from the last mouse position.
*
* @param button The mouse button pressed, between 1 and 3.
*/
public void mouseClick(int button) {
mouseClick(button, lastMouseX, lastMouseY);
}
@Override
public void mouseUp(int button, int x, int y) {
if (!mouseSupport || button < 1 || button > 3) return;
var clampedX = lastMouseX = Math.min(Math.max(x, 1), termWidth);
var clampedY = lastMouseY = Math.min(Math.max(y, 1), termHeight);
if (lastMouseDown == button) {
delegate.mouseUp(button, clampedX, clampedY);
lastMouseDown = -1;
}
}
/**
* Queue a {@code mouse_scroll} event on the computer. This behaves the same as {@link #mouseUp(int, int, int)},
* but infers the mouse position from the last mouse position.
*
* @param button The mouse button released, between 1 and 3.
*/
public void mouseUp(int button) {
mouseUp(button, lastMouseX, lastMouseY);
}
@Override
public void mouseDrag(int button, int x, int y) {
if (!mouseSupport || button < 1 || button > 3) return;
var clampedX = Math.min(Math.max(x, 1), termWidth);
var clampedY = Math.min(Math.max(y, 1), termHeight);
if (button == lastMouseDown && (clampedX != lastMouseX || clampedY != lastMouseY)) {
delegate.mouseDrag(button, clampedX, clampedY);
lastMouseX = clampedX;
lastMouseY = clampedY;
}
}
/**
* Update the mouse position, and optionally queue a {@code mouse_drag} event on the computer.
* <p>
* This is similar to {@link #mouseDrag(int, int, int)}, but when the currently clicked button is not available.
*
* @param x The X position of the mouse, between 1 and the terminal width.
* @param y The Y position of the mouse, between 1 and the terminal width.
*/
public void mouseMove(int x, int y) {
if (!mouseSupport) return;
var clampedX = Math.min(Math.max(x, 1), termWidth);
var clampedY = Math.min(Math.max(y, 1), termHeight);
if (lastMouseDown != -1 && (clampedX != lastMouseX || clampedY != lastMouseY)) {
delegate.mouseDrag(lastMouseDown, clampedX, clampedY);
}
lastMouseX = clampedX;
lastMouseY = clampedY;
}
@Override
public void mouseScroll(int direction, int x, int y) {
if (!mouseSupport || direction == 0) return;
var clampedX = lastMouseX = Math.min(Math.max(x, 1), termWidth);
var clampedY = lastMouseY = Math.min(Math.max(y, 1), termHeight);
delegate.mouseScroll(direction, clampedX, clampedY);
}
/**
* Queue a {@code mouse_scroll} event on the computer. This behaves the same as {@link #mouseScroll(int, int, int)},
* but infers the mouse position from the last mouse position.
*
* @param direction The direction of the scroll.
*/
public void mouseScroll(int direction) {
mouseScroll(direction, lastMouseX, lastMouseY);
}
/**
* Release all currently held inputs, such as held keys and pressed mouse buttons.
*/
public void releaseInputs() {
// Release all keys
var keys = keysDown.iterator();
while (keys.hasNext()) delegate.keyUp(keys.nextInt());
keysDown.clear();
// Release last held mouse button.
if (lastMouseDown != -1) {
delegate.mouseUp(lastMouseDown, lastMouseX, lastMouseY);
lastMouseDown = -1;
}
}
}
@@ -4,7 +4,7 @@
package dan200.computercraft.core.util;
import dan200.computercraft.core.computer.ComputerEvents;
import dan200.computercraft.core.input.ComputerInput;
import java.nio.ByteBuffer;
@@ -71,8 +71,8 @@ public final class StringUtil {
}
/**
* Check if a character is capable of being input and passed to a {@linkplain ComputerEvents#charTyped(ComputerEvents.Receiver, byte)
* "char" event}.
* Check if a character is capable of being input and passed to a {@linkplain ComputerInput#charTyped(byte) "char"
* event}.
*
* @param chr The character to check.
* @return Whether this character can be typed.
@@ -82,8 +82,8 @@ public final class StringUtil {
}
/**
* Check if a character is capable of being input and passed to a {@linkplain ComputerEvents#charTyped(ComputerEvents.Receiver, byte)
* "char" event}.
* Check if a character is capable of being input and passed to a {@linkplain ComputerInput#charTyped(byte) "char"
* * event}.
*
* @param chr The character to check.
* @return Whether this character can be typed.
@@ -5,29 +5,14 @@
--[[- Use [modems][`modem`] to locate the position of the current turtle or
computers.
It broadcasts a PING message over [`rednet`] and wait for responses. In order for
this system to work, there must be at least 4 computers used as gps hosts which
will respond and allow trilateration. Three of these hosts should be in a plane,
and the fourth should be either above or below the other three. The three in a
plane should not be in a line with each other. You can set up hosts using the
gps program.
> [!NOTE]
> When entering in the coordinates for the host you need to put in the `x`, `y`,
> and `z` coordinates of the block that the modem is connected to, not the modem.
> All modem distances are measured from the block that the modem is placed on.
Also note that you may choose which axes x, y, or z refers to - so long as your
systems have the same definition as any GPS servers that're in range, it works
just the same. For example, you might build a GPS cluster according to [this
tutorial][1], using z to account for height, or you might use y to account for
height in the way that Minecraft's debug screen displays.
[1]: https://ccf.squiddev.cc/forums2/index.php?/topic/3088-how-to-guide-gps-global-position-system/
This works by communicating with other computers (called GPS hosts) that already
know their position, finding the distance to those computers (with
[`modem_message`]), and using that to derive its position from theirs (with a
process known as [trilateration](https://en.wikipedia.org/wiki/Trilateration).
@module gps
@since 1.31
@see gps_setup For more detailed instructions on setting up GPS
@see gps_setup
]]
local expect = dofile("rom/modules/main/cc/expect.lua").expect
@@ -72,18 +72,20 @@ function parseImage(image)
return tImage
end
--- Loads an image from a file.
--
-- You can create a file suitable for being loaded using the `paint` program.
--
-- @tparam string path The file to load.
--
-- @treturn table|nil The parsed image data, suitable for use with
-- [`paintutils.drawImage`], or `nil` if the file does not exist.
-- @usage Load an image and draw it.
--
-- local image = paintutils.loadImage("data/example.nfp")
-- paintutils.drawImage(image, term.getCursorPos())
--[[- Loads an image from a file.
You can create a file suitable for being loaded using the `paint` program.
@tparam string path The file to load.
@treturn table|nil The parsed image data, suitable for use with
[`paintutils.drawImage`], or `nil` if the file does not exist.
@usage Load an image and draw it.
local image = paintutils.loadImage("data/example.nfp")
paintutils.drawImage(image, term.getCursorPos())
]]
function loadImage(path)
expect(1, path, "string")
@@ -96,15 +98,17 @@ function loadImage(path)
return nil
end
--- Draws a single pixel to the current term at the specified position.
--
-- Be warned, this may change the position of the cursor and the current
-- background colour. You should not expect either to be preserved.
--
-- @tparam number xPos The x position to draw at, where 1 is the far left.
-- @tparam number yPos The y position to draw at, where 1 is the very top.
-- @tparam[opt] number colour The [color][`colors`] of this pixel. This will be
-- the current background colour if not specified.
--[[- Draws a single pixel to the current term at the specified position.
> [!WARNING]
> This function may change the position of the cursor and the current
> background colour. You should not expect either to be preserved.
@tparam number xPos The x position to draw at, where 1 is the far left.
@tparam number yPos The y position to draw at, where 1 is the very top.
@tparam[opt] number colour The [color][`colors`] of this pixel. This will be
the current background colour if not specified.
]]
function drawPixel(xPos, yPos, colour)
expect(1, xPos, "number")
expect(2, yPos, "number")
@@ -116,18 +120,21 @@ function drawPixel(xPos, yPos, colour)
return drawPixelInternal(xPos, yPos)
end
--- Draws a straight line from the start to end position.
--
-- Be warned, this may change the position of the cursor and the current
-- background colour. You should not expect either to be preserved.
--
-- @tparam number startX The starting x position of the line.
-- @tparam number startY The starting y position of the line.
-- @tparam number endX The end x position of the line.
-- @tparam number endY The end y position of the line.
-- @tparam[opt] number colour The [color][`colors`] of this pixel. This will be
-- the current background colour if not specified.
-- @usage paintutils.drawLine(2, 3, 30, 7, colors.red)
--[[- Draws a straight line from the start to end position.
> [!WARNING]
> This function may change the position of the cursor and the current
> background colour. You should not expect either to be preserved.
@tparam number startX The starting x position of the line.
@tparam number startY The starting y position of the line.
@tparam number endX The end x position of the line.
@tparam number endY The end y position of the line.
@tparam[opt] number colour The [color][`colors`] of this pixel. This will be
the current background colour if not specified.
@usage paintutils.drawLine(2, 3, 30, 7, colors.red)
]]
function drawLine(startX, startY, endX, endY, colour)
expect(1, startX, "number")
expect(2, startY, "number")
@@ -189,19 +196,22 @@ function drawLine(startX, startY, endX, endY, colour)
end
end
--- Draws the outline of a box on the current term from the specified start
-- position to the specified end position.
--
-- Be warned, this may change the position of the cursor and the current
-- background colour. You should not expect either to be preserved.
--
-- @tparam number startX The starting x position of the line.
-- @tparam number startY The starting y position of the line.
-- @tparam number endX The end x position of the line.
-- @tparam number endY The end y position of the line.
-- @tparam[opt] number colour The [color][`colors`] of this pixel. This will be
-- the current background colour if not specified.
-- @usage paintutils.drawBox(2, 3, 30, 7, colors.red)
--[[- Draws the outline of a box on the current term from the specified start
position to the specified end position.
> [!WARNING]
> This function may change the position of the cursor and the current
> background colour. You should not expect either to be preserved.
@tparam number startX The starting x position of the line.
@tparam number startY The starting y position of the line.
@tparam number endX The end x position of the line.
@tparam number endY The end y position of the line.
@tparam[opt] number colour The [color][`colors`] of this pixel. This will be
the current background colour if not specified.
@usage paintutils.drawBox(2, 3, 30, 7, colors.red)
]]
function drawBox(startX, startY, endX, endY, nColour)
expect(1, startX, "number")
expect(2, startY, "number")
@@ -242,19 +252,22 @@ function drawBox(startX, startY, endX, endY, nColour)
end
end
--- Draws a filled box on the current term from the specified start position to
-- the specified end position.
--
-- Be warned, this may change the position of the cursor and the current
-- background colour. You should not expect either to be preserved.
--
-- @tparam number startX The starting x position of the line.
-- @tparam number startY The starting y position of the line.
-- @tparam number endX The end x position of the line.
-- @tparam number endY The end y position of the line.
-- @tparam[opt] number colour The [color][`colors`] of this pixel. This will be
-- the current background colour if not specified.
-- @usage paintutils.drawFilledBox(2, 3, 30, 7, colors.red)
--[[- Draws a filled box on the current term from the specified start position to
the specified end position.
> [!WARNING]
> This function may change the position of the cursor and the current
> background colour. You should not expect either to be preserved.
@tparam number startX The starting x position of the line.
@tparam number startY The starting y position of the line.
@tparam number endX The end x position of the line.
@tparam number endY The end y position of the line.
@tparam[opt] number colour The [color][`colors`] of this pixel. This will be
the current background colour if not specified.
@usage paintutils.drawFilledBox(2, 3, 30, 7, colors.red)
]]
function drawFilledBox(startX, startY, endX, endY, nColour)
expect(1, startX, "number")
expect(2, startY, "number")
@@ -288,11 +301,21 @@ function drawFilledBox(startX, startY, endX, endY, nColour)
end
end
--- Draw an image loaded by [`paintutils.parseImage`] or [`paintutils.loadImage`].
--
-- @tparam table image The parsed image data.
-- @tparam number xPos The x position to start drawing at.
-- @tparam number yPos The y position to start drawing at.
--[[- Draw an image loaded by [`paintutils.parseImage`] or [`paintutils.loadImage`].
> [!WARNING]
> This function may change the position of the cursor and the current
> background colour. You should not expect either to be preserved.
@tparam table image The parsed image data.
@tparam number xPos The x position to start drawing at.
@tparam number yPos The y position to start drawing at.
@usage Load an image and draw it.
local image = paintutils.loadImage("data/example.nfp")
paintutils.drawImage(image, term.getCursorPos())
]]
function drawImage(image, xPos, yPos)
expect(1, image, "table")
expect(2, xPos, "number")
@@ -1,3 +1,21 @@
# New features in CC: Tweaked 1.117.0
* Support mouse input for pocket computers on a lectern.
* Pocket computers on a lectern now attach the peripheral below.
* Add map colour to block and item details (ShreksHellraiser).
* Add potion effects to item details.
* add `getResponseHeaders` method to websocket handles.
* Update translations.
Several bug fixes:
* Many documentation fixes (McJack123, tomodachi94).
* Fix crash when CC:T blocks are placed with Building Gadgets.
* Fix redstone relays not updating redstone input/output on chunk load.
* Fix inconsistency with handling `. .` on Windows.
* Fix bundled cable input not updating with MoreRed.
* Fix `websocket_closed` not always being closed when the socket closes due to an error.
* Fix `nbt` hash failing to be computed for some items.
# New features in CC: Tweaked 1.116.2
Several bug fixes:
@@ -30,9 +48,9 @@ Several bug fixes:
* Fix pocket computer dyes being lost when equipping/unequipping upgrades.
* Fix superflous warnings from allocation tracking.
* Fix `__lt`/`__le` not working on heterogeneous types.
* Many documentation fixes (Lemmmy, matematikaadit, McJack12).
* Many documentation fixes (Lemmmy, matematikaadit, McJack123).
* Fix `0` being treated as a valid colour in `window` and `colour.toBlit`.
* Fix out-of-bounds when pasting too lon text.
* Fix out-of-bounds when pasting too long text.
* Fix syntax highlighting of string escapes (LorneHyde).
* Fix sidebar texture of advanced computers being offset.
@@ -1,10 +1,19 @@
New features in CC: Tweaked 1.116.2
New features in CC: Tweaked 1.117.0
* Support mouse input for pocket computers on a lectern.
* Pocket computers on a lectern now attach the peripheral below.
* Add map colour to block and item details (ShreksHellraiser).
* Add potion effects to item details.
* add `getResponseHeaders` method to websocket handles.
* Update translations.
Several bug fixes:
* Update Create compatibility to Create Fabric 6.0.
* Various documentation fixes (Zirunis).
* Fix crash with Inventorio.
* Various fixes to SNBT parsing.
* Fix Regex DDoS in string pattern matching.
* Many documentation fixes (McJack123, tomodachi94).
* Fix crash when CC:T blocks are placed with Building Gadgets.
* Fix redstone relays not updating redstone input/output on chunk load.
* Fix inconsistency with handling `. .` on Windows.
* Fix bundled cable input not updating with MoreRed.
* Fix `websocket_closed` not always being closed when the socket closes due to an error.
* Fix `nbt` hash failing to be computed for some items.
Type "help changelog" to see the full version history.
@@ -432,17 +432,11 @@ public class ComputerTestDelegate {
}
switch (status) {
case "ok":
break;
case "pending":
runResult = new TestAbortedException("Test is pending");
break;
case "fail":
runResult = new AssertionFailedError(wholeMessage.toString());
break;
case "error":
runResult = new IllegalStateException(wholeMessage.toString());
break;
case "ok" -> {
}
case "pending" -> runResult = new TestAbortedException("Test is pending");
case "fail" -> runResult = new AssertionFailedError(wholeMessage.toString());
case "error" -> runResult = new IllegalStateException(wholeMessage.toString());
}
runFinished = true;
@@ -89,6 +89,7 @@ public class FileSystemTest {
new String[]{ "a/./b", "a/b" },
new String[]{ "a/../b", "b" },
new String[]{ "a/.../b", "a/b" },
new String[]{ "a/. ./b", "a/b" },
new String[]{ " a ", "a" },
new String[]{ "a b c", "a b c" },
};
@@ -585,7 +585,7 @@ class TerminalTest {
}
}
public Matcher<Terminal> matches() {
private Matcher<Terminal> matches() {
return allOf(
textMatches(textLines), textColourMatches(textColourLines), backgroundColourMatches(backgroundColourLines)
);
@@ -17,43 +17,52 @@ import io.netty.handler.codec.http.websocketx.WebSocketFrame
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler.HandshakeComplete
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
/**
* Runs a small HTTP server to run alongside [TestHttpApi]
*/
object HttpServer {
const val PORT: Int = 8378
const val URL: String = "http://127.0.0.1:$PORT"
const val WS_URL: String = "ws://127.0.0.1:$PORT/ws"
class HttpServer(val port: Int, private val workerGroup: EventLoopGroup, private val activeConnections: Set<Channel>) {
/** Stop the server from running */
fun stop() {
workerGroup.shutdownGracefully()
}
fun runServer(run: (stop: () -> Unit) -> Unit) {
val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(2, NioIoHandler.newFactory())
try {
val ch = ServerBootstrap()
.group(workerGroup)
.channel(NioServerSocketChannel::class.java)
.childHandler(
object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
val p: ChannelPipeline = ch.pipeline()
p.addLast(HttpServerCodec())
p.addLast(HttpContentCompressor())
p.addLast(HttpObjectAggregator(8192))
p.addLast(HttpServerHandler())
p.addLast(WebSocketServerCompressionHandler(0))
p.addLast(WebSocketServerProtocolHandler("/ws", null, true))
p.addLast(WebSocketFrameHandler())
}
},
).bind(PORT).sync().channel()
/** Broadcast this message to every connected websocket */
fun broadcast(message: WebSocketFrame) {
for (chan in activeConnections) chan.writeAndFlush(message)
}
companion object {
/** Runs a small HTTP server to run alongside [TestHttpApi] */
fun runServer(run: (server: HttpServer) -> Unit) {
val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(2, NioIoHandler.newFactory())
val activeConnections = mutableSetOf<Channel>()
try {
run { workerGroup.shutdownGracefully() }
val ch = ServerBootstrap()
.group(workerGroup)
.channel(NioServerSocketChannel::class.java)
.childHandler(
object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
val p: ChannelPipeline = ch.pipeline()
p.addLast(HttpServerCodec())
p.addLast(HttpContentCompressor())
p.addLast(HttpObjectAggregator(8192))
p.addLast(HttpServerHandler())
p.addLast(WebSocketServerCompressionHandler(0))
p.addLast(WebSocketServerProtocolHandler("/ws", null, true))
p.addLast(WebSocketFrameHandler(activeConnections))
}
},
).bind(0).sync().channel()
val port = (ch.localAddress() as InetSocketAddress).port
try {
run(HttpServer(port, workerGroup, activeConnections))
} finally {
ch.close().sync()
}
} finally {
ch.close().sync()
workerGroup.shutdownGracefully().get()
}
} finally {
workerGroup.shutdownGracefully().get()
}
}
}
@@ -70,7 +79,7 @@ private class HttpServerHandler : SimpleChannelInboundHandler<FullHttpRequest>()
ctx.flush()
}
public override fun channelRead0(ctx: ChannelHandlerContext, request: FullHttpRequest) {
override fun channelRead0(ctx: ChannelHandlerContext, request: FullHttpRequest) {
when (request.uri()) {
"/", "/index.html" -> handleIndex(ctx, request)
"/ws" -> handleWebsocket(ctx, request)
@@ -113,7 +122,7 @@ private class HttpServerHandler : SimpleChannelInboundHandler<FullHttpRequest>()
/**
* A basic WS server which just sends back the original message.
*/
private class WebSocketFrameHandler : SimpleChannelInboundHandler<WebSocketFrame>() {
private class WebSocketFrameHandler(private val activeConnections: MutableSet<Channel>) : SimpleChannelInboundHandler<WebSocketFrame>() {
override fun channelRead0(ctx: ChannelHandlerContext, frame: WebSocketFrame) {
if (frame is TextWebSocketFrame) {
// Send the uppercase string back.
@@ -126,10 +135,16 @@ private class WebSocketFrameHandler : SimpleChannelInboundHandler<WebSocketFrame
override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) {
if (evt is HandshakeComplete) {
// Channel upgrade to websocket, remove WebSocketIndexPageHandler.
// Channel upgrade to websocket, remove HttpServerHandler.
ctx.pipeline().remove(HttpServerHandler::class.java)
activeConnections.add(ctx.channel())
} else {
super.userEventTriggered(ctx, evt)
}
}
override fun channelInactive(ctx: ChannelHandlerContext) {
super.channelInactive(ctx)
activeConnections.remove(ctx.channel())
}
}
@@ -11,14 +11,14 @@ import dan200.computercraft.api.lua.ObjectArguments
import dan200.computercraft.core.CoreConfig
import dan200.computercraft.core.apis.HTTPAPI
import dan200.computercraft.core.apis.handles.ReadHandle
import dan200.computercraft.core.apis.http.HttpServer.URL
import dan200.computercraft.core.apis.http.HttpServer.WS_URL
import dan200.computercraft.core.apis.http.HttpServer.runServer
import dan200.computercraft.core.apis.http.HttpServer.Companion.runServer
import dan200.computercraft.core.apis.http.options.Action
import dan200.computercraft.core.apis.http.options.AddressRule
import dan200.computercraft.core.apis.http.request.HttpResponseHandle
import dan200.computercraft.core.apis.http.websocket.WebsocketHandle
import dan200.computercraft.test.core.computer.LuaTaskRunner
import io.netty.buffer.Unpooled
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.AfterAll
@@ -50,13 +50,14 @@ class TestHttpApi {
@Test
fun `Connects to a HTTP server`() {
runServer {
runServer { server ->
LuaTaskRunner.runTest {
val url = "http://127.0.0.1:${server.port}"
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.request succeeded", httpApi.request(ObjectArguments(URL)), array(equalTo(true)))
assertThat("http.request succeeded", httpApi.request(ObjectArguments(url)), array(equalTo(true)))
val result = pullEvent("http_success")
assertThat(result, array(equalTo("http_success"), equalTo(URL), isA(HttpResponseHandle::class.java)))
assertThat(result, array(equalTo("http_success"), equalTo(url), isA(HttpResponseHandle::class.java)))
val handle = result[2] as HttpResponseHandle
val reader = handle.extra.iterator().next() as ReadHandle
@@ -67,13 +68,14 @@ class TestHttpApi {
@Test
fun `Connects to websocket`() {
runServer {
runServer { server ->
LuaTaskRunner.runTest {
val url = "ws://127.0.0.1:${server.port}/ws"
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(WS_URL)), array(equalTo(true)))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(url)), array(equalTo(true)))
val connectEvent = pullEvent()
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(WS_URL), isA(WebsocketHandle::class.java)))
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(url), isA(WebsocketHandle::class.java)))
val websocket = connectEvent[2] as WebsocketHandle
websocket.send(Coerced(LuaValues.encode("Hello")), Optional.of(false))
@@ -91,13 +93,14 @@ class TestHttpApi {
@Test
fun `Errors if too many websocket messages are sent`() {
runServer {
runServer { server ->
LuaTaskRunner.runTest {
val url = "ws://127.0.0.1:${server.port}/ws"
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(WS_URL)), array(equalTo(true)))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(url)), array(equalTo(true)))
val connectEvent = pullEvent()
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(WS_URL), isA(WebsocketHandle::class.java)))
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(url), isA(WebsocketHandle::class.java)))
val websocket = connectEvent[2] as WebsocketHandle
val error = assertThrows<LuaException> {
@@ -114,24 +117,46 @@ class TestHttpApi {
}
@Test
fun `Queues an event when the socket is externally closed`() {
runServer { stop ->
fun `Closes if a websocket message is too large`() {
runServer { server ->
LuaTaskRunner.runTest {
val url = "ws://127.0.0.1:${server.port}/ws"
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(WS_URL)), array(equalTo(true)))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(url)), array(equalTo(true)))
val connectEvent = pullEvent()
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(WS_URL), isA(WebsocketHandle::class.java)))
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(url), isA(WebsocketHandle::class.java)))
val out = ByteArray(AddressRule.WEBSOCKET_MESSAGE + 1)
Random(0xDEADBEEF).nextBytes(out)
server.broadcast(BinaryWebSocketFrame(Unpooled.wrappedBuffer(out)))
val closeEvent = pullEvent()
assertThat(closeEvent, array(equalTo("websocket_closed"), equalTo(url), equalTo("Received a too-large message"), nullValue()))
}
}
}
@Test
fun `Queues an event when the socket is externally closed`() {
runServer { server ->
LuaTaskRunner.runTest {
val url = "ws://127.0.0.1:${server.port}/ws"
val httpApi = addApi(HTTPAPI(environment))
assertThat("http.websocket succeeded", httpApi.websocket(ObjectArguments(url)), array(equalTo(true)))
val connectEvent = pullEvent()
assertThat(connectEvent, array(equalTo("websocket_success"), equalTo(url), isA(WebsocketHandle::class.java)))
val websocket = connectEvent[2] as WebsocketHandle
stop()
server.stop()
val closeEvent = pullEvent("websocket_closed")
assertThat(
"Websocket was closed",
closeEvent,
array(equalTo("websocket_closed"), equalTo(WS_URL), equalTo("Connection closed"), equalTo(null)),
array(equalTo("websocket_closed"), equalTo(url), equalTo("Connection closed"), equalTo(null)),
)
assertThrows<LuaException>("Throws an exception when sending") {
@@ -156,6 +156,11 @@ describe("The fs library", function()
expect(fs.combine("a", "../../c")):eq("../c")
end)
it("handles weird Windows paths", function()
expect(fs.combine("a", "...")):eq("a")
expect(fs.combine("a", ". .")):eq("a")
end)
it("combines empty paths", function()
expect(fs.combine("a")):eq("a")
expect(fs.combine("a", "")):eq("a")
@@ -5,11 +5,14 @@
package dan200.computercraft.test.core;
import org.hamcrest.Matcher;
import org.hamcrest.StringDescription;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.is;
public class CustomMatchers {
/**
@@ -25,4 +28,32 @@ public class CustomMatchers {
public static <T> Matcher<Iterable<? extends T>> containsWith(List<T> items, Function<T, Matcher<? super T>> matcher) {
return contains(items.stream().map(matcher).toList());
}
/**
* An alternative to {@link org.hamcrest.Matchers#hasEntry(Object, Object)}, that acts as a projection, rather than
* searching the map.
*
* @param key The key to extract.
* @param value The expected value.
* @param <K> The type of keys in the map.
* @param <V> The type of values in the map.
* @return A matcher that projects out of a map.
*/
public static <K, V> Matcher<Map<? extends K, ? extends V>> containsEntry(K key, V value) {
return containsEntryWith(key, is(value));
}
/**
* An alternative to {@link org.hamcrest.Matchers#hasEntry(Matcher, Matcher)}, that acts as a projection, rather
* than searching the map.
*
* @param key The key to extract.
* @param value The expected value.
* @param <K> The type of keys in the map.
* @param <V> The type of values in the map.
* @return A matcher that projects out of a map.
*/
public static <K, V> Matcher<Map<? extends K, ? extends V>> containsEntryWith(K key, Matcher<? super V> value) {
return ContramapMatcher.contramap(value, new StringDescription().appendValue(key).toString(), x -> x.get(key));
}
}
@@ -10,9 +10,11 @@ import dan200.computercraft.api.lua.MethodResult
import dan200.computercraft.api.lua.ObjectArguments
import dan200.computercraft.core.apis.OSAPI
import dan200.computercraft.core.apis.PeripheralAPI
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
/**
@@ -69,7 +71,8 @@ interface LuaTaskContext {
inline fun <reified T : ILuaAPI> LuaTaskContext.getApi(): T = getApi(T::class.java)
abstract class AbstractLuaTaskContext : LuaTaskContext, AutoCloseable {
private val pullEvents = mutableListOf<PullEvent>()
private val isReceiving = AtomicBoolean(false)
private val eventStream: Channel<Event> = Channel(Channel.UNLIMITED)
private val apis = mutableMapOf<Class<out ILuaAPI>, ILuaAPI>()
protected fun addApi(api: ILuaAPI) {
@@ -77,34 +80,40 @@ abstract class AbstractLuaTaskContext : LuaTaskContext, AutoCloseable {
}
protected val hasEventListeners
get() = pullEvents.isNotEmpty()
get() = isReceiving.get()
protected fun queueEvent(eventName: String?, arguments: Array<out Any?>?) {
val fullEvent: Array<out Any?> = when {
eventName == null && arguments == null -> arrayOf()
eventName != null && arguments == null -> arrayOf(eventName)
eventName == null && arguments != null -> arguments
else -> arrayOf(eventName, *arguments!!)
}
for (i in pullEvents.size - 1 downTo 0) {
val puller = pullEvents[i]
if (puller.name == null || puller.name == eventName || eventName == "terminate") {
pullEvents.removeAt(i)
puller.cont.resumeWith(Result.success(fullEvent))
}
}
eventStream.trySend(Event(eventName, arguments)).getOrThrow()
}
override fun close() {
for (pullEvent in pullEvents) pullEvent.cont.cancel()
pullEvents.clear()
eventStream.close()
}
final override fun <T : ILuaAPI> getApi(api: Class<T>): T =
api.cast(apis[api] ?: throw IllegalStateException("No API of type ${api.name}"))
final override suspend fun pullEvent(event: String?): Array<out Any?> =
suspendCancellableCoroutine { cont -> pullEvents.add(PullEvent(event, cont)) }
final override suspend fun pullEvent(event: String?): Array<out Any?> {
if (!isReceiving.compareAndSet(false, true)) {
throw IllegalStateException("Multiple listeners not currently supported")
}
private class PullEvent(val name: String?, val cont: CancellableContinuation<Array<out Any?>>)
try {
while (true) {
val received = eventStream.receive()
if (event == null || received.name == event) {
return received.full
}
}
} catch (e: ClosedReceiveChannelException) {
throw CancellationException(e)
} finally {
isReceiving.set(false)
}
}
private class Event(val name: String?, val args: Array<out Any?>?) {
val full: Array<out Any?>
get() = if (args == null) arrayOf(name) else arrayOf(name, *args)
}
}
@@ -9,24 +9,18 @@ import dan200.computercraft.api.lua.ILuaContext
import dan200.computercraft.api.lua.LuaException
import dan200.computercraft.core.apis.IAPIEnvironment
import dan200.computercraft.test.core.apis.BasicApiEnvironment
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class LuaTaskRunner : AbstractLuaTaskContext() {
private val eventStream: Channel<Event> = Channel(Channel.UNLIMITED)
private val apis = mutableListOf<ILuaAPI>()
val environment: IAPIEnvironment = object : BasicApiEnvironment(BasicEnvironment()) {
override fun queueEvent(event: String?, vararg args: Any?) = this@LuaTaskRunner.queueEvent(event, args)
override fun shutdown() {
super.shutdown()
eventStream.close()
}
}
override val context =
ILuaContext { throw LuaException("Cannot queue main thread task") }
@@ -38,11 +32,10 @@ class LuaTaskRunner : AbstractLuaTaskContext() {
}
override fun close() {
super.close()
environment.shutdown()
}
private class Event(val name: String?, val args: Array<out Any?>)
companion object {
fun runTest(timeout: Duration = 5.seconds, fn: suspend LuaTaskRunner.() -> Unit) {
runBlocking {
@@ -8,8 +8,8 @@ import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import dan200.computercraft.core.apis.transfer.TransferredFile;
import dan200.computercraft.core.apis.transfer.TransferredFiles;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.computer.ComputerEvents;
import dan200.computercraft.core.util.StringUtil;
import dan200.computercraft.core.input.EventComputerInput;
import dan200.computercraft.core.input.UserComputerInput;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.glfw.GLFWDropCallback;
import org.lwjgl.glfw.GLFWKeyCallbackI;
@@ -21,7 +21,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
/**
@@ -35,23 +34,19 @@ public class InputState {
private static final float KEY_SUPPRESS_DELAY = 0.2f;
private final Computer computer;
private final BitSet keysDown = new BitSet(256);
private final UserComputerInput input;
private float terminateTimer = -1;
private float rebootTimer = -1;
private float shutdownTimer = -1;
private int lastMouseButton = -1;
private int lastMouseX = -1;
private int lastMouseY = -1;
public InputState(Computer computer) {
this.computer = computer;
this.input = new UserComputerInput(new EventComputerInput(computer), computer.getEnvironment().getTerminal());
}
public void onCharEvent(int codepoint) {
var terminalChar = StringUtil.unicodeToTerminal(codepoint);
if (StringUtil.isTypableChar(terminalChar)) ComputerEvents.charTyped(computer, (byte) terminalChar);
input.codepointTyped(codepoint);
}
public void onKeyEvent(long window, int key, int action, int modifiers) {
@@ -66,10 +61,7 @@ public class InputState {
if (key == GLFW.GLFW_KEY_V && modifiers == GLFW.GLFW_MOD_CONTROL) {
var string = GLFW.glfwGetClipboardString(window);
if (string != null) {
var clipboard = StringUtil.getClipboardString(string);
if (clipboard.remaining() > 0) ComputerEvents.paste(computer, clipboard);
}
if (string != null) input.paste(string);
return;
}
@@ -88,19 +80,12 @@ public class InputState {
}
if (key >= 0 && terminateTimer < KEY_SUPPRESS_DELAY && rebootTimer < KEY_SUPPRESS_DELAY && shutdownTimer < KEY_SUPPRESS_DELAY) {
// Queue the "key" event and add to the down set
var repeat = keysDown.get(key);
keysDown.set(key);
ComputerEvents.keyDown(computer, key, repeat);
input.keyDown(key);
}
}
private void keyReleased(int key) {
// Queue the "key_up" event and remove from the down set
if (key >= 0 && keysDown.get(key)) {
keysDown.set(key, false);
ComputerEvents.keyUp(computer, key);
}
input.keyUp(key);
switch (key) {
case GLFW.GLFW_KEY_T -> terminateTimer = -1;
@@ -113,33 +98,17 @@ public class InputState {
public void onMouseClick(int button, int action) {
switch (action) {
case GLFW.GLFW_PRESS -> {
ComputerEvents.mouseClick(computer, button + 1, lastMouseX + 1, lastMouseY + 1);
lastMouseButton = button;
}
case GLFW.GLFW_RELEASE -> {
if (button == lastMouseButton) {
ComputerEvents.mouseUp(computer, button + 1, lastMouseX + 1, lastMouseY + 1);
lastMouseButton = -1;
}
}
case GLFW.GLFW_PRESS -> input.mouseClick(button + 1);
case GLFW.GLFW_RELEASE -> input.mouseUp(button + 1);
}
}
public void onMouseMove(int mouseX, int mouseY) {
if (mouseX == lastMouseX && mouseY == lastMouseY) return;
lastMouseX = mouseX;
lastMouseY = mouseY;
if (lastMouseButton != -1) {
ComputerEvents.mouseDrag(computer, lastMouseButton + 1, mouseX + 1, mouseY + 1);
}
input.mouseMove(mouseX + 1, mouseY + 1);
}
public void onMouseScroll(double yOffset) {
if (yOffset != 0) {
ComputerEvents.mouseScroll(computer, yOffset < 0 ? 1 : -1, lastMouseX + 1, lastMouseY + 1);
}
if (yOffset != 0) input.mouseScroll(yOffset < 0 ? 1 : -1);
}
public void onFileDrop(int count, long names) {
@@ -313,8 +313,6 @@ public class Main {
glfwSetCursorPosCallback(window, (w, x, y) -> {
var charX = (int) (((x / SCALE) - MARGIN) / PIXEL_WIDTH);
var charY = (int) (((y / SCALE) - MARGIN) / PIXEL_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
inputState.onMouseMove(charX, charY);
});
glfwSetScrollCallback(window, (w, xOffset, yOffset) -> inputState.onMouseScroll(yOffset));
+3 -3
View File
@@ -88,9 +88,9 @@ const ccTweaked = minify => {
}
},
/** @type {import("rollup").ResolveIdHook} */
async resolveId(source) {
if (source === "cct/classes") return path.resolve("build/teaVM/classes.js");
if (source === "cct/resources") return path.resolve("build/teaVM/resources.js");
if (source.startsWith("cct/")) return path.resolve("build/teaVM/" + source.substring(4));
return null;
},
@@ -124,7 +124,7 @@ export default args => ({
resolve({ browser: true }),
url({
include: ["**/*.dfpwm", "**/*.worker.js", "**/*.png"],
include: ["**/*.dfpwm", "**/*.worker.js", "**/*.png", "**/*.wasm"],
fileName: "[name]-[hash][extname]",
publicPath: "/",
limit: 0,
@@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
@@ -70,14 +71,24 @@ public class Builder {
}
// Then finally start the compiler!
run(output, remapper, TeaVMTargetType.JAVASCRIPT, TeaVMOptimizationLevel.ADVANCED, minify);
run(output, remapper, TeaVMTargetType.WEBASSEMBLY_GC, TeaVMOptimizationLevel.SIMPLE, minify);
try (var runtime = Builder.class.getClassLoader().getResourceAsStream("org/teavm/backend/wasm/wasm-gc-module-runtime.js")) {
if (runtime == null) throw new IllegalStateException("Cannot find WASM runtime");
Files.copy(runtime, output.resolve("wasm-gc-runtime.js"), StandardCopyOption.REPLACE_EXISTING);
}
}
private static void run(Path output, ClassLoader classes, TeaVMTargetType target, TeaVMOptimizationLevel optimise, boolean minify) throws Exception {
var tool = new TeaVMTool();
tool.setTargetType(TeaVMTargetType.JAVASCRIPT);
tool.setTargetType(target);
tool.setJsModuleType(JSModuleType.ES2015);
tool.setTargetDirectory(output.toFile());
tool.setClassLoader(remapper);
tool.setClassLoader(classes);
tool.setMainClass("cc.tweaked.web.Main");
tool.setOptimizationLevel(TeaVMOptimizationLevel.ADVANCED);
tool.setOptimizationLevel(optimise);
tool.setObfuscated(minify);
tool.generate();
+24 -4
View File
@@ -4,11 +4,31 @@
import "setimmediate";
import type { ComputerDisplay, ComputerHandle } from "cct/classes";
export type { ComputerDisplay, ComputerHandle, PeripheralKind, Side } from "cct/classes";
import type { ComputerDisplay, ComputerHandle } from "cct/classes.js";
export type { ComputerDisplay, ComputerHandle, PeripheralKind, Side } from "cct/classes.js";
import { load as teaVMLoad } from "cct/wasm-gc-runtime.js";
import { exceptions, gc } from "wasm-feature-detect";
import wasmClasses from "cct/classes.wasm";
const loadClasses = async (): Promise<{ main: (args: string[]) => void }> => {
if (
typeof WebAssembly === "object" && typeof WebAssembly.compileStreaming === "function" &&
await exceptions() && await gc()
) {
try {
console.log("Loading WASM runtime");
return (await teaVMLoad(wasmClasses)).exports;
} catch (e) {
console.error("Failed to load WebAssembly runtime", e);
}
}
console.log("Using JS runtime");
return await import("cct/classes.js");
}
const load = async (): Promise<(computer: ComputerDisplay) => ComputerHandle> => {
const [classes, { version, resources }] = await Promise.all([import("cct/classes"), import("cct/resources")]);
const [classes, { version, resources }] = await Promise.all([loadClasses(), import("cct/resources.js")]);
let addComputer: ((computer: ComputerDisplay) => ComputerHandle) | null = null;
const encoder = new TextEncoder();
@@ -18,7 +38,7 @@ const load = async (): Promise<(computer: ComputerDisplay) => ComputerHandle> =>
listResources: () => Object.keys(resources),
getResource: path => new Int8Array(encoder.encode(resources[path]))
};
classes.main();
classes.main([]);
if (!addComputer) throw new Error("Callbacks.setup was never called");
return addComputer;
+1
View File
@@ -76,6 +76,7 @@ class Window extends Component<WindowProps, WindowState> {
const elements = document.querySelectorAll("pre[data-lua-kind]");
for (let i = 0; i < elements.length; i++) {
const element = elements[i] as HTMLElement;
if (element.hasAttribute("data-no-run")) continue
let example = element.innerText;
+18 -5
View File
@@ -28,16 +28,21 @@ declare module "*.license" {
}
declare module "*.dfpwm" {
const contents: string;
export default contents;
const url: string;
export default url;
}
declare module "cct/resources" {
declare module "*.wasm" {
const url: string;
export default url;
}
declare module "cct/resources.js" {
export const version: string;
export const resources: Record<string, string>;
}
declare module "cct/classes" {
declare module "cct/classes.js" {
export const main: () => void;
export type Side = "up" | "down" | "left" | "right" | "front" | "back";
@@ -169,10 +174,18 @@ declare module "cct/classes" {
}
}
declare module "cct/wasm-gc-runtime.js" {
export const load: (url: string, options?: any) => Promise<{
exports: { main: (args: string[]) => void },
instance: WebAssembly.Instance,
module: WebAssembly.Module,
}>
}
declare namespace JSX {
export type Element = import("preact").JSX.Element;
export type IntrinsicElements = import("preact").JSX.IntrinsicElements;
export type ElementClass = import("preact").JSX.ElementClass;
}
declare var $javaCallbacks: import("cct/classes").Callbacks; // eslint-disable-line no-var
declare var $javaCallbacks: import("cct/classes.js").Callbacks; // eslint-disable-line no-var
@@ -27,6 +27,7 @@ import org.slf4j.LoggerFactory;
import org.teavm.jso.JSObject;
import org.teavm.jso.core.JSString;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.Int8Array;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -161,7 +162,7 @@ class EmulatedComputer implements ComputerEnvironment, ComputerHandle {
public void transferFiles(FileContents[] files) {
computer.queueEvent(TransferredFiles.EVENT, new Object[]{ new TransferredFiles(
Arrays.stream(files)
.map(x -> new TransferredFile(x.getName(), new ArrayByteChannel(bytesOfBuffer(x.getContents()))))
.map(x -> new TransferredFile(x.getName(), new ArrayByteChannel(new Int8Array(x.getContents()).copyToJavaArray())))
.toList()
) });
}
@@ -186,8 +187,8 @@ class EmulatedComputer implements ComputerEnvironment, ComputerHandle {
@Override
public void addFile(String path, JSObject contents) {
byte[] bytes;
if (JavascriptConv.isArrayBuffer(contents)) {
bytes = bytesOfBuffer((ArrayBuffer) contents);
if (contents instanceof ArrayBuffer buffer) {
bytes = new Int8Array(buffer).copyToJavaArray();
} else {
var string = (JSString) contents;
bytes = string.stringValue().getBytes(StandardCharsets.UTF_8);
@@ -195,9 +196,4 @@ class EmulatedComputer implements ComputerEnvironment, ComputerHandle {
mount.addFile(path, bytes);
}
private byte[] bytesOfBuffer(ArrayBuffer buffer) {
var oldBytes = JavascriptConv.asByteArray(buffer);
return Arrays.copyOf(oldBytes, oldBytes.length);
}
}
@@ -54,7 +54,7 @@ public class Callbacks {
* @param resource The path to the resource to load.
* @return The loaded resource.
*/
@JSByRef
@JSByRef(optional = true)
@JSBody(params = "name", script = "return $javaCallbacks.getResource(name);")
public static native byte[] getResource(String resource);
@@ -6,17 +6,10 @@ package cc.tweaked.web.js;
import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.Nullable;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSByRef;
import org.teavm.jso.JSObject;
import org.teavm.jso.core.JSBoolean;
import org.teavm.jso.core.JSNumber;
import org.teavm.jso.core.JSObjects;
import org.teavm.jso.core.JSString;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.Int8Array;
import java.nio.ByteBuffer;
/**
* Utility methods for converting between Java and Javascript representations.
@@ -44,46 +37,9 @@ public class JavascriptConv {
*/
public static @Nullable Object toJava(@Nullable JSObject value) {
if (value == null) return null;
return switch (JSObjects.typeOf(value)) {
case "string" -> ((JSString) value).stringValue();
case "number" -> ((JSNumber) value).doubleValue();
case "boolean" -> ((JSBoolean) value).booleanValue();
default -> null;
};
}
/**
* Check if an arbitrary object is a {@link ArrayBuffer}.
*
* @param object The object ot check
* @return Whether this is an {@link ArrayBuffer}.
*/
@JSBody(params = "data", script = "return data instanceof ArrayBuffer;")
public static native boolean isArrayBuffer(JSObject object);
/**
* Wrap a JS {@link Int8Array} into a {@code byte[]}.
*
* @param view The array to wrap.
* @return The wrapped array.
*/
@JSByRef
@JSBody(params = "x", script = "return x;")
public static native byte[] asByteArray(Int8Array view);
/**
* Wrap a JS {@link ArrayBuffer} into a {@code byte[]}.
*
* @param view The array to wrap.
* @return The wrapped array.
*/
public static byte[] asByteArray(ArrayBuffer view) {
return asByteArray(new Int8Array(view));
}
public static Int8Array toArray(ByteBuffer buffer) {
var array = new Int8Array(buffer.remaining());
for (var i = 0; i < array.getLength(); i++) array.set(i, buffer.get(i));
return array;
if (value instanceof JSString v) return v.stringValue();
if (value instanceof JSNumber v) return v.doubleValue();
if (value instanceof JSBoolean v) return v.booleanValue();
return null;
}
}
@@ -5,7 +5,6 @@
package dan200.computercraft.core.apis.http.request;
import cc.tweaked.web.Main;
import cc.tweaked.web.js.JavascriptConv;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
@@ -21,6 +20,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.teavm.jso.ajax.XMLHttpRequest;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.Int8Array;
import java.net.URI;
import java.net.URISyntaxException;
@@ -102,7 +102,7 @@ public class THttpRequest extends Resource<THttpRequest> {
request.setRequestHeader(header.getKey(), header.getValue());
}
request.setRequestHeader("X-CC-Redirect", followRedirects ? "true" : "false");
request.send(postBuffer == null ? null : JavascriptConv.toArray(postBuffer));
request.send(postBuffer == null ? null : Int8Array.fromJavaBuffer(postBuffer));
checkClosed();
} catch (Exception e) {
failure("Could not connect");
@@ -118,7 +118,7 @@ public class THttpRequest extends Resource<THttpRequest> {
}
var buffer = (ArrayBuffer) request.getResponse();
SeekableByteChannel contents = new ArrayByteChannel(JavascriptConv.asByteArray(buffer));
SeekableByteChannel contents = new ArrayByteChannel(new Int8Array(buffer).copyToJavaArray());
var reader = new ReadHandle(contents, binary);
Map<String, String> responseHeaders = new HashMap<>();
@@ -5,7 +5,6 @@
package dan200.computercraft.core.apis.http.websocket;
import cc.tweaked.web.js.Console;
import cc.tweaked.web.js.JavascriptConv;
import com.google.common.base.Strings;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.Resource;
@@ -14,11 +13,13 @@ import dan200.computercraft.core.apis.http.options.Action;
import dan200.computercraft.core.apis.http.options.Options;
import io.netty.handler.codec.http.HttpHeaders;
import org.jspecify.annotations.Nullable;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.Int8Array;
import org.teavm.jso.websocket.WebSocket;
import java.net.URI;
import java.nio.ByteBuffer;
import java.util.Map;
/**
* Replaces {@link Websocket} with a version which uses Javascript's built-in {@link WebSocket} client.
@@ -49,10 +50,8 @@ public class TWebsocket extends Resource<TWebsocket> implements WebsocketClient
});
client.onMessage(e -> {
if (isClosed()) return;
if (JavascriptConv.isArrayBuffer(e.getData())) {
var array = new Int8Array(e.getDataAsArray());
var contents = new byte[array.getLength()];
for (var i = 0; i < contents.length; i++) contents[i] = array.get(i);
if (e.getData() instanceof ArrayBuffer buffer) {
var contents = new Int8Array(buffer).copyToJavaArray();
environment.queueEvent("websocket_message", address, contents, true);
} else {
environment.queueEvent("websocket_message", address, e.getDataAsString(), false);
@@ -70,7 +69,7 @@ public class TWebsocket extends Resource<TWebsocket> implements WebsocketClient
@Override
public void sendBinary(ByteBuffer message) {
if (websocket == null) return;
websocket.send(JavascriptConv.toArray(message));
websocket.send(Int8Array.fromJavaBuffer(message));
}
@Override
@@ -85,7 +84,7 @@ public class TWebsocket extends Resource<TWebsocket> implements WebsocketClient
private void success(Options options) {
if (isClosed()) return;
var handle = new WebsocketHandle(environment, address, this, options);
var handle = new WebsocketHandle(environment, address, this, Map.of(), options);
environment.queueEvent(SUCCESS_EVENT, address, handle);
createOwnerReference(handle);