feat(events): audit training owners and drain preference intents
Release / release (push) Failing after 11m16s
Release / release (push) Failing after 11m16s
This commit is contained in:
1 parent
65a76b7fc4
commit
6f8e83a2e8
20 files changed
+1463
-22
No files matched your search
@@ -187,6 +187,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> trainingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge armorAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> armorAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
@@ -900,6 +902,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
trainingAuditEvents = new games.dmg.fantasystick.events.TrainingEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Training event intent failed", failure));
|
||||
admin.attachTrainingEvents(trainingAuditEvents);
|
||||
trainingDummyController.attachEvents(trainingAuditEvents);
|
||||
armorAuditEvents = new games.dmg.fantasystick.events.ArmorEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Armor event intent failed", failure));
|
||||
if (armorMarket != null) armorMarket.attachEvents(armorAuditEvents);
|
||||
@@ -1078,6 +1084,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
|
||||
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
|
||||
});
|
||||
var replayTrainingEvents = trainingAuditEvents;
|
||||
trainingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous training event drain failed", failure);
|
||||
if (replayTrainingEvents != null) replayTrainingEvents.replay();
|
||||
});
|
||||
var replayArmorEvents = armorAuditEvents;
|
||||
armorAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous armor event drain failed", failure);
|
||||
@@ -1186,6 +1197,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
|
||||
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
|
||||
var closingCoinEvents = coinEvents;
|
||||
var closingCombatRewardEvents = combatRewardEvents;
|
||||
@@ -1309,7 +1321,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
|
||||
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
|
||||
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
|
||||
@@ -1557,6 +1568,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingPassivePotionEvents.close();
|
||||
passivePotionAuditDrain = closingPassivePotionEvents.drained();
|
||||
});
|
||||
if (closingTrainingEvents != null) cleanup("training event intent drain", () -> {
|
||||
var registrationsDrained = closingTrainingDummies == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingTrainingDummies.drained();
|
||||
trainingAuditDrain = registrationsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Training registration drain failed", failure);
|
||||
closingTrainingEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingTrainingEvents.drained());
|
||||
});
|
||||
if (closingArmorEvents != null) cleanup("armor event intent drain", () -> {
|
||||
var marketDrained = closingArmorMarket == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorMarket.drained();
|
||||
@@ -1669,6 +1688,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}).thenCompose(ignored -> closingRareEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
// Writer shutdown can still acknowledge already accepted preference exports.
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
|
||||
@@ -435,7 +435,14 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
|
||||
org.bukkit.damage.DamageSource source) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(source);
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
|
||||
games.dmg.fantasystick.training.TrainingDamage.Source metadata = null;
|
||||
try {
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record != null) metadata = new games.dmg.fantasystick.training.TrainingDamage.Source(
|
||||
audit == null ? UUID.randomUUID() : audit.actionId(), input.session().player(), record.id(), record.name(),
|
||||
input.session().session(), definition.name(), definition.id());
|
||||
} catch (RuntimeException ignored) { /* Unavailable audit metadata cannot change the existing effect. */ }
|
||||
var measured = trainingDamage.apply(input.player(), target, metadata, () -> target.damage(amount, source));
|
||||
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
|
||||
}
|
||||
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
|
||||
|
||||
@@ -98,7 +98,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
pending = attempt;
|
||||
boolean completed = false;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
var measurementSource = new games.dmg.fantasystick.training.TrainingDamage.Source(
|
||||
action == null ? java.util.UUID.randomUUID() : action.id(), input.session().player(), record.id(),
|
||||
record.name(), input.session().session(), "Basic Attack", null);
|
||||
var measured = trainingDamage.apply(input.player(), target, measurementSource,
|
||||
() -> target.damage(nativeDamage, input.player()));
|
||||
measured.ifPresent(amount -> {
|
||||
attempt.accepted = true;
|
||||
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
|
||||
|
||||
@@ -25,6 +25,7 @@ public final class PreferenceEventBridge {
|
||||
private final EquipmentSessions sessions;
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final java.util.Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
|
||||
@@ -184,15 +185,32 @@ public final class PreferenceEventBridge {
|
||||
data.put("after", Boolean.toString(action.requested()));
|
||||
data.put("preference_revision", Long.toString(preferenceSequence));
|
||||
}
|
||||
if (acknowledgedIntent) {
|
||||
if (!inFlight.add(action.id())) return;
|
||||
if (preferences.pendingEvents().stream().noneMatch(intent -> intent.actionId().equals(action.id()))) {
|
||||
inFlight.remove(action.id());
|
||||
return; // A stale snapshot cannot replay an already acknowledged preference.
|
||||
}
|
||||
}
|
||||
try {
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
|
||||
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
|
||||
: clock.instant(), type, action.context(), Map.copyOf(data));
|
||||
var result = sink.apply(input);
|
||||
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
|
||||
preferences.acknowledgeEvent(action.id());
|
||||
if (failure != null || delivery == LocalEventLog.Delivery.REJECTED) {
|
||||
inFlight.remove(action.id()); // Rejected evidence remains available for a real retry.
|
||||
return;
|
||||
}
|
||||
preferences.acknowledgeEvent(action.id()).whenComplete((ignored, acknowledgementFailure) -> {
|
||||
if (acknowledgementFailure == null) inFlight.remove(action.id());
|
||||
else try { failures.accept(acknowledgementFailure); } catch (RuntimeException ignoredFailure) { }
|
||||
// Accepted delivery must not be exported twice while its durable acknowledgement is uncertain.
|
||||
});
|
||||
});
|
||||
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
|
||||
} catch (RuntimeException ignored) {
|
||||
if (acknowledgedIntent) inFlight.remove(action.id());
|
||||
/* Logging cannot affect the command or persistent state. */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.training.TrainingDummy;
|
||||
import games.dmg.fantasystick.training.TrainingDummyController;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Immutable acknowledged dummy-owner records; replay has no gameplay authority. */
|
||||
public final class TrainingEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {}
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock = Clock.systemUTC();
|
||||
|
||||
public TrainingEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "training-event-intents", Set.of("training_dummy.placed", "training_dummy.removed", "training_dummy.measured"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender, EquipmentSessions sessions, org.bukkit.Location input) {
|
||||
String kind = sender instanceof Player ? "player" : sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
if (!(sender instanceof Player player)) return new Actor(kind, null, null, null);
|
||||
UUID id = null, character = null, session = null; String name = null, characterName = null;
|
||||
EventInput.Location location = null;
|
||||
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
|
||||
} catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var at = input == null ? player.getLocation() : input;
|
||||
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
|
||||
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
|
||||
}
|
||||
|
||||
public void registration(String type, UUID operation, TrainingDummy dummy, Actor actor,
|
||||
TrainingDummyController.Observed entities, String outcome) {
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("actor_capture_quality", actor == null || "player".equals(actor.kind()) && actor.context() == null ? "unavailable" : "native");
|
||||
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
|
||||
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
|
||||
values.put("operation_id", operation.toString()); values.put("dummy_id", dummy.id().toString());
|
||||
values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
|
||||
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
|
||||
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
|
||||
values.put("world_id", dummy.world().toString()); values.put("x", dummy.x()); values.put("y", dummy.y());
|
||||
values.put("z", dummy.z()); values.put("yaw", (double) dummy.yaw()); values.put("pitch", (double) dummy.pitch());
|
||||
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
|
||||
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
|
||||
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
|
||||
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:training:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type, actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot undo registration or native reconciliation. */ }
|
||||
}
|
||||
|
||||
public record Capture(games.dmg.fantasystick.training.TrainingDamage.Source source, String playerName,
|
||||
EventInput.Location location, EventInput.Context context) {}
|
||||
public Capture capture(Player player, games.dmg.fantasystick.training.TrainingDamage.Source source) {
|
||||
String name = null; EventInput.Location location = null; EventInput.Context context = null;
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var at = player.getLocation();
|
||||
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { context = new EventInput.Context(source.playerId(), name, source.characterId(), source.characterName(), source.sessionId(), location); }
|
||||
catch (RuntimeException ignored) { }
|
||||
return new Capture(source, name, location, context);
|
||||
}
|
||||
|
||||
public EventInput measurement(Capture capture, UUID causation, TrainingDummy dummy, org.bukkit.event.entity.EntityDamageEvent event,
|
||||
java.math.BigDecimal amount) {
|
||||
try {
|
||||
var source = capture.source(); var values = new LinkedHashMap<String, Object>();
|
||||
UUID measurement = UUID.nameUUIDFromBytes(("fantasystick:training:" + causation + ":measured").getBytes(StandardCharsets.UTF_8));
|
||||
values.put("measurement_id", measurement.toString()); values.put("damage_causation_id", causation.toString());
|
||||
values.put("attack_name", source.attackName());
|
||||
if (source.abilityId() != null) values.put("ability_id", source.abilityId());
|
||||
values.put("source_player_id", source.playerId().toString()); values.put("source_character_id", source.characterId().toString());
|
||||
values.put("source_session_id", source.sessionId().toString()); values.put("source_character_name", source.characterName());
|
||||
if (capture.playerName() != null) values.put("source_player_name", capture.playerName());
|
||||
values.put("actor_capture_quality", capture.context() == null ? "unavailable" : "native");
|
||||
values.put("dummy_id", dummy.id().toString()); values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
|
||||
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
|
||||
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
|
||||
values.put("entity_id", event.getEntity().getUniqueId().toString()); values.put("final_amount", amount.toPlainString());
|
||||
values.put("native_prior_cancelled", false); values.put("dummy_containment_cancelled", true);
|
||||
var modifiers = new LinkedHashMap<String, String>();
|
||||
boolean completeModifiers = true;
|
||||
for (var modifier : org.bukkit.event.entity.EntityDamageEvent.DamageModifier.values()) {
|
||||
try {
|
||||
if (event.isApplicable(modifier)) modifiers.put(modifier.name().toLowerCase(java.util.Locale.ROOT),
|
||||
java.math.BigDecimal.valueOf(event.getDamage(modifier)).stripTrailingZeros().toPlainString());
|
||||
} catch (RuntimeException unavailable) { completeModifiers = false; }
|
||||
}
|
||||
values.put("native_modifiers", Map.copyOf(modifiers));
|
||||
values.put("native_modifier_capture_quality", completeModifiers ? "native" : "unavailable");
|
||||
projectile(values, event);
|
||||
if (capture.location() != null) values.put("source_location", coordinates(capture.location()));
|
||||
else values.put("source_location_capture_quality", "unavailable");
|
||||
try {
|
||||
var at = event.getEntity().getLocation(); var world = at.getWorld();
|
||||
if (world != null) values.put("target_location", coordinates(new EventInput.Location(world.getUID(), world.getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
|
||||
else values.put("target_location_capture_quality", "unavailable");
|
||||
} catch (RuntimeException unavailable) { values.put("target_location_capture_quality", "unavailable"); }
|
||||
values.put("outcome", "measured");
|
||||
return new EventInput(measurement, source.actionId(), clock.instant(), "training_dummy.measured", capture.context(), Map.copyOf(values));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
private void projectile(Map<String, Object> values, org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
org.bukkit.entity.Entity direct;
|
||||
try { direct = event.getDamageSource().getDirectEntity(); }
|
||||
catch (RuntimeException unavailable) { values.put("direct_entity_capture_quality", "unavailable"); return; }
|
||||
if (!(direct instanceof org.bukkit.entity.Projectile projectile)) return;
|
||||
try {
|
||||
values.put("projectile_id", projectile.getUniqueId().toString());
|
||||
values.put("projectile_capture_quality", "native");
|
||||
} catch (RuntimeException unavailable) { values.put("projectile_capture_quality", "unavailable"); }
|
||||
var flight = new LinkedHashMap<String, Object>();
|
||||
try {
|
||||
var at = projectile.getLocation(); var world = at.getWorld();
|
||||
if (world == null) flight.put("location_capture_quality", "unavailable");
|
||||
else flight.putAll(coordinates(new EventInput.Location(world.getUID(), world.getName(),
|
||||
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
|
||||
} catch (RuntimeException unavailable) { flight.put("location_capture_quality", "unavailable"); }
|
||||
try {
|
||||
var velocity = projectile.getVelocity();
|
||||
if (Double.isFinite(velocity.getX()) && Double.isFinite(velocity.getY()) && Double.isFinite(velocity.getZ())) {
|
||||
flight.put("velocity_x", velocity.getX()); flight.put("velocity_y", velocity.getY()); flight.put("velocity_z", velocity.getZ());
|
||||
} else flight.put("velocity_capture_quality", "unavailable");
|
||||
} catch (RuntimeException unavailable) { flight.put("velocity_capture_quality", "unavailable"); }
|
||||
flight.put("capture_boundary", "native_damage_interception");
|
||||
values.put("flight_context", Map.copyOf(flight));
|
||||
}
|
||||
|
||||
private static Map<String, Object> coordinates(EventInput.Location location) {
|
||||
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(), "x", location.x(),
|
||||
"y", location.y(), "z", location.z(), "captured_at", location.capturedAt().toString(), "capture_quality", location.quality());
|
||||
}
|
||||
public void measured(EventInput input) {
|
||||
try { if (input != null) outbox.accepted(input); } catch (RuntimeException ignored) { /* Never gameplay. */ }
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -194,6 +194,10 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
|
||||
public CompletionStage<Void> acknowledgeEvent(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
@@ -208,9 +212,12 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
synchronized (this) { pendingEvents.remove(action); }
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
|
||||
} catch (RejectedExecutionException closedWorker) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(closedWorker);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
|
||||
@@ -39,7 +39,8 @@ public final class YamlTrainingDummyRepository {
|
||||
var row = map(value);
|
||||
result.add(new TrainingDummy(UUID.fromString(text(row.get("dummy-id"))), text(row.get("name")),
|
||||
UUID.fromString(text(row.get("world"))), decimal(row.get("x")), decimal(row.get("y")),
|
||||
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch"))));
|
||||
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch")),
|
||||
row.containsKey("registration-revision") ? UUID.fromString(text(row.get("registration-revision"))) : null));
|
||||
}
|
||||
validate(result); return List.copyOf(result);
|
||||
} catch (RuntimeException invalid) {
|
||||
@@ -53,6 +54,7 @@ public final class YamlTrainingDummyRepository {
|
||||
root.put("dummies", dummies.stream().sorted(Comparator.comparing(TrainingDummy::id)).map(value -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("dummy-id", value.id().toString()); row.put("name", value.name());
|
||||
if (value.revision() != null) row.put("registration-revision", value.revision().toString());
|
||||
row.put("world", value.world().toString()); row.put("x", Double.toString(value.x()));
|
||||
row.put("y", Double.toString(value.y())); row.put("z", Double.toString(value.z()));
|
||||
row.put("yaw", Float.toString(value.yaw())); row.put("pitch", Float.toString(value.pitch())); return row;
|
||||
|
||||
@@ -107,6 +107,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
|
||||
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
|
||||
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge trainingEvents;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
@@ -362,6 +363,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachTrainingEvents(games.dmg.fantasystick.events.TrainingEventBridge events) {
|
||||
if (closed || trainingEvents != null) throw new IllegalStateException("Training events already attached or command closed");
|
||||
trainingEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachArmorEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (closed || armorEvents != null) throw new IllegalStateException("Armor events already attached or closed");
|
||||
armorEvents = Objects.requireNonNull(events);
|
||||
@@ -956,10 +961,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals("place") && sender instanceof org.bukkit.entity.Player player) {
|
||||
String name = args.length < 3 ? "" : String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
var location = player.getLocation().clone();
|
||||
var owner = player.getUniqueId();
|
||||
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, location);
|
||||
var operation = UUID.randomUUID();
|
||||
try {
|
||||
trainingDummies.place(name, location.getWorld().getUID(), location.getX(), location.getY(),
|
||||
location.getZ(), location.getYaw(), location.getPitch())
|
||||
.whenComplete((saved, failure) -> placeDummyResponse(player, location, saved, failure));
|
||||
.whenComplete((saved, failure) -> placeDummyResponse(player, owner, location, operation, actor, saved, failure));
|
||||
} catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy name is invalid."); }
|
||||
return;
|
||||
}
|
||||
@@ -967,7 +975,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
UUID id;
|
||||
try { id = UUID.fromString(args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy UUID is invalid."); return; }
|
||||
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, removed, failure));
|
||||
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, null);
|
||||
var operation = UUID.randomUUID();
|
||||
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, operation, actor, removed, failure));
|
||||
return;
|
||||
}
|
||||
trainingDummyUsage(sender);
|
||||
@@ -977,9 +987,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
}
|
||||
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, UUID owner, org.bukkit.Location location, UUID operation,
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
|
||||
games.dmg.fantasystick.training.TrainingDummy saved, Throwable failure) {
|
||||
if (failure != null) report(player.getUniqueId(), "training-dummy-place", failure);
|
||||
if (failure != null) report(owner, "training-dummy-place", failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
@@ -989,7 +1000,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
if (!player.isOnline() || !player.hasPermission(PERMISSION)) {
|
||||
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
|
||||
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-permission-rollback", rollbackFailure);
|
||||
if (rollbackFailure != null) report(owner, "training-dummy-permission-rollback", rollbackFailure);
|
||||
if (!closed) nextTick.accept(() -> {
|
||||
trainingDummyController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Training dummy placement was cancelled because permission changed.");
|
||||
@@ -1002,21 +1013,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (location.getWorld().isChunkLoaded(chunkX, chunkZ)) {
|
||||
trainingDummyController.reconcile(location.getWorld().getChunkAt(chunkX, chunkZ));
|
||||
}
|
||||
if (trainingEvents != null) {
|
||||
var observed = trainingDummyController.observed(saved);
|
||||
String outcome = !observed.quality().equals("native") ? "unavailable"
|
||||
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
|
||||
trainingEvents.registration("training_dummy.placed", operation, saved, actor, observed, outcome);
|
||||
}
|
||||
player.sendMessage("Placed training dummy " + saved.id() + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
report(player.getUniqueId(), "training-dummy-entity", entityFailure);
|
||||
report(owner, "training-dummy-entity", entityFailure);
|
||||
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
|
||||
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-rollback", rollbackFailure);
|
||||
if (rollbackFailure != null) report(owner, "training-dummy-rollback", rollbackFailure);
|
||||
if (!closed) nextTick.accept(() -> {
|
||||
trainingDummyController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
|
||||
});
|
||||
});
|
||||
}
|
||||
}); } catch (RuntimeException rejected) { report(player.getUniqueId(), "training-dummy-response", rejected); }
|
||||
}); } catch (RuntimeException rejected) { report(owner, "training-dummy-response", rejected); }
|
||||
}
|
||||
|
||||
private void removeDummyResponse(CommandSender sender, UUID id,
|
||||
private void removeDummyResponse(CommandSender sender, UUID id, UUID operation,
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
|
||||
java.util.Optional<games.dmg.fantasystick.training.TrainingDummy> removed, Throwable failure) {
|
||||
if (failure != null) report(null, "training-dummy-remove", failure);
|
||||
if (closed) return;
|
||||
@@ -1029,7 +1047,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal was cancelled because permission changed.");
|
||||
return;
|
||||
}
|
||||
try { trainingDummyController.removeLoaded(id); sender.sendMessage("Removed training dummy " + id + "."); }
|
||||
try {
|
||||
var saved = removed.orElseThrow();
|
||||
var before = trainingEvents == null ? null : trainingDummyController.observed(saved);
|
||||
trainingDummyController.removeLoaded(id);
|
||||
if (trainingEvents != null) {
|
||||
var after = trainingDummyController.observed(saved);
|
||||
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
|
||||
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
|
||||
trainingEvents.registration("training_dummy.removed", operation, saved, actor, before, outcome);
|
||||
}
|
||||
sender.sendMessage("Removed training dummy " + id + ".");
|
||||
}
|
||||
catch (RuntimeException entityFailure) {
|
||||
report(null, "training-dummy-entity", entityFailure);
|
||||
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal failed; record and entity are unchanged.");
|
||||
|
||||
@@ -10,6 +10,14 @@ import org.bukkit.entity.Player;
|
||||
public interface TrainingDamage {
|
||||
Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage);
|
||||
|
||||
/** Caller-owned input identity; this metadata cannot authorize or replay an attack. */
|
||||
record Source(java.util.UUID actionId, java.util.UUID playerId, java.util.UUID characterId,
|
||||
String characterName, java.util.UUID sessionId, String attackName, String abilityId) {}
|
||||
|
||||
default Optional<BigDecimal> apply(Player source, LivingEntity target, Source metadata, Runnable nativeDamage) {
|
||||
return apply(source, target, nativeDamage);
|
||||
}
|
||||
|
||||
default boolean target(org.bukkit.entity.Entity entity) { return false; }
|
||||
|
||||
static TrainingDamage passthrough() {
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable desired location for one administrator-managed training dummy. */
|
||||
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
|
||||
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch, UUID revision) {
|
||||
public TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
|
||||
this(id, name, world, x, y, z, yaw, pitch, null);
|
||||
}
|
||||
public TrainingDummy {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
|
||||
name = name.strip();
|
||||
|
||||
@@ -31,9 +31,13 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
private final java.util.Set<Zombie> loaded = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
private Attempt pending;
|
||||
private boolean closed;
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge events;
|
||||
|
||||
private static final class Attempt {
|
||||
final LivingEntity target; BigDecimal amount;
|
||||
final UUID causation = UUID.randomUUID();
|
||||
games.dmg.fantasystick.events.TrainingEventBridge.Capture capture;
|
||||
games.dmg.fantasystick.events.EventInput measurement;
|
||||
Attempt(LivingEntity target) { this.target = target; }
|
||||
}
|
||||
|
||||
@@ -49,6 +53,21 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
}
|
||||
}
|
||||
|
||||
public record Observed(String chunkStatus, java.util.List<UUID> ids, String quality) {}
|
||||
public Observed observed(TrainingDummy value) {
|
||||
try {
|
||||
var world = plugin.getServer().getWorld(value.world());
|
||||
if (world == null) return new Observed("unavailable", java.util.List.of(), "unavailable");
|
||||
if (!world.isChunkLoaded(chunk(value.x()), chunk(value.z())))
|
||||
return new Observed("unloaded", java.util.List.of(), "native");
|
||||
var entities = world.getEntities();
|
||||
if (entities == null) return new Observed("loaded", java.util.List.of(), "unavailable");
|
||||
var ids = entities.stream().filter(this::tagged).filter(entity -> value.id().equals(id(entity)))
|
||||
.map(Entity::getUniqueId).sorted().toList();
|
||||
return new Observed("loaded", ids, "native");
|
||||
} catch (RuntimeException unavailable) { return new Observed("unavailable", java.util.List.of(), "unavailable"); }
|
||||
}
|
||||
|
||||
public boolean tagged(Entity entity) {
|
||||
var data = entity.getPersistentDataContainer();
|
||||
return data != null && TYPE.equals(data.get(typeKey, PersistentDataType.STRING));
|
||||
@@ -93,13 +112,25 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
}
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.TrainingEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Training events already attached or controller closed");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage) {
|
||||
return apply(source, target, null, nativeDamage);
|
||||
}
|
||||
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, TrainingDamage.Source metadata, Runnable nativeDamage) {
|
||||
Objects.requireNonNull(source); Objects.requireNonNull(target); Objects.requireNonNull(nativeDamage);
|
||||
if (closed || !managed(target)) { nativeDamage.run(); return Optional.empty(); }
|
||||
if (pending != null) throw new IllegalStateException("Nested training damage is unavailable");
|
||||
var attempt = new Attempt(target); pending = attempt;
|
||||
var attempt = new Attempt(target);
|
||||
try { if (events != null && metadata != null) attempt.capture = events.capture(source, metadata); }
|
||||
catch (RuntimeException ignored) { /* Unavailable audit context cannot change interception. */ }
|
||||
pending = attempt;
|
||||
try { nativeDamage.run(); }
|
||||
finally { if (pending == attempt) pending = null; }
|
||||
if (events != null) events.measured(attempt.measurement);
|
||||
return Optional.ofNullable(attempt.amount);
|
||||
}
|
||||
|
||||
@@ -171,6 +202,12 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
|
||||
if (attempt != null && attempt.target == event.getEntity() && managed(event.getEntity())
|
||||
&& Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0) {
|
||||
attempt.amount = BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros();
|
||||
try {
|
||||
if (events != null && attempt.capture != null) {
|
||||
var dummy = directory.find(id(event.getEntity())).orElse(null);
|
||||
if (dummy != null) attempt.measurement = events.measurement(attempt.capture, attempt.causation, dummy, event, attempt.amount);
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit cannot interrupt native damage containment. */ }
|
||||
}
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public final class TrainingDummyDirectory implements AutoCloseable {
|
||||
}
|
||||
public CompletableFuture<TrainingDummy> place(String name, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch) {
|
||||
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch);
|
||||
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch, UUID.randomUUID());
|
||||
return submit(() -> {
|
||||
var next = new ArrayList<>(snapshot); next.add(value); repository.save(next);
|
||||
snapshot = List.copyOf(next); return value;
|
||||
|
||||
@@ -33,6 +33,9 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertEquals("Fantasy feedback is now off.", player.messages.getLast());
|
||||
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
fixture.plugin, "configurationDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{}));
|
||||
assertEquals("Fantasy feedback is off.", player.messages.getLast());
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.events.EventInput;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.events.PreferenceEventBridge;
|
||||
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.FantasyParticlesCommand;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PreferenceLifecycleNativeTest {
|
||||
@TempDir Path data;
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredAuraChangeCannotReplayWhileItsRealExportAcknowledgementIsDelayed() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var inventory = actor.core.snapshot();
|
||||
var character = java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(actor.core.record.id() + ".yml"));
|
||||
var preferences = (PlayerFeedbackPreferences) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var command = (FantasyParticlesCommand) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "particlesCommand");
|
||||
var delayed = new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var submitted = new CopyOnWriteArrayList<EventInput>();
|
||||
var bridge = new PreferenceEventBridge(input -> {
|
||||
var delivery = log.emit(input);
|
||||
if (!input.type().equals("preference.changed")) return delivery;
|
||||
submitted.add(input);
|
||||
return delivery.thenCompose(ignored -> delayed);
|
||||
}, Clock.systemUTC(), sessions, preferences, failure -> fail("Preference audit failed", failure));
|
||||
command.attachEvents(bridge);
|
||||
try {
|
||||
assertTrue(f.playerCommand.getExecutor().onCommand(actor.player, f.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
f.tick();
|
||||
assertEquals("Active-character aura particles are now on.", actor.messages.getLast());
|
||||
ReskillEventsNativeTest.await(f, () -> preferences.pendingEvents().stream()
|
||||
.anyMatch(row -> "true".equals(row.captured().get("published"))));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return changes().size() == 1; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
var intent = preferences.pendingEvents().getFirst();
|
||||
var original = submitted.getFirst();
|
||||
assertEquals(intent.actionId(), original.actionId());
|
||||
bridge.replay(intent); bridge.replay(intent);
|
||||
assertEquals(1, submitted.size(),
|
||||
"Startup/recovery replay cannot overtake a registered preference export awaiting acknowledgement");
|
||||
delayed.complete(LocalEventLog.Delivery.EXPORTED);
|
||||
ReskillEventsNativeTest.await(f, () -> preferences.pendingEvents().isEmpty());
|
||||
bridge.replay(intent); // A stale captured intent is not authority for another accepted export.
|
||||
assertEquals(1, submitted.size());
|
||||
assertArrayEquals(inventory, actor.core.snapshot());
|
||||
assertArrayEquals(character, java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(actor.core.record.id() + ".yml")));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "configurationDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.plugin.setEnabled(true);
|
||||
var restarted = (PlayerFeedbackPreferences) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
|
||||
assertTrue(restarted.playerAurasEnabled(actor.core.id));
|
||||
assertTrue(restarted.allParticlesEnabled(actor.core.id)); assertTrue(restarted.enabled(actor.core.id));
|
||||
assertTrue(restarted.pendingEvents().isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
var records = changes(); assertEquals(1, records.size());
|
||||
assertEquals(original.id().toString(), records.getFirst().get("id").getAsString());
|
||||
assertEquals(original.time().toString(), records.getFirst().get("time").getAsString());
|
||||
} finally { delayed.complete(LocalEventLog.Delivery.EXPORTED); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAuraExportQueuedAtShutdownMustBeAcknowledgedBeforeRestart() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
var inventory = actor.core.snapshot();
|
||||
var preferences = (PlayerFeedbackPreferences) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
|
||||
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(log, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var blocked = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
CompletableFuture<Void> releasing = null;
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(f.playerCommand.getExecutor().onCommand(actor.player, f.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
f.tick();
|
||||
assertEquals("Active-character aura particles are now on.", actor.messages.getLast());
|
||||
ReskillEventsNativeTest.await(f, () -> preferences.pendingEvents().stream()
|
||||
.anyMatch(row -> "true".equals(row.captured().get("published"))));
|
||||
var original = preferences.pendingEvents().getFirst();
|
||||
assertTrue(changes().isEmpty(), "The real writer is held before export, not a fabricated sink result");
|
||||
assertArrayEquals(inventory, actor.core.snapshot());
|
||||
releasing = CompletableFuture.runAsync(() -> {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (!(Boolean) FantasyStickLifecycleNativeTest.Fixture.field(log, "closing")
|
||||
&& System.nanoTime() < deadline) java.util.concurrent.locks.LockSupport.parkNanos(100_000);
|
||||
assertTrue((Boolean) FantasyStickLifecycleNativeTest.Fixture.field(log, "closing"));
|
||||
release.countDown();
|
||||
});
|
||||
f.plugin.setEnabled(false);
|
||||
releasing.get(5, TimeUnit.SECONDS); blocked.get(5, TimeUnit.SECONDS);
|
||||
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, changes().size(), "Orderly writer shutdown exports the queued accepted native change");
|
||||
var exported = changes().getFirst().deepCopy();
|
||||
assertEquals(original.actionId().toString(), exported.getAsJsonObject("data").get("action_id").getAsString());
|
||||
assertEquals(original.captured().get("time"), exported.get("time").getAsString());
|
||||
assertTrue(preferences.pendingEvents().isEmpty(),
|
||||
"The preference worker must remain open for the writer's final delivered acknowledgement");
|
||||
f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "configurationDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.plugin.setEnabled(true);
|
||||
var restarted = (PlayerFeedbackPreferences) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
|
||||
assertTrue(restarted.playerAurasEnabled(actor.core.id)); assertTrue(restarted.pendingEvents().isEmpty());
|
||||
assertTrue(restarted.allParticlesEnabled(actor.core.id)); assertTrue(restarted.enabled(actor.core.id));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
assertEquals(java.util.List.of(exported), changes(),
|
||||
"Orderly restart cannot create a second export or recapture the acknowledged original payload");
|
||||
} finally {
|
||||
release.countDown(); blocked.get(5, TimeUnit.SECONDS);
|
||||
if (releasing != null) releasing.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realRejectedAuraDeliveryRetainsImmutableIntentForOneOfflineRetryWithoutGameplay() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
var inventory = actor.core.snapshot();
|
||||
var preferences = (PlayerFeedbackPreferences) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
var runtime = (LocalEventLog.RuntimeInfo) FantasyStickLifecycleNativeTest.Fixture.field(log, "runtime");
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
log.close(); // This real writer refuses the acknowledged native command's audit handoff.
|
||||
var sink = new java.util.concurrent.atomic.AtomicReference<>(log);
|
||||
var submitted = new CopyOnWriteArrayList<EventInput>();
|
||||
var bridge = new PreferenceEventBridge(input -> {
|
||||
if (input.type().equals("preference.changed")) submitted.add(input);
|
||||
return sink.get().emit(input);
|
||||
}, Clock.systemUTC(), sessions, preferences, failure -> fail("Preference retry acknowledgement failed", failure));
|
||||
((FantasyParticlesCommand) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "particlesCommand"))
|
||||
.attachEvents(bridge);
|
||||
assertTrue(f.playerCommand.getExecutor().onCommand(actor.player, f.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
f.tick(); assertEquals("Active-character aura particles are now on.", actor.messages.getLast());
|
||||
ReskillEventsNativeTest.await(f, () -> preferences.pendingEvents().stream()
|
||||
.anyMatch(row -> "true".equals(row.captured().get("published"))));
|
||||
var intent = preferences.pendingEvents().getFirst(); var original = submitted.getFirst();
|
||||
bridge.replay(intent); bridge.replay(intent);
|
||||
assertEquals(3, submitted.size(), "Real rejected deliveries release the claim and remain retryable");
|
||||
assertTrue(submitted.stream().allMatch(original::equals));
|
||||
assertTrue(changes().isEmpty()); assertTrue(preferences.playerAurasEnabled(actor.core.id));
|
||||
f.players.clear(); org.mockito.Mockito.clearInvocations(actor.player);
|
||||
try (var reopened = LocalEventLog.open(data, Clock.systemUTC(), runtime, 256, 134_217_728,
|
||||
diagnostic -> { })) {
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, reopened.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
sink.set(reopened);
|
||||
bridge.replay(intent); bridge.replay(intent);
|
||||
ReskillEventsNativeTest.await(f, () -> preferences.pendingEvents().isEmpty());
|
||||
bridge.replay(intent);
|
||||
assertEquals(4, submitted.size()); assertEquals(original, submitted.getLast());
|
||||
assertTrue(preferences.playerAurasEnabled(actor.core.id));
|
||||
assertTrue(preferences.allParticlesEnabled(actor.core.id)); assertTrue(preferences.enabled(actor.core.id));
|
||||
assertArrayEquals(inventory, actor.core.snapshot()); org.mockito.Mockito.verifyNoInteractions(actor.player);
|
||||
var rows = changes(); assertEquals(1, rows.size());
|
||||
var row = rows.getFirst(); var payload = row.getAsJsonObject("data");
|
||||
assertEquals(original.id().toString(), row.get("id").getAsString());
|
||||
assertEquals(original.time().toString(), row.get("time").getAsString());
|
||||
assertEquals(original.actionId().toString(), payload.get("action_id").getAsString());
|
||||
for (var field : original.data().entrySet())
|
||||
assertEquals(String.valueOf(field.getValue()), payload.get(field.getKey()).getAsString(), field.getKey());
|
||||
assertEquals(original.context().playerId().toString(), payload.get("player_id").getAsString());
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> changes() throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().endsWith("preference.changed")
|
||||
&& row.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.YamlTrainingDummyRepository;
|
||||
import games.dmg.fantasystick.training.TrainingDummyDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Zombie;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class TrainingDummyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredPlacementReconciliationRemovalExportsRealRevisionEntityAndOriginalCoordinates() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var ports = npc(f); var admin = ports.actor();
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"training-dummy", "place", "Measured Target"});
|
||||
admin.core.location.add(20, 0, 20);
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1 && ports.present().get());
|
||||
var dummy = directory.all().getFirst();
|
||||
assertNotNull(dummy.revision()); assertNotEquals(dummy.id(), dummy.revision());
|
||||
assertEquals(List.of(dummy), new YamlTrainingDummyRepository(data).load());
|
||||
for (int attempt = 0; attempt < 100 && rows("training_dummy.placed").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("training_dummy.placed").size(), "Acknowledged real dummy registration and native reconciliation must reach its owner JSONL record");
|
||||
var placed = rows("training_dummy.placed").getFirst(); var values = placed.getAsJsonObject("data");
|
||||
assertEquals(admin.core.id.toString(), placed.get("playerid").getAsString());
|
||||
assertEquals(dummy.id().toString(), values.get("dummy_id").getAsString());
|
||||
assertEquals(dummy.revision().toString(), values.get("dummy_revision").getAsString());
|
||||
assertEquals("persisted", values.get("revision_capture_quality").getAsString());
|
||||
assertEquals("Measured Target", values.get("dummy_name").getAsString());
|
||||
assertEquals(ports.entity().getUniqueId().toString(), values.get("entity_id").getAsString());
|
||||
assertEquals("materialized", values.get("entity_outcome").getAsString());
|
||||
assertEquals("loaded", values.get("chunk_status").getAsString());
|
||||
assertEquals(dummy.world().toString(), values.get("world_id").getAsString());
|
||||
assertEquals(ports.anchor().getX(), values.get("x").getAsDouble()); assertEquals(ports.anchor().getY(), values.get("y").getAsDouble());
|
||||
assertEquals(ports.anchor().getZ(), values.get("z").getAsDouble()); assertEquals(ports.anchor().getYaw(), values.get("yaw").getAsDouble());
|
||||
assertEquals(ports.anchor().getPitch(), values.get("pitch").getAsDouble());
|
||||
f.dispatcher.accept(new org.bukkit.event.world.ChunkLoadEvent(ports.chunk(), false));
|
||||
verify(admin.world, times(1)).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
assertEquals(1, rows("training_dummy.placed").size(), "Loaded-chunk reconciliation is not another registration");
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(console.port, "fstickadmin", new String[]{"training-dummy", "remove", dummy.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !ports.present().get() && rows("training_dummy.removed").size() == 1);
|
||||
var removed = rows("training_dummy.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(dummy.revision().toString(), removed.get("dummy_revision").getAsString());
|
||||
assertEquals(ports.entity().getUniqueId().toString(), removed.get("entity_id").getAsString());
|
||||
assertEquals("removed", removed.get("entity_outcome").getAsString()); assertEquals("console", removed.get("actor_kind").getAsString());
|
||||
f.adminCommand.execute(console.port, "fstickadmin", new String[]{"training-dummy", "remove", dummy.id().toString()});
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("training_dummy.placed").size()); assertEquals(1, rows("training_dummy.removed").size());
|
||||
}
|
||||
|
||||
@Test void realRegisteredBasicHitExportsFinalFractionalMeasurementWithCadenceAndNoDamageOrRewards() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.actor();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "Measured Target"});
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1 && ports.present().get() && rows("training_dummy.placed").size() == 1);
|
||||
f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow();
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(active.character()).get().orElseThrow(); var inventory = player.core.snapshot();
|
||||
var intercepted = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var cause = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(cause.getCausingEntity()).thenReturn(player.player); when(cause.getDirectEntity()).thenReturn(player.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, cause, (Double) call.getArgument(0));
|
||||
event.setDamage(2.75); intercepted.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(player.player));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(1, intercepted.size()); assertTrue(intercepted.getFirst().isCancelled());
|
||||
assertEquals(1, player.messages.stream().filter("Basic Attack did 2.75 damage to Measured Target."::equals).count());
|
||||
for (int attempt = 0; attempt < 100 && rows("training_dummy.measured").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("training_dummy.measured").size(), "A real intercepted Basic Attack must export its final would-be amount, not combat damage");
|
||||
var row = rows("training_dummy.measured").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals("Basic Attack", values.get("attack_name").getAsString()); assertFalse(values.has("ability_id"));
|
||||
assertEquals("2.75", values.get("final_amount").getAsString());
|
||||
assertEquals(player.core.id.toString(), row.get("playerid").getAsString()); assertEquals(before.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(active.session().toString(), values.get("source_session_id").getAsString());
|
||||
assertEquals(directory.all().getFirst().id().toString(), values.get("dummy_id").getAsString());
|
||||
assertEquals(directory.all().getFirst().revision().toString(), values.get("dummy_revision").getAsString());
|
||||
assertEquals(ports.entity().getUniqueId().toString(), values.get("entity_id").getAsString());
|
||||
java.util.UUID.fromString(values.get("measurement_id").getAsString()); java.util.UUID.fromString(values.get("damage_causation_id").getAsString());
|
||||
var start = rows("action.observed").stream().filter(value -> "basic_attack".equals(value.getAsJsonObject("data").get("action_kind").getAsString())).findFirst().orElseThrow();
|
||||
assertEquals(start.getAsJsonObject("data").get("action_id").getAsString(), values.get("action_id").getAsString());
|
||||
assertEquals(ports.anchor().getX(), values.getAsJsonObject("target_location").get("x").getAsDouble());
|
||||
assertEquals(ports.anchor().getZ(), values.getAsJsonObject("source_location").get("z").getAsDouble());
|
||||
assertFalse(values.get("native_prior_cancelled").getAsBoolean()); assertTrue(values.get("dummy_containment_cancelled").getAsBoolean());
|
||||
assertEquals("2.75", values.getAsJsonObject("native_modifiers").get("base").getAsString());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(1, intercepted.size(), "Measurement must spend the original successful-hit cadence");
|
||||
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertArrayEquals(inventory, player.core.snapshot());
|
||||
verify(ports.entity(), never()).setHealth(anyDouble()); verify(ports.entity(), never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("training_dummy.measured").size());
|
||||
}
|
||||
|
||||
@Test void realBookLearnedPartialGreatSwordExportsOneAbilityMeasurementAndRetainsSpentCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.actor();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1 && ports.present().get() && rows("training_dummy.placed").size() == 1);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(player.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, java.math.BigInteger.valueOf(12));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration()), old.revision());
|
||||
f.activate(player); player.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, player);
|
||||
PartyBookNativeTest.click(f, player, abilityButton(player, "[Learn]", "great_sword"));
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow();
|
||||
ReskillEventsNativeTest.await(f, () -> sessions.currentRecord(active).orElseThrow().learned().contains("great_sword"));
|
||||
var before = sessions.currentRecord(active).orElseThrow(); var inventory = player.core.snapshot();
|
||||
var intercepted = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var cause = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, cause, (Double) call.getArgument(0));
|
||||
event.setDamage(2.125); intercepted.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
when(player.player.getAttackCooldown()).thenReturn(0.5f); clearInvocations(ports.entity());
|
||||
player.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
ReskillEventsNativeTest.await(f, () -> intercepted.size() == 1);
|
||||
assertTrue(intercepted.getFirst().isCancelled()); assertEquals(3.2, intercepted.getFirst().getOriginalDamage(org.bukkit.event.entity.EntityDamageEvent.DamageModifier.BASE));
|
||||
assertEquals(1, player.messages.stream().filter("Great Sword did 2.13 damage to Training Dummy."::equals).count());
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var after = storage.find(before.id()).get().orElseThrow(); assertNotNull(after.cooldowns().get("great_sword"));
|
||||
assertEquals(before.experience(), after.experience()); assertEquals(before.coins(), after.coins()); assertEquals(before.health(), after.health());
|
||||
for (int attempt = 0; attempt < 100 && rows("training_dummy.measured").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("training_dummy.measured").size(), "The real learned offensive ability must export its intercepted final amount without damage/reward");
|
||||
var row = rows("training_dummy.measured").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals("Great Sword", values.get("attack_name").getAsString()); assertEquals("great_sword", values.get("ability_id").getAsString());
|
||||
assertEquals("2.125", values.get("final_amount").getAsString()); assertEquals(active.session().toString(), values.get("source_session_id").getAsString());
|
||||
var cast = rows("ability.cast").stream().filter(value -> "great_sword".equals(value.getAsJsonObject("data").get("ability_id").getAsString())).findFirst().orElseThrow();
|
||||
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(), values.get("action_id").getAsString());
|
||||
assertEquals(before.id().toString(), row.get("characterid").getAsString());
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
verify(ports.entity(), never()).setHealth(anyDouble()); verify(ports.entity(), never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
// Cooldown presentation may legitimately replace only the owned control; it is not replay or another hit.
|
||||
assertEquals(inventory[0], player.core.snapshot()[0]);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("training_dummy.measured").size());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"fireball", "frost_shard", "longbow"})
|
||||
void ownedProjectileNativeSweptCollisionExportsFlightAndOneFinalMeasurement(String abilityId) throws Exception {
|
||||
var attackName = java.util.Map.of("fireball", "Fireball", "frost_shard", "Frost Shard", "longbow", "Longbow").get(abilityId);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.actor();
|
||||
var nativeDummy = mock(net.minecraft.world.entity.monster.zombie.Zombie.class, CALLS_REAL_METHODS);
|
||||
doReturn(false).when(nativeDummy).isRemoved(); doReturn(nativeDummy).when(nativeDummy).getRootVehicle();
|
||||
doReturn(ports.entity().getUniqueId()).when(nativeDummy).getUUID(); doReturn(0f).when(nativeDummy).getPickRadius();
|
||||
var anchor = ports.anchor();
|
||||
doReturn(new net.minecraft.world.phys.AABB(anchor.getX() - 0.3, anchor.getY(), anchor.getZ() - 0.3,
|
||||
anchor.getX() + 0.3, anchor.getY() + 2, anchor.getZ() + 0.3)).when(nativeDummy).getBoundingBox();
|
||||
doAnswer(call -> { nativeDummy.collides = call.getArgument(0); return null; }).when(ports.entity()).setCollidable(anyBoolean());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1 && ports.present().get() && rows("training_dummy.placed").size() == 1);
|
||||
assertTrue(nativeDummy.isPickable(), "Actual registered dummy configuration must retain its native projectile hit surface");
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(player.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, java.math.BigInteger.TEN);
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.DEX, java.math.BigInteger.valueOf(14));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration()), old.revision());
|
||||
f.activate(player); player.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, player);
|
||||
PartyBookNativeTest.click(f, player, abilityButton(player, "[Learn]", abilityId));
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow();
|
||||
ReskillEventsNativeTest.await(f, () -> sessions.currentRecord(active).orElseThrow().learned().contains(abilityId));
|
||||
var before = sessions.currentRecord(active).orElseThrow();
|
||||
player.core.location.setX(anchor.getX() - 6);
|
||||
var eye = player.core.location.clone().add(0, 1.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
doReturn(eye).when(player.player).getEyeLocation();
|
||||
var nativeArrow = mock(net.minecraft.world.entity.projectile.arrow.Arrow.class);
|
||||
var arrow = mock(org.bukkit.craftbukkit.entity.CraftArrow.class); var flight = eye.clone(); var velocity = new org.bukkit.util.Vector(3, 0, 0);
|
||||
when(arrow.getHandle()).thenReturn(nativeArrow); when(nativeArrow.getBukkitEntity()).thenReturn(arrow);
|
||||
var projectileId = UUID.randomUUID(); when(nativeArrow.getUUID()).thenReturn(projectileId);
|
||||
when(arrow.getUniqueId()).thenReturn(projectileId); when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
when(arrow.getWorld()).thenReturn(player.world); when(arrow.getShooter()).thenReturn(player.player);
|
||||
when(arrow.getLocation()).thenAnswer(call -> flight.clone()); when(arrow.getVelocity()).thenAnswer(call -> velocity.clone());
|
||||
when(arrow.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
var launches = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> { launches.incrementAndGet(); velocity.copy(call.getArgument(1));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(arrow)); return arrow;
|
||||
}).when(player.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, (org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
|
||||
assertSame(arrow, event.getDamageSource().getDirectEntity(), "The typed native source must retain the actual owned projectile");
|
||||
event.setDamage(4.875); hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(player.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
player.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
if (abilityId.equals("longbow")) f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(
|
||||
player.player, player.core.inventory.getItem(2).clone(), 20));
|
||||
ReskillEventsNativeTest.await(f, () -> launches.get() == 1); assertTrue(hits.isEmpty());
|
||||
var level = mock(net.minecraft.world.level.Level.class);
|
||||
when(nativeArrow.level()).thenReturn(level); when(nativeArrow.getRootVehicle()).thenReturn(nativeArrow);
|
||||
when(level.getEntities(same(nativeArrow), any(net.minecraft.world.phys.AABB.class), any())).thenAnswer(call -> {
|
||||
@SuppressWarnings("unchecked") var predicate = (java.util.function.Predicate<net.minecraft.world.entity.Entity>) call.getArgument(2);
|
||||
return predicate.test(nativeDummy) ? java.util.List.of(nativeDummy) : java.util.List.of();
|
||||
});
|
||||
net.minecraft.world.phys.EntityHitResult collision = null; int steps = 0;
|
||||
for (; steps < 3 && collision == null; steps++) {
|
||||
var start = new net.minecraft.world.phys.Vec3(flight.getX(), flight.getY(), flight.getZ());
|
||||
var end = start.add(velocity.getX(), velocity.getY(), velocity.getZ());
|
||||
collision = net.minecraft.world.entity.projectile.ProjectileUtil.getEntityHitResult(nativeArrow, start, end,
|
||||
new net.minecraft.world.phys.AABB(start, end).inflate(0.3), net.minecraft.world.entity.Entity::isPickable, velocity.lengthSquared());
|
||||
flight.setX(end.x); flight.setY(end.y); flight.setZ(end.z);
|
||||
}
|
||||
assertNotNull(collision, "The owned flight must reach an actual pinned-native swept dummy collision, not only a fabricated terminal event");
|
||||
assertEquals(2, steps); assertSame(nativeDummy, collision.getEntity()); assertEquals(ports.entity().getUniqueId(), collision.getEntity().getUUID());
|
||||
flight.setX(collision.getLocation().x); flight.setY(collision.getLocation().y); flight.setZ(collision.getLocation().z);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, ports.entity()));
|
||||
assertEquals(1, hits.size()); assertTrue(hits.getFirst().isCancelled());
|
||||
assertEquals(1, player.messages.stream().filter((attackName + " did 4.88 damage to Training Dummy.")::equals).count());
|
||||
for (int attempt = 0; attempt < 100 && rows("training_dummy.measured").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("training_dummy.measured").size()); var value = rows("training_dummy.measured").getFirst().getAsJsonObject("data");
|
||||
assertTrue(value.has("projectile_id"), "A genuine owned collision measurement must preserve its projectile and flight context");
|
||||
assertEquals(arrow.getUniqueId().toString(), value.get("projectile_id").getAsString());
|
||||
assertEquals("4.875", value.get("final_amount").getAsString()); assertEquals(abilityId, value.get("ability_id").getAsString());
|
||||
assertEquals(flight.getX(), value.getAsJsonObject("flight_context").get("x").getAsDouble());
|
||||
assertEquals(3, value.getAsJsonObject("flight_context").get("velocity_x").getAsDouble());
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, ports.entity())); assertEquals(1, hits.size());
|
||||
verify(arrow, atLeastOnce()).remove(); verify(ports.entity(), never()).setHealth(anyDouble());
|
||||
verify(ports.entity(), never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var after = storage.find(before.id()).get().orElseThrow(); assertNotNull(after.cooldowns().get(abilityId));
|
||||
assertEquals(before.health(), after.health()); assertEquals(before.experience(), after.experience()); assertEquals(before.coins(), after.coins());
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("training_dummy.measured").size());
|
||||
}
|
||||
|
||||
@Test void realBoundAreaAndTwoHopChainMeasureEachRegisteredDummyOncePerOriginalCastWithoutSecondaryEffects() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.actor();
|
||||
var second = TrainingDummyNativeTest.zombie(ports.anchor().clone().add(2, 0, 0));
|
||||
var live = new java.util.ArrayList<Zombie>();
|
||||
when(ports.chunk().getEntities()).thenAnswer(call -> live.toArray(Entity[]::new));
|
||||
when(player.world.getEntities()).thenAnswer(call -> List.copyOf(live));
|
||||
doAnswer(call -> { Zombie target = live.isEmpty() ? ports.entity() : second; Consumer<? super Zombie> configure = call.getArgument(2);
|
||||
configure.accept(target); live.add(target); return target;
|
||||
}).when(player.world).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
doAnswer(call -> { live.remove(ports.entity()); return null; }).when(ports.entity()).remove();
|
||||
doAnswer(call -> { live.remove(second); return null; }).when(second).remove();
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "First"});
|
||||
ReskillEventsNativeTest.await(f, () -> rows("training_dummy.placed").size() == 1);
|
||||
player.core.location.setX(ports.anchor().getX() + 2);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "Second"});
|
||||
ReskillEventsNativeTest.await(f, () -> rows("training_dummy.placed").size() == 2);
|
||||
assertEquals(2, directory.all().size()); assertEquals(2, live.size());
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(player.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(20));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.DEX, java.math.BigInteger.valueOf(12));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration()), old.revision());
|
||||
f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow();
|
||||
for (var ability : List.of("frost_nova", "chain_lightning")) {
|
||||
player.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, player);
|
||||
PartyBookNativeTest.click(f, player, abilityButton(player, "[Learn]", ability));
|
||||
ReskillEventsNativeTest.await(f, () -> sessions.currentRecord(active).orElseThrow().learned().contains(ability));
|
||||
}
|
||||
var before = sessions.currentRecord(active).orElseThrow(); player.core.location.setX(ports.anchor().getX() - 2);
|
||||
doReturn(player.core.location.clone().add(0, 1.6, 0)).when(player.player).getEyeLocation();
|
||||
doReturn(List.of(ports.entity(), second, second)).when(player.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(true).when(player.player).hasLineOfSight(ports.entity()); doReturn(true).when(player.player).hasLineOfSight(second);
|
||||
doReturn(new org.bukkit.util.RayTraceResult(ports.anchor().toVector(), ports.entity())).when(player.world)
|
||||
.rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0), org.mockito.ArgumentMatchers.<java.util.function.Predicate<Entity>>any());
|
||||
doReturn(List.of(second, second)).when(ports.entity()).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(true).when(ports.entity()).hasLineOfSight(second); doReturn(List.of(ports.entity())).when(second).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
for (var target : List.of(ports.entity(), second)) {
|
||||
doAnswer(call -> {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.CUSTOM, (org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
|
||||
event.setDamage(target == ports.entity() ? 2.625 : 1.125); hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); clearInvocations(target);
|
||||
}
|
||||
for (int slot : List.of(2, 3)) {
|
||||
player.core.inventory.getInventory().setSelectedSlot(slot);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(player.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
player.core.inventory.getItem(slot), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
int count = slot == 2 ? 2 : 4; ReskillEventsNativeTest.await(f, () -> rows("training_dummy.measured").size() == count);
|
||||
}
|
||||
assertEquals(4, hits.size()); assertTrue(hits.stream().allMatch(org.bukkit.event.entity.EntityDamageEvent::isCancelled));
|
||||
var measured = rows("training_dummy.measured");
|
||||
assertEquals(4, measured.stream().map(row -> row.getAsJsonObject("data").get("damage_causation_id").getAsString()).distinct().count());
|
||||
for (var ability : List.of("frost_nova", "chain_lightning")) {
|
||||
var applications = measured.stream().filter(row -> row.getAsJsonObject("data").get("ability_id").getAsString().equals(ability)).toList();
|
||||
assertEquals(2, applications.size()); assertEquals(1, applications.stream().map(row -> row.getAsJsonObject("data").get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(2, applications.stream().map(row -> row.getAsJsonObject("data").get("dummy_id").getAsString()).distinct().count());
|
||||
assertEquals(java.util.Set.of("2.625", "1.125"), applications.stream().map(row -> row.getAsJsonObject("data").get("final_amount").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
var cast = rows("ability.cast").stream().filter(row -> row.getAsJsonObject("data").get("ability_id").getAsString().equals(ability)).findFirst().orElseThrow();
|
||||
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(), applications.getFirst().getAsJsonObject("data").get("action_id").getAsString());
|
||||
}
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var after = storage.find(before.id()).get().orElseThrow();
|
||||
assertNotNull(after.cooldowns().get("frost_nova")); assertNotNull(after.cooldowns().get("chain_lightning"));
|
||||
assertEquals(before.health(), after.health()); assertEquals(before.experience(), after.experience()); assertEquals(before.coins(), after.coins());
|
||||
assertEquals(before.restrictedKills(), after.restrictedKills()); assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
for (var target : List.of(ports.entity(), second)) {
|
||||
verify(target, never()).setHealth(anyDouble()); verify(target, never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
verify(target, never()).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(4, rows("training_dummy.measured").size());
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String id) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, id); if (found != null) return found; }
|
||||
throw new AssertionError("Missing real book " + label + " for " + id);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String id) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom click
|
||||
&& click.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload && payload.value().contains(":" + id)) return click;
|
||||
for (var child : component.getSiblings()) { var found = abilityButton(child, label, id); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
record Npc(FantasyStickLifecycleNativeTest.Actor actor, Zombie entity, Location anchor, Chunk chunk, AtomicBoolean present) {}
|
||||
private Npc npc(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { return npc(f, data); }
|
||||
static Npc npc(FantasyStickLifecycleNativeTest.Fixture f, Path data) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player); doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var anchor = actor.core.location.clone(); int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
|
||||
var entity = TrainingDummyNativeTest.zombie(anchor); var present = new AtomicBoolean();
|
||||
var nativeName = new java.util.concurrent.atomic.AtomicReference<>("Training Dummy");
|
||||
doAnswer(call -> { nativeName.set(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||
.serialize(call.getArgument(0))); return null; }).when(entity).customName(any(net.kyori.adventure.text.Component.class));
|
||||
when(entity.getName()).thenAnswer(call -> nativeName.get());
|
||||
var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(actor.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
|
||||
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new Entity[]{entity} : new Entity[0]);
|
||||
when(actor.world.getEntities()).thenAnswer(call -> present.get() ? List.of(entity) : List.of());
|
||||
when(actor.world.isChunkLoaded(x, z)).thenReturn(true); when(actor.world.getChunkAt(x, z)).thenReturn(chunk);
|
||||
doAnswer(call -> { Consumer<Zombie> configure = call.getArgument(2); configure.accept(entity); present.set(true); return entity; })
|
||||
.when(actor.world).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
doAnswer(call -> { present.set(false); return null; }).when(entity).remove();
|
||||
return new Npc(actor, entity, anchor, chunk, present);
|
||||
}
|
||||
private List<JsonObject> rows(String type) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(type)).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse exported dummy owner evidence", failure); }
|
||||
}
|
||||
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.training.TrainingDummyDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Zombie;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class TrainingDummyFaultEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realRemovalSaveAndNativeFailuresRetainExactRegistrationAndOnlyLegitimateRetryExportsRemoval() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get() && count("training_dummy.placed") == 1);
|
||||
var saved = directory.all().getFirst(); var file = data.resolve("training-dummies.yml"); var bytes = Files.readAllBytes(file);
|
||||
var backup = data.resolve("training-dummies-test-backup.yml"); Files.move(file, backup); Files.createDirectory(file);
|
||||
var blocker = file.resolve("cannot-replace-nonempty-directory"); Files.writeString(blocker, "guaranteed removal save failure");
|
||||
try {
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Training dummy removal failed; record and entity are unchanged."));
|
||||
assertEquals(saved, directory.find(saved.id()).orElseThrow()); assertTrue(ports.present().get()); assertEquals(0, count("training_dummy.removed"));
|
||||
} finally { Files.deleteIfExists(blocker); Files.deleteIfExists(file); Files.move(backup, file); }
|
||||
int messages = player.messages.size();
|
||||
doThrow(new IllegalStateException("Test-only native removal failed")).when(ports.entity()).remove();
|
||||
try {
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.size() > messages && player.messages.getLast().equals("Training dummy removal failed; record and entity are unchanged."));
|
||||
assertEquals(saved, directory.find(saved.id()).orElseThrow()); assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
assertTrue(ports.present().get()); assertEquals(0, count("training_dummy.removed"));
|
||||
} finally { doAnswer(call -> { ports.present().set(false); return null; }).when(ports.entity()).remove(); }
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !ports.present().get() && count("training_dummy.removed") == 1);
|
||||
var removed = ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("training_dummy.removed")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(saved.revision().toString(), removed.get("dummy_revision").getAsString()); assertEquals("removed", removed.get("entity_outcome").getAsString());
|
||||
assertEquals(ports.entity().getUniqueId().toString(), removed.get("entity_id").getAsString()); assertEquals(1, count("training_dummy.placed"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, count("training_dummy.removed"));
|
||||
}
|
||||
|
||||
@Test void failedNativeSpawnRollsBackRegistrationAndChangedPermissionCannotClaimPlacement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
doThrow(new IllegalStateException("Test-only native spawn failed")).when(player.world)
|
||||
.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Training dummy placement failed; no record or entity changed."));
|
||||
assertTrue(directory.all().isEmpty()); assertFalse(ports.present().get()); assertEquals(0, count("training_dummy.placed"));
|
||||
doAnswer(call -> { Consumer<? super Zombie> configure = call.getArgument(2); configure.accept(ports.entity()); ports.present().set(true); return ports.entity(); })
|
||||
.when(player.world).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
doReturn(false).when(player.player).hasPermission("fantasystick.admin");
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Training dummy placement was cancelled because permission changed."));
|
||||
assertTrue(directory.all().isEmpty()); assertFalse(ports.present().get()); assertEquals(0, count("training_dummy.placed"));
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "Denied"});
|
||||
assertTrue(directory.all().isEmpty()); assertEquals(0, count("training_dummy.placed"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closeBeforeNativeAcknowledgementAndLegitimateLoadedRestartCannotInventPlacementOrDuplicateNpc() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
when(player.world.getLoadedChunks()).thenReturn(new org.bukkit.Chunk[]{ports.chunk()});
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "Restart Target"});
|
||||
for (int i = 0; i < 500 && directory.all().isEmpty(); i++) Thread.sleep(10);
|
||||
assertEquals(1, directory.all().size(), "Real save acknowledgement must exist before closing its still-pending native callback");
|
||||
var saved = directory.all().getFirst(); assertFalse(ports.present().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
Runnable callback; while ((callback = f.tasks.poll()) != null) callback.run();
|
||||
assertFalse(ports.present().get()); assertEquals(0, count("training_dummy.placed"));
|
||||
verify(player.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
f.plugin.setEnabled(true);
|
||||
var reopened = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
assertEquals(saved, reopened.find(saved.id()).orElseThrow()); assertTrue(ports.present().get());
|
||||
var controller = (games.dmg.fantasystick.training.TrainingDummyController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
|
||||
controller.reconcile(ports.chunk()); controller.reconcile(ports.chunk());
|
||||
verify(player.world, times(1)).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
assertEquals(0, count("training_dummy.placed"), "Legitimate startup materialization is not an acknowledged new administration operation or audit replay");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
assertFalse(ports.present().get());
|
||||
}
|
||||
}
|
||||
|
||||
private long count(String suffix) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).count(); }
|
||||
catch (Exception error) { throw new AssertionError("Cannot parse dummy fault evidence", error); }
|
||||
}
|
||||
|
||||
@Test void realPlacementIoFailureDeliversNativeRefusalWithoutRegistrationEntityOrSuccessAudit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
var file = data.resolve("training-dummies.yml"); Files.createDirectory(file);
|
||||
var blocker = file.resolve("cannot-replace-nonempty-directory"); Files.writeString(blocker, "guaranteed test-only save failure");
|
||||
try {
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"training-dummy", "place", "Unwritten"});
|
||||
var response = f.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(response, "Real asynchronous dummy save failure must still deliver its main-thread refusal callback"); response.run();
|
||||
assertTrue(actor.messages.contains("Training dummy placement failed; no record or entity changed."));
|
||||
assertTrue(directory.all().isEmpty());
|
||||
verify(actor.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<Consumer<? super Zombie>>any());
|
||||
verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
|
||||
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().endsWith("training_dummy.placed")));
|
||||
} finally { Files.deleteIfExists(blocker); Files.deleteIfExists(file); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.YamlTrainingDummyRepository;
|
||||
import games.dmg.fantasystick.training.TrainingDummy;
|
||||
import games.dmg.fantasystick.training.TrainingDummyDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class TrainingDummyReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realLegacyStartupIsByteIdenticalAndUnloadedRemovalDoesNotInventRegistrationRevisionOrEntity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(player.core.id, player.player);
|
||||
var at = player.core.location;
|
||||
var legacy = new TrainingDummy(UUID.randomUUID(), "Legacy Target", player.world.getUID(), at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch());
|
||||
new YamlTrainingDummyRepository(data).save(List.of(legacy)); var bytes = Files.readAllBytes(data.resolve("training-dummies.yml"));
|
||||
f.plugin.setEnabled(true);
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
assertEquals(List.of(legacy), directory.all()); assertNull(directory.all().getFirst().revision());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("training-dummies.yml")));
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"training-dummy", "remove", legacy.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> rows("training_dummy.removed").size() == 1);
|
||||
var values = rows("training_dummy.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals("unavailable", values.get("revision_capture_quality").getAsString()); assertFalse(values.has("dummy_revision"));
|
||||
assertEquals(legacy.id().toString(), values.get("dummy_id").getAsString()); assertEquals("unloaded", values.get("chunk_status").getAsString());
|
||||
assertEquals("none_loaded", values.get("entity_outcome").getAsString()); assertFalse(values.has("entity_id"));
|
||||
verify(player.world, never()).getChunkAt(anyInt(), anyInt()); assertTrue(directory.all().isEmpty()); assertTrue(rows("training_dummy.placed").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realRejectedPlacementMeasurementRemovalReplayOriginalImmutableHistoryOfflineWithoutAnyGameplayOrFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS); log.close();
|
||||
var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place", "Original Target"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get() && player.messages.stream().anyMatch(message -> message.startsWith("Placed training dummy")));
|
||||
var registered = directory.all().getFirst(); f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow(); var before = sessions.currentRecord(active).orElseThrow(); var inventory = player.core.snapshot();
|
||||
var intercepted = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getCausingEntity()).thenReturn(player.player); when(source.getDirectEntity()).thenReturn(player.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setDamage(1.375); intercepted.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(player.player));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(1, intercepted.size()); assertTrue(intercepted.getFirst().isCancelled());
|
||||
assertEquals(1, player.messages.stream().filter("Basic Attack did 1.38 damage to Original Target."::equals).count());
|
||||
assertEquals(before, sessions.currentRecord(active).orElseThrow()); assertArrayEquals(inventory, player.core.snapshot());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "remove", registered.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !ports.present().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var registrationBytes = Files.readAllBytes(data.resolve("training-dummies.yml"));
|
||||
var characterBytes = Files.readAllBytes(data.resolve("characters").resolve(before.id() + ".yml"));
|
||||
var messages = List.copyOf(player.messages); var intents = new java.util.ArrayList<Properties>();
|
||||
try (var paths = Files.list(data.resolve("training-event-intents"))) {
|
||||
for (var path : paths.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
|
||||
var properties = new Properties(); try (var input = Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
|
||||
}
|
||||
}
|
||||
assertEquals(3, intents.size());
|
||||
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().contains("training_dummy.")));
|
||||
f.players.clear(); player.core.online = false; org.mockito.Mockito.<Object>clearInvocations(player.player, player.world, ports.entity());
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> trainingRows().size() == 3);
|
||||
assertTrue(((TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies")).all().isEmpty());
|
||||
assertArrayEquals(registrationBytes, Files.readAllBytes(data.resolve("training-dummies.yml")));
|
||||
assertArrayEquals(characterBytes, Files.readAllBytes(data.resolve("characters").resolve(before.id() + ".yml")));
|
||||
assertEquals(messages, player.messages); verifyNoInteractions(player.player, player.world, ports.entity());
|
||||
for (var intent : intents) {
|
||||
var row = trainingRows().stream().filter(value -> value.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow();
|
||||
assertEquals(intent.getProperty("time"), row.get("time").getAsString()); assertEquals(intent.getProperty("action"), row.getAsJsonObject("data").get("action_id").getAsString());
|
||||
assertEquals(registered.id().toString(), row.getAsJsonObject("data").get("dummy_id").getAsString());
|
||||
assertEquals(registered.revision().toString(), row.getAsJsonObject("data").get("dummy_revision").getAsString());
|
||||
assertEquals(player.core.id.toString(), row.get("playerid").getAsString());
|
||||
for (var key : intent.stringPropertyNames().stream().filter(value -> value.startsWith("data.")).toList()) {
|
||||
var segments = key.substring(5).split("\\."); var payload = row.getAsJsonObject("data");
|
||||
for (int i = 0; i < segments.length - 1; i++) payload = payload.getAsJsonObject(segments[i]);
|
||||
var actual = payload.get(segments[segments.length - 1]); assertNotNull(actual, "Original immutable payload key " + key);
|
||||
String encoded = intent.getProperty(key); var expected = switch (encoded.charAt(0)) {
|
||||
case 's' -> new com.google.gson.JsonPrimitive(encoded.substring(1));
|
||||
case 'b' -> new com.google.gson.JsonPrimitive(Boolean.parseBoolean(encoded.substring(1)));
|
||||
case 'd' -> new com.google.gson.JsonPrimitive(Double.parseDouble(encoded.substring(1)));
|
||||
case 'i' -> new com.google.gson.JsonPrimitive(Integer.parseInt(encoded.substring(1)));
|
||||
case 'j' -> com.google.gson.JsonParser.parseString(encoded.substring(1));
|
||||
default -> throw new AssertionError("Unexpected original dummy owner encoding: " + encoded.charAt(0));
|
||||
};
|
||||
assertEquals(expected, actual, "Audit replay must retain original immutable payload " + key);
|
||||
}
|
||||
}
|
||||
var measured = rows("training_dummy.measured").getFirst(); assertEquals("1.375", measured.getAsJsonObject("data").get("final_amount").getAsString());
|
||||
assertEquals(active.session().toString(), measured.getAsJsonObject("data").get("source_session_id").getAsString());
|
||||
assertEquals(before.id().toString(), measured.get("characterid").getAsString());
|
||||
var immutable = trainingRows().stream().map(JsonObject::toString).sorted().toList();
|
||||
((games.dmg.fantasystick.events.TrainingEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditEvents")).replay();
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(immutable, trainingRows().stream().map(JsonObject::toString).sorted().toList());
|
||||
assertEquals(messages, player.messages); verifyNoInteractions(player.player, player.world, ports.entity());
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String suffix) { return all().stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).toList(); }
|
||||
private List<JsonObject> trainingRows() { return all().stream().filter(row -> row.get("type").getAsString().contains("training_dummy.")).toList(); }
|
||||
private List<JsonObject> all() {
|
||||
try { return ReskillEventsNativeTest.records(data); } catch (Exception error) { throw new AssertionError("Cannot parse dummy replay evidence", error); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class TrainingDummySafetyEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realCancelledZeroNonfiniteOrdinaryAndEnvironmentalAttemptsStaySilentWhileMutedExtremeHitRetainsCadenceAndKnownIdentity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get() && rows("training_dummy.placed").size() == 1);
|
||||
f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow(); var before = sessions.currentRecord(active).orElseThrow(); var inventory = player.core.snapshot();
|
||||
var amount = new AtomicReference<>(2.5); var cancelled = new AtomicBoolean(); var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getCausingEntity()).thenReturn(player.player); when(source.getDirectEntity()).thenReturn(player.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setDamage(amount.get()); event.setCancelled(cancelled.get()); hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(player.player));
|
||||
clearInvocations(ports.entity());
|
||||
var lava = new org.bukkit.event.entity.EntityDamageEvent(ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.LAVA, 50);
|
||||
f.dispatcher.accept(lava); assertTrue(lava.isCancelled());
|
||||
var ordinary = TrainingDummyNativeTest.zombie(ports.anchor().clone().add(1, 0, 0));
|
||||
var ordinaryHit = new org.bukkit.event.entity.EntityDamageByEntityEvent(ordinary, ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
mock(org.bukkit.damage.DamageSource.class), 8); f.dispatcher.accept(ordinaryHit); assertTrue(ordinaryHit.isCancelled());
|
||||
player.core.inventory.getInventory().setSelectedSlot(1);
|
||||
cancelled.set(true);
|
||||
for (int i = 0; i < 2; i++) f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(2, hits.size(), "External cancellation spends no successful-hit cadence");
|
||||
cancelled.set(false);
|
||||
for (double value : new double[]{0, Double.NaN, Double.POSITIVE_INFINITY}) {
|
||||
amount.set(value);
|
||||
for (int i = 0; i < 2; i++) f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
}
|
||||
assertEquals(8, hits.size(), "Non-positive/nonfinite final amounts spend no successful-hit cadence");
|
||||
assertTrue(hits.stream().allMatch(org.bukkit.event.entity.EntityDamageEvent::isCancelled));
|
||||
assertTrue(rows("training_dummy.measured").isEmpty()); assertFalse(player.messages.stream().anyMatch(value -> value.startsWith("Basic Attack did")));
|
||||
f.playerCommand.execute(player.player, "fstick", new String[]{"feedback", "off"});
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Fantasy feedback is now off."));
|
||||
var accountName = player.player.getName();
|
||||
var unavailableNameCaptures = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
boolean ownerCapture = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).walk(frames -> frames
|
||||
.anyMatch(frame -> frame.getDeclaringClass() == games.dmg.fantasystick.events.TrainingEventBridge.class));
|
||||
if (ownerCapture) { unavailableNameCaptures.incrementAndGet(); throw new IllegalStateException("Unavailable owner account metadata only"); }
|
||||
return accountName;
|
||||
}).when(player.player).getName();
|
||||
try {
|
||||
amount.set(Double.MAX_VALUE);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(9, hits.size()); assertTrue(hits.getLast().isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(9, hits.size(), "An independently accepted muted extreme hit spends the normal cadence exactly once");
|
||||
ReskillEventsNativeTest.await(f, () -> rows("training_dummy.measured").size() == 1);
|
||||
var row = rows("training_dummy.measured").getFirst(); var values = row.getAsJsonObject("data");
|
||||
var expected = java.math.BigDecimal.valueOf(Double.MAX_VALUE).stripTrailingZeros().toPlainString();
|
||||
assertEquals(expected, values.get("final_amount").getAsString()); assertEquals(expected, values.getAsJsonObject("native_modifiers").get("base").getAsString());
|
||||
assertTrue(unavailableNameCaptures.get() > 0, "The real owner capture must exercise unavailable metadata");
|
||||
assertEquals("unavailable", values.get("actor_capture_quality").getAsString()); assertFalse(values.has("source_player_name"));
|
||||
assertEquals(player.core.id.toString(), values.get("source_player_id").getAsString()); assertEquals(before.id().toString(), values.get("source_character_id").getAsString());
|
||||
assertEquals(active.session().toString(), values.get("source_session_id").getAsString());
|
||||
assertEquals("system", row.getAsJsonObject("data").get("actor_kind").getAsString());
|
||||
assertEquals(before, sessions.currentRecord(active).orElseThrow()); assertArrayEquals(inventory, player.core.snapshot());
|
||||
assertFalse(player.messages.stream().anyMatch(value -> value.startsWith("Basic Attack did")));
|
||||
verify(ports.entity(), never()).setHealth(anyDouble()); verify(ports.entity(), never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
} finally { doReturn(accountName).when(player.player).getName(); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("training_dummy.measured").size());
|
||||
}
|
||||
|
||||
@Test void unavailableOptionalNativeModifierMetadataCannotSuppressAnOtherwiseAcceptedActualMeasurement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get() && rows("training_dummy.placed").size() == 1); f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow(); var before = sessions.currentRecord(active).orElseThrow();
|
||||
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getCausingEntity()).thenReturn(player.player); when(source.getDirectEntity()).thenReturn(player.player);
|
||||
var event = spy(new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0))); event.setDamage(2.5);
|
||||
doThrow(new IllegalStateException("Optional native modifiers unavailable")).when(event).isApplicable(any(org.bukkit.event.entity.EntityDamageEvent.DamageModifier.class));
|
||||
hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(player.player));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(1, hits.size()); assertTrue(hits.getFirst().isCancelled());
|
||||
assertEquals(1, player.messages.stream().filter("Basic Attack did 2.5 damage to Training Dummy."::equals).count());
|
||||
for (int i = 0; i < 100 && rows("training_dummy.measured").isEmpty(); i++) Thread.sleep(10);
|
||||
assertEquals(1, rows("training_dummy.measured").size(), "Unavailable optional modifiers must not discard a known positive final measurement");
|
||||
var values = rows("training_dummy.measured").getFirst().getAsJsonObject("data");
|
||||
assertEquals("2.5", values.get("final_amount").getAsString()); assertEquals("unavailable", values.get("native_modifier_capture_quality").getAsString());
|
||||
assertTrue(values.getAsJsonObject("native_modifiers").entrySet().isEmpty(), "Unknown modifiers must not be invented as zero or final amount");
|
||||
assertEquals(before.id().toString(), values.get("source_character_id").getAsString());
|
||||
assertEquals(before, sessions.currentRecord(active).orElseThrow()); verify(ports.entity(), never()).setHealth(anyDouble());
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualTrainingIntentStoreFailureCannotAuthorizeInterruptDuplicateOrUndoAnAcceptedVirtualHit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var player = ports.actor();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get() && rows("training_dummy.placed").size() == 1);
|
||||
var intents = data.resolve("training-event-intents");
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try (var paths = java.nio.file.Files.list(intents)) { return paths.noneMatch(path -> path.getFileName().toString().endsWith(".pending")); }
|
||||
catch (java.io.IOException error) { throw new AssertionError(error); }
|
||||
});
|
||||
f.activate(player);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(player.core.id).orElseThrow(); var before = sessions.currentRecord(active).orElseThrow(); var inventory = player.core.snapshot();
|
||||
var nativeCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
nativeCalls.incrementAndGet(); var source = mock(org.bukkit.damage.DamageSource.class); when(source.getCausingEntity()).thenReturn(player.player); when(source.getDirectEntity()).thenReturn(player.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(player.player, ports.entity(), org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setDamage(3.25); f.dispatcher.accept(event); assertTrue(event.isCancelled()); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(player.player)); clearInvocations(ports.entity());
|
||||
var failure = new AtomicReference<java.util.logging.LogRecord>();
|
||||
var handler = new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) { if (record.getMessage().equals("Training event intent failed")) failure.set(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
var backup = data.resolve("test-saved-training-intents"); java.nio.file.Files.move(intents, backup); java.nio.file.Files.writeString(intents, "test-only impossible intent directory");
|
||||
f.plugin.getLogger().addHandler(handler);
|
||||
try {
|
||||
player.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(player.player, ports.entity(), true));
|
||||
assertEquals(1, nativeCalls.get(), "Real store failure cannot duplicate the native hit or reopen its successful-hit cadence");
|
||||
ReskillEventsNativeTest.await(f, () -> failure.get() != null); assertNotNull(failure.get().getThrown());
|
||||
assertEquals(1, player.messages.stream().filter("Basic Attack did 3.25 damage to Training Dummy."::equals).count());
|
||||
assertEquals(before, sessions.currentRecord(active).orElseThrow()); assertArrayEquals(inventory, player.core.snapshot());
|
||||
assertTrue(rows("training_dummy.measured").isEmpty(), "A failed intent write must not fabricate export success");
|
||||
verify(ports.entity(), never()).setHealth(anyDouble()); verify(ports.entity(), never()).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
assertTrue(rows("combat.damage").isEmpty()); assertTrue(rows("combat.rewarded").isEmpty());
|
||||
} finally {
|
||||
f.plugin.getLogger().removeHandler(handler); java.nio.file.Files.deleteIfExists(intents); java.nio.file.Files.move(backup, intents);
|
||||
}
|
||||
((games.dmg.fantasystick.events.TrainingEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditEvents")).replay();
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, nativeCalls.get()); assertTrue(rows("training_dummy.measured").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String suffix) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).toList(); }
|
||||
catch (Exception error) { throw new AssertionError("Cannot parse native dummy safety evidence", error); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.training.TrainingDummy;
|
||||
import games.dmg.fantasystick.training.TrainingDummyDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class DummyRegistrationRevisionTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void realRegistrationPersistsIndependentUuidAndRemoveRestoreReloadRetainIt() throws Exception {
|
||||
var repository = new YamlTrainingDummyRepository(data);
|
||||
TrainingDummy registered;
|
||||
try (var directory = new TrainingDummyDirectory(repository)) {
|
||||
registered = directory.place("Revision Target", UUID.randomUUID(), 1.25, 64, -2.5, 90, 10).get();
|
||||
var first = row();
|
||||
assertTrue(first.containsKey("registration-revision"), "New durable dummies must carry their real registration revision UUID");
|
||||
var revision = UUID.fromString(first.get("registration-revision").toString());
|
||||
assertNotEquals(registered.id(), revision, "Dummy identity is not a fabricated registration revision");
|
||||
assertEquals(registered.id().toString(), first.get("dummy-id"));
|
||||
assertEquals(registered, repository.load().getFirst());
|
||||
assertEquals(registered, directory.remove(registered.id()).get().orElseThrow());
|
||||
assertTrue(repository.load().isEmpty());
|
||||
assertEquals(registered, directory.restore(registered).get());
|
||||
assertEquals(revision.toString(), row().get("registration-revision"));
|
||||
}
|
||||
try (var reloaded = new TrainingDummyDirectory(repository)) {
|
||||
assertEquals(registered, reloaded.find(registered.id()).orElseThrow());
|
||||
assertEquals(registered, repository.load().getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void legacyStartupReadNeverInventsRevisionOrRewritesRegistration() throws Exception {
|
||||
var legacy = new TrainingDummy(UUID.randomUUID(), "Legacy Target", UUID.randomUUID(), 1.25, 64, -2.5, 90, 10);
|
||||
var repository = new YamlTrainingDummyRepository(data); repository.save(List.of(legacy));
|
||||
assertFalse(row().containsKey("registration-revision"));
|
||||
var original = Files.readAllBytes(data.resolve("training-dummies.yml"));
|
||||
assertEquals(List.of(legacy), repository.load());
|
||||
try (var directory = new TrainingDummyDirectory(repository)) { assertEquals(List.of(legacy), directory.all()); }
|
||||
assertArrayEquals(original, Files.readAllBytes(data.resolve("training-dummies.yml")));
|
||||
repository.save(repository.load()); assertFalse(row().containsKey("registration-revision"));
|
||||
}
|
||||
|
||||
private Map<?, ?> row() throws Exception {
|
||||
var document = new Yaml().load(Files.readString(data.resolve("training-dummies.yml")));
|
||||
assertInstanceOf(Map.class, document);
|
||||
var dummies = ((Map<?, ?>) document).get("dummies"); assertInstanceOf(List.class, dummies);
|
||||
assertInstanceOf(Map.class, ((List<?>) dummies).getFirst());
|
||||
return (Map<?, ?>) ((List<?>) dummies).getFirst();
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,41 @@ class PlayerFeedbackPreferencesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedAuditAcknowledgementMustDrainBeforeRestart() throws Exception {
|
||||
UUID account = UUID.randomUUID(); UUID action = UUID.randomUUID();
|
||||
var preferences = new PlayerFeedbackPreferences(data);
|
||||
var update = preferences.setPlayerAuras(account, true,
|
||||
new PlayerFeedbackPreferences.EventIntent(action, java.util.Map.of("published", "true")))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(update));
|
||||
var field = PlayerFeedbackPreferences.class.getDeclaredField("worker"); field.setAccessible(true);
|
||||
var worker = (java.util.concurrent.ExecutorService) field.get(preferences);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var blocker = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
java.util.concurrent.CompletableFuture<Void> acknowledgement = null;
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
acknowledgement = preferences.acknowledgeEvent(action).toCompletableFuture();
|
||||
preferences.close();
|
||||
assertFalse(preferences.drained().toCompletableFuture().isDone(),
|
||||
"Orderly preference drain must wait for the accepted audit acknowledgement, not reopen stale intent");
|
||||
} finally {
|
||||
release.countDown(); preferences.close(); blocker.get(5, TimeUnit.SECONDS);
|
||||
if (acknowledgement != null) acknowledgement.get(5, TimeUnit.SECONDS);
|
||||
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
try (var reopened = new PlayerFeedbackPreferences(data)) {
|
||||
assertTrue(reopened.playerAurasEnabled(account));
|
||||
assertTrue(reopened.pendingEvents().isEmpty());
|
||||
assertTrue(reopened.allParticlesEnabled(account)); assertTrue(reopened.enabled(account));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
|
||||
UUID account = UUID.randomUUID();
|
||||
var preferences = new PlayerFeedbackPreferences(data);
|
||||
|
||||
Reference in new issue
Block a user