feat(potions): add restoration potion for default player size
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting Spigot Heights
|
|||||||
|
|
||||||
# Spigot Heights Design Log
|
# 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
|
## 2026-09-04 — Initial design approved
|
||||||
|
|
||||||
- Player scale defaults to a configurable range of `0.4` through `2.0`.
|
- Player scale defaults to a configurable range of `0.4` through `2.0`.
|
||||||
|
|||||||
@@ -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)
|
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)
|
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)
|
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)
|
||||||
|
|||||||
@@ -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] Player scales are stored by UUID using atomic file replacement where supported.
|
||||||
- [x] Updating known state preserves unknown forward-compatible YAML fields.
|
- [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] 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.
|
- [x] Configuration and state behavior have automated tests.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [User-story catalog](index.md)
|
- [User-story catalog](index.md)
|
||||||
|
- [US-006: Restore default stature](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)
|
||||||
@@ -27,7 +27,8 @@ public final class HeightMath {
|
|||||||
if (stored == null || !Double.isFinite(stored)) {
|
if (stored == null || !Double.isFinite(stored)) {
|
||||||
return clamp(1.0, settings);
|
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) {
|
public static double clamp(double value, HeightSettings settings) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public final class PotionRecipes {
|
|||||||
static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"};
|
static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"};
|
||||||
static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"};
|
static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"};
|
||||||
static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"};
|
static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"};
|
||||||
|
static final String[] RESTORATION_SHAPE = {"GAG", "ASA", "GUG"};
|
||||||
|
|
||||||
private final JavaPlugin plugin;
|
private final JavaPlugin plugin;
|
||||||
private final NamespacedKey potionKindKey;
|
private final NamespacedKey potionKindKey;
|
||||||
@@ -30,6 +31,7 @@ public final class PotionRecipes {
|
|||||||
registerShifting();
|
registerShifting();
|
||||||
registerGrowth();
|
registerGrowth();
|
||||||
registerDiminution();
|
registerDiminution();
|
||||||
|
registerRestoration();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ItemStack create(StaturePotion kind) {
|
public ItemStack create(StaturePotion kind) {
|
||||||
@@ -96,11 +98,23 @@ public final class PotionRecipes {
|
|||||||
plugin.getServer().addRecipe(recipe);
|
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) {
|
private static Color color(StaturePotion kind) {
|
||||||
return switch (kind) {
|
return switch (kind) {
|
||||||
case SHIFTING -> Color.PURPLE;
|
case SHIFTING -> Color.PURPLE;
|
||||||
case GROWTH -> Color.LIME;
|
case GROWTH -> Color.LIME;
|
||||||
case DIMINUTION -> Color.FUCHSIA;
|
case DIMINUTION -> Color.FUCHSIA;
|
||||||
|
case RESTORATION -> Color.WHITE;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,8 @@ public final class StatureListener implements Listener {
|
|||||||
}
|
}
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
double current = currentScale(player);
|
double current = currentScale(player);
|
||||||
double scale = switch (kind) {
|
double scale = kind.scaleAfterDrinking(current, settings,
|
||||||
case SHIFTING -> HeightMath.randomScale(settings,
|
|
||||||
bound -> ThreadLocalRandom.current().nextInt(bound));
|
bound -> ThreadLocalRandom.current().nextInt(bound));
|
||||||
case GROWTH -> HeightMath.grow(current, settings);
|
|
||||||
case DIMINUTION -> HeightMath.shrink(current, settings);
|
|
||||||
};
|
|
||||||
applyAndSave(player, scale);
|
applyAndSave(player, scale);
|
||||||
player.sendMessage("Your scale is now " + scale + ".");
|
player.sendMessage("Your scale is now " + scale + ".");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package games.dmg.spigotheights;
|
package games.dmg.spigotheights;
|
||||||
|
|
||||||
|
import java.util.function.IntUnaryOperator;
|
||||||
|
|
||||||
public enum StaturePotion {
|
public enum StaturePotion {
|
||||||
SHIFTING("Potion of Shifting Stature"),
|
SHIFTING("Potion of Shifting Stature"),
|
||||||
GROWTH("Potion of Growth"),
|
GROWTH("Potion of Growth"),
|
||||||
DIMINUTION("Potion of Diminution");
|
DIMINUTION("Potion of Diminution"),
|
||||||
|
RESTORATION("Potion of Restoration");
|
||||||
|
|
||||||
private final String displayName;
|
private final String displayName;
|
||||||
|
|
||||||
@@ -11,6 +14,15 @@ public enum StaturePotion {
|
|||||||
this.displayName = displayName;
|
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() {
|
public String displayName() {
|
||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ class HeightMathTest {
|
|||||||
assertEquals(1.1, HeightMath.grow(1.0, SETTINGS), 0.000001);
|
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
|
@Test
|
||||||
void missingAndStoredValuesAreSafelyClamped() {
|
void missingAndStoredValuesAreSafelyClamped() {
|
||||||
assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001);
|
assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001);
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ class HeightStoreTest {
|
|||||||
@TempDir
|
@TempDir
|
||||||
Path temporaryDirectory;
|
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
|
@Test
|
||||||
void storesByUuidAndPreservesUnknownYamlFields() throws Exception {
|
void storesByUuidAndPreservesUnknownYamlFields() throws Exception {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package games.dmg.spigotheights;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -13,6 +14,17 @@ class PotionRecipesTest {
|
|||||||
assertArrayEquals(new String[] {"GAG", "ASA", "GFG"}, PotionRecipes.DIMINUTION_SHAPE);
|
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
|
@Test
|
||||||
void everyPotionHasASeparatePersistentIdentity() {
|
void everyPotionHasASeparatePersistentIdentity() {
|
||||||
assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name());
|
assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user