Skip to content
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +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 `<maven.compiler.release>8</maven.compiler.release>` in `pom.xml`; the target runtime is a Java 17+ Spigot server. Do not "modernize" the bytecode level. Build/verify: `JAVA_HOME=<jdk25> 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`, `ArmorHandItem`, `BasicDamageCalculator`, `model.id.TypedString`, `model.id.StringId`, `model.id.ItemHandle`, `model.id.TaskHandle`, `model.player.WornArmor`. 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`, `BasicDamageCalculator`, `model.id.TypedString`, `model.id.StringId`, `model.id.ItemHandle`, `model.id.TaskHandle`, `model.player.WornArmor`, `model.player.PlayerArmorWearerRegistry`, `model.set.ArmorSetRegistry`. 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 `<argLine>`). 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).
- Characterization test safety net: `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 (`PlayerArmorSetServiceTest`), warp safety (`WarpUtil.isSafe`), XP multiplier (`ExperienceSetData.onTargetDeath`), potion checks (`SetStatusEffectsManager.potionCheck`), set detection (`Set.isWearingSet/verifyPiece`), and messaging (`MessageType/CommandDebug`). Later refactors 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), `PlayerArmorSetService.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`, `port`. 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.
- Damage-calculation logic lives in `model.ability`: `BasicDamageCalculator` (attack damage multiplier, defense reduction + knockback) and `ArmorHandItem.calculateFinalDamage()` (hand-item combined multiplier). The old `BasicSetData` and `HandSetData` classes are now thin delegators - they translate Bukkit events into domain calls and apply results back. Do not add logic to those delegators; extend the domain classes instead.
- Wearer/set tracking state lives in pure registries: `model.player.PlayerArmorWearerRegistry` (`PlayerId` -> `ArmorSetWearer` map) and `model.set.ArmorSetRegistry<T>` (`ArmorSetId` -> set, generic because no domain `ArmorSet` aggregate exists yet - config parsing owns it). The Bukkit-facing adapters over these registries are `armor.ArmorSetCatalog` (holds an `ArmorSetRegistry<Set>`; `loadSets`, `getSets`, `getSet`) and `player.PlayerArmorSetService` (holds a `PlayerArmorWearerRegistry` + the catalog; `addSetPlayer`/`removeSetPlayer`/`isWearingSet`/`getSetPlayer`/`getPiecesWearing`/`getSetFromHand`/`init`). Both are ordinary injected instances - NO static state, NO `get()` entry point (they replaced the former `SetManager`/`SetPlayerManager` singletons). `PlayerArmorSetService.getSetPlayer` reconstructs the Bukkit `SetPlayer` on demand from the wearer record via `catalog.getSet(...)`. `ArmorSetCatalog.getSet` guards null/empty names before `ArmorSetId.of` (which rejects empty). `getPiecesWearing`/`getSetFromHand` read live Bukkit inventory/NBT, not wearer-map state. Do not add tracking logic to the adapters; extend the registries instead.
- Dependency injection (Guice 5.1.0, JSR-330 `javax.inject`, chosen over Dagger to avoid a second annotation processor alongside Lombok and for lower Spigot-runtime ceremony): the composition root is `ArmorPlus.onEnable`, which builds one `Injector` from `ArmorPlusModule` and resolves the shared collaborators. `ArmorPlusModule` binds the two pure registries (no-arg construction, kept framework-annotation-free so the `model` layer imports zero DI types), `ArmorSetCatalog`, and `PlayerArmorSetService` as singletons; the catalog + service are constructor-injected (`@Inject com.google.inject.Inject`) and threaded to listeners/commands/expansion/GUI. Guice + guava + `javax.inject` + aopalliance are shaded and relocated under `gg.steve.mc.ap.lib.*` (mirrors the commons-lang3 relocation) to avoid classpath collisions on a shared server; annotation-only transitives (jsr305, checker-qual, error_prone_annotations, j2objc, listenablefuture) are excluded from the shaded jar. Remaining `ArmorPlus.get()` uses are deferred (scheduler/logger via `LogUtil`, ability tick tasks, `eco()`, `formatNumber()`, and the static `getApGui()` facade, which reads the injected catalog stored on the plugin).
- 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 `<annotationProcessorPaths>`. 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`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `ArmorHandItem`, `BasicDamageCalculator`, `TypedString`, `StringId`, `ItemHandle`, `TaskHandle`, `WornArmor`); 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`, `BasicDamageCalculator`, `TypedString`, `StringId`, `ItemHandle`, `TaskHandle`, `WornArmor`, `PlayerArmorWearerRegistry`, `ArmorSetRegistry`); 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
Expand Down
44 changes: 44 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@
<include>gg.steve.mc.ap.model.id.ItemHandle</include>
<include>gg.steve.mc.ap.model.id.TaskHandle</include>
<include>gg.steve.mc.ap.model.player.WornArmor</include>
<include>gg.steve.mc.ap.model.player.PlayerArmorWearerRegistry</include>
<include>gg.steve.mc.ap.model.set.ArmorSetRegistry</include>
</includes>
<limits>
<limit>
Expand Down Expand Up @@ -155,7 +157,40 @@
<pattern>org.apache.commons.lang3</pattern>
<shadedPattern>gg.steve.mc.ap.lib.commons.lang3</shadedPattern>
</relocation>
<!-- Relocate the DI framework and its transitives so the shaded jar
never clashes with another plugin's Guice/Guava on the shared server classpath. -->
<relocation>
<pattern>com.google.inject</pattern>
<shadedPattern>gg.steve.mc.ap.lib.inject</shadedPattern>
</relocation>
<relocation>
<pattern>com.google.common</pattern>
<shadedPattern>gg.steve.mc.ap.lib.guava</shadedPattern>
</relocation>
<relocation>
<pattern>com.google.thirdparty</pattern>
<shadedPattern>gg.steve.mc.ap.lib.guava.thirdparty</shadedPattern>
</relocation>
<relocation>
<pattern>javax.inject</pattern>
<shadedPattern>gg.steve.mc.ap.lib.javax.inject</shadedPattern>
</relocation>
<relocation>
<pattern>org.aopalliance</pattern>
<shadedPattern>gg.steve.mc.ap.lib.aopalliance</shadedPattern>
</relocation>
</relocations>
<!-- Guava drags in annotation-only jars (jsr305, checker-qual, error_prone,
j2objc, listenablefuture) that add nothing at runtime; drop them from the shaded jar. -->
<artifactSet>
<excludes>
<exclude>com.google.code.findbugs:jsr305</exclude>
<exclude>org.checkerframework:checker-qual</exclude>
<exclude>com.google.errorprone:error_prone_annotations</exclude>
<exclude>com.google.j2objc:j2objc-annotations</exclude>
<exclude>com.google.guava:listenablefuture</exclude>
</excludes>
</artifactSet>
</configuration>
<executions>
<execution>
Expand Down Expand Up @@ -253,6 +288,15 @@
<version>3.18.0</version>
<scope>compile</scope>
</dependency>
<!-- Dependency injection. 5.1.0 is the last Guice line targeting Java 8 bytecode
(JSR-330 javax.inject); it is shaded and relocated below so it never collides
with other plugins on the server classpath. -->
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.mojang</groupId>
<artifactId>authlib</artifactId>
Expand Down
27 changes: 19 additions & 8 deletions src/main/java/gg/steve/mc/ap/ArmorPlus.java
Original file line number Diff line number Diff line change
@@ -1,40 +1,50 @@
package gg.steve.mc.ap;

import gg.steve.mc.ap.armor.SetManager;
import com.google.inject.Guice;
import com.google.inject.Injector;
import gg.steve.mc.ap.armor.ArmorSetCatalog;
import gg.steve.mc.ap.gui.ApGui;
import gg.steve.mc.ap.managers.ConfigManager;
import gg.steve.mc.ap.managers.FileManager;
import gg.steve.mc.ap.managers.SetupManager;
import gg.steve.mc.ap.papi.ArmorPlusExpansion;
import gg.steve.mc.ap.player.SetPlayerManager;
import gg.steve.mc.ap.player.PlayerArmorSetService;
import gg.steve.mc.ap.utils.LogUtil;
import net.milkbowl.vault.economy.Economy;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;

import java.text.DecimalFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public final class ArmorPlus extends JavaPlugin {
private static ArmorPlus instance;
private static Economy economy;
private static ApGui apGui;
private static DecimalFormat numberFormat = new DecimalFormat("#,###.##");

// kept on the plugin so the static getApGui() facade can reach it
private ArmorSetCatalog catalog;

@Override
public void onEnable() {
// Plugin startup logic
instance = this;
// reset apgui to null for reloading
apGui = null;
SetupManager.setupFiles(new FileManager(instance));
SetupManager.registerCommands(instance);
SetupManager.registerEvents(instance);
SetManager.loadSets();
SetPlayerManager.init();
Injector injector = Guice.createInjector(new ArmorPlusModule(this));
this.catalog = injector.getInstance(ArmorSetCatalog.class);
PlayerArmorSetService playerArmorSetService = injector.getInstance(PlayerArmorSetService.class);
SetupManager.registerCommands(instance, catalog);
SetupManager.registerEvents(instance, catalog, playerArmorSetService);
catalog.loadSets();
playerArmorSetService.init();
// verify that the server is running vault so that eco features can be used
if (Bukkit.getPluginManager().getPlugin("Vault") != null) {
LogUtil.info("Vault found, hooking into it now...");
Expand All @@ -50,7 +60,7 @@ public void onEnable() {
// placeholder hook
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
LogUtil.info("PlaceholderAPI found, registering expansion with it now...");
new ArmorPlusExpansion(instance).register();
new ArmorPlusExpansion(instance, catalog, playerArmorSetService).register();
}
// metrics, just doing it here for now
Metrics metrics = new Metrics(this, 7719);
Expand Down Expand Up @@ -80,7 +90,8 @@ public static Economy eco() {

public static ApGui getApGui() {
if (apGui == null) {
apGui = new ApGui(ConfigManager.CONFIG.get().getConfigurationSection("gui"));
YamlConfiguration config = Objects.requireNonNull(ConfigManager.CONFIG.get(), "armor+.yml config is not loaded");
apGui = new ApGui(config.getConfigurationSection("gui"), instance.catalog);
} else {
apGui.refresh();
}
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/gg/steve/mc/ap/ArmorPlusModule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package gg.steve.mc.ap;

import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import com.google.inject.TypeLiteral;
import gg.steve.mc.ap.armor.ArmorSetCatalog;
import gg.steve.mc.ap.armor.Set;
import gg.steve.mc.ap.model.player.PlayerArmorWearerRegistry;
import gg.steve.mc.ap.model.set.ArmorSetRegistry;
import gg.steve.mc.ap.player.PlayerArmorSetService;

public class ArmorPlusModule extends AbstractModule {
private final ArmorPlus plugin;

public ArmorPlusModule(ArmorPlus plugin) {
this.plugin = plugin;
}

@Override
protected void configure() {
bind(ArmorPlus.class).toInstance(plugin);
// Bind the pure model registries here rather than annotating them, so the domain layer stays DI-free.
bind(new TypeLiteral<ArmorSetRegistry<Set>>() {}).in(Singleton.class);
bind(PlayerArmorWearerRegistry.class).in(Singleton.class);
bind(ArmorSetCatalog.class).in(Singleton.class);
bind(PlayerArmorSetService.class).in(Singleton.class);
}
}
Loading
Loading