Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d5ea82f1 | ||
|
|
40e41d6b7a | ||
|
|
b493cfe192 | ||
|
|
3d045ff99b |
No files matched your search
@@ -1,14 +1,15 @@
|
||||
# Fantasy Stick
|
||||
|
||||
**Equipment packing/restoration (US-002) is complete with automated verification. The full RPG remains in development and is not deployment-ready.**
|
||||
**Transferable characters and standalone names (US-001), equipment restoration (US-002), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting and equipment-only offhand sessions are assembled; fantasy health/nutrition, display identity, book UI, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health, display and spell interactions remain incomplete.
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, equipment-only offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; fantasy health/nutrition, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health and spell interactions remain incomplete.
|
||||
|
||||
## Build and verify
|
||||
|
||||
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
|
||||
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
|
||||
- Node.js 20 or later and npm for the TypeScript release-tool tests.
|
||||
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled.
|
||||
|
||||
```sh
|
||||
./gradlew clean check jar
|
||||
@@ -99,7 +100,7 @@ Nine native lifecycle tests load the actual packaged entrypoint through a test c
|
||||
|
||||
Vanilla retains responsibility for item movement, XP charging and anvil damage. The inspected event fires at the start of native accepted-take processing, before that continuation; the observer does not make native completion contingent on YAML acknowledgment. Failure/crash may leave item presentation ahead of the saved name. The original saved progress is retained and captured IDs are logged; there is no automatic item/XP rollback, reissue or guaranteed retry.
|
||||
|
||||
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation and fantasy sessions remain incomplete. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
|
||||
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation now refreshes after acknowledged saves, including renaming a copied reference held by someone else; stale session/revision callbacks cannot rename a replacement holder. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
|
||||
|
||||
## Native health ceiling (activation still unwired)
|
||||
|
||||
@@ -121,7 +122,7 @@ Six unit tests cover exclusion, transfer/switch release, stale callbacks, invali
|
||||
|
||||
Version-1 box metadata uses `fantasystick:storage_version`, `storage_owner_id`, `storage_session_id` and `storage_slots`. Box positions 0–8 contain original hotbar slots 0–8; positions 9–12 contain boots, leggings, chest and head from player slots 36–39. These metadata fields are for identity/restoration, not the only copy of the items. Restoration searches main slots 9–35, so sorting relocation does not lose the box. Repeated requests, wrong identity, duplicate/malformed boxes, unexpected extra contents or occupied restoration slots do not overwrite/delete items. These are adapter error results, not a persistent administrator lock or completed recovery policy.
|
||||
|
||||
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers equipment-only sessions. Fantasy-health, nutrition and display snapshots remain separate unfinished features. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers equipment-only sessions. Fantasy-health and nutrition overrides remain separate unfinished features; standalone display projection is implemented below. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
|
||||
## Equipment sessions and safe box movement
|
||||
|
||||
@@ -131,15 +132,50 @@ The genuine **Fantasy Storage Box** contains actual original items. Picking it u
|
||||
|
||||
Exit removes controls and restores original slots once, locating the genuine box by owner/session rather than original index. A new head uses the freed box slot. Native player phase markers and the persisted receipt must agree for recovery; ambiguous state is preserved/reported, never guessed into replacement gear. Shutdown is best-effort and nonblocking; accepted cleanup drains before its receipt worker closes. Equipment stays vanilla-readable without this plugin.
|
||||
|
||||
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. Settings are read on enable; there is no reload command or deployed configuration change.
|
||||
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. The switch-delay setting is read on enable; there is no general reload command or deployed configuration change.
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
|
||||
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
|
||||
|
||||
## Standalone character presentation
|
||||
|
||||
Saved character identity/progress transfers between holders; ordinary equipment, vitals, return location and switch deadline do not. Native reference/session tests cover live/dead/invalid/copied identities, concurrent exclusion, orderly switches and legitimate backend revival. The complete Revive spell remains unfinished.
|
||||
|
||||
ProtocolLib-backed presentation shows the full literal saved name above the player, in chat and in the player list, without changing the server account name/UUID/profile, scoreboard membership, ordinary display/list fields or authoritative name-tag-distance attribute. A detached client-only TextDisplay replaces the native overhead label; display-name-only player-info packets and the chat renderer's sender-name argument cover the other surfaces. No signed message-body or recipient rewrite is introduced.
|
||||
|
||||
Output resources belong to a player connection/session, not the sheet. Exit/defeat/lifecycle cleanup withdraws them independently of successful gear restoration, using current ordinary server state rather than replaying stale fantasy names. Renames refresh only after authoritative file acknowledgment; future sessions also reload saved names. Reconnect/restart discards client-only labels, not ordinary identity data.
|
||||
|
||||
**Cross-plugin display compatibility is unsupported**, as explicitly requested. There is no Stealth/Leaf/Ignis bridge or nickname/concealment guarantee. Native/platform visibility and ordinary cancellations are covered; supplemental presentation-owned packets bypass ProtocolLib filters to avoid recursive projection. Names can therefore conflict with other plugins' presentation or packet-only concealment. This does not disable ordinary protection or inventory safeguards.
|
||||
|
||||
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is described below; its verification does not complete the remaining fantasy health, nutrition or combat features.
|
||||
|
||||
## Optional Adventure mode
|
||||
|
||||
`adventure-mode: true` is the default. A new entry captures this policy; enabled entries save the holder's original game mode in the player/session receipt before changing it to Adventure. Disabled entries leave the mode untouched, as do holders already in Adventure. This state never transfers with the character.
|
||||
|
||||
The admin command saves on a worker, preserving unrelated configuration values, before publishing its effective value. Invalid values and failed writes leave that value unchanged. Already active sessions keep their captured policy. Accepted writes drain without blocking shutdown; immediate re-enable defers startup asynchronously until those writes finish, then reads the saved policy.
|
||||
|
||||
Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Adventure change. Observed later admin changes relinquish ownership, including an observed change away and back. Native cancellation refuses entry without packing; a cancelled restoration retains its receipt for inspection/retry. Interrupted or mismatched player markers are not replay permission. Mode cleanup is independent of uncertain equipment cleanup.
|
||||
|
||||
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
|
||||
|
||||
## Character-book parties and combat protection
|
||||
|
||||
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
|
||||
|
||||
A disconnected leader remains leader while another member is online. All-offline parties, invitations and book views are discarded on shutdown/restart. This does not persist or restore party rosters.
|
||||
|
||||
Native damage protection blocks friendly fire between active fantasy party members and player-attributable mixed-mode damage in both directions. Ordinary-versus-ordinary and environmental/mob combat retain their native rules and prior cancellations. Projectiles, TNT, fangs, clouds, fireworks and pet attacks retain player attribution; delayed fantasy sources cannot borrow a new session after exit, transfer or reconnect. Valid native healing is unchanged; stale projectile impacts are rejected before execution. Full RPG attacks and support/XP abilities remain separate unfinished work.
|
||||
|
||||
Attributable poison, wither and burning retain bounded source/recipient provenance in native entity/player PDC (`combat_origin_v1`, `combat_dot_*_v1`). Each damage tick checks current membership, source/recipient session identity and native effect state. Poison/wither hidden layers keep their own source rather than inheriting a covering effect's origin. Removal, expiry and unrelated replacement clear only the matching provenance; cleanup does not remove native effects or change health, equipment, character YAML or XP. A tick-driven cleanup checks only the three relevant player metadata keys and stops on disable. Native PDC serialization retains source proofs across reload; ended fantasy sessions remain invalid, while parties themselves never survive restart. These are native save guarantees, not cross-save atomicity or dedicated cross-plugin integration.
|
||||
|
||||
Automated party tests exercise actual packaged listeners, native book custom-click payloads, native event dispatch, effect-merging semantics and PDC serialization. Player/network/world execution remains represented by explicit test ports; live multiplayer testing and deployment are separate.
|
||||
|
||||
## CI and publication
|
||||
|
||||
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
|
||||
@@ -25,6 +25,8 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
@@ -71,6 +73,8 @@ val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||
@@ -78,6 +82,8 @@ dependencies {
|
||||
val nativeRuntimeJars = files(fileTree(nativeRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareNativeRuntime)
|
||||
// Pinned output-only packet codec; native classes remain provided by the server, never bundled.
|
||||
sourceSets.main.get().compileClasspath += nativeRuntimeJars
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation;
|
||||
import games.dmg.fantasystick.presentation.CharacterChatNames;
|
||||
import games.dmg.fantasystick.presentation.ProtocolPresentation;
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingController;
|
||||
@@ -17,7 +21,7 @@ import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Crafting, names and equipment-only sessions; health, nutrition, display and spells remain inactive. */
|
||||
/** Crafting, equipment sessions and standalone names; health, nutrition and spells remain inactive. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
@@ -27,13 +31,39 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private EquipmentSessionStore receipts;
|
||||
private EquipmentSessions sessions;
|
||||
private EquipmentSessionEvents sessionEvents;
|
||||
private games.dmg.fantasystick.book.CharacterBookController books;
|
||||
private games.dmg.fantasystick.party.PartyRoster parties;
|
||||
private games.dmg.fantasystick.party.PartyEvents partyEvents;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private volatile Object lifetime;
|
||||
private CharacterPresentation presentation;
|
||||
private ProtocolPresentation protocolPresentation;
|
||||
private CharacterChatNames chatNames;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings adventureSettings;
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
Object generation = new Object();
|
||||
lifetime = generation;
|
||||
if (!configurationDrain.toCompletableFuture().isDone()) {
|
||||
configurationDrain.whenComplete((ignored, failure) -> {
|
||||
if (lifetime != generation) return;
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (lifetime == generation && isEnabled()) onEnable();
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
getLogger().log(Level.WARNING, "Configuration startup continuation unavailable", rejected);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
reloadConfig();
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var defaults = ProgressionRules.defaults();
|
||||
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
|
||||
@@ -45,21 +75,54 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
|
||||
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
|
||||
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
|
||||
java.util.function.Consumer<Throwable> presentationFailures = problem ->
|
||||
getLogger().log(Level.WARNING, "Character presentation failed", problem);
|
||||
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
|
||||
var localPresentation = new java.util.concurrent.atomic.AtomicReference<CharacterPresentation>();
|
||||
var installationFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
protocolPresentation = new ProtocolPresentation(this, ProtocolLibrary.getProtocolManager(),
|
||||
() -> localPresentation.get().snapshot(), problem -> {
|
||||
installationFailure.compareAndSet(null, problem);
|
||||
presentationFailures.accept(problem);
|
||||
});
|
||||
presentation = new CharacterPresentation(protocolPresentation, nextTick,
|
||||
() -> getServer().isPrimaryThread(), presentationFailures, characters::find);
|
||||
localPresentation.set(presentation);
|
||||
chatNames = new CharacterChatNames(presentation::name);
|
||||
protocolPresentation.install();
|
||||
if (installationFailure.get() != null) {
|
||||
throw new IllegalStateException("Could not install character presentation", installationFailure.get());
|
||||
}
|
||||
guard = new ManualCraftingGuard();
|
||||
anvilNames = new CharacterAnvilNames(characters,
|
||||
problem -> getLogger().log(Level.WARNING, "Character name update was refused or failed for player "
|
||||
+ problem.player() + "; character " + problem.character(), problem.cause()), getServer().getItemFactory());
|
||||
+ problem.player() + "; character " + problem.character(), problem.cause()), getServer().getItemFactory(),
|
||||
presentation::acknowledged);
|
||||
receipts = new EquipmentSessionStore(getDataFolder().toPath(), Clock.systemUTC());
|
||||
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
|
||||
java.util.function.Consumer<EquipmentSessions.Failure> diagnostics = problem ->
|
||||
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
|
||||
+ "; session " + problem.session(), problem.cause());
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics);
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics);
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()));
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
@@ -67,59 +130,87 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
|
||||
} catch (RuntimeException failure) {
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
lifetime = null;
|
||||
if (admin != null) { admin.close(); admin = null; }
|
||||
if (sessionEvents != null) sessionEvents.close();
|
||||
if (crafting != null) crafting.close();
|
||||
var closingSessions = sessions;
|
||||
var closingReceipts = receipts;
|
||||
sessions = null;
|
||||
receipts = null; // A reentrant disable cannot close the store ahead of this sweep.
|
||||
if (closingSessions != null) closingSessions.close();
|
||||
if (sessionEvents != null) {
|
||||
org.bukkit.event.HandlerList.unregisterAll(sessionEvents);
|
||||
sessionEvents = null;
|
||||
// Detach this enable's resources before any native callback can reenter disable.
|
||||
var closingPresentation = presentation; presentation = null;
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
var closingParties = parties; parties = null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
var closingSessions = sessions; sessions = null;
|
||||
var closingReceipts = receipts; receipts = null;
|
||||
var closingCharacters = characters; characters = null;
|
||||
var closingSettings = adventureSettings; adventureSettings = null;
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingSettings != null) {
|
||||
configurationDrain = closingSettings.drained();
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (recipeRegistered) {
|
||||
try {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false;
|
||||
} catch (RuntimeException failure) {
|
||||
getLogger().log(Level.WARNING, "Could not remove the character-sheet recipe; continuing shutdown cleanup", failure);
|
||||
}
|
||||
}
|
||||
if (crafting != null) {
|
||||
org.bukkit.event.HandlerList.unregisterAll(crafting);
|
||||
crafting = null;
|
||||
}
|
||||
if (guard != null) {
|
||||
org.bukkit.event.HandlerList.unregisterAll(guard);
|
||||
guard = null;
|
||||
}
|
||||
if (anvilNames != null) {
|
||||
org.bukkit.event.HandlerList.unregisterAll(anvilNames);
|
||||
anvilNames = null;
|
||||
}
|
||||
if (characters != null) {
|
||||
characters.close();
|
||||
characters = null;
|
||||
}
|
||||
// Do not await IO on the server thread. Capture this lifetime's store: an old
|
||||
// preparation may still acknowledge and clear its never-packed receipt after re-enable.
|
||||
if (closingReceipts != null) {
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
unregister(closingAnvil);
|
||||
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
// Never await IO on the server thread or close receipts ahead of accepted preparations.
|
||||
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
|
||||
if (closingSessions == null) closingReceipts.close();
|
||||
else closingSessions.drained().whenComplete((ignored, failure) -> closingReceipts.close());
|
||||
});
|
||||
}
|
||||
|
||||
private void unregister(org.bukkit.event.Listener listener) {
|
||||
if (listener != null) cleanup("listener " + listener.getClass().getSimpleName(),
|
||||
() -> org.bukkit.event.HandlerList.unregisterAll(listener));
|
||||
}
|
||||
|
||||
private void cleanup(String resource, Runnable action) {
|
||||
try { action.run(); }
|
||||
catch (RuntimeException | LinkageError failure) {
|
||||
try { getLogger().log(Level.WARNING, "Could not close " + resource + "; continuing shutdown cleanup", failure); }
|
||||
catch (RuntimeException | LinkageError ignored) { /* Diagnostics must not prevent remaining cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
partyControls.forget(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
if (closed || pending.get(owner) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
input.player().sendMessage("This character is no longer available.");
|
||||
return;
|
||||
}
|
||||
try { input.player().openBook(render(record.orElseThrow(), input)); }
|
||||
catch (RuntimeException error) { partyControls.forget(owner); report(input, error); }
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(owner, ticket);
|
||||
report(input, rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + record.health().min(maximum).stripTrailingZeros().toPlainString() + "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var pages = new java.util.ArrayList<Component>(List.of(Component.text(first.toString()), Component.text(second)));
|
||||
pages.addAll(partyControls.pages(input));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void report(ControlInput input, Throwable failure) {
|
||||
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; pending.clear(); partyControls.close(); }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import io.papermc.paper.connection.PlayerGameConnection;
|
||||
import io.papermc.paper.event.player.PlayerCustomClickEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.nbt.api.BinaryTagHolder;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** One single-use view per player; client payloads select server-owned actions, never arbitrary UUIDs/commands. */
|
||||
public final class PartyBookControls implements AutoCloseable {
|
||||
private static final Key ACTION = Key.key("fantasystick", "party_book");
|
||||
private static final Pattern PAYLOAD = Pattern.compile("\"([a-f0-9-]{36}):([0-9]{1,3})\"");
|
||||
private static final int PAGE_SIZE = 8;
|
||||
private enum Menu { ROOT, PLAYERS, INVITATIONS }
|
||||
private record View(String nonce, ControlInput input, List<Runnable> actions) {
|
||||
private View { actions = List.copyOf(actions); }
|
||||
}
|
||||
private final Map<UUID, View> views = new HashMap<>();
|
||||
private final PartyRoster parties;
|
||||
private final Server server;
|
||||
private final Function<UUID, Component> names;
|
||||
private final Consumer<ControlInput> refresh;
|
||||
private final BiConsumer<ControlInput, Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PartyBookControls(PartyRoster parties, Server server, Function<UUID, Component> names,
|
||||
Consumer<ControlInput> refresh, BiConsumer<ControlInput, Throwable> failures) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
this.refresh = Objects.requireNonNull(refresh);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
var owner = input.session().player();
|
||||
var party = parties.partyOf(owner);
|
||||
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
|
||||
if (menu == Menu.ROOT) {
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
|
||||
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
|
||||
build.pages.add(summary);
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
boolean leader = group.leader().equals(owner);
|
||||
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
|
||||
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
|
||||
if (leader) {
|
||||
summary = summary.append(Component.newline())
|
||||
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
|
||||
if (group.members().size() < 6) summary = summary.append(Component.newline())
|
||||
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
|
||||
}
|
||||
build.pages.add(summary);
|
||||
for (var member : group.members()) {
|
||||
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
|
||||
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
|
||||
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
|
||||
build.pages.add(row);
|
||||
}
|
||||
}
|
||||
} else if (menu == Menu.PLAYERS) {
|
||||
var group = party.orElseThrow();
|
||||
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
|
||||
.map(player -> player.getUniqueId()).filter(id -> parties.partyOf(id).isEmpty())
|
||||
.sorted(Comparator.comparing(UUID::toString)).toList();
|
||||
int start = start(offset, candidates.size());
|
||||
build.navigation("Invite players", Menu.PLAYERS, start, candidates.size());
|
||||
for (int i = start; i < Math.min(candidates.size(), start + PAGE_SIZE); i++) {
|
||||
UUID target = candidates.get(i);
|
||||
build.pages.add(Component.text("Invite\n\n").append(names.apply(target)).append(Component.text("\n\n"))
|
||||
.append(build.button("Invite", () -> mutate(input, () -> {
|
||||
boolean invited = parties.invite(owner, group.id(), target).isPresent();
|
||||
if (invited) {
|
||||
var recipient = server.getPlayer(target);
|
||||
if (recipient != null && recipient.isOnline()) recipient.sendMessage(
|
||||
"Party invitation received. Open your character book to accept within 60 seconds.");
|
||||
}
|
||||
return invited;
|
||||
}))));
|
||||
}
|
||||
} else {
|
||||
var invitations = parties.invitationsFor(owner);
|
||||
int start = start(offset, invitations.size());
|
||||
build.navigation("Invitations", Menu.INVITATIONS, start, invitations.size());
|
||||
for (int i = start; i < Math.min(invitations.size(), start + PAGE_SIZE); i++) {
|
||||
var invitation = invitations.get(i);
|
||||
build.pages.add(Component.text("Invitation from\n\n").append(names.apply(invitation.leader()))
|
||||
.append(Component.text("\n\n")).append(build.button("Accept",
|
||||
() -> mutate(input, () -> parties.accept(owner, invitation.id())))));
|
||||
}
|
||||
}
|
||||
views.put(owner, new View(build.nonce, input, build.actions));
|
||||
return List.copyOf(build.pages);
|
||||
}
|
||||
|
||||
private static int start(int requested, int size) {
|
||||
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
|
||||
}
|
||||
|
||||
private final class Pages {
|
||||
final ControlInput input;
|
||||
final String nonce = UUID.randomUUID().toString();
|
||||
final List<Component> pages = new ArrayList<>();
|
||||
final List<Runnable> actions = new ArrayList<>();
|
||||
Pages(ControlInput input) { this.input = input; }
|
||||
Component button(String label, Runnable action) {
|
||||
int index = actions.size();
|
||||
actions.add(action);
|
||||
return Component.text("[" + label + "]", NamedTextColor.DARK_BLUE).clickEvent(ClickEvent.custom(ACTION,
|
||||
BinaryTagHolder.binaryTagHolder("\"" + nonce + ":" + index + "\"")));
|
||||
}
|
||||
void navigation(String title, Menu menu, int start, int count) {
|
||||
Component page = Component.text(title + "\n\n" + (count == 0 ? "None available\n\n" : ""))
|
||||
.append(button("Character book", () -> refresh.accept(input)));
|
||||
if (start > 0) page = page.append(Component.newline()).append(button("Previous", () -> show(input, menu, start - PAGE_SIZE)));
|
||||
if (start + PAGE_SIZE < count) page = page.append(Component.newline()).append(button("Next", () -> show(input, menu, start + PAGE_SIZE)));
|
||||
pages.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
private void show(ControlInput input, Menu menu, int offset) {
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Party"); meta.setAuthor("Fantasy Stick");
|
||||
meta.pages(render(input, menu, offset));
|
||||
item.setItemMeta(meta);
|
||||
input.player().openBook(item);
|
||||
}
|
||||
|
||||
public void click(PlayerCustomClickEvent event) {
|
||||
if (closed || !ACTION.equals(event.getIdentifier()) || !(event.getCommonConnection() instanceof PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = views.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
var tag = event.getTag();
|
||||
if (tag == null) return;
|
||||
String raw = tag.string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int index = Integer.parseInt(match.group(2));
|
||||
if (index >= view.actions().size()) return;
|
||||
views.remove(player.getUniqueId());
|
||||
if (!view.input().stillCurrent()) return;
|
||||
try { view.actions().get(index).run(); }
|
||||
catch (RuntimeException failure) { forget(player.getUniqueId()); failures.accept(view.input(), failure); }
|
||||
}
|
||||
|
||||
private void mutate(ControlInput input, BooleanSupplier operation) {
|
||||
if (!operation.getAsBoolean()) input.player().sendMessage("Party action unavailable: membership, authority or invitation changed.");
|
||||
refresh.accept(input);
|
||||
}
|
||||
public void forget(UUID player) { views.remove(player); }
|
||||
@Override public void close() { closed = true; views.clear(); }
|
||||
}
|
||||
@@ -16,12 +16,20 @@ public final class CharacterAnvilNames implements Listener {
|
||||
private final CharacterStorage storage;
|
||||
private final Consumer<Failure> failures;
|
||||
private final org.bukkit.inventory.ItemFactory items;
|
||||
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
|
||||
|
||||
/** Diagnostics may run on the persistence worker and must not access native player state. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
|
||||
this(storage, failures, items, ignored -> { });
|
||||
}
|
||||
|
||||
/** Acknowledgments run after file commit, possibly on the worker; never access Bukkit in that callback. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items,
|
||||
Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged) {
|
||||
this.items = java.util.Objects.requireNonNull(items);
|
||||
this.storage = java.util.Objects.requireNonNull(storage);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
this.acknowledged = java.util.Objects.requireNonNull(acknowledged);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -37,7 +45,9 @@ public final class CharacterAnvilNames implements Listener {
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = id.orElseThrow();
|
||||
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) failures.accept(new Failure(player, character, failure));
|
||||
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
|
||||
try { acknowledged.accept(renamed.orElseThrow()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,14 @@ public final class SessionControls {
|
||||
}
|
||||
}
|
||||
|
||||
/** A structurally recognized selectable ability; still not session/cast authorization. */
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
|
||||
final class CombatSources {
|
||||
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
|
||||
record Origin(UUID player, UUID session, UUID character) {
|
||||
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
|
||||
static Origin decode(String value) {
|
||||
if (value == null || value.length() > 110) return null;
|
||||
var parts = value.split("/", -1);
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
var origin = new Origin(UUID.fromString(parts[0]), parts[1].equals("-") ? null : UUID.fromString(parts[1]),
|
||||
parts[2].equals("-") ? null : UUID.fromString(parts[2]));
|
||||
return (origin.session == null) == (origin.character == null) && value.equals(origin.encode()) ? origin : null;
|
||||
} catch (IllegalArgumentException malformed) { return null; }
|
||||
}
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
Origin player(UUID player) {
|
||||
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
|
||||
.orElseGet(() -> new Origin(player, null, null));
|
||||
}
|
||||
boolean current(Origin origin) {
|
||||
return origin.equals(player(origin.player())) && (origin.session() == null
|
||||
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
|
||||
}
|
||||
Origin read(Entity entity) { return read(entity, 0); }
|
||||
private Origin read(Entity entity, int depth) {
|
||||
if (entity == null || depth >= 8) return null;
|
||||
if (entity instanceof Player player) return player(player.getUniqueId());
|
||||
var saved = Origin.decode(entity.getPersistentDataContainer().get(ORIGIN, PersistentDataType.STRING));
|
||||
if (saved != null) return saved;
|
||||
if (entity instanceof org.bukkit.entity.Tameable pet && pet.getOwnerUniqueId() != null) {
|
||||
return player(pet.getOwnerUniqueId()); // A pet's new attack, not its old taming session.
|
||||
}
|
||||
var owner = owner(entity, depth + 1);
|
||||
// No launch proof (e.g. predating plugin startup): never assign a current fantasy session.
|
||||
return owner == null ? null : new Origin(owner.player(), null, null);
|
||||
}
|
||||
private Origin owner(Entity entity, int depth) {
|
||||
if (entity instanceof Projectile projectile && projectile.getShooter() instanceof Entity shooter) return read(shooter, depth);
|
||||
if (entity instanceof org.bukkit.entity.Firework firework && firework.getSpawningEntity() != null) return player(firework.getSpawningEntity());
|
||||
if (entity instanceof org.bukkit.entity.TNTPrimed tnt) return read(tnt.getSource(), depth);
|
||||
if (entity instanceof org.bukkit.entity.EvokerFangs fangs) return read(fangs.getOwner(), depth);
|
||||
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Entity source) return read(source, depth);
|
||||
return null;
|
||||
}
|
||||
void launch(Entity entity) {
|
||||
if (entity.getPersistentDataContainer().has(ORIGIN)) return;
|
||||
store(entity, owner(entity, 0));
|
||||
}
|
||||
void store(Entity entity, Origin origin) {
|
||||
if (origin != null) entity.getPersistentDataContainer().set(ORIGIN, PersistentDataType.STRING, origin.encode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded provenance for native damage layers, not copied effects or character/XP state.
|
||||
* Native effects remain authoritative. Durations reconcile in native ticks (also across offline saves),
|
||||
* so a hidden effect regaining the foreground keeps its own source, not a covering effect's source.
|
||||
*/
|
||||
final class OngoingDamage {
|
||||
record Application(CombatSources.Origin source, CombatSources.Origin target) { }
|
||||
private record Layer(int amplifier, int duration, Application application) { }
|
||||
private static final List<DamageCause> CAUSES = List.of(DamageCause.POISON, DamageCause.WITHER, DamageCause.FIRE_TICK);
|
||||
private static NamespacedKey key(DamageCause cause) {
|
||||
return new NamespacedKey("fantasystick", "combat_dot_" + cause.name().toLowerCase(java.util.Locale.ROOT) + "_v1");
|
||||
}
|
||||
static DamageCause cause(PotionEffectType type) {
|
||||
if (type.equals(PotionEffectType.POISON)) return DamageCause.POISON;
|
||||
if (type.equals(PotionEffectType.WITHER)) return DamageCause.WITHER;
|
||||
return null;
|
||||
}
|
||||
static void clear(Player player, DamageCause cause) { player.getPersistentDataContainer().remove(key(cause)); }
|
||||
static void potion(Player player, PotionEffect old, PotionEffect incoming, boolean override, Application application) {
|
||||
var cause = cause(incoming.getType());
|
||||
var layers = old == null ? new ArrayList<Layer>() : reconcile(load(player, cause), old.getAmplifier(), old.getDuration());
|
||||
var next = new Layer(incoming.getAmplifier(), incoming.getDuration(), application);
|
||||
// Purpur also stores a longer weaker effect behind the active one when override=false.
|
||||
if (layers.isEmpty() || override || next.amplifier() < layers.getFirst().amplifier()
|
||||
&& longer(next.duration(), layers.getFirst().duration())) merge(layers, 0, next);
|
||||
save(player, cause, layers);
|
||||
}
|
||||
private static boolean longer(int a, int b) { return a == -1 ? b != -1 : b != -1 && a > b; }
|
||||
private static void merge(List<Layer> layers, int index, Layer next) {
|
||||
if (index == layers.size()) { layers.add(next); return; }
|
||||
var old = layers.get(index);
|
||||
if (next.amplifier() > old.amplifier()) {
|
||||
if (longer(old.duration(), next.duration())) layers.add(index, next);
|
||||
else layers.set(index, next);
|
||||
} else if (next.amplifier() == old.amplifier()) {
|
||||
if (longer(next.duration(), old.duration())) layers.set(index, next);
|
||||
} else if (longer(next.duration(), old.duration())) merge(layers, index + 1, next);
|
||||
}
|
||||
static void fire(Player player, int duration, Application application) {
|
||||
save(player, DamageCause.FIRE_TICK, List.of(new Layer(0, duration, application)));
|
||||
}
|
||||
static Application read(Player player, DamageCause cause) {
|
||||
if (!CAUSES.contains(cause) || !player.getPersistentDataContainer().has(key(cause))) return null;
|
||||
List<Layer> layers = load(player, cause);
|
||||
if (cause == DamageCause.FIRE_TICK) {
|
||||
int fire = player.getFireTicks();
|
||||
layers = fire > 0 && !layers.isEmpty() && fire <= layers.getFirst().duration()
|
||||
? List.of(new Layer(0, fire, layers.getFirst().application())) : List.of();
|
||||
} else {
|
||||
var effect = player.getPotionEffect(cause == DamageCause.POISON ? PotionEffectType.POISON : PotionEffectType.WITHER);
|
||||
layers = effect == null ? List.of() : reconcile(layers, effect.getAmplifier(), effect.getDuration());
|
||||
}
|
||||
save(player, cause, layers);
|
||||
return layers.isEmpty() ? null : layers.getFirst().application();
|
||||
}
|
||||
static void sweep(Player player) { for (var cause : CAUSES) read(player, cause); }
|
||||
|
||||
private static ArrayList<Layer> reconcile(List<Layer> saved, int amplifier, int duration) {
|
||||
var result = new ArrayList<Layer>();
|
||||
for (int i = 0; i < saved.size(); i++) {
|
||||
var layer = saved.get(i);
|
||||
if (layer.amplifier() != amplifier || longer(duration, layer.duration())) continue;
|
||||
int elapsed = layer.duration() == -1 ? 0 : layer.duration() - duration;
|
||||
for (int j = i; j < saved.size(); j++) {
|
||||
var remaining = saved.get(j);
|
||||
int ticks = remaining.duration() == -1 ? -1 : Math.max(0, remaining.duration() - elapsed);
|
||||
if (ticks != 0) result.add(new Layer(remaining.amplifier(), ticks, remaining.application()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Unknown/out-of-band replacement never borrows old attribution or gains party immunity.
|
||||
if (duration != 0) result.add(new Layer(amplifier, duration, null));
|
||||
return result;
|
||||
}
|
||||
private static List<Layer> load(Player player, DamageCause cause) {
|
||||
String value = player.getPersistentDataContainer().get(key(cause), PersistentDataType.STRING);
|
||||
if (value == null || value.length() > 65536) return List.of();
|
||||
var result = new ArrayList<Layer>();
|
||||
try {
|
||||
for (var row : value.split("\n", -1)) {
|
||||
var cells = row.split(",", -1);
|
||||
if (cells.length != 4 || result.size() >= 256) return List.of();
|
||||
int amplifier = Integer.parseInt(cells[0]), duration = Integer.parseInt(cells[1]);
|
||||
if (amplifier < 0 || amplifier > 255 || duration < -1 || duration == 0
|
||||
|| !result.isEmpty() && amplifier >= result.getLast().amplifier()) return List.of();
|
||||
Application application = null;
|
||||
if (!cells[2].equals("-") || !cells[3].equals("-")) {
|
||||
var source = CombatSources.Origin.decode(cells[2]);
|
||||
var target = CombatSources.Origin.decode(cells[3]);
|
||||
if (source == null || target == null || !target.player().equals(player.getUniqueId())) return List.of();
|
||||
application = new Application(source, target);
|
||||
}
|
||||
result.add(new Layer(amplifier, duration, application));
|
||||
}
|
||||
} catch (IllegalArgumentException malformed) { return List.of(); }
|
||||
return result;
|
||||
}
|
||||
private static void save(Player player, DamageCause cause, List<Layer> layers) {
|
||||
if (layers.stream().noneMatch(layer -> layer.application() != null)) { clear(player, cause); return; }
|
||||
var value = new StringBuilder();
|
||||
for (var layer : layers) {
|
||||
if (!value.isEmpty()) value.append('\n');
|
||||
value.append(layer.amplifier()).append(',').append(layer.duration()).append(',');
|
||||
var application = layer.application();
|
||||
value.append(application == null ? "-,-" : application.source().encode() + "," + application.target().encode());
|
||||
}
|
||||
player.getPersistentDataContainer().set(key(cause), PersistentDataType.STRING, value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntitySpawnEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
|
||||
/** Server-thread native damage protection; never uncancels another protection's decision. */
|
||||
public final class PartyCombat implements Listener, AutoCloseable {
|
||||
private org.bukkit.scheduler.BukkitTask cleanup;
|
||||
private boolean closed;
|
||||
private final PartyRoster parties;
|
||||
private final CombatSources sources;
|
||||
|
||||
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
|
||||
}, 1, 1);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (cleanup != null) cleanup.cancel();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void launch(EntitySpawnEvent event) { sources.launch(event.getEntity()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void splash(org.bukkit.event.entity.PotionSplashEvent event) {
|
||||
// Native splash passes only its owner to addEffect, losing the projectile's session.
|
||||
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
|
||||
// actual harmful applications and instantaneous damage have their own guards below.
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void staleLingering(LingeringPotionSplashEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void lingering(LingeringPotionSplashEvent event) {
|
||||
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
if (origin != null && (!sources.current(origin)
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void potion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|
||||
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
|
||||
var origin = sources.read(event.getSource());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var cause = OngoingDamage.cause(event.getModifiedType());
|
||||
if (cause == null) return;
|
||||
if (event.getNewEffect() == null) OngoingDamage.clear(target, cause);
|
||||
else {
|
||||
var origin = sources.read(event.getSource());
|
||||
OngoingDamage.potion(target, event.getOldEffect(), event.getNewEffect(), event.isOverride(), origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = sources.read(event.getCombuster());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedCombust(org.bukkit.event.entity.EntityCombustEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = event instanceof org.bukkit.event.entity.EntityCombustByEntityEvent entity
|
||||
? sources.read(entity.getCombuster()) : null;
|
||||
int duration = Math.max(target.getFireTicks(), (int) Math.ceil(event.getDuration() * 20));
|
||||
OngoingDamage.fire(target, duration, origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
boolean fantasy = origin.session() != null;
|
||||
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
|
||||
&& !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
/** Quit is authoritative even before Bukkit's online flag changes. */
|
||||
public final class PartyEvents implements Listener {
|
||||
private final PartyRoster parties;
|
||||
public PartyEvents(PartyRoster parties) { this.parties = Objects.requireNonNull(parties); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { parties.arrive(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(PlayerQuitEvent event) { parties.disconnect(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
|
||||
public final class PartyRoster implements AutoCloseable {
|
||||
public record Party(UUID id, UUID leader, List<UUID> members) {
|
||||
public Party { members = List.copyOf(members); }
|
||||
}
|
||||
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
|
||||
private final Map<UUID, Party> membership = new HashMap<>();
|
||||
private final Map<UUID, Invitation> invitations = new HashMap<>();
|
||||
private final Set<UUID> departed = new HashSet<>();
|
||||
private boolean closed;
|
||||
private final Clock clock;
|
||||
private final Predicate<UUID> online;
|
||||
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.online = Objects.requireNonNull(online);
|
||||
}
|
||||
public Optional<Party> create(UUID player) {
|
||||
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
|
||||
var party = new Party(UUID.randomUUID(), player, List.of(player));
|
||||
membership.put(player, party);
|
||||
return Optional.of(party);
|
||||
}
|
||||
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
|
||||
public List<Invitation> invitationsFor(UUID player) {
|
||||
expire();
|
||||
return invitations.values().stream().filter(i -> i.invitee().equals(player))
|
||||
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
|
||||
}
|
||||
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
|
||||
expire();
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
|
||||
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
|
||||
if (existing.isPresent()) return existing;
|
||||
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
|
||||
invitations.put(invitation.id(), invitation);
|
||||
return Optional.of(invitation);
|
||||
}
|
||||
public boolean accept(UUID player, UUID invitation) {
|
||||
expire();
|
||||
var offer = invitations.get(invitation);
|
||||
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
|
||||
var group = membership.get(offer.leader());
|
||||
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
|
||||
var members = new ArrayList<>(group.members());
|
||||
members.add(player);
|
||||
var updated = new Party(group.id(), group.leader(), members);
|
||||
for (var member : members) membership.put(member, updated);
|
||||
invitations.values().removeIf(i -> i.invitee().equals(player));
|
||||
return true;
|
||||
}
|
||||
public boolean kick(UUID leader, UUID party, UUID member) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
|
||||
removeMember(group, member);
|
||||
return true;
|
||||
}
|
||||
public boolean disband(UUID leader, UUID party) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null) return false;
|
||||
remove(group);
|
||||
return true;
|
||||
}
|
||||
private Party managedBy(UUID leader, UUID party) {
|
||||
var group = membership.get(leader);
|
||||
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
|
||||
}
|
||||
public boolean leave(UUID player, UUID party) {
|
||||
var group = membership.get(player);
|
||||
if (group == null || !group.id().equals(party) || !connected(player)) return false;
|
||||
if (group.leader().equals(player)) remove(group);
|
||||
else removeMember(group, player);
|
||||
return true;
|
||||
}
|
||||
private void removeMember(Party group, UUID player) {
|
||||
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
|
||||
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
|
||||
membership.remove(player);
|
||||
departed.remove(player);
|
||||
var updated = new Party(group.id(), group.leader(), remaining);
|
||||
for (var member : remaining) membership.put(member, updated);
|
||||
}
|
||||
public void arrive(UUID player) { departed.remove(player); }
|
||||
public void disconnect(UUID player) {
|
||||
var group = membership.get(player);
|
||||
if (group == null) return;
|
||||
departed.add(player);
|
||||
if (group.members().stream().noneMatch(this::connected)) remove(group);
|
||||
}
|
||||
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
|
||||
private void expire() {
|
||||
var now = clock.instant();
|
||||
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
|
||||
}
|
||||
private void remove(Party group) {
|
||||
for (var member : group.members()) membership.remove(member);
|
||||
departed.removeAll(group.members());
|
||||
invitations.values().removeIf(i -> i.party().equals(group.id()));
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
membership.clear();
|
||||
invitations.clear();
|
||||
departed.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Worker-owned config writes; publish a new-session policy only after durable file acknowledgment. */
|
||||
public final class AdventureSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public AdventureSettings(Path directory, boolean initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
AdventureSettings(Path directory, boolean initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
enabled = initial;
|
||||
}
|
||||
|
||||
public static boolean validate(Object configured) {
|
||||
if (configured == null) return true;
|
||||
if (configured instanceof Boolean value) return value;
|
||||
throw new IllegalArgumentException("adventure-mode must be true or false");
|
||||
}
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
values.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put("adventure-mode", value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
/** Drain accepted writes without waiting on the server thread. */
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
worker.shutdown();
|
||||
if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,15 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-equipment-session-persistence"));
|
||||
|
||||
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt) {
|
||||
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
|
||||
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null); }
|
||||
public Receipt {
|
||||
Objects.requireNonNull(sessionId);
|
||||
Objects.requireNonNull(characterId);
|
||||
timestamp(preparedAt);
|
||||
if (previousGameMode != null && !Set.of("SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR").contains(previousGameMode)) {
|
||||
throw new IllegalArgumentException("Invalid previous game mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,16 +110,21 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
return submit(() -> withLock(() -> load(player)));
|
||||
}
|
||||
|
||||
/** A receipt acknowledgment is a prerequisite for packing, not evidence that packing happened. */
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character) {
|
||||
return prepare(player, session, character, null);
|
||||
}
|
||||
|
||||
/** A receipt acknowledgment precedes mode/gear mutation; it is not proof either happened. */
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode) {
|
||||
Objects.requireNonNull(player);
|
||||
var receipt = new Receipt(session, character, clock.instant());
|
||||
var receipt = new Receipt(session, character, clock.instant(), previousGameMode);
|
||||
return submit(() -> withLock(() -> {
|
||||
var found = load(player);
|
||||
if (found.isPresent()) {
|
||||
var old = found.orElseThrow();
|
||||
if (old.pending() != null) {
|
||||
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())) {
|
||||
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())
|
||||
&& Objects.equals(previousGameMode, old.pending().previousGameMode())) {
|
||||
return old;
|
||||
}
|
||||
throw new IOException("Conflicting pending equipment session");
|
||||
@@ -282,8 +291,12 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
root.put("revision", state.revision().toString());
|
||||
if (state.pending() != null) {
|
||||
var receipt = state.pending();
|
||||
root.put("pending", Map.of("session-id", receipt.sessionId().toString(),
|
||||
"character-id", receipt.characterId().toString(), "prepared-at", receipt.preparedAt().toString()));
|
||||
var pending = new LinkedHashMap<String, Object>();
|
||||
pending.put("session-id", receipt.sessionId().toString());
|
||||
pending.put("character-id", receipt.characterId().toString());
|
||||
pending.put("prepared-at", receipt.preparedAt().toString());
|
||||
if (receipt.previousGameMode() != null) pending.put("previous-game-mode", receipt.previousGameMode());
|
||||
root.put("pending", pending);
|
||||
}
|
||||
if (state.lastExit() != null) {
|
||||
var exit = state.lastExit();
|
||||
@@ -304,8 +317,13 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
Exit exit = null;
|
||||
if (root.containsKey("pending")) {
|
||||
var keys = Set.of("session-id", "character-id", "prepared-at");
|
||||
var row = mapping(root.get("pending"), keys, keys);
|
||||
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")));
|
||||
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode"));
|
||||
String previousMode = null;
|
||||
if (row.containsKey("previous-game-mode")) {
|
||||
if (!(row.get("previous-game-mode") instanceof String value)) throw new IllegalArgumentException("Invalid previous mode");
|
||||
previousMode = value;
|
||||
}
|
||||
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode);
|
||||
}
|
||||
if (root.containsKey("last-exit")) {
|
||||
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
/** Substitutes only the renderer's sender-name argument; signed body and delivery stay native. */
|
||||
public final class CharacterChatNames implements Listener {
|
||||
private final Function<UUID, String> names;
|
||||
public CharacterChatNames(Function<UUID, String> names) { this.names = names; }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onChat(AsyncChatEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
String name = names.apply(event.getPlayer().getUniqueId());
|
||||
if (name == null) return;
|
||||
var literal = net.kyori.adventure.text.Component.text(name);
|
||||
var ordinary = event.renderer();
|
||||
event.renderer((source, ignoredName, message, viewer) -> ordinary.render(source, literal, message, viewer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Immutable session decisions shared by chat/packet threads; native output stays on the server thread. */
|
||||
public final class CharacterPresentation implements SessionPresentation, AutoCloseable {
|
||||
public record View(UUID holder, UUID session, UUID character, BigInteger revision, String name) { }
|
||||
public interface Output extends AutoCloseable {
|
||||
void show(Player player, View view);
|
||||
void rename(View view);
|
||||
void clear(View view);
|
||||
@Override void close();
|
||||
}
|
||||
private final Output output;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final BooleanSupplier mainThread;
|
||||
private final Consumer<Throwable> failures;
|
||||
private volatile Map<UUID, View> views = Map.of();
|
||||
private boolean closed;
|
||||
private java.util.function.Function<UUID, java.util.concurrent.CompletionStage<java.util.Optional<CharacterRecord>>> currentRecord =
|
||||
ignored -> java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.empty());
|
||||
public CharacterPresentation(Output output, Consumer<Runnable> nextTick, BooleanSupplier mainThread, Consumer<Throwable> failures) {
|
||||
this.output = output; this.nextTick = nextTick; this.mainThread = mainThread; this.failures = failures;
|
||||
}
|
||||
public CharacterPresentation(Output output, Consumer<Runnable> nextTick, BooleanSupplier mainThread, Consumer<Throwable> failures,
|
||||
java.util.function.Function<UUID, java.util.concurrent.CompletionStage<java.util.Optional<CharacterRecord>>> currentRecord) {
|
||||
this(output, nextTick, mainThread, failures);
|
||||
this.currentRecord = java.util.Objects.requireNonNull(currentRecord);
|
||||
}
|
||||
public Map<UUID, View> snapshot() { return views; }
|
||||
public String name(UUID holder) { var view = views.get(holder); return view == null ? null : view.name(); }
|
||||
@Override public synchronized void activate(Player player, UUID session, CharacterRecord record) {
|
||||
if (closed) return;
|
||||
UUID holder = player.getUniqueId();
|
||||
var previous = views.get(holder);
|
||||
if (previous != null) withdraw(holder, previous.session());
|
||||
var view = new View(holder, session, record.id(), record.revision(), record.name());
|
||||
put(view);
|
||||
safely(() -> output.show(player, view));
|
||||
if (!closed && view.equals(views.get(holder))) safely(() -> currentRecord.apply(record.id()).whenComplete((saved, failure) -> {
|
||||
if (failure != null) { safely(() -> { throw new java.util.concurrent.CompletionException(failure); }); return; }
|
||||
saved.ifPresent(fresh -> acknowledge(view, fresh));
|
||||
}));
|
||||
}
|
||||
@Override public synchronized void withdraw(UUID player, UUID session) {
|
||||
var view = views.get(player);
|
||||
if (view == null || !view.session().equals(session)) return;
|
||||
var copy = new java.util.HashMap<>(views); copy.remove(player); views = Map.copyOf(copy);
|
||||
// Invalidate synchronously even on a worker failure; never access Bukkit on that worker.
|
||||
if (mainThread.getAsBoolean()) safely(() -> output.clear(view));
|
||||
else safely(() -> nextTick.accept(() -> safely(() -> output.clear(view))));
|
||||
}
|
||||
/** File acknowledgment, not the physical sheet name. Safe to call from the persistence worker. */
|
||||
public void acknowledged(CharacterRecord record) {
|
||||
// Capture lifetime tokens now. A late callback cannot name a replacement holder/session.
|
||||
for (var view : views.values()) acknowledge(view, record);
|
||||
}
|
||||
private void acknowledge(View target, CharacterRecord record) {
|
||||
if (!newer(target, record)) return;
|
||||
safely(() -> nextTick.accept(() -> {
|
||||
synchronized (CharacterPresentation.this) {
|
||||
if (closed || !newer(target, record)) return;
|
||||
var updated = new View(target.holder(), target.session(), target.character(), record.revision(), record.name());
|
||||
put(updated);
|
||||
safely(() -> output.rename(updated));
|
||||
}
|
||||
}));
|
||||
}
|
||||
private boolean newer(View target, CharacterRecord record) {
|
||||
var current = views.get(target.holder());
|
||||
return current != null && current.session().equals(target.session()) && record.id().equals(current.character())
|
||||
&& !record.dead() && record.revision().compareTo(current.revision()) > 0;
|
||||
}
|
||||
private void put(View view) {
|
||||
var copy = new java.util.HashMap<>(views); copy.put(view.holder(), view); views = Map.copyOf(copy);
|
||||
}
|
||||
private void safely(Runnable action) {
|
||||
try { action.run(); }
|
||||
catch (RuntimeException | LinkageError failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException | LinkageError ignored) { /* Cleanup must continue. */ }
|
||||
}
|
||||
}
|
||||
@Override public synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var view : java.util.List.copyOf(views.values())) withdraw(view.holder(), view.session());
|
||||
safely(output::close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Entry;
|
||||
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.AttributeSnapshot;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Display.BillboardConstraints;
|
||||
import net.minecraft.world.entity.Display.TextDisplay;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.PositionMoveRotation;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Display-only codec pinned to Purpur 26.2 build 2618. No packet delivery, world insertion,
|
||||
* profile/team changes, or authoritative player mutations belong here.
|
||||
*
|
||||
* <p>Player reads and detached-label creation/updates belong on the server thread. Pure
|
||||
* packet projections accept already-captured session identity/text; the caller owns visibility,
|
||||
* per-viewer range, lifecycle checks and removing each viewer's client-only labels.
|
||||
*/
|
||||
public final class NativePresentationPackets {
|
||||
private NativePresentationPackets() { }
|
||||
|
||||
/**
|
||||
* Replace only the selected UUID's display component when its action is already present.
|
||||
* Preserve all other entry fields and the exact action set, including mixed ADD packets.
|
||||
* ADD without UPDATE_DISPLAY_NAME needs a separate targeted update after acceptance.
|
||||
*/
|
||||
public static ClientboundPlayerInfoUpdatePacket projectPlayerInfo(
|
||||
ClientboundPlayerInfoUpdatePacket packet, UUID activePlayer, String name) {
|
||||
var actions = packet.actions();
|
||||
if (!actions.contains(Action.UPDATE_DISPLAY_NAME)) {
|
||||
return packet;
|
||||
}
|
||||
var entries = new ArrayList<Entry>(packet.entries().size());
|
||||
boolean changed = false;
|
||||
for (var entry : packet.entries()) {
|
||||
if (entry.profileId().equals(activePlayer)) {
|
||||
entries.add(new Entry(entry.profileId(), entry.profile(), entry.listed(), entry.latency(),
|
||||
entry.gameMode(), literal(name), entry.showHat(), entry.listOrder(), entry.chatSession()));
|
||||
changed = true;
|
||||
} else {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
if (!changed) { return packet; }
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.copyOf(actions), entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an existing NAME_TAG_DISTANCE snapshot with base zero/no modifiers, only for
|
||||
* the selected entity. Do not inject absent attributes; use suppressNameTag on activation.
|
||||
*/
|
||||
public static ClientboundUpdateAttributesPacket projectAttributes(
|
||||
ClientboundUpdateAttributesPacket packet, int activeEntityId) {
|
||||
if (packet.getEntityId() != activeEntityId || packet.getValues().stream()
|
||||
.noneMatch(value -> value.attribute().equals(Attributes.NAME_TAG_DISTANCE))) {
|
||||
return packet;
|
||||
}
|
||||
var projected = new ClientboundUpdateAttributesPacket(activeEntityId, List.of());
|
||||
for (var value : packet.getValues()) {
|
||||
projected.getValues().add(value.attribute().equals(Attributes.NAME_TAG_DISTANCE)
|
||||
? new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE, 0, List.of()) : value);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/** One client-only distance snapshot; never changes the server's attribute instance. */
|
||||
public static ClientboundUpdateAttributesPacket suppressNameTag(int entityId) {
|
||||
var packet = new ClientboundUpdateAttributesPacket(entityId, List.of());
|
||||
packet.getValues().add(new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE, 0, List.of()));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** Snapshot the current authoritative distance base and all modifiers, not an old default. */
|
||||
public static ClientboundUpdateAttributesPacket restoreNameTag(ServerPlayer player) {
|
||||
var distance = player.getAttribute(Attributes.NAME_TAG_DISTANCE);
|
||||
if (distance == null) {
|
||||
throw new IllegalStateException("Native player has no NAME_TAG_DISTANCE attribute");
|
||||
}
|
||||
var packet = new ClientboundUpdateAttributesPacket(player.getId(), List.of());
|
||||
packet.getValues().add(new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE,
|
||||
distance.getBaseValue(), List.copyOf(distance.getModifiers())));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** UPDATE_DISPLAY_NAME only, using the current ordinary native tab display (including null). */
|
||||
public static ClientboundPlayerInfoUpdatePacket refreshTab(ServerPlayer player) {
|
||||
// Only UUID and displayName are serialized by this action. Do not capture unrelated
|
||||
// connection/game-mode state or synthesize ADD_PLAYER/listed/profile updates.
|
||||
var ordinary = player.getTabListDisplayName();
|
||||
var entry = new Entry(player.getUUID(), null, false, 0,
|
||||
GameType.DEFAULT_MODE, ordinary == null ? null : ordinary.copy(),
|
||||
false, 0, null);
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.UPDATE_DISPLAY_NAME), List.of(entry));
|
||||
}
|
||||
|
||||
/** Pure targeted display update: no profile, listed, game-mode or reconnect actions. */
|
||||
public static ClientboundPlayerInfoUpdatePacket displayName(UUID player, String name) {
|
||||
var entry = new Entry(player, null, false, 0, GameType.DEFAULT_MODE, literal(name), false, 0, null);
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.UPDATE_DISPLAY_NAME), List.of(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a detached TextDisplay in the player's current native level at explicit coordinates.
|
||||
* Native construction allocates its ID through ServerLevel.getNextEntityId; never register it.
|
||||
* The caller chooses overhead position and recreates the label after world changes.
|
||||
*/
|
||||
public static Label createLabel(ServerPlayer player, String text, double x, double y, double z) {
|
||||
validatePosition(x, y, z);
|
||||
var component = literal(text);
|
||||
var level = player.level();
|
||||
if (level == null) { throw new IllegalStateException("Native player has no current server level"); }
|
||||
// A native constructor supplies the exact metadata schema and native ID allocation.
|
||||
// Never spawn, tick, wrap in a Bukkit entity, or expose this detached instance.
|
||||
var display = new TextDisplay(EntityTypes.TEXT_DISPLAY, level);
|
||||
display.persist = false;
|
||||
display.setNoGravity(true);
|
||||
display.setBillboardConstraints(BillboardConstraints.CENTER);
|
||||
display.getEntityData().set(TextDisplay.DATA_LINE_WIDTH_ID, Integer.MAX_VALUE);
|
||||
display.setFlags((byte) (TextDisplay.FLAG_SHADOW | TextDisplay.FLAG_USE_DEFAULT_BACKGROUND));
|
||||
display.setText(component);
|
||||
display.setPos(x, y, z);
|
||||
return new Label(display);
|
||||
}
|
||||
|
||||
private static Component literal(String text) {
|
||||
if (text == null) { throw new IllegalArgumentException("Display text must not be null"); }
|
||||
return Component.literal(text);
|
||||
}
|
||||
|
||||
private static void validatePosition(double x, double y, double z) {
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) {
|
||||
throw new IllegalArgumentException("Label coordinates must be finite");
|
||||
}
|
||||
}
|
||||
|
||||
/** A session-owned detached entity; no native entity is exposed for accidental world insertion. */
|
||||
public static final class Label {
|
||||
private final TextDisplay display;
|
||||
|
||||
private Label(TextDisplay display) { this.display = display; }
|
||||
|
||||
public int id() { return display.getId(); }
|
||||
public UUID uuid() { return display.getUUID(); }
|
||||
|
||||
/** Send spawn followed by metadata to each newly eligible viewer. */
|
||||
public ClientboundAddEntityPacket spawn() {
|
||||
return new ClientboundAddEntityPacket(id(), uuid(), display.getX(), display.getY(), display.getZ(),
|
||||
0, 0, display.getType(), 0, Vec3.ZERO, 0);
|
||||
}
|
||||
|
||||
/** Complete metadata, not consumable packDirty state: safe for multiple viewers. */
|
||||
public ClientboundSetEntityDataPacket metadata() {
|
||||
return new ClientboundSetEntityDataPacket(id(), display.getEntityData().packAll());
|
||||
}
|
||||
|
||||
/** Update only the detached label and return an absolute teleport packet. */
|
||||
public ClientboundTeleportEntityPacket move(double x, double y, double z) {
|
||||
validatePosition(x, y, z);
|
||||
display.setPos(x, y, z);
|
||||
return ClientboundTeleportEntityPacket.teleport(id(),
|
||||
PositionMoveRotation.of(display), Set.of(), false);
|
||||
}
|
||||
|
||||
/** Replace only the detached label's literal text and return complete metadata. */
|
||||
public ClientboundSetEntityDataPacket rename(String text) {
|
||||
display.setText(literal(text));
|
||||
return metadata();
|
||||
}
|
||||
|
||||
/** Client-only removal; never calls entity discard/remove on the server world. */
|
||||
public ClientboundRemoveEntitiesPacket remove() {
|
||||
return new ClientboundRemoveEntitiesPacket(id());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.NetworkMarker;
|
||||
import com.comphenix.protocol.events.PacketPostListener;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation.View;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundBundlePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundRespawnPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/**
|
||||
* Pinned native output adapter. Public lifecycle methods and the scheduled reconciliation run
|
||||
* on the server thread. Packet callbacks use only immutable decisions, the parent's published
|
||||
* snapshot, packet values and recipient UUIDs. No authoritative presentation is ever changed.
|
||||
* The snapshot supplier is not read during construction: the owner may initialize its field
|
||||
* lazily, then publish a View before calling show/rename. Diagnostics may run on packet threads.
|
||||
*/
|
||||
public final class ProtocolPresentation implements CharacterPresentation.Output {
|
||||
private final Plugin plugin;
|
||||
private final ProtocolManager protocol;
|
||||
private final Supplier<Map<UUID, View>> snapshot;
|
||||
private final Consumer<Throwable> diagnostics;
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private final PacketAdapter listener;
|
||||
// Cancelled events never post. Weak keys release their immutable context with the marker.
|
||||
private final Map<NetworkMarker, Map<UUID, Decision>> deliveries = new WeakHashMap<>();
|
||||
private final PacketPostListener postListener = new PacketPostListener() {
|
||||
@Override public Plugin getPlugin() { return plugin; }
|
||||
@Override public void onPostEvent(PacketEvent event) { accepted(event); }
|
||||
};
|
||||
private record Decision(View view, int entity, Map<UUID, Integer> labels) { }
|
||||
private Map<UUID, Decision> decisions = Map.of();
|
||||
private record Route(UUID holder, UUID session, UUID viewer) { }
|
||||
private final Map<Route, Integer> blocked = new HashMap<>();
|
||||
private final Set<Route> retired = new HashSet<>();
|
||||
private final Set<Route> tracking = new HashSet<>();
|
||||
private static final int UNTRACKED = 1;
|
||||
private static final int INVISIBLE = 2;
|
||||
private static final int FAILED = 4;
|
||||
private static final int TAB_REMOVED = 8;
|
||||
private BukkitTask task;
|
||||
private boolean installed;
|
||||
private boolean closed;
|
||||
private int warnings;
|
||||
private boolean operating;
|
||||
private boolean draining;
|
||||
private final ArrayDeque<Runnable> pending = new ArrayDeque<>();
|
||||
|
||||
private static final class State {
|
||||
private final Player player;
|
||||
private int entity;
|
||||
private View view;
|
||||
private org.bukkit.World world;
|
||||
private NativePresentationPackets.Label label;
|
||||
private final Map<UUID, Player> viewers = new HashMap<>();
|
||||
private State(Player player, View view) {
|
||||
this.player = player; this.view = view; this.entity = player.getEntityId();
|
||||
this.world = player.getWorld();
|
||||
}
|
||||
}
|
||||
|
||||
public ProtocolPresentation(Plugin plugin, ProtocolManager protocol,
|
||||
Supplier<Map<UUID, View>> snapshot, Consumer<Throwable> diagnostics) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.protocol = Objects.requireNonNull(protocol);
|
||||
this.snapshot = Objects.requireNonNull(snapshot);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
listener = new PacketAdapter(plugin, ListenerPriority.HIGHEST,
|
||||
PacketType.Play.Server.PLAYER_INFO, PacketType.Play.Server.UPDATE_ATTRIBUTES,
|
||||
PacketType.Play.Server.ENTITY_DESTROY, PacketType.Play.Server.ENTITY_METADATA,
|
||||
PacketType.Play.Server.SPAWN_ENTITY, PacketType.Play.Server.PLAYER_INFO_REMOVE,
|
||||
PacketType.Play.Server.RESPAWN, PacketType.Play.Server.BUNDLE) {
|
||||
@Override public void onPacketSending(PacketEvent event) { project(event); }
|
||||
};
|
||||
}
|
||||
|
||||
/** Idempotent; the supplied manager is the sole ProtocolLib dependency. */
|
||||
public synchronized void install() {
|
||||
if (closed || installed) { return; }
|
||||
installed = true;
|
||||
try {
|
||||
protocol.addPacketListener(listener);
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 1L, 1L);
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
report(failure); close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void show(Player player, View view) {
|
||||
if (!begin(() -> show(player, view))) { return; }
|
||||
try {
|
||||
if (closed || !view.equals(snapshot.get().get(view.holder()))
|
||||
|| !player.getUniqueId().equals(view.holder())) { return; }
|
||||
var old = states.get(view.holder());
|
||||
if (old != null) {
|
||||
if (sameSession(old.view, view)) { rename(view); return; }
|
||||
states.remove(view.holder());
|
||||
removeLabels(old);
|
||||
forget(old);
|
||||
}
|
||||
if (closed || !view.equals(snapshot.get().get(view.holder())) || states.containsKey(view.holder())) { return; }
|
||||
var state = new State(player, view);
|
||||
states.put(view.holder(), state);
|
||||
// Existing trackers predate activation; subsequent viewers need an accepted pairing.
|
||||
for (var viewer : player.getTrackedBy()) { tracking.add(route(view, viewer.getUniqueId())); }
|
||||
publish();
|
||||
refreshTab(state, true);
|
||||
reconcile(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
@Override public synchronized void rename(View view) {
|
||||
if (!begin(() -> rename(view))) { return; }
|
||||
try {
|
||||
var state = states.get(view.holder());
|
||||
if (closed || state == null || !sameSession(state.view, view)
|
||||
|| !view.equals(snapshot.get().get(view.holder()))
|
||||
|| view.revision().compareTo(state.view.revision()) < 0) { return; }
|
||||
state.view = view;
|
||||
publish();
|
||||
refreshTab(state, true);
|
||||
reconcile(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
@Override public synchronized void clear(View view) {
|
||||
if (!begin(() -> clear(view))) { return; }
|
||||
try {
|
||||
var state = states.get(view.holder());
|
||||
if (state == null || !sameSession(state.view, view)) { return; }
|
||||
states.remove(view.holder());
|
||||
publish();
|
||||
removeLabels(state);
|
||||
forget(state);
|
||||
restore(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
private synchronized void tick() {
|
||||
if (!begin(this::tick)) { return; }
|
||||
try {
|
||||
if (closed) { return; }
|
||||
for (var state : List.copyOf(states.values())) {
|
||||
try {
|
||||
if (!state.player.isOnline() || !sameSession(state.view, snapshot.get().get(state.view.holder()))) {
|
||||
clear(state.view);
|
||||
} else { reconcile(state); }
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
report(failure); removeLabels(state);
|
||||
}
|
||||
}
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
private boolean current(State state, View expected) {
|
||||
return !closed && states.get(expected.holder()) == state && expected.equals(state.view)
|
||||
&& expected.equals(snapshot.get().get(expected.holder()));
|
||||
}
|
||||
|
||||
private boolean deliver(State state, View expected, Player viewer, Object packet) {
|
||||
// A stale decision is skipped, not classified as an uncertain transport failure.
|
||||
return !current(state, expected) || send(viewer, packet);
|
||||
}
|
||||
|
||||
private void reconcile(State state) {
|
||||
var expected = state.view;
|
||||
if (!current(state, expected)) { return; }
|
||||
try {
|
||||
var player = state.player;
|
||||
for (var id : List.copyOf(state.viewers.keySet())) {
|
||||
if (retired.remove(route(state.view, id))) { state.viewers.remove(id); }
|
||||
}
|
||||
publish();
|
||||
var location = player.getLocation();
|
||||
if (state.world != player.getWorld() || state.entity != player.getEntityId()) {
|
||||
removeLabels(state);
|
||||
if (!current(state, expected)) { return; }
|
||||
state.label = null;
|
||||
state.world = player.getWorld();
|
||||
state.entity = player.getEntityId();
|
||||
// A world/entity change is not delivery evidence. Only an accepted native
|
||||
// spawn may clear UNTRACKED/INVISIBLE, never a tick with stale platform trackers.
|
||||
publish();
|
||||
}
|
||||
if (state.label == null) {
|
||||
state.label = NativePresentationPackets.createLabel(((CraftPlayer) player).getHandle(),
|
||||
state.view.name(), location.getX(), location.getY() + player.getHeight() + 0.3, location.getZ());
|
||||
}
|
||||
var move = state.label.move(location.getX(), location.getY() + player.getHeight() + 0.3, location.getZ());
|
||||
var metadata = state.label.rename(state.view.name());
|
||||
var tracked = player.getTrackedBy();
|
||||
for (var entry : List.copyOf(state.viewers.entrySet())) {
|
||||
if (!tracked.contains(entry.getValue()) || !eligible(player, entry.getValue())) {
|
||||
state.viewers.remove(entry.getKey());
|
||||
removeLabel(state, entry.getValue());
|
||||
}
|
||||
}
|
||||
for (var viewer : tracked) {
|
||||
if (!current(state, expected)) { return; }
|
||||
try {
|
||||
if (!eligible(player, viewer)) { continue; }
|
||||
var id = viewer.getUniqueId();
|
||||
var key = route(state.view, id);
|
||||
if (!tracking.contains(key) || blocked.containsKey(key)) { continue; }
|
||||
if (!state.viewers.containsKey(id)) {
|
||||
state.viewers.put(id, viewer);
|
||||
publish();
|
||||
if (!deliver(state, expected, viewer, NativePresentationPackets.suppressNameTag(state.entity))
|
||||
|| !deliver(state, expected, viewer, state.label.spawn()) || !deliver(state, expected, viewer, metadata)) {
|
||||
quarantine(state, viewer);
|
||||
}
|
||||
} else if (!deliver(state, expected, viewer, move) || !deliver(state, expected, viewer, metadata)) {
|
||||
quarantine(state, viewer);
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); removeLabels(state); }
|
||||
finally { publish(); }
|
||||
}
|
||||
|
||||
private void quarantine(State state, Player viewer) {
|
||||
if (states.get(state.view.holder()) != state) { return; }
|
||||
var id = viewer.getUniqueId();
|
||||
// A send can have escaped before throwing. Retire once; never retry its spawn sequence.
|
||||
blocked.merge(route(state.view, id), FAILED, (before, flag) -> before | flag);
|
||||
state.viewers.remove(id);
|
||||
publish();
|
||||
send(viewer, state.label.remove());
|
||||
}
|
||||
|
||||
private boolean eligible(Player target, Player viewer) {
|
||||
if (viewer.getUniqueId().equals(target.getUniqueId()) || !viewer.isOnline()
|
||||
|| target.getWorld() != viewer.getWorld() || !viewer.canSee(target)
|
||||
|| target.isInvisible() || target.getGameMode() == GameMode.SPECTATOR) { return false; }
|
||||
var nativeTarget = ((CraftPlayer) target).getHandle();
|
||||
var distance = nativeTarget.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE);
|
||||
if (distance == null) { return false; }
|
||||
double range = distance.getValue();
|
||||
if (target.isSneaking()) { range = Math.min(32, range); }
|
||||
if (!(range > 0) || !(viewer.getLocation().distanceSquared(target.getLocation()) < range * range)) { return false; }
|
||||
// The recipient's native scoreboard is the one its client sees (it may differ from
|
||||
// the main scoreboard). Read existing membership only; never create private teams.
|
||||
var board = ((CraftPlayer) viewer).getScoreboard().getHandle();
|
||||
var team = board.getPlayersTeam(target.getName());
|
||||
if (team == null) { return true; }
|
||||
var viewerTeam = board.getPlayersTeam(viewer.getName());
|
||||
return switch (team.getNameTagVisibility()) {
|
||||
case ALWAYS -> true;
|
||||
case NEVER -> false;
|
||||
case HIDE_FOR_OTHER_TEAMS -> viewerTeam == null || team.isAlliedTo(viewerTeam);
|
||||
case HIDE_FOR_OWN_TEAM -> viewerTeam == null || !team.isAlliedTo(viewerTeam);
|
||||
};
|
||||
}
|
||||
|
||||
private void refreshTab(State state, boolean fantasy) {
|
||||
var expected = state.view;
|
||||
try {
|
||||
var ordinary = NativePresentationPackets.refreshTab(((CraftPlayer) state.player).getHandle());
|
||||
var packet = fantasy ? NativePresentationPackets.projectPlayerInfo(ordinary, state.view.holder(), state.view.name()) : ordinary;
|
||||
for (var viewer : List.copyOf(plugin.getServer().getOnlinePlayers())) {
|
||||
if (fantasy ? !current(state, expected) : !restorable(state)) { return; }
|
||||
try { if (viewer.canSee(state.player)) { send(viewer, packet); } }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private boolean restorable(State state) {
|
||||
var replacement = snapshot.get().get(state.view.holder());
|
||||
return !states.containsKey(state.view.holder()) && (replacement == null || sameSession(state.view, replacement));
|
||||
}
|
||||
|
||||
private void restore(State state) {
|
||||
if (!restorable(state)) { return; }
|
||||
refreshTab(state, false);
|
||||
try {
|
||||
var packet = NativePresentationPackets.restoreNameTag(((CraftPlayer) state.player).getHandle());
|
||||
var recipients = new HashSet<>(state.player.getTrackedBy());
|
||||
recipients.add(state.player);
|
||||
for (var viewer : recipients) {
|
||||
if (!restorable(state)) { return; }
|
||||
try { if (viewer.canSee(state.player)) { send(viewer, packet); } }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void removeLabel(State state, Player viewer) {
|
||||
if (!send(viewer, state.label.remove()) && states.get(state.view.holder()) == state) {
|
||||
blocked.merge(route(state.view, viewer.getUniqueId()), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeLabels(State state) {
|
||||
var viewers = List.copyOf(state.viewers.values());
|
||||
var label = state.label;
|
||||
state.viewers.clear();
|
||||
publish();
|
||||
if (label != null) {
|
||||
for (var viewer : viewers) {
|
||||
if (!send(viewer, label.remove()) && states.get(state.view.holder()) == state) {
|
||||
blocked.merge(route(state.view, viewer.getUniqueId()), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
var next = new HashMap<UUID, Decision>();
|
||||
for (var state : states.values()) {
|
||||
var labels = new HashMap<UUID, Integer>();
|
||||
if (state.label != null) {
|
||||
for (var viewer : state.viewers.keySet()) { labels.put(viewer, state.label.id()); }
|
||||
}
|
||||
next.put(state.view.holder(), new Decision(state.view, state.entity, Map.copyOf(labels)));
|
||||
}
|
||||
decisions = Map.copyOf(next);
|
||||
}
|
||||
|
||||
private static Route route(View view, UUID viewer) { return new Route(view.holder(), view.session(), viewer); }
|
||||
|
||||
private void forget(State state) {
|
||||
blocked.keySet().removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
retired.removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
tracking.removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
}
|
||||
|
||||
private synchronized void project(PacketEvent event) {
|
||||
if (closed) { return; }
|
||||
try {
|
||||
var original = event.getPacket().getHandle();
|
||||
var viewer = event.getPlayer().getUniqueId();
|
||||
reserveSpawn(original, viewer);
|
||||
if (event.isCancelled()) { return; }
|
||||
var marker = event.getNetworkMarker();
|
||||
deliveries.putIfAbsent(marker, decisions);
|
||||
marker.addPostListener(postListener); // The parent and children share this deduplicating set.
|
||||
// ProtocolLib visits children AFTER the parent, including lower-priority vetoes.
|
||||
// Never expand a child into a bundle or mutate route state speculatively here.
|
||||
Object projected = original;
|
||||
for (var decision : decisions.values()) {
|
||||
var view = currentView(decision);
|
||||
if (view == null) { continue; }
|
||||
if (projected instanceof ClientboundPlayerInfoUpdatePacket info) {
|
||||
projected = NativePresentationPackets.projectPlayerInfo(info, view.holder(), view.name());
|
||||
} else if (projected instanceof ClientboundUpdateAttributesPacket attributes) {
|
||||
projected = NativePresentationPackets.projectAttributes(attributes, decision.entity());
|
||||
}
|
||||
}
|
||||
if (projected != original) { event.setPacket(wrap(projected)); }
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void reserveSpawn(Object packet, UUID viewer) {
|
||||
if (packet instanceof ClientboundBundlePacket bundle) {
|
||||
bundle.subPackets().forEach(child -> reserveSpawn(child, viewer));
|
||||
} else if (packet instanceof ClientboundAddEntityPacket spawn) {
|
||||
var decision = decisions.get(spawn.getUUID());
|
||||
if (decision != null && decision.entity() == spawn.getId() && !decision.labels().containsKey(viewer)) {
|
||||
// A tick between dispatch and post must not invent a successful new pairing.
|
||||
// Do not retire an existing label or clear any visibility flag on an attempt.
|
||||
blocked.merge(route(decision.view(), viewer), UNTRACKED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Native topology survives renames; any rendering payload uses the latest published view. */
|
||||
private View currentView(Decision expected) {
|
||||
var now = decisions.get(expected.view().holder());
|
||||
var latest = snapshot.get().get(expected.view().holder());
|
||||
return !closed && now != null && now.entity() == expected.entity()
|
||||
&& sameSession(expected.view(), now.view()) && sameSession(expected.view(), latest) ? latest : null;
|
||||
}
|
||||
|
||||
private synchronized void accepted(PacketEvent event) {
|
||||
if (!begin(() -> accepted(event))) { return; }
|
||||
try {
|
||||
var captured = deliveries.remove(NetworkMarker.getNetworkMarker(event));
|
||||
if (closed || event.isCancelled() || captured == null) { return; }
|
||||
var viewer = event.getPlayer();
|
||||
var id = viewer.getUniqueId();
|
||||
var removals = new HashMap<Decision, Integer>();
|
||||
var displays = new HashSet<Decision>();
|
||||
commitNative(event.getPacket().getHandle(), id, captured, removals, displays);
|
||||
for (var entry : removals.entrySet()) {
|
||||
var decision = entry.getKey();
|
||||
if (currentView(decision) != null && !send(viewer, new ClientboundRemoveEntitiesPacket(entry.getValue()))) {
|
||||
blocked.merge(route(decision.view(), id), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
for (var decision : displays) {
|
||||
var view = currentView(decision);
|
||||
if (view != null && (blocked.getOrDefault(route(view, id), 0) & TAB_REMOVED) == 0) {
|
||||
send(viewer, NativePresentationPackets.displayName(view.holder(), view.name()));
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
finally { finish(); }
|
||||
}
|
||||
|
||||
private void commitNative(Object packet, UUID viewer, Map<UUID, Decision> captured,
|
||||
Map<Decision, Integer> removals, Set<Decision> displays) {
|
||||
if (packet instanceof ClientboundBundlePacket bundle) {
|
||||
bundle.subPackets().forEach(child -> commitNative(child, viewer, captured, removals, displays));
|
||||
return;
|
||||
}
|
||||
for (var decision : captured.values()) {
|
||||
var view = currentView(decision);
|
||||
if (view == null) { continue; }
|
||||
var key = route(view, viewer);
|
||||
int mask = blocked.getOrDefault(key, 0);
|
||||
if (packet instanceof ClientboundPlayerInfoUpdatePacket info) {
|
||||
if (info.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER)
|
||||
&& info.entries().stream().anyMatch(entry -> entry.profileId().equals(view.holder()))) {
|
||||
mask &= ~TAB_REMOVED;
|
||||
if (!info.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME)) {
|
||||
displays.add(decision);
|
||||
}
|
||||
}
|
||||
} else if (packet instanceof ClientboundRemoveEntitiesPacket removal
|
||||
&& removal.getEntityIds().contains(decision.entity())) { mask |= UNTRACKED; }
|
||||
else if (packet instanceof ClientboundPlayerInfoRemovePacket removal
|
||||
&& removal.profileIds().contains(view.holder())) { mask |= TAB_REMOVED; }
|
||||
else if (packet instanceof ClientboundRespawnPacket) { mask |= UNTRACKED; }
|
||||
else if (packet instanceof ClientboundAddEntityPacket spawn && spawn.getId() == decision.entity()
|
||||
&& spawn.getUUID().equals(view.holder())) {
|
||||
mask &= ~(UNTRACKED | INVISIBLE);
|
||||
tracking.add(key);
|
||||
}
|
||||
else if (packet instanceof ClientboundSetEntityDataPacket metadata && metadata.id() == decision.entity()) {
|
||||
for (var value : metadata.packedItems()) {
|
||||
if (value.id() == 0 && value.serializer() == net.minecraft.network.syncher.EntityDataSerializers.BYTE
|
||||
&& value.value() instanceof Byte flags) {
|
||||
mask = (flags & 0x20) != 0 ? mask | INVISIBLE : mask & ~INVISIBLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mask == 0) { blocked.remove(key); } else { blocked.put(key, mask); }
|
||||
var label = decisions.get(view.holder()).labels().get(viewer);
|
||||
if (mask != 0 && label != null && retired.add(key)) { removals.put(decision, label); }
|
||||
}
|
||||
}
|
||||
|
||||
private boolean send(Player viewer, Object raw) {
|
||||
try { protocol.sendServerPacket(viewer, wrap(raw), false); return true; }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); return false; }
|
||||
}
|
||||
|
||||
private boolean begin(Runnable retry) {
|
||||
if (operating) { pending.add(retry); return false; }
|
||||
operating = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void finish() {
|
||||
operating = false;
|
||||
if (draining) { return; }
|
||||
// Reentrant lifecycle operations wait until the transport attempt AND its failure/
|
||||
// resource bookkeeping finish. Cleanup is thus ordered after every possibly escaped
|
||||
// old packet, and a queued rename cannot continue an uncertain spawn. Never replay it.
|
||||
draining = true;
|
||||
try {
|
||||
while (!pending.isEmpty()) {
|
||||
try { pending.removeFirst().run(); }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} finally { draining = false; }
|
||||
}
|
||||
|
||||
private static PacketContainer wrap(Object raw) {
|
||||
PacketType type;
|
||||
if (raw instanceof ClientboundBundlePacket) { type = PacketType.Play.Server.BUNDLE; }
|
||||
else if (raw instanceof ClientboundPlayerInfoUpdatePacket) { type = PacketType.Play.Server.PLAYER_INFO; }
|
||||
else if (raw instanceof ClientboundUpdateAttributesPacket) { type = PacketType.Play.Server.UPDATE_ATTRIBUTES; }
|
||||
else if (raw instanceof ClientboundAddEntityPacket) { type = PacketType.Play.Server.SPAWN_ENTITY; }
|
||||
else if (raw instanceof ClientboundSetEntityDataPacket) { type = PacketType.Play.Server.ENTITY_METADATA; }
|
||||
else if (raw instanceof ClientboundTeleportEntityPacket) { type = PacketType.Play.Server.ENTITY_TELEPORT; }
|
||||
else if (raw instanceof ClientboundRemoveEntitiesPacket) { type = PacketType.Play.Server.ENTITY_DESTROY; }
|
||||
else { throw new IllegalArgumentException("Unsupported presentation packet"); }
|
||||
return new PacketContainer(type, raw);
|
||||
}
|
||||
|
||||
private static boolean sameSession(View first, View second) {
|
||||
return second != null && first.holder().equals(second.holder())
|
||||
&& first.session().equals(second.session()) && first.character().equals(second.character());
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
if (warnings < 8) {
|
||||
warnings++;
|
||||
try { diagnostics.accept(failure); } catch (RuntimeException | LinkageError ignored) { /* Cleanup must continue. */ }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
if (!begin(this::close)) { return; }
|
||||
try {
|
||||
if (closed) { return; }
|
||||
closed = true;
|
||||
deliveries.clear();
|
||||
if (task != null) {
|
||||
try { task.cancel(); } catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
if (installed) {
|
||||
try { protocol.removePacketListener(listener); } catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
for (var state : List.copyOf(states.values())) { clear(state.view); }
|
||||
} finally { finish(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Session-owned output only. Withdrawal must invalidate decisions even when native cleanup fails. */
|
||||
public interface SessionPresentation {
|
||||
SessionPresentation NONE = new SessionPresentation() {
|
||||
@Override public void activate(Player player, UUID session, CharacterRecord record) { }
|
||||
@Override public void withdraw(UUID player, UUID session) { }
|
||||
};
|
||||
void activate(Player player, UUID session, CharacterRecord record);
|
||||
/** May be called by a failed worker completion: no worker Bukkit access is permitted. */
|
||||
void withdraw(UUID player, UUID session);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Main-thread holder/session proof for optional Adventure mode; the receipt must already be saved. */
|
||||
public final class AdventureModeLease implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void changed(org.bukkit.event.player.PlayerGameModeChangeEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
var player = event.getPlayer();
|
||||
var mark = read(player);
|
||||
// Events precede the write. A currently non-Adventure mode proves a previous
|
||||
// external change already happened, even when this new request returns to Adventure.
|
||||
// A lone external change is also detected directly by restore(). Never infer success
|
||||
// from this event's requested value, which a later listener may still cancel.
|
||||
if (mark != null && mark.phase() == Phase.APPLIED && player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(mark.session(), mark.previous(), Phase.RELEASED));
|
||||
}
|
||||
}
|
||||
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "adventure_session");
|
||||
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
|
||||
private record Mark(UUID session, String previous, Phase phase) {
|
||||
boolean matches(UUID expected, String original) { return session.equals(expected) && previous.equals(original); }
|
||||
}
|
||||
|
||||
public boolean available(Player player) {
|
||||
var mark = read(player);
|
||||
return !player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED;
|
||||
}
|
||||
|
||||
public boolean enter(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var existing = read(player);
|
||||
if (player.getPersistentDataContainer().has(KEY) && (existing == null || existing.phase() != Phase.RELEASED)) {
|
||||
return existing != null && existing.matches(session, previous) && existing.phase() == Phase.APPLIED
|
||||
&& player.getGameMode() == GameMode.ADVENTURE;
|
||||
}
|
||||
if (player.getGameMode() != GameMode.valueOf(previous)) return false;
|
||||
write(player, new Mark(session, previous, Phase.APPLYING));
|
||||
player.setGameMode(GameMode.ADVENTURE);
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return false;
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean restore(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.matches(session, previous)) return false;
|
||||
if (mark.phase() == Phase.RELEASED) return true;
|
||||
if (mark.phase() != Phase.APPLIED) return false; // Interrupted setter is not restoration proof.
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true; // A later external/admin change is not ours to undo.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RESTORING));
|
||||
player.setGameMode(GameMode.valueOf(previous));
|
||||
if (player.getGameMode() == GameMode.ADVENTURE && !previous.equals("ADVENTURE")) {
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return false; // Native veto: unchanged and still owned, not successful restoration.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Mark read(Player player) {
|
||||
var data = player.getPersistentDataContainer();
|
||||
if (!data.has(KEY, PersistentDataType.STRING)) return null;
|
||||
try {
|
||||
String[] fields = data.get(KEY, PersistentDataType.STRING).split("\\|", -1);
|
||||
if (fields.length != 4 || !fields[0].equals("1")) return null;
|
||||
var session = UUID.fromString(fields[1]);
|
||||
if (!session.toString().equals(fields[1])) return null;
|
||||
GameMode.valueOf(fields[2]);
|
||||
return new Mark(session, fields[2], Phase.valueOf(fields[3]));
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
|
||||
private static void write(Player player, Mark mark) {
|
||||
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
|
||||
"1|" + mark.session() + "|" + mark.previous() + "|" + mark.phase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Internal server-thread input port for book/combat/ability adapters, not an add-on API.
|
||||
* Containment of vanilla item behavior is separate from executing an ability. Recipients
|
||||
* must revalidate after deferred work; this notification is never durable cast authority.
|
||||
*/
|
||||
public record ControlInput(Player player, EquipmentSessions.Active session, int slot, String ability,
|
||||
Kind kind, Gesture gesture, Entity target, int heldTicks, float attackStrength,
|
||||
BooleanSupplier validity) {
|
||||
public enum Kind { BOOK, BASIC_ATTACK, ABILITY }
|
||||
public enum Gesture { USE, ATTACK, DRAW_RELEASE }
|
||||
|
||||
public ControlInput {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(kind);
|
||||
Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(validity);
|
||||
if (slot < 0 || slot > 8 || heldTicks < 0 || !Float.isFinite(attackStrength)
|
||||
|| attackStrength < 0 || attackStrength > 1) throw new IllegalArgumentException("Invalid control input");
|
||||
}
|
||||
|
||||
public boolean stillCurrent() { return validity.getAsBoolean(); }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -30,10 +31,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics);
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to inspect or recover equipment sessions.");
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("config")) {
|
||||
configuration(sender, args);
|
||||
return true;
|
||||
}
|
||||
UUID owner;
|
||||
@@ -96,6 +108,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|
||||
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
|
||||
return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
|
||||
return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
|
||||
: "Adventure configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
try { diagnostics.accept(new Failure(owner, operation, cause)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
|
||||
|
||||
@@ -70,6 +70,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick) {
|
||||
this(sessions, nextTick, failure -> System.getLogger(EquipmentSessionEvents.class.getName())
|
||||
@@ -82,7 +85,14 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
sessions.attachCursorCustody(this::cancelMove);
|
||||
sessions.attachCursorCustody(this::cancelCustody);
|
||||
}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
this(sessions, nextTick, diagnostics);
|
||||
this.inputs = Objects.requireNonNull(inputs);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -205,6 +215,31 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (blockedClick(player, event)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void recipeBook(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event) {
|
||||
var player = event.getPlayer();
|
||||
// Native recipe-book placement can pull directly from the hotbar without a click
|
||||
// event. Exact recipe ingredients also bypass vanilla's custom-name exclusion.
|
||||
if (Arrays.stream(player.getOpenInventory().getTopInventory().getContents()).anyMatch(this::special)) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
var recipe = player.getServer().getRecipe(event.getRecipe());
|
||||
java.util.Collection<org.bukkit.inventory.RecipeChoice> choices;
|
||||
if (recipe instanceof org.bukkit.inventory.ShapedRecipe shaped) choices = shaped.getChoiceMap().values();
|
||||
else if (recipe instanceof org.bukkit.inventory.ShapelessRecipe shapeless) choices = shapeless.getChoiceList();
|
||||
else if (recipe instanceof org.bukkit.inventory.CookingRecipe<?> cooking) choices = java.util.List.of(cooking.getInputChoice());
|
||||
else return; // Other recipe types do not use these native auto-fill routes.
|
||||
for (var item : player.getInventory().getStorageContents()) {
|
||||
if (!special(item)) continue;
|
||||
boolean ordinaryEligible = net.minecraft.world.entity.player.Inventory.isUsableForCrafting(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item));
|
||||
if (choices.stream().filter(Objects::nonNull).anyMatch(choice -> choice.test(item)
|
||||
&& (ordinaryEligible || choice instanceof org.bukkit.inventory.RecipeChoice.ExactChoice))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean cursorIndependent(InventoryClickEvent event) {
|
||||
return switch (event.getAction()) {
|
||||
case DROP_ONE_SLOT, DROP_ALL_SLOT, MOVE_TO_OTHER_INVENTORY, HOTBAR_SWAP -> true;
|
||||
@@ -253,7 +288,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (event.isShiftClick() && !empty(current)) {
|
||||
if (ownInventory(player, inventory) && event.getView().getType() == InventoryType.CRAFTING && slot >= 9 && slot < 36) {
|
||||
// Native InventoryMenu tries armor first, otherwise routes main -> hotbar.
|
||||
return !(cosmetic(current) && empty(player.getInventory().getHelmet()));
|
||||
return !(cosmetic(current) && !forbiddenEquip(current) && empty(player.getInventory().getHelmet()));
|
||||
}
|
||||
if (!ownInventory(player, inventory) && mainCapacity(player, current) < current.getAmount()) return true;
|
||||
}
|
||||
@@ -294,15 +329,124 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
// also run for RIGHT_CLICK_AIR / an existing block veto, without changing that veto.
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
|
||||
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
}
|
||||
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
|
||||
ControlInput.Gesture.USE, null, 0, 0);
|
||||
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
|
||||
var draw = new Draw(player, item.clone(), selected);
|
||||
draws.put(owner, draw);
|
||||
try {
|
||||
// Keep the native bow animation/timing, without BowItem.use's ammunition lookup.
|
||||
player.startUsingItem(EquipmentSlot.HAND);
|
||||
if (closed || draws.get(owner) != draw || !sessions.selectedControl(player, draw.item()).filter(selected::equals).isPresent()
|
||||
|| !draw.item().equals(player.getActiveItem())) cancelDraw(draw);
|
||||
} catch (RuntimeException failure) {
|
||||
cancelDraw(draw);
|
||||
report(owner, selected.session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void release(io.papermc.paper.event.player.PlayerStopUsingItemEvent event) {
|
||||
var player = event.getPlayer();
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() != player) draw = null;
|
||||
if (draw != null) draws.remove(owner, draw); // One-use, before a native setter or recipient can reenter.
|
||||
try {
|
||||
boolean matching = draw != null && draw.item().equals(event.getItem())
|
||||
&& draw.item().equals(player.getActiveItem()) && event.getTicksHeldFor() >= 0;
|
||||
// Purpur 2618 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
|
||||
// Clear it first: cancelling EntityShootBowEvent alone is too late to own ammo draw.
|
||||
if (special(event.getItem()) && event.getItem().equals(player.getActiveItem())) player.clearActiveItem();
|
||||
if (matching) emit(player, draw.item(), draw.selected(), ControlInput.Gesture.DRAW_RELEASE,
|
||||
null, event.getTicksHeldFor(), 0);
|
||||
} catch (RuntimeException failure) {
|
||||
// Unknown native state is not a completed cast or authority to replay an input.
|
||||
report(owner, draw == null ? null : draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void held(org.bukkit.event.player.PlayerItemHeldEvent event) {
|
||||
var draw = draws.get(event.getPlayer().getUniqueId());
|
||||
if (draw != null && draw.player() == event.getPlayer() && event.getPreviousSlot() != event.getNewSlot()) cancelDraw(draw);
|
||||
}
|
||||
|
||||
private void cancelCustody(UUID owner) {
|
||||
try { cancelMove(owner); }
|
||||
finally {
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null) cancelDraw(draw);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelDraw(Draw draw) {
|
||||
draws.remove(draw.player().getUniqueId(), draw);
|
||||
try {
|
||||
if (draw.item().equals(draw.player().getActiveItem())) draw.player().clearActiveItem();
|
||||
} catch (RuntimeException failure) {
|
||||
report(draw.player().getUniqueId(), draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean useGesture(EquipmentSessions.SelectedControl selected) {
|
||||
return selected.slot() == 0 || selected.slot() >= 2
|
||||
&& !selected.ability().equals("great_sword") && !selected.ability().equals("longbow");
|
||||
}
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
|
||||
if (special(event.getPlayer().getInventory().getItem(event.getHand()))) event.setCancelled(true);
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItem(event.getHand());
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (event.getHand() == EquipmentSlot.HAND) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && useGesture(selected)) emit(player, item, selected,
|
||||
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void attack(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event) {
|
||||
// The ability/basic-attack adapter owns intentional damage. Never also execute
|
||||
// vanilla Player.attack with the icon's material, enchantments or attributes.
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItemInMainHand();
|
||||
boolean willAttack = event.willAttack();
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (willAttack) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
|
||||
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -571,7 +715,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
cancelMove(owner);
|
||||
cancelCustody(owner);
|
||||
swaps.remove(owner);
|
||||
sessions.invalidateTail(owner);
|
||||
}
|
||||
@@ -695,6 +839,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private void reconcile(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() == player
|
||||
&& !sessions.selectedControl(player, draw.item()).filter(draw.selected()::equals).isPresent()) cancelDraw(draw);
|
||||
if (closed || writing.contains(owner) || !player.isOnline()) return;
|
||||
var marker = EquipmentSessionMarker.read(player).orElse(null);
|
||||
if (marker != null && (marker.phase() == EquipmentSessionMarker.Phase.PACKING
|
||||
@@ -735,6 +882,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var owner : java.util.List.copyOf(moves.keySet())) cancelMove(owner);
|
||||
for (var draw : java.util.List.copyOf(draws.values())) cancelDraw(draw);
|
||||
queued.clear();
|
||||
swaps.clear();
|
||||
observed.clear();
|
||||
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
@@ -24,7 +25,7 @@ import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* Server-thread equipment custody and minimal defeat handling. Fantasy health attributes,
|
||||
* nutrition, display and rewards are not applied here. No inventory/file transaction,
|
||||
* nutrition and rewards are not applied here. Presentation is an independently cleaned output port. No inventory/file transaction,
|
||||
* automatic retry or snapshot rollback is promised. The scheduler must defer onto
|
||||
* the server thread; diagnostics must be thread-safe and must not use Player APIs.
|
||||
*/
|
||||
@@ -32,13 +33,16 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Notice(UUID player, String code, String message) { }
|
||||
public record Failure(UUID player, UUID session, Throwable cause) { }
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private final Duration switchDelay;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Notice> notices;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private final SessionControls controls = new SessionControls();
|
||||
@@ -51,6 +55,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
private boolean cursorCustodyAttached;
|
||||
private boolean closed;
|
||||
private java.util.function.BooleanSupplier adventureMode = () -> false;
|
||||
private AdventureModeLease gameModes = new AdventureModeLease();
|
||||
private boolean closeSweepFinished;
|
||||
private int persistenceOperations;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -59,6 +65,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
UUID character;
|
||||
CharacterRecord record;
|
||||
SessionClaims.Claim claim;
|
||||
UUID session;
|
||||
ItemStack[] snapshot;
|
||||
@@ -68,6 +75,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean acknowledged;
|
||||
boolean saving;
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
boolean presentationInstalled;
|
||||
boolean cleaning;
|
||||
boolean cleanupAttempted;
|
||||
boolean uncertainSave;
|
||||
@@ -91,15 +102,38 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
|
||||
this(characters, receipts, switchDelay, Clock.systemUTC(), nextTick, notices, diagnostics);
|
||||
}
|
||||
|
||||
/** Supply the same wall clock as persistence when testing entry/exit deadline boundaries. */
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
this.switchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.notices = Objects.requireNonNull(notices);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation) {
|
||||
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics);
|
||||
this.presentation = Objects.requireNonNull(presentation);
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation,
|
||||
java.util.function.BooleanSupplier adventureMode, AdventureModeLease gameModes) {
|
||||
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics, presentation);
|
||||
this.adventureMode = Objects.requireNonNull(adventureMode);
|
||||
this.gameModes = Objects.requireNonNull(gameModes);
|
||||
}
|
||||
|
||||
/** One server-thread custody bridge per coordinator; never called by persistence workers. */
|
||||
synchronized void attachCursorCustody(Consumer<UUID> invalidate) {
|
||||
if (cursorCustodyAttached) throw new IllegalStateException("Cursor custody already attached");
|
||||
@@ -146,6 +180,34 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
|
||||
}
|
||||
|
||||
/** Server-thread effect authorization: registry identity alone is not live custody. */
|
||||
public synchronized boolean current(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return !closed && attempt != null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
var actual = player.getInventory().getItem(slot);
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != slot || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session) || !Objects.equals(actual, reported)) return Optional.empty();
|
||||
String ability = "";
|
||||
if (slot >= 2) {
|
||||
ability = controls.ability(actual).orElse("");
|
||||
if (ability.isEmpty() || !ability.equals(attempt.record.loadout().get(slot - 2))) return Optional.empty();
|
||||
}
|
||||
return Optional.of(new SelectedControl(new Active(attempt.owner, attempt.session, attempt.character), slot, ability));
|
||||
}
|
||||
|
||||
public synchronized void enter(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
@@ -164,6 +226,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character.orElseThrow());
|
||||
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
|
||||
attempt.previousGameMode = player.getGameMode().name();
|
||||
}
|
||||
attempt.snapshot = snapshot(player);
|
||||
attempt.cursor = copy(player.getItemOnCursor());
|
||||
pending.put(owner, attempt);
|
||||
@@ -174,7 +239,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
|
||||
|
||||
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
|
||||
if (hasRemnants(attempt.player)) return false;
|
||||
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (EquipmentSessionMarker.present(attempt.player)) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
@@ -214,9 +279,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (state.pending() != null) {
|
||||
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
|
||||
}
|
||||
if (!state.canEnter(attempt.character, switchDelay, Instant.now())) {
|
||||
refuse(attempt, "COOLDOWN", "Wait until " + state.lastExit().deadline()
|
||||
+ " to switch characters; the same character may be resumed now."); return;
|
||||
Instant now = clock.instant();
|
||||
if (!state.canEnter(attempt.character, switchDelay, now)) {
|
||||
Duration remaining = Duration.between(now, state.lastExit().deadline());
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
|
||||
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
|
||||
+ state.lastExit().deadline() + "); the same character may be resumed now."); return;
|
||||
}
|
||||
}
|
||||
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
|
||||
@@ -236,7 +304,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
owned.put(attempt.owner, attempt);
|
||||
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
|
||||
attempt.saving = true;
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character), (saved, error) -> {
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode), (saved, error) -> {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
@@ -260,6 +328,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
try {
|
||||
var items = controls.create(record, attempt.owner, attempt.session);
|
||||
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
if (!gameModes.enter(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false; // Returned refusal is not an uncertain setter exception.
|
||||
refuse(attempt, "GAME_MODE_REFUSED", "Adventure mode was refused; entry did not pack equipment."); return;
|
||||
}
|
||||
if (!entryCurrent(attempt) || attempt.exitRequested) return;
|
||||
attempt.effectsStarted = true;
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKING);
|
||||
var result = equipment.pack(attempt.player.getInventory(), attempt.owner, attempt.session);
|
||||
@@ -276,12 +350,23 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) active.put(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
active.put(attempt.owner, attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
}
|
||||
attempt.player.updateInventory();
|
||||
} catch (RuntimeException error) {
|
||||
fail(attempt, error); // Effects may have started: never roll back or replay them.
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
|
||||
} finally {
|
||||
if (!active.containsKey(attempt.owner) && attempt.modeStarted) {
|
||||
restoreGameMode(attempt);
|
||||
if (!attempt.effectsStarted && attempt.exitRequested) pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.effectsStarted && !current(attempt)) abandon(attempt);
|
||||
}
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
// The deferred exit must reserve persistence before drain can be signalled.
|
||||
try {
|
||||
@@ -306,6 +391,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var transition = transitioning.get(owner);
|
||||
if (transition != null) {
|
||||
active.remove(owner, transition);
|
||||
withdrawPresentation(transition);
|
||||
transition.exitRequested = true;
|
||||
invalidateCursor(owner);
|
||||
return EquipmentStorage.RestoreResult.NOT_FOUND;
|
||||
@@ -336,6 +422,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
return;
|
||||
}
|
||||
withdrawPresentation(attempt);
|
||||
attempt.defeated = true;
|
||||
event.setCancelled(true);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
@@ -436,10 +523,32 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
+ ". Retain current items and authoritative state; no automatic reissue or revival."));
|
||||
}
|
||||
|
||||
private void withdrawPresentation(Attempt attempt) {
|
||||
if (!attempt.presentationInstalled) return;
|
||||
attempt.presentationInstalled = false;
|
||||
try { presentation.withdraw(attempt.owner, attempt.session); }
|
||||
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private boolean restoreGameMode(Attempt attempt) {
|
||||
if (attempt.previousGameMode == null) return true;
|
||||
try {
|
||||
if (gameModes.restore(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false;
|
||||
return true;
|
||||
}
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
notice(attempt.owner, "GAME_MODE_RECOVERY", "Game-mode restoration was refused or ambiguous; retained receipt requires inspection.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
try {
|
||||
invalidateCursor(attempt.owner);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
EquipmentStorage.RestoreResult result;
|
||||
try {
|
||||
result = restoreMarked(attempt);
|
||||
@@ -450,7 +559,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && !modeRestored) attempt.gearRestoredForModeRecovery = true;
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else sync(attempt);
|
||||
@@ -468,6 +578,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private EquipmentStorage.RestoreResult restoreMarked(Attempt attempt) {
|
||||
invalidateCursor(attempt.owner);
|
||||
var marker = EquipmentSessionMarker.read(attempt.player);
|
||||
// Only a successful restoration observed by this process for this exact Player
|
||||
// permits finishing a mode-only retry. Serialized RESTORED alone proves nothing.
|
||||
if (attempt.gearRestoredForModeRecovery && !hasRemnants(attempt.player)
|
||||
&& marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.RESTORED).isPresent()) {
|
||||
return EquipmentStorage.RestoreResult.RESTORED;
|
||||
}
|
||||
if (marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.PACKED).isEmpty()) {
|
||||
notice(attempt.owner, "NATIVE_MARKER", "Missing, malformed, mismatched or ambiguous native equipment marker; "
|
||||
@@ -508,7 +625,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
|
||||
if (attempt.modeStarted || attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
|
||||
if (attempt.acknowledged) {
|
||||
attempt.cleaning = true;
|
||||
attempt.cleanupAttempted = true;
|
||||
@@ -528,6 +645,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private boolean entryCurrent(Attempt attempt) {
|
||||
if (!current(attempt)) { abandon(attempt); return false; }
|
||||
if (!attempt.player.isOnline()
|
||||
|| attempt.previousGameMode != null && !attempt.modeStarted
|
||||
&& !attempt.player.getGameMode().name().equals(attempt.previousGameMode)
|
||||
|| attempt.markerValidated && (EquipmentSessionMarker.present(attempt.player) != (attempt.entryMarker != null)
|
||||
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|
||||
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
@@ -547,6 +666,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (attempt.effectsStarted && owned.get(attempt.owner) == attempt) attempt.nativeUncertain = true;
|
||||
pending.remove(attempt.owner, attempt);
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
abandon(attempt);
|
||||
diagnostic(attempt, failure);
|
||||
}
|
||||
@@ -629,6 +749,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.previousGameMode = receipt.previousGameMode();
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
|
||||
@@ -641,13 +763,18 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
restoreExit(previous);
|
||||
return;
|
||||
}
|
||||
if (previous != null) attempt.claim = previous.claim;
|
||||
if (previous != null) {
|
||||
attempt.claim = previous.claim;
|
||||
attempt.gearRestoredForModeRecovery = previous.player == attempt.player && previous.gearRestoredForModeRecovery;
|
||||
}
|
||||
owned.put(attempt.owner, attempt);
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
try {
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
persistExit(attempt, true);
|
||||
if (!modeRestored) attempt.gearRestoredForModeRecovery = true;
|
||||
persistExit(attempt, modeRestored);
|
||||
sync(attempt);
|
||||
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
} catch (RuntimeException error) {
|
||||
@@ -662,7 +789,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
for (var attempt : transitioning.values()) attempt.exitRequested = true;
|
||||
for (var attempt : transitioning.values()) {
|
||||
attempt.exitRequested = true;
|
||||
withdrawPresentation(attempt);
|
||||
}
|
||||
// Accepted main-thread tasks may be discarded on disable. Report now as well as
|
||||
// from worker completion; neither path needs a scheduler on a disabled plugin.
|
||||
for (var attempt : java.util.List.copyOf(owned.values())) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
@@ -2,14 +2,15 @@ name: FantasyStick
|
||||
version: ${version}
|
||||
main: games.dmg.fantasystick.FantasyStickPlugin
|
||||
api-version: "26.2"
|
||||
depend: [ProtocolLib]
|
||||
description: Transferable character sheets and recoverable equipment sessions; other RPG gameplay is not activated.
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect equipment receipts or request validated online recovery.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid>
|
||||
description: Inspect/recover equipment sessions or configure fantasy mode.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Inspect equipment sessions and request proof-based recovery.
|
||||
description: Inspect/recover equipment sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.session.AdventureModeLease;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdventureModeLeaseNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void changesAndRestoresOnlyTheAcknowledgedHoldersOriginalMode() {
|
||||
for (var original : new GameMode[]{GameMode.SURVIVAL, GameMode.CREATIVE, GameMode.SPECTATOR}) {
|
||||
var actor = new Actor(original);
|
||||
var lease = new AdventureModeLease();
|
||||
var session = UUID.randomUUID();
|
||||
assertTrue(lease.enter(actor.player, session, original.name()));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get());
|
||||
assertTrue(lease.restore(actor.player, session, original.name()));
|
||||
assertEquals(original, actor.mode.get());
|
||||
assertTrue(lease.restore(actor.player, session, original.name()));
|
||||
assertEquals(original, actor.mode.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void adminChangeAwayAndBackToAdventureRelinquishesOriginalRestoration() {
|
||||
var actor = new Actor(GameMode.CREATIVE);
|
||||
var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
assertTrue(lease.enter(actor.player, session, "CREATIVE"));
|
||||
actor.mode.set(GameMode.SURVIVAL);
|
||||
// Native event is dispatched before the second external mode change takes effect.
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(actor.player, GameMode.ADVENTURE);
|
||||
lease.changed(event);
|
||||
actor.mode.set(GameMode.ADVENTURE);
|
||||
assertTrue(lease.restore(actor.player, session, "CREATIVE"));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get(), "Do not override the administrator's final mode");
|
||||
}
|
||||
|
||||
@Test void nativeVetoesLeaveEntryUntouchedAndRestorationRetryableWithoutClaimingSuccess() {
|
||||
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
actor.change = ignored -> {};
|
||||
assertFalse(lease.enter(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.SURVIVAL, actor.mode.get()); assertTrue(lease.available(actor.player));
|
||||
actor.change = actor.mode::set;
|
||||
assertTrue(lease.enter(actor.player, session, "SURVIVAL"));
|
||||
actor.change = ignored -> {};
|
||||
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get()); assertFalse(lease.available(actor.player));
|
||||
actor.change = actor.mode::set;
|
||||
assertTrue(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.SURVIVAL, actor.mode.get());
|
||||
}
|
||||
|
||||
@Test void serializedMatchingProofRecoversButMissingWrongOrInterruptedProofNeverGuesses() throws Exception {
|
||||
var actor = new Actor(GameMode.CREATIVE); var session = UUID.randomUUID();
|
||||
assertTrue(new AdventureModeLease().enter(actor.player, session, "CREATIVE"));
|
||||
var reloaded = new Actor(GameMode.ADVENTURE);
|
||||
reloaded.pdc.readFromBytes(actor.pdc.serializeToBytes(), true);
|
||||
var lease = new AdventureModeLease();
|
||||
assertFalse(lease.restore(reloaded.player, UUID.randomUUID(), "CREATIVE"));
|
||||
assertFalse(lease.restore(reloaded.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
|
||||
assertTrue(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
assertEquals(GameMode.CREATIVE, reloaded.mode.get());
|
||||
var key = new org.bukkit.NamespacedKey("fantasystick", "adventure_session");
|
||||
for (String phase : new String[]{"APPLYING", "RESTORING", "unknown"}) {
|
||||
reloaded.mode.set(GameMode.ADVENTURE);
|
||||
reloaded.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "1|" + session + "|CREATIVE|" + phase);
|
||||
var before = reloaded.pdc.serializeToBytes();
|
||||
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
assertArrayEquals(before, reloaded.pdc.serializeToBytes());
|
||||
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
|
||||
}
|
||||
reloaded.pdc.remove(key);
|
||||
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
}
|
||||
|
||||
@Test void setterExceptionsRetainAmbiguousProofWithoutAutomaticRetryAndDisabledNeverTouchesMode() {
|
||||
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
actor.change = ignored -> { throw new IllegalStateException("uncertain"); };
|
||||
assertTrue(lease.enter(actor.player, session, null)); assertTrue(lease.restore(actor.player, session, null));
|
||||
assertTrue(lease.available(actor.player));
|
||||
assertThrows(IllegalStateException.class, () -> lease.enter(actor.player, session, "SURVIVAL"));
|
||||
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertFalse(lease.available(actor.player));
|
||||
}
|
||||
|
||||
static final class Actor {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final AtomicReference<GameMode> mode;
|
||||
java.util.function.Consumer<GameMode> change;
|
||||
final org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer pdc =
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
|
||||
final Player player;
|
||||
Actor(GameMode original) {
|
||||
mode = new AtomicReference<>(original);
|
||||
change = mode::set;
|
||||
player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> switch (name) {
|
||||
case "getUniqueId" -> id;
|
||||
case "getGameMode" -> mode.get();
|
||||
case "setGameMode" -> { change.accept((GameMode) args[0]); yield null; }
|
||||
case "getPersistentDataContainer" -> pdc;
|
||||
default -> throw new AssertionError(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,47 @@ class CharacterAnvilNamesNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void onlyAcknowledgedCopyRenameRefreshesAnActiveOtherHolderAndLateCallbacksCannotReplay() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var anvil = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original); anvil.grid.setItem(0, sheet);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
var tasks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
var output = new CharacterPresentationNativeTest.Output();
|
||||
var holder = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID();
|
||||
var player = FantasyStickLifecycleNativeTest.port(org.bukkit.entity.Player.class, (name, args) -> holder);
|
||||
var main = Thread.currentThread();
|
||||
var presentation = new games.dmg.fantasystick.presentation.CharacterPresentation(output, tasks::add,
|
||||
() -> Thread.currentThread() == main, failure -> { throw new AssertionError(failure); });
|
||||
presentation.activate(player, session, original);
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
var listener = new CharacterAnvilNames(storage, problems::add,
|
||||
org.bukkit.craftbukkit.inventory.CraftItemFactory.instance(), presentation::acknowledged);
|
||||
var renamed = named(sheet, Component.text("Copy's <red>saved name 雪 🐉"));
|
||||
listener.onTaken(new AnvilTakeResultEvent(anvil.actor.player, anvil.view, renamed));
|
||||
var saved = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals(original.name(), presentation.name(holder), "Worker may not perform native output");
|
||||
var callback = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertEquals(saved.name(), presentation.name(holder)); assertEquals(1, output.renamed.size());
|
||||
assertNotEquals(holder, anvil.actor.id, "An inactive physical copy may rename another holder's active character");
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
listener.onTaken(new AnvilTakeResultEvent(anvil.actor.player, anvil.view, named(sheet, Component.text("failed"))));
|
||||
assertEquals(saved, storage.find(original.id()).join().orElseThrow());
|
||||
assertEquals(1, problems.size()); assertTrue(tasks.isEmpty());
|
||||
assertEquals(saved.name(), presentation.name(holder));
|
||||
}
|
||||
listener.onTaken(new AnvilTakeResultEvent(anvil.actor.player, anvil.view, named(sheet, Component.text("late"))));
|
||||
storage.find(original.id()).join();
|
||||
callback = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback);
|
||||
presentation.withdraw(holder, session); presentation.close(); callback.run();
|
||||
assertNull(presentation.name(holder)); assertEquals(1, output.renamed.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAcceptedNativeResultSavesItsNewNameWithoutTouchingInventoryOrPlayerXp() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Actual packaged input/book assembly with native pages/items and explicit client/scheduler ports. */
|
||||
class CharacterBookNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void queuedInspectionCannotOpenForAMovedSheetChangedSlotQuitOrDisabledLifetime() throws Exception {
|
||||
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
click(f, a); drainReads(f);
|
||||
assertEquals(1, f.tasks.size());
|
||||
switch (route) {
|
||||
case 0 -> { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
case 1 -> a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
case 2 -> { a.core.online = false; f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty())); }
|
||||
case 3 -> { f.plugin.setEnabled(false); f.awaitWorkers(); }
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
runQueued(f);
|
||||
assertTrue(a.books.isEmpty(), "Stale book callback opened a UI on route " + route);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void latestRequestWinsAndReloadsNamesWithoutRetainingAnOlderRead() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
click(f, a); drainReads(f);
|
||||
var characters = characters(f);
|
||||
characters.renameCharacter(a.core.record.id(), "Latest book name").get(5, TimeUnit.SECONDS);
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var old = a.core.record;
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
var strength = new java.math.BigInteger("123456789012345678901234567890");
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, 30, java.math.BigInteger.valueOf(2900), java.math.BigInteger.valueOf(9), old.health(),
|
||||
java.util.Set.of("revive", "unavailable_saved_id"), old.loadout(), java.util.Map.of(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
String rendered = text(a.books.getFirst());
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
|
||||
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedRecordReadLeavesDataAndInventoryUntouchedAndCanBeRetriedAfterRepair() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var path = data.resolve("characters").resolve(a.core.record.id() + ".yml");
|
||||
byte[] valid = Files.readAllBytes(path);
|
||||
byte[] invalid = "schema-version: malformed\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var before = a.core.snapshot();
|
||||
Files.write(path, invalid); click(f, a); drainReads(f); runQueued(f);
|
||||
assertTrue(a.books.isEmpty());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be loaded")));
|
||||
assertArrayEquals(invalid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
|
||||
Files.write(path, valid); click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
assertArrayEquals(valid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var vetoed = interaction(a); vetoed.setUseItemInHand(Event.Result.DENY); f.dispatcher.accept(vetoed);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1); click(f, a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2); click(f, a);
|
||||
drainReads(f); runQueued(f);
|
||||
assertTrue(a.books.isEmpty());
|
||||
assertTrue(a.messages.isEmpty(), "Inert controls do not manufacture book errors or mutate items");
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
private static CharacterStorage characters(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
characters(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
private static PlayerInteractEvent interaction(FantasyStickLifecycleNativeTest.Actor a) {
|
||||
return new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(), null,
|
||||
BlockFace.SELF, EquipmentSlot.HAND);
|
||||
}
|
||||
private static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) {
|
||||
var event = interaction(a); f.dispatcher.accept(event);
|
||||
assertEquals(Event.Result.DENY, event.useItemInHand());
|
||||
}
|
||||
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
Runnable task;
|
||||
while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.presentation.CharacterChatNames;
|
||||
import io.papermc.paper.chat.ChatRenderer;
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.audience.Audience;
|
||||
import net.kyori.adventure.chat.SignedMessage;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterChatNamesNativeTest {
|
||||
@Test void nativeRendererReceivesLiteralSessionNameWithoutReplacingSignedBodyOrRecipients() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Player player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> {
|
||||
assertEquals("getUniqueId", name); return id;
|
||||
});
|
||||
var names = new HashMap<UUID, String>();
|
||||
String name = "<rainbow>雪 🐉 literal name longer than sixteen";
|
||||
names.put(id, name);
|
||||
var listener = new CharacterChatNames(names::get);
|
||||
var body = Component.text("original signed <body>");
|
||||
var signed = SignedMessage.system("original signed <body>", body);
|
||||
var viewers = new HashSet<Audience>(); viewers.add(Audience.empty());
|
||||
ChatRenderer renderer = (sender, display, message, viewer) -> {
|
||||
assertSame(player, sender); assertSame(body, message);
|
||||
assertEquals(Component.text(name), display);
|
||||
return Component.text("prefix ").append(display).append(message);
|
||||
};
|
||||
var event = new AsyncChatEvent(true, player, viewers, renderer, body, body, signed);
|
||||
listener.onChat(event);
|
||||
assertSame(body, event.message()); assertSame(body, event.originalMessage());
|
||||
assertSame(signed, event.signedMessage()); assertEquals(viewers, event.viewers()); assertFalse(event.isCancelled());
|
||||
// The renderer may run after a switch; this accepted event captures its immutable sender name.
|
||||
names.put(id, "replacement");
|
||||
assertEquals(Component.text("prefix ").append(Component.text(name)).append(body),
|
||||
event.renderer().render(player, Component.text("account"), event.message(), Audience.empty()));
|
||||
}
|
||||
|
||||
@Test void cancellationsAndOrdinaryRenderersAreUntouched() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Player player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> id);
|
||||
var listener = new CharacterChatNames(ignored -> "fantasy");
|
||||
var body = Component.text("body"); var renderer = ChatRenderer.defaultRenderer();
|
||||
var event = new AsyncChatEvent(true, player, new HashSet<>(), renderer, body, body, SignedMessage.system("body", body));
|
||||
event.setCancelled(true); listener.onChat(event);
|
||||
assertTrue(event.isCancelled()); assertSame(renderer, event.renderer());
|
||||
event.setCancelled(false); new CharacterChatNames(ignored -> null).onChat(event);
|
||||
assertSame(renderer, event.renderer()); assertSame(body, event.message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterPresentationNativeTest {
|
||||
@Test void immutableSessionNamesWithdrawBeforeOutputFailureAndRejectOldAcknowledgments() {
|
||||
UUID holder = UUID.randomUUID(); var first = UUID.randomUUID(); var second = UUID.randomUUID();
|
||||
var player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> { assertEquals("getUniqueId", name); return holder; });
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var tasks = new ArrayList<Runnable>(); var failures = new ArrayList<Throwable>();
|
||||
var output = new Output();
|
||||
var presentation = new CharacterPresentation(output, tasks::add, () -> true, failures::add);
|
||||
presentation.activate(player, first, record);
|
||||
assertEquals(record.name(), presentation.name(holder));
|
||||
assertEquals(1, output.shown.size());
|
||||
var snapshot = presentation.snapshot();
|
||||
var renamed = rename(record, "雪 <red>literal 🐉", 1);
|
||||
presentation.acknowledged(renamed);
|
||||
presentation.withdraw(holder, first);
|
||||
presentation.activate(player, second, rename(record, "new authoritative", 2));
|
||||
tasks.forEach(Runnable::run); tasks.clear();
|
||||
assertEquals("new authoritative", presentation.name(holder));
|
||||
assertEquals(record.name(), snapshot.get(holder).name());
|
||||
presentation.withdraw(holder, first); // Old token cannot erase replacement.
|
||||
assertEquals("new authoritative", presentation.name(holder));
|
||||
output.failClear = true;
|
||||
presentation.withdraw(holder, second);
|
||||
assertNull(presentation.name(holder)); assertEquals(1, failures.size());
|
||||
presentation.close(); presentation.acknowledged(rename(record, "late", 3));
|
||||
tasks.forEach(Runnable::run);
|
||||
presentation.activate(player, first, record);
|
||||
assertTrue(presentation.snapshot().isEmpty());
|
||||
}
|
||||
|
||||
@Test void activationClosesTheFreshReadToRenameAcknowledgmentRaceAndNeverAppliesAnOldReadToANewSession() {
|
||||
UUID holder = UUID.randomUUID();
|
||||
Player player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> holder);
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var reads = new ArrayList<java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>>();
|
||||
var tasks = new ArrayList<Runnable>(); var output = new Output();
|
||||
var presentation = new CharacterPresentation(output, tasks::add, () -> true, failure -> { throw new AssertionError(failure); }, id -> {
|
||||
assertEquals(record.id(), id);
|
||||
var read = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>(); reads.add(read); return read;
|
||||
});
|
||||
// Rename committed after EquipmentSessions' final load but before presentation activation.
|
||||
presentation.acknowledged(rename(record, "already saved while not active", 1));
|
||||
UUID session = UUID.randomUUID();
|
||||
presentation.activate(player, session, record);
|
||||
assertEquals(1, reads.size(), "Resume always rechecks the authoritative record, not a physical-sheet/cache name");
|
||||
reads.getFirst().complete(java.util.Optional.of(rename(record, "already saved while not active", 1)));
|
||||
tasks.forEach(Runnable::run); tasks.clear();
|
||||
assertEquals("already saved while not active", presentation.name(holder));
|
||||
presentation.withdraw(holder, session);
|
||||
UUID replacement = UUID.randomUUID();
|
||||
presentation.activate(player, replacement, record);
|
||||
reads.get(1).complete(java.util.Optional.of(rename(record, "old queued name", 2)));
|
||||
presentation.withdraw(holder, replacement);
|
||||
presentation.activate(player, UUID.randomUUID(), rename(record, "fresh current name", 3));
|
||||
tasks.forEach(Runnable::run); tasks.clear();
|
||||
assertEquals("fresh current name", presentation.name(holder));
|
||||
reads.get(2).complete(java.util.Optional.of(rename(record, "fresh current name", 3)));
|
||||
assertTrue(tasks.isEmpty(), "Equal revision does not enqueue redundant native output");
|
||||
presentation.close();
|
||||
}
|
||||
|
||||
static CharacterRecord rename(CharacterRecord r, String name, int revision) {
|
||||
return new CharacterRecord(r.id(), name, java.math.BigInteger.valueOf(revision), r.stats(), r.level(), r.experience(),
|
||||
r.unspentPoints(), r.health(), r.learned(), r.loadout(), r.cooldowns(), r.dead(), r.deathId(), r.diedAt());
|
||||
}
|
||||
static final class Output implements CharacterPresentation.Output {
|
||||
final ArrayList<CharacterPresentation.View> shown = new ArrayList<>();
|
||||
final ArrayList<CharacterPresentation.View> renamed = new ArrayList<>();
|
||||
final ArrayList<CharacterPresentation.View> cleared = new ArrayList<>();
|
||||
boolean failClear;
|
||||
@Override public void show(Player player, CharacterPresentation.View view) { shown.add(view); }
|
||||
@Override public void rename(CharacterPresentation.View view) { renamed.add(view); }
|
||||
@Override public void clear(CharacterPresentation.View view) { cleared.add(view); if (failClear) throw new IllegalStateException("native send failed"); }
|
||||
@Override public void close() { }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,62 @@ class EquipmentAdminCommandNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=true"));
|
||||
sender.messages.clear();
|
||||
for (String value : List.of("yes", "1", "TRUE", "")) {
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", value});
|
||||
assertTrue(sender.messages.getLast().contains("true|false"));
|
||||
}
|
||||
assertTrue(f.tasks.isEmpty());
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
sender.allowed = false;
|
||||
f.command.getExecutor().onCommand(sender.port, f.command, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
sender.allowed = true; sender.messages.clear();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
assertTrue(sender.messages.isEmpty(), "Do not report success before acknowledgment");
|
||||
var reply = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(reply); reply.run();
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
assertTrue(sender.messages.getLast().contains("new sessions"));
|
||||
assertEquals(0, f.playerLookups);
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableWaitsOffThreadForAcceptedConfigurationWritesBeforeReadingNewPolicy() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var old = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(old, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"), "Do not activate from a stale config snapshot");
|
||||
release.countDown();
|
||||
f.tick();
|
||||
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void permissionAndStrictShapeRejectBeforePersistenceOrNativeLookup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -626,7 +626,9 @@ class EquipmentSessionEventsNativeTest {
|
||||
assertTrue(dead.dead()); assertEquals("Newest name", dead.name());
|
||||
assertEquals(dead.deathId(), new games.dmg.fantasystick.items.TailBooks().identify(book).orElseThrow().deathId());
|
||||
assertEquals(1, a.teleports); assertEquals(0, f.world.creates);
|
||||
assertNull(f.core.state(a).pending());
|
||||
var exited = f.core.state(a);
|
||||
assertNull(exited.pending()); assertEquals(a.record.id(), exited.lastExit().characterId());
|
||||
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exited.lastExit().exitedAt(), exited.lastExit().deadline()));
|
||||
assertTrue(f.core.notices.stream().anyMatch(n -> n.code().equals("DEFEAT")));
|
||||
}
|
||||
}
|
||||
@@ -1234,6 +1236,285 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookInputIsDeliveredAfterVanillaContainmentAndExpiresWithItsSession() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.onInput = input -> assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand(),
|
||||
"Vanilla must already be contained before a callback can reenter");
|
||||
f.dispatcher.accept(event);
|
||||
assertEquals(1, f.inputs.size(), "The custody guard must not swallow the intentional book action");
|
||||
var input = f.inputs.getFirst();
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.BOOK, input.kind());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, input.gesture());
|
||||
assertEquals(f.core.sessions.active(a.id).orElseThrow(), input.session());
|
||||
assertEquals(0, input.slot()); assertEquals("", input.ability());
|
||||
assertTrue(input.stillCurrent());
|
||||
f.core.sessions.exit(a.player);
|
||||
assertFalse(input.stillCurrent(), "Queued handlers cannot reuse input after an exit");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void basicAndSwordAttackInputsPreserveNativeChargeWithoutAnOrdinarySecondHit() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("great_sword");
|
||||
var a = f.actor; f.core.activate(a);
|
||||
a.attackStrength = 0.375f;
|
||||
var target = port(org.bukkit.entity.Zombie.class);
|
||||
for (int slot : new int[]{1, 2}) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.onInput = input -> assertTrue(event.isCancelled());
|
||||
int before = f.inputs.size(); f.dispatcher.accept(event);
|
||||
assertEquals(before + 1, f.inputs.size(), "Native melee intent must reach the owned action adapter");
|
||||
var input = f.inputs.getLast();
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, input.gesture());
|
||||
assertEquals(slot == 1 ? "" : "great_sword", input.ability());
|
||||
assertEquals(slot == 1 ? games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK
|
||||
: games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, input.kind());
|
||||
assertSame(target, input.target()); assertEquals(0.375f, input.attackStrength());
|
||||
assertTrue(input.stillCurrent());
|
||||
f.dispatcher.accept(event); assertEquals(before + 1, f.inputs.size(), "Contained event replay is not another input");
|
||||
}
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var bookHit = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(bookHit); assertTrue(bookHit.isCancelled()); assertEquals(2, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
|
||||
f.loadout(abilities);
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Player.class);
|
||||
for (int index = 0; index < abilities.length; index++) {
|
||||
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var item = a.inventory.getItem(slot);
|
||||
var air = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
item, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
int before = f.inputs.size(); f.dispatcher.accept(air);
|
||||
assertEquals(before + 1, f.inputs.size(), abilities[index]);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, air.useItemInHand());
|
||||
assertEquals(abilities[index], f.inputs.getLast().ability());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, f.inputs.getLast().kind());
|
||||
assertTrue(f.inputs.getLast().stillCurrent());
|
||||
var entity = new org.bukkit.event.player.PlayerInteractEntityEvent(a.player, target, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(entity);
|
||||
assertTrue(entity.isCancelled()); assertEquals(before + 2, f.inputs.size());
|
||||
assertSame(target, f.inputs.getLast().target());
|
||||
assertEquals(abilities[index], f.inputs.getLast().ability());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inputRoutingRejectsVetoesWrongHandsForgedSlotsAndAbilitiesAndEndedSessions() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("revive", "", "removed_spell");
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var book = a.inventory.getItem(0).clone();
|
||||
var use = new java.util.function.BiFunction<ItemStack, org.bukkit.inventory.EquipmentSlot, org.bukkit.event.player.PlayerInteractEvent>() {
|
||||
@Override public org.bukkit.event.player.PlayerInteractEvent apply(ItemStack item, org.bukkit.inventory.EquipmentSlot hand) {
|
||||
return new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
item, null, org.bukkit.block.BlockFace.SELF, hand);
|
||||
}
|
||||
};
|
||||
var vetoed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
vetoed.setUseItemInHand(org.bukkit.event.Event.Result.DENY); f.dispatcher.accept(vetoed);
|
||||
f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.OFF_HAND));
|
||||
var wrongReport = book.clone(); wrongReport.editMeta(meta -> meta.displayName(net.kyori.adventure.text.Component.text("stale")));
|
||||
f.dispatcher.accept(use.apply(wrongReport, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
for (String key : new String[]{"control_owner_id", "control_session_id"}) {
|
||||
var forged = book.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", key), org.bukkit.persistence.PersistentDataType.STRING,
|
||||
java.util.UUID.randomUUID().toString()));
|
||||
a.inventory.setItem(0, forged); f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
}
|
||||
a.inventory.setItem(0, book);
|
||||
a.inventory.getInventory().setSelectedSlot(2);
|
||||
var equipped = a.inventory.getItem(2).clone();
|
||||
// Keep valid owner/session/slot but substitute another structurally valid echo-shard spell reference.
|
||||
var forged = equipped.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "control_ability_id"), org.bukkit.persistence.PersistentDataType.STRING, "fireball"));
|
||||
forged.setType(Material.SNOWBALL); a.inventory.setItem(2, forged);
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(forged).isPresent(),
|
||||
"This must exercise authoritative loadout matching, not only malformed item rejection");
|
||||
f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
a.inventory.setItem(2, equipped);
|
||||
for (int slot : new int[]{3, 4}) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
f.dispatcher.accept(use.apply(a.inventory.getItem(slot), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
}
|
||||
assertTrue(f.inputs.isEmpty(), "No ability/cast authority from invalid or inert inputs");
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var allowed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
allowed.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
|
||||
allowed.setUseItemInHand(org.bukkit.event.Event.Result.ALLOW);
|
||||
f.dispatcher.accept(allowed);
|
||||
assertEquals(1, f.inputs.size(), "An independent native block denial does not forbid an explicitly allowed item action");
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, allowed.useInteractedBlock());
|
||||
f.dispatcher.accept(allowed); assertEquals(1, f.inputs.size());
|
||||
var input = f.inputs.getFirst();
|
||||
a.inventory.getInventory().setSelectedSlot(1); assertFalse(input.stillCurrent());
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
a.inventory.setItemInOffHand(null); assertFalse(input.stillCurrent());
|
||||
f.core.sessions.exit(a.player);
|
||||
a.inventory.setItem(0, book); f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
assertEquals(1, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void longbowUsesNativeDrawingAndReportsReleaseWithoutVanillaAmmoOrWeaponUse() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
a.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
|
||||
var before = a.snapshot();
|
||||
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(start);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, start.useItemInHand());
|
||||
assertEquals(1, a.useStarts, "Begin native drawing directly, not vanilla BowItem.use's ammunition path");
|
||||
assertEquals(bow, a.activeItem); assertTrue(f.inputs.isEmpty(), "The spell consumes a release, not each right-click");
|
||||
assertEquals(net.minecraft.world.item.ItemUseAnimation.BOW,
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).getUseAnimation());
|
||||
f.onInput = input -> assertTrue(a.activeItem.isEmpty(), "Stop native release before BowItem can draw ordinary ammunition");
|
||||
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 23);
|
||||
f.dispatcher.accept(release);
|
||||
assertEquals(1, f.inputs.size()); assertEquals(1, a.useClears);
|
||||
var input = f.inputs.getFirst();
|
||||
assertEquals("longbow", input.ability());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, input.gesture());
|
||||
assertEquals(23, input.heldTicks()); assertTrue(input.stillCurrent());
|
||||
// Execute the actual post-event ItemStack continuation on the cleared native use item.
|
||||
// Null world/entity ports deliberately fail if it tries to run a weapon/effect instead of AIR.
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).releaseUsing(null, null, 0);
|
||||
assertArrayEquals(before, a.snapshot(), "No arrows, durability, controls or ordinary stacks consumed");
|
||||
f.dispatcher.accept(release); assertEquals(1, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void drawingIsRevokedOnExitHeldChangeSlotReplacementAndLifecycleWithoutClearingUnrelatedUse() throws Exception {
|
||||
for (int route = 0; route < 9; route++) try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
if (route == 8) a.startUse = () -> f.core.sessions.exit(a.player);
|
||||
f.dispatcher.accept(start); assertEquals(1, a.useStarts);
|
||||
switch (route) {
|
||||
case 0 -> f.core.sessions.exit(a.player);
|
||||
case 1 -> f.bridge.close();
|
||||
case 2 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
|
||||
case 3 -> { f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick(); }
|
||||
case 4 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3));
|
||||
case 5 -> { a.inventory.setItem(2, new ItemStack(Material.DIAMOND)); f.changed(); }
|
||||
case 6 -> { a.activeItem = new ItemStack(Material.APPLE); f.core.sessions.exit(a.player); }
|
||||
case 7 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerRespawnEvent(a.player, a.location, false));
|
||||
case 8 -> { /* Exit ran inside the native start callback. */ }
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
if (route == 6) {
|
||||
assertEquals(new ItemStack(Material.APPLE), a.activeItem); assertEquals(0, a.useClears);
|
||||
} else assertTrue(a.activeItem.isEmpty(), "Own drawing survived lifecycle route " + route);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20));
|
||||
assertTrue(f.inputs.isEmpty(), "Stale draw release must never trigger a spell");
|
||||
if (route == 6) assertEquals(new ItemStack(Material.APPLE), a.activeItem);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void releaseSetterFailureIsReportedWithoutEmittingOrReplayingTheOwnedInput() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
a.clearUse = () -> { throw new IllegalStateException("Native clear failed after changing state"); };
|
||||
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20);
|
||||
assertDoesNotThrow(() -> f.bridge.release(release), "The native handler must contain and report setter failure");
|
||||
assertEquals(1, f.bridgeFailures.size(), "Native uncertainty must reach transition diagnostics");
|
||||
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
|
||||
assertTrue(f.inputs.isEmpty()); assertTrue(a.activeItem.isEmpty());
|
||||
a.clearUse = () -> {};
|
||||
f.dispatcher.accept(release); assertTrue(f.inputs.isEmpty());
|
||||
f.bridgeFailures.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inputCallbacksMayExitOrFailWithoutReleasingVanillaContainmentOrReplaying() throws Exception {
|
||||
for (boolean fail : new boolean[]{false, true}) try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var book = a.inventory.getItem(0).clone();
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
book, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.onInput = input -> {
|
||||
assertTrue(input.stillCurrent());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
if (fail) throw new IllegalStateException("Action adapter failed");
|
||||
f.core.sessions.exit(a.player);
|
||||
assertFalse(input.stillCurrent());
|
||||
};
|
||||
f.dispatcher.accept(event); f.dispatcher.accept(event);
|
||||
assertEquals(1, f.inputs.size());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
if (fail) {
|
||||
assertEquals(1, f.bridgeFailures.size());
|
||||
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
|
||||
f.bridgeFailures.clear();
|
||||
} else assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void drawVetoesDuplicateStartsAndUnrelatedNativeUseArePreserved() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
f.dispatcher.accept(event); assertEquals(0, a.useStarts);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT);
|
||||
a.activeItem = new ItemStack(Material.APPLE);
|
||||
f.dispatcher.accept(event); assertEquals(0, a.useStarts); assertEquals(0, a.useClears);
|
||||
a.activeItem = new ItemStack(Material.AIR);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
|
||||
assertEquals(1, a.useStarts);
|
||||
var held = new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3);
|
||||
held.setCancelled(true); f.dispatcher.accept(held);
|
||||
assertEquals(bow, a.activeItem); assertEquals(0, a.useClears);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 0));
|
||||
assertEquals(1, f.inputs.size()); assertEquals(0, f.inputs.getFirst().heldTicks(),
|
||||
"Input routing must not invent partial-draw balance or charge thresholds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void protectedIconsCannotAttackAsVanillaWeaponsButOrdinaryAttacksStayUntouched() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Zombie.class);
|
||||
var before = a.snapshot();
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "Control slot " + slot + " must not deal a second ordinary weapon hit");
|
||||
}
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
f.core.sessions.exit(a.player);
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var ordinary = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
var vetoed = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeUseDropTransferCraftAndDispenseCannotConsumeOrExportProtectedItems() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1536,16 +1817,51 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defeatStartsPlayerDelayButLegitimateBackendRevivalCanResumeTheSameIdentity() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; var copiedSheet = a.inventory.getItemInOffHand().clone();
|
||||
f.core.activate(a); f.changed();
|
||||
var active = f.core.sessions.active(a.id).orElseThrow();
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
|
||||
var exit = f.core.state(a).lastExit();
|
||||
assertEquals(active.session(), exit.sessionId()); assertEquals(a.record.id(), exit.characterId());
|
||||
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
f.core.tick(); // Confirm Tail delivery and release the defeat's claim.
|
||||
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
|
||||
a.inventory.setItemInOffHand(copiedSheet); f.changed();
|
||||
// The bridge observed the original ID before defeat; join explicitly requests saved-state eligibility.
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.core.tick(); f.core.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code());
|
||||
assertEquals(exit, f.core.state(a).lastExit());
|
||||
var other = f.core.actor(); a.inventory.setItemInOffHand(other.inventory.getItemInOffHand()); f.changed();
|
||||
f.ticks.remove().run(); f.core.tick();
|
||||
assertEquals("COOLDOWN", f.core.notices.getLast().code());
|
||||
var dead = f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
var live = f.core.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
a.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(live)); f.changed();
|
||||
f.ticks.remove().run(); f.core.tick(); f.core.tick();
|
||||
assertEquals(live.id(), f.core.sessions.active(a.id).orElseThrow().character());
|
||||
assertEquals(exit, f.core.state(a).lastExit());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacingCharacterExitsFirstAndCoordinatorEnforcesDifferentIdCooldownWithoutExtendingIt() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a); f.changed();
|
||||
var other = f.core.actor();
|
||||
a.inventory.setItemInOffHand(other.inventory.getItemInOffHand().clone());
|
||||
var sameName = f.core.characters.renameCharacter(other.record.id(), a.record.name())
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
a.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(sameName));
|
||||
f.changed();
|
||||
assertTrue(f.core.sessions.active(a.id).isEmpty());
|
||||
assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType(), "Replacement exits and restores before its new entry");
|
||||
assertEquals(Material.DIAMOND_HELMET, a.inventory.getHelmet().getType());
|
||||
var restored = a.snapshot(); var cursor = a.cursor.clone();
|
||||
var exited = f.core.state(a).lastExit(); // IO barrier, not a tick-thread production wait.
|
||||
f.ticks.remove().run(); f.core.tick();
|
||||
assertEquals("COOLDOWN", f.core.notices.getLast().code());
|
||||
assertArrayEquals(restored, a.snapshot()); assertEquals(cursor, a.cursor);
|
||||
assertEquals(exited, f.core.state(a).lastExit());
|
||||
a.inventory.setItemInOffHand(new ItemStack(Material.AIR)); f.changed();
|
||||
a.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(a.record)); f.changed();
|
||||
@@ -1653,6 +1969,30 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shiftClickCannotEquipAHeadWhoseNativeComponentTargetsChestLegsOrFeet() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
for (var slot : new org.bukkit.inventory.EquipmentSlot[]{org.bukkit.inventory.EquipmentSlot.CHEST,
|
||||
org.bukkit.inventory.EquipmentSlot.LEGS, org.bukkit.inventory.EquipmentSlot.FEET}) {
|
||||
var head = new ItemStack(Material.PLAYER_HEAD);
|
||||
head.setData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE,
|
||||
io.papermc.paper.datacomponent.item.Equippable.equippable(slot));
|
||||
a.inventory.setItem(20, head);
|
||||
var before = a.snapshot();
|
||||
var event = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||
f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled(), "Native quick-move uses the component's " + slot + " destination, not the head material");
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
}
|
||||
a.inventory.setItem(20, new ItemStack(Material.PLAYER_HEAD));
|
||||
var allowed = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||
f.dispatcher.accept(allowed);
|
||||
assertFalse(allowed.isCancelled(), "A genuine unprotected head may still equip in HEAD");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cosmeticAttributesAndNonHeadEquippableComponentsCannotBypassArmorGuard() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1742,6 +2082,78 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recipeBookAutoFillCannotPullAnExactBoundControlFromTheHotbar() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var control = a.inventory.getItem(1).clone();
|
||||
var key = new org.bukkit.NamespacedKey("test", "exact_control_recipe");
|
||||
var recipe = new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND))
|
||||
.addIngredient(new org.bukkit.inventory.RecipeChoice.ExactChoice(control));
|
||||
f.recipes.put(key, recipe);
|
||||
var nativeControl = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(control);
|
||||
assertFalse(net.minecraft.world.entity.player.Inventory.isUsableForCrafting(nativeControl),
|
||||
"Ordinary auto-fill ignores named controls, but exact ingredients bypass that check");
|
||||
assertEquals(1, a.inventory.getInventory().findSlotMatchingCraftingIngredient(
|
||||
new io.papermc.paper.inventory.recipe.ItemOrExact.Exact(nativeControl), net.minecraft.world.item.ItemStack.EMPTY));
|
||||
var before = a.snapshot();
|
||||
for (boolean all : new boolean[]{false, true}) {
|
||||
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, all);
|
||||
f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled(), "Auto-fill has no ordinary InventoryClickEvent custody check");
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
assertEquals(key, event.getRecipe()); assertEquals(all, event.isMakeAll());
|
||||
}
|
||||
var ordinaryKey = new org.bukkit.NamespacedKey("test", "ordinary_recipe");
|
||||
f.recipes.put(ordinaryKey, new org.bukkit.inventory.ShapelessRecipe(ordinaryKey, new ItemStack(Material.DIAMOND))
|
||||
.addIngredient(Material.STONE));
|
||||
a.inventory.setItem(20, new ItemStack(Material.STONE));
|
||||
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, ordinaryKey, false);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void autoFillGuardsShapedCookingUnnamedAndGridControlsWithoutBlockingOrdinaryRecipes() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var key = new org.bukkit.NamespacedKey("test", "autofill");
|
||||
var control = a.inventory.getItem(1).clone();
|
||||
for (var recipe : new org.bukkit.inventory.Recipe[]{
|
||||
new org.bukkit.inventory.ShapedRecipe(key, new ItemStack(Material.DIAMOND)).shape("S")
|
||||
.setIngredient('S', new org.bukkit.inventory.RecipeChoice.ExactChoice(control)),
|
||||
new org.bukkit.inventory.FurnaceRecipe(key, new ItemStack(Material.DIAMOND),
|
||||
new org.bukkit.inventory.RecipeChoice.ExactChoice(control), 0, 100)}) {
|
||||
f.recipes.put(key, recipe);
|
||||
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
var before = a.snapshot(); f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
|
||||
}
|
||||
f.recipes.put(key, new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND)).addIngredient(Material.STICK));
|
||||
var named = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(named); assertFalse(named.isCancelled(), "Native name exclusion already prevents choosing the icon");
|
||||
var meta = control.getItemMeta(); meta.displayName(null); control.setItemMeta(meta);
|
||||
a.inventory.setItem(1, control);
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(control).isPresent());
|
||||
var unnamed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, true);
|
||||
f.dispatcher.accept(unnamed); assertTrue(unnamed.isCancelled(), "Custody cannot depend on a display name");
|
||||
var grid = (org.bukkit.inventory.CraftingInventory) a.view.getTopInventory();
|
||||
grid.setMatrix(new ItemStack[]{control, null, null, null});
|
||||
var stale = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer,
|
||||
new org.bukkit.NamespacedKey("test", "missing_recipe"), false);
|
||||
f.dispatcher.accept(stale); assertTrue(stale.isCancelled(), "Do not return existing protected grid contents through vanilla auto-fill");
|
||||
grid.setMatrix(new ItemStack[4]);
|
||||
var vetoed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
|
||||
f.core.sessions.exit(a.player);
|
||||
// A recovered/remnant control outside an active session is still not a crafting ingredient.
|
||||
a.inventory.setItem(20, control);
|
||||
var remnant = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(remnant); assertTrue(remnant.isCancelled());
|
||||
a.inventory.setItem(20, new ItemStack(Material.STICK));
|
||||
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void genericTransformationResultsAndArmorStandConsumptionProtectHiddenControlInputs() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1843,8 +2255,11 @@ class EquipmentSessionEventsNativeTest {
|
||||
boolean throwingDiagnostics;
|
||||
final EquipmentSessionEvents bridge;
|
||||
final java.util.ArrayList<games.dmg.fantasystick.session.EquipmentSessions.Failure> bridgeFailures = new java.util.ArrayList<>();
|
||||
final java.util.List<games.dmg.fantasystick.session.ControlInput> inputs = new java.util.ArrayList<>();
|
||||
java.util.function.Consumer<games.dmg.fantasystick.session.ControlInput> onInput = ignored -> { };
|
||||
final PlayerSheetDropsNativeTest.WorldFixture world = new PlayerSheetDropsNativeTest.WorldFixture();
|
||||
org.bukkit.entity.Player eventPlayer;
|
||||
final java.util.Map<org.bukkit.NamespacedKey, org.bukkit.inventory.Recipe> recipes = new java.util.HashMap<>();
|
||||
final Plugin plugin = FantasyStickLifecycleNativeTest.port(Plugin.class, (name, args) -> switch (name) {
|
||||
case "isEnabled" -> true;
|
||||
case "getPluginMeta", "getDescription" -> new org.bukkit.plugin.PluginDescriptionFile("EventBridgeTest", "1", "test.Test");
|
||||
@@ -1869,6 +2284,7 @@ class EquipmentSessionEventsNativeTest {
|
||||
});
|
||||
var server = FantasyStickLifecycleNativeTest.port(Server.class, (name, args) -> {
|
||||
if (name.equals("getPluginManager")) return manager;
|
||||
if (name.equals("getRecipe")) return recipes.get(args[0]);
|
||||
throw new AssertionError(name);
|
||||
});
|
||||
eventPlayer = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(org.bukkit.entity.Player.class.getClassLoader(),
|
||||
@@ -1897,13 +2313,24 @@ class EquipmentSessionEventsNativeTest {
|
||||
}, failure -> {
|
||||
bridgeFailures.add(failure);
|
||||
if (throwingDiagnostics) throw new IllegalStateException("Broken diagnostics");
|
||||
});
|
||||
}, input -> { inputs.add(input); onInput.accept(input); });
|
||||
dispatcher.register(bridge, plugin);
|
||||
}
|
||||
org.bukkit.event.inventory.InventoryClickEvent click(int raw, org.bukkit.event.inventory.ClickType click,
|
||||
org.bukkit.event.inventory.InventoryAction action, int button) {
|
||||
return new org.bukkit.event.inventory.InventoryClickEvent(actor.view, actor.view.getSlotType(raw), raw, click, action, button);
|
||||
}
|
||||
void loadout(String... assigned) throws Exception {
|
||||
var original = actor.record;
|
||||
var slots = new java.util.ArrayList<>(java.util.Collections.nCopies(7, ""));
|
||||
for (int i = 0; i < assigned.length; i++) slots.set(i, assigned[i]);
|
||||
var learned = slots.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(java.util.UUID.randomUUID(), original.name(),
|
||||
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
|
||||
original.health(), learned, slots, java.util.Map.of(), false, null, null);
|
||||
core.characters.create(record).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
actor.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(record));
|
||||
}
|
||||
void pickupBox() {
|
||||
var event = click(9, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL, -1);
|
||||
|
||||
@@ -38,6 +38,191 @@ class EquipmentSessionsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void presentationUsesAuthoritativeRecordAndWithdrawsBeforeEvenRefusedGearRestoration() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var actor = f.actor();
|
||||
var renamed = f.characters.renameCharacter(actor.record.id(), "<rainbow>雪 🐉 character longer than sixteen").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var shown = new ArrayList<CharacterRecord>();
|
||||
var withdrawn = new ArrayList<UUID>();
|
||||
f.presentation = new games.dmg.fantasystick.presentation.SessionPresentation() {
|
||||
@Override public void activate(Player player, UUID session, CharacterRecord record) {
|
||||
assertSame(f.main, Thread.currentThread()); assertSame(actor.player, player);
|
||||
assertTrue(f.sessions.active(actor.id).isPresent()); shown.add(record);
|
||||
}
|
||||
@Override public void withdraw(UUID player, UUID session) { withdrawn.add(session); }
|
||||
};
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
f.activate(actor);
|
||||
var session = f.sessions.active(actor.id).orElseThrow().session();
|
||||
assertEquals(java.util.List.of(renamed), shown);
|
||||
actor.inventory.setItem(9, null); // Missing genuine box cannot retain a fantasy display.
|
||||
f.sessions.exit(actor.player);
|
||||
assertEquals(java.util.List.of(session), withdrawn);
|
||||
assertTrue(f.sessions.active(actor.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainControlMutationWithReentrantExitStillRestoresOwnedGameMode() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
a.mutation = () -> {
|
||||
if (writes.incrementAndGet() == 15) {
|
||||
a.mutation = () -> {};
|
||||
f.sessions.exit(a.player);
|
||||
throw new IllegalStateException("Uncertain control write after packing");
|
||||
}
|
||||
};
|
||||
f.sessions.enter(a.player); f.tick(); f.tick();
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNotNull(f.state(a).pending(), "Uncertain gear remains retained independently of safe mode cleanup");
|
||||
assertFailures(f, IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReceiptSaveNeverChangesModeOrPacksEquipment() throws Exception {
|
||||
try (var f = new Fixture((source, target) -> { throw new java.io.IOException("Injected receipt failure"); })) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
|
||||
a.beforeMode = () -> fail("Mode setter must not run before acknowledged receipt persistence");
|
||||
f.sessions.enter(a.player); f.tick();
|
||||
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
|
||||
assertFailures(f, java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transferredCharacterNeverCarriesPreviousHoldersGameModeAndAlreadyAdventureNeedsNoChange() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var first = f.actor(); first.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
f.activate(first); f.sessions.exit(first.player); assertNull(f.state(first).pending());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, first.gameMode);
|
||||
var second = f.actor(first.record); f.activate(second);
|
||||
assertEquals("SURVIVAL", f.state(second).pending().previousGameMode());
|
||||
f.sessions.exit(second.player); assertNull(f.state(second).pending());
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, second.gameMode);
|
||||
var third = f.actor(first.record); third.gameMode = org.bukkit.GameMode.ADVENTURE;
|
||||
third.beforeMode = () -> fail("Already-Adventure sessions must not claim an unnecessary mode change");
|
||||
f.activate(third); assertNull(f.state(third).pending().previousGameMode());
|
||||
f.sessions.exit(third.player); assertNull(f.state(third).pending());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, third.gameMode);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recoverySynchronizesRestoredEquipmentButRetainsMissingGameModeProof() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); var original = a.snapshot(); f.activate(a);
|
||||
var reloaded = a.reload();
|
||||
reloaded.pdc.remove(new org.bukkit.NamespacedKey("fantasystick", "adventure_session"));
|
||||
var syncs = new java.util.concurrent.atomic.AtomicInteger(); reloaded.sync = syncs::incrementAndGet;
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, reloaded.gameMode, "Missing proof cannot authorize a game-mode change");
|
||||
assertNotNull(f.state(reloaded).pending());
|
||||
assertEquals(1, syncs.get(), "Verified equipment restoration must still synchronize the client");
|
||||
assertNotNull(f.state(reloaded).lastExit());
|
||||
assertTrue(f.notices.stream().noneMatch(notice -> notice.message().contains("Equipment recovery refused: RESTORED")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartRecoveryRestoresSavedGameModeEvenWhenNewSessionPolicyIsDisabled() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE; var original = a.snapshot();
|
||||
f.activate(a);
|
||||
var reloaded = a.reload();
|
||||
// Fresh coordinator models loss of process-local claims at a restart; no orderly exit.
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
assertNull(f.state(reloaded).pending());
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reentrantExitDuringAdventureApplicationRestoresModeWithoutPackingOrStartingDeadline() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); var before = a.snapshot();
|
||||
a.beforeMode = () -> { a.beforeMode = () -> {}; f.sessions.exit(a.player); };
|
||||
f.sessions.enter(a.player); f.tick(); f.tick();
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, a.gameMode);
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertNull(f.state(a).pending()); assertNull(f.state(a).lastExit());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void orphanAdventureOwnershipCannotBeOverwrittenEvenWhenNewModePolicyIsDisabled() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor();
|
||||
new games.dmg.fantasystick.session.AdventureModeLease().enter(a.player, UUID.randomUUID(), "SURVIVAL");
|
||||
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
|
||||
f.sessions.enter(a.player); f.tick();
|
||||
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void adventurePolicyIsCapturedPerSessionAndSavedBeforeNativeModeChangeThenRestored() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var enabled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, enabled::get,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
a.beforeMode = () -> {
|
||||
try { assertEquals("CREATIVE", f.state(a).pending().previousGameMode()); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
};
|
||||
f.sessions.enter(a.player);
|
||||
enabled.set(false); // The entered request already captured its policy.
|
||||
f.tick(); f.tick();
|
||||
assertTrue(f.sessions.active(a.id).isPresent());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.gameMode);
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, f.sessions.exit(a.player));
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNull(f.state(a).pending());
|
||||
a.beforeMode = () -> { throw new AssertionError("Disabled sessions must not change mode"); };
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNull(f.state(a).pending().previousGameMode());
|
||||
f.sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePlayerMarkerBracketsPackingAndRestorationAndSurvivesSerialization() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var actor = f.actor();
|
||||
@@ -849,6 +1034,247 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reopenedCoordinatorCountsOfflineTimeAtExactExpiryAndNewExitsUseCurrentDuration() throws Exception {
|
||||
for (long configured : new long[]{1, 900, 3600}) try (var f = new Fixture(Duration.ofSeconds(900), new MutableClock())) {
|
||||
var clock = (MutableClock) f.clock;
|
||||
var actor = f.actor(); var other = f.actor(); f.activate(actor); f.sessions.exit(actor.player);
|
||||
var exited = f.state(actor); var deadline = exited.lastExit().deadline();
|
||||
clock.now = deadline.minusNanos(1);
|
||||
actor = f.reopen(actor, Duration.ofSeconds(configured));
|
||||
actor.inventory.setItemInOffHand(other.inventory.getItemInOffHand().clone());
|
||||
var before = actor.snapshot();
|
||||
f.sessions.arrive(actor.player); f.tick(); f.tick();
|
||||
assertEquals("COOLDOWN", f.notices.getLast().code());
|
||||
assertTrue(f.notices.getLast().message().startsWith("Wait 1 seconds"));
|
||||
assertEquals(exited, f.state(actor)); assertArrayEquals(before, actor.snapshot());
|
||||
clock.now = deadline; // Inclusive expiry, even with a longer/shorter newly configured duration.
|
||||
f.sessions.arrive(actor.player); f.tick(); f.tick(); f.tick();
|
||||
assertEquals(other.record.id(), f.sessions.active(actor.id).orElseThrow().character());
|
||||
assertEquals(exited.lastExit(), f.state(actor).lastExit());
|
||||
clock.advance(Duration.ofSeconds(5)); f.sessions.exit(actor.player);
|
||||
var next = f.state(actor).lastExit();
|
||||
assertNotEquals(exited.lastExit().sessionId(), next.sessionId());
|
||||
assertEquals(other.record.id(), next.characterId());
|
||||
assertEquals(clock.now, next.exitedAt()); assertEquals(clock.now.plusSeconds(configured), next.deadline());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameIdFailedEquipmentPreflightPreservesExistingDeadlineAndAllItems() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); f.activate(actor); f.sessions.exit(actor.player);
|
||||
var exited = f.state(actor); clock.advance(Duration.ofSeconds(60));
|
||||
if (full) for (int slot = 9; slot < 36; slot++) actor.inventory.setItem(slot, new ItemStack(Material.STONE, 64));
|
||||
else actor.inventory.setItem(0, new ItemStack(Material.SHULKER_BOX));
|
||||
var before = actor.snapshot(); var marker = actor.marker();
|
||||
f.sessions.enter(actor.player); f.tick();
|
||||
assertTrue(f.sessions.active(actor.id).isEmpty());
|
||||
assertFalse(f.notices.isEmpty()); assertNotEquals("COOLDOWN", f.notices.getLast().code());
|
||||
assertArrayEquals(before, actor.snapshot()); assertEquals(marker, actor.marker());
|
||||
assertEquals(exited, f.state(actor), "Failed packing preflight is not an actual exit or a timer reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroBypassesSavedDeadlineAfterRestartWithoutSkippingAuthoritativeEligibility() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); var other = f.actor(); f.activate(actor); f.sessions.exit(actor.player);
|
||||
var exited = f.state(actor); clock.advance(Duration.ofSeconds(100));
|
||||
actor = f.reopen(actor, Duration.ZERO);
|
||||
actor.inventory.setItemInOffHand(other.inventory.getItemInOffHand().clone());
|
||||
var dead = f.characters.recordDeath(other.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var before = actor.snapshot(); f.sessions.enter(actor.player); f.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", f.notices.getLast().code());
|
||||
assertArrayEquals(before, actor.snapshot()); assertEquals(exited, f.state(actor));
|
||||
var live = f.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
actor.inventory.setItemInOffHand(new CharacterSheets().create(live)); f.activate(actor);
|
||||
assertEquals(exited.lastExit(), f.state(actor).lastExit(), "Zero bypass does not rewrite the old absolute deadline");
|
||||
f.sessions.exit(actor.player);
|
||||
var next = f.state(actor).lastExit();
|
||||
assertEquals(clock.now, next.exitedAt()); assertEquals(clock.now, next.deadline());
|
||||
assertEquals(live.id(), next.characterId());
|
||||
actor = f.reopen(actor, Duration.ofSeconds(900));
|
||||
actor.inventory.setItemInOffHand(new CharacterSheets().create(actor.record)); f.activate(actor);
|
||||
assertEquals(next, f.state(actor).lastExit(), "Re-enabling delay cannot retroactively stretch a zero-duration exit");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameCharacterReentryDoesNotResetDelayButItsNextActualExitDoes() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); var other = f.actor(); f.activate(actor); f.sessions.exit(actor.player);
|
||||
var first = f.state(actor).lastExit(); clock.advance(Duration.ofSeconds(30));
|
||||
f.activate(actor); assertEquals(first, f.state(actor).lastExit());
|
||||
clock.advance(Duration.ofSeconds(20)); f.sessions.exit(actor.player);
|
||||
var second = f.state(actor).lastExit();
|
||||
assertNotEquals(first.sessionId(), second.sessionId()); assertEquals(first.characterId(), second.characterId());
|
||||
assertEquals(clock.now, second.exitedAt()); assertEquals(first.deadline().plusSeconds(50), second.deadline());
|
||||
clock.now = first.deadline(); actor.inventory.setItemInOffHand(other.inventory.getItemInOffHand());
|
||||
var before = actor.snapshot(); f.sessions.enter(actor.player); f.tick();
|
||||
assertEquals("COOLDOWN", f.notices.getLast().code());
|
||||
assertTrue(f.notices.getLast().message().startsWith("Wait 50 seconds")); assertArrayEquals(before, actor.snapshot());
|
||||
assertEquals(second, f.state(actor).lastExit());
|
||||
clock.now = second.deadline().plusNanos(1); f.activate(actor);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedRecoveryAfterRestartAndDurationChangeNeverExtendsTheActualExit() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); f.activate(actor); var control = actor.inventory.getItem(0).clone();
|
||||
actor.inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
assertEquals(EquipmentStorage.RestoreResult.OCCUPIED_SLOT, f.sessions.exit(actor.player));
|
||||
var exit = f.state(actor).lastExit();
|
||||
clock.advance(Duration.ofSeconds(200)); actor = f.reopen(actor, Duration.ofSeconds(3600));
|
||||
var before = actor.snapshot(); f.sessions.recover(actor.player); f.tick();
|
||||
assertArrayEquals(before, actor.snapshot()); assertEquals(exit, f.state(actor).lastExit());
|
||||
actor.inventory.setItem(0, control); f.sessions.recover(actor.player); f.tick();
|
||||
assertNull(f.state(actor).pending()); assertEquals(exit, f.state(actor).lastExit());
|
||||
var restored = actor.snapshot();
|
||||
for (int retry = 0; retry < 2; retry++) {
|
||||
clock.advance(Duration.ofSeconds(100)); f.sessions.recover(actor.player); f.tick();
|
||||
assertArrayEquals(restored, actor.snapshot()); assertEquals(exit, f.state(actor).lastExit());
|
||||
assertEquals("NO_RECEIPT", f.notices.getLast().code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualNativeExitStartsAtRequestNotDelayedReceiptWorkerExecution() throws Exception {
|
||||
for (boolean restored : new boolean[]{false, true}) {
|
||||
var clock = new MutableClock();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var block = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
try (var f = new Fixture((source, target) -> {
|
||||
if (block.compareAndSet(true, false)) { entered.countDown(); gate(release); }
|
||||
move(source, target);
|
||||
}, Duration.ofSeconds(900), EquipmentSessionsNativeTest::move, clock)) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
if (!restored) actor.inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
var before = actor.snapshot();
|
||||
block.set(true);
|
||||
var blocker = f.receipts.prepare(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
|
||||
java.time.Instant requested;
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
clock.advance(Duration.ofSeconds(2)); requested = clock.now;
|
||||
assertEquals(restored ? EquipmentStorage.RestoreResult.RESTORED : EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
f.sessions.exit(actor.player));
|
||||
assertTrue(f.sessions.active(actor.id).isEmpty());
|
||||
if (restored) assertEquals(Material.DIAMOND_SWORD, actor.inventory.getItem(0).getType());
|
||||
else assertArrayEquals(before, actor.snapshot());
|
||||
clock.advance(Duration.ofHours(1));
|
||||
} finally { release.countDown(); }
|
||||
blocker.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var state = f.state(actor);
|
||||
assertEquals(requested, state.lastExit().exitedAt());
|
||||
assertEquals(requested.plusSeconds(900), state.lastExit().deadline());
|
||||
assertEquals(restored, state.pending() == null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameStableIdReentersThroughLiveRenamedSerializedCopiedAndBackendRevivedReferences() throws Exception {
|
||||
for (int kind = 0; kind < 4; kind++) try (var f = new Fixture()) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
f.sessions.exit(actor.player); var exited = f.state(actor);
|
||||
var record = actor.record;
|
||||
if (kind == 1) record = f.characters.renameCharacter(record.id(), "Renamed adventurer").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
if (kind == 3) {
|
||||
var dead = f.characters.recordDeath(record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var before = actor.snapshot();
|
||||
f.sessions.enter(actor.player); f.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", f.notices.getLast().code());
|
||||
assertArrayEquals(before, actor.snapshot()); assertEquals(exited, f.state(actor));
|
||||
assertTrue(f.characters.restoreDeathRecord(dead.id(), UUID.randomUUID()).get(5, TimeUnit.SECONDS).isEmpty());
|
||||
record = f.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(actor.record.id(), record.id()); assertFalse(record.dead());
|
||||
// Real consume-once backend + native reference factory; not a claim of a Revive spell/UI.
|
||||
assertTrue(f.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, TimeUnit.SECONDS).isEmpty());
|
||||
}
|
||||
var sheet = new CharacterSheets().create(record);
|
||||
if (kind == 2) sheet.setAmount(3);
|
||||
actor.inventory.setItemInOffHand(sheet.clone());
|
||||
actor = actor.reload(); // Real native item codec + player PDC, not the original item object.
|
||||
f.activate(actor);
|
||||
assertEquals(exited.lastExit(), f.state(actor).lastExit(), "Reentry itself does not start a delay");
|
||||
assertEquals(record.id(), f.sessions.active(actor.id).orElseThrow().character());
|
||||
assertNotEquals(exited.lastExit().sessionId(), f.sessions.active(actor.id).orElseThrow().session());
|
||||
assertEquals(record, f.characters.find(record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
|
||||
"Session delay does not rewrite character progress or ability cooldowns");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingFakeAndDifferentIdSameNameCannotUseTheSameCharacterException() throws Exception {
|
||||
for (int kind = 0; kind < 3; kind++) try (var f = new Fixture()) {
|
||||
var actor = f.actor(); f.activate(actor); f.sessions.exit(actor.player);
|
||||
var exited = f.state(actor);
|
||||
if (kind == 0) java.nio.file.Files.delete(data.resolve("characters").resolve(actor.record.id() + ".yml"));
|
||||
if (kind == 1) {
|
||||
var fake = new ItemStack(Material.PAPER); fake.setItemMeta(actor.inventory.getItemInOffHand().getItemMeta());
|
||||
var meta = fake.getItemMeta();
|
||||
meta.getPersistentDataContainer().remove(new org.bukkit.NamespacedKey("fantasystick", "character_id"));
|
||||
fake.setItemMeta(meta); actor.inventory.setItemInOffHand(fake);
|
||||
}
|
||||
if (kind == 2) {
|
||||
var other = f.actor();
|
||||
var sameName = f.characters.renameCharacter(other.record.id(), actor.record.name()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
actor.inventory.setItemInOffHand(new CharacterSheets().create(sameName));
|
||||
}
|
||||
var before = actor.snapshot(); var marker = actor.marker();
|
||||
f.sessions.enter(actor.player); if (kind != 1) f.tick();
|
||||
assertEquals(kind == 0 ? "UNAVAILABLE_CHARACTER" : kind == 1 ? "INVALID_SHEET" : "COOLDOWN", f.notices.getLast().code());
|
||||
assertTrue(f.sessions.active(actor.id).isEmpty()); assertArrayEquals(before, actor.snapshot());
|
||||
assertEquals(marker, actor.marker()); assertEquals(exited, f.state(actor));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sheetTransferDoesNotInheritOrResetEitherPlayersExistingSwitchDeadline() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var first = f.actor(); var second = f.actor(); var freshHolder = f.actor(first.record);
|
||||
f.activate(first); f.activate(second);
|
||||
f.sessions.exit(first.player); f.sessions.exit(second.player);
|
||||
var firstExit = f.state(first); var secondExit = f.state(second);
|
||||
var renamed = f.characters.renameCharacter(first.record.id(), "Transferred identity").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var transferred = new CharacterSheets().create(renamed);
|
||||
first.inventory.setItemInOffHand(new CharacterSheets().create(second.record));
|
||||
second.inventory.setItemInOffHand(transferred.clone());
|
||||
for (var holder : java.util.List.of(first, second)) {
|
||||
var before = holder.snapshot(); f.sessions.enter(holder.player); f.tick();
|
||||
assertEquals("COOLDOWN", f.notices.getLast().code()); assertArrayEquals(before, holder.snapshot());
|
||||
}
|
||||
freshHolder.inventory.setItemInOffHand(transferred.clone()); f.activate(freshHolder);
|
||||
assertNull(f.state(freshHolder).lastExit(), "The new holder does not inherit the sender's delay");
|
||||
assertEquals(firstExit, f.state(first)); assertEquals(secondExit, f.state(second));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cooldownExplainsRemainingSecondsAndSameCharacterBeforeEquipmentPreflight() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
f.sessions.exit(actor.player);
|
||||
var exited = f.state(actor);
|
||||
var other = f.actor();
|
||||
actor.inventory.setItemInOffHand(other.inventory.getItemInOffHand().clone());
|
||||
// Both equipment checks would refuse too, but the switch explanation must win.
|
||||
for (int slot = 9; slot < 36; slot++) actor.inventory.setItem(slot, new ItemStack(Material.STONE, 64));
|
||||
actor.inventory.setItem(0, new ItemStack(Material.SHULKER_BOX));
|
||||
actor.cursor = new ItemStack(Material.EMERALD);
|
||||
var before = actor.snapshot(); var cursor = actor.cursor.clone(); var marker = actor.marker();
|
||||
for (int retry = 0; retry < 2; retry++) {
|
||||
f.sessions.enter(actor.player); f.tick();
|
||||
var notice = f.notices.getLast();
|
||||
assertEquals("COOLDOWN", notice.code());
|
||||
assertTrue(notice.message().matches("(?s).*Wait [1-9][0-9]* seconds.*"), notice::message);
|
||||
assertTrue(notice.message().contains("same character may be resumed now"), notice::message);
|
||||
assertArrayEquals(before, actor.snapshot()); assertEquals(cursor, actor.cursor);
|
||||
assertEquals(marker, actor.marker()); assertEquals(exited, f.state(actor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroHealthLiveRecordCannotEnterAndNoReceiptIsInvented() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var actor = f.actor(); var old = actor.record;
|
||||
@@ -1107,8 +1533,10 @@ class EquipmentSessionsNativeTest {
|
||||
final CopyOnWriteArrayList<EquipmentSessions.Failure> failures = new CopyOnWriteArrayList<>();
|
||||
final ArrayList<EquipmentSessions.Notice> notices = new ArrayList<>();
|
||||
final CharacterStorage characters;
|
||||
final EquipmentSessionStore receipts;
|
||||
EquipmentSessionStore receipts;
|
||||
final Clock clock;
|
||||
EquipmentSessions sessions;
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
volatile boolean rejectSchedule;
|
||||
boolean receiptClosed;
|
||||
boolean characterClosed;
|
||||
@@ -1119,22 +1547,29 @@ class EquipmentSessionsNativeTest {
|
||||
Fixture(DiskMove move) throws Exception { this(move, Duration.ofSeconds(900)); }
|
||||
Fixture(DiskMove move, Duration delay) throws Exception { this(move, delay, EquipmentSessionsNativeTest::move); }
|
||||
Fixture(DiskMove move, Duration delay, DiskMove characterMove) throws Exception {
|
||||
this(move, delay, characterMove, Clock.systemUTC());
|
||||
}
|
||||
Fixture(Duration delay, Clock clock) throws Exception {
|
||||
this(EquipmentSessionsNativeTest::move, delay, EquipmentSessionsNativeTest::move, clock);
|
||||
}
|
||||
Fixture(DiskMove move, Duration delay, DiskMove characterMove, Clock clock) throws Exception {
|
||||
this.clock = clock;
|
||||
var characterSeam = Class.forName("games.dmg.fantasystick.persistence.YamlCharacterRepository$Replacement");
|
||||
var characterConstructor = YamlCharacterRepository.class.getDeclaredConstructor(Path.class, characterSeam);
|
||||
characterConstructor.setAccessible(true);
|
||||
Object characterReplacement = Proxy.newProxyInstance(characterSeam.getClassLoader(), new Class<?>[]{characterSeam},
|
||||
(proxy, method, args) -> { characterMove.move((Path) args[0], (Path) args[1]); return null; });
|
||||
characters = new CharacterStorage(characterConstructor.newInstance(data, characterReplacement), Clock.systemUTC());
|
||||
characters = new CharacterStorage(characterConstructor.newInstance(data, characterReplacement), clock);
|
||||
var seam = Class.forName("games.dmg.fantasystick.persistence.EquipmentSessionStore$Replacement");
|
||||
var constructor = EquipmentSessionStore.class.getDeclaredConstructor(Path.class, Clock.class, seam);
|
||||
constructor.setAccessible(true);
|
||||
Object replacement = Proxy.newProxyInstance(seam.getClassLoader(), new Class<?>[]{seam},
|
||||
(proxy, method, args) -> { move.move((Path) args[0], (Path) args[1]); return null; });
|
||||
receipts = constructor.newInstance(data, Clock.systemUTC(), replacement);
|
||||
receipts = constructor.newInstance(data, clock, replacement);
|
||||
sessions = coordinator(delay);
|
||||
}
|
||||
EquipmentSessions coordinator(Duration delay) {
|
||||
return new EquipmentSessions(characters, receipts, delay, task -> {
|
||||
return new EquipmentSessions(characters, receipts, delay, clock, task -> {
|
||||
if (rejectSchedule) throw new java.util.concurrent.RejectedExecutionException("test scheduler stopped");
|
||||
tasks.add(task);
|
||||
}, notice -> {
|
||||
@@ -1143,7 +1578,7 @@ class EquipmentSessionsNativeTest {
|
||||
}, failure -> {
|
||||
failures.add(failure);
|
||||
if (throwDiagnostic) throw new IllegalStateException("Injected diagnostic observer failure");
|
||||
});
|
||||
}, presentation);
|
||||
}
|
||||
Actor restart(Actor actor) throws Exception {
|
||||
state(actor); // Drain the owned store, then discard process-local coordinator proof (no orderly close).
|
||||
@@ -1151,6 +1586,14 @@ class EquipmentSessionsNativeTest {
|
||||
sessions = coordinator(Duration.ofSeconds(900));
|
||||
return actor.reload();
|
||||
}
|
||||
Actor reopen(Actor actor, Duration delay) throws Exception {
|
||||
sessions.close(); sessions.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
state(actor); receipts.close();
|
||||
assertTrue(tasks.isEmpty());
|
||||
receipts = new EquipmentSessionStore(data, clock);
|
||||
sessions = coordinator(delay);
|
||||
return actor.reload();
|
||||
}
|
||||
Actor actor() throws Exception {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
characters.create(record).get(5, TimeUnit.SECONDS);
|
||||
@@ -1186,6 +1629,15 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
static final class MutableClock extends Clock {
|
||||
// Synthetic clock for exact boundaries, not a document/event timestamp.
|
||||
volatile java.time.Instant now = java.time.Instant.parse("2000-01-01T00:00:00.123456789Z");
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now; }
|
||||
void advance(Duration elapsed) { now = now.plus(elapsed); }
|
||||
}
|
||||
|
||||
@FunctionalInterface interface DiskMove { void move(Path source, Path target) throws java.io.IOException; }
|
||||
static void move(Path source, Path target) throws java.io.IOException {
|
||||
java.nio.file.Files.move(source, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
@@ -1230,7 +1682,15 @@ class EquipmentSessionsNativeTest {
|
||||
org.bukkit.Location teleported;
|
||||
boolean teleportAccepted = true;
|
||||
Runnable teleport = () -> {};
|
||||
org.bukkit.GameMode gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
Runnable beforeMode = () -> {};
|
||||
double health = 20;
|
||||
float attackStrength = 1;
|
||||
ItemStack activeItem = new ItemStack(Material.AIR);
|
||||
int useStarts;
|
||||
int useClears;
|
||||
Runnable startUse = () -> {};
|
||||
Runnable clearUse = () -> {};
|
||||
double normalMaximum = 28;
|
||||
int teleports;
|
||||
final org.bukkit.attribute.AttributeInstance maximum = FantasyStickLifecycleNativeTest.port(
|
||||
@@ -1257,7 +1717,18 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
case "getOpenInventory" -> view;
|
||||
case "getLocation" -> location.clone();
|
||||
case "getGameMode" -> gameMode;
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "getAttackCooldown" -> attackStrength;
|
||||
case "getActiveItem" -> activeItem.clone();
|
||||
case "hasActiveItem" -> !activeItem.isEmpty();
|
||||
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
case "startUsingItem" -> {
|
||||
assertEquals(org.bukkit.inventory.EquipmentSlot.HAND, args[0]);
|
||||
useStarts++; activeItem = inventory.getItemInMainHand().clone(); startUse.run(); yield null;
|
||||
}
|
||||
case "clearActiveItem" -> { useClears++; activeItem = new ItemStack(Material.AIR); clearUse.run(); yield null; }
|
||||
case "setHealth" -> { health = (Double) args[0]; yield null; }
|
||||
case "getAttribute" -> { assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]); yield maximum; }
|
||||
case "teleport" -> {
|
||||
@@ -1285,6 +1756,7 @@ class EquipmentSessionsNativeTest {
|
||||
Actor reload() throws Exception {
|
||||
var reloaded = new Actor(record, id);
|
||||
reloaded.pdc.readFromBytes(pdc.serializeToBytes(), true);
|
||||
reloaded.gameMode = gameMode;
|
||||
var ops = net.minecraft.resources.RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
for (int slot = 0; slot < inventory.getSize(); slot++) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
@@ -46,6 +47,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(fixture.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
assertTrue(fixture.plugin.getDescription().getDepend().contains("ProtocolLib"));
|
||||
int recipe = fixture.order.indexOf("recipe");
|
||||
assertTrue(fixture.order.indexOf("ManualCraftingController") >= 0);
|
||||
assertTrue(fixture.order.indexOf("ManualCraftingController") < recipe);
|
||||
@@ -53,12 +55,51 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("ManualCraftingGuard") < recipe);
|
||||
assertTrue(fixture.order.indexOf("EquipmentSessionEvents") >= 0);
|
||||
assertTrue(fixture.order.indexOf("EquipmentSessionEvents") < recipe);
|
||||
assertTrue(fixture.order.indexOf("ProtocolPresentation") >= 0);
|
||||
assertTrue(fixture.order.indexOf("ProtocolPresentation") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(2, fixture.timers.size(), "Presentation and bounded combat-provenance cleanup");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var original = a.core.snapshot();
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var saved = characters.renameCharacter(a.core.record.id(), "Current saved name").get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(saved.isPresent());
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(event);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
// Drain reads before checking the scheduled action; production never waits on the tick thread.
|
||||
characters.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
assertEquals(1, a.books.size(), "Registered book input must reach a real book consumer");
|
||||
var item = a.books.getFirst();
|
||||
assertEquals(org.bukkit.Material.WRITTEN_BOOK, item.getType());
|
||||
var nativeItem = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item);
|
||||
var content = nativeItem.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
|
||||
assertTrue(text.contains(expected), expected + " missing from " + text);
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
|
||||
assertEquals(saved, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesOnlyItsRecipeAndListenersAndIsSafeToRepeat() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
@@ -76,6 +117,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(org.bukkit.event.block.CrafterCraftEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.AsyncChatEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
verify(fixture.protocol).removePacketListener(fixture.packetListener);
|
||||
verify(fixture.timers.getFirst()).cancel();
|
||||
fixture.plugin.onDisable();
|
||||
assertSame(other, fixture.recipes.get(otherKey));
|
||||
}
|
||||
@@ -83,7 +128,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "presentation-timer")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -101,6 +146,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.AsyncChatEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
verify(fixture.protocol).removePacketListener(fixture.packetListener);
|
||||
fixture.timers.forEach(timer -> verify(timer).cancel());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +356,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
var directory = data.resolve("death-" + disable);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player);
|
||||
var viewer = f.viewer(a, directory); f.activate(a);
|
||||
assertNativeName(f, a, viewer, a.core.record.name());
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
|
||||
@@ -327,6 +378,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
try {
|
||||
f.dispatcher.accept(death);
|
||||
assertTrue(death.isCancelled()); assertFalse(death.getKeepInventory());
|
||||
assertRestored(f, a, viewer);
|
||||
assertEquals(a.core.normalMaximum, a.core.health);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
assertEquals(sheet, a.core.inventory.getItemInOffHand());
|
||||
@@ -376,8 +428,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
for (String route : List.of("removal", "swap", "quit", "disable")) {
|
||||
try (var f = new Fixture(data.resolve(route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player);
|
||||
var viewer = f.viewer(a, data.resolve(route)); f.activate(a);
|
||||
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
|
||||
var receipts = f.receipts();
|
||||
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
|
||||
var requested = java.time.Instant.now();
|
||||
if (route.equals("removal")) {
|
||||
a.core.cursor = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
@@ -388,6 +444,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
} else if (route.equals("quit")) {
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
|
||||
assertRestored(f, a, viewer);
|
||||
assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertEquals(org.bukkit.Material.DIAMOND_HELMET, a.core.inventory.getHelmet().getType());
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, route.equals("swap")
|
||||
@@ -399,7 +456,14 @@ class FantasyStickLifecycleNativeTest {
|
||||
while (!f.tasks.isEmpty()) f.tasks.remove().run();
|
||||
assertArrayEquals(restored, a.core.snapshot());
|
||||
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data.resolve(route), java.time.Clock.systemUTC())) {
|
||||
assertNull(reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
|
||||
var state = reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending());
|
||||
var exit = state.lastExit();
|
||||
assertEquals(session.sessionId(), exit.sessionId()); assertEquals(a.core.record.id(), exit.characterId());
|
||||
assertFalse(exit.exitedAt().isBefore(requested)); assertFalse(exit.exitedAt().isAfter(java.time.Instant.now()));
|
||||
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(state, reopened.completeExit(a.core.id, session.sessionId(), java.time.Duration.ofHours(1))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS), "Repeated lifecycle acknowledgment must not extend the delay");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -607,6 +671,110 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeModeCancellationRefusesEntryAndRetainsRestorationForExplicitRetry() throws Exception {
|
||||
for (String route : List.of("entry", "retry", "restart")) {
|
||||
boolean entering = route.equals("entry");
|
||||
Path directory = data.resolve("veto-" + route); java.nio.file.Files.createDirectories(directory);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
|
||||
var original = a.core.snapshot();
|
||||
var listener = new org.bukkit.event.Listener() {};
|
||||
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(
|
||||
listener, (ignored, raw) -> {
|
||||
var event = (org.bukkit.event.player.PlayerGameModeChangeEvent) raw;
|
||||
if (event.getNewGameMode() == (entering ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE)) event.setCancelled(true);
|
||||
}, org.bukkit.event.EventPriority.HIGHEST, f.plugin, false));
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
if (entering) {
|
||||
f.changed(a); f.tick(); f.tick(); f.tick();
|
||||
assertTrue(sessions.active(a.core.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNull(saved.pending()); assertNull(saved.lastExit());
|
||||
} else {
|
||||
f.activate(a); sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
|
||||
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().unregister(listener);
|
||||
if (route.equals("restart")) {
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
while (a.player.getGameMode() == org.bukkit.GameMode.ADVENTURE) f.tick();
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending(),
|
||||
"A serialized RESTORED marker cannot replace lost process-local gear proof");
|
||||
} else {
|
||||
sessions.recover(a.player); f.tick();
|
||||
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending());
|
||||
}
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedAdventureRestoresOnQuitDefeatDisableAndPreservesLaterAdminModeChanges() throws Exception {
|
||||
for (String route : List.of("quit", "defeat", "disable", "admin")) {
|
||||
Path directory = data.resolve(route); java.nio.file.Files.createDirectories(directory);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
switch (route) {
|
||||
case "quit" -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
|
||||
case "disable" -> f.plugin.setEnabled(false);
|
||||
case "admin" -> {
|
||||
a.player.setGameMode(org.bukkit.GameMode.SURVIVAL);
|
||||
a.player.setGameMode(org.bukkit.GameMode.ADVENTURE);
|
||||
sessions.exit(a.player);
|
||||
}
|
||||
case "defeat" -> {
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
}
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
assertEquals(route.equals("admin") ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedAdventureDefaultAndAdminChangesApplyOnlyToNewSessions() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
a.modeEvents = f.dispatcher::accept;
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
assertEquals("CREATIVE", f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
while (sender.messages.isEmpty()) f.tick();
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get();
|
||||
var b = new Actor(data); f.players.put(b.core.id, b.player);
|
||||
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
f.activate(b);
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
|
||||
assertNull(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
|
||||
sessions.exit(b.player);
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredSwitchDelaysReachRealPersistedExits() throws Exception {
|
||||
for (String value : List.of("default", "0", "37")) {
|
||||
var directory = data.resolve(value); java.nio.file.Files.createDirectories(directory);
|
||||
@@ -617,6 +785,23 @@ class FantasyStickLifecycleNativeTest {
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,6 +821,144 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void presentationAndRemainingResourcesCloseEvenWhenEquipmentCleanupThrows() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actual = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
var broken = mock(games.dmg.fantasystick.session.EquipmentSessions.class, org.mockito.AdditionalAnswers.delegatesTo(actual));
|
||||
doAnswer(call -> { actual.close(); throw new IllegalStateException("Equipment cleanup failed"); }).when(broken).close();
|
||||
setField(f.plugin.getClass(), f.plugin, "sessions", broken);
|
||||
assertDoesNotThrow(() -> f.plugin.setEnabled(false));
|
||||
verify(f.protocol).removePacketListener(f.packetListener); verify(f.timers.getFirst()).cancel();
|
||||
assertFalse(f.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.AsyncChatEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == f.plugin));
|
||||
assertNull(Fixture.field(f.plugin, "characters"));
|
||||
f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void assembledSessionUsesAuthoritativeUnicodeNameOnNativeOverheadTabAndChat() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); var viewer = new Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null);
|
||||
viewer.core.location.setWorld(a.core.location.getWorld());
|
||||
a.tracked.add(viewer.player);
|
||||
f.players.put(a.core.id, a.player); f.players.put(viewer.core.id, viewer.player);
|
||||
String saved = "勇者 🐉 <red>literal</red> longer than sixteen";
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
characters.renameCharacter(a.core.record.id(), saved).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
f.activate(a); // Offhand still has its old physical name; YAML is authoritative.
|
||||
assertNativeName(f, a, viewer, saved);
|
||||
assertChat(f, a, saved);
|
||||
assertEquals("Account", a.player.getName()); assertEquals(a.core.id, a.player.getUniqueId());
|
||||
assertEquals(64, a.distance.getValue()); assertTrue(a.board.getPlayerTeams().isEmpty());
|
||||
verify(a.player, never()).setDisplayName(anyString());
|
||||
verify(a.player, never()).setPlayerListName(anyString());
|
||||
String renamed = "雪 🐈 renamed by another holder's copy";
|
||||
f.renameCopy(a, renamed);
|
||||
assertNativeName(f, a, viewer, saved); // No native mutation on the persistence worker.
|
||||
f.tick();
|
||||
assertNativeName(f, a, viewer, renamed); assertChat(f, a, renamed);
|
||||
assertEquals(renamed, new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow().name());
|
||||
assertTrue(f.presentationFailures.isEmpty(), f.presentationFailures::toString);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anvilAcknowledgmentFromDisabledLifetimeCannotReachReenabledPresentationAndRestartReadsSavedName() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); f.players.put(a.core.id, a.player); var viewer = f.viewer(a, data); f.activate(a);
|
||||
var oldCharacters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var oldSessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(oldCharacters, "worker");
|
||||
var receiptWorker = (java.util.concurrent.ExecutorService) Fixture.field(f.receipts(), "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var gate = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { throw new AssertionError(interrupted); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.renameCopy(a, "Late old-lifetime 雪 rename");
|
||||
var retiredListener = f.packetListener;
|
||||
f.plugin.setEnabled(false); assertRestored(f, a, viewer);
|
||||
oldSessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(receiptWorker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.players.clear(); f.plugin.setEnabled(true);
|
||||
f.players.put(a.core.id, a.player); f.players.put(viewer.core.id, viewer.player); f.reenter(a);
|
||||
assertNativeName(f, a, viewer, a.core.record.name());
|
||||
int scheduled = f.schedulerCalls.get(); int packets = f.sent.size();
|
||||
release.countDown(); gate.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertEquals(scheduled, f.schedulerCalls.get(), "Old observer must capture its manager, not dereference the new plugin field");
|
||||
assertEquals(packets, f.sent.size()); assertTrue(f.tasks.isEmpty());
|
||||
assertChat(f, a, a.core.record.name());
|
||||
var ordinary = games.dmg.fantasystick.presentation.NativePresentationPackets.refreshTab(a.nativePlayer);
|
||||
var event = com.comphenix.protocol.events.PacketEvent.fromServer(this,
|
||||
new com.comphenix.protocol.events.PacketContainer(com.comphenix.protocol.PacketType.Play.Server.PLAYER_INFO, ordinary), viewer.player);
|
||||
retiredListener.onPacketSending(event); f.pulses.getFirst().run();
|
||||
assertSame(ordinary, event.getPacket().getHandle()); assertEquals(packets, f.sent.size());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.players.clear(); f.plugin.setEnabled(true);
|
||||
f.players.put(a.core.id, a.player); f.players.put(viewer.core.id, viewer.player); f.reenter(a);
|
||||
assertNativeName(f, a, viewer, "Late old-lifetime 雪 rename"); assertChat(f, a, "Late old-lifetime 雪 rename");
|
||||
assertTrue(f.presentationFailures.isEmpty(), f.presentationFailures::toString);
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNativeName(Fixture f, Actor a, Actor viewer, String name) {
|
||||
var tabs = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class);
|
||||
assertFalse(tabs.isEmpty(), () -> "Assembled session must send native tab output, not merely catch a CraftPlayer failure: "
|
||||
+ f.presentationFailures.stream().map(record -> record.getMessage() + ": " + record.getThrown()).toList()
|
||||
+ "; player notices=" + a.messages);
|
||||
var tab = tabs.getLast();
|
||||
assertEquals(java.util.EnumSet.of(net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME), tab.actions());
|
||||
assertEquals(a.core.id, tab.entries().getFirst().profileId());
|
||||
assertEquals(net.minecraft.network.chat.Component.literal(name), tab.entries().getFirst().displayName());
|
||||
var spawn = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundAddEntityPacket.class).getLast();
|
||||
assertEquals(net.minecraft.world.entity.EntityTypes.TEXT_DISPLAY, spawn.getType());
|
||||
var metadata = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.class).getLast();
|
||||
assertEquals(spawn.getId(), metadata.id());
|
||||
assertTrue(metadata.packedItems().stream().anyMatch(value -> net.minecraft.network.chat.Component.literal(name).equals(value.value())));
|
||||
assertEquals(0, f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.class)
|
||||
.getFirst().getValues().getFirst().base());
|
||||
assertTrue(f.packets(a.player, net.minecraft.network.protocol.game.ClientboundAddEntityPacket.class).isEmpty());
|
||||
}
|
||||
|
||||
private static void assertRestored(Fixture f, Actor actor, Actor viewer) {
|
||||
var spawn = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundAddEntityPacket.class).getLast();
|
||||
assertTrue(f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class).stream()
|
||||
.anyMatch(packet -> packet.getEntityIds().contains(spawn.getId())));
|
||||
var tab = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class).getLast();
|
||||
assertEquals(net.minecraft.network.chat.Component.literal("Ordinary tab"), tab.entries().getFirst().displayName());
|
||||
var attributes = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.class).getLast();
|
||||
assertEquals(64, attributes.getValues().getFirst().base());
|
||||
assertEquals(64, actor.distance.getValue()); assertChat(f, actor, null);
|
||||
assertTrue(f.presentationFailures.isEmpty(), f.presentationFailures::toString);
|
||||
}
|
||||
|
||||
private static void assertChat(Fixture f, Actor actor, String expected) {
|
||||
var body = net.kyori.adventure.text.Component.text("signed body <untouched>");
|
||||
var signed = net.kyori.adventure.chat.SignedMessage.system("signed body <untouched>", body);
|
||||
var viewers = new java.util.HashSet<net.kyori.adventure.audience.Audience>(); viewers.add(actor.player);
|
||||
io.papermc.paper.chat.ChatRenderer renderer = (sender, display, message, recipient) -> {
|
||||
assertSame(actor.player, sender); assertSame(body, message); assertSame(actor.player, recipient);
|
||||
return display.append(message);
|
||||
};
|
||||
var event = new io.papermc.paper.event.player.AsyncChatEvent(false, actor.player, viewers, renderer, body, body, signed);
|
||||
f.dispatcher.accept(event);
|
||||
assertSame(signed, event.signedMessage()); assertSame(body, event.message()); assertSame(body, event.originalMessage());
|
||||
assertEquals(viewers, event.viewers()); assertFalse(event.isCancelled());
|
||||
if (expected == null) assertSame(renderer, event.renderer());
|
||||
var ordinary = net.kyori.adventure.text.Component.text("Ordinary chat");
|
||||
assertEquals((expected == null ? ordinary : net.kyori.adventure.text.Component.text(expected)).append(body),
|
||||
event.renderer().render(actor.player, ordinary, body, actor.player));
|
||||
}
|
||||
|
||||
private static void awaitUnissuedRecordCleanup(Path directory) throws Exception {
|
||||
// The batch was ready before disable, so its cleanup was queued before shutdown.
|
||||
// Production disable remains nonblocking; only the test waits before deleting its fixture directory.
|
||||
@@ -655,6 +978,23 @@ class FantasyStickLifecycleNativeTest {
|
||||
final LinkedBlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
|
||||
final java.util.concurrent.atomic.AtomicInteger schedulerCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
final List<Listener> listeners = new ArrayList<>();
|
||||
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
final org.mockito.MockedStatic<com.comphenix.protocol.ProtocolLibrary> protocolScope;
|
||||
final org.mockito.MockedStatic<org.bukkit.Bukkit> platform;
|
||||
final org.mockito.MockedStatic<io.papermc.paper.configuration.GlobalConfiguration> configScope;
|
||||
record Sent(org.bukkit.entity.Player viewer, Object packet) { }
|
||||
final List<Sent> sent = new ArrayList<>();
|
||||
final List<java.util.logging.LogRecord> presentationFailures = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
final java.util.logging.Handler logHandler = new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
if (record.getMessage().contains("presentation")) presentationFailures.add(record);
|
||||
}
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
final List<BukkitTask> timers = new ArrayList<>();
|
||||
final List<Runnable> pulses = new ArrayList<>();
|
||||
com.comphenix.protocol.events.PacketListener packetListener;
|
||||
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
|
||||
final Thread main = Thread.currentThread();
|
||||
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
|
||||
@@ -668,6 +1008,35 @@ class FantasyStickLifecycleNativeTest {
|
||||
final JavaPlugin plugin;
|
||||
|
||||
Fixture(Path data) throws Exception {
|
||||
protocolScope = mockStatic(com.comphenix.protocol.ProtocolLibrary.class);
|
||||
protocolScope.when(com.comphenix.protocol.ProtocolLibrary::getProtocolManager).thenReturn(protocol);
|
||||
var nativeServer = mock(org.bukkit.craftbukkit.CraftServer.class);
|
||||
platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> nativeServer;
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
config.anticheat = config.new Anticheat();
|
||||
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
|
||||
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
|
||||
config.anticheat.obfuscation.items.binding = mock(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class);
|
||||
setField(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class, config.anticheat.obfuscation.items.binding, "level",
|
||||
io.papermc.paper.util.sanitizer.ItemObfuscationSession.ObfuscationLevel.OVERSIZED);
|
||||
configScope = mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class);
|
||||
configScope.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
|
||||
doAnswer(call -> {
|
||||
sent.add(new Sent(call.getArgument(0), ((com.comphenix.protocol.events.PacketContainer) call.getArgument(1)).getHandle()));
|
||||
return null;
|
||||
}).when(protocol).sendServerPacket(any(org.bukkit.entity.Player.class), any(com.comphenix.protocol.events.PacketContainer.class), eq(false));
|
||||
doAnswer(call -> {
|
||||
packetListener = call.getArgument(0); order.add("ProtocolPresentation");
|
||||
if ("ProtocolPresentation".equals(failRegistrationAt)) throw new IllegalStateException("Partial protocol registration");
|
||||
return null;
|
||||
}).when(protocol).addPacketListener(any());
|
||||
var manager = port(PluginManager.class, (name, args) -> switch (name) {
|
||||
case "registerEvents" -> {
|
||||
var listener = (Listener) args[0];
|
||||
@@ -682,6 +1051,11 @@ class FantasyStickLifecycleNativeTest {
|
||||
default -> throw new AssertionError("Unexpected manager call " + name);
|
||||
});
|
||||
var scheduler = port(BukkitScheduler.class, (name, args) -> switch (name) {
|
||||
case "runTaskTimer" -> {
|
||||
if ("presentation-timer".equals(failRegistrationAt)) throw new IllegalStateException("Timer rejected");
|
||||
pulses.add((Runnable) args[1]);
|
||||
var timer = mock(BukkitTask.class); timers.add(timer); yield timer;
|
||||
}
|
||||
case "runTask" -> {
|
||||
schedulerCalls.incrementAndGet();
|
||||
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
|
||||
@@ -715,6 +1089,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
dispatcher = new NativeEventDispatcher(server);
|
||||
loader = new Loader(server, data);
|
||||
plugin = (JavaPlugin) loader.loadClass("games.dmg.fantasystick.FantasyStickPlugin").getConstructor().newInstance();
|
||||
plugin.getLogger().addHandler(logHandler);
|
||||
var definition = plugin.getDescription().getCommands().get("fantasystick");
|
||||
if (definition != null) {
|
||||
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
|
||||
@@ -728,6 +1103,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
|
||||
return sent.stream().filter(s -> s.viewer() == viewer && type.isInstance(s.packet())).map(s -> type.cast(s.packet())).toList();
|
||||
}
|
||||
private NativeEventDispatcher dispatcher() { return dispatcher; }
|
||||
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
|
||||
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
|
||||
@@ -736,16 +1114,35 @@ class FantasyStickLifecycleNativeTest {
|
||||
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
|
||||
}
|
||||
void renameCopy(Actor actor, String name) throws Exception {
|
||||
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(actor.core.record);
|
||||
var output = CharacterAnvilNamesNativeTest.named(sheet, net.kyori.adventure.text.Component.text(name));
|
||||
anvil.grid.setItem(0, sheet); anvil.grid.setItem(2, output);
|
||||
dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output));
|
||||
assertEquals(sheet, anvil.grid.getItem(0)); assertEquals(output, anvil.grid.getItem(2));
|
||||
}
|
||||
Actor viewer(Actor target, Path directory) throws Exception {
|
||||
var viewer = new Actor(directory); viewer.core.inventory.setItemInOffHand(null);
|
||||
viewer.core.location.setWorld(target.core.location.getWorld()); target.tracked.add(viewer.player);
|
||||
players.put(viewer.core.id, viewer.player); return viewer;
|
||||
}
|
||||
void changed(Actor actor) {
|
||||
dispatcher.accept(new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player, 40,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.AIR), actor.core.inventory.getItemInOffHand()));
|
||||
}
|
||||
void reenter(Actor actor) throws Exception {
|
||||
// A restored-marker observation is deliberately not itself a fresh entry request.
|
||||
var sheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
actor.core.inventory.setItemInOffHand(null); changed(actor);
|
||||
actor.core.inventory.setItemInOffHand(sheet); activate(actor);
|
||||
}
|
||||
void activate(Actor actor) throws Exception {
|
||||
changed(actor); tick(); tick(); tick();
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
@@ -753,7 +1150,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
private static Object field(Object object, String name) {
|
||||
static Object field(Object object, String name) {
|
||||
try {
|
||||
var field = object.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(object);
|
||||
} catch (ReflectiveOperationException error) { throw new AssertionError(error); }
|
||||
@@ -766,30 +1163,81 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
@Override public void close() throws IOException {
|
||||
try { plugin.setEnabled(false); awaitWorkers(); }
|
||||
finally { HandlerList.unregisterAll(plugin); loader.close(); }
|
||||
finally {
|
||||
HandlerList.unregisterAll(plugin); plugin.getLogger().removeHandler(logHandler);
|
||||
loader.close(); configScope.close(); platform.close(); protocolScope.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Real native inventory/PDC from the coordinator fixture; message/particle/network ports only. */
|
||||
static final class Actor {
|
||||
final EquipmentSessionsNativeTest.Actor core;
|
||||
// Location holds a WeakReference: the fixture must model the server's world ownership.
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final org.bukkit.entity.Player player;
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance distance = new net.minecraft.world.entity.ai.attributes.AttributeInstance(
|
||||
net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE, ignored -> {});
|
||||
final java.util.Set<org.bukkit.entity.Player> tracked = new java.util.HashSet<>();
|
||||
final net.minecraft.world.scores.Scoreboard board = new net.minecraft.world.scores.Scoreboard();
|
||||
Actor(Path data) throws Exception {
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);
|
||||
core = new EquipmentSessionsNativeTest.Actor(record);
|
||||
player = (org.bukkit.entity.Player) Proxy.newProxyInstance(org.bukkit.entity.Player.class.getClassLoader(),
|
||||
new Class<?>[]{org.bukkit.entity.Player.class}, (proxy, method, args) -> {
|
||||
assertSame(core.main, Thread.currentThread(), "Worker accessed Player: " + method.getName());
|
||||
return switch (method.getName()) {
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
var craftBoard = mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class);
|
||||
when(craftBoard.getHandle()).thenReturn(board);
|
||||
player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, call -> {
|
||||
var method = call.getMethod(); var args = call.getArguments();
|
||||
assertSame(core.main, Thread.currentThread(), "Worker accessed Player: " + method.getName());
|
||||
return switch (method.getName()) {
|
||||
case "hashCode" -> System.identityHashCode(call.getMock());
|
||||
case "equals" -> call.getMock() == args[0];
|
||||
case "getHandle" -> nativePlayer;
|
||||
case "getEntityId" -> 42;
|
||||
case "getName" -> "Account";
|
||||
case "getWorld" -> core.location.getWorld();
|
||||
case "getHeight" -> 1.8;
|
||||
case "getTrackedBy" -> tracked;
|
||||
case "canSee" -> true;
|
||||
case "getGameMode" -> core.gameMode;
|
||||
case "setGameMode" -> {
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
|
||||
modeEvents.accept(event);
|
||||
if (!event.isCancelled()) { core.beforeMode.run(); core.gameMode = event.getNewGameMode(); }
|
||||
yield null;
|
||||
}
|
||||
case "isSneaking", "isInvisible" -> false;
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
core.location.setWorld(world);
|
||||
when(nativePlayer.getId()).thenReturn(42);
|
||||
when(nativePlayer.getUUID()).thenReturn(core.id);
|
||||
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
|
||||
distance.setBaseValue(64);
|
||||
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE)).thenReturn(distance);
|
||||
var level = mock(net.minecraft.server.level.ServerLevel.class);
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
var worldConfig = mock(io.papermc.paper.configuration.WorldConfiguration.class);
|
||||
worldConfig.entities = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
|
||||
worldConfig.entities.spawning = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
|
||||
worldConfig.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>();
|
||||
when(level.paperConfig()).thenReturn(worldConfig);
|
||||
setField(net.minecraft.world.level.Level.class, level, "spigotConfig", allocate(org.spigotmc.SpigotWorldConfig.class));
|
||||
var purpur = allocate(org.purpurmc.purpur.PurpurWorldConfig.class); purpur.drowningAirTicks = 300;
|
||||
setField(net.minecraft.world.level.Level.class, level, "purpurConfig", purpur);
|
||||
var ids = new java.util.concurrent.atomic.AtomicInteger(100);
|
||||
when(level.getNextEntityId()).thenAnswer(call -> ids.incrementAndGet());
|
||||
var grid = new ManualCraftPlanNativeTest.Fixture(2, 1).grid;
|
||||
core.view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
|
||||
@Override public org.bukkit.inventory.Inventory getTopInventory() { return grid; }
|
||||
@@ -844,6 +1292,15 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T allocate(Class<T> type) throws Exception {
|
||||
var unsafe = Class.forName("sun.misc.Unsafe");
|
||||
var field = unsafe.getDeclaredField("theUnsafe"); field.setAccessible(true);
|
||||
return type.cast(unsafe.getMethod("allocateInstance", Class.class).invoke(field.get(null), type));
|
||||
}
|
||||
private static void setField(Class<?> owner, Object target, String name, Object value) throws Exception {
|
||||
var field = owner.getDeclaredField(name); field.setAccessible(true); field.set(target, value);
|
||||
}
|
||||
|
||||
@FunctionalInterface interface Call { Object invoke(String name, Object[] args); }
|
||||
static <T> T port(Class<T> type, Call call) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (object, method, args) -> {
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.common.collect.ImmutableMultimap;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.authlib.properties.Property;
|
||||
import com.mojang.authlib.properties.PropertyMap;
|
||||
import games.dmg.fantasystick.presentation.NativePresentationPackets;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.RemoteChatSession;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Entry;
|
||||
import net.minecraft.world.entity.player.ProfilePublicKey;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NativePresentationPacketsTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private io.papermc.paper.configuration.GlobalConfiguration previousConfig;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void metadataSerializationConfig() throws Exception {
|
||||
// Pinned metadata encoding opens Paper's item-obfuscation scope even for TextDisplay.
|
||||
// Install only this test JVM's default binding, and restore the previous global afterward.
|
||||
previousConfig = io.papermc.paper.configuration.GlobalConfiguration.get();
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
config.anticheat = config.new Anticheat();
|
||||
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
|
||||
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
|
||||
var binding = allocate(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class);
|
||||
setField(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class, binding, "level",
|
||||
io.papermc.paper.util.sanitizer.ItemObfuscationSession.ObfuscationLevel.OVERSIZED);
|
||||
config.anticheat.obfuscation.items.binding = binding;
|
||||
setField(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", config);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.AfterEach
|
||||
void restoreSerializationConfig() throws Exception {
|
||||
setField(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", previousConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsOnlyActiveDisplayNameAndPreservesEveryNativeEntryFieldThroughTheWire() throws Exception {
|
||||
var active = entry("AccountOne", false, 217, GameType.SPECTATOR, false, 43);
|
||||
var ordinary = entry("AccountTwo", true, 7, GameType.CREATIVE, true, 81);
|
||||
var actions = EnumSet.allOf(Action.class);
|
||||
var original = new ClientboundPlayerInfoUpdatePacket(actions, List.of(active, ordinary));
|
||||
String name = "勇者 🧙🏽♀️ e\u0301 <red>literal</red> §a " + "長い名前".repeat(40);
|
||||
|
||||
var projected = NativePresentationPackets.projectPlayerInfo(original, active.profileId(), name);
|
||||
|
||||
assertEquals(Component.literal(name), projected.entries().getFirst().displayName(),
|
||||
"Character names are full literal Unicode components, not account/profile replacements");
|
||||
assertNotSame(original, projected);
|
||||
assertEquals(actions, projected.actions());
|
||||
assertEquals(EnumSet.allOf(Action.class), original.actions());
|
||||
assertSame(active, original.entries().getFirst());
|
||||
assertSame(ordinary, projected.entries().get(1));
|
||||
assertSame(active.profile(), projected.entries().getFirst().profile());
|
||||
assertSame(active.chatSession(), projected.entries().getFirst().chatSession());
|
||||
assertEquals(Component.literal("Ordinary AccountOne"), active.displayName());
|
||||
|
||||
var decoded = roundTrip(projected);
|
||||
assertEquals(actions, decoded.actions());
|
||||
assertEquals(2, decoded.entries().size());
|
||||
assertPreserved(active, decoded.entries().getFirst(), Component.literal(name));
|
||||
assertPreserved(ordinary, decoded.entries().get(1), ordinary.displayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributesSuppressOnlySelectedNativeSnapshotAndNeverMutateOriginalInstances() {
|
||||
var distance = attribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE, 57.25);
|
||||
var speed = attribute(net.minecraft.world.entity.ai.attributes.Attributes.MOVEMENT_SPEED, 0.37);
|
||||
var original = new net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket(81, List.of(speed, distance));
|
||||
var projected = NativePresentationPackets.projectAttributes(original, 81);
|
||||
var decoded = wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC, projected);
|
||||
assertNotSame(original, projected);
|
||||
assertEquals(81, decoded.getEntityId());
|
||||
assertEquals(2, decoded.getValues().size());
|
||||
assertSame(original.getValues().getFirst(), projected.getValues().getFirst());
|
||||
assertEquals(speed.getAttribute(), decoded.getValues().getFirst().attribute());
|
||||
assertEquals(speed.getBaseValue(), decoded.getValues().getFirst().base());
|
||||
assertEquals(speed.getModifiers(), java.util.Set.copyOf(decoded.getValues().getFirst().modifiers()));
|
||||
assertEquals(0, decoded.getValues().get(1).base());
|
||||
assertTrue(decoded.getValues().get(1).modifiers().isEmpty());
|
||||
assertEquals(57.25, original.getValues().get(1).base());
|
||||
assertEquals(3, original.getValues().get(1).modifiers().size());
|
||||
assertEquals(57.25, distance.getBaseValue());
|
||||
assertEquals(3, distance.getModifiers().size());
|
||||
assertSame(original, NativePresentationPackets.projectAttributes(original, 82));
|
||||
var absent = new net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket(81, List.of(speed));
|
||||
assertSame(absent, NativePresentationPackets.projectAttributes(absent, 81));
|
||||
var suppression = wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC,
|
||||
NativePresentationPackets.suppressNameTag(81));
|
||||
assertEquals(81, suppression.getEntityId());
|
||||
assertEquals(1, suppression.getValues().size());
|
||||
assertEquals(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE, suppression.getValues().getFirst().attribute());
|
||||
assertEquals(0, suppression.getValues().getFirst().base());
|
||||
assertTrue(suppression.getValues().getFirst().modifiers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorationSnapshotsCurrentAuthoritativeAttributeAndRefusesMissingNativeSupport() throws Exception {
|
||||
var player = player();
|
||||
var distance = player.distance;
|
||||
var modifiers = java.util.Set.copyOf(distance.getModifiers());
|
||||
var first = NativePresentationPackets.restoreNameTag(player);
|
||||
distance.setBaseValue(13.75);
|
||||
distance.removeModifiers();
|
||||
var decodedFirst = wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC, first);
|
||||
assertEquals(player.getId(), decodedFirst.getEntityId());
|
||||
assertEquals(1, decodedFirst.getValues().size());
|
||||
assertEquals(57.25, decodedFirst.getValues().getFirst().base());
|
||||
assertEquals(modifiers, java.util.Set.copyOf(decodedFirst.getValues().getFirst().modifiers()));
|
||||
var latest = wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC,
|
||||
NativePresentationPackets.restoreNameTag(player));
|
||||
assertEquals(13.75, latest.getValues().getFirst().base());
|
||||
assertTrue(latest.getValues().getFirst().modifiers().isEmpty());
|
||||
assertEquals(13.75, distance.getBaseValue());
|
||||
assertTrue(distance.getModifiers().isEmpty());
|
||||
player.distance = null;
|
||||
assertThrows(IllegalStateException.class, () -> NativePresentationPackets.restoreNameTag(player));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabRefreshUsesCurrentOrdinaryComponentIncludingNullAndOnlyDisplayAction() throws Exception {
|
||||
var player = player();
|
||||
var id = player.getUUID();
|
||||
player.ordinary = Component.literal("Ordinary 🐈").withStyle(net.minecraft.ChatFormatting.GOLD);
|
||||
var first = NativePresentationPackets.refreshTab(player);
|
||||
player.ordinary = Component.literal("New ordinary");
|
||||
var decoded = roundTrip(first);
|
||||
assertEquals(EnumSet.of(Action.UPDATE_DISPLAY_NAME), decoded.actions());
|
||||
assertEquals(1, decoded.entries().size());
|
||||
assertEquals(id, decoded.entries().getFirst().profileId());
|
||||
assertEquals(Component.literal("Ordinary 🐈").withStyle(net.minecraft.ChatFormatting.GOLD), decoded.entries().getFirst().displayName());
|
||||
assertEquals(player.ordinary, roundTrip(NativePresentationPackets.refreshTab(player)).entries().getFirst().displayName());
|
||||
player.ordinary = null;
|
||||
assertNull(roundTrip(NativePresentationPackets.refreshTab(player)).entries().getFirst().displayName());
|
||||
assertEquals(id, player.getUUID());
|
||||
assertEquals(57.25, player.distance.getBaseValue());
|
||||
assertEquals(3, player.distance.getModifiers().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabProjectionNeverBroadensActionsOrChangesUnrelatedIdentities() throws Exception {
|
||||
var active = entry("Account", true, 9, GameType.SURVIVAL, true, 8);
|
||||
for (var action : Action.values()) {
|
||||
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.of(action), List.of(active));
|
||||
var projected = NativePresentationPackets.projectPlayerInfo(original, active.profileId(), "角色");
|
||||
if (action == Action.UPDATE_DISPLAY_NAME) {
|
||||
var expected = EnumSet.of(action, Action.UPDATE_DISPLAY_NAME);
|
||||
assertEquals(expected, roundTrip(projected).actions());
|
||||
assertEquals(Component.literal("角色"), roundTrip(projected).entries().getFirst().displayName());
|
||||
} else {
|
||||
assertSame(original, projected, action.name());
|
||||
}
|
||||
assertEquals(EnumSet.of(action), original.actions());
|
||||
assertSame(original, NativePresentationPackets.projectPlayerInfo(original, UUID.randomUUID(), "Other"));
|
||||
}
|
||||
var empty = new ClientboundPlayerInfoUpdatePacket(EnumSet.noneOf(Action.class), List.of(active));
|
||||
assertSame(empty, NativePresentationPackets.projectPlayerInfo(empty, active.profileId(), "Ignored"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedAddOnlyRetainsOriginalActionsAndEveryEntryField() throws Exception {
|
||||
var active = entry("Account", false, 97, GameType.SPECTATOR, false, 13);
|
||||
var unrelated = entry("Unrelated", true, 42, GameType.CREATIVE, true, 9);
|
||||
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.ADD_PLAYER), List.of(active, unrelated));
|
||||
var projected = NativePresentationPackets.projectPlayerInfo(original, active.profileId(), "Character");
|
||||
assertEquals(EnumSet.of(Action.ADD_PLAYER), roundTrip(projected).actions(),
|
||||
"Adding UPDATE_DISPLAY_NAME would serialize the unrelated entry's currently irrelevant display component");
|
||||
assertSame(original, projected);
|
||||
assertEquals(List.of(active, unrelated), projected.entries());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detachedLabelSpawnsWithNativeIdsAndCompleteTextDisplaySchemaForEveryViewer() throws Exception {
|
||||
var player = player();
|
||||
var level = level();
|
||||
player.world = level;
|
||||
int before = level.getNextEntityId();
|
||||
level.chunks.rejectNext = true; // Exercise the real allocator's existing-ID collision check.
|
||||
String text = "勇者 🧙🏽♀️ e\u0301 <red>literal</red> §a " + "長い名前".repeat(80);
|
||||
var label = NativePresentationPackets.createLabel(player, text, 12.25, 89.125, -3.75);
|
||||
assertTrue(label.id() > before + 1, "Native allocator skips an occupied ID");
|
||||
assertEquals(3, level.chunks.checks);
|
||||
int after = level.getNextEntityId();
|
||||
assertTrue(after > label.id());
|
||||
var spawn = wire(net.minecraft.network.protocol.game.ClientboundAddEntityPacket.STREAM_CODEC, label.spawn());
|
||||
assertEquals(label.id(), spawn.getId());
|
||||
assertEquals(label.uuid(), spawn.getUUID());
|
||||
assertNotEquals(player.getUUID(), label.uuid());
|
||||
assertEquals(net.minecraft.world.entity.EntityTypes.TEXT_DISPLAY, spawn.getType());
|
||||
assertEquals(12.25, spawn.getX());
|
||||
assertEquals(89.125, spawn.getY());
|
||||
assertEquals(-3.75, spawn.getZ());
|
||||
assertEquals(net.minecraft.world.phys.Vec3.ZERO, spawn.getMovement());
|
||||
assertEquals(0, spawn.getData());
|
||||
assertEquals(0, spawn.getXRot());
|
||||
assertEquals(0, spawn.getYRot());
|
||||
assertEquals(0, spawn.getYHeadRot());
|
||||
|
||||
var metadata = wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.metadata());
|
||||
var nextViewer = wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.metadata());
|
||||
assertEquals(label.id(), metadata.id());
|
||||
assertEquals(metadata, nextViewer, "Metadata is a full repeatable snapshot, never packDirty");
|
||||
var receiver = new net.minecraft.world.entity.Display.TextDisplay(net.minecraft.world.entity.EntityTypes.TEXT_DISPLAY, level);
|
||||
assertEquals(receiver.getEntityData().packAll().size(), metadata.packedItems().size());
|
||||
receiver.getEntityData().assignValues(metadata.packedItems());
|
||||
assertEquals(Component.literal(text), receiver.getText());
|
||||
assertEquals(net.minecraft.world.entity.Display.BillboardConstraints.CENTER, receiver.getBillboardConstraints());
|
||||
assertEquals(Integer.MAX_VALUE, receiver.getLineWidth());
|
||||
assertEquals((byte) -1, receiver.getTextOpacity());
|
||||
assertEquals(net.minecraft.world.entity.Display.TextDisplay.FLAG_SHADOW
|
||||
| net.minecraft.world.entity.Display.TextDisplay.FLAG_USE_DEFAULT_BACKGROUND, receiver.getFlags());
|
||||
assertEquals(0, receiver.getFlags() & net.minecraft.world.entity.Display.TextDisplay.FLAG_SEE_THROUGH);
|
||||
assertTrue(receiver.isNoGravity());
|
||||
assertEquals(0, receiver.getShadowRadius());
|
||||
assertEquals(0, receiver.getWidth());
|
||||
assertEquals(0, receiver.getHeight());
|
||||
assertEquals(57.25, player.distance.getBaseValue());
|
||||
assertEquals(3, player.distance.getModifiers().size());
|
||||
|
||||
// White-box safety invariant only: the opaque session handle cannot expose/register this entity.
|
||||
var field = label.getClass().getDeclaredField("display");
|
||||
field.setAccessible(true);
|
||||
var detached = (net.minecraft.world.entity.Display.TextDisplay) field.get(label);
|
||||
assertFalse(detached.persist);
|
||||
assertFalse(detached.valid);
|
||||
assertFalse(detached.isRemoved());
|
||||
assertSame(level, detached.level());
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelMoveRenameAndRemoveSerializeWithoutChangingIdentityOrAuthoritativeState() throws Exception {
|
||||
var player = player();
|
||||
player.world = level();
|
||||
var label = NativePresentationPackets.createLabel(player, "Old", 1, 2, 3);
|
||||
var other = NativePresentationPackets.createLabel(player, "Other", 4, 5, 6);
|
||||
assertNotEquals(label.id(), other.id());
|
||||
assertNotEquals(label.uuid(), other.uuid());
|
||||
var id = label.id();
|
||||
var uuid = label.uuid();
|
||||
int allocated = player.world.chunks.checks;
|
||||
var originalMetadata = label.metadata();
|
||||
var move = wire(net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket.STREAM_CODEC,
|
||||
label.move(-17.625, 193.125, 4.5));
|
||||
assertEquals(id, move.id());
|
||||
assertEquals(new net.minecraft.world.phys.Vec3(-17.625, 193.125, 4.5), move.change().position());
|
||||
assertEquals(net.minecraft.world.phys.Vec3.ZERO, move.change().deltaMovement());
|
||||
assertEquals(0, move.change().xRot());
|
||||
assertEquals(0, move.change().yRot());
|
||||
assertTrue(move.relatives().isEmpty());
|
||||
assertFalse(move.onGround());
|
||||
var lateSpawn = wire(net.minecraft.network.protocol.game.ClientboundAddEntityPacket.STREAM_CODEC, label.spawn());
|
||||
assertEquals(-17.625, lateSpawn.getX());
|
||||
assertEquals(193.125, lateSpawn.getY());
|
||||
assertEquals(4.5, lateSpawn.getZ());
|
||||
String name = "改名 🌈 e\u0301 <bold>literal</bold>";
|
||||
var renamed = wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.rename(name));
|
||||
assertEquals(id, renamed.id());
|
||||
assertEquals(renamed, wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.metadata()));
|
||||
var changed = new java.util.ArrayList<net.minecraft.network.syncher.SynchedEntityData.DataValue<?>>();
|
||||
for (int index = 0; index < renamed.packedItems().size(); index++) {
|
||||
var value = renamed.packedItems().get(index);
|
||||
if (!value.equals(originalMetadata.packedItems().get(index))) { changed.add(value); }
|
||||
}
|
||||
assertEquals(1, changed.size(), "Rename changes only literal text, not any other native field");
|
||||
assertSame(net.minecraft.network.syncher.EntityDataSerializers.COMPONENT, changed.getFirst().serializer());
|
||||
assertEquals(Component.literal(name), changed.getFirst().value());
|
||||
assertEquals(Component.literal("Old"), originalMetadata.packedItems().stream()
|
||||
.filter(value -> value.id() == changed.getFirst().id()).findFirst().orElseThrow().value());
|
||||
var removal = wire(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC, label.remove());
|
||||
assertArrayEquals(new int[] {id}, removal.getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, label.remove().getEntityIds().toIntArray());
|
||||
assertEquals(id, label.id());
|
||||
assertEquals(uuid, label.uuid());
|
||||
assertEquals(allocated, player.world.chunks.checks, "Updates and removals never allocate replacement IDs");
|
||||
assertEquals(4, other.spawn().getX());
|
||||
assertEquals(5, other.spawn().getY());
|
||||
assertEquals(6, other.spawn().getZ());
|
||||
assertEquals(57.25, player.distance.getBaseValue());
|
||||
assertEquals(3, player.distance.getModifiers().size());
|
||||
var field = label.getClass().getDeclaredField("display");
|
||||
field.setAccessible(true);
|
||||
var detached = (net.minecraft.world.entity.Display.TextDisplay) field.get(label);
|
||||
assertFalse(detached.persist);
|
||||
assertFalse(detached.valid);
|
||||
assertFalse(detached.isRemoved(), "Client removal must not invoke native discard/remove");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidLabelInputsFailBeforeAllocationOrMutationAndMissingWorldIsUnsupported() throws Exception {
|
||||
var player = player();
|
||||
player.world = level();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativePresentationPackets.createLabel(player, "Name", Double.NaN, 1, 2));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativePresentationPackets.createLabel(player, "Name", 1, Double.POSITIVE_INFINITY, 2));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativePresentationPackets.createLabel(player, "Name", 1, 2, Double.NEGATIVE_INFINITY));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativePresentationPackets.createLabel(player, null, 1, 2, 3));
|
||||
assertEquals(0, player.world.chunks.checks);
|
||||
var label = NativePresentationPackets.createLabel(player, "Unchanged", 1, 2, 3);
|
||||
var metadata = label.metadata();
|
||||
assertThrows(IllegalArgumentException.class, () -> label.move(Double.NaN, 6, 7));
|
||||
assertThrows(IllegalArgumentException.class, () -> label.move(5, Double.NEGATIVE_INFINITY, 7));
|
||||
assertThrows(IllegalArgumentException.class, () -> label.move(5, 6, Double.POSITIVE_INFINITY));
|
||||
assertThrows(IllegalArgumentException.class, () -> label.rename(null));
|
||||
assertEquals(metadata, label.metadata());
|
||||
assertEquals(1, label.spawn().getX());
|
||||
assertEquals(2, label.spawn().getY());
|
||||
assertEquals(3, label.spawn().getZ());
|
||||
assertEquals(1, player.world.chunks.checks);
|
||||
player.world = null;
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> NativePresentationPackets.createLabel(player, "Name", 1, 2, 3));
|
||||
assertDoesNotThrow(() -> wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC,
|
||||
label.rename("")), "Empty literal text is valid; it is not an account-name restriction");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allPresentationOperationsPreserveNativeAccountUuidTeamAndInventoryAcrossWorldChange() throws Exception {
|
||||
var player = player();
|
||||
player.world = level();
|
||||
var oldLevel = player.world;
|
||||
var id = player.getUUID();
|
||||
var entityId = player.getId();
|
||||
var profile = player.getGameProfile();
|
||||
var team = player.board.addPlayerTeam("ordinary-team");
|
||||
team.setPlayerPrefix(Component.literal("[Ordinary]"));
|
||||
team.setNameTagVisibility(net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OTHER_TEAMS);
|
||||
player.board.addPlayerToTeam(profile.name(), team);
|
||||
var members = java.util.Set.copyOf(team.getPlayers());
|
||||
var prefix = team.getPlayerPrefix().copy();
|
||||
var stack = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND, 23);
|
||||
stack.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, Component.literal("Do not change 🐈"));
|
||||
player.getInventory().getNonEquipmentItems().set(17, stack);
|
||||
var savedStack = stack.copy();
|
||||
var modifiers = java.util.Set.copyOf(player.distance.getModifiers());
|
||||
player.ordinary = Component.literal("Ordinary Tab");
|
||||
var label = NativePresentationPackets.createLabel(player, "Character", 1, 2, 3);
|
||||
wire(net.minecraft.network.protocol.game.ClientboundAddEntityPacket.STREAM_CODEC, label.spawn());
|
||||
wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.metadata());
|
||||
wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC,
|
||||
NativePresentationPackets.suppressNameTag(entityId));
|
||||
var info = new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.ADD_PLAYER), List.of(
|
||||
new Entry(id, profile, true, 51, GameType.CREATIVE, player.ordinary, true, 11, null)));
|
||||
roundTrip(NativePresentationPackets.projectPlayerInfo(info, id, "Character"));
|
||||
player.world = level();
|
||||
int oldAllocations = oldLevel.chunks.checks;
|
||||
wire(net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket.STREAM_CODEC, label.move(4, 5, 6));
|
||||
wire(net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket.STREAM_CODEC, label.rename("New character"));
|
||||
wire(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC, label.remove());
|
||||
wire(net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.STREAM_CODEC,
|
||||
NativePresentationPackets.restoreNameTag(player));
|
||||
roundTrip(NativePresentationPackets.refreshTab(player));
|
||||
assertEquals(oldAllocations, oldLevel.chunks.checks);
|
||||
assertEquals(0, player.world.chunks.checks, "Updating an old handle never silently creates a new-world entity");
|
||||
assertEquals(id, player.getUUID());
|
||||
assertEquals(entityId, player.getId());
|
||||
assertSame(profile, player.getGameProfile());
|
||||
assertEquals("Account", player.getGameProfile().name());
|
||||
assertEquals("skin-payload", player.getGameProfile().properties().get("textures").iterator().next().value());
|
||||
assertSame(team, player.getTeam());
|
||||
assertEquals(members, java.util.Set.copyOf(team.getPlayers()));
|
||||
assertEquals(prefix, team.getPlayerPrefix());
|
||||
assertEquals(net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OTHER_TEAMS, team.getNameTagVisibility());
|
||||
assertEquals(1, player.board.getPlayerTeams().size());
|
||||
assertSame(stack, player.getInventory().getNonEquipmentItems().get(17));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(savedStack, stack));
|
||||
assertEquals(57.25, player.distance.getBaseValue());
|
||||
assertEquals(modifiers, player.distance.getModifiers());
|
||||
assertEquals(Component.literal("Ordinary Tab"), player.getTabListDisplayName());
|
||||
}
|
||||
|
||||
/** The real ServerLevel ID allocator runs against an explicit tracking-index read port. */
|
||||
private static final class ReadChunks extends net.minecraft.server.level.ServerChunkCache {
|
||||
private int checks;
|
||||
private boolean rejectNext;
|
||||
private ReadChunks() { super(null, null, null, null, null, null, 0, 0, false, null, null, null); }
|
||||
@Override public boolean hasEntityWithId(int id) {
|
||||
checks++;
|
||||
boolean reject = rejectNext;
|
||||
rejectNext = false;
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ReadLevel extends net.minecraft.server.level.ServerLevel {
|
||||
private io.papermc.paper.configuration.WorldConfiguration config;
|
||||
private ReadChunks chunks;
|
||||
private ReadLevel() { super(null, null, null, null, null, null, false, 0, null, false, null, null, null, null, null, null); }
|
||||
@Override public io.papermc.paper.configuration.WorldConfiguration paperConfig() { return config; }
|
||||
@Override public boolean addFreshEntity(net.minecraft.world.entity.Entity entity) { throw new AssertionError("Never insert a label"); }
|
||||
@Override public boolean addFreshEntity(net.minecraft.world.entity.Entity entity, org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason reason) {
|
||||
throw new AssertionError("Never insert a label");
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadLevel level() throws Exception {
|
||||
var level = allocate(ReadLevel.class);
|
||||
level.chunks = allocate(ReadChunks.class);
|
||||
setField(net.minecraft.server.level.ServerLevel.class, level, "chunkSource", level.chunks);
|
||||
setField(net.minecraft.world.level.Level.class, level, "spigotConfig", allocate(org.spigotmc.SpigotWorldConfig.class));
|
||||
var purpur = allocate(org.purpurmc.purpur.PurpurWorldConfig.class);
|
||||
purpur.drowningAirTicks = 300;
|
||||
setField(net.minecraft.world.level.Level.class, level, "purpurConfig", purpur);
|
||||
level.config = allocate(io.papermc.paper.configuration.WorldConfiguration.class);
|
||||
level.config.entities = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
|
||||
level.config.entities.spawning = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
|
||||
level.config.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>();
|
||||
return level;
|
||||
}
|
||||
|
||||
private static void setField(Class<?> owner, Object target, String name, Object value) throws Exception {
|
||||
var field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/** Constructor-free read boundary. Native attributes and packet codecs are real. */
|
||||
private static final class ReadPlayer extends net.minecraft.server.level.ServerPlayer {
|
||||
private net.minecraft.world.entity.ai.attributes.AttributeInstance distance;
|
||||
private Component ordinary;
|
||||
private net.minecraft.world.scores.Scoreboard board;
|
||||
private ReadLevel world;
|
||||
@Override public net.minecraft.server.level.ServerLevel level() { return world; }
|
||||
private ReadPlayer() { super(null, null, null, null); throw new AssertionError("Never construct a server player"); }
|
||||
@Override public Component getTabListDisplayName() { return ordinary; }
|
||||
@Override public net.minecraft.world.scores.PlayerTeam getTeam() { return board.getPlayersTeam(getScoreboardName()); }
|
||||
@Override public net.minecraft.world.entity.ai.attributes.AttributeInstance getAttribute(
|
||||
net.minecraft.core.Holder<net.minecraft.world.entity.ai.attributes.Attribute> type) {
|
||||
assertEquals(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE, type);
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadPlayer player() throws Exception {
|
||||
var player = allocate(ReadPlayer.class);
|
||||
player.setId(93);
|
||||
player.setUUID(UUID.randomUUID());
|
||||
player.gameProfile = new GameProfile(player.getUUID(), "Account", new PropertyMap(ImmutableMultimap.of("textures",
|
||||
new Property("textures", "skin-payload", "skin-signature"))));
|
||||
player.board = new net.minecraft.world.scores.Scoreboard();
|
||||
setField(net.minecraft.world.entity.player.Player.class, player, "inventory",
|
||||
new net.minecraft.world.entity.player.Inventory(player, new net.minecraft.world.entity.EntityEquipment()));
|
||||
player.distance = attribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE, 57.25);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static <T> T allocate(Class<T> type) throws Exception {
|
||||
var unsafe = Class.forName("sun.misc.Unsafe");
|
||||
var field = unsafe.getDeclaredField("theUnsafe");
|
||||
field.setAccessible(true);
|
||||
return type.cast(unsafe.getMethod("allocateInstance", Class.class).invoke(field.get(null), type));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.entity.ai.attributes.AttributeInstance attribute(
|
||||
net.minecraft.core.Holder<net.minecraft.world.entity.ai.attributes.Attribute> type, double base) {
|
||||
var instance = new net.minecraft.world.entity.ai.attributes.AttributeInstance(type, ignored -> { });
|
||||
instance.setBaseValue(base);
|
||||
for (var operation : net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.values()) {
|
||||
instance.addTransientModifier(new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", operation.name().toLowerCase(java.util.Locale.ROOT)),
|
||||
0.25, operation));
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static <T> T wire(net.minecraft.network.codec.StreamCodec<? super RegistryFriendlyByteBuf, T> codec, T packet) {
|
||||
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
codec.encode(buffer, packet);
|
||||
T decoded = codec.decode(buffer);
|
||||
assertFalse(buffer.isReadable(), "The actual pinned schema consumes every byte");
|
||||
return decoded;
|
||||
} finally { buffer.release(); }
|
||||
}
|
||||
|
||||
private static Entry entry(String account, boolean listed, int latency, GameType gameMode,
|
||||
boolean hat, int order) throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var profile = new GameProfile(id, account, new PropertyMap(ImmutableMultimap.of("textures",
|
||||
new Property("textures", "skin-payload-" + account, "skin-signature-" + account))));
|
||||
var keyGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyGenerator.initialize(2048);
|
||||
var key = new ProfilePublicKey.Data(Instant.ofEpochSecond(2_000_000_000L),
|
||||
keyGenerator.generateKeyPair().getPublic(), new byte[] {1, 3, 7, 9});
|
||||
return new Entry(id, profile, listed, latency, gameMode, Component.literal("Ordinary " + account),
|
||||
hat, order, new RemoteChatSession.Data(UUID.randomUUID(), key));
|
||||
}
|
||||
|
||||
private static void assertPreserved(Entry before, Entry after, Component expectedDisplay) {
|
||||
assertEquals(before.profileId(), after.profileId());
|
||||
assertEquals(before.profile().id(), after.profile().id());
|
||||
assertEquals(before.profile().name(), after.profile().name());
|
||||
assertEquals(before.profile().properties(), after.profile().properties());
|
||||
assertEquals(before.listed(), after.listed());
|
||||
assertEquals(before.latency(), after.latency());
|
||||
assertEquals(before.gameMode(), after.gameMode());
|
||||
assertEquals(expectedDisplay, after.displayName());
|
||||
assertEquals(before.showHat(), after.showHat());
|
||||
assertEquals(before.listOrder(), after.listOrder());
|
||||
assertEquals(before.chatSession().sessionId(), after.chatSession().sessionId());
|
||||
var beforeKey = before.chatSession().profilePublicKey();
|
||||
var afterKey = after.chatSession().profilePublicKey();
|
||||
assertEquals(beforeKey.expiresAt(), afterKey.expiresAt());
|
||||
assertEquals(beforeKey.key(), afterKey.key());
|
||||
assertArrayEquals(beforeKey.keySignature(), afterKey.keySignature());
|
||||
}
|
||||
|
||||
private static ClientboundPlayerInfoUpdatePacket roundTrip(ClientboundPlayerInfoUpdatePacket packet) {
|
||||
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
|
||||
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
|
||||
assertFalse(buffer.isReadable(), "The actual pinned packet schema consumes every byte");
|
||||
return decoded;
|
||||
} finally { buffer.release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Native book payloads feed real Paper custom-click events through packaged startup registration. */
|
||||
class PartyBookNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookCreatesPlayerOwnedPartyThroughNativeCustomClickWithoutInventoryChanges() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var before = a.core.snapshot();
|
||||
open(f, a);
|
||||
var create = button(a, "[Create party]");
|
||||
click(f, a, create);
|
||||
var party = PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow();
|
||||
assertEquals(a.core.id, party.leader());
|
||||
assertEquals(java.util.List.of(a.core.id), party.members());
|
||||
click(f, a, create); // The old page cannot perform the action twice.
|
||||
assertEquals(party, PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow());
|
||||
assertArrayEquals(before, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void memberBookLeavePreservesPartyButLeaderBookLeaveDisbandsWithoutSuccession() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
open(f, member); click(f, member, button(member, "[Leave party]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
open(f, leader); click(f, leader, button(leader, "[Leave party]"));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeBookInvitesRequireAcceptanceDisplayCharacterNamesAndAllowLeaderKick() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
open(f, leader); click(f, leader, button(leader, "[Create party]")); drain(f);
|
||||
click(f, leader, button(leader, "[Invite players]"));
|
||||
assertTrue(text(leader).contains(member.core.record.name()));
|
||||
click(f, leader, button(leader, "[Invite]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(member.messages.stream().anyMatch(message -> message.contains("invitation")));
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
assertTrue(text(member).contains(leader.core.record.name()));
|
||||
click(f, member, button(member, "[Accept]")); drain(f);
|
||||
assertEquals(parties.partyOf(leader.core.id), parties.partyOf(member.core.id));
|
||||
assertTrue(text(member).contains(leader.core.record.name()));
|
||||
assertTrue(text(member).contains(member.core.record.name()));
|
||||
open(f, leader); click(f, leader, button(leader, "[Kick]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(java.util.List.of(leader.core.id), parties.partyOf(leader.core.id).orElseThrow().members());
|
||||
drain(f); click(f, leader, button(leader, "[Disband party]"));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void copiedReplacedMalformedAndWrongNamespaceActionsCannotCreateParties() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
open(f, a); var old = button(a, "[Create party]");
|
||||
open(f, b); click(f, b, old);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(b.core.id).isEmpty());
|
||||
open(f, a); var current = button(a, "[Create party]");
|
||||
click(f, a, old);
|
||||
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
|
||||
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
|
||||
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(current.id(), java.util.Optional.of(payload)));
|
||||
}
|
||||
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("other", "party_book"), current.payload()));
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
|
||||
click(f, a, current);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isPresent(), "Invalid packets must not consume the legitimate view");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedSlotOrEndedCharacterCannotUseOldPartyControlsEvenAfterReturningToBookSlot() throws Exception {
|
||||
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
open(f, a); var action = button(a, "[Create party]");
|
||||
if (route == 0) a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
else if (route == 1) { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
else { f.plugin.setEnabled(false); f.awaitWorkers(); }
|
||||
click(f, a, action);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
click(f, a, action);
|
||||
assertTrue(parties.partyOf(a.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void managementRechecksPartyIdentityRatherThanDisbandingAReplacementFromAnOldPage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var previous = parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); var old = button(a, "[Disband party]");
|
||||
assertTrue(parties.leave(a.core.id, previous.id()));
|
||||
var replacement = parties.create(a.core.id).orElseThrow();
|
||||
click(f, a, old);
|
||||
assertEquals(replacement, parties.partyOf(a.core.id).orElseThrow());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Party action unavailable")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inviteChoicesArePagedAndMovingPagesRevokesTheOldPagesActions() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var candidates = new java.util.ArrayList<UUID>();
|
||||
for (int i = 0; i < 101; i++) {
|
||||
var id = UUID.randomUUID(); candidates.add(id);
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
|
||||
when(player.getName()).thenReturn("Candidate " + i); f.players.put(id, player);
|
||||
}
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); click(f, a, button(a, "[Invite players]"));
|
||||
var old = button(a, "[Invite]");
|
||||
assertTrue(((org.bukkit.inventory.meta.BookMeta) a.books.getLast().getItemMeta()).getPageCount() <= 100);
|
||||
click(f, a, button(a, "[Next]")); click(f, a, old);
|
||||
assertTrue(candidates.stream().allMatch(id -> parties.invitationsFor(id).isEmpty()));
|
||||
click(f, a, button(a, "[Invite]"));
|
||||
assertEquals(1, candidates.stream().filter(id -> !parties.invitationsFor(id).isEmpty()).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anInvitationAlreadyRenderedInTheBookCannotBeAcceptedAtItsRealTimeDeadline() throws Exception {
|
||||
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.EPOCH);
|
||||
var clock = new java.time.Clock() {
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now.get(); }
|
||||
};
|
||||
try (var clocks = mockStatic(java.time.Clock.class, CALLS_REAL_METHODS)) {
|
||||
clocks.when(java.time.Clock::systemUTC).thenReturn(clock);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var group = parties.create(leader.core.id).orElseThrow();
|
||||
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
var accept = button(member, "[Accept]");
|
||||
now.set(java.time.Instant.EPOCH.plusSeconds(60));
|
||||
click(f, member, accept);
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(parties.invitationsFor(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anOpenAcceptancePageCannotOverfillAPartyThatFilledAfterRendering() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var group = parties.create(leader.core.id).orElseThrow();
|
||||
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
var accept = button(member, "[Accept]");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
|
||||
f.players.put(id, player);
|
||||
assertTrue(parties.accept(id, parties.invite(leader.core.id, group.id(), id).orElseThrow().id()));
|
||||
}
|
||||
click(f, member, accept);
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(6, parties.partyOf(leader.core.id).orElseThrow().members().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sheetTransferAndCharacterSwitchKeepMembershipOnThePlayerAndInvalidateOldBookActions() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
var otherSheet = b.core.inventory.getItemInOffHand().clone();
|
||||
f.activate(a);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); var oldDisband = button(a, "[Disband party]");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
open(f, b);
|
||||
assertTrue(text(b).contains(a.core.record.name()));
|
||||
assertTrue(parties.partyOf(b.core.id).isEmpty(), "A traded sheet must not transfer party membership");
|
||||
click(f, b, oldDisband);
|
||||
a.core.inventory.setItemInOffHand(otherSheet); f.activate(a);
|
||||
open(f, a);
|
||||
assertTrue(text(a).contains(b.core.record.name()));
|
||||
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
|
||||
click(f, a, oldDisband);
|
||||
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
static String text(FantasyStickLifecycleNativeTest.Actor a) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream()
|
||||
.map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
static void open(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
f.dispatcher.accept(new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(),
|
||||
null, BlockFace.SELF, EquipmentSlot.HAND));
|
||||
drain(f);
|
||||
}
|
||||
static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable task;
|
||||
while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a, String label) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var click = find(page.raw(), label);
|
||||
if (click != null) return click;
|
||||
}
|
||||
throw new AssertionError("Missing native book action " + label);
|
||||
}
|
||||
static net.minecraft.network.chat.ClickEvent.Custom find(net.minecraft.network.chat.Component component, String label) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = find(child, label);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a,
|
||||
net.minecraft.network.chat.ClickEvent.Custom click) {
|
||||
var connection = mock(io.papermc.paper.connection.PlayerGameConnection.class);
|
||||
when(connection.getPlayer()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
|
||||
net.kyori.adventure.key.Key.key(click.id().toString()), connection, click.payload().orElse(null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Actual packaged listeners and book clicks; native damage events have explicit world/source ports. */
|
||||
class PartyCombatNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
private static final java.util.Map<org.bukkit.entity.Player, java.util.Map<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>> effects = new java.util.WeakHashMap<>();
|
||||
private static final java.util.Map<org.bukkit.entity.Player, Integer> fires = new java.util.WeakHashMap<>();
|
||||
|
||||
@Test void bookConsentProtectsCombatAndBookLeaveKickDisbandImmediatelyRemoveProtection() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
f.activate(a); f.activate(b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "Non-party fantasy combat remains available");
|
||||
createAndInvite(f, a, b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "An invitation is not membership");
|
||||
accept(f, b);
|
||||
assertTrue(hit(f, a.player, b.player).isCancelled(), "Book acceptance must protect native combat");
|
||||
assertTrue(hit(f, b.player, a.player).isCancelled());
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
invite(f, a); accept(f, b);
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Kick");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
invite(f, a); accept(f, b);
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void mixedModesAreProtectedBothWaysButOrdinaryCombatMobsSelfDamageAndPriorVetoesAreUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
f.activate(a);
|
||||
assertTrue(hit(f, a.player, b.player).isCancelled());
|
||||
assertTrue(hit(f, b.player, a.player).isCancelled());
|
||||
assertFalse(hit(f, entity(org.bukkit.entity.Zombie.class), a.player).isCancelled());
|
||||
assertFalse(hit(f, a.player, entity(org.bukkit.entity.Zombie.class)).isCancelled());
|
||||
assertFalse(hit(f, a.player, a.player).isCancelled());
|
||||
var environmental = new org.bukkit.event.entity.EntityDamageEvent(a.player, DamageCause.FALL, mock(DamageSource.class), 4);
|
||||
f.dispatcher.accept(environmental); assertFalse(environmental.isCancelled());
|
||||
f.activate(b);
|
||||
var damage = mock(DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(a.player);
|
||||
var veto = new EntityDamageByEntityEvent(a.player, b.player, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
veto.setCancelled(true); f.dispatcher.accept(veto);
|
||||
assertTrue(veto.isCancelled()); assertEquals(4, veto.getDamage());
|
||||
var group = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
|
||||
PartyLifecycleNativeTest.parties(f).invite(a.core.id, group.id(), b.core.id).orElseThrow().id()));
|
||||
exit(f, a); exit(f, b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "Retained membership is not ordinary PvP immunity");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void projectilesRecheckPartyAtImpactAndCannotBorrowANewSessionAfterExitOrSwitch() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Native indirect attribution must use current party");
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
exit(f, a);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Fantasy projectile loses authority on exit");
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Same character reentry is still a different session");
|
||||
assertFalse(hit(f, projectile(f, a.player), b.player).isCancelled());
|
||||
var stranger = actor(f);
|
||||
var ordinaryArrow = projectile(f, stranger.player);
|
||||
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled());
|
||||
f.activate(stranger);
|
||||
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled(), "Old ordinary projectile must not become fantasy damage");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explosiveFangsCloudPetAndFireworkSourcesRespectPartiesAndCapturedSessions() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
var tnt = entity(org.bukkit.entity.TNTPrimed.class); when(tnt.getSource()).thenReturn(a.player);
|
||||
var fangs = entity(org.bukkit.entity.EvokerFangs.class); when(fangs.getOwner()).thenReturn(a.player);
|
||||
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
|
||||
var pet = entity(org.bukkit.entity.Wolf.class); when(pet.getOwnerUniqueId()).thenReturn(a.core.id);
|
||||
var firework = entity(org.bukkit.entity.Firework.class); when(firework.getSpawningEntity()).thenReturn(a.core.id);
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, firework)) f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(source));
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) {
|
||||
assertTrue(hit(f, source, b.player).isCancelled(), source.getClass().toString());
|
||||
}
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertFalse(hit(f, source, b.player).isCancelled());
|
||||
exit(f, a);
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertTrue(hit(f, source, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lingeringCloudInheritsTheOriginalThrownPotionSessionRatherThanItsOwnersReplacement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
|
||||
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
|
||||
var splash = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
|
||||
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(cloud));
|
||||
assertTrue(hit(f, cloud, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void poisonAndWitherRevalidateEachTickAndClearOnRemovalReplacementWithoutTouchingHealing() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
for (var type : java.util.List.of(org.bukkit.potion.PotionEffectType.POISON, org.bukkit.potion.PotionEffectType.WITHER)) {
|
||||
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
|
||||
var cause = type.equals(org.bukkit.potion.PotionEffectType.POISON) ? DamageCause.POISON : DamageCause.WITHER;
|
||||
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, cause).isCancelled(), "Tick loses native attacker but not our application provenance");
|
||||
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 200, 0);
|
||||
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
|
||||
assertTrue(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), a.player).isCancelled());
|
||||
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
f.dispatcher.accept(remove); effects.get(b.player).remove(type);
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled(), "Removed attribution must not protect unrelated later damage");
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
|
||||
// An unrelated mob/environmental replacement clears only this effect's provenance.
|
||||
assertFalse(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), null).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled());
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ongoingDamageCannotMoveToANewSourceOrTargetSessionAndCancelledChangesDoNotEraseProvenance() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
apply(f, b, null, effect, a.player);
|
||||
var replacement = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
replacement.setCancelled(true); f.dispatcher.accept(replacement);
|
||||
var sheet = b.core.inventory.getItemInOffHand().clone(); exit(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "An old poison cannot attack a replacement target session");
|
||||
apply(f, b, effect, new org.bukkit.potion.PotionEffect(effect.getType(), 300, 1), a.player);
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
exit(f, a);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void burningChecksPartyEveryTickAndCancelledOrEnvironmentalIgnitionCannotStealAttribution() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
var fire = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 10.0f);
|
||||
f.dispatcher.accept(fire); assertFalse(fire.isCancelled()); fires.put(b.player, 200);
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
var refused = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 15.0f);
|
||||
f.dispatcher.accept(refused); assertTrue(refused.isCancelled());
|
||||
var cancelledEnvironmental = new org.bukkit.event.entity.EntityCombustEvent(b.player, 5.0f);
|
||||
cancelledEnvironmental.setCancelled(true); f.dispatcher.accept(cancelledEnvironmental);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustEvent(b.player, 15.0f));
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiredAndHiddenPotionLayersKeepOnlyTheirOwnSourceAndFireExpiryIsCleaned() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var type = org.bukkit.potion.PotionEffectType.POISON;
|
||||
var poison = new org.bukkit.potion.PotionEffect(type, 200, 0);
|
||||
apply(f, b, null, poison, a.player);
|
||||
// A shorter, stronger environmental layer temporarily covers the attributable one.
|
||||
apply(f, b, poison, new org.bukkit.potion.PotionEffect(type, 40, 1), null);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Environmental top layer must not inherit old source");
|
||||
var nativeEffect = effects.get(b.player).get(type);
|
||||
assertNotNull(nativeEffect.hiddenEffect);
|
||||
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Native hidden-layer restoration retains its own source");
|
||||
effects.get(b.player).clear();
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustByEntityEvent(a.player, b.player, 2.0f));
|
||||
fires.put(b.player, 40);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
fires.put(b.player, 0);
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
fires.put(b.player, 20); // Later unrelated fire must not borrow a stale player source.
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleNativeSplashIsCancelledBeforeOwnerOnlyEffectAttributionAndValidHealingIsUntouched() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
|
||||
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
|
||||
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
|
||||
assertEquals(1.0, splash.getIntensity(b.player));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
var stale = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
|
||||
f.dispatcher.accept(stale);
|
||||
assertTrue(stale.isCancelled(), "Native splash passes only owner to addEffect: stop stale continuation before attribution is lost");
|
||||
var arrow = projectile(f, a.player);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player);
|
||||
f.dispatcher.accept(impact); assertTrue(impact.isCancelled());
|
||||
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 100, 0);
|
||||
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledApplicationsAndLaunchesRetainUnrelatedDataAndCleanupStopsOnDisable() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = entity(org.bukkit.entity.Arrow.class); when(arrow.getShooter()).thenReturn(a.player);
|
||||
var launch = new org.bukkit.event.entity.ProjectileLaunchEvent(arrow); launch.setCancelled(true);
|
||||
var unrelated = new org.bukkit.NamespacedKey("other", "state");
|
||||
arrow.getPersistentDataContainer().set(unrelated, org.bukkit.persistence.PersistentDataType.STRING, "retained");
|
||||
f.dispatcher.accept(launch);
|
||||
assertEquals(java.util.Set.of(unrelated), arrow.getPersistentDataContainer().getKeys());
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
var veto = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, null, effect, a.player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false);
|
||||
veto.setCancelled(true); f.dispatcher.accept(veto);
|
||||
assertTrue(veto.isCancelled());
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
var before = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id()), "Party proximity does not grant progress or XP");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
for (var timer : f.timers) verify(timer, atLeastOnce()).cancel();
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aCapturedFantasySourceRequiresCurrentOffhandCustodyAndConnectionNotJustRegistryPresence() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
a.core.online = false;
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled());
|
||||
a.core.online = true;
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); // Before the slot-change/recovery callback.
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled());
|
||||
a.core.inventory.setItemInOffHand(sheet);
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeSerializationAndRestartKeepStaleDamageProvenanceButNeverRestorePartiesOrXp() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
var restoredArrow = entity(org.bukkit.entity.Arrow.class); when(restoredArrow.getShooter()).thenReturn(a.player);
|
||||
restoredArrow.getPersistentDataContainer().readFromBytes(arrow.getPersistentDataContainer().serializeToBytes(), true);
|
||||
var poison = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
apply(f, b, null, poison, a.player);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
b.core.pdc.readFromBytes(b.core.pdc.serializeToBytes(), true);
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
|
||||
assertTrue(hit(f, restoredArrow, b.player).isCancelled());
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
|
||||
// Malformed plugin-owned provenance cannot confer immunity or erase other namespaces.
|
||||
var key = b.core.pdc.getKeys().stream().filter(k -> k.getKey().startsWith("combat_dot_")).findFirst().orElseThrow();
|
||||
var other = new org.bukkit.NamespacedKey("other", "state");
|
||||
b.core.pdc.set(other, org.bukkit.persistence.PersistentDataType.STRING, "preserved");
|
||||
b.core.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
assertFalse(b.core.pdc.has(key));
|
||||
assertEquals("preserved", b.core.pdc.get(other, org.bukkit.persistence.PersistentDataType.STRING));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void weakerHiddenEffectsAndInfiniteDurationsMatchNativeReplacementSemantics() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var type = org.bukkit.potion.PotionEffectType.WITHER;
|
||||
var strong = new org.bukkit.potion.PotionEffect(type, 40, 2);
|
||||
apply(f, b, null, strong, null);
|
||||
var weaker = new org.bukkit.potion.PotionEffect(type, -1, 0);
|
||||
var hidden = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, strong, weaker, a.player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, false);
|
||||
f.dispatcher.accept(hidden); assertFalse(hidden.isCancelled());
|
||||
var nativeEffect = effects.get(b.player).get(type);
|
||||
nativeEffect.update(new net.minecraft.world.effect.MobEffectInstance(
|
||||
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), -1, 0));
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
|
||||
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
// An infinite active effect is not replaced by a shorter same-strength application.
|
||||
assertFalse(apply(f, b, weaker, new org.bukkit.potion.PotionEffect(type, 20, 0), null).isCancelled());
|
||||
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
static org.bukkit.event.entity.EntityPotionEffectEvent apply(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor target, org.bukkit.potion.PotionEffect old,
|
||||
org.bukkit.potion.PotionEffect effect, Entity source) {
|
||||
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, old, effect, source,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
old == null ? org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED : org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, true);
|
||||
f.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) {
|
||||
var nativeEffect = new net.minecraft.world.effect.MobEffectInstance(
|
||||
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(effect.getType()), effect.getDuration(), effect.getAmplifier());
|
||||
var current = effects.get(target.player).get(effect.getType());
|
||||
if (current == null) effects.get(target.player).put(effect.getType(), nativeEffect);
|
||||
else if (event.isOverride() || current.getAmplifier() > nativeEffect.getAmplifier()
|
||||
&& current.isShorterDurationThan(nativeEffect)) current.update(nativeEffect);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
static org.bukkit.event.entity.EntityDamageEvent tickDamage(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor target, DamageCause cause) {
|
||||
var event = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause, mock(DamageSource.class), 1);
|
||||
f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Player owner) {
|
||||
var arrow = entity(org.bukkit.entity.Arrow.class);
|
||||
when(arrow.getShooter()).thenReturn(owner);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(arrow));
|
||||
return arrow;
|
||||
}
|
||||
static <T extends Entity> T entity(Class<T> type) {
|
||||
var entity = mock(type);
|
||||
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return entity;
|
||||
}
|
||||
|
||||
static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
var active = new java.util.HashMap<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>();
|
||||
effects.put(a.player, active); fires.put(a.player, 0);
|
||||
doAnswer(call -> {
|
||||
var effect = active.get(call.getArgument(0));
|
||||
return effect == null ? null : new org.bukkit.potion.PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
|
||||
}).when(a.player).getPotionEffect(any());
|
||||
doAnswer(call -> fires.get(a.player)).when(a.player).getFireTicks();
|
||||
return a;
|
||||
}
|
||||
static void createAndInvite(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor a, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Create party"); invite(f, a);
|
||||
}
|
||||
static void invite(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Invite players"); action(f, a, "Invite");
|
||||
}
|
||||
static void accept(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Invitations"); action(f, b, "Accept");
|
||||
}
|
||||
static void action(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a, String label) throws Exception {
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[" + label + "]"));
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, Entity source, Entity target) {
|
||||
var damage = mock(DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(source);
|
||||
when(damage.getDirectEntity()).thenReturn(source);
|
||||
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
f.dispatcher.accept(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import java.nio.file.Path;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Packaged plugin startup and registered native lifecycle events; not proof of book/effect controls. */
|
||||
class PartyLifecycleNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredQuitHandlesLastMemberEvenWhileNativeOnlineFlagIsStillTrue() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
leader.core.online = false;
|
||||
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingAndReenablingDisbandsAndCannotReuseOldInvitationsOrServices() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var old = parties(f);
|
||||
var party = old.create(leader.core.id).orElseThrow();
|
||||
var invitation = old.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
assertTrue(old.partyOf(leader.core.id).isEmpty());
|
||||
assertFalse(old.accept(member.core.id, invitation.id()));
|
||||
assertTrue(old.create(leader.core.id).isEmpty());
|
||||
f.plugin.setEnabled(true);
|
||||
var fresh = parties(f);
|
||||
assertNotSame(old, fresh);
|
||||
assertTrue(fresh.partyOf(leader.core.id).isEmpty());
|
||||
assertFalse(fresh.accept(member.core.id, invitation.id()));
|
||||
assertNotEquals(party.id(), fresh.create(leader.core.id).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void consecutiveQuitEventsAreAuthoritativeEvenIfBothOnlineFlagsLag() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejoiningBeforeTheOtherMemberQuitsRetainsTheSamePartyAndLeader() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(leader.player, Component.empty()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
|
||||
assertEquals(leader.core.id, parties.partyOf(leader.core.id).orElseThrow().leader());
|
||||
}
|
||||
}
|
||||
|
||||
static PartyRoster parties(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (PartyRoster) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "parties");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,528 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Equipment-session composition, not a fantasy health/nutrition engine or live display test. */
|
||||
@SuppressWarnings("try")
|
||||
class WholeCharacterTransferNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void completeCharacterTransfersWhileGearVitalsLocationsDeadlinesAndOrdinaryIdentityStayWithHolders() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Original sheet name");
|
||||
var a = h.holder(record, "AccountA", Material.DIAMOND_SWORD, 13.5, 7, 1.5f, 2.75f);
|
||||
var b = h.holder(record, "AccountB", Material.IRON_AXE, 6.25, 18, 8.5f, 0.25f);
|
||||
a.actor.location = new Location(null, 11, 71, -8, 30, 10);
|
||||
b.actor.location = new Location(null, -90, 65, 123, -45, 20);
|
||||
var aEntry = a.actor.location.clone(); var bEntry = b.actor.location.clone();
|
||||
var aBefore = a.actor.snapshot(); var bBefore = b.actor.snapshot();
|
||||
var aOrdinary = a.ordinary(); var bOrdinary = b.ordinary();
|
||||
// Physical sheets are now stale. Activation must read the complete authoritative YAML record.
|
||||
var current = h.f.characters.renameCharacter(record.id(), "雪 <red>Literal transferred character 🐉")
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
h.activate(a);
|
||||
var aSession = h.f.sessions.active(a.actor.id).orElseThrow();
|
||||
assertEquals(current.name(), h.presentation.name(a.actor.id));
|
||||
assertEquals(current.revision(), h.output.shown.getLast().revision());
|
||||
assertEquals(current.id(), h.output.shown.getLast().character());
|
||||
assertEquals(aOrdinary, a.ordinary()); assertEquals(bOrdinary, b.ordinary());
|
||||
assertEquals(current, h.saved(record.id()));
|
||||
a.actor.location = new Location(null, 222, 80, -333);
|
||||
var aExitLocation = a.actor.location.clone();
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, h.f.sessions.exit(a.player));
|
||||
var aExit = h.f.state(a.actor);
|
||||
assertArrayEquals(aBefore, a.actor.snapshot());
|
||||
assertEquals(aExitLocation, a.actor.location, "Voluntary exit does not use the entry return location");
|
||||
assertEquals(0, a.actor.teleports);
|
||||
assertNull(h.presentation.name(a.actor.id));
|
||||
assertEquals(aSession.session(), h.output.cleared.getLast().session());
|
||||
|
||||
var transferred = a.actor.inventory.getItemInOffHand().clone();
|
||||
a.actor.inventory.setItemInOffHand(null);
|
||||
b.actor.inventory.setItemInOffHand(transferred);
|
||||
h.activate(b);
|
||||
var bSession = h.f.sessions.active(b.actor.id).orElseThrow();
|
||||
assertEquals(current.id(), bSession.character()); assertNotEquals(aSession.session(), bSession.session());
|
||||
assertEquals(current.name(), h.presentation.name(b.actor.id));
|
||||
assertNull(h.presentation.name(a.actor.id));
|
||||
assertNull(h.f.state(b.actor).lastExit(), "A's switch deadline is not a character property");
|
||||
assertEquals(aExit, h.f.state(a.actor));
|
||||
assertEquals(bEntry, b.actor.location); assertNotEquals(aEntry, b.actor.location);
|
||||
assertEquals(aOrdinary, a.ordinary()); assertEquals(bOrdinary, b.ordinary());
|
||||
assertEquals(current, h.saved(record.id()), "Transfer preserves stats, XP, points, health, learned/loadout and cooldowns");
|
||||
assertEquals(current, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(record.id()).orElseThrow());
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, h.f.sessions.exit(b.player));
|
||||
assertNull(h.f.state(b.actor).pending());
|
||||
assertArrayEquals(bBefore, b.actor.snapshot(), "B restores B's real native gear, never A's");
|
||||
assertEquals(Material.DIAMOND_SWORD, a.actor.inventory.getItem(0).getType());
|
||||
assertEquals(aOrdinary, a.ordinary()); assertEquals(bOrdinary, b.ordinary());
|
||||
assertEquals(bEntry, b.actor.location); assertEquals(0, b.actor.teleports);
|
||||
assertEquals(aExit, h.f.state(a.actor)); assertEquals(current, h.saved(record.id()));
|
||||
assertTrue(h.presentation.snapshot().isEmpty());
|
||||
assertEquals(List.of(a.actor.id, b.actor.id), h.output.cleared.stream().map(CharacterPresentation.View::holder).toList());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void pendingAndActiveCopiesAreBusyCounterfeitsAndDeadRecordsRefuseButConsumeOnceBackendRevivalEnters() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("One transferable life");
|
||||
var a = h.holder(record); var b = h.holder(record);
|
||||
var beforeB = b.actor.snapshot();
|
||||
h.f.sessions.enter(a.player); var loadA = h.f.next();
|
||||
h.f.sessions.enter(b.player); var loadB = h.f.next();
|
||||
loadA.run(); loadB.run(); // Both reads were pending before either player acquired the character.
|
||||
assertEquals("CHARACTER_BUSY", h.f.notices.getLast().code());
|
||||
assertArrayEquals(beforeB, b.actor.snapshot());
|
||||
assertTrue(h.f.sessions.active(b.actor.id).isEmpty());
|
||||
assertTrue(h.presentation.snapshot().isEmpty(), "No presentation before native activation");
|
||||
h.f.tick();
|
||||
assertTrue(h.f.sessions.active(a.actor.id).isPresent());
|
||||
h.f.sessions.enter(b.player); h.f.tick();
|
||||
assertEquals("CHARACTER_BUSY", h.f.notices.getLast().code());
|
||||
assertArrayEquals(beforeB, b.actor.snapshot()); assertNull(h.presentation.name(b.actor.id));
|
||||
assertEquals(record, h.saved(record.id()));
|
||||
assertTrue(h.f.receipts.find(b.actor.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
|
||||
h.f.sessions.exit(a.player); var exit = h.f.state(a.actor);
|
||||
|
||||
var fake = b.actor.inventory.getItemInOffHand().clone();
|
||||
var meta = fake.getItemMeta();
|
||||
meta.getPersistentDataContainer().remove(new org.bukkit.NamespacedKey("fantasystick", "character_id"));
|
||||
fake.setItemMeta(meta); b.actor.inventory.setItemInOffHand(fake);
|
||||
var counterfeitState = b.actor.snapshot();
|
||||
h.f.sessions.enter(b.player);
|
||||
assertEquals("INVALID_SHEET", h.f.notices.getLast().code());
|
||||
assertArrayEquals(counterfeitState, b.actor.snapshot()); assertNull(h.presentation.name(b.actor.id));
|
||||
assertEquals(record, h.saved(record.id())); assertTrue(h.f.tasks.isEmpty());
|
||||
|
||||
var dead = h.f.characters.recordDeath(record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var beforeA = a.actor.snapshot();
|
||||
h.f.sessions.enter(a.player); h.f.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", h.f.notices.getLast().code());
|
||||
assertArrayEquals(beforeA, a.actor.snapshot()); assertEquals(exit, h.f.state(a.actor));
|
||||
assertTrue(h.f.sessions.active(a.actor.id).isEmpty()); assertNull(h.presentation.name(a.actor.id));
|
||||
assertEquals(dead, h.saved(record.id()));
|
||||
assertTrue(h.f.characters.restoreDeathRecord(dead.id(), UUID.randomUUID()).get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertEquals(dead, h.saved(record.id()));
|
||||
var revived = h.f.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertProgress(record, revived); assertFalse(revived.dead());
|
||||
assertEquals(0, record.maximumHealth().compareTo(revived.health()));
|
||||
assertTrue(h.f.characters.restoreDeathRecord(dead.id(), dead.deathId()).get(5, TimeUnit.SECONDS).isEmpty());
|
||||
a.actor.inventory.setItemInOffHand(new CharacterSheets().create(revived));
|
||||
h.activate(a); // Real session, despite the still-running player delay: exact stable ID exception.
|
||||
assertEquals(revived.id(), h.f.sessions.active(a.actor.id).orElseThrow().character());
|
||||
assertEquals(revived.revision(), h.presentation.snapshot().get(a.actor.id).revision());
|
||||
assertEquals(exit.lastExit(), h.f.state(a.actor).lastExit());
|
||||
b.actor.inventory.setItemInOffHand(beforeB[40]); // Old copied native reference still cannot create a second life.
|
||||
h.f.sessions.enter(b.player); h.f.tick();
|
||||
assertEquals("CHARACTER_BUSY", h.f.notices.getLast().code());
|
||||
assertTrue(h.f.sessions.active(b.actor.id).isEmpty()); assertNull(h.presentation.name(b.actor.id));
|
||||
assertEquals(revived, h.saved(record.id()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void directSheetReplacementExitsBeforeDelayCheckSameIdResumesAndDifferentIdEntersOnlyAtExactDeadline() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var first = h.saveCharacter("Identical name"); var second = h.saveCharacter("Identical name");
|
||||
var holder = h.holder(first); var ordinaryGear = holder.actor.inventory.getItem(0).clone();
|
||||
h.activate(holder);
|
||||
var firstSession = h.f.sessions.active(holder.actor.id).orElseThrow().session();
|
||||
holder.actor.inventory.setItemInOffHand(new CharacterSheets().create(second));
|
||||
h.changed(holder); // Actual event bridge must perform orderly exit before deferred entry.
|
||||
assertTrue(h.f.sessions.active(holder.actor.id).isEmpty()); assertNull(h.presentation.name(holder.actor.id));
|
||||
assertEquals(ordinaryGear, holder.actor.inventory.getItem(0));
|
||||
var exit = h.f.state(holder.actor);
|
||||
assertEquals(first.id(), exit.lastExit().characterId());
|
||||
assertEquals(firstSession, exit.lastExit().sessionId());
|
||||
h.eventTasks.remove().run(); h.f.tick();
|
||||
assertEquals("COOLDOWN", h.f.notices.getLast().code());
|
||||
assertEquals(exit, h.f.state(holder.actor)); assertEquals(1, h.output.shown.size());
|
||||
|
||||
h.clock.advance(Duration.ofSeconds(20));
|
||||
var renamed = h.f.characters.renameCharacter(first.id(), "Same ID, different name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
holder.actor.inventory.setItemInOffHand(new CharacterSheets().create(renamed).clone());
|
||||
h.changed(holder); h.eventTasks.remove().run(); h.f.tick(); h.f.tick();
|
||||
assertEquals(first.id(), h.f.sessions.active(holder.actor.id).orElseThrow().character());
|
||||
assertEquals(renamed.name(), h.presentation.name(holder.actor.id));
|
||||
assertEquals(exit.lastExit(), h.f.state(holder.actor).lastExit(), "Resume does not extend the deadline");
|
||||
h.clock.advance(Duration.ofSeconds(10));
|
||||
h.f.sessions.exit(holder.player); var nextExit = h.f.state(holder.actor);
|
||||
assertEquals(exit.lastExit().deadline().plusSeconds(30), nextExit.lastExit().deadline());
|
||||
holder.actor.inventory.setItemInOffHand(new CharacterSheets().create(second));
|
||||
var before = holder.actor.snapshot();
|
||||
h.clock.now = nextExit.lastExit().deadline().minusNanos(1);
|
||||
h.f.sessions.enter(holder.player); h.f.tick();
|
||||
assertEquals("COOLDOWN", h.f.notices.getLast().code());
|
||||
assertArrayEquals(before, holder.actor.snapshot()); assertEquals(nextExit, h.f.state(holder.actor));
|
||||
assertNull(h.presentation.name(holder.actor.id));
|
||||
h.clock.now = nextExit.lastExit().deadline(); h.activate(holder);
|
||||
assertEquals(second.id(), h.f.sessions.active(holder.actor.id).orElseThrow().character());
|
||||
assertEquals(second.id(), h.output.shown.getLast().character());
|
||||
assertEquals(second.name(), h.presentation.name(holder.actor.id));
|
||||
assertEquals(nextExit.lastExit(), h.f.state(holder.actor).lastExit());
|
||||
assertEquals(renamed, h.saved(first.id())); assertEquals(second, h.saved(second.id()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedNamesUseImmutableViewsAndLateCallbacksCannotRenameOrWithdrawTransferredReplacementSessions() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Before accepted rename");
|
||||
var a = h.holder(record); var b = h.holder(record); h.activate(a);
|
||||
var oldSnapshot = h.presentation.snapshot();
|
||||
var firstView = oldSnapshot.get(a.actor.id);
|
||||
assertThrows(UnsupportedOperationException.class, oldSnapshot::clear);
|
||||
var renamed = h.f.characters.renameCharacter(record.id(), "Acknowledged 雪 🐉").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
h.presentation.acknowledged(renamed);
|
||||
assertEquals(record.name(), h.presentation.name(a.actor.id), "Acknowledgment queues native output, never writes it inline");
|
||||
h.displayTasks.remove().run();
|
||||
assertEquals(renamed.name(), h.presentation.name(a.actor.id));
|
||||
assertEquals(renamed.revision(), h.output.renamed.getLast().revision());
|
||||
assertEquals(record.name(), oldSnapshot.get(a.actor.id).name());
|
||||
assertEquals(firstView.session(), h.output.renamed.getLast().session());
|
||||
var latest = h.f.characters.renameCharacter(record.id(), "Transferred authoritative name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
h.presentation.acknowledged(latest); var stale = h.displayTasks.remove();
|
||||
h.f.sessions.exit(a.player); h.f.state(a.actor);
|
||||
var sheet = a.actor.inventory.getItemInOffHand().clone(); a.actor.inventory.setItemInOffHand(null);
|
||||
b.actor.inventory.setItemInOffHand(sheet); h.activate(b);
|
||||
var bView = h.presentation.snapshot().get(b.actor.id);
|
||||
assertEquals(latest.name(), bView.name());
|
||||
stale.run(); stale.run(); h.presentation.withdraw(a.actor.id, firstView.session());
|
||||
assertEquals(Map.of(b.actor.id, bView), h.presentation.snapshot());
|
||||
assertEquals(1, h.output.renamed.size(), "A's delayed acknowledgment does not become B's callback");
|
||||
h.f.sessions.exit(b.player); h.f.state(b.actor); h.activate(b);
|
||||
var replacement = h.presentation.snapshot().get(b.actor.id);
|
||||
assertNotEquals(bView.session(), replacement.session());
|
||||
h.presentation.withdraw(b.actor.id, bView.session()); stale.run();
|
||||
assertEquals(Map.of(b.actor.id, replacement), h.presentation.snapshot());
|
||||
assertEquals(latest, h.saved(record.id()));
|
||||
}
|
||||
}
|
||||
|
||||
private enum Ending { EXIT, QUIT, DISABLE, DEFEAT }
|
||||
private enum Restoration { SUCCESS, MISSING_BOX, WRITE_FAILURE }
|
||||
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> lifecycleCases() {
|
||||
return java.util.Arrays.stream(Ending.values()).flatMap(ending -> java.util.Arrays.stream(Restoration.values())
|
||||
.map(restoration -> org.junit.jupiter.params.provider.Arguments.of(ending, restoration)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest(name = "{0} / {1}")
|
||||
@org.junit.jupiter.params.provider.MethodSource("lifecycleCases")
|
||||
void lifecycleWithdrawsBeforeRestorationEvenOnRefusalOrExceptionAndLateNamesCannotResurrectDisplay(
|
||||
Ending ending, Restoration restoration) throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Lifecycle character"); var holder = h.holder(record);
|
||||
var original = holder.actor.snapshot(); var ordinary = holder.ordinary();
|
||||
h.activate(holder);
|
||||
var view = h.presentation.snapshot().get(holder.actor.id);
|
||||
var renamed = h.f.characters.renameCharacter(record.id(), "Late lifecycle name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
h.presentation.acknowledged(renamed); var lateName = h.displayTasks.remove();
|
||||
if (restoration == Restoration.MISSING_BOX) holder.actor.inventory.setItem(9, null);
|
||||
if (restoration == Restoration.WRITE_FAILURE) {
|
||||
h.f.expectedFailure = IllegalStateException.class;
|
||||
holder.actor.beforeMutation = () -> {
|
||||
holder.actor.beforeMutation = () -> {};
|
||||
throw new IllegalStateException("Interrupted equipment restoration before first slot write");
|
||||
};
|
||||
}
|
||||
var beforeExit = holder.actor.snapshot();
|
||||
h.output.onClear = cleared -> {
|
||||
assertEquals(view.session(), cleared.session());
|
||||
assertNull(h.presentation.name(holder.actor.id), "Invalidate the view before native output cleanup");
|
||||
assertArrayEquals(beforeExit, holder.actor.snapshot(), "Display withdrawal precedes equipment writes");
|
||||
};
|
||||
org.bukkit.event.entity.PlayerDeathEvent death = null;
|
||||
switch (ending) {
|
||||
case EXIT -> h.f.sessions.exit(holder.player);
|
||||
case QUIT -> {
|
||||
h.events.quit(new org.bukkit.event.player.PlayerQuitEvent(holder.player, Component.empty()));
|
||||
holder.actor.online = false;
|
||||
}
|
||||
case DISABLE -> { h.events.close(); h.f.sessions.close(); }
|
||||
case DEFEAT -> {
|
||||
death = death(holder); h.events.death(death); assertTrue(death.isCancelled());
|
||||
}
|
||||
}
|
||||
assertTrue(h.f.sessions.active(holder.actor.id).isEmpty());
|
||||
assertTrue(h.presentation.snapshot().isEmpty()); assertEquals(List.of(view), h.output.cleared);
|
||||
lateName.run(); lateName.run();
|
||||
assertTrue(h.output.renamed.isEmpty()); assertTrue(h.presentation.snapshot().isEmpty());
|
||||
var state = h.f.state(holder.actor);
|
||||
assertNotNull(state.lastExit()); assertEquals(record.id(), state.lastExit().characterId());
|
||||
if (restoration == Restoration.SUCCESS) {
|
||||
assertNull(state.pending()); assertArrayEquals(original, holder.actor.snapshot());
|
||||
} else {
|
||||
assertNotNull(state.pending()); assertArrayEquals(beforeExit, holder.actor.snapshot());
|
||||
assertTrue(holder.actor.marker().endsWith(restoration == Restoration.MISSING_BOX ? "|PACKED" : "|RESTORING"));
|
||||
}
|
||||
if (ending == Ending.DEFEAT) {
|
||||
if (restoration != Restoration.SUCCESS) h.f.expectedFailure = IllegalStateException.class;
|
||||
h.f.tick(); // Actual asynchronous death acknowledgment / one-use Tail attempt.
|
||||
var dead = h.saved(record.id()); assertTrue(dead.dead()); assertProgress(renamed, dead);
|
||||
assertEquals(0, dead.health().signum());
|
||||
assertEquals(restoration == Restoration.SUCCESS ? Material.WRITTEN_BOOK : Material.PAPER,
|
||||
holder.actor.inventory.getItemInOffHand().getType());
|
||||
assertEquals(holder.actor.normalMaximum, death.getReviveHealth());
|
||||
assertEquals(holder.actor.normalMaximum, holder.actor.health, "Existing minimal defeat healing, not a fantasy health engine");
|
||||
assertEquals(ordinary.food(), holder.player.getFoodLevel());
|
||||
assertEquals(ordinary.saturation(), holder.player.getSaturation());
|
||||
assertEquals(ordinary.exhaustion(), holder.player.getExhaustion());
|
||||
} else {
|
||||
assertEquals(renamed, h.saved(record.id())); assertEquals(ordinary, holder.ordinary());
|
||||
}
|
||||
assertTrue(h.presentation.snapshot().isEmpty()); assertEquals(1, h.output.cleared.size());
|
||||
h.f.sessions.exit(holder.player); // Repeated cleanup cannot output a second stale withdrawal.
|
||||
assertEquals(1, h.output.cleared.size()); assertEquals(state, h.f.state(holder.actor));
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest(name = "disable={0}")
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void quitOrDisableBeforeAcknowledgedNativeEntryCannotInstallPresentationFromStaleCallback(boolean disable) throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Never activated"); var holder = h.holder(record);
|
||||
var before = holder.actor.snapshot();
|
||||
h.f.sessions.enter(holder.player); h.f.tick(); var callback = h.f.next();
|
||||
assertNotNull(h.f.state(holder.actor).pending()); assertTrue(h.presentation.snapshot().isEmpty());
|
||||
if (disable) { h.events.close(); h.f.sessions.close(); }
|
||||
else h.events.quit(new org.bukkit.event.player.PlayerQuitEvent(holder.player, Component.empty()));
|
||||
assertNull(h.f.state(holder.actor).pending());
|
||||
callback.run(); callback.run();
|
||||
assertArrayEquals(before, holder.actor.snapshot()); assertNull(holder.actor.marker());
|
||||
assertTrue(h.f.sessions.active(holder.actor.id).isEmpty()); assertTrue(h.presentation.snapshot().isEmpty());
|
||||
assertTrue(h.output.shown.isEmpty()); assertTrue(h.output.cleared.isEmpty());
|
||||
assertEquals(record, h.saved(record.id()));
|
||||
if (!disable) {
|
||||
h.activate(holder); var replacement = h.presentation.snapshot().get(holder.actor.id);
|
||||
callback.run(); assertEquals(Map.of(holder.actor.id, replacement), h.presentation.snapshot());
|
||||
assertEquals(1, h.output.shown.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedThenSuccessfulRecoveryNeverReinstallsOldPresentationOrReplaysItsDelayedCallbacks() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Recoverable character"); var holder = h.holder(record);
|
||||
var original = holder.actor.snapshot(); h.activate(holder);
|
||||
var oldView = h.presentation.snapshot().get(holder.actor.id);
|
||||
var renamed = h.f.characters.renameCharacter(record.id(), "Recovered current name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
h.presentation.acknowledged(renamed); var oldName = h.displayTasks.remove();
|
||||
var control = holder.actor.inventory.getItem(0).clone();
|
||||
holder.actor.inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
var refused = holder.actor.snapshot();
|
||||
assertEquals(EquipmentStorage.RestoreResult.OCCUPIED_SLOT, h.f.sessions.exit(holder.player));
|
||||
var exited = h.f.state(holder.actor);
|
||||
assertTrue(h.presentation.snapshot().isEmpty()); assertEquals(List.of(oldView), h.output.cleared);
|
||||
h.f.sessions.recover(holder.player); h.f.tick();
|
||||
assertArrayEquals(refused, holder.actor.snapshot()); assertEquals(exited, h.f.state(holder.actor));
|
||||
assertTrue(h.presentation.snapshot().isEmpty()); assertEquals(1, h.output.shown.size());
|
||||
holder.actor.inventory.setItem(0, control);
|
||||
h.f.sessions.recover(holder.player); var staleRecovery = h.f.next();
|
||||
h.f.sessions.cancelEntry(holder.actor.id);
|
||||
h.f.sessions.recover(holder.player); h.f.tick();
|
||||
assertArrayEquals(original, holder.actor.snapshot()); assertNull(h.f.state(holder.actor).pending());
|
||||
assertEquals(exited.lastExit(), h.f.state(holder.actor).lastExit());
|
||||
oldName.run(); staleRecovery.run();
|
||||
assertTrue(h.presentation.snapshot().isEmpty()); assertTrue(h.output.renamed.isEmpty());
|
||||
assertEquals(1, h.output.shown.size()); assertEquals(1, h.output.cleared.size());
|
||||
h.activate(holder); var replacement = h.presentation.snapshot().get(holder.actor.id);
|
||||
assertNotEquals(oldView.session(), replacement.session()); assertEquals(renamed.name(), replacement.name());
|
||||
oldName.run(); staleRecovery.run(); h.presentation.withdraw(holder.actor.id, oldView.session());
|
||||
assertEquals(Map.of(holder.actor.id, replacement), h.presentation.snapshot());
|
||||
assertEquals(replacement.session(), h.f.state(holder.actor).pending().sessionId());
|
||||
assertEquals(renamed, h.saved(record.id()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defeatAfterTransferReturnsOnlyTheNewHolderToTheirOwnEntryAndLeavesTheFormerHolderUntouched() throws Exception {
|
||||
try (var h = new Harness()) {
|
||||
var record = h.saveCharacter("Transferred then defeated"); var a = h.holder(record); var b = h.holder(record);
|
||||
a.actor.location = new Location(null, 100, 70, 100);
|
||||
b.actor.location = new Location(null, -500, 80, -600);
|
||||
var bEntry = b.actor.location.clone(); var bGear = b.actor.inventory.getItem(0).clone();
|
||||
h.activate(a); h.f.sessions.exit(a.player); var aExit = h.f.state(a.actor);
|
||||
b.actor.inventory.setItemInOffHand(a.actor.inventory.getItemInOffHand().clone()); a.actor.inventory.setItemInOffHand(null);
|
||||
var aItems = a.actor.snapshot(); var aOrdinary = a.ordinary(); var aLocation = a.actor.location.clone();
|
||||
h.activate(b); b.actor.location = new Location(null, 999, 64, 888);
|
||||
var event = death(b); h.events.death(event);
|
||||
assertTrue(event.isCancelled()); assertEquals(bEntry, b.actor.teleported); assertEquals(bEntry, b.actor.location);
|
||||
assertEquals(1, b.actor.teleports); assertEquals(0, a.actor.teleports);
|
||||
assertEquals(bGear, b.actor.inventory.getItem(0));
|
||||
assertNull(h.presentation.name(b.actor.id)); assertNull(h.f.state(b.actor).pending());
|
||||
h.f.tick();
|
||||
assertEquals(Material.WRITTEN_BOOK, b.actor.inventory.getItemInOffHand().getType());
|
||||
assertTrue(h.saved(record.id()).dead()); assertProgress(record, h.saved(record.id()));
|
||||
assertArrayEquals(aItems, a.actor.snapshot()); assertEquals(aOrdinary, a.ordinary());
|
||||
assertEquals(aLocation, a.actor.location); assertEquals(aExit, h.f.state(a.actor));
|
||||
assertTrue(h.presentation.snapshot().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.event.entity.PlayerDeathEvent death(Holder holder) {
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
|
||||
return new org.bukkit.event.entity.PlayerDeathEvent(holder.player, source, new ArrayList<>(), 7,
|
||||
Component.text("ordinary death"), true);
|
||||
}
|
||||
|
||||
private static void assertProgress(CharacterRecord expected, CharacterRecord actual) {
|
||||
assertEquals(expected.id(), actual.id()); assertEquals(expected.name(), actual.name());
|
||||
assertEquals(expected.stats(), actual.stats()); assertEquals(expected.level(), actual.level());
|
||||
assertEquals(expected.experience(), actual.experience()); assertEquals(expected.unspentPoints(), actual.unspentPoints());
|
||||
assertEquals(expected.learned(), actual.learned()); assertEquals(expected.loadout(), actual.loadout());
|
||||
assertEquals(expected.cooldowns(), actual.cooldowns());
|
||||
}
|
||||
|
||||
private final class Harness implements AutoCloseable {
|
||||
final EquipmentSessionsNativeTest.MutableClock clock = new EquipmentSessionsNativeTest.MutableClock();
|
||||
final EquipmentSessionsNativeTest.Fixture f;
|
||||
final ArrayDeque<Runnable> displayTasks = new ArrayDeque<>();
|
||||
final ArrayDeque<Runnable> eventTasks = new ArrayDeque<>();
|
||||
final games.dmg.fantasystick.session.EquipmentSessionEvents events;
|
||||
final ArrayList<Throwable> displayFailures = new ArrayList<>();
|
||||
final RecordingOutput output = new RecordingOutput();
|
||||
final CharacterPresentation presentation;
|
||||
Harness() throws Exception {
|
||||
var owner = new EquipmentSessionsNativeTest(); owner.data = data;
|
||||
f = owner.new Fixture(Duration.ofSeconds(900), clock);
|
||||
presentation = new CharacterPresentation(output, displayTasks::add,
|
||||
() -> Thread.currentThread() == f.main, displayFailures::add);
|
||||
f.presentation = presentation;
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
events = new games.dmg.fantasystick.session.EquipmentSessionEvents(f.sessions, eventTasks::add, f.failures::add);
|
||||
}
|
||||
void changed(Holder holder) {
|
||||
events.changed(new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(holder.player, 40,
|
||||
new ItemStack(Material.AIR), holder.actor.inventory.getItemInOffHand()));
|
||||
}
|
||||
CharacterRecord saveCharacter(String name) throws Exception {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (var stat : Stat.values()) stats.put(stat, BigInteger.valueOf(10L + stat.ordinal()));
|
||||
var record = new CharacterRecord(UUID.randomUUID(), name, BigInteger.ZERO, stats, 7,
|
||||
BigInteger.valueOf(643), BigInteger.valueOf(9), new BigDecimal("12.25"),
|
||||
Set.of("fireball", "heal", "revive"), List.of("fireball", "", "heal", "", "revive", "", ""),
|
||||
Map.of("fireball", clock.instant().plusSeconds(30), "revive", clock.instant().plusSeconds(86400)),
|
||||
false, null, null);
|
||||
f.characters.create(record).get(5, TimeUnit.SECONDS);
|
||||
return record;
|
||||
}
|
||||
Holder holder(CharacterRecord record, String account, Material gear, double health, int food, float saturation, float exhaustion) {
|
||||
return new Holder(f.actor(record), account, gear, health, food, saturation, exhaustion);
|
||||
}
|
||||
Holder holder(CharacterRecord record) { return holder(record, "Account-" + UUID.randomUUID(), Material.IRON_AXE, 8.25, 9, 2.5f, 1.25f); }
|
||||
void activate(Holder holder) throws Exception {
|
||||
f.sessions.enter(holder.player); f.tick(); f.tick();
|
||||
assertTrue(f.sessions.active(holder.actor.id).isPresent());
|
||||
}
|
||||
CharacterRecord saved(UUID id) throws Exception { return f.characters.find(id).get(5, TimeUnit.SECONDS).orElseThrow(); }
|
||||
@Override public void close() throws Exception {
|
||||
events.close();
|
||||
try { f.close(); }
|
||||
finally { presentation.close(); }
|
||||
assertTrue(displayFailures.isEmpty(), displayFailures::toString);
|
||||
}
|
||||
}
|
||||
|
||||
private record Ordinary(UUID id, String account, double health, double maximum,
|
||||
int food, float saturation, float exhaustion, Component chat, Component tab) { }
|
||||
|
||||
/** Strict Player port adds observable ordinary nutrition/identity to the existing native-inventory actor. */
|
||||
private static final class Holder {
|
||||
final EquipmentSessionsNativeTest.Actor actor;
|
||||
final Player player;
|
||||
final String account;
|
||||
final Component chat;
|
||||
final Component tab;
|
||||
int food;
|
||||
float saturation;
|
||||
float exhaustion;
|
||||
Holder(EquipmentSessionsNativeTest.Actor actor, String account, Material gear,
|
||||
double health, int food, float saturation, float exhaustion) {
|
||||
this.actor = actor; this.account = account; this.food = food;
|
||||
this.saturation = saturation; this.exhaustion = exhaustion;
|
||||
chat = Component.text("ordinary chat " + account); tab = Component.text("ordinary tab " + account);
|
||||
actor.health = health; actor.inventory.setItem(0, new ItemStack(gear));
|
||||
player = (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
|
||||
assertSame(actor.main, Thread.currentThread(), "Ordinary Player access stays on the server thread");
|
||||
return switch (method.getName()) {
|
||||
case "getName" -> account;
|
||||
case "displayName" -> { assertEquals(0, method.getParameterCount()); yield chat; }
|
||||
case "playerListName" -> { assertEquals(0, method.getParameterCount()); yield tab; }
|
||||
case "getFoodLevel" -> this.food;
|
||||
case "setFoodLevel" -> { this.food = (Integer) args[0]; yield null; }
|
||||
case "getSaturation" -> this.saturation;
|
||||
case "setSaturation" -> { this.saturation = (Float) args[0]; yield null; }
|
||||
case "getExhaustion" -> this.exhaustion;
|
||||
case "setExhaustion" -> { this.exhaustion = (Float) args[0]; yield null; }
|
||||
default -> {
|
||||
try { yield method.invoke(actor.player, args); }
|
||||
catch (InvocationTargetException failure) { throw failure.getCause(); }
|
||||
}
|
||||
};
|
||||
});
|
||||
var grid = new ManualCraftPlanNativeTest.Fixture(2, 1).grid;
|
||||
actor.view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
|
||||
@Override public org.bukkit.inventory.Inventory getTopInventory() { return grid; }
|
||||
@Override public org.bukkit.inventory.PlayerInventory getBottomInventory() { return actor.inventory; }
|
||||
@Override public org.bukkit.entity.HumanEntity getPlayer() { return player; }
|
||||
@Override public org.bukkit.event.inventory.InventoryType getType() { return org.bukkit.event.inventory.InventoryType.CRAFTING; }
|
||||
@Override public ItemStack getCursor() { return actor.cursor; }
|
||||
@Override public void setCursor(ItemStack item) { actor.cursor = item; }
|
||||
@Override public String getTitle() { return "Whole-character native fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title mutation"); }
|
||||
@Override public org.bukkit.inventory.MenuType getMenuType() { return null; }
|
||||
};
|
||||
}
|
||||
Ordinary ordinary() {
|
||||
return new Ordinary(player.getUniqueId(), player.getName(), player.getHealth(),
|
||||
player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue(),
|
||||
player.getFoodLevel(), player.getSaturation(), player.getExhaustion(), player.displayName(), player.playerListName());
|
||||
}
|
||||
}
|
||||
|
||||
/** Output boundary only: native overhead/chat/tab adapters have separate tests owned by the parent. */
|
||||
private static final class RecordingOutput implements CharacterPresentation.Output {
|
||||
final Thread main = Thread.currentThread();
|
||||
final ArrayList<CharacterPresentation.View> shown = new ArrayList<>();
|
||||
final ArrayList<CharacterPresentation.View> renamed = new ArrayList<>();
|
||||
final ArrayList<CharacterPresentation.View> cleared = new ArrayList<>();
|
||||
java.util.function.Consumer<CharacterPresentation.View> onClear = ignored -> {};
|
||||
@Override public void show(Player player, CharacterPresentation.View view) {
|
||||
assertSame(main, Thread.currentThread()); assertEquals(player.getUniqueId(), view.holder()); shown.add(view);
|
||||
}
|
||||
@Override public void rename(CharacterPresentation.View view) { assertSame(main, Thread.currentThread()); renamed.add(view); }
|
||||
@Override public void clear(CharacterPresentation.View view) {
|
||||
assertSame(main, Thread.currentThread()); onClear.accept(view); cleared.add(view);
|
||||
}
|
||||
@Override public void close() { assertSame(main, Thread.currentThread()); }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class DistributionTest {
|
||||
assertFalse(metadata.getVersion().contains("${"));
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
@@ -40,7 +41,7 @@ class DistributionTest {
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort(), "Java 25 bytecode");
|
||||
}
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/")
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("com/comphenix/") || entry.getName().startsWith("org/")
|
||||
|| entry.getName().startsWith("net/") || entry.getName().endsWith("Test.class")),
|
||||
"Server API and test dependencies must not ship in the plugin");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PartyRosterTest {
|
||||
final MutableClock clock = new MutableClock();
|
||||
final Set<UUID> online = new HashSet<>();
|
||||
final PartyRoster parties = new PartyRoster(clock, online::contains);
|
||||
|
||||
@Test void creatorLeadsExactlyOnePlayerOwnedPartyAndSnapshotsCannotMutateMembership() {
|
||||
UUID leader = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertEquals(leader, party.leader());
|
||||
assertEquals(List.of(leader), party.members());
|
||||
assertEquals(party, parties.partyOf(leader).orElseThrow());
|
||||
assertTrue(parties.create(leader).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> party.members().clear());
|
||||
assertTrue(parties.create(UUID.randomUUID()).isEmpty());
|
||||
}
|
||||
|
||||
@Test void invitationsRequireLeaderAuthorityAndRecipientConsentAndCannotBeReplayed() {
|
||||
UUID leader = player(), member = player(), stranger = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.invite(stranger, party.id(), member).isEmpty());
|
||||
assertTrue(parties.invite(leader, UUID.randomUUID(), member).isEmpty());
|
||||
var invitation = parties.invite(leader, party.id(), member).orElseThrow();
|
||||
assertEquals(invitation, parties.invite(leader, party.id(), member).orElseThrow());
|
||||
assertTrue(parties.partyOf(member).isEmpty(), "Inviting must not force membership");
|
||||
assertFalse(parties.accept(stranger, invitation.id()));
|
||||
assertTrue(parties.accept(member, invitation.id()));
|
||||
assertFalse(parties.accept(member, invitation.id()));
|
||||
assertEquals(List.of(leader, member), parties.partyOf(leader).orElseThrow().members());
|
||||
assertEquals(parties.partyOf(leader), parties.partyOf(member));
|
||||
assertEquals(List.of(leader), party.members(), "Old snapshots stay immutable");
|
||||
assertTrue(parties.invite(member, party.id(), stranger).isEmpty());
|
||||
}
|
||||
|
||||
@Test void leaderDisconnectRetainsLeadershipUntilTheLastOnlineMemberDisconnects() {
|
||||
UUID leader = player(), member = player(), invited = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
var outstanding = parties.invite(leader, party.id(), invited).orElseThrow();
|
||||
parties.disconnect(leader); // Bukkit may still report the departing player as online inside QuitEvent.
|
||||
online.remove(leader);
|
||||
assertEquals(leader, parties.partyOf(member).orElseThrow().leader());
|
||||
assertEquals(List.of(leader, member), parties.partyOf(member).orElseThrow().members());
|
||||
parties.disconnect(member); // The invited non-member does not keep the party alive.
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertFalse(parties.accept(invited, outstanding.id()));
|
||||
online.add(leader);
|
||||
assertNotEquals(party.id(), parties.create(leader).orElseThrow().id());
|
||||
}
|
||||
|
||||
@Test void explicitLeaderLeaveDisbandsWithoutSuccessionAndStaleActionsCannotTouchANewParty() {
|
||||
UUID leader = player(), member = player(), invited = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
var offer = parties.invite(leader, party.id(), invited).orElseThrow();
|
||||
assertFalse(parties.leave(member, UUID.randomUUID()));
|
||||
assertTrue(parties.leave(leader, party.id()));
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
var replacement = parties.create(leader).orElseThrow();
|
||||
assertFalse(parties.leave(leader, party.id()));
|
||||
assertFalse(parties.accept(invited, offer.id()));
|
||||
assertEquals(replacement, parties.partyOf(leader).orElseThrow());
|
||||
}
|
||||
|
||||
@Test void invitationsExpireAtExactlySixtyRealSecondsAndNewInvitesDoNotReviveOldTokens() {
|
||||
UUID leader = player(), before = player(), at = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var beforeOffer = parties.invite(leader, party.id(), before).orElseThrow();
|
||||
var atOffer = parties.invite(leader, party.id(), at).orElseThrow();
|
||||
clock.now = Instant.EPOCH.plusSeconds(60).minusNanos(1);
|
||||
assertTrue(parties.accept(before, beforeOffer.id()));
|
||||
clock.now = Instant.EPOCH.plusSeconds(60);
|
||||
assertFalse(parties.accept(at, atOffer.id()));
|
||||
var fresh = parties.invite(leader, party.id(), at).orElseThrow();
|
||||
assertNotEquals(atOffer.id(), fresh.id());
|
||||
assertFalse(parties.accept(at, atOffer.id()));
|
||||
assertTrue(parties.accept(at, fresh.id()));
|
||||
}
|
||||
|
||||
@Test void invitationsDoNotReserveSlotsAndAcceptanceRechecksTheSixPlayerCapacity() {
|
||||
UUID leader = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var offers = new java.util.ArrayList<PartyRoster.Invitation>();
|
||||
for (int i = 0; i < 7; i++) offers.add(parties.invite(leader, party.id(), player()).orElseThrow());
|
||||
assertEquals(1, parties.partyOf(leader).orElseThrow().members().size());
|
||||
for (int i = 0; i < 5; i++) assertTrue(parties.accept(offers.get(i).invitee(), offers.get(i).id()));
|
||||
assertFalse(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
|
||||
assertFalse(parties.accept(offers.get(6).invitee(), offers.get(6).id()));
|
||||
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
|
||||
assertTrue(parties.invite(leader, party.id(), player()).isEmpty());
|
||||
assertTrue(parties.leave(offers.getFirst().invitee(), party.id()));
|
||||
assertTrue(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
|
||||
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
|
||||
}
|
||||
|
||||
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
|
||||
UUID leader = player(), member = player(), stranger = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
assertFalse(parties.kick(member, party.id(), leader));
|
||||
assertFalse(parties.disband(member, party.id()));
|
||||
assertFalse(parties.disband(stranger, party.id()));
|
||||
assertFalse(parties.kick(leader, UUID.randomUUID(), member));
|
||||
assertFalse(parties.kick(leader, party.id(), stranger));
|
||||
online.remove(leader);
|
||||
assertFalse(parties.kick(leader, party.id(), member));
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
online.add(leader);
|
||||
assertTrue(parties.kick(leader, party.id(), member));
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
|
||||
assertTrue(parties.disband(leader, party.id()));
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
}
|
||||
|
||||
@Test void shutdownRevokesAllPartiesInvitationsAndOldServiceAuthorityWithoutRestartPersistence() {
|
||||
UUID leader = player(), member = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var offer = parties.invite(leader, party.id(), member).orElseThrow();
|
||||
parties.close(); parties.close();
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertFalse(parties.accept(member, offer.id()));
|
||||
assertTrue(parties.create(leader).isEmpty());
|
||||
assertTrue(parties.invite(leader, party.id(), member).isEmpty());
|
||||
try (var restarted = new PartyRoster(clock, online::contains)) {
|
||||
assertTrue(restarted.partyOf(leader).isEmpty());
|
||||
assertFalse(restarted.accept(member, offer.id()));
|
||||
assertNotEquals(party.id(), restarted.create(leader).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void quitImmediatelyRevokesManagementDespiteALaggingOnlineFlagAndJoinRestoresIt() {
|
||||
UUID leader = player(), member = player(), target = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
parties.disconnect(leader);
|
||||
assertTrue(parties.invite(leader, party.id(), target).isEmpty());
|
||||
assertFalse(parties.kick(leader, party.id(), member));
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
assertFalse(parties.leave(leader, party.id()));
|
||||
parties.arrive(leader);
|
||||
assertTrue(parties.invite(leader, party.id(), target).isPresent());
|
||||
assertTrue(parties.leave(member, party.id()));
|
||||
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
|
||||
}
|
||||
|
||||
@Test void lastOnlineMemberLeavingAlsoDisbandsRatherThanStrandingOfflineMembers() {
|
||||
UUID leader = player(), member = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
parties.disconnect(leader);
|
||||
assertTrue(parties.leave(member, party.id()));
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
}
|
||||
|
||||
UUID player() { UUID id = UUID.randomUUID(); online.add(id); return id; }
|
||||
static final class MutableClock extends Clock {
|
||||
Instant now = Instant.EPOCH;
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdventureSettingsTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test void acknowledgesDurableUpdateWithoutDiscardingOtherConfiguration() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
java.nio.file.Files.writeString(file, "starting-points: 73\nother:\n enabled: true\n");
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertFalse(settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertFalse(settings.enabled());
|
||||
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(java.nio.file.Files.readString(file));
|
||||
assertEquals(false, saved.get("adventure-mode"));
|
||||
assertEquals(73, saved.get("starting-points"));
|
||||
assertEquals(java.util.Map.of("enabled", true), saved.get("other"));
|
||||
assertFalse(AdventureSettings.validate(saved.get("adventure-mode")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgmentsAreWorkerOwnedOrderedAndSurviveObserverCancellationAndClose() throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var caller = Thread.currentThread();
|
||||
var settings = new AdventureSettings(directory, true, (from, to) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
|
||||
java.nio.file.Files.move(from, to, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try {
|
||||
var first = settings.set(false);
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
first.toCompletableFuture().cancel(false);
|
||||
var second = settings.set(true);
|
||||
settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get());
|
||||
release.countDown();
|
||||
assertFalse(first.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(second.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(
|
||||
java.nio.file.Files.readString(directory.resolve("config.yml")));
|
||||
assertEquals(true, saved.get("adventure-mode"));
|
||||
} finally { release.countDown(); settings.close(); }
|
||||
}
|
||||
|
||||
@Test void failedSavePreservesEffectiveValueAndUnrelatedOriginalBytes() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
String original = "other: 17\nadventure-mode: true\n";
|
||||
java.nio.file.Files.writeString(file, original);
|
||||
try (var settings = new AdventureSettings(directory, true, (from, to) -> { throw new java.io.IOException("no write"); })) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
assertEquals(original, java.nio.file.Files.readString(file));
|
||||
}
|
||||
try (var files = java.nio.file.Files.list(directory)) {
|
||||
assertFalse(files.anyMatch(path -> path.toString().endsWith(".tmp")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void corruptDuplicateOversizedOrSymlinkedConfigurationIsRetained() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
for (String invalid : java.util.List.of("a: [broken", "other: 1\nother: 2\n", "[1, 2]", "#".repeat(1_048_577))) {
|
||||
java.nio.file.Files.writeString(file, invalid);
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled()); assertEquals(invalid, java.nio.file.Files.readString(file));
|
||||
}
|
||||
}
|
||||
java.nio.file.Files.delete(file);
|
||||
var outside = directory.resolve("outside.yml");
|
||||
java.nio.file.Files.writeString(outside, "keep: me\n");
|
||||
java.nio.file.Files.createSymbolicLink(file, outside);
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled()); assertEquals("keep: me\n", java.nio.file.Files.readString(outside));
|
||||
assertTrue(java.nio.file.Files.isSymbolicLink(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defaultsToTrueAndAcceptsOnlyActualBooleanValues() {
|
||||
assertTrue(AdventureSettings.validate(null));
|
||||
assertTrue(AdventureSettings.validate(Boolean.TRUE));
|
||||
assertFalse(AdventureSettings.validate(Boolean.FALSE));
|
||||
for (Object invalid : new Object[]{"true", "false", "yes", 0, 1, java.util.List.of(), java.util.Map.of()}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> AdventureSettings.validate(invalid));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,19 @@ class EquipmentSessionStoreTest {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test void previousGameModeIsAcknowledgedPersistedAndCannotBeReplacedByARepeatedPreparation() throws Exception {
|
||||
try (var store = new EquipmentSessionStore(data, CLOCK)) {
|
||||
var prepared = await(store.prepare(player, session, character, "CREATIVE"));
|
||||
assertEquals("CREATIVE", prepared.pending().previousGameMode());
|
||||
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
|
||||
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
|
||||
assertEquals(prepared, await(reopened.prepare(player, session, character, "CREATIVE")));
|
||||
failure(reopened.prepare(player, session, character, "SURVIVAL"));
|
||||
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path record() { return data.resolve("sessions").resolve(player + ".yml"); }
|
||||
|
||||
private static Throwable failure(CompletionStage<?> stage) {
|
||||
@@ -183,6 +196,39 @@ class EquipmentSessionStoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedExitUsesRequestTimeBehindAWorkerBacklogAndRetriesKeepThatDeadline() throws Exception {
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var time = new AtomicReference<>(NOW);
|
||||
Clock clock = new Clock() {
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return time.get(); }
|
||||
};
|
||||
try (var store = new EquipmentSessionStore(data, clock, (source, target) -> {
|
||||
entered.countDown(); waitFor(release); replace(source, target);
|
||||
})) {
|
||||
var preparation = store.prepare(player, session, character);
|
||||
CompletionStage<EquipmentSessionStore.State> completion;
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
time.set(NOW.plusSeconds(2));
|
||||
completion = store.completeExit(player, session, Duration.ofSeconds(900));
|
||||
assertFalse(completion.toCompletableFuture().isDone());
|
||||
time.set(NOW.plusSeconds(3600)); // Worker cannot execute the exit until after its deadline.
|
||||
} finally { release.countDown(); }
|
||||
await(preparation);
|
||||
var completed = await(completion);
|
||||
assertNull(completed.pending());
|
||||
assertEquals(NOW.plusSeconds(2), completed.lastExit().exitedAt());
|
||||
assertEquals(NOW.plusSeconds(902), completed.lastExit().deadline());
|
||||
assertTrue(completed.canEnter(UUID.randomUUID(), Duration.ofSeconds(900), clock.instant()));
|
||||
assertEquals(completed, await(store.completeExit(player, session, Duration.ofDays(1))));
|
||||
assertEquals(completed, await(store.recordExit(player, session, Duration.ofDays(1))));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedForcedTemporaryReplacementPreservesOriginalAndRetryIsPossible() throws Exception {
|
||||
EquipmentSessionStore.State original;
|
||||
|
||||
Reference in new issue
Block a user