Skip to content

feat(model): define inbound/outbound port interfaces for the hexagonal domain boundary#15

Merged
nbdSteve merged 3 commits into
masterfrom
fm/armorplus-4c-ports-p6
Jul 10, 2026
Merged

feat(model): define inbound/outbound port interfaces for the hexagonal domain boundary#15
nbdSteve merged 3 commits into
masterfrom
fm/armorplus-4c-ports-p6

Conversation

@nbdSteve

Copy link
Copy Markdown
Owner

Intent

Define the port interfaces that connect the pure model layer to the outside world - the hexagonal boundary. 5 inbound ports (ArmorSetEquipPort, ArmorCombatPort, ArmorCommandPort, ArmorPurchasePort, ArmorSetQueryPort) and 6 outbound ports (PlayerStatePort, NbtPort, EffectsPort, MessagingPort, EconomyPort, SchedulerPort) under model.port.inbound and model.port.outbound sub-packages. All port method signatures use ONLY model types and Java built-ins - zero Bukkit/NMS/vendored types. Supporting pure value types (WornArmor, WorldPosition, Velocity, ItemHandle, TaskHandle, GiveResult) were added to express port signatures without leaking platform types. This is purely additive - no existing production classes are modified (only AGENTS.md docs and pom.xml JaCoCo gate). The new value types with executable logic are added to the core coverage gate. Design decisions: ports are Java interfaces with Javadoc, adapters implement them outside the model; ItemHandle and TaskHandle are opaque typed-string handles rather than leaking Bukkit ItemStack or int taskId; WorldPosition and Velocity replace Bukkit Location and Vector; WornArmor is a map-based snapshot of equipped slots to set IDs; GiveResult is an enum for command outcomes. No Manager suffix anywhere per project convention.

What Changed

  • Added 5 inbound ports (ArmorSetEquipPort, ArmorCombatPort, ArmorCommandPort, ArmorPurchasePort, ArmorSetQueryPort) and 6 outbound ports (PlayerStatePort, NbtPort, EffectsPort, MessagingPort, EconomyPort, SchedulerPort) under gg.steve.mc.ap.model.port, all expressed purely in model types and Java built-ins with zero Bukkit/NMS/vendored imports.
  • Added supporting Bukkit-free value types to express port signatures: WornArmor, WorldPosition, Velocity, ItemHandle, TaskHandle, and GiveResult, with unit tests for each; the new types with executable logic were added to the JaCoCo 100% core coverage gate in pom.xml.
  • Review-driven fixes folded in: SchedulerPort.runRepeating now takes separate initial-delay and period parameters (matching Bukkit's runTaskTimer semantics), ItemHandle/TaskHandle extend StringId instead of duplicating validation, and WorldPosition rejects a null world. AGENTS.md/README.md docs were synced to the new port and coverage-gate lists.

Risk Assessment

✅ Low: Purely additive change introducing interfaces and value types with no modifications to existing production logic; all previous findings were fixed, tests cover new value types, and the model layer remains Bukkit-free.

Testing

The hexagonal port layer works as intended: all 11 port interfaces and 6 supporting value types compile to Java 8 bytecode, ship in the plugin jar, are exercisable without Bukkit at runtime, and the 100% coverage gate on executable value types passes. An end-to-end standalone demo proves adapters can implement ports and drive the domain model through them without any platform dependency - the core architectural promise of this change.

Evidence: Port purity check (zero platform imports)
=== Port interfaces present (model/port) ===
src/main/java/gg/steve/mc/ap/model/port/inbound/ArmorCombatPort.java
src/main/java/gg/steve/mc/ap/model/port/inbound/ArmorCommandPort.java
src/main/java/gg/steve/mc/ap/model/port/inbound/ArmorPurchasePort.java
src/main/java/gg/steve/mc/ap/model/port/inbound/ArmorSetEquipPort.java
src/main/java/gg/steve/mc/ap/model/port/inbound/ArmorSetQueryPort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/EconomyPort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/EffectsPort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/MessagingPort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/NbtPort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/PlayerStatePort.java
src/main/java/gg/steve/mc/ap/model/port/outbound/SchedulerPort.java

=== Purity check: forbidden platform imports in the entire model package ===
$ grep -rnE "import (org\.bukkit|net\.minecraft|de\.tr7zw|org\.spigotmc)" src/main/java/gg/steve/mc/ap/model/
(no matches - model package is platform-free)

=== Purity check: imports of plugin classes outside the model package ===
$ grep -rn "import gg.steve" src/main/java/gg/steve/mc/ap/model/ | grep -v "import gg.steve.mc.ap.model"
(no matches - model only imports model types)

=== All imports actually used across port interfaces and new value types ===
import gg.steve.mc.ap.model.ability.ArmorSetBasicStats;
import gg.steve.mc.ap.model.combat.CombatDamageContext;
import gg.steve.mc.ap.model.combat.CombatDamageModification;
import gg.steve.mc.ap.model.effect.NotificationSound;
import gg.steve.mc.ap.model.effect.PotionEffect;
import gg.steve.mc.ap.model.id.ArmorSetId;
import gg.steve.mc.ap.model.id.ItemHandle;
import gg.steve.mc.ap.model.id.PlayerId;
import gg.steve.mc.ap.model.id.PotionEffectId;
import gg.steve.mc.ap.model.id.TaskHandle;
import gg.steve.mc.ap.model.player.Velocity;
import gg.steve.mc.ap.model.player.WorldPosition;
import gg.steve.mc.ap.model.player.WornArmor;
import gg.steve.mc.ap.model.set.ArmorPieceSlot;
import gg.steve.mc.ap.model.set.ArmorSetPurchaseResult;
import gg.steve.mc.ap.model.set.GiveResult;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import org.apache.commons.lang3.Validate;
Evidence: Port API surface (javap of all 11 interfaces)
=== Full API surface of the 11 hexagonal port interfaces (javap of compiled classes) ===
Compiled from "ArmorSetEquipPort.java"
public interface gg.steve.mc.ap.model.port.inbound.ArmorSetEquipPort {
  public abstract void onEquip(gg.steve.mc.ap.model.id.PlayerId, gg.steve.mc.ap.model.id.ArmorSetId, gg.steve.mc.ap.model.set.ArmorPieceSlot);
  public abstract void onUnequip(gg.steve.mc.ap.model.id.PlayerId, gg.steve.mc.ap.model.id.ArmorSetId, gg.steve.mc.ap.model.set.ArmorPieceSlot);
  public abstract void onJoin(gg.steve.mc.ap.model.id.PlayerId, gg.steve.mc.ap.model.player.WornArmor);
  public abstract void onQuit(gg.steve.mc.ap.model.id.PlayerId);
}

Compiled from "ArmorCombatPort.java"
public interface gg.steve.mc.ap.model.port.inbound.ArmorCombatPort {
  public abstract java.util.Optional<gg.steve.mc.ap.model.combat.CombatDamageModification> onDealDamage(gg.steve.mc.ap.model.combat.CombatDamageContext);
  public abstract java.util.Optional<gg.steve.mc.ap.model.combat.CombatDamageModification> onTakeDamage(gg.steve.mc.ap.model.combat.CombatDamageContext);
  public abstract boolean onFallDamage(gg.steve.mc.ap.model.id.PlayerId);
  public abstract boolean onHungerDeplete(gg.steve.mc.ap.model.id.PlayerId);
  public abstract void onTargetKilled(gg.steve.mc.ap.model.id.PlayerId, int);
}

Compiled from "ArmorCommandPort.java"
public interface gg.steve.mc.ap.model.port.inbound.ArmorCommandPort {
  public abstract gg.steve.mc.ap.model.set.GiveResult giveSet(gg.steve.mc.ap.model.id.ArmorSetId, gg.steve.mc.ap.model.set.ArmorPieceSlot, gg.steve.mc.ap.model.id.PlayerId, int);
  public abstract void openGui(gg.steve.mc.ap.model.id.PlayerId, gg.steve.mc.ap.model.id.ArmorSetId);
  public abstract void reload();
}

Compiled from "ArmorPurchasePort.java"
public interface gg.steve.mc.ap.model.port.inbound.ArmorPurchasePort {
  public abstract gg.steve.mc.ap.model.set.ArmorSetPurchaseResult purchase(gg.steve.mc.ap.model.id.PlayerId, gg.steve.mc.ap.model.id.ArmorSetId, gg.steve.mc.ap.model.set.ArmorPieceSlot);
}

Compiled from "ArmorSetQueryPort.java"
public interface gg.steve.mc.ap.model.port.inbound.ArmorSetQueryPort {
  public abstract boolean isWearingSet(gg.steve.mc.ap.model.id.PlayerId);
  public abstract java.util.Optional<gg.steve.mc.ap.model.id.ArmorSetId> getCurrentSet(gg.steve.mc.ap.model.id.PlayerId);
  public abstract int getPiecesWearing(gg.steve.mc.ap.model.id.ArmorSetId, gg.steve.mc.ap.model.id.PlayerId);
  public abstract java.util.Optional<gg.steve.mc.ap.model.ability.ArmorSetBasicStats> getBasicStats(gg.steve.mc.ap.model.id.PlayerId);
}

Compiled from "PlayerStatePort.java"
public interface gg.steve.mc.ap.model.port.outbound.PlayerStatePort {
  public abstract gg.steve.mc.ap.model.player.WornArmor getWornArmor(gg.steve.mc.ap.model.id.PlayerId);
  public abstract java.util.Optional<gg.steve.mc.ap.model.id.ItemHandle> getHeldItem(gg.steve.mc.ap.model.id.PlayerId);
Evidence: Port boundary demo transcript (no-Bukkit end-to-end)

== Hexagonal port boundary demo (no Bukkit on classpath) == java.version=25.0.3 -- player joins wearing a saved helmet -- [domain] 11111111-2222-3333-4444-555555555555 joined wearing 1 piece(s) helmet slot resolves to: dragon -- player equips chestplate -- [domain] 11111111-2222-3333-4444-555555555555 equipped 'dragon' on CHESTPLATE now wearing 2 piece(s); isEmpty=false -- domain schedules an ability tick via outbound SchedulerPort -- [domain] ability tick fired for 11111111-2222-3333-4444-555555555555 received opaque handle: fake-task-42 [platform] cancelled task fake-task-42 -- pure value types used in port signatures -- WorldPosition: WorldPosition(world=world, x=100.5, y=64.0, z=-20.25, yaw=90.0, pitch=0.0) Velocity: Velocity(x=0.0, y=1.5, z=0.0) ItemHandle: item-abc123 (equals same value: true) GiveResult values: [SUCCESS, SET_NOT_FOUND, PLAYER_NOT_FOUND] -- WornArmor defensive copy check -- slots map is unmodifiable (UnsupportedOperationException) - defensive copy OK -- typed-string handle null/type safety -- ItemHandle.of(null) rejected: id must not be null ItemHandle vs TaskHandle same string equal? false (different ID types never equal) == DEMO COMPLETE: domain driven end-to-end through ports with zero platform types ==

== Hexagonal port boundary demo (no Bukkit on classpath) ==
java.version=25.0.3

-- player joins wearing a saved helmet --
  [domain] 11111111-2222-3333-4444-555555555555 joined wearing 1 piece(s)
  helmet slot resolves to: dragon

-- player equips chestplate --
  [domain] 11111111-2222-3333-4444-555555555555 equipped 'dragon' on CHESTPLATE
  now wearing 2 piece(s); isEmpty=false

-- domain schedules an ability tick via outbound SchedulerPort --
  [domain] ability tick fired for 11111111-2222-3333-4444-555555555555
  received opaque handle: fake-task-42
  [platform] cancelled task fake-task-42

-- pure value types used in port signatures --
  WorldPosition: WorldPosition(world=world, x=100.5, y=64.0, z=-20.25, yaw=90.0, pitch=0.0)
  Velocity:      Velocity(x=0.0, y=1.5, z=0.0)
  ItemHandle:    item-abc123 (equals same value: true)
  GiveResult values: [SUCCESS, SET_NOT_FOUND, PLAYER_NOT_FOUND]

-- WornArmor defensive copy check --
  slots map is unmodifiable (UnsupportedOperationException) - defensive copy OK

-- typed-string handle null/type safety --
  ItemHandle.of(null) rejected: id must not be null
  ItemHandle vs TaskHandle same string equal? false (different ID types never equal)

== DEMO COMPLETE: domain driven end-to-end through ports with zero platform types ==
Evidence: New value-type test results (18 tests, all green)
[INFO] Running gg.steve.mc.ap.model.id.ItemHandleTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in gg.steve.mc.ap.model.id.ItemHandleTest
[INFO] Running gg.steve.mc.ap.model.id.TaskHandleTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.008 s -- in gg.steve.mc.ap.model.id.TaskHandleTest
[INFO] Running gg.steve.mc.ap.model.set.GiveResultTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s -- in gg.steve.mc.ap.model.set.GiveResultTest
[INFO] Running gg.steve.mc.ap.model.player.WornArmorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in gg.steve.mc.ap.model.player.WornArmorTest
[INFO] Running gg.steve.mc.ap.model.player.WorldPositionTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s -- in gg.steve.mc.ap.model.player.WorldPositionTest
[INFO] Running gg.steve.mc.ap.model.player.VelocityTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in gg.steve.mc.ap.model.player.VelocityTest
[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Evidence: Coverage gate passes with new includes
=== JaCoCo check-core gate now includes new value types (pom.xml) and passes at 100% ===
                                        <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>

[INFO] Analyzed bundle 'ArmorPlus' with 78 classes
[INFO] 
[INFO] --- jacoco:0.8.15:check (check-core) @ ArmorPlus ---
[INFO] Loading execution data file /Users/goodhill/.no-mistakes/worktrees/694674b8f9a9/01KX4MMSAFN1T710HEE1CYKSN3/target/jacoco.exec

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ src/main/java/gg/steve/mc/ap/model/port/outbound/SchedulerPort.java:14 - SchedulerPort.runRepeating takes a single 'delayTicks' parameter, but Bukkit's runTaskTimer requires two longs: initial delay and repeat period. All existing usages (FairySetData, ColorWaySetData, LightningAttackUtil, EngineerAttackUtil) pass both (e.g. '0L, this.delay'). The port cannot express this distinction, and adapters won't know whether delayTicks is the delay, the period, or both.
  • ℹ️ src/main/java/gg/steve/mc/ap/model/id/ItemHandle.java:9 - ItemHandle and TaskHandle extend TypedString directly with manual null/empty validation, diverging from the established pattern where all other string-based leaf IDs (ArmorSetId, DamageCauseId, PotionEffectId, SoundId) extend StringId which already provides that validation. Extending StringId would eliminate the duplicate checks and align with the documented convention in AGENTS.md.
  • ℹ️ src/main/java/gg/steve/mc/ap/model/player/WorldPosition.java:10 - WorldPosition uses Lombok @value without null-checking the 'world' field. Other model types (TypedString, PlayerId) explicitly reject null in constructors. A null world would create a silently broken value object.

🔧 Fix: Fix SchedulerPort signature, ID hierarchy, and null validation
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • JAVA_HOME=<jdk25> mvn -B clean verify (full build, 203 tests, JaCoCo check-core gate)
  • mvn -B test -Dtest=ItemHandleTest,TaskHandleTest,VelocityTest,WorldPositionTest,WornArmorTest,GiveResultTest (18 new value-type tests)
  • grep -rnE platform-import scan of entire src/main/java/gg/steve/mc/ap/model/ (zero violations)
  • Standalone PortBoundaryDemo.java compiled+run against target/classes with only commons-lang3 - no Bukkit jar - exercises ArmorSetEquipPort, SchedulerPort, WornArmor builder/getSlot, defensive copy immutability, null rejection, cross-type inequality, WorldPosition, Velocity, ItemHandle, GiveResult
  • javap bytecode version check: port interfaces emit major version 52 (Java 8)
  • unzip -l target/ArmorPlus-v2.3.6.jar confirms all 11 port interfaces + 5 value types present in the shaded artifact
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Stephen Goodhill added 3 commits July 10, 2026 09:48
…odel

Add 5 inbound and 6 outbound port interfaces under model.port that
define the hexagonal boundary between the pure domain layer and platform
adapters. All port method signatures use only model types and Java
built-ins - zero Bukkit/NMS/vendored imports.

Inbound ports (adapters call into domain):
- ArmorSetEquipPort: equip/unequip/join/quit lifecycle
- ArmorCombatPort: deal/take damage, fall, hunger, target-killed
- ArmorCommandPort: give set, open gui, reload
- ArmorPurchasePort: purchase a set piece
- ArmorSetQueryPort: is-wearing, current set, pieces-worn, stats

Outbound ports (domain asks the platform):
- PlayerStatePort: worn armor, held item, speeds, health, damage,
  teleport, velocity, food
- NbtPort: get/set NBT tags on items
- EffectsPort: potion, lightning, TNT, falling blocks
- MessagingPort: message, sound, command dispatch
- EconomyPort: balance, withdraw, availability check
- SchedulerPort: run-repeating, cancel

Supporting pure value types for port signatures:
- WornArmor, WorldPosition, Velocity (model.player)
- ItemHandle, TaskHandle (model.id)
- GiveResult (model.set)

All new value types with executable logic are added to the JaCoCo core
coverage gate with full test coverage.
@sonarqubecloud

Copy link
Copy Markdown

@nbdSteve
nbdSteve merged commit 9553adf into master Jul 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant