diff --git a/.gitignore b/.gitignore
index 316d19b..53ef314 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,5 @@ ChangeLog.txt
target/
repo/
out/
-META-INF/
\ No newline at end of file
+META-INF/
+build.log
diff --git a/AGENTS.md b/AGENTS.md
index 3635121..1319f55 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,16 @@ This file is the project's committed home for project-intrinsic agent knowledge:
- Build with JDK 25 or newer - `maven-enforcer-plugin` (`requireJavaVersion [25,)`) fails `validate` on anything older. Emitted plugin bytecode is intentionally Java 8 (major version 52) via `8` in `pom.xml`; the target runtime is a Java 17+ Spigot server. Do not "modernize" the bytecode level. Build/verify: `JAVA_HOME= mvn -B clean verify`.
- `de.tr7zw:functional-annotations` is pinned to `0.1-SNAPSHOT` because no released version has ever been published (CodeMC metadata lists only the SNAPSHOT). It is a real compile dependency (`de.tr7zw.annotations.FAUtil`, used by the `nbt` package), so it cannot be dropped. Revisit if upstream ever cuts a release.
- Test harness: JUnit 5 + MockBukkit + Mockito + JaCoCo. `mvn verify` runs tests and produces `target/site/jacoco/` coverage report. JaCoCo excludes `**/nbt/**` (vendored) and `**/ArmorPlus.class` (bootstrap).
-- Coverage gate (CORE set): JaCoCo `check-core` execution enforces 100% line AND branch on these pure-logic classes: `PlayerDirectionUtil`, `ColorUtil`, `Piece`, `SetType`, `SetDataType`. Build fails if core coverage drops below 100%. Everything else remains report-only. Excluded from core: `LogUtil` (calls `ArmorPlus.get()` singleton), `nbt.NBTType` (vendored `**/nbt/**` exclusion). When modifying a core class, add tests in the same commit.
+- Coverage gate (CORE set): JaCoCo `check-core` execution enforces 100% line AND branch on these pure-logic classes: `PlayerDirectionUtil`, `ColorUtil`, `Piece`, `SetType`, `SetDataType`, `ArmorHandItem`, `model.id.TypedString`, `model.id.StringId`. Build fails if core coverage drops below 100%. Everything else remains report-only. Excluded from core: `LogUtil` (calls `ArmorPlus.get()` singleton), `nbt.NBTType` (vendored `**/nbt/**` exclusion). When modifying a core class, add tests in the same commit.
- JDK/bytecode split: main code compiles to Java 8 (`maven.compiler.release=8`), test code compiles to Java 17 (`maven.compiler.testRelease=17`). The `testRelease` property is picked up automatically by maven-compiler-plugin's default-testCompile execution - no custom execution block needed. Both compile under the same JDK 25 build. MockBukkit + paper-api are Java 17 bytecode, so tests cannot target 8.
- MockBukkit limitation: the plugin cannot be loaded via `MockBukkit.load(ArmorPlus.class)` because the vendored NBT-API performs reflection at enable time that MockBukkit does not model. Use pure-logic unit tests or Mockito for testing pieces that interact with Bukkit APIs.
- Mockito on JDK 25: surefire must pass `-Dnet.bytebuddy.experimental=true` (configured in pom.xml ``). Without it, ByteBuddy fails to recognize the JDK 25 class file version.
- Characterization test safety net (Phase 4A): `src/test/java/gg/steve/mc/ap/{data,player,armor,message}` contains characterization tests pinning current behavior of damage calc (`HandSetData.calculateFinalDamage`, `BasicSetData.onHit/onDamage`), wearer tracking (`SetPlayerManager`), warp safety (`WarpUtil.isSafe`), XP multiplier (`ExperienceSetData.onTargetDeath`), potion checks (`SetStatusEffectsManager.potionCheck`), set detection (`Set.isWearingSet/verifyPiece`), and messaging (`MessageType/CommandDebug`). Rearch phases 4B+ MUST keep these tests green - they prove behavior is preserved during extraction. Do NOT "fix" surprising behavior these tests pin; document it and defer to a separate bug-fix PR.
- Integration-only (not netted): `Set.isWearingSet` full integration path (NBT reflection in real server), `SetPlayerManager.init()` (iterates Bukkit.getOnlinePlayers), GUI rendering, PAPI expansion, scheduler-dependent abilities (Fairy/ColorWay/Lightning/Engineer/Traveller tick logic).
+- Domain model package: `gg.steve.mc.ap.model` with sub-packages `set`, `ability`, `combat`, `effect`, `notification`, `player`, `id`. Contains pure-Java value types with ZERO Bukkit/NMS/NBT imports. Uses Lombok (`@Value`, `@Builder`) for boilerplate elimination. The `lombok.config` at repo root sets `lombok.addLombokGeneratedAnnotation=true` so JaCoCo auto-excludes generated code.
+- Domain model conventions: No class may use the `Manager` suffix (use Registry, Service, Store, Factory, Source, Resolver, etc.). Use the typed ID wrappers in model.id (PlayerId, ArmorSetId, DamageCauseId, PotionEffectId, SoundId) as identity currency - never reference Bukkit Player/ItemStack/World in the model layer. ID wrappers extend the `TypedString`/`StringId` base hierarchy: `TypedString` (abstract) rejects null via commons-lang3 `Validate.notNull` in its constructor and implements `equals`/`hashCode` with `getClass()` checks (different subtypes wrapping the same string are never equal); `StringId` extends it adding `Validate.notEmpty`. Leaf classes are final with a private constructor and an `of()` factory; `PlayerId` extends `TypedString` directly, wrapping a `UUID`. Use `@Value` + `@Builder` for multi-field value types. Annotate `List`/`Set`/`Map` fields with `@Singular` so Lombok's builder produces genuinely unmodifiable defensive copies at `build()` time.
+- Lombok dependency: `org.projectlombok:lombok:1.18.38` (provided scope). Annotation processor configured in maven-compiler-plugin's ``. Works with JDK 25 and emits Java 8 bytecode.
+- No-Bukkit-in-model rule: verified by grep (no ArchUnit yet). The `model` package must have zero imports of `org.bukkit.*`, `net.minecraft.*`, `de.tr7zw.*`, or any plugin class outside `model`.
## Maintaining this file
diff --git a/README.md b/README.md
index ac93a9d..a399c3b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ For more information about the plugin, permissions, and commands please refer to
## Code Coverage
JaCoCo runs on every CI build (`mvn verify`).
-The build enforces 100% line and branch coverage on the pure-logic core classes (`PlayerDirectionUtil`, `ColorUtil`, `Piece`, `SetType`, `SetDataType`); a coverage drop on any of these will fail the build.
+The build enforces 100% line and branch coverage on the pure-logic core classes (`PlayerDirectionUtil`, `ColorUtil`, `Piece`, `SetType`, `SetDataType`, `ArmorHandItem`, `TypedString`, `StringId`); a coverage drop on any of these will fail the build.
The HTML coverage report is uploaded as the **jacoco-report** artifact on each workflow run - download it from the [Actions tab](https://github.com/nbdSteve/ArmorPlus/actions/workflows/ci.yml).
## Soft Dependencies
diff --git a/lombok.config b/lombok.config
new file mode 100644
index 0000000..df71bb6
--- /dev/null
+++ b/lombok.config
@@ -0,0 +1,2 @@
+config.stopBubbling = true
+lombok.addLombokGeneratedAnnotation = true
diff --git a/pom.xml b/pom.xml
index 905db77..3bd700a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,6 +35,13 @@
3.14.0
${maven.compiler.release}
+
+
+ org.projectlombok
+ lombok
+ 1.18.38
+
+
@@ -107,6 +114,9 @@
gg.steve.mc.ap.armor.Piece
gg.steve.mc.ap.armor.SetType
gg.steve.mc.ap.data.SetDataType
+ gg.steve.mc.ap.model.ability.ArmorHandItem
+ gg.steve.mc.ap.model.id.TypedString
+ gg.steve.mc.ap.model.id.StringId
@@ -137,6 +147,10 @@
gg.steve.mc.ap
+
+ org.apache.commons.lang3
+ gg.steve.mc.ap.lib.commons.lang3
+
@@ -229,12 +243,24 @@
1.7
compile
+
+ org.apache.commons
+ commons-lang3
+ 3.17.0
+ compile
+
com.mojang
authlib
1.5.21
provided
+
+ org.projectlombok
+ lombok
+ 1.18.38
+ provided
+
org.junit.jupiter
diff --git a/src/main/java/gg/steve/mc/ap/model/ability/ArmorHandItem.java b/src/main/java/gg/steve/mc/ap/model/ability/ArmorHandItem.java
new file mode 100644
index 0000000..b103012
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/ability/ArmorHandItem.java
@@ -0,0 +1,25 @@
+package gg.steve.mc.ap.model.ability;
+
+import gg.steve.mc.ap.model.id.DamageCauseId;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class ArmorHandItem {
+ double increase;
+ boolean requireSet;
+ DamageCauseId damageCause;
+
+ /**
+ * Pure damage calculation mirroring HandSetData.calculateFinalDamage.
+ * When setIncrease is -1, the set bonus is not applied.
+ */
+ public double calculateFinalDamage(double damage, double setIncrease) {
+ if (setIncrease != -1) {
+ double set = setIncrease - 1;
+ return damage * (set + this.increase);
+ }
+ return damage * this.increase;
+ }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityType.java b/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityType.java
new file mode 100644
index 0000000..62ef7e2
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityType.java
@@ -0,0 +1,16 @@
+package gg.steve.mc.ap.model.ability;
+
+public enum ArmorSetAbilityType {
+ BASIC,
+ LIGHTNING,
+ WARP,
+ POTION,
+ FALL,
+ HUNGER,
+ TRAVELLER,
+ STUN,
+ HAND,
+ ENGINEER,
+ COLOR_WAY,
+ EXPERIENCE;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStats.java b/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStats.java
new file mode 100644
index 0000000..2b658ad
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStats.java
@@ -0,0 +1,17 @@
+package gg.steve.mc.ap.model.ability;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class ArmorSetBasicStats {
+ double increase;
+ double reduction;
+ double knockback;
+ double health;
+ float walkSpeed;
+ float walkSpeedDefault;
+ float flySpeed;
+ float flySpeedDefault;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageContext.java b/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageContext.java
new file mode 100644
index 0000000..2415276
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageContext.java
@@ -0,0 +1,16 @@
+package gg.steve.mc.ap.model.combat;
+
+import gg.steve.mc.ap.model.id.DamageCauseId;
+import gg.steve.mc.ap.model.id.PlayerId;
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class CombatDamageContext {
+ PlayerId attacker;
+ PlayerId target;
+ double baseDamage;
+ DamageCauseId cause;
+ boolean projectile;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageModification.java b/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageModification.java
new file mode 100644
index 0000000..bae2ecd
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/combat/CombatDamageModification.java
@@ -0,0 +1,14 @@
+package gg.steve.mc.ap.model.combat;
+
+import lombok.Value;
+
+/**
+ * Represents a modification to damage and knockback.
+ * A null CombatDamageModification at call sites means "no change" - callers should use
+ * Optional to represent the absence of modification.
+ */
+@Value
+public class CombatDamageModification {
+ double newDamage;
+ double knockbackMultiplier;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/effect/NotificationSound.java b/src/main/java/gg/steve/mc/ap/model/effect/NotificationSound.java
new file mode 100644
index 0000000..779784e
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/effect/NotificationSound.java
@@ -0,0 +1,11 @@
+package gg.steve.mc.ap.model.effect;
+
+import gg.steve.mc.ap.model.id.SoundId;
+import lombok.Value;
+
+@Value
+public class NotificationSound {
+ SoundId name;
+ float volume;
+ float pitch;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/effect/PotionEffect.java b/src/main/java/gg/steve/mc/ap/model/effect/PotionEffect.java
new file mode 100644
index 0000000..30eb24e
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/effect/PotionEffect.java
@@ -0,0 +1,11 @@
+package gg.steve.mc.ap.model.effect;
+
+import gg.steve.mc.ap.model.id.PotionEffectId;
+import lombok.Value;
+
+@Value
+public class PotionEffect {
+ PotionEffectId type;
+ int duration;
+ int amplifier;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/ArmorSetId.java b/src/main/java/gg/steve/mc/ap/model/id/ArmorSetId.java
new file mode 100644
index 0000000..f23f913
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/ArmorSetId.java
@@ -0,0 +1,6 @@
+package gg.steve.mc.ap.model.id;
+
+public final class ArmorSetId extends StringId {
+ private ArmorSetId(String value) { super(value); }
+ public static ArmorSetId of(String value) { return new ArmorSetId(value); }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/DamageCauseId.java b/src/main/java/gg/steve/mc/ap/model/id/DamageCauseId.java
new file mode 100644
index 0000000..0c243b4
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/DamageCauseId.java
@@ -0,0 +1,6 @@
+package gg.steve.mc.ap.model.id;
+
+public final class DamageCauseId extends StringId {
+ private DamageCauseId(String value) { super(value); }
+ public static DamageCauseId of(String value) { return new DamageCauseId(value); }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/PlayerId.java b/src/main/java/gg/steve/mc/ap/model/id/PlayerId.java
new file mode 100644
index 0000000..a1f9732
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/PlayerId.java
@@ -0,0 +1,19 @@
+package gg.steve.mc.ap.model.id;
+
+import java.util.UUID;
+
+public final class PlayerId extends TypedString {
+ private final UUID uuid;
+
+ private PlayerId(UUID value) {
+ super(value.toString());
+ this.uuid = value;
+ }
+
+ public UUID getValue() { return uuid; }
+
+ public static PlayerId of(UUID value) {
+ if (value == null) throw new NullPointerException("value must not be null");
+ return new PlayerId(value);
+ }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/PotionEffectId.java b/src/main/java/gg/steve/mc/ap/model/id/PotionEffectId.java
new file mode 100644
index 0000000..be170c5
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/PotionEffectId.java
@@ -0,0 +1,6 @@
+package gg.steve.mc.ap.model.id;
+
+public final class PotionEffectId extends StringId {
+ private PotionEffectId(String value) { super(value); }
+ public static PotionEffectId of(String value) { return new PotionEffectId(value); }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/SoundId.java b/src/main/java/gg/steve/mc/ap/model/id/SoundId.java
new file mode 100644
index 0000000..874e262
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/SoundId.java
@@ -0,0 +1,6 @@
+package gg.steve.mc.ap.model.id;
+
+public final class SoundId extends StringId {
+ private SoundId(String value) { super(value); }
+ public static SoundId of(String value) { return new SoundId(value); }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/StringId.java b/src/main/java/gg/steve/mc/ap/model/id/StringId.java
new file mode 100644
index 0000000..b550245
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/StringId.java
@@ -0,0 +1,10 @@
+package gg.steve.mc.ap.model.id;
+
+import org.apache.commons.lang3.Validate;
+
+public abstract class StringId extends TypedString {
+ protected StringId(String id) {
+ super(id);
+ Validate.notEmpty(id);
+ }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/id/TypedString.java b/src/main/java/gg/steve/mc/ap/model/id/TypedString.java
new file mode 100644
index 0000000..fb5dbed
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/id/TypedString.java
@@ -0,0 +1,40 @@
+package gg.steve.mc.ap.model.id;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+public abstract class TypedString {
+ private final String id;
+
+ protected TypedString(String id) {
+ this.id = Objects.requireNonNull(id, "id must not be null");
+ }
+
+ @Override
+ public int hashCode() {
+ return new HashCodeBuilder().append(id).toHashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+ if (obj == this) return true;
+ if (obj.getClass() != getClass()) return false;
+ TypedString other = (TypedString) obj;
+ return new EqualsBuilder().append(id, other.id).isEquals();
+ }
+
+ @Override
+ public String toString() {
+ return id;
+ }
+
+ public static List toStrings(Collection extends TypedString> typedStrings) {
+ return typedStrings.stream().map(TypedString::toString).collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/notification/ArmorSetNotification.java b/src/main/java/gg/steve/mc/ap/model/notification/ArmorSetNotification.java
new file mode 100644
index 0000000..427cb81
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/notification/ArmorSetNotification.java
@@ -0,0 +1,16 @@
+package gg.steve.mc.ap.model.notification;
+
+import gg.steve.mc.ap.model.effect.NotificationSound;
+import lombok.Builder;
+import lombok.Singular;
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+@Builder
+public class ArmorSetNotification {
+ @Singular List messages;
+ NotificationSound sound;
+ @Singular List commands;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/player/ArmorSetWearer.java b/src/main/java/gg/steve/mc/ap/model/player/ArmorSetWearer.java
new file mode 100644
index 0000000..b8276b8
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/player/ArmorSetWearer.java
@@ -0,0 +1,11 @@
+package gg.steve.mc.ap.model.player;
+
+import gg.steve.mc.ap.model.id.ArmorSetId;
+import gg.steve.mc.ap.model.id.PlayerId;
+import lombok.Value;
+
+@Value
+public class ArmorSetWearer {
+ PlayerId playerId;
+ ArmorSetId setId;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/set/ArmorPieceSlot.java b/src/main/java/gg/steve/mc/ap/model/set/ArmorPieceSlot.java
new file mode 100644
index 0000000..e610647
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/set/ArmorPieceSlot.java
@@ -0,0 +1,9 @@
+package gg.steve.mc.ap.model.set;
+
+public enum ArmorPieceSlot {
+ HELMET,
+ CHESTPLATE,
+ LEGGINGS,
+ BOOTS,
+ HAND;
+}
diff --git a/src/main/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResult.java b/src/main/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResult.java
new file mode 100644
index 0000000..b04af51
--- /dev/null
+++ b/src/main/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResult.java
@@ -0,0 +1,7 @@
+package gg.steve.mc.ap.model.set;
+
+public enum ArmorSetPurchaseResult {
+ SUCCESS,
+ INSUFFICIENT_FUNDS,
+ NO_PERMISSION;
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/ability/ArmorHandItemTest.java b/src/test/java/gg/steve/mc/ap/model/ability/ArmorHandItemTest.java
new file mode 100644
index 0000000..f38fc93
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/ability/ArmorHandItemTest.java
@@ -0,0 +1,119 @@
+package gg.steve.mc.ap.model.ability;
+
+import gg.steve.mc.ap.model.id.DamageCauseId;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorHandItemTest {
+
+ private static final DamageCauseId ENTITY_ATTACK = DamageCauseId.of("ENTITY_ATTACK");
+ private static final DamageCauseId PROJECTILE = DamageCauseId.of("PROJECTILE");
+
+ @Test
+ void builderCreatesExpectedValues() {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(2.5)
+ .requireSet(true)
+ .damageCause(ENTITY_ATTACK)
+ .build();
+
+ assertEquals(2.5, item.getIncrease());
+ assertTrue(item.isRequireSet());
+ assertEquals(ENTITY_ATTACK, item.getDamageCause());
+ }
+
+ @Test
+ void calculateFinalDamage_withSetIncrease_combinesBothMultipliers() {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(1.5)
+ .requireSet(false)
+ .damageCause(ENTITY_ATTACK)
+ .build();
+
+ assertEquals(25.0, item.calculateFinalDamage(10.0, 2.0), 0.0001);
+ }
+
+ @Test
+ void calculateFinalDamage_withoutSetIncrease_usesHandMultiplierOnly() {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(1.5)
+ .requireSet(false)
+ .damageCause(ENTITY_ATTACK)
+ .build();
+
+ assertEquals(15.0, item.calculateFinalDamage(10.0, -1), 0.0001);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "10.0, 1.5, 3.0, 35.0",
+ "5.0, 2.0, 1.0, 10.0",
+ "8.0, 1.0, 2.0, 16.0",
+ "0.0, 1.5, 2.0, 0.0",
+ })
+ void calculateFinalDamage_withSetIncrease_parametrized(
+ double damage, double increase, double setIncrease, double expected) {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(increase)
+ .requireSet(false)
+ .damageCause(ENTITY_ATTACK)
+ .build();
+
+ assertEquals(expected, item.calculateFinalDamage(damage, setIncrease), 0.0001);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "10.0, 1.5, 15.0",
+ "5.0, 2.0, 10.0",
+ "0.0, 3.0, 0.0",
+ "8.0, 0.5, 4.0",
+ })
+ void calculateFinalDamage_withoutSetIncrease_parametrized(
+ double damage, double increase, double expected) {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(increase)
+ .requireSet(false)
+ .damageCause(ENTITY_ATTACK)
+ .build();
+
+ assertEquals(expected, item.calculateFinalDamage(damage, -1), 0.0001);
+ }
+
+ @Test
+ void calculateFinalDamage_setIncreaseOfOne_yieldsHandMultiplierOnly() {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(2.0)
+ .requireSet(true)
+ .damageCause(PROJECTILE)
+ .build();
+
+ assertEquals(20.0, item.calculateFinalDamage(10.0, 1.0), 0.0001);
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ ArmorHandItem a = ArmorHandItem.builder()
+ .increase(1.5).requireSet(true).damageCause(ENTITY_ATTACK).build();
+ ArmorHandItem b = ArmorHandItem.builder()
+ .increase(1.5).requireSet(true).damageCause(ENTITY_ATTACK).build();
+ ArmorHandItem c = ArmorHandItem.builder()
+ .increase(2.0).requireSet(false).damageCause(PROJECTILE).build();
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+
+ @Test
+ void toStringContainsFields() {
+ ArmorHandItem item = ArmorHandItem.builder()
+ .increase(1.5).requireSet(true).damageCause(ENTITY_ATTACK).build();
+ String s = item.toString();
+ assertTrue(s.contains("1.5"));
+ assertTrue(s.contains("ENTITY_ATTACK"));
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityTypeTest.java b/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityTypeTest.java
new file mode 100644
index 0000000..d508a05
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetAbilityTypeTest.java
@@ -0,0 +1,21 @@
+package gg.steve.mc.ap.model.ability;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetAbilityTypeTest {
+
+ @Test
+ void allValuesPresent() {
+ ArmorSetAbilityType[] values = ArmorSetAbilityType.values();
+ assertEquals(12, values.length);
+ }
+
+ @Test
+ void valueOfRoundTrips() {
+ for (ArmorSetAbilityType type : ArmorSetAbilityType.values()) {
+ assertEquals(type, ArmorSetAbilityType.valueOf(type.name()));
+ }
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStatsTest.java b/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStatsTest.java
new file mode 100644
index 0000000..aa97410
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/ability/ArmorSetBasicStatsTest.java
@@ -0,0 +1,57 @@
+package gg.steve.mc.ap.model.ability;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetBasicStatsTest {
+
+ @Test
+ void builderCreatesExpectedValues() {
+ ArmorSetBasicStats stats = ArmorSetBasicStats.builder()
+ .increase(1.5)
+ .reduction(0.8)
+ .knockback(0.3)
+ .health(30.0)
+ .walkSpeed(0.25f)
+ .walkSpeedDefault(0.2f)
+ .flySpeed(0.15f)
+ .flySpeedDefault(0.1f)
+ .build();
+
+ assertEquals(1.5, stats.getIncrease());
+ assertEquals(0.8, stats.getReduction());
+ assertEquals(0.3, stats.getKnockback());
+ assertEquals(30.0, stats.getHealth());
+ assertEquals(0.25f, stats.getWalkSpeed());
+ assertEquals(0.2f, stats.getWalkSpeedDefault());
+ assertEquals(0.15f, stats.getFlySpeed());
+ assertEquals(0.1f, stats.getFlySpeedDefault());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ ArmorSetBasicStats a = ArmorSetBasicStats.builder()
+ .increase(1.5).reduction(0.8).knockback(0.3).health(30.0)
+ .walkSpeed(0.25f).walkSpeedDefault(0.2f)
+ .flySpeed(0.15f).flySpeedDefault(0.1f).build();
+ ArmorSetBasicStats b = ArmorSetBasicStats.builder()
+ .increase(1.5).reduction(0.8).knockback(0.3).health(30.0)
+ .walkSpeed(0.25f).walkSpeedDefault(0.2f)
+ .flySpeed(0.15f).flySpeedDefault(0.1f).build();
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+
+ @Test
+ void disabledValuesUseNegativeOne() {
+ ArmorSetBasicStats stats = ArmorSetBasicStats.builder()
+ .increase(-1).reduction(-1).knockback(-1).health(-1)
+ .walkSpeed(-1f).walkSpeedDefault(-1f)
+ .flySpeed(-1f).flySpeedDefault(-1f).build();
+
+ assertEquals(-1, stats.getIncrease());
+ assertEquals(-1, stats.getReduction());
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageContextTest.java b/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageContextTest.java
new file mode 100644
index 0000000..14ae6bf
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageContextTest.java
@@ -0,0 +1,50 @@
+package gg.steve.mc.ap.model.combat;
+
+import gg.steve.mc.ap.model.id.DamageCauseId;
+import gg.steve.mc.ap.model.id.PlayerId;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class CombatDamageContextTest {
+
+ @Test
+ void builderCreatesExpectedValues() {
+ PlayerId attacker = PlayerId.of(UUID.randomUUID());
+ PlayerId target = PlayerId.of(UUID.randomUUID());
+ DamageCauseId cause = DamageCauseId.of("ENTITY_ATTACK");
+
+ CombatDamageContext ctx = CombatDamageContext.builder()
+ .attacker(attacker)
+ .target(target)
+ .baseDamage(10.0)
+ .cause(cause)
+ .projectile(false)
+ .build();
+
+ assertEquals(attacker, ctx.getAttacker());
+ assertEquals(target, ctx.getTarget());
+ assertEquals(10.0, ctx.getBaseDamage());
+ assertEquals(cause, ctx.getCause());
+ assertFalse(ctx.isProjectile());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ PlayerId attacker = PlayerId.of(UUID.fromString("00000000-0000-0000-0000-000000000001"));
+ PlayerId target = PlayerId.of(UUID.fromString("00000000-0000-0000-0000-000000000002"));
+ DamageCauseId cause = DamageCauseId.of("PROJECTILE");
+
+ CombatDamageContext a = CombatDamageContext.builder()
+ .attacker(attacker).target(target)
+ .baseDamage(5.0).cause(cause).projectile(true).build();
+ CombatDamageContext b = CombatDamageContext.builder()
+ .attacker(attacker).target(target)
+ .baseDamage(5.0).cause(cause).projectile(true).build();
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageModificationTest.java b/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageModificationTest.java
new file mode 100644
index 0000000..0dd866d
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/combat/CombatDamageModificationTest.java
@@ -0,0 +1,26 @@
+package gg.steve.mc.ap.model.combat;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class CombatDamageModificationTest {
+
+ @Test
+ void constructsWithExpectedValues() {
+ CombatDamageModification mod = new CombatDamageModification(15.0, 0.5);
+ assertEquals(15.0, mod.getNewDamage());
+ assertEquals(0.5, mod.getKnockbackMultiplier());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ CombatDamageModification a = new CombatDamageModification(10.0, 1.0);
+ CombatDamageModification b = new CombatDamageModification(10.0, 1.0);
+ CombatDamageModification c = new CombatDamageModification(10.0, 2.0);
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/effect/NotificationSoundTest.java b/src/test/java/gg/steve/mc/ap/model/effect/NotificationSoundTest.java
new file mode 100644
index 0000000..0eaa139
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/effect/NotificationSoundTest.java
@@ -0,0 +1,28 @@
+package gg.steve.mc.ap.model.effect;
+
+import gg.steve.mc.ap.model.id.SoundId;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NotificationSoundTest {
+
+ @Test
+ void constructsWithExpectedValues() {
+ NotificationSound sound = new NotificationSound(SoundId.of("ENTITY_PLAYER_LEVELUP"), 1.0f, 0.5f);
+ assertEquals(SoundId.of("ENTITY_PLAYER_LEVELUP"), sound.getName());
+ assertEquals(1.0f, sound.getVolume());
+ assertEquals(0.5f, sound.getPitch());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ NotificationSound a = new NotificationSound(SoundId.of("DING"), 1.0f, 1.0f);
+ NotificationSound b = new NotificationSound(SoundId.of("DING"), 1.0f, 1.0f);
+ NotificationSound c = new NotificationSound(SoundId.of("BOOM"), 0.5f, 2.0f);
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/effect/PotionEffectTest.java b/src/test/java/gg/steve/mc/ap/model/effect/PotionEffectTest.java
new file mode 100644
index 0000000..0afe6bc
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/effect/PotionEffectTest.java
@@ -0,0 +1,28 @@
+package gg.steve.mc.ap.model.effect;
+
+import gg.steve.mc.ap.model.id.PotionEffectId;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PotionEffectTest {
+
+ @Test
+ void constructsWithExpectedValues() {
+ PotionEffect effect = new PotionEffect(PotionEffectId.of("SPEED"), 200, 1);
+ assertEquals(PotionEffectId.of("SPEED"), effect.getType());
+ assertEquals(200, effect.getDuration());
+ assertEquals(1, effect.getAmplifier());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ PotionEffect a = new PotionEffect(PotionEffectId.of("SPEED"), 200, 1);
+ PotionEffect b = new PotionEffect(PotionEffectId.of("SPEED"), 200, 1);
+ PotionEffect c = new PotionEffect(PotionEffectId.of("JUMP"), 100, 2);
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/ArmorSetIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/ArmorSetIdTest.java
new file mode 100644
index 0000000..ec55c95
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/ArmorSetIdTest.java
@@ -0,0 +1,35 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetIdTest {
+
+ @Test
+ void ofCreatesExpectedValue() {
+ ArmorSetId id = ArmorSetId.of("dragon");
+ assertEquals("dragon", id.toString());
+ }
+
+ @Test
+ void ofNullThrows() {
+ assertThrows(NullPointerException.class, () -> ArmorSetId.of(null));
+ }
+
+ @Test
+ void ofEmptyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> ArmorSetId.of(""));
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ ArmorSetId a = ArmorSetId.of("dragon");
+ ArmorSetId b = ArmorSetId.of("dragon");
+ ArmorSetId c = ArmorSetId.of("knight");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/DamageCauseIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/DamageCauseIdTest.java
new file mode 100644
index 0000000..ab19d18
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/DamageCauseIdTest.java
@@ -0,0 +1,35 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DamageCauseIdTest {
+
+ @Test
+ void ofCreatesExpectedValue() {
+ DamageCauseId id = DamageCauseId.of("ENTITY_ATTACK");
+ assertEquals("ENTITY_ATTACK", id.toString());
+ }
+
+ @Test
+ void ofNullThrows() {
+ assertThrows(NullPointerException.class, () -> DamageCauseId.of(null));
+ }
+
+ @Test
+ void ofEmptyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> DamageCauseId.of(""));
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ DamageCauseId a = DamageCauseId.of("ENTITY_ATTACK");
+ DamageCauseId b = DamageCauseId.of("ENTITY_ATTACK");
+ DamageCauseId c = DamageCauseId.of("PROJECTILE");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/PlayerIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/PlayerIdTest.java
new file mode 100644
index 0000000..6eaf4c7
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/PlayerIdTest.java
@@ -0,0 +1,41 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PlayerIdTest {
+
+ @Test
+ void ofCreatesExpectedValue() {
+ UUID uuid = UUID.randomUUID();
+ PlayerId id = PlayerId.of(uuid);
+ assertEquals(uuid, id.getValue());
+ }
+
+ @Test
+ void ofNullThrows() {
+ assertThrows(NullPointerException.class, () -> PlayerId.of(null));
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ PlayerId a = PlayerId.of(uuid);
+ PlayerId b = PlayerId.of(uuid);
+ PlayerId c = PlayerId.of(UUID.fromString("00000000-0000-0000-0000-000000000002"));
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+
+ @Test
+ void toStringReturnsUuidString() {
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ PlayerId id = PlayerId.of(uuid);
+ assertEquals("00000000-0000-0000-0000-000000000001", id.toString());
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/PotionEffectIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/PotionEffectIdTest.java
new file mode 100644
index 0000000..b03bf1e
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/PotionEffectIdTest.java
@@ -0,0 +1,35 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PotionEffectIdTest {
+
+ @Test
+ void ofCreatesExpectedValue() {
+ PotionEffectId id = PotionEffectId.of("SPEED");
+ assertEquals("SPEED", id.toString());
+ }
+
+ @Test
+ void ofNullThrows() {
+ assertThrows(NullPointerException.class, () -> PotionEffectId.of(null));
+ }
+
+ @Test
+ void ofEmptyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> PotionEffectId.of(""));
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ PotionEffectId a = PotionEffectId.of("SPEED");
+ PotionEffectId b = PotionEffectId.of("SPEED");
+ PotionEffectId c = PotionEffectId.of("JUMP");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/SoundIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/SoundIdTest.java
new file mode 100644
index 0000000..58afd15
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/SoundIdTest.java
@@ -0,0 +1,35 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SoundIdTest {
+
+ @Test
+ void ofCreatesExpectedValue() {
+ SoundId id = SoundId.of("ENTITY_PLAYER_LEVELUP");
+ assertEquals("ENTITY_PLAYER_LEVELUP", id.toString());
+ }
+
+ @Test
+ void ofNullThrows() {
+ assertThrows(NullPointerException.class, () -> SoundId.of(null));
+ }
+
+ @Test
+ void ofEmptyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> SoundId.of(""));
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ SoundId a = SoundId.of("DING");
+ SoundId b = SoundId.of("DING");
+ SoundId c = SoundId.of("BOOM");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/StringIdTest.java b/src/test/java/gg/steve/mc/ap/model/id/StringIdTest.java
new file mode 100644
index 0000000..6930a2d
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/StringIdTest.java
@@ -0,0 +1,13 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class StringIdTest {
+
+ @Test
+ void ofEmptyThrowsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () -> ArmorSetId.of(""));
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/id/TypedStringTest.java b/src/test/java/gg/steve/mc/ap/model/id/TypedStringTest.java
new file mode 100644
index 0000000..119a1df
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/id/TypedStringTest.java
@@ -0,0 +1,71 @@
+package gg.steve.mc.ap.model.id;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TypedStringTest {
+
+ @Test
+ void differentSubtypesWithSameStringAreNotEqual() {
+ ArmorSetId armorSetId = ArmorSetId.of("SPEED");
+ PotionEffectId potionEffectId = PotionEffectId.of("SPEED");
+
+ assertNotEquals(armorSetId, potionEffectId);
+ }
+
+ @Test
+ void toStringsReturnsRawStrings() {
+ List ids = Arrays.asList(
+ ArmorSetId.of("dragon"),
+ ArmorSetId.of("knight")
+ );
+
+ List result = TypedString.toStrings(ids);
+
+ assertEquals(Arrays.asList("dragon", "knight"), result);
+ }
+
+ @Test
+ void toStringReturnsRawIdString() {
+ ArmorSetId id = ArmorSetId.of("dragon");
+ assertEquals("dragon", id.toString());
+ }
+
+ @Test
+ void nullConstructorThrows() {
+ assertThrows(NullPointerException.class, () -> ArmorSetId.of(null));
+ }
+
+ @SuppressWarnings("EqualsWithItself")
+ @Test
+ void equalsNullReturnsFalse() {
+ ArmorSetId id = ArmorSetId.of("dragon");
+ assertFalse(id.equals(null));
+ }
+
+ @SuppressWarnings("EqualsWithItself")
+ @Test
+ void equalsSelfReturnsTrue() {
+ ArmorSetId id = ArmorSetId.of("dragon");
+ assertTrue(id.equals(id));
+ }
+
+ @Test
+ void sameTypeDifferentValueNotEqual() {
+ ArmorSetId a = ArmorSetId.of("dragon");
+ ArmorSetId b = ArmorSetId.of("knight");
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ void sameTypeSameValueEqual() {
+ ArmorSetId a = ArmorSetId.of("dragon");
+ ArmorSetId b = ArmorSetId.of("dragon");
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/notification/ArmorSetNotificationTest.java b/src/test/java/gg/steve/mc/ap/model/notification/ArmorSetNotificationTest.java
new file mode 100644
index 0000000..13e1b60
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/notification/ArmorSetNotificationTest.java
@@ -0,0 +1,108 @@
+package gg.steve.mc.ap.model.notification;
+
+import gg.steve.mc.ap.model.effect.NotificationSound;
+import gg.steve.mc.ap.model.id.SoundId;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetNotificationTest {
+
+ @Test
+ void builderCreatesExpectedValues() {
+ NotificationSound sound = new NotificationSound(SoundId.of("DING"), 1.0f, 1.0f);
+
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .message("Welcome!")
+ .message("Enjoy your armor.")
+ .sound(sound)
+ .command("give %player% diamond 1")
+ .build();
+
+ assertEquals(Arrays.asList("Welcome!", "Enjoy your armor."), notification.getMessages());
+ assertEquals(sound, notification.getSound());
+ assertEquals(Arrays.asList("give %player% diamond 1"), notification.getCommands());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ NotificationSound sound = new NotificationSound(SoundId.of("DING"), 1.0f, 1.0f);
+ ArmorSetNotification a = ArmorSetNotification.builder()
+ .message("Hi")
+ .sound(sound)
+ .build();
+ ArmorSetNotification b = ArmorSetNotification.builder()
+ .message("Hi")
+ .sound(sound)
+ .build();
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+
+ @Test
+ void emptyListsWhenNoItemsAdded() {
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .sound(null)
+ .build();
+
+ assertNotNull(notification.getMessages());
+ assertTrue(notification.getMessages().isEmpty());
+ assertNotNull(notification.getCommands());
+ assertTrue(notification.getCommands().isEmpty());
+ }
+
+ @Test
+ void bulkMessagesMethodDefensivelyCopies() {
+ List original = new ArrayList<>(Arrays.asList("A", "B"));
+
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .messages(original)
+ .command("cmd")
+ .build();
+
+ original.add("C");
+
+ assertEquals(Arrays.asList("A", "B"), notification.getMessages());
+ }
+
+ @Test
+ void bulkCommandsMethodDefensivelyCopies() {
+ List original = new ArrayList<>(Arrays.asList("x", "y"));
+
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .commands(original)
+ .message("msg")
+ .build();
+
+ original.add("z");
+
+ assertEquals(Arrays.asList("x", "y"), notification.getCommands());
+ }
+
+ @Test
+ void getMessagesReturnsUnmodifiableList() {
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .message("hello")
+ .command("cmd")
+ .build();
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> notification.getMessages().add("sneaky"));
+ }
+
+ @Test
+ void getCommandsReturnsUnmodifiableList() {
+ ArmorSetNotification notification = ArmorSetNotification.builder()
+ .message("msg")
+ .command("run")
+ .build();
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> notification.getCommands().add("sneaky"));
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/player/ArmorSetWearerTest.java b/src/test/java/gg/steve/mc/ap/model/player/ArmorSetWearerTest.java
new file mode 100644
index 0000000..856d661
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/player/ArmorSetWearerTest.java
@@ -0,0 +1,34 @@
+package gg.steve.mc.ap.model.player;
+
+import gg.steve.mc.ap.model.id.ArmorSetId;
+import gg.steve.mc.ap.model.id.PlayerId;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetWearerTest {
+
+ @Test
+ void constructsWithExpectedValues() {
+ PlayerId id = PlayerId.of(UUID.randomUUID());
+ ArmorSetId setId = ArmorSetId.of("dragon");
+ ArmorSetWearer wearer = new ArmorSetWearer(id, setId);
+
+ assertEquals(id, wearer.getPlayerId());
+ assertEquals(setId, wearer.getSetId());
+ }
+
+ @Test
+ void equalsAndHashCode() {
+ PlayerId id = PlayerId.of(UUID.fromString("00000000-0000-0000-0000-000000000001"));
+ ArmorSetWearer a = new ArmorSetWearer(id, ArmorSetId.of("dragon"));
+ ArmorSetWearer b = new ArmorSetWearer(id, ArmorSetId.of("dragon"));
+ ArmorSetWearer c = new ArmorSetWearer(id, ArmorSetId.of("knight"));
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/set/ArmorPieceSlotTest.java b/src/test/java/gg/steve/mc/ap/model/set/ArmorPieceSlotTest.java
new file mode 100644
index 0000000..8cfc53b
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/set/ArmorPieceSlotTest.java
@@ -0,0 +1,30 @@
+package gg.steve.mc.ap.model.set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorPieceSlotTest {
+
+ @Test
+ void allValuesPresent() {
+ ArmorPieceSlot[] values = ArmorPieceSlot.values();
+ assertEquals(5, values.length);
+ }
+
+ @Test
+ void valueOfRoundTrips() {
+ for (ArmorPieceSlot slot : ArmorPieceSlot.values()) {
+ assertEquals(slot, ArmorPieceSlot.valueOf(slot.name()));
+ }
+ }
+
+ @Test
+ void expectedSlots() {
+ assertNotNull(ArmorPieceSlot.valueOf("HELMET"));
+ assertNotNull(ArmorPieceSlot.valueOf("CHESTPLATE"));
+ assertNotNull(ArmorPieceSlot.valueOf("LEGGINGS"));
+ assertNotNull(ArmorPieceSlot.valueOf("BOOTS"));
+ assertNotNull(ArmorPieceSlot.valueOf("HAND"));
+ }
+}
diff --git a/src/test/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResultTest.java b/src/test/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResultTest.java
new file mode 100644
index 0000000..49d0165
--- /dev/null
+++ b/src/test/java/gg/steve/mc/ap/model/set/ArmorSetPurchaseResultTest.java
@@ -0,0 +1,21 @@
+package gg.steve.mc.ap.model.set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArmorSetPurchaseResultTest {
+
+ @Test
+ void allValuesPresent() {
+ ArmorSetPurchaseResult[] values = ArmorSetPurchaseResult.values();
+ assertEquals(3, values.length);
+ }
+
+ @Test
+ void valueOfRoundTrips() {
+ for (ArmorSetPurchaseResult result : ArmorSetPurchaseResult.values()) {
+ assertEquals(result, ArmorSetPurchaseResult.valueOf(result.name()));
+ }
+ }
+}