Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a08e8a79 | ||
|
|
83f0ea9a6e | ||
|
|
359b723897 |
No files matched your search
@@ -1,9 +1,11 @@
|
||||
# purpur-heights agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-heights] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-heights/index.md`, `engineering.md` in that project section, and relevant Kaneo tickets with subject prefix `[purpur-heights]`. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -82,6 +82,10 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void resume(Player player) {
|
||||
resume(player, true);
|
||||
}
|
||||
|
||||
public void resume(Player player, boolean notify) {
|
||||
UUID id = player.getUniqueId();
|
||||
queue.submit(() -> {
|
||||
requireOnline(id);
|
||||
@@ -89,7 +93,7 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
Instant now = Instant.now();
|
||||
return () -> stature.resume(id, limits, now);
|
||||
}, (result, error) -> {
|
||||
if (error == null) { applyRestoration(id, result); } else { report(id, error); }
|
||||
if (error == null) { applyRestoration(id, result, notify); } else { report(id, error); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,8 +175,12 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void applyRestoration(UUID id, double value) {
|
||||
applyRestoration(id, value, true);
|
||||
}
|
||||
|
||||
private void applyRestoration(UUID id, double value, boolean notify) {
|
||||
HeightStore.Sequence pending = store.sequence(id);
|
||||
if (applyOnline(id, value) && pending != null && !Instant.now().isBefore(pending.expiresAt())) {
|
||||
if (applyOnline(id, value, notify) && pending != null && !Instant.now().isBefore(pending.expiresAt())) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
stature.acknowledgeRestoration(id, pending);
|
||||
@@ -184,16 +192,30 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
private boolean applyOnline(UUID id, double value) {
|
||||
return applyOnline(id, value, true);
|
||||
}
|
||||
|
||||
private boolean applyOnline(UUID id, double value, boolean notify) {
|
||||
Player player = plugin.getServer().getPlayer(id);
|
||||
if (plugin.isEnabled() && player != null && player.isOnline() && !player.isDead()) {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
if (attribute != null) {
|
||||
attribute.setBaseValue(value);
|
||||
player.sendMessage("Your scale is now " + value + ".");
|
||||
return true;
|
||||
}
|
||||
return plugin.isEnabled() && applyToPlayer(player, value, notify);
|
||||
}
|
||||
|
||||
static boolean applyToPlayer(Player player, double value, boolean notify) {
|
||||
if (player == null || !player.isOnline() || player.isDead()) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
return applyScale(player, player.getAttribute(Attribute.SCALE), value, notify);
|
||||
}
|
||||
|
||||
static boolean applyScale(Player player, AttributeInstance attribute, double value, boolean notify) {
|
||||
if (attribute == null) {
|
||||
return false;
|
||||
}
|
||||
attribute.setBaseValue(value);
|
||||
if (notify) {
|
||||
player.sendMessage("Your scale is now " + value + ".");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void report(UUID id, Exception error) {
|
||||
|
||||
@@ -18,11 +18,11 @@ import org.bukkit.inventory.Recipe;
|
||||
public final class StatureListener implements Listener {
|
||||
private final PotionRecipes potions;
|
||||
private final BiConsumer<Player, StaturePotion> drink;
|
||||
private final Consumer<Player> resume;
|
||||
private final BiConsumer<Player, Boolean> resume;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
|
||||
public StatureListener(PotionRecipes potions, BiConsumer<Player, StaturePotion> drink,
|
||||
Consumer<Player> resume, Consumer<Runnable> nextTick) {
|
||||
BiConsumer<Player, Boolean> resume, Consumer<Runnable> nextTick) {
|
||||
this.potions = potions;
|
||||
this.drink = drink;
|
||||
this.resume = resume;
|
||||
@@ -54,11 +54,11 @@ public final class StatureListener implements Listener {
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
resume.accept(event.getPlayer());
|
||||
resume.accept(event.getPlayer(), false);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
nextTick.accept(() -> resume.accept(event.getPlayer()));
|
||||
nextTick.accept(() -> resume.accept(event.getPlayer(), true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BukkitStatureServiceTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void silentRestorationKeepsStoredClampingAndExpiredBaselineWithoutFeedback() throws Exception {
|
||||
HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
PlayerFixture player = new PlayerFixture();
|
||||
|
||||
UUID saved = UUID.randomUUID();
|
||||
store.save(saved, 3.0);
|
||||
assertTrue(BukkitStatureService.applyScale(
|
||||
player.player, player.attribute, stature.resume(saved, settings, Instant.now()), false));
|
||||
assertEquals(2.0, player.scale);
|
||||
assertEquals(List.of(), player.messages);
|
||||
|
||||
UUID expired = UUID.randomUUID();
|
||||
Instant start = Instant.parse("2026-01-01T00:00:00Z");
|
||||
stature.apply(expired, 1.4, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
assertTrue(BukkitStatureService.applyScale(player.player, player.attribute,
|
||||
stature.resume(expired, settings, start.plusSeconds(301)), false));
|
||||
assertEquals(1.4, player.scale);
|
||||
assertEquals(List.of(), player.messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyingApplicationPreservesPotionAdministratorAndRespawnFeedback() {
|
||||
PlayerFixture player = new PlayerFixture();
|
||||
|
||||
assertTrue(BukkitStatureService.applyScale(player.player, player.attribute, 1.2, true));
|
||||
assertEquals(1.2, player.scale);
|
||||
assertEquals(List.of("Your scale is now 1.2."), player.messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailablePlayerStillRefusesRestorationWithoutFeedback() {
|
||||
Player offline = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> {
|
||||
if (method.getName().equals("isOnline")) {
|
||||
return false;
|
||||
}
|
||||
throw new AssertionError("Offline player should not be changed: " + method.getName());
|
||||
});
|
||||
|
||||
assertFalse(BukkitStatureService.applyToPlayer(offline, 0.8, false));
|
||||
assertFalse(BukkitStatureService.applyToPlayer(null, 0.8, true));
|
||||
assertFalse(BukkitStatureService.applyScale(offline, null, 0.8, true));
|
||||
}
|
||||
|
||||
private static final class PlayerFixture {
|
||||
private double scale = 1.0;
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private final AttributeInstance attribute = BukkitDoubles.proxy(
|
||||
AttributeInstance.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "setBaseValue" -> { scale = (double) args[0]; yield null; }
|
||||
case "getBaseValue" -> scale;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
private final Player player = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> {
|
||||
if (method.getName().equals("sendMessage")) {
|
||||
messages.add((String) args[0]);
|
||||
return null;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package games.dmg.spigotheights;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -16,7 +19,8 @@ class StatureListenerTest {
|
||||
PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
BukkitDoubles.Item::new);
|
||||
List<StaturePotion> drinks = new ArrayList<>();
|
||||
StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind), player -> {}, Runnable::run);
|
||||
StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind),
|
||||
(player, notify) -> {}, Runnable::run);
|
||||
Player player = ThrowableStatureListenerTest.entity(Player.class, null);
|
||||
var cancelled = new PlayerItemConsumeEvent(player, potions.create(StaturePotion.GROWTH), EquipmentSlot.HAND);
|
||||
cancelled.setCancelled(true);
|
||||
@@ -29,4 +33,23 @@ class StatureListenerTest {
|
||||
listener.onConsume(cancelled);
|
||||
assertEquals(List.of(StaturePotion.GROWTH), drinks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRestorationIsSilentWhileRespawnRestorationStillNotifies() {
|
||||
PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
BukkitDoubles.Item::new);
|
||||
List<Boolean> notificationModes = new ArrayList<>();
|
||||
List<Runnable> nextTick = new ArrayList<>();
|
||||
StatureListener listener = new StatureListener(potions, (player, kind) -> {},
|
||||
(player, notify) -> notificationModes.add(notify), nextTick::add);
|
||||
Player player = ThrowableStatureListenerTest.entity(Player.class, null);
|
||||
|
||||
listener.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
assertEquals(List.of(false), notificationModes);
|
||||
|
||||
listener.onRespawn(new PlayerRespawnEvent(player, new Location(null, 0, 0, 0), false));
|
||||
assertEquals(1, nextTick.size());
|
||||
nextTick.getFirst().run();
|
||||
assertEquals(List.of(false, true), notificationModes);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user