Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.github.minecraft_ta.totalDebugCompanion.catalog;

import java.util.concurrent.CompletableFuture;
import java.nio.file.attribute.FileTime;
import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch;
import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord;
import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks;
import com.github.minecraft_ta.totalDebugCompanion.game.GameState;
import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation;
import com.github.minecraft_ta.totalDebugCompanion.util.FileReading;
import com.github.minecraft_ta.totalDebugCompanion.util.Signal;
import com.github.minecraft_ta.totalDebugCompanion.util.Strand;
import com.github.minecraft_ta.totalDebugCompanion.util.Workers;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;

/**
* The instance's current world: the one the game has open, otherwise the one played last, read from its folder
* (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself.
* Which world is current is decided on its strand, again when the game connects, plays another world or leaves one, and
* when a world of {@code saves} changes, as when a game Companion is not connected to opens or saves one. The current
* world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the connected
* game plays it, which the game tells only once it let go of the world: on Windows a watched folder cannot be deleted or
* renamed, as by the game's Delete World.
*/
public final class WorldReading implements AutoCloseable {
/** The entries of a world's folder that make up what is read of it. */
private static final long SETTLE_MILLIS = 500;
private static final Set<String> ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks", "icon.png");

/** The current world: its folder and what its level.dat holds, or why there is nothing to show. */
public record World(Path directory, CurrentWorld.Saved saved, String problem) {
}

/** What is read of a world's folder: its level.dat, and when its icon was written, so a new icon is a change too. */
private record Read(CurrentWorld.Saved saved, FileTime icon) {
}

/** The world followed and its reading, published together. */
private record Followed(Path directory, FileReading<Read> reading) {
}

private final GameLocation location;
private final Signal changed = new Signal();
private final Runnable stopFollowingSaves;
private final Runnable stopFollowingDatapacks;
private final Strand strand = Workers.strand();
private final Runnable stopFollowingGame;
private volatile Followed followed = new Followed(null, null);
// Changed on the strand only.
private Runnable stopReading = () -> { };
/** The played world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */
private Path datapacksOf;
private Runnable stopDatapacks = () -> { };
private boolean closed;

/**
* Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. Reads which
* world that is now. Blocking.
*/
public WorldReading(GameLocation location, GamePacks packs) {
this.location = Objects.requireNonNull(location, "location");
// A game opening a world creates its session lock before it takes it: which world is open is looked at after that.
this.stopFollowingSaves = FileWatch.shared().watch(location.workspace().resolve("saves"), world -> true,
() -> Workers.later(SETTLE_MILLIS, this.strand, this::follow));
// Companion changed the datapacks, also in the level.dat of a world the game does not hold.
this.stopFollowingDatapacks = packs.changed(ChangeRecord.PackSide.DATA).subscribe(() -> this.strand.execute(this::follow));
this.stopFollowingGame = location.addListener(change -> this.strand.execute(this::follow));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-evaluate the directory when an unconnected game changes worlds

When Companion remains open while an unconnected game moves from world A to world B, GameLocation emits no listener event because that state is derived from filesystem locks in read(), so this callback never invokes follow() for B. Events from A only reread A, leaving the World page and tree on the wrong world until a later connection event; the owner needs a filesystem-owned trigger that reruns current-directory selection.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

// Followed after the triggers are, so none is missed in between; on the strand, as every later look.
CompletableFuture.runAsync(this::follow, this.strand).join();
}

/** Fires after the current world changed or what it holds did. */
public Signal changed() {
return this.changed;
}

/** The current world as read last; read now where it was not read yet. Blocking then. */
public World value() {
Followed now = this.followed;
if (now.directory() == null) return new World(null, null, "No world has been played in this instance yet.");
try {
return new World(now.directory(), now.reading().value().saved(), "");
} catch (IOException unreadable) {
return new World(now.directory(), null,
"The world " + now.directory().getFileName() + " could not be read: " + unreadable.getMessage());
}
}

/** Finds which world is current now and follows it; the one followed so far is read again, as it may have opened. */
private void follow() {
if (this.closed) return;
GameState game = this.location.read();
Path current = CurrentWorld.directory(game).orElse(null);
Followed before = this.followed;
boolean played = current != null && game.plays(current);
if (Objects.equals(current, before.directory())) {
if (before.reading() != null) {
before.reading().watch(played);
watchDatapacks(played ? current : null, before.reading());
before.reading().readNow();
}
return;
}
this.stopReading.run();
if (current == null) {
watchDatapacks(null, null);
this.stopReading = () -> { };
this.followed = new Followed(null, null);
} else {
FileReading<Read> reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()),
world -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS));
reading.watch(played);
watchDatapacks(played ? current : null, reading);
Runnable stopTelling = reading.changed().subscribe(this.changed::fire);
this.stopReading = () -> {
stopTelling.run();
reading.close();
};
this.followed = new Followed(current, reading);
}
if (before.directory() != null || current != null) this.changed.fire();
}

/** Watches the datapacks folder of {@code world}, which the game plays, or none; a change there reads it again. */
private void watchDatapacks(Path world, FileReading<Read> reading) {
if (Objects.equals(world, this.datapacksOf)) return;
this.stopDatapacks.run();
this.stopDatapacks = () -> { };
this.datapacksOf = world;
if (world != null) this.stopDatapacks = FileWatch.shared().watch(world.resolve("datapacks"), entry -> true, reading::readNow);
}

/** When the world's icon was written, or null without one. */
private static FileTime iconWritten(Path world) {
try {
Path icon = world.resolve("icon.png");
return Files.isRegularFile(icon) ? Files.getLastModifiedTime(icon) : null;
} catch (IOException unreadable) {
return null;
}
}

@Override
public void close() {
this.stopFollowingGame.run();
this.stopFollowingSaves.run();
this.stopFollowingDatapacks.run();
this.strand.execute(() -> {
this.closed = true;
this.stopDatapacks.run();
this.stopReading.run();
});
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.minecraft_ta.totalDebugCompanion.project;

import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading;
import com.github.minecraft_ta.totaldebug.protocol.scnet.RelayFailedMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.PlayingMessage;
import com.github.minecraft_ta.totaldebug.protocol.scnet.DatapacksMessage;
Expand All @@ -11,7 +12,6 @@
import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigLabels;
import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigSettings;
import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingLabels;
import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReadings;
import com.github.minecraft_ta.totalDebugCompanion.catalog.ConfigChanges;
import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyAssignments;
import com.github.minecraft_ta.totalDebugCompanion.catalog.KeyBindingControl;
Expand Down Expand Up @@ -90,9 +90,9 @@ public List<ChangeLabels> changeLabels() {
/** The packs the game uses, as it names them or its files enable them. */
public GamePacks packs() { return packs; }
private final ResourceEdits resources;
private final WorldReadings world = new WorldReadings();
/** What was last read of the current world, which the World page and the Project tree follow. */
public WorldReadings world() { return world; }
private final WorldReading world;
/** The current world, which the World page and the Project tree show. */
public WorldReading world() { return world; }
/** Writes edited resources into the packs Companion manages and reloads them in the running game. */
public ResourceEdits resources() { return resources; }
private final PackSelections packSelections;
Expand All @@ -118,6 +118,7 @@ public ProjectScope(Object lock, CompanionProfile profile, InstanceState state,
this.keyAssignments = new KeyAssignments(profile.workspaceDirectory().resolve("options.txt"));
this.keyBindings = new KeyBindingControl(this.pipeline, this.keyAssignments);
this.packs = new GamePacks(this.location);
this.world = new WorldReading(this.location, this.packs);
this.resources = new ResourceEdits(this.pipeline, this.packs, new ResourceOriginals(paths().originals()),
this.configChanges.writes(), state);
this.packSelections = new PackSelections(this.resources);
Expand Down Expand Up @@ -226,7 +227,7 @@ public List<PendingNavigation> drainNavigations() {
pending.clear();
}
// Writes still queued finish first, so each is recorded before the change record closes.
try { keyAssignments.close(); resources.close(); configChanges.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } }
try { keyAssignments.close(); world.close(); resources.close(); configChanges.close(); closeRuntime(); } finally { try { state.close(); } finally { changes.close(); } }
}

public String loadBreakpointScript(String name) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.github.minecraft_ta.totalDebugCompanion.ui.components.catalog;

import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReading;
import com.github.minecraft_ta.totalDebugCompanion.Icons;
import com.github.minecraft_ta.totalDebugCompanion.catalog.WorldReadings;
import com.github.minecraft_ta.totalDebugCompanion.catalog.CurrentWorld;
import com.github.minecraft_ta.totalDebugCompanion.game.GameState;
import com.github.minecraft_ta.totalDebugCompanion.catalog.PackCatalogService;
Expand Down Expand Up @@ -89,7 +89,7 @@ record ServerWorld(String address, Path world) {

private final PackCatalogService catalog;
private final ItemIconService icons;
private final WorldReadings readings;
private final WorldReading world;
private final PageLoader<Loaded> loader;
private final Runnable removeCatalogListener;
private final SubjectHeader header = new SubjectHeader();
Expand All @@ -112,14 +112,11 @@ record ServerWorld(String address, Path world) {
private WorldTab requested;
private boolean disposed;

/**
* Shows the current world of the game {@code edits} tells of; each read is recorded in {@code readings}, which the
* Project tree follows.
*/
public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReadings readings,
/** Shows {@code world}, the current world, or the world of the server the game {@code edits} tells of plays on. */
public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReading world,
ResourceEdits edits, PackSelections selections, Consumer<NavigationTarget> navigator) {
super(new BorderLayout());
this.readings = Objects.requireNonNull(readings, "readings");
this.world = Objects.requireNonNull(world, "world");
this.catalog = Objects.requireNonNull(catalog, "catalog");
this.icons = Objects.requireNonNull(icons, "icons");
this.datapacks = new PacksPanel(PacksPanel.Side.DATA, Objects.requireNonNull(navigator, "navigator"));
Expand Down Expand Up @@ -153,28 +150,23 @@ public WorldPanel(PackCatalogService catalog, ItemIconService icons, WorldReadin
this.removeCatalogListener = ShownUpdates.follow(this, catalog.changed()::subscribe, () -> {
if (!this.disposed && (this.saved != null || this.server != null)) this.datapacks.setPacks(this.datapackList, this.catalog.index().orElse(null));
});
// The game saves the world while it runs, so the page reads it whenever it is shown. A change of the datapacks,
// which also comes with another world or a disconnect, is read at once, shown or not: the tab names the world or
// server it reads.
// The world's owner reads it when the game saves it or plays another; the datapacks the connected game names
// come with its packs.
this.loader = new PageLoader<>(() -> {
PackStackPayload stack = edits.packs().datapacks();
String refusal = edits.packs().worldRefusal();
return () -> read(edits.location().read(), stack, refusal);
return () -> read(edits.location().read(), this.world, stack, refusal);
}, this::show, failure -> show(Loaded.problem("The world could not be read: " + failure.getMessage())))
.readsWhenShown(this).follow(edits.packs().changed(ChangeRecord.PackSide.DATA)::subscribe);
.page(this).follows(world.changed()).follows(edits.packs().changed(ChangeRecord.PackSide.DATA));
}

private static Loaded read(GameState game, PackStackPayload stack, String refusal) {
private static Loaded read(GameState game, WorldReading world, PackStackPayload stack, String refusal) throws IOException {
Optional<PlayingPayload.Multiplayer> server = game.server();
if (server.isPresent()) return readServer(game, server.get(), stack, refusal);
Optional<Path> world = CurrentWorld.directory(game);
if (world.isEmpty()) return Loaded.problem("No world has been played in this instance yet.");
try {
CurrentWorld.Saved saved = CurrentWorld.read(game, world.get());
return new Loaded(saved, null, PackResources.worldDatapacks(stack, saved), icon(world.get().resolve("icon.png")), "");
} catch (IOException | RuntimeException unreadable) {
return Loaded.problem("The world " + world.get().getFileName() + " could not be read: " + unreadable.getMessage());
}
WorldReading.World current = world.value();
if (current.saved() == null) return Loaded.problem(current.problem());
CurrentWorld.Saved saved = current.saved();
return new Loaded(saved, null, PackResources.worldDatapacks(stack, saved), icon(current.directory().resolve("icon.png")), "");
}

/**
Expand Down Expand Up @@ -208,8 +200,6 @@ private void show(Loaded loaded) {
// Changes staged for another world, such as the server played before, would otherwise be applied to this one.
if (!Objects.equals(world, this.shownWorld)) this.datapacks.discardChanges();
this.shownWorld = world;
this.readings.read(this.server != null ? new WorldReadings.Summary(this.server.world(), 0, this.datapackList.size())
: WorldReadings.Summary.of(this.saved));
if (this.server != null) {
showServer(this.server);
return;
Expand Down Expand Up @@ -349,6 +339,11 @@ private static JScrollPane scroll(JComponent component) {
return scroll;
}

/** How many times the page read the world, which tests count. */
public int reads() {
return this.loader.reads();
}

public void dispose() {
this.disposed = true;
this.loader.dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,12 @@ public List<TreeItem> loadChildren() {
this.tree.refreshRootNodes(roots);
} else {
this.tree.setRootNodes(roots);
// The World rows follow what the World page read last, and the game to another world.
// The World rows follow the current world as its owner read it, and the game to another world.
this.removeWorldListener.run();
Runnable refresh = () -> SwingUtilities.invokeLater(() -> {
if (!this.disposed && project.get() == scope) this.tree.refreshRoot(WorldTreeItems.ROOT);
});
Runnable removeRead = scope.world().addListener(refresh);
Runnable removeRead = scope.world().changed().subscribe(refresh);
// What the game plays also decides whether there is a World root at all.
Runnable removePlayed = scope.location().addListener(change -> {
if (change != GameLocation.Change.PLAYING) return;
Expand Down
Loading