Skip to content

This file was deleted.

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

import com.github.minecraft_ta.totalDebugCompanion.util.FileReading;
import com.github.minecraft_ta.totalDebugCompanion.util.Signal;
import com.github.minecraft_ta.totalDebugCompanion.change.ChangeCategory;
import com.github.minecraft_ta.totalDebugCompanion.change.ChangePipeline;
Expand Down Expand Up @@ -29,33 +30,34 @@
* in {@code options.txt}, which the game reads when it starts. Writing that file while the game runs would be undone the
* next time the game saves its options, so a game running without a connection is asked to connect first.
*/
public final class KeyBindingControl implements ChangeCategory<ChangeRecord.KeyBinding, String> {
public final class KeyBindingControl implements ChangeCategory<ChangeRecord.KeyBinding, String>, AutoCloseable {
/** A change to make: the binding {@code name}, the key it had when the change was made, and its new key. */
public record Change(String name, KeyBindings.Assignment shown, KeyBindings.Assignment assignment) {
}

private final ChangePipeline pipeline;
private final Path options;
private final KeyAssignments assignments;
/** The keys {@code options.txt} assigns, whoever writes the file: the game, Companion or an editor. */
private final FileReading<Map<String, KeyBindings.Assignment>> assignments;

/**
* Changes the keys of the pipeline's game, in its {@code options.txt}, whose keys {@code assignments} follows, whoever
* writes the file.
*/
public KeyBindingControl(ChangePipeline pipeline, KeyAssignments assignments) {
/** Changes the keys of the pipeline's game, in its {@code options.txt}. */
public KeyBindingControl(ChangePipeline pipeline) {
this.pipeline = Objects.requireNonNull(pipeline, "pipeline");
this.options = pipeline.location().workspace().resolve("options.txt");
this.assignments = Objects.requireNonNull(assignments, "assignments");
this.assignments = new FileReading<>(() -> KeyBindings.readOptions(this.options));
}

/** Fires when the keys {@code options.txt} assigns changed, such as a key rebound in the game's controls screen. */
/**
* Fires when the keys {@code options.txt} assigns changed, as read after Companion changed them or when the user came
* back to Companion, such as from the game's controls screen; another option written, such as the volume, tells nobody.
*/
public Signal assignmentsChanged() {
return this.assignments.changed();
}

/** The keys {@code options.txt} assigns now ({@link KeyAssignments#assignments()}). Blocking. */
/** The keys {@code options.txt} assigns, as read last; read now where none were read yet. Blocking then. */
public Map<String, KeyBindings.Assignment> assignments() throws IOException {
return this.assignments.assignments();
return this.assignments.value();
}

/** The change record the bindings' changes are entered in. */
Expand All @@ -75,8 +77,8 @@ public Path options() {
public CompletableFuture<String> set(List<Change> changes) {
List<ChangePipeline.Edit<ChangeRecord.KeyBinding, String>> edits = changes.stream().map(change -> new ChangePipeline.Edit<>(
new ChangeRecord.KeyBinding(change.name()), change.shown().encode(), change.assignment().encode())).toList();
// The game saved options.txt before it answered, or Companion wrote it: the owner of the keys reads what changed.
return this.pipeline.change(this, edits).whenComplete((done, failure) -> this.assignments.readNow())
// The game saved options.txt before it answered, or Companion wrote it: the keys are read again.
return this.pipeline.change(this, edits).whenComplete((done, failure) -> this.assignments.refresh())
.handle((done, failure) -> failure == null ? "" : message(failure));
}

Expand Down Expand Up @@ -144,4 +146,9 @@ public void writeFile(List<Write<ChangeRecord.KeyBinding, String>> writes, Consu
Files.write(this.options, lines, StandardCharsets.UTF_8);
writes.forEach(write -> landed.accept(write.target()));
}

@Override
public void close() {
this.assignments.close();
}
}
Original file line number Diff line number Diff line change
@@ -1,183 +1,95 @@
package com.github.minecraft_ta.totalDebugCompanion.catalog;

import java.util.Optional;
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.game.GameState;
import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks;
import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord;
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.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.Optional;

/**
* 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.
* (docs/SYSTEMS.md, section 2). The World page, the Project tree and the World tab's title show what it read; none reads
* the world itself. It is read again when the game connects, plays another world or leaves one, after Companion changed
* the datapacks, and when the user comes back to Companion, as from a game that saved the world meanwhile. It is first
* read as it is made, so the World tab names the world also while its page was never shown.
*/
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) {
/**
* The current world: its folder, what its level.dat holds and when its icon was written, so a new icon is a change too;
* without what it holds where it could not be read, and all null where no world was played yet.
*/
public record World(Path directory, CurrentWorld.Saved saved, FileTime icon) {
}

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;
private final FileReading<World> reading;
private final List<Runnable> stopFollowing;

/**
* Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. Reads which
* world that is now. Blocking.
*/
/** Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. */
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));
Runnable stopConnection = location.connectionChanged().subscribe(() -> this.strand.execute(this::follow));
Runnable stopPlaying = location.playingChanged().subscribe(() -> this.strand.execute(this::follow));
this.stopFollowingGame = () -> {
stopConnection.run();
stopPlaying.run();
};
// 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();
Objects.requireNonNull(location, "location");
this.reading = new FileReading<>(() -> read(location.read()));

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 Discard world reads overtaken by a location change

When playingChanged() queues another refresh while CurrentWorld.read is still parsing the previous world, that first invocation has already captured the old GameState, and FileReading publishes its result before processing the queued refresh. WorldPanel can consequently display the old world and its datapack applier uses that stale saved directory, allowing an edit intended for the newly entered world to be written to the previous one while the newer read is pending. Preserve a request generation in WorldReading or otherwise prevent an overtaken world-identity read from publishing.

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

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a1fa4e6, in FileReading rather than with a counter in WorldReading: a read under way when a refresh is asked for publishes nothing, and the read it waits for publishes, the newest-wins rule the page loader already follows. The world the game left is never published after the request for the new one. Test: aReadOvertakenByARequestMadeWhileItRanPublishesNothing, which fails without it (published Second, Third).

this.stopFollowing = List.of(location.connectionChanged().subscribe(this.reading::refresh),
location.playingChanged().subscribe(this.reading::refresh),
// Also in the level.dat of a world the game does not hold.
packs.changed(ChangeRecord.PackSide.DATA).subscribe(this.reading::refresh));
Comment on lines +40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid refreshing through both world-change signals

Whenever playingChanged() fires, the GamePacks listener registered earlier in project construction synchronously fires its DATA signal, which queues one refresh through packs.changed(...); the direct playingChanged() subscription here then queues another. FileReading serializes but does not coalesce these requests, so every transition between worlds parses level.dat twice and can drive redundant downstream page loads. Follow one of these equivalent transition notifications or coalesce refresh requests in the owner.

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

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 58edf96, in FileReading rather than by choosing a signal: refresh requests made while one waits merge into it, as PageLoader's do. A world change told by both playing and the datapacks reads level.dat once. The test fails without it (5 reads instead of 3).

this.reading.refresh();
}

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

/** The name of the current world as read last, without reading; empty where none was read yet. */
public Optional<String> publishedName() {
Followed now = this.followed;
if (now.reading() == null) return Optional.empty();
return now.reading().published().map(read -> read.saved().name());
return this.reading.published().map(World::saved).map(CurrentWorld.Saved::name);
}

/** 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());
}
/**
* The current world as read last; read now where it was not read yet, which fails where it cannot be read. A read
* that fails later, as while the game writes level.dat, keeps the world read before. Blocking then.
*/
public World value() throws IOException {
return this.reading.value();
}

/** 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);
// Told once the new world's first read landed, so its followers find it published, as the tab its name.
Workers.files().execute(() -> {
try {
reading.value();
} catch (IOException unreadable) {
// The World page shows why.
}
this.changed.fire();
});
return;
/**
* Reads the current world. A world read before that fails to read now, as while the game writes its level.dat, fails,
* so the world read before stays; another world that fails to read is published without what it holds, so nothing
* shows or changes the world before as if it were current.
*/
private World read(GameState game) throws IOException {
Path directory = CurrentWorld.directory(game).orElse(null);
if (directory == null) return new World(null, null, null);
try {
return new World(directory, CurrentWorld.read(game, directory), iconWritten(directory));
} catch (IOException unreadable) {
if (this.reading.published().map(World::directory).filter(directory::equals).isPresent()) throw unreadable;
return new World(directory, null, null);
}
if (before.directory() != 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 Files.getLastModifiedTime(world.resolve("icon.png"));
} catch (IOException none) {
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.stopFollowing.forEach(Runnable::run);
this.reading.close();
}
}
Loading