Skip to content

Commit be29b85

Browse files
committed
Rework demobox creation workflow
1 parent 1584690 commit be29b85

10 files changed

Lines changed: 493 additions & 107 deletions

File tree

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,29 @@ allowing users to experience mods in survival mode without risking them breaking
77
To create a demo using demobox, first build a structure and store it with a structure block.
88
This structure will spawn in your demo worlds centered at 0, 0.
99

10-
Then you can run the `/demobox open <structure>` command to visit a demo world.
10+
As of 26.1, demos are first created using the `/demo create` command. This command opens a dialog letting your configure the demo.
11+
You can then use `/demo open` to open it, or `/demo edit` to modify it.
12+
13+
Editing demos requires the `demobox.edit` command. This should not be given to untrusted players,
14+
as it permits them to run arbitrary commands with gamemaster permissions. Opening demos requires the `demobox.open` permission.
15+
This one can generally be given to untrusted players unless you want to keep some demos inaccessible.
16+
17+
Demos can also be attached to signs using the `/demobox sign` command. It overwrites the content the targeted sign with a link to the demo.
18+
Using the command requires the `demobox.sign` permission, but interacting with the sign itself doesn't require anything.
19+
20+
All commands except `/demobox leave` default to gamemaster/level 2 permissions unless their specific nodes are set.
21+
22+
<details>
23+
<summary>Pre 26.1 instructions</summary>
24+
25+
Then you can run the `/demobox open <structure>` command to visit a demo world.
1126
You can leave using the `/demobox leave` command or by using the `/game` command from plasmid.
1227

1328
The open command requires a permission level of 2 or the `demobox.open` permission node
14-
because it can be used to create dimensions with arbitrary structures and that could cause problems.
29+
because it can be used to create dimensions with arbitrary structures and that could cause problems.
1530
The recommended way to give players access to demos is by using signs with click events.
1631

1732
The open command also accepts a position for where players spawn and a function to run during setup if you need to do something more advanced.
33+
34+
</details>
35+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package io.github.mattidragon.demobox;
2+
3+
import net.minecraft.resources.Identifier;
4+
5+
public class DemoBoxClickActions {
6+
public static final Identifier CREATE_UPDATE_DEMO = DemoBox.id("create_update_demo");
7+
}

src/main/java/io/github/mattidragon/demobox/DemoBoxCommand.java

Lines changed: 194 additions & 68 deletions
Large diffs are not rendered by default.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.github.mattidragon.demobox;
2+
3+
import com.mojang.serialization.Codec;
4+
import net.minecraft.resources.Identifier;
5+
import net.minecraft.util.Util;
6+
import net.minecraft.world.level.saveddata.SavedData;
7+
import net.minecraft.world.level.saveddata.SavedDataType;
8+
import org.jspecify.annotations.Nullable;
9+
10+
import java.util.Collections;
11+
import java.util.HashMap;
12+
import java.util.Map;
13+
14+
public class DemoBoxConfigs extends SavedData {
15+
private static final Codec<Map<Identifier, DemoConfig>> DATA_CODEC = Codec.unboundedMap(Identifier.CODEC, DemoConfig.CODEC.codec());
16+
public static final Codec<DemoBoxConfigs> CODEC = DATA_CODEC.xmap(
17+
data -> Util.make(new DemoBoxConfigs(), obj -> obj.configs.putAll(data)),
18+
DemoBoxConfigs::getConfigs
19+
);
20+
@SuppressWarnings("DataFlowIssue") // Fabric patches this to accept null
21+
public static final SavedDataType<DemoBoxConfigs> TYPE = new SavedDataType<>(DemoBox.id("configs"), DemoBoxConfigs::new, CODEC, null);
22+
23+
private final Map<Identifier, DemoConfig> configs = new HashMap<>();
24+
25+
public void createOrUpdateConfig(Identifier id, DemoConfig config) {
26+
configs.put(id, config);
27+
setDirty();
28+
}
29+
30+
public boolean removeConfig(Identifier id) {
31+
var old = configs.remove(id);
32+
setDirty();
33+
return old != null;
34+
}
35+
36+
public @Nullable DemoConfig getConfig(Identifier id) {
37+
return configs.get(id);
38+
}
39+
40+
public Map<Identifier, DemoConfig> getConfigs() {
41+
return Collections.unmodifiableMap(configs);
42+
}
43+
}

src/main/java/io/github/mattidragon/demobox/DemoBoxGame.java

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package io.github.mattidragon.demobox;
22

3-
import com.mojang.serialization.MapCodec;
4-
import com.mojang.serialization.codecs.RecordCodecBuilder;
53
import net.minecraft.ChatFormatting;
64
import net.minecraft.commands.CommandSourceStack;
5+
import net.minecraft.commands.functions.CommandFunction;
76
import net.minecraft.core.BlockPos;
87
import net.minecraft.core.Holder;
98
import net.minecraft.core.HolderSet;
109
import net.minecraft.core.RegistryAccess;
1110
import net.minecraft.network.chat.Component;
12-
import net.minecraft.resources.Identifier;
1311
import net.minecraft.server.level.ServerLevel;
1412
import net.minecraft.server.level.ServerPlayer;
1513
import net.minecraft.server.permissions.LevelBasedPermissionSet;
@@ -43,27 +41,27 @@
4341
import java.util.concurrent.CompletableFuture;
4442

4543
public class DemoBoxGame {
46-
public static final GameType<Settings> TYPE = GameTypes.register(DemoBox.id("demo_box"), Settings.CODEC, DemoBoxGame::open);
44+
public static final GameType<DemoConfig> TYPE = GameTypes.register(DemoBox.id("demo_box"), DemoConfig.CODEC, DemoBoxGame::open);
4745

4846
private final ServerLevel world;
4947
private final GameSpace gameSpace;
50-
private final Settings settings;
48+
private final DemoConfig config;
5149

52-
public DemoBoxGame(ServerLevel world, GameSpace gameSpace, Settings settings) {
50+
public DemoBoxGame(ServerLevel world, GameSpace gameSpace, DemoConfig config) {
5351
this.world = world;
5452
this.gameSpace = gameSpace;
55-
this.settings = settings;
53+
this.config = config;
5654
}
5755

5856
public static void register() {
5957
}
6058

61-
public static CompletableFuture<GameSpace> open(Settings settings) {
62-
var config = new GameConfig<>(TYPE, null, null, null, null, CustomValuesConfig.empty(), settings);
59+
public static CompletableFuture<GameSpace> open(DemoConfig demoConfig) {
60+
var config = new GameConfig<>(TYPE, null, null, null, null, CustomValuesConfig.empty(), demoConfig);
6361
return GameSpaceManager.get().open(Holder.direct(config));
6462
}
6563

66-
private static GameOpenProcedure open(GameOpenContext<Settings> context) {
64+
private static GameOpenProcedure open(GameOpenContext<DemoConfig> context) {
6765
return context.openWithLevel(createLevelConfig(context.server().registryAccess()), (activity, world) -> {
6866
var instance = new DemoBoxGame(world, activity.getGameSpace(), context.config());
6967
instance.setup();
@@ -83,17 +81,17 @@ private static GameOpenProcedure open(GameOpenContext<Settings> context) {
8381

8482
private void setup() {
8583
world.getStructureManager()
86-
.get(settings.structureId)
84+
.get(config.structure())
8785
.ifPresent(template -> {
8886
var size = template.getSize();
8987
var pos = new BlockPos(size.getX() / -2, 1, size.getZ() / -2);
9088
template.placeInWorld(world, pos, pos, new StructurePlaceSettings(), world.getRandom(), 0);
9189
});
92-
executeFunctions(settings.functions, null);
90+
executeCommands(config.setupCommands(), null);
9391
}
9492

9593
private void onPlayerLeave(ServerPlayer player) {
96-
if (gameSpace.getPlayers().stream().allMatch(player2 -> player2 != player)) {
94+
if (gameSpace.getPlayers().stream().allMatch(player2 -> player2 == player)) {
9795
gameSpace.close(GameCloseReason.FINISHED);
9896
}
9997
}
@@ -103,7 +101,7 @@ private void onPlayerJoin(ServerPlayer player) {
103101
player.sendSystemMessage(Component.translatable("demobox.info.2").withStyle(ChatFormatting.WHITE));
104102
player.sendSystemMessage(Component.translatable("demobox.info.3").withStyle(ChatFormatting.WHITE));
105103
player.sendSystemMessage(Component.translatable("demobox.info.4").withStyle(ChatFormatting.WHITE));
106-
executeFunctions(settings.playerFunctions, player);
104+
executeCommands(config.joinCommands(), player);
107105
}
108106

109107
private Component onJoinMessage(ServerPlayer player, @Nullable Component currentText, Component defaultText) {
@@ -119,21 +117,16 @@ private JoinOfferResult onPlayerOffered(JoinOffer offer) {
119117
}
120118

121119
private JoinAcceptorResult onPlayerAccepted(JoinAcceptor joinAcceptor) {
122-
return joinAcceptor.teleport(world, settings.playerPos);
120+
return joinAcceptor.teleport(world, config.spawnPos());
123121
}
124122

125-
private void executeFunctions(List<Identifier> functions, Entity entity) {
123+
private void executeCommands(String commands, Entity entity) {
126124
var server = world.getServer();
127-
var manager = server.getFunctions();
128-
for (var id : functions) {
129-
manager.get(id).ifPresentOrElse(
130-
function -> manager.execute(
131-
function,
132-
new CommandSourceStack(server, Vec3.ZERO, Vec2.ZERO, world, LevelBasedPermissionSet.GAMEMASTER, "DemoBox Setup", Component.literal("DemoBox Setup"), server, entity).withSuppressedOutput()
133-
),
134-
() -> DemoBox.LOGGER.warn("Missing function: {}", id)
135-
);
136-
}
125+
var source = new CommandSourceStack(server, Vec3.ZERO, Vec2.ZERO, world, LevelBasedPermissionSet.GAMEMASTER, "DemoBox Setup", Component.literal("DemoBox Setup"), server, entity).withSuppressedOutput();
126+
var dispatcher = source.dispatcher();
127+
128+
var function = CommandFunction.fromLines(DemoBox.id("/virtual_setup_function"), dispatcher, source, commands.lines().toList());
129+
server.getFunctions().execute(function, source);
137130
}
138131

139132
@NotNull
@@ -152,14 +145,4 @@ private static RuntimeLevelConfig createLevelConfig(RegistryAccess registryManag
152145
worldConfig.setSeed(1);
153146
return worldConfig;
154147
}
155-
156-
public record Settings(Identifier structureId, Vec3 playerPos, List<Identifier> functions,
157-
List<Identifier> playerFunctions) {
158-
public static final MapCodec<Settings> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
159-
Identifier.CODEC.fieldOf("structureId").forGetter(Settings::structureId),
160-
Vec3.CODEC.fieldOf("playerPos").forGetter(Settings::playerPos),
161-
Identifier.CODEC.listOf().fieldOf("functions").forGetter(Settings::functions),
162-
Identifier.CODEC.listOf().fieldOf("playerFunctions").forGetter(Settings::playerFunctions)
163-
).apply(instance, Settings::new));
164-
}
165148
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.github.mattidragon.demobox;
2+
3+
import com.mojang.brigadier.StringReader;
4+
import com.mojang.brigadier.exceptions.CommandSyntaxException;
5+
import com.mojang.serialization.Codec;
6+
import com.mojang.serialization.DataResult;
7+
import com.mojang.serialization.MapCodec;
8+
import com.mojang.serialization.codecs.RecordCodecBuilder;
9+
import net.minecraft.commands.arguments.coordinates.WorldCoordinates;
10+
import net.minecraft.resources.Identifier;
11+
import net.minecraft.world.phys.Vec3;
12+
13+
public record DemoConfig(Identifier structure, Vec3 spawnPos, String setupCommands, String joinCommands) {
14+
public static final MapCodec<DemoConfig> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
15+
Identifier.CODEC.fieldOf("structure").forGetter(DemoConfig::structure),
16+
Vec3.CODEC.fieldOf("spawn_pos").forGetter(DemoConfig::spawnPos),
17+
Codec.STRING.fieldOf("setup_commands").forGetter(DemoConfig::setupCommands),
18+
Codec.STRING.fieldOf("join_commands").forGetter(DemoConfig::joinCommands)
19+
).apply(i, DemoConfig::new));
20+
21+
private static final Codec<Vec3> VEC3_STRING_CODEC = Codec.STRING.comapFlatMap(s -> {
22+
var reader = new StringReader(s);
23+
WorldCoordinates worldCoordinates;
24+
try {
25+
worldCoordinates = WorldCoordinates.parseDouble(reader, true);
26+
} catch (CommandSyntaxException e) {
27+
return DataResult.error(() -> "Failed to decode coordinates: " + e.getMessage());
28+
}
29+
while (reader.canRead()) {
30+
if (reader.read() != ' ') {
31+
return DataResult.error(() -> "Unexpected character while parsing coordinates: '" + (char) reader.getCursor() + "'");
32+
}
33+
}
34+
35+
return DataResult.success(new Vec3(
36+
worldCoordinates.x().get(0.5),
37+
worldCoordinates.y().get(2),
38+
worldCoordinates.z().get(0.5)
39+
));
40+
}, vec -> "%s %s %s".formatted(vec.x, vec.y, vec.z));
41+
42+
public static final Codec<DemoConfig> DIALOG_CODEC = RecordCodecBuilder.create(i -> i.group(
43+
Identifier.CODEC.fieldOf("structure").forGetter(DemoConfig::structure),
44+
VEC3_STRING_CODEC.fieldOf("spawn_pos").forGetter(DemoConfig::spawnPos),
45+
Codec.STRING.fieldOf("setup_commands").forGetter(DemoConfig::setupCommands),
46+
Codec.STRING.fieldOf("join_commands").forGetter(DemoConfig::joinCommands)
47+
).apply(i, DemoConfig::new));
48+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.github.mattidragon.demobox.mixin;
2+
3+
import com.mojang.datafixers.util.Pair;
4+
import com.mojang.serialization.DataResult;
5+
import io.github.mattidragon.demobox.DemoBox;
6+
import io.github.mattidragon.demobox.DemoBoxClickActions;
7+
import io.github.mattidragon.demobox.DemoBoxConfigs;
8+
import io.github.mattidragon.demobox.DemoConfig;
9+
import net.minecraft.core.UUIDUtil;
10+
import net.minecraft.nbt.NbtOps;
11+
import net.minecraft.nbt.Tag;
12+
import net.minecraft.network.chat.Component;
13+
import net.minecraft.resources.Identifier;
14+
import net.minecraft.server.MinecraftServer;
15+
import net.minecraft.server.players.PlayerList;
16+
import net.minecraft.world.level.storage.SavedDataStorage;
17+
import org.spongepowered.asm.mixin.Mixin;
18+
import org.spongepowered.asm.mixin.Shadow;
19+
import org.spongepowered.asm.mixin.Unique;
20+
import org.spongepowered.asm.mixin.injection.At;
21+
import org.spongepowered.asm.mixin.injection.Inject;
22+
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
23+
24+
import java.util.Optional;
25+
import java.util.UUID;
26+
27+
@Mixin(MinecraftServer.class)
28+
public abstract class MinecraftServerMixin {
29+
@Shadow
30+
public abstract SavedDataStorage getDataStorage();
31+
32+
@Shadow
33+
public abstract PlayerList getPlayerList();
34+
35+
@Inject(method = "handleCustomClickAction", at = @At("HEAD"))
36+
private void onHandleCustomClickAction(Identifier id, Optional<Tag> payload, CallbackInfo ci) {
37+
if (id.equals(DemoBoxClickActions.CREATE_UPDATE_DEMO)) {
38+
handleCreateUpdate(payload);
39+
}
40+
}
41+
42+
@Unique
43+
private void handleCreateUpdate(Optional<Tag> payload) {
44+
if (payload.isEmpty()) {
45+
DemoBox.LOGGER.error("Received create/update demo click action with no payload");
46+
return;
47+
}
48+
var tag = payload.get();
49+
var ops = NbtOps.INSTANCE;
50+
51+
var playerResult = UUIDUtil.CODEC.fieldOf("player").codec().decode(ops, tag).map(Pair::getFirst);
52+
var idResult = Identifier.CODEC.fieldOf("id").codec().decode(ops, tag).map(Pair::getFirst);
53+
var configResult = DemoConfig.DIALOG_CODEC.decode(ops, tag).map(Pair::getFirst);
54+
55+
if (playerResult instanceof DataResult.Error<UUID> error) {
56+
DemoBox.LOGGER.error("Failed to decode player UUID from click action payload: {}", error.message());
57+
return;
58+
}
59+
if (idResult instanceof DataResult.Error<Identifier> error) {
60+
DemoBox.LOGGER.error("Failed to decode demo config ID from click action payload: {}", error.message());
61+
return;
62+
}
63+
if (configResult instanceof DataResult.Error<DemoConfig> error) {
64+
DemoBox.LOGGER.error("Failed to decode demo config from click action payload: {}", error.message());
65+
playerResult.result().map(getPlayerList()::getPlayer)
66+
.ifPresent(player -> player.sendSystemMessage(Component.translatable("demobox.config.invalid", error.message())));
67+
return;
68+
}
69+
70+
getDataStorage().computeIfAbsent(DemoBoxConfigs.TYPE)
71+
.createOrUpdateConfig(idResult.getOrThrow(), configResult.getOrThrow());
72+
}
73+
}

0 commit comments

Comments
 (0)