diff --git a/README.md b/README.md index 4ffea99..56e1cbf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,16 @@ Gold Ingot | Rabbit's Foot | Gold Ingot Use the Growth recipe with a Fermented Spider Eye instead of the Rabbit's Foot. Growth and Diminution adjust scale by one configured step and clamp at the limits. +### Potion of Restoration + +```text +Gold Ingot | Amethyst Shard | Gold Ingot +Amethyst Shard | Potion of Shifting Stature | Amethyst Shard +Gold Ingot | Sugar | Gold Ingot +``` + +Produces one Potion of Restoration. Drinking it restores scale to exactly `1.0` (normal size), even outside configured limits. This size is saved across reconnects, respawns, and server restarts. Other saved scales are still clamped to the configured range. + ## Tiny-player launchers A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone. diff --git a/design/log.md b/design/log.md index c73a7a1..ddd2756 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting Spigot Heights # Spigot Heights Design Log +## 2026-09-06T23:41:16Z — Restoration completed + +- Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe. +- Consumption restores exact scale `1.0` through the existing report/save path; saved `1.0` bypasses configured-range clamping on join and respawn. +- Verified `./gradlew clean check jar`: all 17 tests passed, including restoration, persistence reload, range exceptions, recipe shape, identity, and existing potion regression coverage. +- Inspected Bukkit recipe/metadata and event wiring; live-server gameplay verification remains unperformed. Updated the README and completed US-006 and the related US-004 change. + +## 2026-09-06T23:37:19Z — Restoration approved and implementation started + +- Approved [US-006](user-stories/us-006-restore-default-stature.md): a Sugar-based upgrade of Shifting Stature restores exact scale `1.0`. +- Approved an exception to saved-scale clamping in [US-004](user-stories/us-004-configure-and-persist.md) so restoration survives reconnects and respawns even outside configured limits. + ## 2026-09-04 — Initial design approved - Player scale defaults to a configurable range of `0.4` through `2.0`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 1cb6a47..1002483 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -11,3 +11,4 @@ description: Catalog of user stories for the Spigot Heights plugin. 3. [US-003: Launch tiny players through dispensers](us-003-launch-tiny-players.md) 4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) 5. [US-005: Build and release the plugin](us-005-build-and-release.md) +6. [US-006: Restore default stature](us-006-restore-default-stature.md) diff --git a/design/user-stories/us-004-configure-and-persist.md b/design/user-stories/us-004-configure-and-persist.md index 267d5ed..af39bca 100644 --- a/design/user-stories/us-004-configure-and-persist.md +++ b/design/user-stories/us-004-configure-and-persist.md @@ -17,9 +17,10 @@ As a **server operator**, I want validated stature and launcher settings with du - [x] Player scales are stored by UUID using atomic file replacement where supported. - [x] Updating known state preserves unknown forward-compatible YAML fields. - [x] Missing state defaults safely to scale `1.0` clamped to the configured range. -- [x] Saved out-of-range state is clamped before it is applied. +- [x] Saved out-of-range state is clamped before it is applied, except exact scale `1.0`, which is preserved for restoration. - [x] Configuration and state behavior have automated tests. ## Related - [User-story catalog](index.md) +- [US-006: Restore default stature](us-006-restore-default-stature.md) diff --git a/design/user-stories/us-006-restore-default-stature.md b/design/user-stories/us-006-restore-default-stature.md new file mode 100644 index 0000000..9f4dbaa --- /dev/null +++ b/design/user-stories/us-006-restore-default-stature.md @@ -0,0 +1,28 @@ +--- +type: User Story +title: "US-006: Restore default stature" +description: Let players craft a potion that permanently restores their scale to 1.0. +status: done +--- + +# US-006: Restore default stature + +As a **player**, I want a Potion of Restoration so that I can return to normal size. + +## Acceptance criteria + +- [x] Drinking the potion sets scale to exactly `1.0`, regardless of configured limits. +- [x] The potion uses distinct persistent metadata rather than its display name for identity. +- [x] The new scale is reported and saved by UUID, and retained across reconnects and respawns. +- [x] The recipe is `GAG/ASA/GUG`, where `G` is Gold Ingot, `A` is Amethyst Shard, `S` is an authenticated Potion of Shifting Stature, and `U` is Sugar. +- [x] Automated tests cover restoration, saved-scale handling, recipe shape, and distinct identity; the README documents the recipe and behavior. + +## Verification + +- `./gradlew clean check jar` passed all 17 tests. +- Automated coverage checks scale calculation, storage reload, range exceptions, recipe shape, and enum identity. Code inspection confirms metadata authentication, ingredient registration, and the shared consumption/join/respawn adapters; no live-server gameplay test was performed. + +## Related + +- [US-002: Make precise stature adjustments](us-002-adjust-stature.md) +- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) diff --git a/src/main/java/games/dmg/spigotheights/HeightMath.java b/src/main/java/games/dmg/spigotheights/HeightMath.java index 5113f38..f1ff273 100644 --- a/src/main/java/games/dmg/spigotheights/HeightMath.java +++ b/src/main/java/games/dmg/spigotheights/HeightMath.java @@ -27,7 +27,8 @@ public final class HeightMath { if (stored == null || !Double.isFinite(stored)) { return clamp(1.0, settings); } - return clamp(stored, settings); + // Restoration is an explicit escape from configured stature limits. + return stored == 1.0 ? 1.0 : clamp(stored, settings); } public static double clamp(double value, HeightSettings settings) { diff --git a/src/main/java/games/dmg/spigotheights/PotionRecipes.java b/src/main/java/games/dmg/spigotheights/PotionRecipes.java index 06c398b..d04357e 100644 --- a/src/main/java/games/dmg/spigotheights/PotionRecipes.java +++ b/src/main/java/games/dmg/spigotheights/PotionRecipes.java @@ -17,6 +17,7 @@ public final class PotionRecipes { static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"}; static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"}; static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"}; + static final String[] RESTORATION_SHAPE = {"GAG", "ASA", "GUG"}; private final JavaPlugin plugin; private final NamespacedKey potionKindKey; @@ -30,6 +31,7 @@ public final class PotionRecipes { registerShifting(); registerGrowth(); registerDiminution(); + registerRestoration(); } public ItemStack create(StaturePotion kind) { @@ -96,11 +98,23 @@ public final class PotionRecipes { plugin.getServer().addRecipe(recipe); } + private void registerRestoration() { + ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "restoration"), + create(StaturePotion.RESTORATION)); + recipe.shape(RESTORATION_SHAPE); + recipe.setIngredient('G', Material.GOLD_INGOT); + recipe.setIngredient('A', Material.AMETHYST_SHARD); + recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING))); + recipe.setIngredient('U', Material.SUGAR); + plugin.getServer().addRecipe(recipe); + } + private static Color color(StaturePotion kind) { return switch (kind) { case SHIFTING -> Color.PURPLE; case GROWTH -> Color.LIME; case DIMINUTION -> Color.FUCHSIA; + case RESTORATION -> Color.WHITE; }; } } diff --git a/src/main/java/games/dmg/spigotheights/StatureListener.java b/src/main/java/games/dmg/spigotheights/StatureListener.java index 7d0d374..7953977 100644 --- a/src/main/java/games/dmg/spigotheights/StatureListener.java +++ b/src/main/java/games/dmg/spigotheights/StatureListener.java @@ -50,12 +50,8 @@ public final class StatureListener implements Listener { } Player player = event.getPlayer(); double current = currentScale(player); - double scale = switch (kind) { - case SHIFTING -> HeightMath.randomScale(settings, - bound -> ThreadLocalRandom.current().nextInt(bound)); - case GROWTH -> HeightMath.grow(current, settings); - case DIMINUTION -> HeightMath.shrink(current, settings); - }; + double scale = kind.scaleAfterDrinking(current, settings, + bound -> ThreadLocalRandom.current().nextInt(bound)); applyAndSave(player, scale); player.sendMessage("Your scale is now " + scale + "."); } diff --git a/src/main/java/games/dmg/spigotheights/StaturePotion.java b/src/main/java/games/dmg/spigotheights/StaturePotion.java index a1cd73e..7e65036 100644 --- a/src/main/java/games/dmg/spigotheights/StaturePotion.java +++ b/src/main/java/games/dmg/spigotheights/StaturePotion.java @@ -1,9 +1,12 @@ package games.dmg.spigotheights; +import java.util.function.IntUnaryOperator; + public enum StaturePotion { SHIFTING("Potion of Shifting Stature"), GROWTH("Potion of Growth"), - DIMINUTION("Potion of Diminution"); + DIMINUTION("Potion of Diminution"), + RESTORATION("Potion of Restoration"); private final String displayName; @@ -11,6 +14,15 @@ public enum StaturePotion { this.displayName = displayName; } + public double scaleAfterDrinking(double current, HeightSettings settings, IntUnaryOperator randomIndex) { + return switch (this) { + case SHIFTING -> HeightMath.randomScale(settings, randomIndex); + case GROWTH -> HeightMath.grow(current, settings); + case DIMINUTION -> HeightMath.shrink(current, settings); + case RESTORATION -> 1.0; + }; + } + public String displayName() { return displayName; } diff --git a/src/test/java/games/dmg/spigotheights/HeightMathTest.java b/src/test/java/games/dmg/spigotheights/HeightMathTest.java index fd203a0..747e279 100644 --- a/src/test/java/games/dmg/spigotheights/HeightMathTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightMathTest.java @@ -20,6 +20,20 @@ class HeightMathTest { assertEquals(1.1, HeightMath.grow(1.0, SETTINGS), 0.000001); } + @Test + void savedDefaultSizeSurvivesRangesThatExcludeIt() { + for (HeightSettings settings : new HeightSettings[] { + new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20), + new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) { + assertEquals(1.0, HeightMath.safeStoredScale(1.0, settings)); + assertEquals(settings.minimum(), HeightMath.safeStoredScale(0.1, settings)); + assertEquals(settings.maximum(), HeightMath.safeStoredScale(3.0, settings)); + double fallback = HeightMath.clamp(1.0, settings); + assertEquals(fallback, HeightMath.safeStoredScale(null, settings)); + assertEquals(fallback, HeightMath.safeStoredScale(Double.NaN, settings)); + } + } + @Test void missingAndStoredValuesAreSafelyClamped() { assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001); diff --git a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java index 52eb9dd..13bad4c 100644 --- a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java @@ -12,6 +12,19 @@ class HeightStoreTest { @TempDir Path temporaryDirectory; + @Test + void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception { + UUID playerId = UUID.randomUUID(); + HeightSettings settings = new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20); + HeightStore store = new HeightStore(temporaryDirectory.toFile()); + store.save(playerId, 0.4); + store.save(playerId, StaturePotion.RESTORATION.scaleAfterDrinking(0.4, settings, bound -> 0)); + + HeightStore reloaded = new HeightStore(temporaryDirectory.toFile()); + assertEquals(1.0, reloaded.find(playerId)); + assertEquals(1.0, HeightMath.safeStoredScale(reloaded.find(playerId), settings)); + } + @Test void storesByUuidAndPreservesUnknownYamlFields() throws Exception { UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java index b9cbf97..f6646b8 100644 --- a/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java +++ b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java @@ -2,6 +2,7 @@ package games.dmg.spigotheights; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; @@ -13,6 +14,17 @@ class PotionRecipesTest { assertArrayEquals(new String[] {"GAG", "ASA", "GFG"}, PotionRecipes.DIMINUTION_SHAPE); } + @Test + void restorationUsesApprovedShapeAndDistinctIdentity() { + assertArrayEquals(new String[] {"GAG", "ASA", "GUG"}, PotionRecipes.RESTORATION_SHAPE); + assertEquals("RESTORATION", StaturePotion.RESTORATION.name()); + for (StaturePotion kind : StaturePotion.values()) { + if (kind != StaturePotion.RESTORATION) { + assertNotEquals(kind.name(), StaturePotion.RESTORATION.name()); + } + } + } + @Test void everyPotionHasASeparatePersistentIdentity() { assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name()); diff --git a/src/test/java/games/dmg/spigotheights/StaturePotionTest.java b/src/test/java/games/dmg/spigotheights/StaturePotionTest.java new file mode 100644 index 0000000..86376ae --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/StaturePotionTest.java @@ -0,0 +1,32 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class StaturePotionTest { + @Test + void existingPotionsKeepTheirScaleRules() { + HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20); + assertEquals(0.4, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> 0)); + assertEquals(2.0, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> bound - 1)); + assertEquals(1.1, StaturePotion.GROWTH.scaleAfterDrinking(1.0, settings, bound -> 0)); + assertEquals(0.9, StaturePotion.DIMINUTION.scaleAfterDrinking(1.0, settings, bound -> 0)); + assertEquals(2.0, StaturePotion.GROWTH.scaleAfterDrinking(2.0, settings, bound -> 0)); + assertEquals(0.4, StaturePotion.DIMINUTION.scaleAfterDrinking(0.4, settings, bound -> 0)); + } + + @Test + void restorationAlwaysReturnsNormalSizeWithoutRandomness() { + for (HeightSettings settings : new HeightSettings[] { + new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20), + new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20), + new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) { + for (double current : new double[] {0.2, 1.0, 2.0}) { + assertEquals(1.0, StaturePotion.RESTORATION.scaleAfterDrinking(current, settings, + bound -> { throw new AssertionError("Restoration must not roll a random size"); })); + } + } + assertEquals("Potion of Restoration", StaturePotion.RESTORATION.displayName()); + } +}