feat(events): record native recipe discovery
Release / release (push) Successful in 14m50s

This commit is contained in:
dmg committed 2026-09-25 10:07:18 -04:00
1 parent 13828054e5
commit 3f104cb0e2
6 files changed
+334 -7

No files matched your search

@@ -831,6 +831,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
if (eventLog != null) {
var recipeEvents = new games.dmg.fantasystick.events.RecipeDiscoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
recipeDiscovery.discoverAll(onlinePlayers);
@@ -1170,6 +1178,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
@@ -11,37 +11,50 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
public final class RecipeDiscovery implements Listener {
public final class RecipeDiscovery implements Listener, AutoCloseable {
private final NamespacedKey recipe;
private final Logger logger;
private games.dmg.fantasystick.events.RecipeDiscoveryEventBridge events;
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
this.recipe = Objects.requireNonNull(recipe, "recipe");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void attachEvents(games.dmg.fantasystick.events.RecipeDiscoveryEventBridge bridge) {
events = Objects.requireNonNull(bridge);
}
public void discoverAll(Iterable<? extends Player> players) {
for (var player : players) discover(player);
for (var player : players) discover(player, "enable_online");
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
discover(event.getPlayer(), "join");
}
public void discover(Player player) {
public void discover(Player player) { discover(player, "join"); }
private void discover(Player player, String trigger) {
Objects.requireNonNull(player, "player");
try {
if (player.hasDiscoveredRecipe(recipe)) return;
player.discoverRecipes(List.of(recipe));
int notifications = player.discoverRecipes(List.of(recipe));
if (!player.hasDiscoveredRecipe(recipe)) {
logger.warning("Could not discover character-sheet recipe for " + label(player));
}
if (events != null) events.failed(player, trigger, "native_unavailable");
} else if (events != null) events.discovered(player, trigger, notifications);
} catch (RuntimeException failure) {
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
if (events != null) events.failed(player, trigger, "native_failed");
}
}
@Override public void close() {
if (events != null) events.close();
}
private static String label(Player player) {
try { return player.getName(); }
catch (RuntimeException ignored) { return "an unidentified player"; }
@@ -0,0 +1,223 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
/** An acknowledged native discovery has one recoverable, account-keyed event intent. */
public final class RecipeDiscoveryEventBridge implements AutoCloseable {
private static final String RECIPE = "fantasystick:character_sheet";
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-recipe-discovery-events"));
public RecipeDiscoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("recipe-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public void discovered(Player player, String trigger, int notifications) {
try {
var record = capture(player, trigger, notifications > 0);
worker.execute(() -> {
try { publish(write(record)); }
catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
/** Called only after the shared writer's startup recovery is ready. */
public void replay() {
try {
worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
public void failed(Player player, String trigger, String reason) {
try {
var action = UUID.randomUUID();
var context = context(player);
emit(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + action + ":failed")
.getBytes(StandardCharsets.UTF_8)), action, clock.instant(), "action.resolved", context,
Map.of("action_kind", "recipe_discovery", "recipe_id", RECIPE, "trigger", trigger,
"outcome", "failed", "reason", reason)));
} catch (RuntimeException failure) { report(failure); }
}
private record Record(UUID account, UUID action, Instant time, String name, String trigger,
boolean notification, EventInput.Location location) { }
private Record capture(Player player, String trigger, boolean notification) {
var context = context(player);
return new Record(context.playerId(), id(context.playerId()), clock.instant(), context.playerName(),
trigger, notification, context.location());
}
private EventInput.Context context(Player player) {
var location = player.getLocation();
EventInput.Location where = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, where);
}
private void publish(Record record) {
var fields = new LinkedHashMap<String, Object>();
fields.put("action_kind", "recipe_discovery");
fields.put("recipe_id", RECIPE);
fields.put("recipe_category", "misc");
fields.put("trigger", record.trigger());
fields.put("outcome", "acknowledged");
fields.put("first_discovery_notification", record.notification());
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + record.action()
+ ":recipe.discovered").getBytes(StandardCharsets.UTF_8)), record.action(), record.time(),
"recipe.discovered", new EventInput.Context(record.account(), record.name(), null, null, null,
record.location()), fields);
emit(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try {
if (Files.deleteIfExists(file(record.account()))) forceDirectory();
} catch (IOException deleteFailure) { report(deleteFailure); }
});
}
private CompletionStage<LocalEventLog.Delivery> emit(EventInput input) {
try { return sink.apply(input); }
catch (RuntimeException failure) {
report(failure);
return java.util.concurrent.CompletableFuture.completedStage(LocalEventLog.Delivery.REJECTED);
}
}
private Path file(UUID account) { return pending.resolve(account + ".pending"); }
private Record write(Record record) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
Files.createDirectories(pending);
Path target = file(record.account());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
return read(target); // Preserve the first native acknowledgment and its original identity.
}
var values = new Properties();
values.setProperty("account", record.account().toString());
values.setProperty("action", record.action().toString());
values.setProperty("time", record.time().toString());
values.setProperty("name", record.name());
values.setProperty("trigger", record.trigger());
values.setProperty("notification", Boolean.toString(record.notification()));
if (record.location() != null) {
var location = record.location();
values.setProperty("world", location.worldId().toString());
values.setProperty("world_name", location.worldName());
values.setProperty("x", Double.toString(location.x()));
values.setProperty("y", Double.toString(location.y()));
values.setProperty("z", Double.toString(location.z()));
values.setProperty("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
values.store(output, "acknowledged native recipe discovery");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
Path temporary = Files.createTempFile(pending, ".recipe-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return record;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private Record read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
var values = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
values.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var account = UUID.fromString(values.getProperty("account"));
var action = UUID.fromString(values.getProperty("action"));
if (!path.equals(file(account)) || !action.equals(id(account))) throw new IOException("Invalid recipe event identity");
EventInput.Location location = values.containsKey("world")
? new EventInput.Location(UUID.fromString(values.getProperty("world")), values.getProperty("world_name"),
Double.parseDouble(values.getProperty("x")), Double.parseDouble(values.getProperty("y")),
Double.parseDouble(values.getProperty("z")), Instant.parse(values.getProperty("location_time")), "exact")
: null;
var trigger = values.getProperty("trigger");
if (!"join".equals(trigger) && !"enable_online".equals(trigger)) throw new IOException("Invalid recipe event trigger");
var notification = values.getProperty("notification");
if (!"true".equals(notification) && !"false".equals(notification)) throw new IOException("Invalid recipe notification");
return new Record(account, action, Instant.parse(values.getProperty("time")),
Objects.requireNonNull(values.getProperty("name")), trigger, Boolean.parseBoolean(notification), location);
} catch (RuntimeException invalid) { throw new IOException("Invalid recipe event intent", invalid); }
}
public static UUID id(UUID player) {
return UUID.nameUUIDFromBytes(("fantasystick:recipe:character_sheet:" + player)
.getBytes(StandardCharsets.UTF_8));
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Recipe event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
}
}
}
@@ -42,7 +42,9 @@ class FantasyFeedbackCommandNativeTest {
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback"))
.map(e -> e.get("id").getAsString()).distinct().count(),
"At-least-once file export may repeat a line, not a logical preference change");
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -438,6 +438,7 @@ class ManualCraftingControllerNativeTest {
case "getUniqueId" -> id;
case "getOpenInventory" -> current;
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
case "getLocation" -> eyeLocation == null ? null : eyeLocation.clone();
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "getName" -> "CraftingActor";
@@ -48,7 +48,17 @@ class RecipeDiscoveryNativeTest {
f.plugin.setEnabled(true);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
f.plugin.setEnabled(false);
}
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered"))
.toList();
assertEquals(2, discoveries.size(), "One native first discovery per account; reload/join creates no duplicate");
assertEquals(java.util.Set.of("enable_online", "join"), discoveries.stream()
.map(e -> e.getAsJsonObject("data").get("trigger").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(discoveries.stream().allMatch(e -> e.getAsJsonObject("data").get("recipe_id")
.getAsString().equals("fantasystick:character_sheet")));
assertEquals(2, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count());
}
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
@@ -80,6 +90,13 @@ class RecipeDiscoveryNativeTest {
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
} finally { f.plugin.getLogger().removeHandler(handler); }
}
var first = events(data.resolve("player-failure"));
assertEquals(1, first.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("action_kind")
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("recipe_discovery")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_failed")).count());
assertEquals(2, first.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count(),
"Healthy and retried players each get one first-discovery acknowledgment");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
@@ -88,6 +105,68 @@ class RecipeDiscoveryNativeTest {
assertTrue(online.recipeAwards.isEmpty());
assertTrue(f.recipes.isEmpty());
}
var registration = data.resolve("registration-failure").resolve("events");
if (java.nio.file.Files.isDirectory(registration))
assertEquals(0, events(data.resolve("registration-failure")).stream()
.filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count());
}
@Test void nativeDiscoveryWithoutPostconditionReportsUnavailableAndRetriesWithoutInventingSuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
var unavailable = new AtomicBoolean(true);
doAnswer(call -> {
if (unavailable.getAndSet(false)) return 0;
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
player.recipeAwards.add(requested); player.knownRecipes.addAll(requested); return requested.size();
}).when(player.player).discoverRecipes(anyCollection());
f.players.put(player.core.id, player.player);
f.dispatcher.accept(new PlayerJoinEvent(player.player, "unavailable"));
assertTrue(player.knownRecipes.isEmpty());
f.dispatcher.accept(new PlayerJoinEvent(player.player, "retry"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), player.knownRecipes);
f.plugin.setEnabled(false);
var records = events(data);
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(player.core.id.toString())).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_unavailable")).count());
}
}
@Test void acknowledgedNativeDiscoveryRetainsStableIdentityThroughEventSinkOutage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(joining.core.id, joining.player);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "join"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "already known"));
f.plugin.setEnabled(false);
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(joining.core.id.toString())).toList();
assertEquals(1, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count(),
"An acknowledged first discovery must be recoverable with its original event identity");
assertEquals("join", discoveries.getFirst().getAsJsonObject("data").get("trigger").getAsString());
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
}
}
private static List<com.google.gson.JsonObject> events(Path directory) throws Exception {
var entries = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) entries.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return entries;
}
private static long records(Path directory) throws Exception {