diff --git a/README.md b/README.md
index d64e49daa..95dee9084 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,26 @@
+> ## ➡️ Development has moved to [StannisMod/Stellurgy](https://github.com/StannisMod/Stellurgy)
+>
+> **This repository is no longer where Stellurgy is developed.** As of **2026-08-31** the project
+> continues in its own standalone repository: **https://github.com/StannisMod/Stellurgy**
+>
+> This one keeps the **Advanced Rocketry line**: the full history up to the split, every issue and
+> pull request number ever opened here, and the fork relationship to
+> [Advanced-Rocketry/AdvancedRocketry](https://github.com/Advanced-Rocketry/AdvancedRocketry) and to
+> the forks descending from it. Nothing was deleted and nothing was rewritten.
+>
+> **Why:** GitHub renders a fork's parent by its *current* name. While this repository was called
+> "Stellurgy", every fork of it advertised that it had been forked from Stellurgy — which reverses
+> the actual order of events, since those forks were taken from here when it was still Advanced
+> Rocketry. Separating the two lets each name mean what it says.
+>
+> - **New issues, pull requests and development** → [StannisMod/Stellurgy](https://github.com/StannisMod/Stellurgy)
+> - **Older references** (`#20`–`#27`, and anything from before the split) live here and are **not**
+> redirected.
+>
+> The text below describes the project as of the split and is kept for context.
+
+---
+
# Stellurgy
**Stellurgy is still in development. There is no public build yet.**
diff --git a/affs/src/main/java/com/github/stannismod/affs/block/BlockShieldCable.java b/affs/src/main/java/com/github/stannismod/affs/block/BlockShieldCable.java
index 277bdf8cc..a344322c0 100644
--- a/affs/src/main/java/com/github/stannismod/affs/block/BlockShieldCable.java
+++ b/affs/src/main/java/com/github/stannismod/affs/block/BlockShieldCable.java
@@ -3,7 +3,8 @@
import com.github.stannismod.affs.AdvancedForceFieldSystem;
import com.github.stannismod.affs.item.ItemBlockTiered;
import com.github.stannismod.affs.te.TileEntityShieldCable;
-import com.github.stannismod.affs.world.shield.IShieldNetworkNode;
+import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkNode;
import net.minecraft.block.Block;
import net.minecraft.block.ITileEntityProvider;
import net.minecraft.block.SoundType;
@@ -213,7 +214,10 @@ private AxisAlignedBB buildBoundingBox(IBlockState state) {
private boolean canConnect(IBlockAccess world, BlockPos pos, EnumFacing facing) {
TileEntity tileEntity = world.getTileEntity(pos.offset(facing));
- return tileEntity instanceof IShieldNetworkNode;
+ // A cable connects to shield nodes only: a ventilation duct laid through the same wall is a
+ // network node too, and joining it would draw an arm to a block this line never feeds.
+ return tileEntity instanceof ISubsystemNetworkNode
+ && ((ISubsystemNetworkNode) tileEntity).getNetworkDomain() == ShieldNetworkManager.DOMAIN;
}
@Override
diff --git a/affs/src/main/java/com/github/stannismod/affs/config/ModConfig.java b/affs/src/main/java/com/github/stannismod/affs/config/ModConfig.java
index 5364eb53a..8c250c19c 100644
--- a/affs/src/main/java/com/github/stannismod/affs/config/ModConfig.java
+++ b/affs/src/main/java/com/github/stannismod/affs/config/ModConfig.java
@@ -60,15 +60,31 @@ public final class ModConfig {
// multi-emitter network squeezed through one thin line. Tunable, never balance-pinned.
public static int cableThroughputPerTick = 20_000;
+ // A shield node's condition drives what it DELIVERS. Two coefficients because the two consequences
+ // are different in kind, not in size: a scalar node (generator / cable / accumulator) simply moves
+ // less, while an emitter projects a SMALLER SPHERE — the one consequence a player can see coming,
+ // before the shell collapses. Each is the fraction lost at the last stage before destruction; 0
+ // disables that consequence entirely. Neither touches what the shield COSTS: a shrunken emitter is
+ // still billed for the radius it was told to hold, or being shot would save energy.
+ public static double shieldNodeDamagePenaltyMax = 0.75D;
+ public static double emitterRadiusDamagePenaltyMax = 0.5D;
+
// D134-2 tier-1 cooperative weapon interaction (axis-G tunable, never balance-pinned):
// - shieldStrikeAbsorptionRate: shield energy spent per unit of a cooperative strike's declared
// impact energy. spent = min(stored, impactEnergy x rate x kindMult / tierEff).
// - shieldStrikeDamageToEnergyFactor: converts a cooperating source's *damage* value to declared
// impact energy when it reports damage rather than energy.
+ // - shieldStrikeReflectionRestitution: how much of a declared travelling body's relative speed
+ // survives the mirror. 1.0 is a perfect mirror — exactly what the per-tick entity scan does — so
+ // at the default the two populations behave identically; the knob exists so they can be split
+ // later without touching the entity compatibility path. It scales SPEED ONLY: the absorption
+ // cost stays impactEnergy x rate x kindMult / tierEff, because one impact must have exactly one
+ // pricing path. Clamped to [0, 1] — above 1 the shell would hand out energy it never absorbed.
// The tier-2 residual hitscan-ray hook (a blanket World.rayTraceBlocks mixin) is deferred to its own
// task; its config lands with it, not here.
public static double shieldStrikeAbsorptionRate = 1.0D;
public static double shieldStrikeDamageToEnergyFactor = 500.0D;
+ public static double shieldStrikeReflectionRestitution = 1.0D;
private ModConfig() {
}
@@ -235,6 +251,28 @@ public static void sync() {
+ "your emitters, not by pipe-sizing. Lower it to make plumbing a real constraint."
);
+ shieldNodeDamagePenaltyMax = configuration.getFloat(
+ "shieldNodeDamagePenaltyMax",
+ CATEGORY_SHIELD,
+ 0.75F,
+ 0.0F,
+ 1.0F,
+ "How much of a shield generator's conversion, a cable's transport or an accumulator's "
+ + "reserve is lost when the block is one stage from destruction. 0 makes battle "
+ + "damage free for these blocks."
+ );
+
+ emitterRadiusDamagePenaltyMax = configuration.getFloat(
+ "emitterRadiusDamagePenaltyMax",
+ CATEGORY_SHIELD,
+ 0.5F,
+ 0.0F,
+ 1.0F,
+ "How much of an emitter's radius is lost when the block is one stage from destruction. "
+ + "The field visibly draws in, uncovering whatever it used to reach; the emitter is "
+ + "still billed for its declared radius, so damage never saves energy."
+ );
+
generatorShieldBuffer = configuration.getInt(
"generatorShieldBuffer",
CATEGORY_BUFFERS,
@@ -293,6 +331,18 @@ public static void sync() {
+ "source reports damage rather than energy."
);
+ shieldStrikeReflectionRestitution = configuration.getFloat(
+ "shieldStrikeReflectionRestitution",
+ CATEGORY_WEAPONS,
+ 1.0F,
+ 0.0F,
+ 1.0F,
+ "Fraction of a declared travelling body's relative speed that survives reflection off the "
+ + "shell. 1.0 is a perfect mirror (what the per-tick entity scan does). Scales speed "
+ + "only — the absorption cost is unaffected. Applies to declared strikes that carry a "
+ + "body; the entity scan is never affected."
+ );
+
if (configuration.hasChanged()) {
configuration.save();
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/gui/GuiFieldGenerator.java b/affs/src/main/java/com/github/stannismod/affs/gui/GuiFieldGenerator.java
index baa44d06b..a2a04f892 100644
--- a/affs/src/main/java/com/github/stannismod/affs/gui/GuiFieldGenerator.java
+++ b/affs/src/main/java/com/github/stannismod/affs/gui/GuiFieldGenerator.java
@@ -42,7 +42,9 @@ protected void actionPerformed(GuiButton button) throws IOException {
}
if (button.id == 0 || button.id == 1) {
- int radius = tile.getRadius();
+ // The SETTING, not what a damaged emitter currently manages to project: stepping from the
+ // shrunken radius would quietly re-declare the field smaller every time it was nudged.
+ int radius = tile.getDeclaredRadius();
radius += button.id == 0 ? -1 : 1;
AdvancedForceFieldSystem.NETWORK.sendToServer(new PacketSetFieldRadius(tile.getPos(), radius));
}
@@ -57,7 +59,11 @@ protected void drawGuiContainerBackgroundLayer(float partialTicks, int mouseX, i
protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) {
drawTitle(I18n.format("tile.field_generator.name"));
drawStat(I18n.format("gui.affs.access_code"), tile.getAccessCode(), rowY(0));
- drawStat(I18n.format("gui.affs.radius"), tile.getRadius(), rowY(2));
+ // Projected / declared while damage is holding the field in, so the panel says which of the two
+ // numbers the bill below is charging for; one number while they agree.
+ drawStat(I18n.format("gui.affs.radius"), tile.getRadius() == tile.getDeclaredRadius()
+ ? String.valueOf(tile.getDeclaredRadius())
+ : tile.getRadius() + " / " + tile.getDeclaredRadius(), rowY(2));
drawStat(I18n.format("gui.affs.shield_accumulator"), tile.getEnergyStored() + " / " + tile.getMaxEnergyStored(), rowY(5));
drawStat(I18n.format("gui.affs.tier"), (tile.getTier() + 1) + " / 4", rowY(6));
drawStat(I18n.format("gui.affs.impact_efficiency"), String.format(Locale.ROOT, "%.2fx", tile.getImpactEfficiencyMultiplier()), rowY(7));
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityContourInjector.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityContourInjector.java
index ed5ee9f2c..fc4a5e3f2 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityContourInjector.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityContourInjector.java
@@ -7,9 +7,7 @@
import com.github.stannismod.affs.world.FieldSurfaceMath;
import com.github.stannismod.affs.world.contour.ContourFrameGeometry;
import com.github.stannismod.affs.world.projectile.IEnergyProjectile;
-import com.github.stannismod.affs.world.shield.IShieldSink;
import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
import com.github.stannismod.affs.world.shield.ShieldNetworkState;
import net.minecraft.entity.Entity;
import net.minecraft.entity.player.EntityPlayerMP;
@@ -25,8 +23,12 @@
import net.minecraft.world.Explosion;
import javax.annotation.Nullable;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
-public class TileEntityContourInjector extends TileEntity implements ITickable, IShieldSink {
+public class TileEntityContourInjector extends TileEntity implements ITickable, ISubsystemSink {
public static final int MAX_SCAN_RADIUS = 16;
public static final int MAX_SHIELD_BUFFER = 200_000;
@@ -82,7 +84,7 @@ public void update() {
frameCount = geometry.getFrameCount();
interiorCount = geometry.getInteriorCount();
- requestedShieldEnergy = getFreeShieldCapacity();
+ requestedShieldEnergy = getFreeCapacity();
refreshFieldActiveState(true);
@@ -119,16 +121,16 @@ public void update() {
public void onLoad() {
super.onLoad();
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
}
@Override
public void invalidate() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.invalidate();
}
@@ -136,12 +138,17 @@ public void invalidate() {
@Override
public void onChunkUnload() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.onChunkUnload();
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -153,21 +160,21 @@ public net.minecraft.world.World getNodeWorld() {
}
@Override
- public int getRequestedShieldEnergy() {
- return currentGeometry == null ? 0 : getFreeShieldCapacity();
+ public int getRequested() {
+ return currentGeometry == null ? 0 : getFreeCapacity();
}
@Override
- public int getFreeShieldCapacity() {
+ public int getFreeCapacity() {
return Math.max(0, MAX_SHIELD_BUFFER - shieldBuffer);
}
@Override
- public int receiveShieldEnergy(int amount) {
+ public int receive(int amount) {
if (world == null || world.isRemote || amount <= 0) {
return 0;
}
- int accepted = Math.min(amount, getFreeShieldCapacity());
+ int accepted = Math.min(amount, getFreeCapacity());
if (accepted > 0) {
shieldBuffer += accepted;
shieldReceivedThisTick += accepted;
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java
index 810455a8d..1d91fe191 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java
@@ -12,9 +12,8 @@
import com.github.stannismod.affs.world.FieldSurfaceMath;
import com.github.stannismod.affs.world.WorldFieldFrame;
import com.github.stannismod.affs.world.projectile.IEnergyProjectile;
-import com.github.stannismod.affs.world.shield.IShieldSink;
+import com.github.stannismod.affs.world.shield.ShieldCondition;
import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
import com.github.stannismod.affs.world.shield.ShieldNetworkState;
import com.github.stannismod.affs.world.shield.ShieldStrikeKind;
import net.minecraft.entity.Entity;
@@ -39,8 +38,12 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.*;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
-public class TileEntityFieldGenerator extends TileEntity implements ITickable, FieldSource, IShieldSink {
+public class TileEntityFieldGenerator extends TileEntity implements ITickable, FieldSource, ISubsystemSink {
public static final int MIN_RADIUS = 1;
public static final int MAX_RADIUS = 16;
@@ -56,12 +59,19 @@ public class TileEntityFieldGenerator extends TileEntity implements ITickable, F
// Both intake and extraction are UNTHROTTLED at the storage (maxReceive == maxExtract == capacity):
// - the per-tick recharge-throughput cap (D134-3) is tier-dependent (getRechargeThroughput()) and
// read from the world block state at runtime, so it cannot live on this construction-time field;
- // it is enforced instead as the coil's advertised network demand (getRequestedShieldEnergy),
+ // it is enforced instead as the coil's advertised network demand (getRequested),
// which is the single source of truth for the throttle;
// - extraction is unthrottled because absorbing one hit may need to spend far more than a tick's
// intake, so a per-tick extract cap would make the coil unable to block any impact above it.
private final ShieldEnergyStorage energy = new ShieldEnergyStorage(ModConfig.emitterCoilBuffer, ModConfig.emitterCoilBuffer, ModConfig.emitterCoilBuffer);
+ // The radius this emitter was TOLD to hold. It is what the player set, what the maintenance draw is
+ // billed against, and what a repair restores to — damage never moves it.
private int radius = DEFAULT_RADIUS;
+ // The radius it actually projects, which is the declared one shrunk by the block's own condition.
+ // Recomputed each server tick and replicated, so the field the client draws is the field that
+ // exists. Kept as a field rather than derived per call because the SDF asks for it once per sample
+ // point, and a damage lookup per sample is a different order of cost.
+ private int effectiveRadius = DEFAULT_RADIUS;
// The frame this emitter's field lives in (§4.3): identity standalone, ship-frame on a VS hull.
// Resolved from the block's position (a network is entirely on one ship or standalone) and refreshed
// each tick, so an emitter assembled into a ship after placement picks up its ship frame.
@@ -95,6 +105,7 @@ public void update() {
shieldReceivedThisTick = 0;
shieldConsumedThisTick = 0;
+ refreshEffectiveRadius();
refreshFieldPowerState(true);
if (fieldPowered) {
int requiredEnergy = getShieldDrainThisTick();
@@ -116,8 +127,23 @@ public void update() {
tickClientSync();
}
+ /**
+ * The radius this emitter actually projects — every piece of geometry reads this one: the SDF, the
+ * zone partition, the ray entry, the influence box and the snapshot the client renders from. A
+ * damaged emitter answers with a smaller sphere here, and the shell shrinks everywhere at once
+ * because there is nowhere else to ask.
+ */
@Override
public int getRadius() {
+ return effectiveRadius;
+ }
+
+ /**
+ * The radius this emitter was told to hold, whatever condition it is in. The ENERGY BILL is priced
+ * against this and not against {@link #getRadius()}: a shrunken emitter costs what it was asked to
+ * cost, or a shot-up shield would be cheaper to run than an intact one.
+ */
+ public int getDeclaredRadius() {
return radius;
}
@@ -131,17 +157,42 @@ public void setRadius(int requestedRadius) {
markDirty();
if (world != null && !world.isRemote) {
+ refreshEffectiveRadius();
refreshFieldPowerState(true);
queueClientSync(true);
}
}
+ /**
+ * Re-read this emitter's own condition and let it drive the radius.
+ *
+ *
PULLED, not pushed (nothing tells a shield it was hit), and re-derived from the DECLARED
+ * radius every time rather than accumulated — which is what makes a repair restore the field
+ * without anyone remembering to undo anything.
+ *
+ * A change is replicated with a snapshot: the client renders the field from the emitter
+ * snapshot, so a shell that shrank on the server and not on the screen would throw away the whole
+ * point of choosing a consequence a player can see.
+ */
+ private void refreshEffectiveRadius() {
+ if (world == null || world.isRemote) {
+ return;
+ }
+ int derived = ShieldCondition.effectiveRadius(world, pos, radius, MIN_RADIUS);
+ if (derived == effectiveRadius) {
+ return;
+ }
+ effectiveRadius = derived;
+ markDirty();
+ queueClientSync(true);
+ }
+
public String getAccessCode() {
return accessCode;
}
@Override
- public int getShieldPriority() {
+ public int getPriority() {
return priority;
}
@@ -152,7 +203,7 @@ public void setPriority(int value) {
priority = value;
if (world != null && !world.isRemote) {
markDirty();
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
queueClientSync(false);
}
}
@@ -163,8 +214,9 @@ public void onLoad() {
resolveFieldFrame();
if (world != null && !world.isRemote) {
ACTIVE_GENERATORS.add(this);
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
+ refreshEffectiveRadius();
refreshFieldPowerState(true);
}
}
@@ -208,6 +260,31 @@ public Vec3d getShellVelocity() {
return shellVelocityAt(getWorldCenter());
}
+ /**
+ * Mirror a DECLARED travelling body's velocity off this shell at a world point, with the same law
+ * {@link #pushEntityBack} uses for a travelling entity: take the velocity relative to the shell,
+ * reflect it about the outward normal, then add the shell's own motion back so the deflected body
+ * still rides a moving ship. The two populations share one reflection law rather than two
+ * implementations free to disagree.
+ *
+ * Two deliberate differences from the entity path. The bounce is scaled by the restitution
+ * tunable (default 1.0 — a perfect mirror, i.e. identical to the entity path); and there is no
+ * minimum-kick fallback for a degenerate mirror. An entity must end up somewhere, so it is nudged
+ * outward; a shot has the better option of ceasing to exist, and the caller ends it at the crossing
+ * point rather than leaving a near-motionless record alive.
+ */
+ public Vec3d reflectBodyVelocity(Vec3d worldPoint, Vec3d velocity) {
+ if (worldPoint == null || velocity == null) {
+ return null;
+ }
+ Vec3d shellVelocity = shellVelocityAt(worldPoint);
+ Vec3d relative = FieldSurfaceMath.subtract(velocity, shellVelocity);
+ Vec3d normal = FieldSurfaceMath.sphereOutwardNormal(getWorldCenter(), worldPoint, relative);
+ Vec3d reflected = FieldSurfaceMath.reflect(relative, normal);
+ double restitution = Math.max(0.0D, Math.min(1.0D, ModConfig.shieldStrikeReflectionRestitution));
+ return FieldSurfaceMath.scale(reflected, restitution).add(shellVelocity);
+ }
+
/** TEST ONLY: set the coil's stored shield energy directly and refresh the powered state. Lets an
* e2e power a shield on an assembled VS ship without wiring a generator/FE feed into the subspace
* structure — the ship-frame geometry, not the energy economy, is what that test exercises. */
@@ -220,6 +297,11 @@ public void setShieldEnergyForTest(int amount) {
refreshFieldPowerState(true);
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -251,13 +333,13 @@ public BlockPos getPos() {
}
@Override
- public int getRequestedShieldEnergy() {
+ public int getRequested() {
// Advertise only what the coil can physically intake this tick (min of free space and this
// emitter's tier-scaled recharge throughput). The network solver uses this as the coil's
// demand-edge capacity, so (a) a large source (e.g. an accumulator) can never have more energy
// extracted from it than the coil actually receives — keeping the network energy-conserving —
// and (b) regeneration is capped at the emitter's throughput (D134-3), the per-zone bottleneck.
- return Math.min(getFreeShieldCapacity(), getRechargeThroughput());
+ return Math.min(getFreeCapacity(), getRechargeThroughput());
}
/**
@@ -273,12 +355,12 @@ public int getRechargeThroughput() {
}
@Override
- public int getFreeShieldCapacity() {
+ public int getFreeCapacity() {
return Math.max(0, energy.getMaxEnergyStored() - energy.getEnergyStored());
}
@Override
- public int receiveShieldEnergy(int amount) {
+ public int receive(int amount) {
if (world == null || world.isRemote || amount <= 0) {
return 0;
}
@@ -297,7 +379,7 @@ public boolean ownsFieldBlock(BlockPos target) {
}
private double getFieldRadiusSq() {
- double fieldRadius = radius + 0.5D;
+ double fieldRadius = getRadius() + 0.5D;
return fieldRadius * fieldRadius;
}
@@ -362,8 +444,8 @@ public boolean shouldRepelEntity(Entity entity) {
double currentCenterY = (currentBox.minY + currentBox.maxY) * 0.5D;
double currentCenterZ = (currentBox.minZ + currentBox.maxZ) * 0.5D;
double currentDistSq = distanceSqToCenter(currentCenterX, currentCenterY, currentCenterZ);
- double innerRadius = Math.max(0.0D, radius - FieldSurfaceMath.FIELD_HALF_THICKNESS);
- double outerRadius = radius + FieldSurfaceMath.FIELD_HALF_THICKNESS;
+ double innerRadius = Math.max(0.0D, getRadius() - FieldSurfaceMath.FIELD_HALF_THICKNESS);
+ double outerRadius = getRadius() + FieldSurfaceMath.FIELD_HALF_THICKNESS;
double innerRadiusSq = innerRadius * innerRadius;
double outerRadiusSq = outerRadius * outerRadius;
@@ -439,6 +521,15 @@ public int getShieldDrainThisTick() {
return getShieldDrainForPhase(shieldDrainPhase);
}
+ /**
+ * What the network should report as CONSUMPTION: the upkeep this emitter actually burns, not the
+ * larger amount it requests while topping its buffer back up.
+ */
+ @Override
+ public int getConsumptionPerTick() {
+ return getShieldDrainThisTick();
+ }
+
private void pushEntityBack(Entity entity) {
if (entity == null || entity.world == null || entity.world.isRemote) {
return;
@@ -461,7 +552,7 @@ private void pushEntityBack(Entity entity) {
Vec3d committedMotion = reflectedMotion.add(shellVelocity);
double entityRadius = Math.max(entity.width, entity.height) * 0.5D;
- Vec3d targetCenter = fieldCenter.add(FieldSurfaceMath.scale(normal, radius + FieldSurfaceMath.FIELD_HALF_THICKNESS + entityRadius + 0.05D));
+ Vec3d targetCenter = fieldCenter.add(FieldSurfaceMath.scale(normal, getRadius() + FieldSurfaceMath.FIELD_HALF_THICKNESS + entityRadius + 0.05D));
setEntityCenter(entity, targetCenter);
entity.motionX = committedMotion.x;
@@ -485,7 +576,7 @@ private void pushEntityBack(Entity entity) {
rememberPlayerSafePosition(entity, true);
}
- Vec3d touchPoint = fieldCenter.add(FieldSurfaceMath.scale(normal, radius + FieldSurfaceMath.FIELD_HALF_THICKNESS));
+ Vec3d touchPoint = fieldCenter.add(FieldSurfaceMath.scale(normal, getRadius() + FieldSurfaceMath.FIELD_HALF_THICKNESS));
onFieldTouched(touchPoint, entity);
}
@@ -575,6 +666,12 @@ private int clampRadius(int requestedRadius) {
return Math.max(MIN_RADIUS, Math.min(MAX_RADIUS, requestedRadius));
}
+ /**
+ * The passive-maintenance draw for one 20-tick cycle, priced against the DECLARED radius — never
+ * the one damage left it projecting. A bill that followed the shrink would make being shot at a way
+ * to save energy, which is a reward dressed as a consequence and the kind of inversion nobody
+ * notices until someone optimises for it.
+ */
public int getShieldCycleCost() {
return estimateShieldCost(radius);
}
@@ -614,8 +711,8 @@ private boolean refreshFieldPowerState(boolean syncSnapshot) {
public void invalidate() {
if (world != null && !world.isRemote) {
ACTIVE_GENERATORS.remove(this);
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.invalidate();
}
@@ -624,8 +721,8 @@ public void invalidate() {
public void onChunkUnload() {
if (world != null && !world.isRemote) {
ACTIVE_GENERATORS.remove(this);
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.onChunkUnload();
}
@@ -729,7 +826,7 @@ private Vec3d getImpactTouchPoint(Entity entity) {
Vec3d motion = FieldSurfaceMath.subtract(
new Vec3d(entity.motionX, entity.motionY, entity.motionZ), shellVelocityAt(currentCenter));
Vec3d normal = FieldSurfaceMath.sphereOutwardNormal(fieldCenter, currentCenter, motion);
- return fieldCenter.add(FieldSurfaceMath.scale(normal, radius + FieldSurfaceMath.FIELD_HALF_THICKNESS));
+ return fieldCenter.add(FieldSurfaceMath.scale(normal, getRadius() + FieldSurfaceMath.FIELD_HALF_THICKNESS));
}
private void rememberPlayerSafePosition(Entity entity, boolean outside) {
@@ -804,6 +901,9 @@ private void updateClientPrediction() {
public NBTTagCompound writeToNBT(NBTTagCompound compound) {
super.writeToNBT(compound);
compound.setInteger("radius", radius);
+ // Replicated, not merely saved: this tag is also the client's update tag, and the client
+ // cannot derive the shrink itself — a block's damage stage is server-side state.
+ compound.setInteger("effectiveRadius", effectiveRadius);
compound.setInteger("energy", energy.getEnergyStored());
compound.setString("accessCode", accessCode);
compound.setInteger("priority", priority);
@@ -818,6 +918,9 @@ public NBTTagCompound writeToNBT(NBTTagCompound compound) {
public void readFromNBT(NBTTagCompound compound) {
super.readFromNBT(compound);
radius = clampRadius(compound.getInteger("radius"));
+ effectiveRadius = compound.hasKey("effectiveRadius")
+ ? Math.max(MIN_RADIUS, Math.min(radius, compound.getInteger("effectiveRadius")))
+ : radius;
energy.setEnergyStored(Math.max(0, Math.min(energy.getMaxEnergyStored(), compound.getInteger("energy"))));
shieldReceivedThisTick = Math.max(0, compound.getInteger("shieldReceivedThisTick"));
shieldConsumedThisTick = Math.max(0, compound.getInteger("shieldConsumedThisTick"));
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldAccumulator.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldAccumulator.java
index 7cde32b3c..719921105 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldAccumulator.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldAccumulator.java
@@ -2,10 +2,7 @@
import com.github.stannismod.affs.AdvancedForceFieldSystem;
import com.github.stannismod.affs.config.ModConfig;
-import com.github.stannismod.affs.world.shield.IShieldSink;
-import com.github.stannismod.affs.world.shield.IShieldSource;
import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.network.NetworkManager;
import net.minecraft.network.play.server.SPacketUpdateTileEntity;
@@ -15,15 +12,20 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSource;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
/**
- * Bulk shield-energy reserve. It is BOTH an {@link IShieldSource} and an {@link IShieldSink}: it fills
+ * Bulk shield-energy reserve. It is BOTH an {@link ISubsystemSource} and an {@link ISubsystemSink}: it fills
* when the network has spare supply and drains when the network is under load. The shield network's
* max-flow solve drives both roles (there is no per-tick self-logic here). Its own storage imposes no
* per-tick throttle — the emitter coil's intake rate and the cables are the throttles; the accumulator
* is a store of duration, not a rate.
*/
-public class TileEntityShieldAccumulator extends TileEntity implements IShieldSource, IShieldSink {
+public class TileEntityShieldAccumulator extends TileEntity implements ISubsystemSource, ISubsystemSink {
private final ShieldEnergyStorage storage =
new ShieldEnergyStorage(ModConfig.accumulatorBuffer, ModConfig.accumulatorBuffer, ModConfig.accumulatorBuffer);
@@ -34,16 +36,16 @@ public class TileEntityShieldAccumulator extends TileEntity implements IShieldSo
public void onLoad() {
super.onLoad();
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
}
@Override
public void invalidate() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.invalidate();
}
@@ -51,12 +53,17 @@ public void invalidate() {
@Override
public void onChunkUnload() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.onChunkUnload();
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -67,15 +74,15 @@ public net.minecraft.world.World getNodeWorld() {
return world;
}
- // --- IShieldSource: hand stored energy to the network under load -----------------------------
+ // --- source: hand stored energy to the network under load --------------------------------------
@Override
- public int getAvailableShieldEnergy() {
+ public int getAvailable() {
return storage.getEnergyStored();
}
@Override
- public int extractShieldEnergy(int amount) {
+ public int extract(int amount) {
if (world == null || world.isRemote || amount <= 0) {
return 0;
}
@@ -87,20 +94,20 @@ public int extractShieldEnergy(int amount) {
return extracted;
}
- // --- IShieldSink: soak up spare supply -------------------------------------------------------
+ // --- sink: soak up spare supply ----------------------------------------------------------------
@Override
- public int getRequestedShieldEnergy() {
- return getFreeShieldCapacity();
+ public int getRequested() {
+ return getFreeCapacity();
}
@Override
- public int getFreeShieldCapacity() {
- return Math.max(0, storage.getMaxEnergyStored() - storage.getEnergyStored());
+ public int getFreeCapacity() {
+ return Math.max(0, getEffectiveMaxShieldStored() - storage.getEnergyStored());
}
@Override
- public int receiveShieldEnergy(int amount) {
+ public int receive(int amount) {
if (world == null || world.isRemote || amount <= 0) {
return 0;
}
@@ -122,6 +129,19 @@ public int getMaxShieldStored() {
return storage.getMaxEnergyStored();
}
+ /**
+ * The reserve this accumulator can actually hold in the condition it is in — the rated capacity
+ * scaled by its own damage stage. A battered bank stops accepting sooner, so a fight that damages
+ * the storage shortens how long the shield can be held up afterwards.
+ *
+ * What is already inside is not destroyed by the shrink: energy that was banked before the hit
+ * is still there to spend, it simply cannot be topped back up to where it was.
+ */
+ public int getEffectiveMaxShieldStored() {
+ return com.github.stannismod.affs.world.shield.ShieldCondition.derate(world, pos,
+ storage.getMaxEnergyStored());
+ }
+
public int getShieldReceivedThisTick() {
return shieldReceivedThisTick;
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldCable.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldCable.java
index 90e5e4086..1f63fe4b1 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldCable.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldCable.java
@@ -1,18 +1,21 @@
package com.github.stannismod.affs.te;
-import com.github.stannismod.affs.world.shield.IShieldCable;
import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.network.NetworkManager;
import net.minecraft.network.play.server.SPacketUpdateTileEntity;
import net.minecraft.tileentity.TileEntity;
import net.minecraft.util.ITickable;
import net.minecraft.util.math.BlockPos;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemCable;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState;
import javax.annotation.Nullable;
-public class TileEntityShieldCable extends TileEntity implements ITickable, IShieldCable {
+public class TileEntityShieldCable extends TileEntity implements ITickable, ISubsystemCable {
private static final int CLIENT_SYNC_BASE_INTERVAL_TICKS = 20;
private static final int CLIENT_SYNC_JITTER_TICKS = 10;
@@ -50,8 +53,8 @@ public void update() {
public void onLoad() {
super.onLoad();
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] load cable at {} dim={}", pos, world.provider.getDimension());
}
@@ -61,8 +64,8 @@ public void onLoad() {
@Override
public void invalidate() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] invalidate cable at {} dim={}", pos, world.provider.getDimension());
}
@@ -73,8 +76,8 @@ public void invalidate() {
@Override
public void onChunkUnload() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] chunk unload cable at {} dim={}", pos, world.provider.getDimension());
}
@@ -82,6 +85,11 @@ public void onChunkUnload() {
super.onChunkUnload();
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -96,28 +104,40 @@ public net.minecraft.world.World getNodeWorld() {
public int getThroughputPerTick() {
// Config-tunable (P6): transport is meant to be the limiter of LAST resort, so this sits well
// above one emitter's recharge throughput and a normal build is bound by emitter placement.
- return com.github.stannismod.affs.config.ModConfig.cableThroughputPerTick;
+ // Scaled by this cable's own condition: a shot-up line carries less, which is what turns a hit
+ // on the plumbing into a shield that refills slowly instead of one that notices nothing.
+ return com.github.stannismod.affs.world.shield.ShieldCondition.derate(world, pos,
+ com.github.stannismod.affs.config.ModConfig.cableThroughputPerTick);
}
@Override
- public void addTransferredShield(int amount) {
+ public void addTransferred(int amount) {
// The cable tracks throughput through network statistics, not local accumulation.
}
- public void setNetworkStats(boolean connected, int status, BlockPos anchor, int cableCount, int sourceCount, int sinkCount, int sourceAvailable, int sinkRequested, int cableCapacity, int deliveredFlow, int saturatedCables, BlockPos bottleneck, int bottleneckUtilizationPermille) {
- BlockPos safeAnchor = anchor == null ? BlockPos.ORIGIN : anchor;
- BlockPos safeBottleneck = bottleneck == null ? BlockPos.ORIGIN : bottleneck;
- boolean changed = componentConnected != connected
- || componentStatus != status
- || componentCableCount != cableCount
- || componentSourceCount != sourceCount
- || componentSinkCount != sinkCount
- || componentSourceAvailable != sourceAvailable
- || componentSinkRequested != sinkRequested
- || componentCableCapacity != cableCapacity
- || componentDeliveredFlow != deliveredFlow
- || componentSaturatedCables != saturatedCables
- || componentBottleneckUtilizationPermille != bottleneckUtilizationPermille
+ /**
+ * The network's report, as the shared primitive delivers it. A cable is the block a player looks
+ * at to ask "why is this network not keeping up", so it mirrors the whole component readout.
+ *
+ * The readout is unpacked into fields here rather than kept as the state object: these values
+ * are what goes over the wire and into NBT, and the client half of this tile has no network to
+ * read, only the fields it was sent.
+ */
+ @Override
+ public void onNetworkStats(SubsystemNetworkState state) {
+ BlockPos safeAnchor = state.getRoot() == null ? BlockPos.ORIGIN : state.getRoot();
+ BlockPos safeBottleneck = state.getBottleneck() == null ? BlockPos.ORIGIN : state.getBottleneck();
+ boolean changed = componentConnected != state.isConnected()
+ || componentStatus != state.getStatus()
+ || componentCableCount != state.getCableCount()
+ || componentSourceCount != state.getSourceCount()
+ || componentSinkCount != state.getSinkCount()
+ || componentSourceAvailable != state.getSourceAvailable()
+ || componentSinkRequested != state.getSinkRequested()
+ || componentCableCapacity != state.getCableCapacity()
+ || componentDeliveredFlow != state.getDeliveredFlow()
+ || componentSaturatedCables != state.getSaturatedCables()
+ || componentBottleneckUtilizationPermille != state.getBottleneckUtilizationPermille()
|| componentAnchorX != safeAnchor.getX()
|| componentAnchorY != safeAnchor.getY()
|| componentAnchorZ != safeAnchor.getZ()
@@ -125,17 +145,17 @@ public void setNetworkStats(boolean connected, int status, BlockPos anchor, int
|| componentBottleneckY != safeBottleneck.getY()
|| componentBottleneckZ != safeBottleneck.getZ();
- componentConnected = connected;
- componentStatus = status;
- componentCableCount = cableCount;
- componentSourceCount = sourceCount;
- componentSinkCount = sinkCount;
- componentSourceAvailable = sourceAvailable;
- componentSinkRequested = sinkRequested;
- componentCableCapacity = cableCapacity;
- componentDeliveredFlow = deliveredFlow;
- componentSaturatedCables = saturatedCables;
- componentBottleneckUtilizationPermille = bottleneckUtilizationPermille;
+ componentConnected = state.isConnected();
+ componentStatus = state.getStatus();
+ componentCableCount = state.getCableCount();
+ componentSourceCount = state.getSourceCount();
+ componentSinkCount = state.getSinkCount();
+ componentSourceAvailable = state.getSourceAvailable();
+ componentSinkRequested = state.getSinkRequested();
+ componentCableCapacity = state.getCableCapacity();
+ componentDeliveredFlow = state.getDeliveredFlow();
+ componentSaturatedCables = state.getSaturatedCables();
+ componentBottleneckUtilizationPermille = state.getBottleneckUtilizationPermille();
componentAnchorX = safeAnchor.getX();
componentAnchorY = safeAnchor.getY();
componentAnchorZ = safeAnchor.getZ();
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldConsole.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldConsole.java
index 3a57c885b..7679c6a50 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldConsole.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldConsole.java
@@ -18,6 +18,12 @@
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSource;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState;
public class TileEntityShieldConsole extends TileEntity implements ITickable, IShieldNetworkController, com.github.stannismod.affs.gui.INetworkMapSource {
@@ -69,16 +75,16 @@ public void update() {
public void onLoad() {
super.onLoad();
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
}
@Override
public void invalidate() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.invalidate();
}
@@ -86,12 +92,17 @@ public void invalidate() {
@Override
public void onChunkUnload() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
super.onChunkUnload();
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -103,46 +114,49 @@ public net.minecraft.world.World getNodeWorld() {
}
@Override
- public void applyNetworkState(ShieldNetworkState state) {
- if (world == null || world.isRemote || state == null) {
+ public void applyNetworkState(SubsystemNetworkState state) {
+ // The shield domain's own state type: this console edits and displays the resistance
+ // bias, which only that subclass carries.
+ if (world == null || world.isRemote || !(state instanceof ShieldNetworkState)) {
return;
}
-
- boolean changed = networkConnected != state.isConnected()
- || networkStatus != state.getStatus()
- || cableCount != state.getCableCount()
- || generatorCount != state.getSourceCount()
- || injectorCount != state.getSinkCount()
- || sourceAvailable != state.getSourceAvailable()
- || sinkRequested != state.getSinkRequested()
- || cableCapacity != state.getCableCapacity()
- || deliveredFlow != state.getDeliveredFlow()
- || saturatedCables != state.getSaturatedCables()
- || generationPerTick != state.getGenerationPerTick()
- || consumptionPerTick != state.getConsumptionPerTick()
- || bottleneckUtilizationPermille != state.getBottleneckUtilizationPermille()
- || Double.compare(shieldEnergyResistanceBias, state.getShieldEnergyResistanceBias()) != 0
- || rootX != state.getRoot().getX()
- || rootY != state.getRoot().getY()
- || rootZ != state.getRoot().getZ();
-
- networkConnected = state.isConnected();
- networkStatus = state.getStatus();
- cableCount = state.getCableCount();
- generatorCount = state.getSourceCount();
- injectorCount = state.getSinkCount();
- sourceAvailable = state.getSourceAvailable();
- sinkRequested = state.getSinkRequested();
- cableCapacity = state.getCableCapacity();
- deliveredFlow = state.getDeliveredFlow();
- saturatedCables = state.getSaturatedCables();
- generationPerTick = state.getGenerationPerTick();
- consumptionPerTick = state.getConsumptionPerTick();
- bottleneckUtilizationPermille = state.getBottleneckUtilizationPermille();
- shieldEnergyResistanceBias = state.getShieldEnergyResistanceBias();
- rootX = state.getRoot().getX();
- rootY = state.getRoot().getY();
- rootZ = state.getRoot().getZ();
+ ShieldNetworkState shieldState = (ShieldNetworkState) state;
+
+ boolean changed = networkConnected != shieldState.isConnected()
+ || networkStatus != shieldState.getStatus()
+ || cableCount != shieldState.getCableCount()
+ || generatorCount != shieldState.getSourceCount()
+ || injectorCount != shieldState.getSinkCount()
+ || sourceAvailable != shieldState.getSourceAvailable()
+ || sinkRequested != shieldState.getSinkRequested()
+ || cableCapacity != shieldState.getCableCapacity()
+ || deliveredFlow != shieldState.getDeliveredFlow()
+ || saturatedCables != shieldState.getSaturatedCables()
+ || generationPerTick != shieldState.getGenerationPerTick()
+ || consumptionPerTick != shieldState.getConsumptionPerTick()
+ || bottleneckUtilizationPermille != shieldState.getBottleneckUtilizationPermille()
+ || Double.compare(shieldEnergyResistanceBias, shieldState.getShieldEnergyResistanceBias()) != 0
+ || rootX != shieldState.getRoot().getX()
+ || rootY != shieldState.getRoot().getY()
+ || rootZ != shieldState.getRoot().getZ();
+
+ networkConnected = shieldState.isConnected();
+ networkStatus = shieldState.getStatus();
+ cableCount = shieldState.getCableCount();
+ generatorCount = shieldState.getSourceCount();
+ injectorCount = shieldState.getSinkCount();
+ sourceAvailable = shieldState.getSourceAvailable();
+ sinkRequested = shieldState.getSinkRequested();
+ cableCapacity = shieldState.getCableCapacity();
+ deliveredFlow = shieldState.getDeliveredFlow();
+ saturatedCables = shieldState.getSaturatedCables();
+ generationPerTick = shieldState.getGenerationPerTick();
+ consumptionPerTick = shieldState.getConsumptionPerTick();
+ bottleneckUtilizationPermille = shieldState.getBottleneckUtilizationPermille();
+ shieldEnergyResistanceBias = shieldState.getShieldEnergyResistanceBias();
+ rootX = shieldState.getRoot().getX();
+ rootY = shieldState.getRoot().getY();
+ rootZ = shieldState.getRoot().getZ();
if (changed) {
markDirty();
@@ -164,7 +178,6 @@ private void applyDisconnectedState() {
|| generationPerTick != 0
|| consumptionPerTick != 0
|| bottleneckUtilizationPermille != 0
- || Double.compare(shieldEnergyResistanceBias, ModConfig.shieldEnergyResistanceBias) != 0
|| rootX != 0
|| rootY != 0
|| rootZ != 0;
@@ -182,7 +195,12 @@ private void applyDisconnectedState() {
generationPerTick = 0;
consumptionPerTick = 0;
bottleneckUtilizationPermille = 0;
- shieldEnergyResistanceBias = ModConfig.shieldEnergyResistanceBias;
+ // NOT the resistance bias. Everything above is a READOUT of a network that is gone and must
+ // be cleared; the bias is this console's own setting, persisted in its NBT and re-seeded
+ // INTO the network on every rebuild. Clearing it here reset a player's choice to the config
+ // default on the first tick after any world load — the network has not been rebuilt yet at
+ // that point, so this path runs — and the rebuild then seeded the network from the wiped
+ // value, making the loss look like the network's answer.
rootX = 0;
rootY = 0;
rootZ = 0;
@@ -311,7 +329,7 @@ public void applyShieldEnergyResistanceBias(double value) {
markDirty();
if (world != null && !world.isRemote) {
ShieldNetworkManager.setShieldEnergyResistanceBias(world, pos, clamped);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
}
queueClientSync();
}
@@ -376,11 +394,11 @@ private void rebuildMapSnapshot(@Nullable ShieldNetworkState state) {
putMarker(nextMarkers, new NetworkMapMarker(memberPos.getX(), memberPos.getY(), memberPos.getZ(), NetworkMapMarker.KIND_CONSOLE));
continue;
}
- if (member instanceof IShieldSink) {
+ if (member instanceof ISubsystemSink) {
putMarker(nextMarkers, new NetworkMapMarker(memberPos.getX(), memberPos.getY(), memberPos.getZ(), NetworkMapMarker.KIND_SINK));
continue;
}
- if (member instanceof IShieldSource) {
+ if (member instanceof ISubsystemSource) {
putMarker(nextMarkers, new NetworkMapMarker(memberPos.getX(), memberPos.getY(), memberPos.getZ(), NetworkMapMarker.KIND_SOURCE));
continue;
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldGenerator.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldGenerator.java
index 713d6962b..9281fd9bf 100644
--- a/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldGenerator.java
+++ b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityShieldGenerator.java
@@ -1,9 +1,8 @@
package com.github.stannismod.affs.te;
import com.github.stannismod.affs.config.ModConfig;
-import com.github.stannismod.affs.world.shield.IShieldSource;
+import com.github.stannismod.affs.world.shield.ShieldCondition;
import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.network.NetworkManager;
import net.minecraft.network.play.server.SPacketUpdateTileEntity;
@@ -17,8 +16,12 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemSource;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
-public class TileEntityShieldGenerator extends TileEntity implements ITickable, IShieldSource {
+public class TileEntityShieldGenerator extends TileEntity implements ITickable, ISubsystemSource {
public static final int CONVERSION_PER_TICK = 4_000;
private static final int CLIENT_SYNC_BASE_INTERVAL_TICKS = 20;
@@ -51,7 +54,7 @@ public void update() {
shieldProducedThisTick = 0;
shieldExtractedThisTick = 0;
- int convertible = Math.min(CONVERSION_PER_TICK, feStorage.getEnergyStored());
+ int convertible = Math.min(getConversionPerTick(), feStorage.getEnergyStored());
convertible = Math.min(convertible, shieldStorage.getMaxEnergyStored() - shieldStorage.getEnergyStored());
if (convertible > 0) {
feStorage.drainInternal(convertible);
@@ -69,8 +72,8 @@ public void update() {
public void onLoad() {
super.onLoad();
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.register(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.register(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] load generator at {} dim={}", pos, world.provider.getDimension());
}
@@ -80,8 +83,8 @@ public void onLoad() {
@Override
public void invalidate() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] invalidate generator at {} dim={}", pos, world.provider.getDimension());
}
@@ -92,8 +95,8 @@ public void invalidate() {
@Override
public void onChunkUnload() {
if (world != null && !world.isRemote) {
- ShieldNetworkRegistry.unregister(this);
- ShieldNetworkManager.markDirty(world);
+ SubsystemNetworkRegistry.unregister(this);
+ SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world);
if (com.github.stannismod.affs.AdvancedForceFieldSystem.LOG != null) {
com.github.stannismod.affs.AdvancedForceFieldSystem.LOG.info("[ShieldNetwork] chunk unload generator at {} dim={}", pos, world.provider.getDimension());
}
@@ -101,6 +104,11 @@ public void onChunkUnload() {
super.onChunkUnload();
}
+ @Override
+ public SubsystemNetworkDomain getNetworkDomain() {
+ return ShieldNetworkManager.DOMAIN;
+ }
+
@Override
public BlockPos getNodePos() {
return pos;
@@ -112,12 +120,12 @@ public net.minecraft.world.World getNodeWorld() {
}
@Override
- public int getAvailableShieldEnergy() {
+ public int getAvailable() {
return shieldStorage.getEnergyStored();
}
@Override
- public int extractShieldEnergy(int amount) {
+ public int extract(int amount) {
if (world == null || world.isRemote || amount <= 0) {
return 0;
}
@@ -142,8 +150,27 @@ public int getShieldStored() {
return shieldStorage.getEnergyStored();
}
+ /**
+ * How much FE this generator can turn into shield energy in one tick, in the condition it is in.
+ * A battered plant converts less: the rated figure scaled by the block's own damage stage, pulled
+ * from the world rather than pushed by whatever hit it.
+ */
+ public int getConversionPerTick() {
+ return ShieldCondition.derate(world, pos, CONVERSION_PER_TICK);
+ }
+
public int getShieldProductionPotential() {
- return Math.max(0, Math.min(CONVERSION_PER_TICK, Math.min(feStorage.getEnergyStored(), shieldStorage.getMaxEnergyStored() - shieldStorage.getEnergyStored())));
+ return Math.max(0, Math.min(getConversionPerTick(), Math.min(feStorage.getEnergyStored(), shieldStorage.getMaxEnergyStored() - shieldStorage.getEnergyStored())));
+ }
+
+ /**
+ * What the network should report as GENERATION, which is what this generator can convert this
+ * tick — not the buffer it happens to be sitting on. A readout that showed the buffer could not
+ * tell a running plant from a stopped one with a full tank.
+ */
+ @Override
+ public int getGenerationPerTick() {
+ return getShieldProductionPotential();
}
public int getMaxFeStored() {
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/contour/ContourFieldExplosionHandler.java b/affs/src/main/java/com/github/stannismod/affs/world/contour/ContourFieldExplosionHandler.java
index ec1747f3c..485a23812 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/contour/ContourFieldExplosionHandler.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/contour/ContourFieldExplosionHandler.java
@@ -1,8 +1,7 @@
package com.github.stannismod.affs.world.contour;
import com.github.stannismod.affs.te.TileEntityContourInjector;
-import com.github.stannismod.affs.world.shield.IShieldNetworkNode;
-import com.github.stannismod.affs.world.shield.ShieldNetworkRegistry;
+import com.github.stannismod.affs.world.shield.ShieldNetworkManager;
import net.minecraft.entity.Entity;
import net.minecraft.util.math.BlockPos;
import net.minecraft.world.World;
@@ -10,6 +9,8 @@
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import zmaster587.advancedRocketry.api.Constants;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkNode;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkRegistry;
import java.util.*;
@@ -27,7 +28,9 @@ public static void onExplosionDetonate(ExplosionEvent.Detonate event) {
}
Map> injectorBlocks = new LinkedHashMap<>();
- for (IShieldNetworkNode node : ShieldNetworkRegistry.snapshot()) {
+ // The registry is already keyed by domain, so naming it here is the whole filter — there is
+ // nothing else in this set to sort out.
+ for (ISubsystemNetworkNode node : SubsystemNetworkRegistry.snapshot(ShieldNetworkManager.DOMAIN)) {
if (!(node instanceof TileEntityContourInjector)) {
continue;
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldCable.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldCable.java
deleted file mode 100644
index 75c25cd47..000000000
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldCable.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.github.stannismod.affs.world.shield;
-
-public interface IShieldCable extends IShieldNetworkNode {
-
- int getThroughputPerTick();
-
- void addTransferredShield(int amount);
-}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkController.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkController.java
index 74b2e8d0e..8df32f47a 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkController.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkController.java
@@ -1,8 +1,14 @@
package com.github.stannismod.affs.world.shield;
-public interface IShieldNetworkController extends IShieldNetworkNode {
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkController;
- double getShieldEnergyResistanceBias();
+/**
+ * A shield console. This interface survives the collapse of the shield-network bridge layer because
+ * it carries a member no other domain has: the resistance bias, which is a real shield setting and
+ * not another name for something the primitive already provides.
+ */
+public interface IShieldNetworkController extends ISubsystemNetworkController {
- void applyNetworkState(ShieldNetworkState state);
+ /** How much of an impact's energy the network answers with; the console owns it, 0..1. */
+ double getShieldEnergyResistanceBias();
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkNode.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkNode.java
deleted file mode 100644
index 71f1f4ba7..000000000
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkNode.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.github.stannismod.affs.world.shield;
-
-import net.minecraft.util.math.BlockPos;
-import net.minecraft.world.World;
-
-public interface IShieldNetworkNode {
-
- World getNodeWorld();
-
- BlockPos getNodePos();
-}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSink.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSink.java
deleted file mode 100644
index 2f96f92c6..000000000
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSink.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.github.stannismod.affs.world.shield;
-
-public interface IShieldSink extends IShieldNetworkNode {
-
- int getRequestedShieldEnergy();
-
- int getFreeShieldCapacity();
-
- int receiveShieldEnergy(int amount);
-
- /**
- * Redistribution priority (D134-5): under an energy deficit the network satisfies higher-priority
- * sinks first, so a player can pour a starved supply into the emitters that matter ("all power to
- * the rear shields"). Equal priority shares what is left. Default 0 = normal; a bulk store keeps the
- * default so real emitters, when raised, out-rank it.
- */
- default int getShieldPriority() {
- return 0;
- }
-}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSource.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSource.java
deleted file mode 100644
index 9087bcb0f..000000000
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSource.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.github.stannismod.affs.world.shield;
-
-public interface IShieldSource extends IShieldNetworkNode {
-
- int getAvailableShieldEnergy();
-
- int extractShieldEnergy(int amount);
-}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldCondition.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldCondition.java
new file mode 100644
index 000000000..f050faf7a
--- /dev/null
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldCondition.java
@@ -0,0 +1,79 @@
+package com.github.stannismod.affs.world.shield;
+
+import com.github.stannismod.affs.config.ModConfig;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.damage.DamageState;
+
+/**
+ * What a shield block's CONDITION does to what it delivers.
+ *
+ * The stage is PULLED from the world, never pushed: nothing tells a shield generator it was shot.
+ * A node reads the stage of the block it lives in when it needs it, which is one map lookup, and
+ * which survives a save, a chunk reload and a hull being reassembled for free — because the stage
+ * does. Nothing about damage appears in the network solve, and nothing about shields appears in the
+ * damage engine.
+ *
+ * Two different consequences live here because a shield has two kinds of organ:
+ *
+ * - a scalar node — a generator converts less, a cable carries less, an accumulator holds
+ * less — which is the same shape a worn motor's thrust already has;
+ * - an emitter, whose consequence is its RADIUS. A shrinking sphere is the one failure a
+ * player can see BEFORE it matters: the shell draws in, zone ownership shifts, and a stretch
+ * of hull that used to be covered stops being covered while there is still time to react.
+ *
+ *
+ * Neither is gated on the wear config flag. That flag gates where wear ACCRUES; a consequence read
+ * unconditionally is the whole reason a ship shot to pieces stops working, and gating it would let a
+ * modpack that turned wear off field indestructible shields.
+ */
+public final class ShieldCondition {
+
+ private ShieldCondition() {
+ }
+
+ /**
+ * How much of its rated delivery a scalar shield node still provides: 1.0 pristine, falling to
+ * {@code 1 - maxPenalty} at the last stage before destruction. Never negative — a wrecked node
+ * delivers nothing, it does not consume.
+ */
+ public static double scale(double damageFraction, double maxPenalty) {
+ if (maxPenalty <= 0.0D) {
+ return 1.0D;
+ }
+ double fraction = damageFraction < 0.0D ? 0.0D : (damageFraction > 1.0D ? 1.0D : damageFraction);
+ double factor = 1.0D - maxPenalty * fraction;
+ return factor < 0.0D ? 0.0D : factor;
+ }
+
+ /**
+ * The radius a damaged emitter actually projects, given the one it was DECLARED to hold.
+ *
+ * Rounded DOWN so that any real damage is visible rather than absorbed by rounding, and floored
+ * at {@code minRadius}: an emitter that still stands still projects something, and the rung below
+ * that is destruction, which removes the block.
+ */
+ public static int shrinkRadius(int declaredRadius, double damageFraction, double maxPenalty, int minRadius) {
+ int shrunk = (int) Math.floor(declaredRadius * scale(damageFraction, maxPenalty));
+ return Math.max(minRadius, Math.min(declaredRadius, shrunk));
+ }
+
+ /** Delivery factor for the scalar node at {@code pos}, read from the world's own damage record. */
+ public static double deliveryFactor(World world, BlockPos pos) {
+ return scale(DamageState.getDamageFraction(world, pos), ModConfig.shieldNodeDamagePenaltyMax);
+ }
+
+ /** Scale a scalar node's rated per-tick figure by the condition of the block at {@code pos}. */
+ public static int derate(World world, BlockPos pos, int rated) {
+ if (rated <= 0) {
+ return 0;
+ }
+ return (int) Math.round(rated * deliveryFactor(world, pos));
+ }
+
+ /** The radius the emitter at {@code pos} actually projects, given its declared one. */
+ public static int effectiveRadius(World world, BlockPos pos, int declaredRadius, int minRadius) {
+ return shrinkRadius(declaredRadius, DamageState.getDamageFraction(world, pos),
+ ModConfig.emitterRadiusDamagePenaltyMax, minRadius);
+ }
+}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldControl.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldControl.java
index cc01802a7..af9c8f46f 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldControl.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldControl.java
@@ -116,7 +116,7 @@ public static int applyGroups(World world, BlockPos pos) {
if (group == null) {
continue;
}
- if (emitter.getShieldPriority() != group.getPriority()) {
+ if (emitter.getPriority() != group.getPriority()) {
emitter.setPriority(group.getPriority());
}
applied++;
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkManager.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkManager.java
index 84770a255..b3c77a6d2 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkManager.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkManager.java
@@ -1,668 +1,73 @@
package com.github.stannismod.affs.world.shield;
import com.github.stannismod.affs.AdvancedForceFieldSystem;
-import com.github.stannismod.affs.te.TileEntityFieldGenerator;
-import com.github.stannismod.affs.te.TileEntityShieldCable;
-import com.github.stannismod.affs.te.TileEntityShieldGenerator;
-import net.minecraft.util.EnumFacing;
import net.minecraft.util.math.BlockPos;
import net.minecraft.world.World;
-import net.minecraftforge.event.world.WorldEvent;
-import net.minecraftforge.fml.common.Mod;
-import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
-import net.minecraftforge.fml.common.gameevent.TickEvent;
-import zmaster587.advancedRocketry.api.Constants;
-
-import java.util.*;
-
-// Registered under AR's container: the vendored guest modid "affs" is not a loaded mod, so a
-// @EventBusSubscriber keyed on it is skipped by AutomaticEventSubscriber (modid must equal the
-// owning container). All AFFS runtime handlers subscribe under Constants.modId, like the block registrar.
-@Mod.EventBusSubscriber(modid = Constants.modId)
+import org.apache.logging.log4j.Logger;
+import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkController;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkManager;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState;
+
+import java.util.List;
+
+/**
+ * The shield domain. Everything structural — the connected-component graph, the max-flow solve, the
+ * priority tiers, the per-tick statistics — lives in the shared subsystem-network primitive; what is
+ * genuinely shield-specific is the resistance bias a console sets, and that is all this adds.
+ */
public final class ShieldNetworkManager {
- private static final int INF = 1_000_000_000;
- private static final int STATUS_DISCONNECTED = 1;
- private static final int STATUS_SOURCE_LIMITED = 2;
- private static final int STATUS_SINK_LIMITED = 3;
- private static final int STATUS_CABLE_LIMITED = 4;
- private static final int STATUS_BALANCED = 5;
- private static final Map WORLD_STATES = new HashMap<>();
-
- private ShieldNetworkManager() {
- }
-
- public static void markDirty(World world) {
- if (world == null || world.isRemote) {
- return;
- }
- getState(world).dirty = true;
- }
-
- public static ShieldNetworkState getState(World world, BlockPos pos) {
- if (world == null || pos == null) {
- return null;
- }
- WorldState state = WORLD_STATES.get(world.provider.getDimension());
- return state == null ? null : state.stateByPos.get(pos);
- }
-
- public static void setShieldEnergyResistanceBias(World world, BlockPos pos, double bias) {
- if (world == null || world.isRemote || pos == null) {
- return;
- }
- WorldState state = getState(world);
- ShieldNetworkState networkState = state.stateByPos.get(pos);
- if (networkState != null) {
- networkState.setShieldEnergyResistanceBias(bias);
- }
- }
-
- @SubscribeEvent
- public static void onWorldTick(TickEvent.WorldTickEvent event) {
- if (event.phase != TickEvent.Phase.END) {
- return;
- }
- World world = event.world;
- if (world == null || world.isRemote) {
- return;
- }
-
- WorldState state = getState(world);
- if (state.dirty) {
- // Topology changes are expensive; only rebuild adjacency when the network actually changed.
- state.rebuild(world);
- }
- // Capacities and demands still change every tick, so max-flow is solved against the cached topology each tick.
- state.solve(world);
- }
-
- @SubscribeEvent
- public static void onWorldUnload(WorldEvent.Unload event) {
- World world = event.getWorld();
- if (world == null || world.isRemote) {
- return;
+ /** The domain handle. Shield nodes register under it; nothing else joins these graphs. */
+ public static final SubsystemNetworkDomain DOMAIN = new SubsystemNetworkDomain("Shield") {
+ @Override
+ public SubsystemNetworkState newState() {
+ return new ShieldNetworkState();
}
- WORLD_STATES.remove(world.provider.getDimension());
- ShieldNetworkRegistry.clearWorld(world);
- }
- private static WorldState getState(World world) {
- int dim = world.provider.getDimension();
- WorldState state = WORLD_STATES.get(dim);
- if (state == null) {
- state = new WorldState();
- WORLD_STATES.put(dim, state);
- }
- return state;
- }
-
- private static final class WorldState {
- private boolean dirty = true;
- private final List components = new ArrayList<>();
- private final Map stateByPos = new HashMap<>();
-
- private void rebuild(World world) {
- components.clear();
- Map previousStateByPos = new HashMap<>(stateByPos);
- Set consumedStates = new HashSet<>();
- stateByPos.clear();
-
- Set nodes = ShieldNetworkRegistry.snapshot();
- Map cables = new HashMap<>();
- Map sources = new HashMap<>();
- Map sinks = new HashMap<>();
- Map controllers = new HashMap<>();
- int skippedNull = 0;
- int skippedWorld = 0;
-
- for (IShieldNetworkNode node : nodes) {
- if (node == null) {
- skippedNull++;
- continue;
- }
- World nodeWorld = node.getNodeWorld();
- if (nodeWorld == null || nodeWorld.provider.getDimension() != world.provider.getDimension()) {
- skippedWorld++;
- continue;
- }
- // Roles are not exclusive: an accumulator is both a source and a sink, so it must land
- // in both maps. A block that is a cable is only a cable (transport, not a store).
- BlockPos nodePos = node.getNodePos();
- if (node instanceof IShieldCable) {
- cables.put(nodePos, (IShieldCable) node);
- }
- if (node instanceof IShieldSource) {
- sources.put(nodePos, (IShieldSource) node);
- }
- if (node instanceof IShieldSink) {
- sinks.put(nodePos, (IShieldSink) node);
- }
- if (node instanceof IShieldNetworkController) {
- controllers.put(nodePos, (IShieldNetworkController) node);
- }
- }
-
- if (AdvancedForceFieldSystem.LOG != null) {
- AdvancedForceFieldSystem.LOG.info(
- "[ShieldNetwork] rebuild dim={} snapshot={} skippedNull={} skippedWorld={} cables={} sources={} sinks={} controllers={}",
- world.provider.getDimension(),
- nodes.size(),
- skippedNull,
- skippedWorld,
- cables.size(),
- sources.size(),
- sinks.size(),
- controllers.size()
- );
- }
-
- // A network is a connected component over block-adjacency of ALL shield nodes, not just
- // cables: two directly-adjacent nodes (e.g. a generator touching an emitter) form one
- // network with no cable in between. Cables are a scaling/reach tool, not a requirement.
- Set allPositions = new HashSet<>();
- allPositions.addAll(cables.keySet());
- allPositions.addAll(sources.keySet());
- allPositions.addAll(sinks.keySet());
- allPositions.addAll(controllers.keySet());
-
- Set visited = new HashSet<>();
- for (BlockPos startPos : allPositions) {
- if (!visited.add(startPos)) {
- continue;
- }
-
- Set component = new HashSet<>();
- ArrayDeque queue = new ArrayDeque<>();
- queue.add(startPos);
-
- while (!queue.isEmpty()) {
- BlockPos current = queue.removeFirst();
- component.add(current);
- for (EnumFacing facing : EnumFacing.VALUES) {
- BlockPos next = current.offset(facing);
- if (allPositions.contains(next) && visited.add(next)) {
- queue.add(next);
- }
- }
- }
-
- List componentCables = new ArrayList<>();
- List componentSources = new ArrayList<>();
- List componentSinks = new ArrayList<>();
- List componentControllers = new ArrayList<>();
- for (BlockPos pos : component) {
- IShieldCable cable = cables.get(pos);
- if (cable != null) {
- componentCables.add(new CableNode(pos, cable));
- }
- IShieldSource source = sources.get(pos);
- if (source != null) {
- componentSources.add(new SourceNode(pos, source));
- }
- IShieldSink sink = sinks.get(pos);
- if (sink != null) {
- componentSinks.add(new SinkNode(pos, sink));
- }
- IShieldNetworkController controller = controllers.get(pos);
- if (controller != null) {
- componentControllers.add(new ControllerNode(pos, controller));
- }
- }
-
- List componentMemberPositions = new ArrayList<>(component);
- BlockPos anchor = componentCables.isEmpty() ? startPos : componentCables.get(0).pos;
-
- ShieldNetworkState state = findExistingState(componentMemberPositions, previousStateByPos, consumedStates);
- if (state == null) {
- state = new ShieldNetworkState();
- }
- seedShieldEnergyBiasFromControllers(state, componentControllers);
- state.clearMembers();
- state.setRoot(anchor);
- for (BlockPos pos : componentMemberPositions) {
- state.addMember(pos);
- stateByPos.put(pos, state);
- }
-
- components.add(new ComponentTopology(state, componentCables, componentSources, componentSinks, componentControllers));
- if (AdvancedForceFieldSystem.LOG != null) {
- AdvancedForceFieldSystem.LOG.info(
- "[ShieldNetwork] component anchor={} cables={} sources={} sinks={} controllers={}",
- anchor,
- componentCables.size(),
- componentSources.size(),
- componentSinks.size(),
- componentControllers.size()
- );
- }
- }
-
- dirty = false;
- }
-
- private ShieldNetworkState findExistingState(List memberPositions, Map previousStateByPos, Set consumedStates) {
- ShieldNetworkState best = null;
- for (BlockPos pos : memberPositions) {
- ShieldNetworkState candidate = previousStateByPos.get(pos);
- if (candidate != null) {
- best = candidate;
- break;
- }
- }
- if (best == null) {
- return null;
- }
- if (consumedStates.add(best)) {
- return best;
- }
- return best.copy();
- }
-
- private void seedShieldEnergyBiasFromControllers(ShieldNetworkState state, List controllers) {
- if (state == null || controllers == null || controllers.isEmpty()) {
- return;
- }
- for (ControllerNode controller : controllers) {
- if (controller == null || controller.controller == null) {
- continue;
- }
- state.setShieldEnergyResistanceBias(controller.controller.getShieldEnergyResistanceBias());
- return;
- }
- }
-
- private void solve(World world) {
- if (components.isEmpty()) {
- return;
- }
-
- for (ComponentTopology component : components) {
- component.solve();
- }
- }
- }
-
- private static final class ComponentTopology {
- private final ShieldNetworkState state;
- private final List cables;
- private final List sources;
- private final List sinks;
- private final List controllers;
-
- private ComponentTopology(ShieldNetworkState state, List cables, List sources, List sinks, List controllers) {
- this.state = state;
- this.cables = cables;
- this.sources = sources;
- this.sinks = sinks;
- this.controllers = controllers;
- }
-
- private void solve() {
- if (sources.isEmpty() || sinks.isEmpty()) {
- publishDisconnected();
+ @Override
+ public void onComponentRebuilt(SubsystemNetworkState state, List controllers) {
+ if (!(state instanceof ShieldNetworkState) || controllers == null || controllers.isEmpty()) {
return;
}
-
- MaxFlowSolver solver = new MaxFlowSolver();
- int superSource = solver.addNode();
- int superSink = solver.addNode();
-
- // Unified port model: every node exposes a supply port (energy leaves here) and/or a demand
- // port (energy enters here). A cable owns both, joined internally by its throughput edge; a
- // source owns only supply; a sink only demand; an accumulator owns both. Adjacent nodes are
- // linked supplyOut(A) -> demandIn(B) at INF, so a source touching a sink connects with no
- // cable, while a cable's finite in->out edge is the only throttled link.
- Map supplyOut = new HashMap<>();
- Map demandIn = new HashMap<>();
- Map cableThroughputRefs = new HashMap<>();
- int totalCableCapacity = 0;
-
- for (CableNode cable : cables) {
- int in = solver.addNode();
- int out = solver.addNode();
- demandIn.put(cable.pos, in);
- supplyOut.put(cable.pos, out);
- int throughput = Math.max(0, cable.cable.getThroughputPerTick());
- totalCableCapacity += throughput;
- cableThroughputRefs.put(cable.pos, solver.addEdge(in, out, throughput, cable.cable));
- }
-
- List sourceRefs = new ArrayList<>();
- int totalSourceAvailable = 0;
- int totalGenerationPerTick = 0;
- for (SourceNode source : sources) {
- int sourceNode = solver.addNode();
- supplyOut.put(source.pos, sourceNode);
- int available = Math.max(0, source.source.getAvailableShieldEnergy());
- totalSourceAvailable += available;
- totalGenerationPerTick += estimateSourceGenerationPerTick(source.source);
- sourceRefs.add(solver.addEdge(superSource, sourceNode, available, source.source));
- }
-
- List sinkRefs = new ArrayList<>();
- List sinkDemand = new ArrayList<>(); // parallel to sinkRefs: [requested, priority]
- int totalSinkRequested = 0;
- int totalConsumptionPerTick = 0;
- for (SinkNode sink : sinks) {
- int sinkNode = solver.addNode();
- demandIn.put(sink.pos, sinkNode);
- int requested = Math.max(0, sink.sink.getRequestedShieldEnergy());
- totalSinkRequested += requested;
- totalConsumptionPerTick += estimateSinkConsumptionPerTick(sink.sink);
- // Open the demand edge at capacity 0; priority tiers below raise it to `requested`.
- sinkRefs.add(solver.addEdge(sinkNode, superSink, 0, sink.sink));
- sinkDemand.add(new int[]{requested, sink.sink.getShieldPriority()});
- }
-
- // Link each supply port to the demand port of every adjacent node (INF): transport across
- // touching blocks, including the direct source->sink edge that makes cables optional.
- for (Map.Entry entry : supplyOut.entrySet()) {
- int from = entry.getValue();
- for (EnumFacing facing : EnumFacing.VALUES) {
- Integer to = demandIn.get(entry.getKey().offset(facing));
- if (to != null) {
- solver.addEdge(from, to, INF, null);
- }
- }
- }
-
- // Priority-tiered redistribution (D134-5): open the sink demand edges in descending priority
- // order, augmenting the flow at each tier, so a scarce supply fills the highest-priority
- // emitters first and equal-priority emitters share what remains. With a single priority (the
- // default — every emitter in one implicit group) this is one pass, identical to plain max-flow.
- java.util.TreeSet priorityTiers = new java.util.TreeSet<>(java.util.Collections.reverseOrder());
- for (int[] d : sinkDemand) {
- priorityTiers.add(d[1]);
- }
- int maxFlow = 0;
- for (int tier : priorityTiers) {
- for (int i = 0; i < sinkRefs.size(); i++) {
- if (sinkDemand.get(i)[1] == tier) {
- sinkRefs.get(i).setCapacity(sinkDemand.get(i)[0]);
- }
- }
- maxFlow += solver.maxFlow(superSource, superSink);
- }
-
- boolean hasCables = !cables.isEmpty();
- int saturatedCables = 0;
- BlockPos bottleneckCable = state.getRoot();
- int bottleneckUtilizationPermille = 0;
- for (MaxFlowSolver.EdgeRef ref : sourceRefs) {
- int used = ref.edge.flow;
- if (used > 0) {
- ((IShieldSource) ref.owner).extractShieldEnergy(used);
- }
- }
-
- for (MaxFlowSolver.EdgeRef ref : sinkRefs) {
- int used = ref.edge.flow;
- if (used > 0) {
- ((IShieldSink) ref.owner).receiveShieldEnergy(used);
- }
- }
-
- for (Map.Entry entry : cableThroughputRefs.entrySet()) {
- int used = Math.max(0, entry.getValue().edge.flow);
- if (used > 0) {
- ((IShieldCable) entry.getValue().owner).addTransferredShield(used);
- }
- int capacity = Math.max(0, entry.getValue().edge.capacity);
- int permille = capacity <= 0 ? 0 : (int) Math.round((used * 1000.0D) / capacity);
- if (permille >= bottleneckUtilizationPermille) {
- bottleneckUtilizationPermille = permille;
- bottleneckCable = entry.getKey();
- }
- if (used >= capacity && capacity > 0) {
- saturatedCables++;
- }
- }
-
- state.setStatistics(
- true,
- statusFor(totalSourceAvailable, totalSinkRequested, maxFlow, totalCableCapacity, hasCables),
- state.getRoot(),
- cables.size(),
- sources.size(),
- sinks.size(),
- totalSourceAvailable,
- totalSinkRequested,
- totalCableCapacity,
- maxFlow,
- saturatedCables,
- bottleneckCable,
- bottleneckUtilizationPermille,
- totalGenerationPerTick,
- totalConsumptionPerTick
- );
-
- pushNetworkStateToControllers();
-
- for (CableNode cable : cables) {
- if (cable.cable instanceof TileEntityShieldCable) {
- ((TileEntityShieldCable) cable.cable).setNetworkStats(
- true,
- statusFor(totalSourceAvailable, totalSinkRequested, maxFlow, totalCableCapacity, hasCables),
- state.getRoot(),
- cables.size(),
- sources.size(),
- sinks.size(),
- totalSourceAvailable,
- totalSinkRequested,
- totalCableCapacity,
- maxFlow,
- saturatedCables,
- bottleneckCable,
- bottleneckUtilizationPermille
- );
- }
- }
- }
-
- private void publishDisconnected() {
- BlockPos anchor = state.getRoot();
- state.setStatistics(false, STATUS_DISCONNECTED, anchor, cables.size(), sources.size(), sinks.size(), 0, 0, 0, 0, 0, anchor, 0, 0, 0);
- for (CableNode cable : cables) {
- if (cable.cable instanceof TileEntityShieldCable) {
- ((TileEntityShieldCable) cable.cable).setNetworkStats(
- false,
- STATUS_DISCONNECTED,
- anchor,
- cables.size(),
- sources.size(),
- sinks.size(),
- 0,
- 0,
- 0,
- 0,
- 0,
- anchor,
- 0
- );
+ for (ISubsystemNetworkController controller : controllers) {
+ if (controller instanceof IShieldNetworkController) {
+ ((ShieldNetworkState) state).setShieldEnergyResistanceBias(
+ ((IShieldNetworkController) controller).getShieldEnergyResistanceBias());
+ return;
}
}
}
- private void pushNetworkStateToControllers() {
- for (ControllerNode controller : controllers) {
- controller.controller.applyNetworkState(state);
- }
- }
-
- private int estimateSourceGenerationPerTick(IShieldSource source) {
- if (source instanceof TileEntityShieldGenerator) {
- return ((TileEntityShieldGenerator) source).getShieldProductionPotential();
- }
- return Math.max(0, source.getAvailableShieldEnergy());
+ @Override
+ public Logger getLogger() {
+ return AdvancedForceFieldSystem.LOG;
}
+ };
- private int estimateSinkConsumptionPerTick(IShieldSink sink) {
- if (sink instanceof TileEntityFieldGenerator) {
- return ((TileEntityFieldGenerator) sink).getShieldDrainThisTick();
- }
- return Math.max(0, sink.getRequestedShieldEnergy());
- }
-
- private int statusFor(int sourceAvailable, int sinkRequested, int maxFlow, int cableCapacity, boolean hasCables) {
- if (sourceAvailable <= 0 || sinkRequested <= 0) {
- return STATUS_DISCONNECTED;
- }
- int limiting = Math.min(sourceAvailable, sinkRequested);
- if (hasCables && maxFlow < limiting && maxFlow < cableCapacity) {
- return STATUS_CABLE_LIMITED;
- }
- if (sourceAvailable <= sinkRequested && maxFlow >= sourceAvailable) {
- return STATUS_SOURCE_LIMITED;
- }
- if (sinkRequested < sourceAvailable && maxFlow >= sinkRequested) {
- return STATUS_SINK_LIMITED;
- }
- return STATUS_BALANCED;
- }
- }
-
- private static final class CableNode {
- private final BlockPos pos;
- private final IShieldCable cable;
-
- private CableNode(BlockPos pos, IShieldCable cable) {
- this.pos = pos;
- this.cable = cable;
- }
- }
-
- private static final class SourceNode {
- private final BlockPos pos;
- private final IShieldSource source;
-
- private SourceNode(BlockPos pos, IShieldSource source) {
- this.pos = pos;
- this.source = source;
- }
- }
-
- private static final class SinkNode {
- private final BlockPos pos;
- private final IShieldSink sink;
-
- private SinkNode(BlockPos pos, IShieldSink sink) {
- this.pos = pos;
- this.sink = sink;
- }
+ private ShieldNetworkManager() {
}
- private static final class ControllerNode {
- private final BlockPos pos;
- private final IShieldNetworkController controller;
-
- private ControllerNode(BlockPos pos, IShieldNetworkController controller) {
- this.pos = pos;
- this.controller = controller;
- }
+ /**
+ * The shield network at this position, as the shield's own state type.
+ *
+ * Kept where a plain domain-supplying forwarder was not: this one narrows the shared state to
+ * the subclass that carries the resistance bias, so every caller does not repeat the same cast.
+ * Callers that only need to name the domain — marking the topology dirty, registering a node —
+ * say {@link #DOMAIN} at the call site instead, exactly as the ventilation domain does.
+ */
+ public static ShieldNetworkState getState(World world, BlockPos pos) {
+ SubsystemNetworkState state = SubsystemNetworkManager.getState(DOMAIN, world, pos);
+ return state instanceof ShieldNetworkState ? (ShieldNetworkState) state : null;
}
- private static final class MaxFlowSolver {
- private final List> graph = new ArrayList<>();
-
- private int addNode() {
- graph.add(new ArrayList<>());
- return graph.size() - 1;
- }
-
- private EdgeRef addEdge(int from, int to, int capacity, Object owner) {
- Edge forward = new Edge(to, capacity);
- Edge backward = new Edge(from, 0);
- forward.rev = graph.get(to).size();
- backward.rev = graph.get(from).size();
- graph.get(from).add(forward);
- graph.get(to).add(backward);
- return new EdgeRef(forward, owner);
- }
-
- private int maxFlow(int source, int sink) {
- int flow = 0;
- int[] level = new int[graph.size()];
- while (bfs(source, sink, level)) {
- int[] next = new int[graph.size()];
- int pushed;
- while ((pushed = dfs(source, sink, INF, level, next)) > 0) {
- flow += pushed;
- }
- }
- return flow;
- }
-
- private boolean bfs(int source, int sink, int[] level) {
- for (int i = 0; i < level.length; i++) {
- level[i] = -1;
- }
- ArrayDeque queue = new ArrayDeque<>();
- level[source] = 0;
- queue.add(source);
- while (!queue.isEmpty()) {
- int v = queue.removeFirst();
- for (Edge edge : graph.get(v)) {
- if (edge.remaining() > 0 && level[edge.to] < 0) {
- level[edge.to] = level[v] + 1;
- queue.add(edge.to);
- }
- }
- }
- return level[sink] >= 0;
- }
-
- private int dfs(int v, int sink, int pushed, int[] level, int[] next) {
- if (v == sink) {
- return pushed;
- }
- List edges = graph.get(v);
- for (; next[v] < edges.size(); next[v]++) {
- Edge edge = edges.get(next[v]);
- if (edge.remaining() <= 0 || level[edge.to] != level[v] + 1) {
- continue;
- }
- int tr = dfs(edge.to, sink, Math.min(pushed, edge.remaining()), level, next);
- if (tr <= 0) {
- continue;
- }
- edge.flow += tr;
- graph.get(edge.to).get(edge.rev).flow -= tr;
- return tr;
- }
- return 0;
- }
-
- private static final class Edge {
- private final int to;
- // Not final: a sink's demand edge is opened tier-by-tier for priority redistribution, so its
- // capacity is raised from 0 to the requested amount between max-flow augmentations.
- private int capacity;
- private int flow;
- private int rev;
-
- private Edge(int to, int capacity) {
- this.to = to;
- this.capacity = capacity;
- }
-
- private int remaining() {
- return capacity - flow;
- }
+ public static void setShieldEnergyResistanceBias(World world, BlockPos pos, double bias) {
+ if (world == null || world.isRemote) {
+ return;
}
-
- private static final class EdgeRef {
- private final Edge edge;
- private final Object owner;
-
- private EdgeRef(Edge edge, Object owner) {
- this.edge = edge;
- this.owner = owner;
- }
-
- private void setCapacity(int capacity) {
- edge.capacity = capacity;
- }
+ ShieldNetworkState state = getState(world, pos);
+ if (state != null) {
+ state.setShieldEnergyResistanceBias(bias);
}
}
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkRegistry.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkRegistry.java
deleted file mode 100644
index 000cb62f3..000000000
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkRegistry.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.github.stannismod.affs.world.shield;
-
-import com.github.stannismod.affs.AdvancedForceFieldSystem;
-import net.minecraft.world.World;
-
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-
-public final class ShieldNetworkRegistry {
-
- private static final Set NODES = new HashSet<>();
-
- private ShieldNetworkRegistry() {
- }
-
- public static synchronized void register(IShieldNetworkNode node) {
- if (node != null) {
- NODES.add(node);
- log("register", node);
- }
- }
-
- public static synchronized void unregister(IShieldNetworkNode node) {
- if (node != null) {
- NODES.remove(node);
- log("unregister", node);
- }
- }
-
- public static synchronized Set snapshot() {
- return Collections.unmodifiableSet(new HashSet<>(NODES));
- }
-
- public static synchronized void clearWorld(World world) {
- if (world == null) {
- return;
- }
- int dim = world.provider.getDimension();
- int before = NODES.size();
- NODES.removeIf(node -> node != null && matchesDimension(node.getNodeWorld(), dim));
- if (before != NODES.size()) {
- logMessage("clearWorld dim=" + dim + " removed=" + (before - NODES.size()) + " remaining=" + NODES.size());
- }
- }
-
- private static void log(String action, IShieldNetworkNode node) {
- if (node == null) {
- return;
- }
- String worldInfo = node.getNodeWorld() == null ? "null" : "dim=" + node.getNodeWorld().provider.getDimension();
- logMessage(action + " " + node.getClass().getSimpleName() + " pos=" + node.getNodePos() + " " + worldInfo + " total=" + NODES.size());
- }
-
- private static void logMessage(String message) {
- if (AdvancedForceFieldSystem.LOG != null) {
- AdvancedForceFieldSystem.LOG.info("[ShieldNetworkRegistry] {}", message);
- }
- }
-
- private static boolean matchesDimension(World nodeWorld, int dimension) {
- return nodeWorld != null && nodeWorld.provider.getDimension() == dimension;
- }
-}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkState.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkState.java
index c95d76567..4fcc86432 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkState.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkState.java
@@ -1,123 +1,24 @@
package com.github.stannismod.affs.world.shield;
import com.github.stannismod.affs.config.ModConfig;
-import net.minecraft.util.math.BlockPos;
+import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState;
-import java.util.HashSet;
-import java.util.Set;
+/**
+ * The shared network state plus the one thing that is shield-specific: how much of an impact's
+ * energy the network answers with, which a console edits and the whole network then obeys.
+ */
+public final class ShieldNetworkState extends SubsystemNetworkState {
-public final class ShieldNetworkState {
-
- private BlockPos root = BlockPos.ORIGIN;
- private final Set memberPositions = new HashSet<>();
- private boolean connected;
- private int status;
- private int cableCount;
- private int sourceCount;
- private int sinkCount;
- private int sourceAvailable;
- private int sinkRequested;
- private int cableCapacity;
- private int deliveredFlow;
- private int saturatedCables;
- private int generationPerTick;
- private int consumptionPerTick;
- private BlockPos bottleneck = BlockPos.ORIGIN;
- private int bottleneckUtilizationPermille;
private double shieldEnergyResistanceBias = ModConfig.shieldEnergyResistanceBias;
+ @Override
public ShieldNetworkState copy() {
ShieldNetworkState copy = new ShieldNetworkState();
- copy.root = root;
- copy.memberPositions.addAll(memberPositions);
- copy.connected = connected;
- copy.status = status;
- copy.cableCount = cableCount;
- copy.sourceCount = sourceCount;
- copy.sinkCount = sinkCount;
- copy.sourceAvailable = sourceAvailable;
- copy.sinkRequested = sinkRequested;
- copy.cableCapacity = cableCapacity;
- copy.deliveredFlow = deliveredFlow;
- copy.saturatedCables = saturatedCables;
- copy.generationPerTick = generationPerTick;
- copy.consumptionPerTick = consumptionPerTick;
- copy.bottleneck = bottleneck;
- copy.bottleneckUtilizationPermille = bottleneckUtilizationPermille;
+ copyInto(copy);
copy.shieldEnergyResistanceBias = shieldEnergyResistanceBias;
return copy;
}
- public void clearMembers() {
- memberPositions.clear();
- }
-
- public void addMember(BlockPos pos) {
- if (pos != null) {
- memberPositions.add(pos);
- }
- }
-
- public BlockPos getRoot() {
- return root;
- }
-
- public void setRoot(BlockPos root) {
- this.root = root == null ? BlockPos.ORIGIN : root;
- }
-
- public boolean isConnected() {
- return connected;
- }
-
- public int getStatus() {
- return status;
- }
-
- public int getCableCount() {
- return cableCount;
- }
-
- public int getSourceCount() {
- return sourceCount;
- }
-
- public int getSinkCount() {
- return sinkCount;
- }
-
- public int getSourceAvailable() {
- return sourceAvailable;
- }
-
- public int getSinkRequested() {
- return sinkRequested;
- }
-
- public int getCableCapacity() {
- return cableCapacity;
- }
-
- public int getDeliveredFlow() {
- return deliveredFlow;
- }
-
- public int getSaturatedCables() {
- return saturatedCables;
- }
-
- public int getGenerationPerTick() {
- return generationPerTick;
- }
-
- public int getConsumptionPerTick() {
- return consumptionPerTick;
- }
-
- public int getBottleneckUtilizationPermille() {
- return bottleneckUtilizationPermille;
- }
-
public double getShieldEnergyResistanceBias() {
return shieldEnergyResistanceBias;
}
@@ -126,31 +27,6 @@ public void setShieldEnergyResistanceBias(double shieldEnergyResistanceBias) {
this.shieldEnergyResistanceBias = clamp01(shieldEnergyResistanceBias);
}
- public Set getMemberPositions() {
- return new HashSet<>(memberPositions);
- }
-
- public void setStatistics(boolean connected, int status, BlockPos root, int cableCount, int sourceCount, int sinkCount,
- int sourceAvailable, int sinkRequested, int cableCapacity, int deliveredFlow,
- int saturatedCables, BlockPos bottleneck, int bottleneckUtilizationPermille,
- int generationPerTick, int consumptionPerTick) {
- this.connected = connected;
- this.status = status;
- this.root = root == null ? BlockPos.ORIGIN : root;
- this.cableCount = cableCount;
- this.sourceCount = sourceCount;
- this.sinkCount = sinkCount;
- this.sourceAvailable = sourceAvailable;
- this.sinkRequested = sinkRequested;
- this.cableCapacity = cableCapacity;
- this.deliveredFlow = deliveredFlow;
- this.saturatedCables = saturatedCables;
- this.bottleneck = bottleneck == null ? BlockPos.ORIGIN : bottleneck;
- this.bottleneckUtilizationPermille = bottleneckUtilizationPermille;
- this.generationPerTick = generationPerTick;
- this.consumptionPerTick = consumptionPerTick;
- }
-
private static double clamp01(double value) {
if (value < 0.0D) {
return 0.0D;
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrike.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrike.java
index 237e0caec..8f8f09279 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrike.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrike.java
@@ -13,6 +13,15 @@
* is the strike's own declared energy in shield-energy-equivalent units, before the shield applies its
* kind and tier multipliers. A source that reports weapon damage rather than energy can convert
* via {@link #fromDamage} using the tunable {@code shieldStrikeDamageToEnergyFactor} (D134-2, axis G).
+ *
+ * A strike may additionally declare the travelling body it carries, as a velocity vector: its
+ * presence IS the statement "there is a body here", and its absence the statement that there is not.
+ * That distinction is what a shot which exists as a registry record rather than as a Forge {@code Entity}
+ * needs — the body is real, it simply is not something the field's per-tick entity scan can see. A
+ * declared kinetic strike with a body is mirrored off the shell by
+ * {@link ShieldStrikeService#resolve}; one without is absorbed, as it always was. The energy stays
+ * declared either way: a velocity being available is not a reason to start inferring what the caller can
+ * state.
*/
public final class ShieldStrike {
@@ -22,15 +31,22 @@ public final class ShieldStrike {
private final int impactEnergy;
private final ShieldStrikeKind kind;
private final boolean unblockable;
+ private final Vec3d bodyVelocity;
public ShieldStrike(Vec3d origin, Vec3d direction, double maxDistance, int impactEnergy,
ShieldStrikeKind kind, boolean unblockable) {
+ this(origin, direction, maxDistance, impactEnergy, kind, unblockable, null);
+ }
+
+ public ShieldStrike(Vec3d origin, Vec3d direction, double maxDistance, int impactEnergy,
+ ShieldStrikeKind kind, boolean unblockable, Vec3d bodyVelocity) {
this.origin = origin;
this.direction = normalize(direction);
this.maxDistance = Math.max(0.0D, maxDistance);
this.impactEnergy = Math.max(0, impactEnergy);
this.kind = kind == null ? ShieldStrikeKind.RADIANT : kind;
this.unblockable = unblockable;
+ this.bodyVelocity = bodyVelocity;
}
/** A blockable beam of the given declared energy. */
@@ -39,6 +55,16 @@ public static ShieldStrike beam(Vec3d origin, Vec3d direction, double maxDistanc
return new ShieldStrike(origin, direction, maxDistance, impactEnergy, kind, false);
}
+ /**
+ * A kinetic strike that declares the travelling body behind it — a shot that exists as a record
+ * rather than as an entity. Full absorption reflects it; see {@link ShieldStrikeResult#reflected}.
+ */
+ public static ShieldStrike kineticBody(Vec3d origin, Vec3d direction, double maxDistance,
+ int impactEnergy, Vec3d bodyVelocity) {
+ return new ShieldStrike(origin, direction, maxDistance, impactEnergy, ShieldStrikeKind.KINETIC,
+ false, bodyVelocity);
+ }
+
/** A beam whose declared energy is derived from a weapon's damage value (axis G tunable factor). */
public static ShieldStrike fromDamage(Vec3d origin, Vec3d direction, double maxDistance, double damage,
ShieldStrikeKind kind) {
@@ -72,6 +98,16 @@ public boolean isUnblockable() {
return unblockable;
}
+ /** The declared travelling body's world velocity, or null when the strike carries no body. */
+ public Vec3d getBodyVelocity() {
+ return bodyVelocity;
+ }
+
+ /** True when this strike declares a travelling body (the thing the entity scan cannot see). */
+ public boolean hasBody() {
+ return bodyVelocity != null;
+ }
+
private static Vec3d normalize(Vec3d v) {
if (v == null) {
return new Vec3d(0.0D, 0.0D, 0.0D);
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeKind.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeKind.java
index bfd92d425..1a51e0826 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeKind.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeKind.java
@@ -8,9 +8,17 @@
public enum ShieldStrikeKind {
/**
- * A moving mass / kinetic projectile. The per-tick entity scan physically reflects a
- * travelling one; a declared kinetic strike with no travelling entity is absorbed at the physical-
- * resistance multiplier.
+ * A moving mass / kinetic projectile. What the field does with it is decided by whether a travelling
+ * body exists — not by whether that body happens to be a Forge {@code Entity}:
+ *
+ *
+ * - a travelling entity — reflected by the per-tick AABB scan, as it always has been (a
+ * permanent compatibility path for other mods' projectiles and thrown bodies);
+ * - a declared strike that carries a body ({@link ShieldStrike#hasBody()}) — a shot living as a
+ * record rather than an entity — reflected by {@link ShieldStrikeService#resolve};
+ * - a declared strike with no body at all — an abstract kinetic source — absorbed at the
+ * physical-resistance multiplier.
+ *
*/
KINETIC,
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeResult.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeResult.java
index bd22e5175..aeec37c47 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeResult.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeResult.java
@@ -14,6 +14,12 @@
* zero. This is the graceful-penetration "shields fall" case (D134-2), the same degrade shape as
* the kinetic path where a downed shield lets a body through.
*
+ *
+ * A fully absorbed strike that declared a travelling body is additionally
+ * {@link #reflected}: the shield mirrored the body off the shell and hands back its new velocity, so the
+ * shot resumes at {@link #getHitPoint()} rather than ending there. Reflection is a specialisation of
+ * full interception, not a fourth state — a caller that has never heard of it still reads
+ * "intercepted, fully absorbed, nothing passed", which remains true.
*/
public final class ShieldStrikeResult {
@@ -21,23 +27,39 @@ public final class ShieldStrikeResult {
private final Vec3d hitPoint;
private final int absorbedShieldEnergy;
private final int residualImpactEnergy;
+ private final Vec3d reflectedVelocity;
private ShieldStrikeResult(boolean intercepted, Vec3d hitPoint, int absorbedShieldEnergy,
- int residualImpactEnergy) {
+ int residualImpactEnergy, Vec3d reflectedVelocity) {
this.intercepted = intercepted;
this.hitPoint = hitPoint;
this.absorbedShieldEnergy = absorbedShieldEnergy;
this.residualImpactEnergy = residualImpactEnergy;
+ this.reflectedVelocity = reflectedVelocity;
}
public static ShieldStrikeResult passed() {
- return new ShieldStrikeResult(false, null, 0, 0);
+ return new ShieldStrikeResult(false, null, 0, 0, null);
}
public static ShieldStrikeResult intercepted(Vec3d hitPoint, int absorbedShieldEnergy,
int residualImpactEnergy) {
return new ShieldStrikeResult(true, hitPoint, Math.max(0, absorbedShieldEnergy),
- Math.max(0, residualImpactEnergy));
+ Math.max(0, residualImpactEnergy), null);
+ }
+
+ /**
+ * A fully absorbed strike whose declared body was mirrored off the shell: the shot continues from
+ * {@code hitPoint} with {@code newVelocity} instead of stopping there. Residual is 0 by
+ * construction — a short pay never reflects, because there the shield spent everything it had and
+ * the body carries on through unchanged.
+ *
+ * A null {@code newVelocity} degrades to a plain full interception rather than claiming a
+ * reflection with nowhere to go.
+ */
+ public static ShieldStrikeResult reflected(Vec3d hitPoint, int absorbedShieldEnergy,
+ Vec3d newVelocity) {
+ return new ShieldStrikeResult(true, hitPoint, Math.max(0, absorbedShieldEnergy), 0, newVelocity);
}
/** True when the strike met a powered shell and at least some of it was absorbed. */
@@ -64,4 +86,14 @@ public int getAbsorbedShieldEnergy() {
public int getResidualImpactEnergy() {
return residualImpactEnergy;
}
+
+ /** True when the shield mirrored a declared travelling body — the shot resumes, it does not stop. */
+ public boolean isReflected() {
+ return reflectedVelocity != null;
+ }
+
+ /** The mirrored body's new world velocity, or null when nothing was reflected. */
+ public Vec3d getReflectedVelocity() {
+ return reflectedVelocity;
+ }
}
diff --git a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeService.java b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeService.java
index 16c947d23..b893fdd05 100644
--- a/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeService.java
+++ b/affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldStrikeService.java
@@ -16,6 +16,10 @@
* shield spends {@code min(stored, impactEnergy x rate x kindMult / tierEff)}; a full pay stops the
* strike at the shell, a short pay lets the remainder through and drops the shield toward zero.
*
+ * A fully paid KINETIC strike that declares a travelling body is reflected rather than
+ * stopped: the shield owns that computation because the surface normal is field geometry the caller has
+ * no access to, and because the moving-shell correction already lives here.
+ *
* Server-authoritative: energy is spent on the logical server only. This is the tier the mod builds
* against; non-cooperating fire is covered separately (explosions and travelling projectiles already,
* a residual hitscan-ray hook as best-effort future work).
@@ -30,28 +34,56 @@ public static ShieldStrikeResult resolve(World world, ShieldStrike strike) {
|| strike.getImpactEnergy() <= 0) {
return ShieldStrikeResult.passed();
}
- // Cheap global short-circuit before any per-generator geometry.
- if (!TileEntityFieldGenerator.hasActiveGenerators()) {
+
+ TileEntityFieldGenerator nearest = nearestShell(world, strike.getOrigin(),
+ strike.getDirection(), strike.getMaxDistance());
+ if (nearest == null) {
return ShieldStrikeResult.passed();
}
+ double nearestT = FieldSurfaceMath.rayShellEntry(nearest, strike.getOrigin(),
+ strike.getDirection(), strike.getMaxDistance());
+ Vec3d hitPoint = strike.getOrigin().add(FieldSurfaceMath.scale(strike.getDirection(), nearestT));
+ return absorb(nearest, strike, hitPoint);
+ }
+
+ /**
+ * How far along {@code dir} this ray first meets a powered shell, or {@code -1} when it meets
+ * none within {@code maxDist}. A pure geometric question: nothing is absorbed and no shield is
+ * charged.
+ *
+ * It exists for a caller that has to decide which of several layers a travelling body meets
+ * FIRST — the field, or the hull behind it — and therefore has to know where the field is before
+ * committing to hitting it. {@link #resolve} finds its shell through the same search, so the two
+ * cannot answer differently about where the shell is.
+ */
+ public static double nearestShellCrossing(World world, Vec3d origin, Vec3d dir, double maxDist) {
+ if (world == null || world.isRemote || origin == null || dir == null) {
+ return -1.0D;
+ }
+ TileEntityFieldGenerator nearest = nearestShell(world, origin, dir, maxDist);
+ return nearest == null ? -1.0D
+ : FieldSurfaceMath.rayShellEntry(nearest, origin, dir, maxDist);
+ }
+
+ /** The powered shell this ray enters first, or null. The one shell search in this service. */
+ private static TileEntityFieldGenerator nearestShell(World world, Vec3d origin, Vec3d dir,
+ double maxDist) {
+ // Cheap global short-circuit before any per-generator geometry.
+ if (!TileEntityFieldGenerator.hasActiveGenerators()) {
+ return null;
+ }
List generators = FieldSurfaceMath.getActiveGenerators(world);
TileEntityFieldGenerator nearest = null;
double nearestT = Double.POSITIVE_INFINITY;
for (TileEntityFieldGenerator generator : generators) {
- double t = FieldSurfaceMath.rayShellEntry(generator, strike.getOrigin(), strike.getDirection(),
- strike.getMaxDistance());
+ double t = FieldSurfaceMath.rayShellEntry(generator, origin, dir, maxDist);
if (t >= 0.0D && t < nearestT) {
nearestT = t;
nearest = generator;
}
}
- if (nearest == null) {
- return ShieldStrikeResult.passed();
- }
-
- Vec3d hitPoint = strike.getOrigin().add(FieldSurfaceMath.scale(strike.getDirection(), nearestT));
- return absorb(nearest, strike, hitPoint);
+ return nearest;
}
private static ShieldStrikeResult absorb(TileEntityFieldGenerator generator, ShieldStrike strike,
@@ -69,9 +101,17 @@ private static ShieldStrikeResult absorb(TileEntityFieldGenerator generator, Shi
generator.onFieldTouched(hitPoint, null); // flash at the crossing
if (spent >= cost) {
+ // Full pay + a declared travelling body => the shell mirrors it, with the law the entity
+ // scan already uses (shell velocity out, reflect, shell velocity back in). RADIANT never
+ // reflects however it arrives — a beam has no velocity to mirror.
+ if (strike.getKind() == ShieldStrikeKind.KINETIC && strike.hasBody()) {
+ Vec3d newVelocity = generator.reflectBodyVelocity(hitPoint, strike.getBodyVelocity());
+ return ShieldStrikeResult.reflected(hitPoint, spent, newVelocity);
+ }
return ShieldStrikeResult.intercepted(hitPoint, spent, 0);
}
- // Short pay: the shield covered only a fraction, the remainder passes downstream.
+ // Short pay: the shield covered only a fraction, the remainder passes downstream. It never
+ // reflects, body or not — graceful penetration means exactly what it always meant.
double fractionStopped = (double) spent / (double) cost;
int residual = (int) Math.round(strike.getImpactEnergy() * (1.0D - fractionStopped));
return ShieldStrikeResult.intercepted(hitPoint, spent, Math.max(1, residual));
diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java
index 34a37fc6d..c608c40a8 100644
--- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java
+++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java
@@ -156,6 +156,19 @@
@Mod(modid = Tags.MOD_ID, name = Tags.MOD_NAME, version = Tags.VERSION, dependencies = Constants.DEPENDENCIES)
public class AdvancedRocketry {
+ /**
+ * How much absorbed energy a mirror's metal film sheds before it melts. Shared by every tier: a
+ * film is a film, and what separates aluminium from gold is how much of a hit it lets into that
+ * film rather than how much the film can take.
+ */
+ private static final int MIRROR_FILM_DISSIPATION = 4000;
+ /**
+ * How much of an impact one PLATE of reactive armour swallows; a full block takes twice. Set so
+ * that ordinary fire is eaten whole and a railgun-class round is not — which is the ordering the
+ * mechanic exists to produce, not a number anybody should read as sacred.
+ */
+ private static final int REACTIVE_PLATE_CAPACITY = 10000;
+
private static final String PLANET = "Planet";
public static final RecipeHandler machineRecipes = new RecipeHandler();
public static final Logger logger = LogManager.getLogger(Constants.modId);
@@ -455,6 +468,12 @@ public void preInit(FMLPreInitializationEvent event) {
GameRegistry.registerTileEntity(TilePrecisionLaserEtcher.class, new ResourceLocation(Constants.modId, "ARPrecisionLaserEtcher"));
GameRegistry.registerTileEntity(TileSolarArray.class, new ResourceLocation(Constants.modId, "ARSolarArray"));
GameRegistry.registerTileEntity(TileOrbitalRegistry.class, new ResourceLocation(Constants.modId, "orbitalRegistry"));
+ GameRegistry.registerTileEntity(zmaster587.advancedRocketry.tile.weapon.TileTurret.class,
+ new ResourceLocation(Constants.modId, "ARturret"));
+ GameRegistry.registerTileEntity(zmaster587.advancedRocketry.tile.weapon.TileWeaponConsole.class,
+ new ResourceLocation(Constants.modId, "ARweaponConsole"));
+ GameRegistry.registerTileEntity(zmaster587.advancedRocketry.tile.sensor.TileFireControlSensor.class,
+ new ResourceLocation(Constants.modId, "ARfireControlSensor"));
if (zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().enableGravityController)
GameRegistry.registerTileEntity(TileAreaGravityController.class, "ARGravityMachine");
@@ -538,6 +557,7 @@ public void registerItems(RegistryEvent.Register- evt) {
//TODO: move registration in the case we have more than one chip type
AdvancedRocketryItems.itemDataUnit = new ItemData().setUnlocalizedName("advancedrocketry:dataUnit").setCreativeTab(tabAdvRocketry);
AdvancedRocketryItems.itemMemoryCrystal = new zmaster587.advancedRocketry.item.ItemMemoryCrystal().setUnlocalizedName("advancedrocketry:memoryCrystal").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryItems.itemRepairWelder = new zmaster587.advancedRocketry.item.ItemRepairWelder().setUnlocalizedName("advancedrocketry:repairWelder").setCreativeTab(tabAdvRocketry);
AdvancedRocketryItems.itemOreScanner = new ItemOreScanner().setUnlocalizedName("OreScanner").setCreativeTab(tabAdvRocketry);
AdvancedRocketryItems.itemQuartzCrucible = new ItemBlock(AdvancedRocketryBlocks.blockQuartzCrucible).setUnlocalizedName("qcrucible").setCreativeTab(tabAdvRocketry);
AdvancedRocketryItems.itemSatellite = new ItemSatellite().setUnlocalizedName("satellite").setCreativeTab(tabAdvRocketry).setMaxStackSize(1);
@@ -595,6 +615,7 @@ public void registerItems(RegistryEvent.Register
- evt) {
LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemSpaceStationChip.setRegistryName("spaceStationChip"));
LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemDataUnit.setRegistryName("dataUnit"));
LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemMemoryCrystal.setRegistryName("memoryCrystal"));
+ LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemRepairWelder.setRegistryName("repairWelder"));
//Satellite bits
LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemSatellite.setRegistryName("satellite"));
LibVulpesBlocks.registerItem(AdvancedRocketryItems.itemSatellitePowerSource.setRegistryName("satellitePowerSource"));
@@ -655,6 +676,20 @@ public void registerBlocks(RegistryEvent.Register evt) {
AdvancedRocketryBlocks.blockBlastBrick = new BlockMultiBlockComponentVisible(Material.ROCK).setCreativeTab(tabAdvRocketry).setUnlocalizedName("blastBrick").setHardness(3F).setResistance(15F);
AdvancedRocketryBlocks.blockStructureTower = new BlockAlphaTexture(Material.IRON).setUnlocalizedName("structuretower").setCreativeTab(tabAdvRocketry).setHardness(2f);
AdvancedRocketryBlocks.blockLens = new BlockLens().setUnlocalizedName("lens").setCreativeTab(tabAdvRocketry).setHardness(0.3f);
+ // The tiers differ ONLY in reflectance, deliberately: the film they share is the same
+ // thickness, so what a better mirror buys is that less of each hit stays in it.
+ AdvancedRocketryBlocks.blockMirrorPlatingAluminium = new BlockMirrorPlating(0.90D, MIRROR_FILM_DISSIPATION)
+ .setUnlocalizedName("mirrorPlatingAluminium").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockMirrorPlatingSilver = new BlockMirrorPlating(0.96D, MIRROR_FILM_DISSIPATION)
+ .setUnlocalizedName("mirrorPlatingSilver").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockMirrorPlatingGold = new BlockMirrorPlating(0.97D, MIRROR_FILM_DISSIPATION)
+ .setUnlocalizedName("mirrorPlatingGold").setCreativeTab(tabAdvRocketry);
+ // Heavy plating swallows twice what light does; nothing else separates the two, because what a
+ // body meets is the voxel and not the shape inside it.
+ AdvancedRocketryBlocks.blockReactivePlate = new BlockReactivePlating(REACTIVE_PLATE_CAPACITY)
+ .setUnlocalizedName("reactivePlate").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockReactiveBlock = new BlockReactivePlating(REACTIVE_PLATE_CAPACITY * 2)
+ .setUnlocalizedName("reactiveBlock").setCreativeTab(tabAdvRocketry);
AdvancedRocketryBlocks.blockSolarPanel = new Block(Material.IRON).setUnlocalizedName("solarPanel").setCreativeTab(tabAdvRocketry).setHardness(3f);
AdvancedRocketryBlocks.blockSolarArrayPanel = new BlockMultiBlockComponentVisibleAlphaTexture(Material.IRON).setUnlocalizedName("solararraypanel").setCreativeTab(tabAdvRocketry).setHardness(1).setResistance(1f);
AdvancedRocketryBlocks.blockQuartzCrucible = new BlockQuartzCrucible().setUnlocalizedName("qcrucible").setCreativeTab(tabAdvRocketry);
@@ -724,6 +759,43 @@ public void registerBlocks(RegistryEvent.Register evt) {
AdvancedRocketryBlocks.blockOxidizerFuelTank = new BlockOxidizerFuelTank(Material.IRON).setUnlocalizedName("oxidizerfueltank").setCreativeTab(tabAdvRocketry).setHardness(2f);
AdvancedRocketryBlocks.blockNuclearFuelTank = new BlockNuclearFuelTank(Material.IRON).setUnlocalizedName("nuclearfueltank").setCreativeTab(tabAdvRocketry).setHardness(2f);
AdvancedRocketryBlocks.blockNuclearCore = new BlockNuclearCore(Material.IRON).setUnlocalizedName("nuclearcore").setCreativeTab(tabAdvRocketry).setHardness(2f);
+ // The gun family. Each part states what it is worth and nothing else; the numbers a built
+ // gun ends up with are the sum, which is why a longer barrel is a real decision rather than
+ // a tier. A part contributes only when it is placed against a gun, so these are ordinary
+ // blocks with no wiring of their own.
+ AdvancedRocketryBlocks.blockTurret = new zmaster587.advancedRocketry.block.weapon.BlockTurret()
+ .setUnlocalizedName("turret").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockGunBarrel = new zmaster587.advancedRocketry.block.weapon.BlockGunPart(
+ builder -> builder.addMuzzleSpeed(0.9D).addImpactEnergy(8).addSpreadDegrees(-0.8D)
+ .addLifetimeTicks(20).addEnergyPerShot(50).addHeatPerShot(1))
+ .setUnlocalizedName("gunBarrel").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockGunAmmoFeed = new zmaster587.advancedRocketry.block.weapon.BlockGunPart(
+ builder -> builder.speedUpFireIntervalBy(3).addImpactEnergy(6).addEnergyPerShot(75)
+ .addHeatPerShot(2)
+ .declareInput(zmaster587.advancedRocketry.api.weapon.GunInput.FORGE_ENERGY))
+ .setUnlocalizedName("gunAmmoFeed").setCreativeTab(tabAdvRocketry);
+ // The one part that makes a gun a BEAM rather than a thrower. Power per TICK, so a bigger
+ // laser is a laser with more emitters rather than a bigger number written beside one; the
+ // declared kind is what makes it priced against the ablation column and absorbed whole by a
+ // shell instead of being thrown back off it.
+ AdvancedRocketryBlocks.blockGunBeamEmitter = new zmaster587.advancedRocketry.block.weapon.BlockGunPart(
+ builder -> builder.addBeamPowerPerTick(4_000).setKind(
+ zmaster587.advancedRocketry.api.damage.ImpactKind.BEAM)
+ .addHeatPerShot(1).addHeatCapacity(20)
+ .declareInput(zmaster587.advancedRocketry.api.weapon.GunInput.FORGE_ENERGY))
+ .setUnlocalizedName("gunBeamEmitter").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockGunCooling = new zmaster587.advancedRocketry.block.weapon.BlockGunPart(
+ builder -> builder.addHeatCapacity(40).addCoolingPerTick(2).addTraverseDegreesPerTick(0.5D))
+ .setUnlocalizedName("gunCooling").setCreativeTab(tabAdvRocketry);
+ AdvancedRocketryBlocks.blockWeaponConsole = new BlockTile(zmaster587.advancedRocketry.tile.weapon.TileWeaponConsole.class,
+ GuiHandler.guiId.MODULARNOINV.ordinal()).setUnlocalizedName("weaponConsole")
+ .setCreativeTab(tabAdvRocketry).setHardness(3f);
+ // The battery's eyes. A node of the same network the guns are on, so a sensor placed against
+ // a gun feeds it with no wiring, and one placed alone feeds nothing - which is honest: there
+ // is nothing for it to hand a contact to.
+ AdvancedRocketryBlocks.blockFireControlSensor = new BlockTile(zmaster587.advancedRocketry.tile.sensor.TileFireControlSensor.class,
+ GuiHandler.guiId.MODULARNOINV.ordinal()).setUnlocalizedName("fireControlSensor")
+ .setCreativeTab(tabAdvRocketry).setHardness(3f);
AdvancedRocketryBlocks.blockGuidanceComputer = new BlockTile(TileGuidanceComputer.class, GuiHandler.guiId.MODULAR.ordinal()).setUnlocalizedName("guidanceComputer").setCreativeTab(tabAdvRocketry).setHardness(3f);
AdvancedRocketryBlocks.blockAdvancedFlightComputer = new zmaster587.advancedRocketry.block.BlockAdvancedFlightComputer(GuiHandler.guiId.MODULARNOINV.ordinal()).setUnlocalizedName("advancedFlightComputer").setCreativeTab(tabAdvRocketry).setHardness(3f);
// MODULARNOINV, not MODULAR: the console needs the whole panel for its own controls, and a
@@ -843,6 +915,11 @@ public void registerBlocks(RegistryEvent.Register evt) {
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockBlastBrick.setRegistryName("blastbrick"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockStructureTower.setRegistryName("structureTower"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockLens.setRegistryName("blockLens"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockMirrorPlatingAluminium.setRegistryName("mirrorPlatingAluminium"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockMirrorPlatingSilver.setRegistryName("mirrorPlatingSilver"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockMirrorPlatingGold.setRegistryName("mirrorPlatingGold"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockReactivePlate.setRegistryName("reactivePlate"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockReactiveBlock.setRegistryName("reactiveBlock"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockSolarPanel.setRegistryName("solarPanel"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockSolarArrayPanel.setRegistryName("solararraypanel"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockQuartzCrucible.setRegistryName("quartzcrucible"), null, false);
@@ -914,6 +991,13 @@ public void registerBlocks(RegistryEvent.Register evt) {
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockOxidizerFuelTank.setRegistryName("oxidizerfueltank"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockNuclearFuelTank.setRegistryName("nuclearfueltank"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockNuclearCore.setRegistryName("nuclearcore"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockTurret.setRegistryName("turret"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockWeaponConsole.setRegistryName("weaponConsole"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockFireControlSensor.setRegistryName("fireControlSensor"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunBarrel.setRegistryName("gunBarrel"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunAmmoFeed.setRegistryName("gunAmmoFeed"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunBeamEmitter.setRegistryName("gunBeamEmitter"));
+ LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunCooling.setRegistryName("gunCooling"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGuidanceComputer.setRegistryName("guidanceComputer"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockAdvancedFlightComputer.setRegistryName("advancedFlightComputer"));
LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockNavigationComputer.setRegistryName("navigationComputer"));
@@ -1132,6 +1216,7 @@ public void postInit(FMLPostInitializationEvent event) {
CapabilitySpaceArmor.register();
zmaster587.advancedRocketry.api.capability.CapabilityWear.register();
+ zmaster587.advancedRocketry.api.capability.CapabilityDamageAware.register();
//Need to raise the Max Entity Radius to allow player interaction with rockets
World.MAX_ENTITY_RADIUS = 20;
@@ -1188,6 +1273,8 @@ public void postInit(FMLPostInitializationEvent event) {
MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.world.weather.PlanetWeatherEventHandler());
// Acid rain damage on planets flagged acidicRain
MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.event.AcidRainHandler());
+ // Forget a block's damage record when a player breaks or replaces that block
+ MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.damage.DamageInvalidationHandler());
WirelessDataTickHandler wirelessTickHandler = new WirelessDataTickHandler();
MinecraftForge.EVENT_BUS.register(wirelessTickHandler);
diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java
index d7ced1d88..1b3467daa 100644
--- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java
+++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java
@@ -49,6 +49,7 @@ public class ARConfiguration {
private final static String OXYGEN = "Oxygen System";
private final static String ENERGY = "Energy Production";
private final static String MISSION = "Resource Collection Missions";
+ private final static String WEAPONS = "Weapons";
private final static String PERFORMANCE = "Performance";
private final static String CLIENT = "Client";
private final static String COMPAT = "Compatibility";
@@ -138,6 +139,7 @@ public class ARConfiguration {
public int dilithiumPerChunk;
@ConfigProperty
public int dilithiumPerChunkMoon;
+ @ConfigProperty
public int aluminumPerChunk;
@ConfigProperty
public int aluminumClumpSize;
@@ -412,6 +414,187 @@ public class ARConfiguration {
public boolean wearCriticalBlocksLaunch = false;
@ConfigProperty(needsSync = true)
public double serviceStationStandaloneRepairMultiplier = 3.0;
+ /**
+ * Share of a block's own crafting recipe charged to repair it from destroyed-adjacent back to
+ * pristine BY HAND, spread over its stages. 1.0 means a full hand repair costs about what the
+ * block costs — the welder's advantage over simply replacing it is that the block, and whatever
+ * its tile entity holds, stays where it is.
+ */
+ @ConfigProperty(needsSync = true)
+ public double repairCostPerStageFraction = 1.0;
+ @ConfigProperty(needsSync = true)
+ public int repairWelderEnergyPerStage = 2000;
+ @ConfigProperty(needsSync = true)
+ public int repairWelderCapacity = 100000;
+ /**
+ * Whether the war exists: whether a weapon fires, a sensor acquires, and weapon fire damages
+ * anything.
+ *
+ *
One key, because a pack asks one question
+ * What a server owner wants to decide is "is there combat here", and the answer has to cover
+ * every weapon family at once. The key this replaced gated the shot registry alone, which left a
+ * held beam burning hulls with the war "off" — a switch that covers half a mechanic is worse than
+ * none, because it reads as a promise.
+ *
+ * OFF is reversible, and that bounds what it may do
+ * It is meant to be thrown on a world that has already been fought over, and thrown back later
+ * on the same save. So OFF destroys nothing a later ON would need: guns keep their builds,
+ * buffers and targets, damage records stay on the blocks that carry them, repair keeps working,
+ * and shields — which defend against more than weapons — are untouched. The one thing it ends is
+ * flights, because a round left in the registry is written back into the save forever and would
+ * resume months later into a world that has moved on.
+ *
+ * A gun that is off SAYS so rather than falling silent: "the war is off" is a distinct answer
+ * beside "holding fire" and "nothing left to fire with".
+ */
+ @ConfigProperty(needsSync = true)
+ public boolean enableWeapons = true;
+ /**
+ * Below this speed, in blocks per tick, a shot mirrored off a shield is ended at the shell rather
+ * than left alive. A body deflected to nearly nothing has to be somewhere if it is an entity; a
+ * record does not, and a cloud of near-motionless rounds loitering against a shell is both a
+ * simulation cost and a lie about what is in the air.
+ */
+ @ConfigProperty(needsSync = true)
+ public double shotReflectionSpeedFloor = 0.05;
+ @ConfigProperty(needsSync = true)
+ public double shotPenetrationSpeedFloor = 0.05;
+ /**
+ * The widest a shot's body may be treated as, in blocks, however wide it was declared. A body
+ * sweeps a cylinder rather than a line, and the blocks one step examines grow with the SQUARE of
+ * its width, so this is what keeps an absurd calibre from being a way of making the server do
+ * arbitrary work. It caps the geometry only: the declared cross-section still prices the shot.
+ */
+ @ConfigProperty(needsSync = true)
+ public double shotBodyRadiusCap = 2.0;
+ /**
+ * How much dearer a block is to BOIL AWAY than to push through, when nothing has written it its
+ * own ablation row. Both figures are energy per unit of volume removed — the same dimension — and
+ * they are nowhere near the same magnitude: for steel, being pushed through costs of order a
+ * gigajoule per cubic metre while heating, melting and vaporising it costs tens of them. So per
+ * joule a kinetic round removes far more hull than a beam does, and a laser buys precision,
+ * instantaneity and nothing to reload instead of digging power.
+ */
+ @ConfigProperty(needsSync = true)
+ public double ablationResistanceFactor = 20.0;
+ /**
+ * The power density below which a beam does not drill at all — it warms the plate and the energy
+ * is conducted away — as energy per unit of the body's cross-section, in the units an impact
+ * budget is in. Off by default, and the reason is worth reading before turning it on.
+ *
+ * The worry it was written for is real: a linear ablation law would let a faint beam held long
+ * enough cut a battleship. But the law is NOT linear in the way that worry assumes — a stage is
+ * bought whole or not at all, and a beam that cannot afford one keeps its energy and buys nothing
+ * this tick or any other. Since a stage costs `perStage x ablation x area / referenceArea`, "can
+ * this beam afford a stage" is ALREADY the question "is this beam intense enough", with a
+ * threshold of `perStage x ablation / referenceArea` that each block sets for itself. A faint beam
+ * therefore never accumulates, and the battleship is safe without this knob.
+ *
+ * What the knob buys, at a value ABOVE that affordability line, is the thing the pricing does
+ * NOT do: a sub-threshold beam's energy is ABSORBED. Without it a beam too weak to mark a plate
+ * does not warm it — it passes clean through with everything it arrived with, a free x-ray of the
+ * hull. That, rather than the battleship, is why the threshold is set.
+ *
+ * The default sits above the affordability line of METAL. The line is
+ * `perStage x ablation / referenceArea`, which each block sets for itself: with the shipped table
+ * that is of order 8 000 for stone and 38 500 for an iron block. At 50 000 a small emitter does
+ * nothing whatever to a metal hull, however long it is held, and its energy stays in the plate —
+ * which is the qualitative gap between a big emitter and a small one, and the reason a pulsed
+ * laser is worth building. Zero disables it and restores the x-ray.
+ */
+ @ConfigProperty(needsSync = true)
+ public double beamAblationIntensityThreshold = 50000.0;
+ /**
+ * How glancing a hit has to be before a solid round skips off METAL instead of digging in, as the
+ * angle between the round and the surface normal in degrees: 0 is square-on, 90 is a pure graze.
+ * Only metal deflects — a round never skips off a plank wall — so a player meets bouncing rounds
+ * where a player expects them. 90 disables ricochet entirely.
+ */
+ @ConfigProperty(needsSync = true)
+ public double ricochetIncidenceDegrees = 65.0;
+ /**
+ * How much of its speed a ricocheting round keeps. Below 1 a bounce costs something, which is what
+ * stops a round skipping between two plates forever; at 1 a graze is free.
+ */
+ @ConfigProperty(needsSync = true)
+ public double ricochetRestitution = 0.75;
+ /**
+ * How many shots one world may carry at once. A refusal, not an eviction: dropping somebody
+ * else's round to make room would turn a burst of cheap fire into a way of deleting incoming fire.
+ */
+ @ConfigProperty(needsSync = true)
+ public int maxShotsPerWorld = 256;
+ /**
+ * How near a player's eye a round's PATH — or a held beam's lit LENGTH — must pass before that
+ * player is told about it, in blocks. Weapon fire is a server record, so being told is the only
+ * way a client can draw any of it; sending every round to everybody in the world would put a
+ * battery's whole rate of fire on every player's connection, including the ones on the far side
+ * of a planet. Zero switches the whole drawing channel off: the mechanics still work and nothing
+ * is drawn. One radius covers both families deliberately — it is the same question about the
+ * same guns, and two knobs would be two answers.
+ */
+ @ConfigProperty(needsSync = true)
+ public int shotVisibilityRadius = 256;
+ /**
+ * Whether the fire-control sensor searches for targets at all. With this off the block still
+ * exists and still says what it is, and it acquires nothing, publishes nothing and draws no
+ * power — so a battery falls back to being pointed by hand, which is exactly what it was before
+ * the sensor existed rather than a broken version of it.
+ */
+ @ConfigProperty(needsSync = true)
+ public boolean enableFireControlSensor = true;
+ /** How far a fire-control sensor can look at all, in blocks. Its envelope, not its lock range. */
+ @ConfigProperty(needsSync = true)
+ public double fireControlSensorRadius = 96.0;
+ /**
+ * Ticks between sweeps. A contact's position is re-read by the gun every tick from the entity
+ * itself, so this is the cadence at which the sensor reconsiders WHICH thing to shoot at, not
+ * the cadence at which the mount is allowed to follow it.
+ */
+ @ConfigProperty(needsSync = true)
+ public int fireControlSensorScanIntervalTicks = 10;
+ /** How many contacts one sensor can hold. A bound on work, and on how much a readout can say. */
+ @ConfigProperty(needsSync = true)
+ public int fireControlSensorMaxTracks = 8;
+ /**
+ * FE per tick an ACTIVE sensor draws. An illuminating sensor is a machine that is running; a
+ * listening one costs nothing, which is what makes going quiet a genuine option rather than a
+ * penalty. A sensor that cannot pay falls back to listening rather than lying about its lock.
+ */
+ @ConfigProperty(needsSync = true)
+ public int fireControlSensorActiveEnergyPerTick = 40;
+ /**
+ * The lock quality an ACTIVE sensor holds a contact at inside its envelope. This is the
+ * passive/active gap: everything a listening sensor gets is bounded by what the target radiates,
+ * and this is what illuminating buys instead.
+ */
+ @ConfigProperty(needsSync = true)
+ public double fireControlSensorActiveLockQuality = 0.95;
+ /**
+ * How well a contact must be resolved before a gun will fire at it, 0..1. Below it a battery
+ * still tracks — knowing something is out there and being unable to hit it is a real state, and
+ * the reason to switch the sensor on.
+ */
+ @ConfigProperty(needsSync = true)
+ public double fireControlSensorLockQualityToFire = 0.25;
+ /**
+ * Whether acquisition is limited to hostile mobs and players. Off, a defence battery opens up on
+ * whatever wanders past, which is a legitimate way to run a perimeter and a poor default.
+ */
+ @ConfigProperty(needsSync = true)
+ public boolean fireControlSensorAcquireHostilesOnly = true;
+ /**
+ * How far gone a turret's own block must be, 0..1, before its traverse slows and then seizes.
+ * The ORDER of the two rungs is the mechanic and is not configurable; where they sit is balance.
+ *
+ * Note what is deliberately absent: no flag disables this. Damage and wear advance the same
+ * stage counter, so a switch here would be a switch that makes ships unkillable — and the
+ * parts-wear flag gates wear where wear ACCRUES, never where a consequence is read.
+ */
+ @ConfigProperty(needsSync = true)
+ public double turretDerateDamageFraction = 0.25;
+ @ConfigProperty(needsSync = true)
+ public double turretJamDamageFraction = 0.75;
@ConfigProperty(needsSync = true)
public double wearTankLeakChanceMax = 0.5;
@ConfigProperty(needsSync = true)
@@ -650,14 +833,37 @@ public static void loadPreInit() {
arConfig.weightMaterialScale = config.get(ROCKET, "weightMaterialScale", 1.0, "Global multiplier applied to material-derived and fallback block weights (does not affect explicit overrides or rocket component parts). Raise to make hulls/structure mass matter more").getDouble();
arConfig.fuelMassScale = config.get(ROCKET, "fuelMassScale", 1.0, "Global multiplier applied to the mass of fuel/oxidizer carried by a rocket. Raise to make full tanks weigh more relative to thrust").getDouble();
arConfig.minLaunchTWR = config.get(ROCKET, "minLaunchTWR", 1.05, "Minimum thrust-to-weight ratio (thrust / wet weight) a rocket needs before it is allowed to launch. 1.0 means it can barely lift itself; values above 1.0 add a safety margin").getDouble();
- arConfig.wearThrustPenaltyMax = config.get(ROCKET, "wearThrustPenaltyMax", 0.5, "Fraction of thrust a fully-worn rocket motor loses (partsWearSystem). 0.5 means a motor at max wear produces half thrust; 0 disables the thrust penalty (wear then only affects explosion chance)").getDouble();
+ arConfig.wearThrustPenaltyMax = config.get(ROCKET, "wearThrustPenaltyMax", 0.5, "Fraction of thrust a fully-worn rocket motor loses. 0.5 means a motor at max wear produces half thrust; 0 disables the thrust penalty entirely (condition then only affects the failure roll). Independent of partsWearSystem, which gates only whether wear ACCRUES").getDouble();
arConfig.wearWarnProbability = config.get(ROCKET, "wearWarnProbability", 0.05, "Failure probability (0..1) at or above which the pilot is warned before launch that the rocket is worn. Also the threshold that blocks launch when wearCriticalBlocksLaunch is true").getDouble();
arConfig.wearCriticalBlocksLaunch = config.get(ROCKET, "wearCriticalBlocksLaunch", false, "If true, a rocket whose failure probability is at/above wearWarnProbability is refused launch (no explosion). If false, the pilot is warned but may still launch and risk the stochastic explosion").getBoolean();
arConfig.serviceStationStandaloneRepairMultiplier = config.get(ROCKET, "serviceStationStandaloneRepairMultiplier", 3.0, "Resource cost multiplier when the service station repairs a worn part WITHOUT a linked PrecisionAssembler (consumes the repair recipe's non-part ingredients times this factor). The assembler-backed path stays at 1x").getDouble();
+ arConfig.repairCostPerStageFraction = config.get(ROCKET, "repairCostPerStageFraction", 1.0, "Share of a block's own crafting recipe charged for a FULL hand repair with the welder, spread evenly over its damage stages (1.0 = repairing a block from its worst stage costs about what crafting it costs). Ingredient counts round up, so no stage is ever free").getDouble();
+ arConfig.repairWelderEnergyPerStage = config.get(ROCKET, "repairWelderEnergyPerStage", 2000, "Forge Energy the repair welder spends per stage of damage removed").getInt();
+ arConfig.repairWelderCapacity = config.get(ROCKET, "repairWelderCapacity", 100000, "Forge Energy the repair welder holds when fully charged").getInt();
arConfig.wearTankLeakChanceMax = config.get(ROCKET, "wearTankLeakChanceMax", 0.5, "Chance (0..1) that a fully-worn fuel tank carrying fuel/oxidizer leaks at launch. Scaled by the tank's wear stage. A leak both bleeds fuel and adds to the launch failure (explosion) probability").getDouble();
arConfig.wearTankLeakFuelLoss = config.get(ROCKET, "wearTankLeakFuelLoss", 0.25, "Fraction of a fuel type's loaded fuel lost when a worn tank of that type leaks at launch").getDouble();
arConfig.wearSeatBlockStageFraction = config.get(ROCKET, "wearSeatBlockStageFraction", 0.7, "Wear fraction (0..1 of max stage) at or above which a worn seat blocks a CREWED launch. Uncrewed/automated rockets ignore seat wear").getDouble();
- arConfig.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Enable rocket part wear and exploding chance.").getBoolean();
+ arConfig.enableWeapons = config.get(WEAPONS, "enableWeapons", true, "Whether combat exists on this server: whether guns fire (thrown rounds and held beams alike), whether sensors acquire targets, and whether weapon fire damages anything. Safe to switch off and back on again on a live world - guns keep their builds, buffers and targets, damage already done stays on the blocks that carry it, repair keeps working, and shields are unaffected. The only thing ending is the rounds still in the air, which would otherwise sit in the save waiting to resume. A gun with combat off reports itself disabled rather than silently doing nothing").getBoolean();
+ arConfig.shotReflectionSpeedFloor = config.get(WEAPONS, "shotReflectionSpeedFloor", 0.05, "Speed in blocks per tick below which a shot deflected by a shield is ended at the shell instead of continuing. Prevents near-motionless rounds loitering against a shield", 0.0, Double.MAX_VALUE).getDouble();
+ arConfig.shotPenetrationSpeedFloor = config.get(WEAPONS, "shotPenetrationSpeedFloor", 0.05, "Speed in blocks per tick below which a round boring through a hull is treated as having come to rest inside it. Penetration costs a round its speed, and without a floor a spent one creeps forward forever", 0.0, Double.MAX_VALUE).getDouble();
+ arConfig.shotBodyRadiusCap = config.get(WEAPONS, "shotBodyRadiusCap", 2.0, "The widest a shot's body is treated as when it sweeps its way through blocks, in blocks. A body sweeps a cylinder rather than a line and the work one step does grows with the square of its width, so this bounds what an absurd calibre can cost the server. The declared cross-section still prices the shot; only the geometry is capped", 0.0, 8.0).getDouble();
+ arConfig.ricochetIncidenceDegrees = config.get(WEAPONS, "ricochetIncidenceDegrees", 65.0, "How glancing a hit must be before a solid round skips off METAL rather than digging in, in degrees from the surface normal: 0 is square-on, 90 a pure graze. Only metal deflects, so a round never skips off a plank wall. 90 disables ricochet", 0.0, 90.0).getDouble();
+ arConfig.ricochetRestitution = config.get(WEAPONS, "ricochetRestitution", 0.75, "How much of its speed a ricocheting round keeps. Below 1 a bounce costs something, which is what stops a round skipping between two plates forever", 0.0, 1.0).getDouble();
+ arConfig.ablationResistanceFactor = config.get(WEAPONS, "ablationResistanceFactor", 20.0, "How much dearer a block is to boil away than to push through, when nothing has written it its own ablation row. Both are energy per unit volume removed; they are nowhere near the same magnitude, which is why a laser buys precision rather than digging power. 1.0 makes a beam dig exactly like a slug", 0.01, 1000.0).getDouble();
+ arConfig.beamAblationIntensityThreshold = config.get(WEAPONS, "beamAblationIntensityThreshold", 50000.0, "Energy per unit of a beam's cross-section below which it removes nothing and its energy is absorbed as heat instead of being carried onward. The default sits above the affordability line of metal (order 38500 for an iron block), so a small emitter does nothing to a metal hull however long it is held. 0 disables it, and a sub-threshold beam then passes clean through the plate with everything it arrived with", 0.0, Double.MAX_VALUE).getDouble();
+ arConfig.maxShotsPerWorld = config.get(WEAPONS, "maxShotsPerWorld", 256, "How many shots one world may have in flight at once. Further fire is refused until some land; nothing already in flight is ever dropped to make room", 1, Integer.MAX_VALUE).getInt();
+ arConfig.shotVisibilityRadius = config.get(WEAPONS, "shotVisibilityRadius", 256, "How near a player the path of a fired round, or the lit length of a held beam, must pass before that player is told about it and can see it drawn, in blocks. 0 disables weapon-fire replication entirely — the mechanics still work, nothing is drawn", 0, Integer.MAX_VALUE).getInt();
+ arConfig.enableFireControlSensor = config.get(WEAPONS, "enableFireControlSensor", true, "Whether fire-control sensors search for targets. Off, a sensor acquires nothing, publishes nothing and draws no power: batteries are pointed by hand, as they were before sensors existed").getBoolean();
+ arConfig.fireControlSensorRadius = config.get(WEAPONS, "fireControlSensorRadius", 96.0, "How far a fire-control sensor can look, in blocks. Its envelope — a target inside it may still be too poorly resolved to shoot at", 1.0, 1024.0).getDouble();
+ arConfig.fireControlSensorScanIntervalTicks = config.get(WEAPONS, "fireControlSensorScanIntervalTicks", 10, "Ticks between sweeps. The cadence at which a sensor reconsiders which contact to hand its battery, not the rate at which the guns follow it", 1, 200).getInt();
+ arConfig.fireControlSensorMaxTracks = config.get(WEAPONS, "fireControlSensorMaxTracks", 8, "How many contacts one sensor holds at once", 1, 64).getInt();
+ arConfig.fireControlSensorActiveEnergyPerTick = config.get(WEAPONS, "fireControlSensorActiveEnergyPerTick", 40, "FE per tick an actively illuminating sensor draws. Passive listening is free; a sensor that cannot pay falls back to listening", 0, Integer.MAX_VALUE).getInt();
+ arConfig.fireControlSensorActiveLockQuality = config.get(WEAPONS, "fireControlSensorActiveLockQuality", 0.95, "Lock quality an active sensor holds a contact at inside its envelope, 0..1 — what illuminating buys over listening", 0.0, 1.0).getDouble();
+ arConfig.fireControlSensorLockQualityToFire = config.get(WEAPONS, "fireControlSensorLockQualityToFire", 0.25, "How well a contact must be resolved, 0..1, before a gun fires at it. Below it the battery tracks without shooting", 0.0, 1.0).getDouble();
+ arConfig.fireControlSensorAcquireHostilesOnly = config.get(WEAPONS, "fireControlSensorAcquireHostilesOnly", true, "Whether acquisition is limited to hostile mobs and players. Off, a battery engages whatever wanders into range").getBoolean();
+ arConfig.turretDerateDamageFraction = config.get(WEAPONS, "turretDerateDamageFraction", 0.25, "How far gone a turret's own block must be, 0..1, before its traverse slows down. The order of the rungs is the mechanic; where they sit is balance", 0.0, 1.0).getDouble();
+ arConfig.turretJamDamageFraction = config.get(WEAPONS, "turretJamDamageFraction", 0.75, "How far gone a turret's own block must be, 0..1, before its traverse seizes entirely. A seized mount still fires down the bearing it stopped at", 0.0, 1.0).getDouble();
+ arConfig.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Whether rocket parts ACCRUE wear: whether a launch advances a seat, tank or motor towards its next stage. It does not gate what a worn part then DOES - a rocket shot up on the pad has to fly like a rocket shot up on the pad whatever this says, because battle damage and a long career put stages on the same axis. Off means a save stops getting worse, not that the damage already on it stops mattering: the launch warning, the critical-wear refusal, the failure roll and the tank leaks all still apply").getBoolean();
arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble();
//Ore configuration
diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java
index 972e92819..9da2cf3b8 100644
--- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java
+++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java
@@ -20,6 +20,18 @@ public class AdvancedRocketryBlocks {
public static Block blockPlanetAnalyser;
public static Block blockLaunchpad;
public static Block blockStructureTower;
+ /**
+ * Mirror plating, one block per FILM. The tiers are the reflectances of the metals mirrors are
+ * really made of, so the ladder is physics rather than invention: aluminium is the workhorse,
+ * silver the best in the visible band, and gold the infrared mirror — which is the band a weapon
+ * laser lives in, and the reason gold answers one weapon family and not another.
+ */
+ public static Block blockMirrorPlatingAluminium;
+ public static Block blockMirrorPlatingSilver;
+ public static Block blockMirrorPlatingGold;
+ /** Reactive plating: a charge that spends itself. Two thicknesses, and layering is allowed. */
+ public static Block blockReactivePlate;
+ public static Block blockReactiveBlock;
public static Block blockRocketBuilder;
public static Block blockGenericSeat;
public static Block blockPilotSeat;
@@ -39,6 +51,24 @@ public class AdvancedRocketryBlocks {
public static Block blockGuidanceComputer;
public static Block blockAdvancedFlightComputer;
public static Block blockNavigationComputer;
+ /**
+ * The gun family: a controller, and the parts a gun's numbers are derived from. A turret is
+ * whatever was built around the controller, so these are placed rather than crafted into a
+ * fixed shape.
+ */
+ public static Block blockTurret;
+ public static Block blockGunBarrel;
+ public static Block blockGunAmmoFeed;
+ public static Block blockGunBeamEmitter;
+ public static Block blockGunCooling;
+ /** The one thing the weapons network adds: a place to point every gun at once. */
+ public static Block blockWeaponConsole;
+ /**
+ * The eyes of a battery: it finds targets so that nobody has to name them, and hands its
+ * network one contact at a time. Off a ship it is a planetary-defence radar; the block is the
+ * same either way.
+ */
+ public static Block blockFireControlSensor;
/** The hyperdrive family: the machines that make a jump possible. */
public static Block blockHyperdriveGenerator;
public static Block blockHyperdriveCoil;
diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryItems.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryItems.java
index b8a36ea4d..ab7683150 100644
--- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryItems.java
+++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryItems.java
@@ -24,6 +24,7 @@ public class AdvancedRocketryItems {
public static Item itemQuartzCrucible;
public static Item itemDataUnit;
public static Item itemMemoryCrystal;
+ public static Item itemRepairWelder;
public static Item itemSatellite;
public static Item itemSatelliteIdChip;
public static Item itemPlanetIdChip;
diff --git a/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityDamageAware.java b/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityDamageAware.java
new file mode 100644
index 000000000..803ebb7ff
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityDamageAware.java
@@ -0,0 +1,68 @@
+package zmaster587.advancedRocketry.api.capability;
+
+import net.minecraft.nbt.NBTBase;
+import net.minecraft.tileentity.TileEntity;
+import net.minecraft.util.EnumFacing;
+import net.minecraftforge.common.capabilities.Capability;
+import net.minecraftforge.common.capabilities.CapabilityInject;
+import net.minecraftforge.common.capabilities.CapabilityManager;
+
+import zmaster587.advancedRocketry.api.damage.DamageOccurrence;
+import zmaster587.advancedRocketry.api.damage.IDamageAware;
+
+import javax.annotation.Nullable;
+
+/**
+ * Capability carrying {@link IDamageAware} — a unit's willingness to be told what broke it.
+ * Registered exactly as {@link CapabilityWear} is.
+ *
+ * There is no storage. An occurrence is news, not state: it is true at one moment and has no
+ * persistent form, so there is nothing to write to NBT and nothing to read back. A unit that turns an
+ * occurrence into durable state — a tripped breaker, a scrammed reactor — persists THAT in its own
+ * tile, where it belongs, exactly as a worn part persists its stage.
+ */
+public class CapabilityDamageAware {
+
+ @CapabilityInject(IDamageAware.class)
+ public static Capability DAMAGE_AWARE = null;
+
+ public CapabilityDamageAware() {
+ }
+
+ /** The capability on a tile, or null when it has none — which is the ordinary case. */
+ @Nullable
+ public static IDamageAware get(@Nullable TileEntity te) {
+ if (te == null || DAMAGE_AWARE == null) {
+ return null;
+ }
+ return te.getCapability(DAMAGE_AWARE, null);
+ }
+
+ public static void register() {
+ CapabilityManager.INSTANCE.register(IDamageAware.class, new Capability.IStorage() {
+ @Override
+ public void readNBT(Capability capability, IDamageAware instance,
+ EnumFacing side, NBTBase nbt) {
+ }
+
+ @Override
+ public NBTBase writeNBT(Capability capability, IDamageAware instance,
+ EnumFacing side) {
+ return null;
+ }
+ }, DeafUnit::new);
+ }
+
+ /**
+ * The default the capability system requires: a unit that hears and does nothing.
+ *
+ * It exists because {@code CapabilityManager.register} demands a factory, not because anybody
+ * should attach one. A unit with no reaction is better served by not carrying the capability at
+ * all — then nothing is even looked up for it.
+ */
+ public static class DeafUnit implements IDamageAware {
+ @Override
+ public void onDamage(DamageOccurrence occurrence) {
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/Contact.java b/src/main/java/zmaster587/advancedRocketry/api/damage/Contact.java
new file mode 100644
index 000000000..3a6ca1b56
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/Contact.java
@@ -0,0 +1,144 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.EnumFacing;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * One travelling body meeting one block: everything the block needs to decide what it does about it.
+ *
+ * Why a block is asked at all
+ * Toughness alone can only make a block expensive. It cannot send a body somewhere else, cannot
+ * spend a charge of its own to stop one, and cannot answer a beam differently from a slug — so mirror
+ * armour, reactive armour and a ricochet have nowhere to live. A block that is ASKED, and answers with
+ * a {@link ContactResult}, has all three.
+ *
+ * What is in here and what is deliberately not
+ * The body's own facts (how fast, how wide, how much it still carries, what kind of thing it is)
+ * and the geometry of the meeting (where, through which face, at what angle). Not budgets,
+ * stages or toughness: those belong to the layer that spends, and a block answering a contact is not
+ * spending anything — it is saying what happens.
+ *
+ * Frames — the one thing to get right
+ * {@link #getPos()}, {@link #getEntryFace()} and {@link #getVelocity()} are all in the BLOCK's own
+ * frame: subspace on a ship, world off one. They travel together on purpose — an incidence angle is
+ * an angle between a body and a face, and on a hull that has rotated, a world-frame velocity against
+ * a subspace face is not an angle at all, it is two unrelated numbers. {@link #getPoint()} stays
+ * WORLD, because that is where the flash goes and what a player saw. {@link #getShipId()} says which
+ * case this is rather than leaving it to be inferred from coordinates.
+ */
+public final class Contact {
+
+ private final BlockPos pos;
+ private final Vec3d point;
+ private final EnumFacing entryFace;
+ private final Vec3d velocity;
+ private final ImpactKind kind;
+ private final int energy;
+ private final double radius;
+ private final double share;
+ private final String shipId;
+
+ public Contact(BlockPos pos, Vec3d point, EnumFacing entryFace, Vec3d velocity, ImpactKind kind,
+ int energy, double radius, double share, String shipId) {
+ this.pos = pos;
+ this.point = point;
+ this.entryFace = entryFace;
+ this.velocity = velocity;
+ this.kind = kind;
+ this.energy = Math.max(0, energy);
+ this.radius = Math.max(0.0D, radius);
+ this.share = share <= 0.0D ? 0.0D : (share > 1.0D ? 1.0D : share);
+ this.shipId = shipId;
+ }
+
+ /** The block met, in ITS OWN frame: a subspace address on a ship, a world one off a ship. */
+ public BlockPos getPos() {
+ return pos;
+ }
+
+ /** Where the body crossed into it, in WORLD coordinates. */
+ public Vec3d getPoint() {
+ return point;
+ }
+
+ /**
+ * The face it came in through, as an OUTWARD normal — it points back the way the body came.
+ * {@code null} only when the body began its step already inside this block, which is the one case
+ * with no face to have crossed.
+ */
+ public EnumFacing getEntryFace() {
+ return entryFace;
+ }
+
+ /** The outward surface normal at the contact, or {@code null} when there is no entry face. */
+ public Vec3d getNormal() {
+ if (entryFace == null) {
+ return null;
+ }
+ return new Vec3d(entryFace.getFrontOffsetX(), entryFace.getFrontOffsetY(),
+ entryFace.getFrontOffsetZ());
+ }
+
+ /** The body's velocity when it arrived, in the BLOCK's frame (see the class note on frames). */
+ public Vec3d getVelocity() {
+ return velocity;
+ }
+
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ /**
+ * How much impact energy is on the table AT THIS BLOCK — already the block's share of a body wide
+ * enough to meet several at once, never the whole body's remaining energy.
+ */
+ public int getEnergy() {
+ return energy;
+ }
+
+ /** The body's cross-section radius, in blocks. Zero for a body treated as a point. */
+ public double getRadius() {
+ return radius;
+ }
+
+ /** What fraction of the body's cross-section this block covers, in {@code (0, 1]}. */
+ public double getShare() {
+ return share;
+ }
+
+ /** The ship whose blocks were met, or {@code null} for the world's own. */
+ public String getShipId() {
+ return shipId;
+ }
+
+ /**
+ * The angle between the incoming body and the surface normal, in degrees: {@code 0} for a body
+ * arriving square-on and approaching {@code 90} for one merely grazing the face.
+ *
+ * This is the quantity a ricochet is decided on, so it is computed once here rather than by
+ * every block that cares — two implementations of an angle would eventually disagree about which
+ * end of the range means "glancing". Answers {@code 0} when there is no face or no motion, which
+ * is the reading that never bounces.
+ */
+ public double getIncidenceDegrees() {
+ Vec3d normal = getNormal();
+ if (normal == null || velocity == null) {
+ return 0.0D;
+ }
+ double speed = velocity.lengthVector();
+ if (speed <= 1.0E-9D) {
+ return 0.0D;
+ }
+ // The body travels INTO the block, so its direction opposes the outward normal; negating one
+ // of them puts the two vectors on the same side and makes the dot product the cosine of the
+ // angle a reader would name.
+ double cos = -(velocity.x * normal.x + velocity.y * normal.y + velocity.z * normal.z) / speed;
+ if (cos > 1.0D) {
+ cos = 1.0D;
+ } else if (cos < -1.0D) {
+ cos = -1.0D;
+ }
+ return Math.toDegrees(Math.acos(cos));
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java
new file mode 100644
index 000000000..3cd148064
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java
@@ -0,0 +1,113 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * What a block answered when a travelling body met it.
+ *
+ * Three behaviours, and a way to have none
+ *
+ * - {@link #noOpinion} — this block has nothing to say about THIS body, and the default law
+ * applies exactly as if it answered nothing at all. It is not a behaviour, it is a declining to
+ * have one, and it exists because the alternative was to decline by saying
+ * {@code passedThrough(everything)} — which is a real answer meaning "through, for free". A
+ * mirror shipped saying that about slugs and became an armour plate that kinetic fire could
+ * neither pay for nor break.
+ * - {@link #passedThrough} — the body carries on, worth less. The default: it is what an ordinary
+ * block does, and what "weakened penetration" means.
+ * - {@link #stopped} — nothing continues past this block. Reactive armour is this, plus the
+ * block spending itself; a block that detonates its own charge answers {@code stopped} and takes
+ * care of its own destruction, because what a unit does about its own damage is the unit's
+ * (see the damage-occurrence interface).
+ * - {@link #deflected} — the body continues somewhere else. Mirror armour and an angle
+ * ricochet are both this: they differ only in who computed the new velocity — the block's
+ * own law, or the default law from the surface normal and the incidence angle.
+ *
+ *
+ * Deflection is not a special case a reader has to know about: one that has never heard of mirror
+ * armour still reads "the body did not stop here", which stays true. The same discipline the shield's
+ * own strike result keeps for its reflection.
+ */
+public final class ContactResult {
+
+ /**
+ * The one instance of "nothing to say". A singleton because it carries no facts: two declinings
+ * are the same declining, and giving it a residual energy would invite somebody to read one.
+ */
+ private static final ContactResult NO_OPINION = new ContactResult(false, 0, null, true);
+
+ private final boolean stopped;
+ private final int residualEnergy;
+ private final Vec3d deflectedVelocity;
+ private final boolean noOpinion;
+
+ private ContactResult(boolean stopped, int residualEnergy, Vec3d deflectedVelocity,
+ boolean noOpinion) {
+ this.stopped = stopped;
+ this.residualEnergy = Math.max(0, residualEnergy);
+ this.deflectedVelocity = deflectedVelocity;
+ this.noOpinion = noOpinion;
+ }
+
+ /**
+ * This block declines to answer for this body: the default law applies, exactly as it does for the
+ * two thousand blocks that implement nothing at all.
+ *
+ * A responder answers for the arrivals it has a mechanism for and declines for the rest —
+ * a mirror has a law about light and none about a solid round, and the round should then be priced
+ * and resisted like any other piece of glass. Whoever declines here is asking for the ordinary
+ * treatment, not asking to be skipped.
+ */
+ public static ContactResult noOpinion() {
+ return NO_OPINION;
+ }
+
+ /** True when this block declined to answer and the default law should decide instead. */
+ public boolean isNoOpinion() {
+ return noOpinion;
+ }
+
+ /** The body carries on along its own course with {@code residualEnergy} left. */
+ public static ContactResult passedThrough(int residualEnergy) {
+ return new ContactResult(false, residualEnergy, null, false);
+ }
+
+ /** Nothing continues past this block. */
+ public static ContactResult stopped() {
+ return new ContactResult(true, 0, null, false);
+ }
+
+ /**
+ * The body leaves along {@code newVelocity} with {@code residualEnergy} left.
+ *
+ * A null or motionless {@code newVelocity} degrades to {@link #stopped()} rather than claiming
+ * a deflection with nowhere to go — the same refusal the shield's reflection makes, and for the
+ * same reason: a body deflected to a standstill is a body that stopped.
+ */
+ public static ContactResult deflected(Vec3d newVelocity, int residualEnergy) {
+ if (newVelocity == null || newVelocity.lengthVector() <= 1.0E-9D) {
+ return stopped();
+ }
+ return new ContactResult(false, residualEnergy, newVelocity, false);
+ }
+
+ /** True when nothing continues past the block that answered. */
+ public boolean isStopped() {
+ return stopped;
+ }
+
+ /** True when the body continues, but along a course this block chose. */
+ public boolean isDeflected() {
+ return deflectedVelocity != null;
+ }
+
+ /** What the body still carries; {@code 0} when it stopped. */
+ public int getResidualEnergy() {
+ return residualEnergy;
+ }
+
+ /** The course the body leaves on, or {@code null} when it kept its own. */
+ public Vec3d getDeflectedVelocity() {
+ return deflectedVelocity;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageCause.java b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageCause.java
new file mode 100644
index 000000000..d9a39da03
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageCause.java
@@ -0,0 +1,40 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/**
+ * What happened to a unit, as opposed to how much of it happened.
+ *
+ * This list is deliberately OPEN, and nothing may depend on it being complete
+ * New members are added without ceremony, and a unit that meets one it does not recognise treats it
+ * as something happened — never as an error, never as a reason to do nothing. A {@code switch}
+ * over these values without a default is a bug waiting for the next member; write the default first.
+ *
+ * Presence in a nebula is a named likely member that does not exist yet. It is mentioned here so
+ * that the absence reads as "not built" rather than as "not thought of".
+ *
+ * Why this sits ABOVE {@link ImpactKind} rather than beside it
+ * {@code ImpactKind} answers what kind of thing struck structure — kinetic, thermal,
+ * explosive — and it only makes sense when something struck along a line. A hyperspace window
+ * collapsing is not an impact of any kind, and forcing it to name one would make every reader of the
+ * kind field ask which lie was told. So the cause names the EVENT, and the kind rides along only when
+ * there was a body.
+ */
+public enum DamageCause {
+
+ /** Something arrived along a line and spent a budget: a shell, a bolt, a beam. Carries a kind. */
+ IMPACT,
+
+ /** Two structures met at speed. Carries a kind; the geometry is the collision's, not a weapon's. */
+ COLLISION,
+
+ /** A hull put down harder than it should have been. */
+ HARD_LANDING,
+
+ /** A blast in the world, this unit inside it. */
+ EXPLOSION,
+
+ /** A jump ended the way nobody wanted. Hull-wide by nature, and has no point. */
+ HYPERSPACE_EXIT,
+
+ /** Accrued use rather than an event — the wear channel, which has always had its own writer. */
+ WEAR
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOccurrence.java b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOccurrence.java
new file mode 100644
index 000000000..39d285cb4
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOccurrence.java
@@ -0,0 +1,130 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.world.World;
+
+/**
+ * What just happened to one unit — a cause, a severity and a place, at a moment.
+ *
+ * Why this exists beside the stage, which is already there
+ * A stage answers how broken am I: it is durable, it survives a save and a reassembly, and
+ * it is PULLED by whoever wants it. It cannot answer what just happened to me, because a
+ * shell and a collapsing hyperspace window leave exactly the same stage behind. A cause has no
+ * persistent form to pull, so it is pushed once, at the moment it is true, and a unit that was not
+ * listening has missed it — which is correct: it is news, not state.
+ *
+ * The unit decides; this value decides nothing
+ * Everything here is a FACT about the event. There is no derate in it, no probability, no verdict,
+ * because the consequence of being damaged is the unit's own to compute — a damaged engine throttles
+ * itself back because it decides to stay safe, not because a table above it lowered a number. This
+ * value is what the unit needs in order to decide, and nothing more.
+ */
+public final class DamageOccurrence {
+
+ private final DamageCause cause;
+ private final ImpactKind kind;
+ private final World world;
+ private final BlockPos pos;
+ private final Vec3d where;
+ private final int stageBefore;
+ private final int stageAfter;
+ private final int maxStage;
+ private final int budgetSpent;
+ private final String shipId;
+
+ public DamageOccurrence(DamageCause cause, ImpactKind kind, World world, BlockPos pos, Vec3d where,
+ int stageBefore, int stageAfter, int maxStage, int budgetSpent,
+ String shipId) {
+ this.cause = cause;
+ this.kind = kind;
+ this.world = world;
+ this.pos = pos;
+ this.where = where;
+ this.stageBefore = stageBefore;
+ this.stageAfter = stageAfter;
+ this.maxStage = maxStage;
+ this.budgetSpent = budgetSpent;
+ this.shipId = shipId;
+ }
+
+ /** What happened. Never null, and never to be switched over without a default — the list is open. */
+ public DamageCause getCause() {
+ return cause;
+ }
+
+ /**
+ * What kind of thing struck, when something did; {@code null} for a cause that is not an arrival
+ * along a line. Absent rather than defaulted on purpose: a hyperspace exit that reported itself as
+ * {@code KINETIC} would be a lie every reader of this field would believe.
+ */
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ /**
+ * The world this happened in. Carried because a unit computing its own consequence — a tank
+ * letting go, a reactor scramming — needs a handle on the game to do it with, and the alternative
+ * was a static that would answer about whoever asked last.
+ */
+ public World getWorld() {
+ return world;
+ }
+
+ /**
+ * The unit's own position, in the frame its blocks live in — subspace aboard a ship, the world's
+ * own otherwise. This is the position a tile lookup takes; {@link #getWhere()} is the one a
+ * particle or a sound takes.
+ */
+ public BlockPos getPos() {
+ return pos;
+ }
+
+ /**
+ * Where it happened in WORLD coordinates, or {@code null} for a cause that has no place — a
+ * hull-wide occurrence is not "at" anywhere and says so by carrying nothing rather than by
+ * carrying the hull's centre, which would be a point nothing actually happened at.
+ */
+ public Vec3d getWhere() {
+ return where;
+ }
+
+ /** The stage this unit was at before, and after. Equal means nothing advanced. */
+ public int getStageBefore() {
+ return stageBefore;
+ }
+
+ public int getStageAfter() {
+ return stageAfter;
+ }
+
+ /** The stage at which this unit is gone. {@code getStageAfter() >= getMaxStage()} is destruction. */
+ public int getMaxStage() {
+ return maxStage;
+ }
+
+ /** How much energy went into THIS unit — the severity, in the engine's own units. */
+ public int getBudgetSpent() {
+ return budgetSpent;
+ }
+
+ /** The hull this happened to, or {@code null} when the unit is standing on the ground. */
+ public String getShipId() {
+ return shipId;
+ }
+
+ /**
+ * Was this the blow that ended the unit? The one occurrence a unit most needs, and the one a
+ * naive implementation loses: by the time an ordinary reader looks, the block is already air.
+ */
+ public boolean isDestroyed() {
+ return stageAfter >= maxStage;
+ }
+
+ @Override
+ public String toString() {
+ return "DamageOccurrence[" + cause + (kind == null ? "" : "/" + kind)
+ + " at " + pos + " stage " + stageBefore + "->" + stageAfter + "/" + maxStage
+ + " spent " + budgetSpent + (shipId == null ? "" : " ship " + shipId) + "]";
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOutcome.java b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOutcome.java
new file mode 100644
index 000000000..2cc6b7b49
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageOutcome.java
@@ -0,0 +1,14 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/** What became of a declared impact. The branch point a shot reads to decide its own fate. */
+public enum DamageOutcome {
+
+ /** The impact met no structure at all — nothing was spent and nothing was touched. */
+ NOTHING_STRUCK,
+
+ /** Structure took the whole budget: the impact ends inside what it struck. */
+ ABSORBED,
+
+ /** Structure did not consume the whole budget and the impact left the far side, still carrying it. */
+ EXITED
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java
new file mode 100644
index 000000000..ce1b14cc1
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java
@@ -0,0 +1,110 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * What structure did with a declared impact. The report states facts and never a decision:
+ * whether the shot that produced this impact now terminates, keeps flying or ricochets is the
+ * weapon's own business, and it is decidable from these fields.
+ *
+ * There is deliberately no per-block list. A turret firing continuously produces a stream of
+ * small impacts, and a list would be an allocation per shot on the hot path; which particular blocks
+ * changed is a surfacing concern and rides the damage map, not the return value.
+ */
+public final class DamageReport {
+
+ private final DamageOutcome outcome;
+ private final StopReason stopReason;
+ private final int budgetSpent;
+ private final int budgetLeft;
+ private final int blocksStaged;
+ private final int blocksDestroyed;
+ private final Vec3d entryPoint;
+ private final Vec3d exitPoint;
+ private final int penetrationDepth;
+ private final double distanceWalked;
+
+ public DamageReport(DamageOutcome outcome, StopReason stopReason, int budgetSpent, int budgetLeft,
+ int blocksStaged, int blocksDestroyed, Vec3d entryPoint, Vec3d exitPoint,
+ int penetrationDepth) {
+ this(outcome, stopReason, budgetSpent, budgetLeft, blocksStaged, blocksDestroyed, entryPoint,
+ exitPoint, penetrationDepth, 0.0D);
+ }
+
+ public DamageReport(DamageOutcome outcome, StopReason stopReason, int budgetSpent, int budgetLeft,
+ int blocksStaged, int blocksDestroyed, Vec3d entryPoint, Vec3d exitPoint,
+ int penetrationDepth, double distanceWalked) {
+ this.outcome = outcome;
+ this.stopReason = stopReason;
+ this.budgetSpent = budgetSpent;
+ this.budgetLeft = budgetLeft;
+ this.blocksStaged = blocksStaged;
+ this.blocksDestroyed = blocksDestroyed;
+ this.entryPoint = entryPoint;
+ this.exitPoint = exitPoint;
+ this.penetrationDepth = penetrationDepth;
+ this.distanceWalked = Math.max(0.0D, distanceWalked);
+ }
+
+ /** Nothing damageable was met: no spend, no change. */
+ public static DamageReport nothingStruck(int budget, StopReason reason) {
+ return new DamageReport(DamageOutcome.NOTHING_STRUCK, reason, 0, budget, 0, 0, null, null, 0);
+ }
+
+ /** This identity was already applied; the caller is seeing its own earlier impact. */
+ public static DamageReport duplicate(int budget) {
+ return nothingStruck(budget, StopReason.DUPLICATE_IMPACT);
+ }
+
+ public DamageOutcome getOutcome() {
+ return outcome;
+ }
+
+ public StopReason getStopReason() {
+ return stopReason;
+ }
+
+ /** Budget consumed by structure. */
+ public int getBudgetSpent() {
+ return budgetSpent;
+ }
+
+ /** Budget still unspent — what a shot leaving the far side carries onward. */
+ public int getBudgetLeft() {
+ return budgetLeft;
+ }
+
+ /** How many blocks were advanced by at least one damage stage without being destroyed. */
+ public int getBlocksStaged() {
+ return blocksStaged;
+ }
+
+ public int getBlocksDestroyed() {
+ return blocksDestroyed;
+ }
+
+ /** Where the impact entered structure, world frame; null when nothing was struck. */
+ public Vec3d getEntryPoint() {
+ return entryPoint;
+ }
+
+ /** Where it left, world frame; null unless the outcome is {@link DamageOutcome#EXITED}. */
+ public Vec3d getExitPoint() {
+ return exitPoint;
+ }
+
+ /** Blocks traversed along the path — what tells two weapons of equal energy apart. */
+ /**
+ * How far into the target this impact actually got, in blocks along its own direction — distinct
+ * from {@link #getPenetrationDepth()}, which counts blocks met. A body that penetrates over time
+ * advances by this, so a bore that stalls against armour advances by very little and one that
+ * sails through advances by its whole reach.
+ */
+ public double getDistanceWalked() {
+ return distanceWalked;
+ }
+
+ public int getPenetrationDepth() {
+ return penetrationDepth;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java
new file mode 100644
index 000000000..c6dedf106
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java
@@ -0,0 +1,42 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.world.World;
+
+/**
+ * A block that has something to say about a body meeting it — armour, in one word.
+ *
+ * Implement on the {@code Block} when the answer is a property of the material (a plate is a plate
+ * wherever it is placed), or on its {@code TileEntity} when the answer depends on state the block is
+ * carrying (a reactive charge that has already been spent). The block is asked first.
+ *
+ * An implementation decides and answers; it does not spend budgets, advance stages or destroy
+ * anything through this call — except itself, which is its own business. What it must NOT do is
+ * assume it is the only block being asked: a body wide enough covers several at once, and each is
+ * asked with {@link Contact#getShare()} of the energy.
+ *
+ * A block that implements nothing gets the default law, which is ordinary penetration — so this
+ * interface is what armour opts INTO, never something every block has to answer.
+ */
+public interface IContactResponder {
+
+ /**
+ * Answer for one body meeting this block. Never null: return
+ * {@link ContactResult#noOpinion()} to decline having one, and the default law decides instead —
+ * price, stages, ricochet, the lot — exactly as it does for a block that implements nothing.
+ *
+ * Do not decline with {@link ContactResult#passedThrough(int)}. That is an answer, and
+ * what it says is "through, carrying this much", so declining with the arriving energy says
+ * "through, for free". This javadoc told implementers to do exactly that until 2026-08-19, and
+ * mirror plating followed it: a solid round crossed the film spending nothing and left it standing,
+ * so the one armour a beam could strip was the one kinetic fire could not.
+ *
+ * The world is passed rather than carried on the {@link Contact} on purpose. A contact states
+ * the FACTS of a meeting — that is what lets a held beam, which is not a shot in any registry, use
+ * the same seam — while a block that spends ITSELF needs a handle on the game to do it with. One
+ * argument keeps both true, where a world on the contact would have made every future caller
+ * produce one and a static would have made the answer depend on who asked last.
+ *
+ * @param world the world this meeting happened in; server side, never null
+ */
+ ContactResult onContact(World world, Contact contact);
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/IDamageAware.java b/src/main/java/zmaster587/advancedRocketry/api/damage/IDamageAware.java
new file mode 100644
index 000000000..13419507e
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/IDamageAware.java
@@ -0,0 +1,38 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/**
+ * A unit that wants to know what broke it.
+ *
+ * The layer above computes nothing on your behalf
+ * What being damaged DOES to a unit is the unit's own to work out — the derate, the probability of
+ * a failure, what the failure looks like. A damaged engine gives less thrust because it throttles
+ * itself back to stay safe, not because anything above it lowered a number, and a crew that overrides
+ * that takes the risk knowingly. So this hands you the facts and wants nothing back.
+ *
+ * Told, not asked
+ * {@link #onDamage} returns nothing. There is no answer the damage layer would act on: the stage is
+ * already written, the budget is already spent, and the unit's reaction is the unit's business. If you
+ * want to change what happens to the thing that HIT you, that is a different seam entirely — see
+ * {@link IContactResponder}, which is asked BEFORE anything is spent and whose answer decides the
+ * body's fate. A block may implement both; they are two different sentences about it.
+ *
+ * Carried as a capability, unlike the contact seam
+ * A contact is asked of the BLOCK as much as the tile, because two thousand vanilla blocks have no
+ * tile and still have to be met. An occurrence is only meaningful to something with state to change,
+ * and anything with state has a tile — so this rides
+ * {@link zmaster587.advancedRocketry.api.capability.CapabilityDamageAware}, which also lets a foreign
+ * tile be given one without subclassing anything.
+ */
+public interface IDamageAware {
+
+ /**
+ * Something happened to this unit. Called on the SERVER, after the stage has been written and, for
+ * a fatal blow, after the block itself is gone — the unit is being told about its own destruction,
+ * which is exactly the case it most needs and the one it would never hear if it were told earlier
+ * or later. {@link DamageOccurrence#isDestroyed()} is how you tell.
+ *
+ * Throwing from here is not a way to refuse an occurrence: the stage is already written and the
+ * budget already spent, so an exception loses the news and changes nothing else.
+ */
+ void onDamage(DamageOccurrence occurrence);
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactKind.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactKind.java
new file mode 100644
index 000000000..47fe19754
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactKind.java
@@ -0,0 +1,28 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/**
+ * What KIND of impact is being declared against structure — the hull layer's own vocabulary.
+ *
+ * This enum lives in AR's public API and deliberately does not reference the shield mod's
+ * {@code ShieldStrikeKind}: a dependent mod that wants to damage a hull must not be forced to import
+ * the shield package. The mapping between the two is declared in one place on AR's side.
+ *
+ * The mapping is many-to-two by design. A shell distinguishes only "physical" from "energy",
+ * because that is all its resistance bias needs; a hull cares about more than that — thermal ablation
+ * and a solid round are one pair of shield kinds and two entirely different things to structure. New
+ * kinds may be added here without the shield layer growing a matching constant.
+ */
+public enum ImpactKind {
+
+ /** A solid travelling mass: a slug, a round, a thrown body, a collision. */
+ KINETIC,
+
+ /** A blast: energy delivered as overpressure across a region rather than along a line. */
+ EXPLOSIVE,
+
+ /** Sustained heat: star plasma, a corona, re-entry ablation. */
+ THERMAL,
+
+ /** Coherent directed energy: a laser or particle beam. */
+ BEAM
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java
new file mode 100644
index 000000000..204550a4a
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java
@@ -0,0 +1,203 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * One declared impact against structure — everything the damage engine needs and nothing about the
+ * thing that produced it. A weapon names no block, no stage and no toughness; the engine names no
+ * weapon, no archetype and no trajectory. This object is the whole of what crosses between them.
+ *
+ * Frames
+ * {@link #getPoint()} and {@link #getDirection()} are world coordinates. A shot is computed
+ * in the world frame, so that is what it hands over; converting into the frame the blocks actually
+ * live in happens once, inside the engine, where it can be got right in one place rather than at every
+ * call site.
+ *
+ * Budget
+ * {@link #getBudget()} is denominated in the same unit as the shield's impact energy, so a shield
+ * that is overwhelmed hands its residual straight through as a budget with no conversion factor in
+ * between. There is exactly one conversion in the whole chain and it already lives at the muzzle.
+ *
+ * Identity
+ * {@link #getImpactId()} identifies this impact for as long as it might be retried. The engine
+ * refuses a repeat outright, because the paths that make retries real — an impact resolved later
+ * because its region was unloaded, a shot re-examined across a load transition — are exactly the paths
+ * where double damage would never show up in a diff. A caller that genuinely wants a second, distinct
+ * impact gives it a new id.
+ */
+public final class ImpactRequest {
+
+ private final long impactId;
+ private final Vec3d point;
+ private final Vec3d direction;
+ private final int budget;
+ private final ImpactKind kind;
+ private final SelectionMode selectionMode;
+ private final double reachBlocks;
+ private final double crossSectionArea;
+ private final boolean resumesInside;
+ private final DamageCause cause;
+
+ /** The cross-section a budget is priced against unless the caller says otherwise. */
+ public static final double REFERENCE_AREA = Math.PI * 0.25D * 0.25D;
+
+ /** Reach for a caller that has no notion of one — an explosion, a collision, a hazard. */
+ public static final double UNBOUNDED_REACH = Double.MAX_VALUE;
+
+ public ImpactRequest(long impactId, Vec3d point, Vec3d direction, int budget, ImpactKind kind,
+ SelectionMode selectionMode) {
+ this(impactId, point, direction, budget, kind, selectionMode, UNBOUNDED_REACH, REFERENCE_AREA);
+ }
+
+ public ImpactRequest(long impactId, Vec3d point, Vec3d direction, int budget, ImpactKind kind,
+ SelectionMode selectionMode, double reachBlocks, double crossSectionArea) {
+ this(impactId, point, direction, budget, kind, selectionMode, reachBlocks, crossSectionArea,
+ false);
+ }
+
+ public ImpactRequest(long impactId, Vec3d point, Vec3d direction, int budget, ImpactKind kind,
+ SelectionMode selectionMode, double reachBlocks, double crossSectionArea,
+ boolean resumesInside) {
+ this.resumesInside = resumesInside;
+ this.impactId = impactId;
+ this.point = point;
+ this.direction = normalize(direction);
+ this.budget = Math.max(0, budget);
+ this.kind = kind == null ? ImpactKind.KINETIC : kind;
+ this.selectionMode = selectionMode == null ? SelectionMode.PENETRATING : selectionMode;
+ this.reachBlocks = reachBlocks <= 0.0D ? 0.0D : reachBlocks;
+ this.crossSectionArea = crossSectionArea <= 0.0D ? REFERENCE_AREA : crossSectionArea;
+ this.cause = DamageCause.IMPACT;
+ }
+
+ /** Copy constructor for {@link #withCause}; the only field that differs is the cause. */
+ private ImpactRequest(ImpactRequest from, DamageCause cause) {
+ this.impactId = from.impactId;
+ this.point = from.point;
+ this.direction = from.direction;
+ this.budget = from.budget;
+ this.kind = from.kind;
+ this.selectionMode = from.selectionMode;
+ this.reachBlocks = from.reachBlocks;
+ this.crossSectionArea = from.crossSectionArea;
+ this.resumesInside = from.resumesInside;
+ this.cause = cause == null ? DamageCause.IMPACT : cause;
+ }
+
+ /**
+ * The same request, declared as a different KIND OF EVENT.
+ *
+ * Every geometric field means what it meant — something arrived at a point and spent a budget
+ * along a direction — so this changes nothing about how the damage resolves. What it changes is
+ * what the units it reaches are TOLD happened to them, and a hull scraping a canyon wall is not a
+ * hull being shot at, however identical the arithmetic.
+ */
+ public ImpactRequest withCause(DamageCause newCause) {
+ return newCause == null || newCause == this.cause ? this : new ImpactRequest(this, newCause);
+ }
+
+ /** A solid body striking at a point and boring along its direction of travel. */
+ public static ImpactRequest penetrating(long impactId, Vec3d point, Vec3d direction, int budget,
+ ImpactKind kind) {
+ return new ImpactRequest(impactId, point, direction, budget, kind, SelectionMode.PENETRATING);
+ }
+
+ /**
+ * The same, from a body that is only allowed to get so far this time and has a cross-section of
+ * its own — a shot boring through a hull over several ticks, which may spend only as much of its
+ * path as it actually travelled.
+ */
+ public static ImpactRequest penetrating(long impactId, Vec3d point, Vec3d direction, int budget,
+ ImpactKind kind, double reachBlocks,
+ double crossSectionArea) {
+ return new ImpactRequest(impactId, point, direction, budget, kind, SelectionMode.PENETRATING,
+ reachBlocks, crossSectionArea, false);
+ }
+
+ /**
+ * The same, from a body that is CONTINUING a bore it began on an earlier tick: it is standing in
+ * the block it starts in and has already been charged for it.
+ *
+ * Without this a slow round pays for the block it is embedded in once per tick and grinds it to
+ * dust without moving, which is not "penetration takes time" — it is a shot that gets stronger the
+ * slower it goes.
+ */
+ public static ImpactRequest resuming(long impactId, Vec3d point, Vec3d direction, int budget,
+ ImpactKind kind, double reachBlocks,
+ double crossSectionArea) {
+ return new ImpactRequest(impactId, point, direction, budget, kind, SelectionMode.PENETRATING,
+ reachBlocks, crossSectionArea, true);
+ }
+
+ /**
+ * What kind of event this was, for the units it reaches. Defaults to {@link DamageCause#IMPACT},
+ * which is what every geometric request is unless its caller says otherwise — the damage layer
+ * resolves all of them identically and only the telling differs.
+ */
+ public DamageCause getCause() {
+ return cause;
+ }
+
+ /** Identity for retry refusal; see the class note. */
+ public long getImpactId() {
+ return impactId;
+ }
+
+ /** Where the impact meets structure, in WORLD coordinates. */
+ public Vec3d getPoint() {
+ return point;
+ }
+
+ /** Unit direction of travel, in WORLD coordinates (zero vector if the caller gave a degenerate one). */
+ public Vec3d getDirection() {
+ return direction;
+ }
+
+ /** Damage budget, in shield-energy-equivalent units. */
+ public int getBudget() {
+ return budget;
+ }
+
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ public SelectionMode getSelectionMode() {
+ return selectionMode;
+ }
+
+ /**
+ * How far along its direction this impact may reach, in blocks. A body that penetrates over time
+ * grants only the distance it actually travelled this tick; a caller with no such notion leaves it
+ * {@link #UNBOUNDED_REACH} and the engine's own path limit is what bounds the walk.
+ */
+ public double getReachBlocks() {
+ return reachBlocks;
+ }
+
+ /**
+ * The body's cross-section, in square blocks. Material resists with a PRESSURE, so the energy a
+ * body spends per unit of depth is that pressure times this area: the same energy behind a wider
+ * face bores less far. Defaults to {@link #REFERENCE_AREA}, at which the price is exactly what it
+ * was before areas were priced at all.
+ */
+ public double getCrossSectionArea() {
+ return crossSectionArea;
+ }
+
+ /** True when the body already paid for the block it starts in, on an earlier tick of the same bore. */
+ public boolean resumesInside() {
+ return resumesInside;
+ }
+
+ private static Vec3d normalize(Vec3d v) {
+ if (v == null) {
+ return new Vec3d(0.0D, 0.0D, 0.0D);
+ }
+ double len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
+ if (len <= 1.0E-8D) {
+ return new Vec3d(0.0D, 0.0D, 0.0D);
+ }
+ return new Vec3d(v.x / len, v.y / len, v.z / len);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/SelectionMode.java b/src/main/java/zmaster587/advancedRocketry/api/damage/SelectionMode.java
new file mode 100644
index 000000000..24a68c183
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/SelectionMode.java
@@ -0,0 +1,32 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/**
+ * Which blocks a damage budget is allowed to spend itself on. One engine, a pluggable rule: the
+ * budget-and-spend loop is identical for every mode and only the candidate order differs.
+ */
+public enum SelectionMode {
+
+ /**
+ * Weighted by subsystem class rather than by geometry — power-carrying blocks first. The mode an
+ * emergency exit uses: it has a ship and a severity, and no impact point at all.
+ */
+ POWER_BIASED,
+
+ /**
+ * Candidates on the incidence SIDE, nearest-first along the incidence normal. Designed for a
+ * hazard that bathes one flank (a star's plasma), where "the side facing it" is the whole of the
+ * geometry — not for a solid round, which makes a hole where it struck.
+ */
+ DIRECTIONAL,
+
+ /** Every candidate equally likely; no geometry and no bias. */
+ UNIFORM,
+
+ /**
+ * Walks the voxel ray from the entry point along the impact direction, spending on each block it
+ * meets until the budget runs out or it leaves the far side. This is the mode that gives a shot a
+ * penetration depth and an exit point, and so the one that lets two weapons of equal energy behave
+ * differently.
+ */
+ PENETRATING
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java b/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java
new file mode 100644
index 000000000..94cd5dbb8
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java
@@ -0,0 +1,43 @@
+package zmaster587.advancedRocketry.api.damage;
+
+/**
+ * Why the spend stopped. This is the field that separates outcomes a bare
+ * {@link DamageOutcome#NOTHING_STRUCK} would blur together — in particular "there was nothing there"
+ * from "there may well be something there, but it is not loaded, so ask again later".
+ */
+public enum StopReason {
+
+ /** The budget ran out inside structure. Pairs with {@link DamageOutcome#ABSORBED}. */
+ BUDGET_EXHAUSTED,
+
+ /** Nothing damageable stood in the way. Pairs with {@link DamageOutcome#NOTHING_STRUCK}. */
+ NO_CANDIDATES,
+
+ /** The path left the structure with budget to spare. Pairs with {@link DamageOutcome#EXITED}. */
+ EXITED_FAR_SIDE,
+
+ /**
+ * The granted PATH ran out while the body was still inside structure, with budget in hand.
+ * Pairs with {@link DamageOutcome#EXITED}, because the budget is handed back either way — and
+ * that shared outcome is exactly why this reason has to exist separately.
+ *
+ * "Budget left over" and "came out the other side" are different facts, and a caller that
+ * has to decide whether the body is still IN there can only tell them apart here. Reported as
+ * {@code EXITED_FAR_SIDE} until 2026-08-20, which told a round that had bored a fifth of a
+ * block that it had left the plate.
+ */
+ REACH_EXHAUSTED,
+
+ /**
+ * The target region is not loaded, so nothing could be resolved. Not a statement that there
+ * is nothing there — a caller able to retry should, and one that treats this as "clean miss"
+ * silently loses shots into unloaded space.
+ */
+ TARGET_UNLOADED,
+
+ /**
+ * This impact identity was applied already and was refused a second time. Zero spend, nothing
+ * touched. A retrying caller sees its own earlier success, not a new one.
+ */
+ DUPLICATE_IMPACT
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/TravellingBody.java b/src/main/java/zmaster587/advancedRocketry/api/damage/TravellingBody.java
new file mode 100644
index 000000000..19287102f
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/damage/TravellingBody.java
@@ -0,0 +1,74 @@
+package zmaster587.advancedRocketry.api.damage;
+
+import net.minecraft.util.math.Vec3d;
+
+/**
+ * The facts a travelling body has when it meets something — and nothing about what is carrying them.
+ *
+ * Why the facts and not the record
+ * Armour answers bodies. A shell fired from a gun is one; a bolt is one; a beam somebody is HOLDING
+ * on a hull is one too, and it is not a shot in any registry — it has no lifetime, no position that
+ * survives a tick and nothing to step. A seam that took the shot record would therefore serve exactly
+ * one weapon family, and every later family would arrive with a choice between inventing a fake shot
+ * and duplicating the armour behind it. Taking the facts costs one small object per contact and makes
+ * armour something the whole game can be built against.
+ *
+ * The identity is given, not minted here
+ * {@link #getImpactId()} is what the damage service's duplicate memory keys on, so it must come
+ * from whatever owns the body's continuity across ticks: a shot mints one per impact from its own
+ * sequence, a held beam mints one per tick it is held. This class does not know how long the thing it
+ * describes has existed and must not guess.
+ */
+public final class TravellingBody {
+
+ private final long impactId;
+ private final Vec3d velocity;
+ private final ImpactKind kind;
+ private final int energy;
+ private final double radius;
+
+ /**
+ * @param impactId identity for THIS meeting, distinct from every other in this world
+ * @param velocity the body's velocity in WORLD terms, blocks per tick
+ * @param kind what sort of arrival this is, in the hull's own vocabulary
+ * @param energy what it is still worth on arrival
+ * @param radius the body's radius in blocks — its cross-section is what the material resists
+ */
+ public TravellingBody(long impactId, Vec3d velocity, ImpactKind kind, int energy, double radius) {
+ this.impactId = impactId;
+ this.velocity = velocity;
+ this.kind = kind;
+ this.energy = Math.max(0, energy);
+ this.radius = Math.max(0.0D, radius);
+ }
+
+ public long getImpactId() {
+ return impactId;
+ }
+
+ /** WORLD velocity, blocks per tick. The block's own frame is derived at the seam, never here. */
+ public Vec3d getVelocity() {
+ return velocity;
+ }
+
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ public int getEnergy() {
+ return energy;
+ }
+
+ public double getRadius() {
+ return radius;
+ }
+
+ /** The direction it is travelling, or straight down when it is not travelling at all. */
+ public Vec3d getDirection() {
+ if (velocity == null) {
+ return new Vec3d(0.0D, -1.0D, 0.0D);
+ }
+ double speed = velocity.lengthVector();
+ return speed <= 1.0E-9D ? new Vec3d(0.0D, -1.0D, 0.0D) : velocity.scale(1.0D / speed);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java
new file mode 100644
index 000000000..7c109bd0c
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java
@@ -0,0 +1,35 @@
+package zmaster587.advancedRocketry.api.projectile;
+
+/**
+ * Why a shot stopped existing. A shot always ends for exactly one stated reason — "it is no longer in
+ * the registry" is not an outcome anybody can act on, and a weapon that cannot tell a hit from a
+ * timeout cannot report a miss.
+ */
+public enum ShotEndReason {
+
+ /** It ran out its declared lifetime without meeting anything. */
+ EXPIRED,
+
+ /** A shell paid for it in full and it had no body left to send anywhere. */
+ FIELD_ABSORBED,
+
+ /**
+ * A shell mirrored it, and what came back was slower than the speed floor. Unlike an entity, which
+ * has to end up somewhere and so gets nudged, a shot record has the better option of ceasing to
+ * exist rather than loitering at the shell at nearly zero velocity.
+ */
+ REFLECTED_TOO_SLOW,
+
+ /** It met structure and its impact was handed to the damage service. */
+ STRUCTURE_IMPACT,
+
+ /** Its world went away underneath it. */
+ WORLD_UNLOADED,
+
+ /**
+ * The substrate was switched off under it. Off has to mean gone rather than paused: a round left
+ * in the registry is written back into the save on every tick that follows, and switching the
+ * flag on again months later would resume a shot into a world that has moved on.
+ */
+ SUBSTRATE_DISABLED
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEnvironment.java b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEnvironment.java
new file mode 100644
index 000000000..705a8f9cb
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEnvironment.java
@@ -0,0 +1,57 @@
+package zmaster587.advancedRocketry.api.projectile;
+
+/**
+ * What acts on a shot between one tick and the next — the whole of the physics a travelling round is
+ * subject to, declared at the muzzle rather than looked up mid-flight.
+ *
+ * It is declared because a shot outlives the loaded state of the place it is crossing. Asking the
+ * world "what is the gravity here" every tick would mean the answer changes when a region unloads,
+ * and a round that curves differently depending on whether anybody happens to be watching is not a
+ * round anybody can aim. So the shooter states the environment once and the shot carries it.
+ *
+ * Only gravity is modelled. Drag is not, and no field is reserved for it: a shot that needs air
+ * resistance needs a decision about what "air" means at 2000 blocks up, and that decision is not
+ * made yet.
+ */
+public final class ShotEnvironment {
+
+ /** Nothing acts. The path is a straight line — space, and the band ships fly in. */
+ public static final ShotEnvironment VACUUM = new ShotEnvironment(0.0D);
+
+ private final double gravityPerTickSquared;
+
+ private ShotEnvironment(double gravityPerTickSquared) {
+ this.gravityPerTickSquared = gravityPerTickSquared;
+ }
+
+ /**
+ * Constant downward acceleration, in blocks per tick squared. Vanilla's own projectile gravity is
+ * around 0.03 at the surface; a body's planet scales it.
+ */
+ public static ShotEnvironment gravity(double perTickSquared) {
+ double g = Math.max(0.0D, perTickSquared);
+ return g == 0.0D ? VACUUM : new ShotEnvironment(g);
+ }
+
+ /** Downward acceleration in blocks per tick squared; 0 in vacuum. */
+ public double getGravityPerTickSquared() {
+ return gravityPerTickSquared;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof ShotEnvironment
+ && Double.compare(((ShotEnvironment) other).gravityPerTickSquared,
+ gravityPerTickSquared) == 0;
+ }
+
+ @Override
+ public int hashCode() {
+ return Double.valueOf(gravityPerTickSquared).hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return gravityPerTickSquared == 0.0D ? "vacuum" : "gravity=" + gravityPerTickSquared;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotSpec.java b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotSpec.java
new file mode 100644
index 000000000..f6196bf7e
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotSpec.java
@@ -0,0 +1,151 @@
+package zmaster587.advancedRocketry.api.projectile;
+
+import net.minecraft.util.math.Vec3d;
+import zmaster587.advancedRocketry.api.damage.ImpactKind;
+
+import java.util.UUID;
+
+/**
+ * Everything a weapon states when it fires, and nothing about the weapon. The muzzle declares where
+ * the round starts, how fast it is going, how much it is worth on arrival and how long it may live;
+ * the substrate decides nothing about any of that and the weapon decides nothing about what happens
+ * when it lands.
+ *
+ * Frames and units
+ * {@link #getOrigin()} and {@link #getVelocity()} are world coordinates, and velocity is in
+ * blocks per tick — the unit the integration steps in, so that no call site is left converting
+ * from blocks per second and getting it wrong by a factor of twenty. {@link #getImpactEnergy()} is in
+ * the same unit as a shield's impact energy and a damage budget, which is what lets a shell hand its
+ * residual straight through with no conversion in between.
+ *
+ * Owner and faction are tokens, not permissions
+ * The substrate never asks either one a question. They travel with the shot so that the layers
+ * which do care — friend-or-foe at the turret, attribution at the impact — have something to read;
+ * a shot does not decline to hit its owner, because deciding that is not the substrate's job.
+ */
+public final class ShotSpec {
+
+ /** Default lifetime when the shooter does not state one: one minute of flight. */
+ public static final int DEFAULT_LIFETIME_TICKS = 1200;
+
+ private final Vec3d origin;
+ private final Vec3d velocity;
+ private final double radius;
+ private final double mass;
+ private final int lifetimeTicks;
+ private final int impactEnergy;
+ private final ImpactKind kind;
+ private final UUID owner;
+ private final String faction;
+ private final ShotEnvironment environment;
+ private final String guidance;
+
+ public ShotSpec(Vec3d origin, Vec3d velocity, double radius, double mass, int lifetimeTicks,
+ int impactEnergy, ImpactKind kind, UUID owner, String faction,
+ ShotEnvironment environment, String guidance) {
+ this.origin = origin;
+ this.velocity = velocity == null ? new Vec3d(0.0D, 0.0D, 0.0D) : velocity;
+ this.radius = Math.max(0.0D, radius);
+ this.mass = Math.max(0.0D, mass);
+ this.lifetimeTicks = Math.max(1, lifetimeTicks);
+ this.impactEnergy = Math.max(0, impactEnergy);
+ this.kind = kind == null ? ImpactKind.KINETIC : kind;
+ this.owner = owner;
+ this.faction = faction;
+ this.environment = environment == null ? ShotEnvironment.VACUUM : environment;
+ this.guidance = guidance;
+ }
+
+ /** A plain unguided round in vacuum, owned by nobody: the shape every other one is built from. */
+ public static ShotSpec kinetic(Vec3d origin, Vec3d velocity, int impactEnergy) {
+ return new ShotSpec(origin, velocity, 0.25D, 1.0D, DEFAULT_LIFETIME_TICKS, impactEnergy,
+ ImpactKind.KINETIC, null, null, ShotEnvironment.VACUUM, null);
+ }
+
+ public ShotSpec withKind(ImpactKind newKind) {
+ return new ShotSpec(origin, velocity, radius, mass, lifetimeTicks, impactEnergy, newKind, owner,
+ faction, environment, guidance);
+ }
+
+ public ShotSpec withLifetime(int ticks) {
+ return new ShotSpec(origin, velocity, radius, mass, ticks, impactEnergy, kind, owner, faction,
+ environment, guidance);
+ }
+
+ public ShotSpec withBody(double newRadius, double newMass) {
+ return new ShotSpec(origin, velocity, newRadius, newMass, lifetimeTicks, impactEnergy, kind,
+ owner, faction, environment, guidance);
+ }
+
+ public ShotSpec withOwner(UUID newOwner, String newFaction) {
+ return new ShotSpec(origin, velocity, radius, mass, lifetimeTicks, impactEnergy, kind, newOwner,
+ newFaction, environment, guidance);
+ }
+
+ public ShotSpec withEnvironment(ShotEnvironment newEnvironment) {
+ return new ShotSpec(origin, velocity, radius, mass, lifetimeTicks, impactEnergy, kind, owner,
+ faction, newEnvironment, guidance);
+ }
+
+ /**
+ * Attach a guidance token. Nothing steers today: the substrate carries this and persists it so
+ * that the layer which eventually does the steering has somewhere to say what it is steering
+ * towards, without every shot in flight at that moment becoming unreadable.
+ */
+ public ShotSpec withGuidance(String newGuidance) {
+ return new ShotSpec(origin, velocity, radius, mass, lifetimeTicks, impactEnergy, kind, owner,
+ faction, environment, newGuidance);
+ }
+
+ /** Where the round starts, in WORLD coordinates. */
+ public Vec3d getOrigin() {
+ return origin;
+ }
+
+ /** Velocity in WORLD coordinates, blocks per tick. */
+ public Vec3d getVelocity() {
+ return velocity;
+ }
+
+ /** Body radius in blocks. Carried for the layers that draw and size it; the crossing test is a ray. */
+ public double getRadius() {
+ return radius;
+ }
+
+ /** Body mass. Carried for the layers that compute recoil and momentum transfer. */
+ public double getMass() {
+ return mass;
+ }
+
+ public int getLifetimeTicks() {
+ return lifetimeTicks;
+ }
+
+ /** What it is worth on arrival, in shield-energy-equivalent units. */
+ public int getImpactEnergy() {
+ return impactEnergy;
+ }
+
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ /** Who fired it, or null. A token the substrate never reads. */
+ public UUID getOwner() {
+ return owner;
+ }
+
+ /** Whose side it is on, or null. A token the substrate never reads. */
+ public String getFaction() {
+ return faction;
+ }
+
+ public ShotEnvironment getEnvironment() {
+ return environment;
+ }
+
+ /** The reserved guidance token, or null. Nothing steers in stage 1; see {@link #withGuidance}. */
+ public String getGuidance() {
+ return guidance;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/sensor/ITargetSignature.java b/src/main/java/zmaster587/advancedRocketry/api/sensor/ITargetSignature.java
new file mode 100644
index 000000000..2253a6f1c
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/sensor/ITargetSignature.java
@@ -0,0 +1,32 @@
+package zmaster587.advancedRocketry.api.sensor;
+
+/**
+ * What a thing looks like to something listening for it: how hot it is, and how much of it there is.
+ *
+ * Two numbers, never one
+ * They are kept apart because the whole build trade lives in their difference. Total radiated
+ * power decides how FAR away a thing can be noticed; radiance — a function of temperature alone —
+ * decides how well it can be RESOLVED once noticed. A compact, chiller-boosted radiator array and a
+ * large cool one can shed exactly the same watts while being completely different targets: the first
+ * is a point beacon that can be locked from a long way off, the second is a smear that is easy to
+ * find and hard to hit. Collapsing them into a single "signature" number would delete that choice.
+ *
+ * The seam
+ * Nothing in the game implements this yet: the heat subsystem that will give a ship a real
+ * radiator temperature is unbuilt, and until it lands a target's numbers are estimated from what the
+ * world already knows about it (see {@code SignatureModel}). This interface is where that estimate
+ * stops being used — a ship, a machine or an addon's entity that can state its own temperature
+ * implements it, and the sensor believes it in preference to any guess.
+ */
+public interface ITargetSignature {
+
+ /**
+ * The temperature of the radiating surface, in kelvin. Never zero: everything is warmer than the
+ * background, so silence reduces the range at which a thing is noticed and never makes it
+ * invisible.
+ */
+ double getRadiatorTemperatureKelvin();
+
+ /** How much radiating surface there is, in square metres. Affects range, never lock quality. */
+ double getRadiatingAreaSquareMetres();
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/sensor/SensorMode.java b/src/main/java/zmaster587/advancedRocketry/api/sensor/SensorMode.java
new file mode 100644
index 000000000..cf25d64f7
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/sensor/SensorMode.java
@@ -0,0 +1,41 @@
+package zmaster587.advancedRocketry.api.sensor;
+
+/**
+ * The two ways a fire-control sensor can find something, and the whole of the trade between them.
+ *
+ * Why there are two
+ * A ship that shuts everything down to avoid being found must still be able to shoot, or "go dark"
+ * is a button that disarms you. And a target that has itself gone dark must be hard to hit, or going
+ * dark buys nothing. Both are true at once only if aiming has a listening mode and an illuminating
+ * one:
+ *
+ *
+ * - {@link #PASSIVE} — the sensor listens. It emits nothing, so a dark ship can fight; but the
+ * quality of what it gets is bounded by what the target itself radiates, so a cold, quiet
+ * target barely resolves at all.
+ * - {@link #ACTIVE} — the sensor illuminates. Steady, good quality against anything inside its
+ * radius including a cold one — and a standing emission of its own, which is to say the end of
+ * your own silence.
+ *
+ *
+ * The rule a player learns without being taught it: you can shoot in the dark, but only at
+ * things that are themselves lit; to shoot at someone who is hiding, you must stop hiding.
+ */
+public enum SensorMode {
+
+ PASSIVE,
+ ACTIVE;
+
+ /**
+ * Whether running in this mode is itself something another sensor can hear.
+ *
+ * Nothing consumes this yet — the EM-signature layer that turns a standing emission into
+ * somebody else's contact is a separate subsystem. It is stated here rather than left implicit
+ * because it is the entire cost of the active mode, and because the target-side "you are being
+ * locked" warning is supposed to fall out of it rather than being authored: a passive lock is
+ * silent, so being tracked passively is undetectable, which is correct.
+ */
+ public boolean isEmitting() {
+ return this == ACTIVE;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/sensor/TargetTrack.java b/src/main/java/zmaster587/advancedRocketry/api/sensor/TargetTrack.java
new file mode 100644
index 000000000..24ddc3b81
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/sensor/TargetTrack.java
@@ -0,0 +1,90 @@
+package zmaster587.advancedRocketry.api.sensor;
+
+import net.minecraft.util.math.Vec3d;
+
+import java.util.UUID;
+
+/**
+ * One contact, as a sensor currently holds it: where it is, where it is going, and how well it is
+ * being held.
+ *
+ * Position AND velocity, because a point cannot be led
+ * A gun handed a point misses a moving target by however far it moves while the round is in the
+ * air. That is not a gun problem — a gun has no way to know a target is moving — so the velocity
+ * travels with the contact, and the mount that consumes it works out the intercept using its own
+ * muzzle speed.
+ *
+ * Quality is not confidence
+ * {@link #getQuality()} is how well the target is RESOLVED, on 0..1. It is what separates
+ * "something is out there" from "I can put a round on it": below the installation's lock threshold a
+ * battery may track a contact all day and still not fire at it. Where the number comes from depends
+ * on the mode that produced it — the target's own radiance when listening, the sensor's own
+ * illumination when lit.
+ *
+ * Immutable. A track is a snapshot of a moment, not a handle onto a target that keeps changing
+ * underneath its reader.
+ */
+public final class TargetTrack {
+
+ private final UUID entity;
+ private final Vec3d position;
+ private final Vec3d velocity;
+ private final double quality;
+ private final SensorMode mode;
+ private final double radianceWattsPerSquareMetre;
+ private final double distance;
+
+ public TargetTrack(UUID entity, Vec3d position, Vec3d velocity, double quality, SensorMode mode,
+ double radianceWattsPerSquareMetre, double distance) {
+ this.entity = entity;
+ this.position = position;
+ this.velocity = velocity == null ? Vec3d.ZERO : velocity;
+ this.quality = Math.max(0.0D, Math.min(1.0D, quality));
+ this.mode = mode;
+ this.radianceWattsPerSquareMetre = radianceWattsPerSquareMetre;
+ this.distance = distance;
+ }
+
+ /** The entity this contact is, or null for a contact that is not one (nothing produces those yet). */
+ public UUID getEntity() {
+ return entity;
+ }
+
+ /** Where it was when the scan saw it, in WORLD coordinates — never a ship's subspace. */
+ public Vec3d getPosition() {
+ return position;
+ }
+
+ /** How it was moving, in blocks per tick, world frame. Zero for something that was not. */
+ public Vec3d getVelocity() {
+ return velocity;
+ }
+
+ /** How well it is resolved, 0..1. */
+ public double getQuality() {
+ return quality;
+ }
+
+ /** Which channel produced this: what was heard, or what was lit. */
+ public SensorMode getMode() {
+ return mode;
+ }
+
+ /** The target's own radiance, W/m² — the passive channel's actual input. */
+ public double getRadianceWattsPerSquareMetre() {
+ return radianceWattsPerSquareMetre;
+ }
+
+ /** How far the contact was from the sensor when it was taken, in blocks. */
+ public double getDistance() {
+ return distance;
+ }
+
+ /**
+ * Whether this contact is resolved well enough to shoot at. A contact that is detected but not
+ * locked is a real and useful state: the battery knows something is there, and cannot hit it.
+ */
+ public boolean isLocked(double qualityFloor) {
+ return quality >= qualityFloor;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunInput.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunInput.java
new file mode 100644
index 000000000..b3520f5dc
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunInput.java
@@ -0,0 +1,26 @@
+package zmaster587.advancedRocketry.api.weapon;
+
+/**
+ * What a gun needs delivered to it in order to fire.
+ *
+ * Declared by the build, so nothing has to guess
+ * A gun states its inputs rather than being asked to justify a failure to fire: a player looking
+ * at a silent weapon can be told "it wants gas and has none" instead of being left to work out which
+ * of six conditions is unmet. An addon's part declares its own input the same way, which is what
+ * lets a supply system serve weapons it was not written knowing about.
+ *
+ * One of these is implemented
+ * {@link #FORGE_ENERGY} is real: guns hold a buffer, draw from any FE source, and the weapons
+ * network distributes it. {@link #GAS} is declared and reserved — the fabric that would carry it
+ * exists for other purposes, and the plasma weapons that would want it are a later wave. A build
+ * that declares it today is not refused and is not charged; the declaration is what makes adding the
+ * supply later a change to one place rather than to every gun.
+ */
+public enum GunInput {
+
+ /** Forge Energy, drawn from the gun's own buffer. Implemented. */
+ FORGE_ENERGY,
+
+ /** A gas feed from the ship's fabric. Declared only — nothing consumes it yet. */
+ GAS
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java
new file mode 100644
index 000000000..fd78e6434
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java
@@ -0,0 +1,329 @@
+package zmaster587.advancedRocketry.api.weapon;
+
+import zmaster587.advancedRocketry.api.damage.ImpactKind;
+
+/**
+ * What a built gun IS, once its parts have been counted: everything the firing code needs and
+ * nothing about which blocks produced it.
+ *
+ * Derived, never authored per block
+ * A gun's numbers come from its build — how long the barrel is, how much feed and cooling it was
+ * given. So this object is produced by walking an assembly, not read off the controller: two guns
+ * with the same parts have the same spec wherever they stand, and adding a barrel section is a
+ * change a player can measure rather than a change to a config file.
+ *
+ * Units are the substrate's units
+ * {@link #getMuzzleSpeed()} is blocks per tick and {@link #getImpactEnergy()} is in the
+ * same unit a shield spends and a damage budget carries, because those are the units the shot layer
+ * speaks. Nothing downstream converts, so nothing downstream can convert wrongly.
+ *
+ * A spec is not permission to fire
+ * It says what a shot would look like; whether one happens is decided by the gun's own energy,
+ * heat and drive state. A spec with a zero fire interval would still not fire a gun that is jammed.
+ */
+public final class GunSpec {
+
+ /** What an assembly with no parts at all is worth: nothing, and it says so rather than firing blanks. */
+ public static final GunSpec EMPTY = new Builder().build();
+
+ private final double muzzleSpeed;
+ private final int impactEnergy;
+ private final int fireIntervalTicks;
+ private final int energyPerShot;
+ private final int heatPerShot;
+ private final int heatCapacity;
+ private final int coolingPerTick;
+ private final double spreadDegrees;
+ private final double traverseDegreesPerTick;
+ private final int lifetimeTicks;
+ private final double projectileRadius;
+ private final double projectileMass;
+ private final ImpactKind kind;
+ private final int beamPowerPerTick;
+ private final int partCount;
+ private final java.util.EnumSet inputs;
+
+ /**
+ * The power a HELD beam puts on target each tick, or {@code 0} for a gun that fires discrete
+ * rounds. It is what makes a gun a beam: there is no separate "weapon type" field, because a type
+ * field and a power field could disagree, and then two places would decide what this gun is.
+ *
+ * Per TICK rather than per shot, and the difference is the family: a discrete gun spends its
+ * energy at intervals and its damage arrives in lumps, while a beam spends every tick it is lit
+ * and its depth grows with how long it is held.
+ */
+ public int getBeamPowerPerTick() {
+ return beamPowerPerTick;
+ }
+
+ /** Does this gun HOLD a beam rather than throw rounds? */
+ public boolean isBeam() {
+ return beamPowerPerTick > 0;
+ }
+
+ private GunSpec(Builder builder) {
+ this.muzzleSpeed = builder.muzzleSpeed;
+ this.impactEnergy = builder.impactEnergy;
+ this.fireIntervalTicks = builder.fireIntervalTicks;
+ this.energyPerShot = builder.energyPerShot;
+ this.heatPerShot = builder.heatPerShot;
+ this.heatCapacity = builder.heatCapacity;
+ this.coolingPerTick = builder.coolingPerTick;
+ this.spreadDegrees = builder.spreadDegrees;
+ this.traverseDegreesPerTick = builder.traverseDegreesPerTick;
+ this.lifetimeTicks = builder.lifetimeTicks;
+ this.projectileRadius = builder.projectileRadius;
+ this.projectileMass = builder.projectileMass;
+ this.kind = builder.kind;
+ this.partCount = builder.partCount;
+ this.inputs = java.util.EnumSet.copyOf(builder.inputs.isEmpty()
+ ? java.util.EnumSet.of(GunInput.FORGE_ENERGY) : builder.inputs);
+ this.beamPowerPerTick = builder.beamPowerPerTick;
+ }
+
+ /**
+ * Whether this assembly can deliver anything at all. Saying so here means every call site asks one
+ * question instead of each inventing its own idea of "complete".
+ *
+ * There are two ways to be a weapon, and the first version of this knew only one. A
+ * build missing the part that makes it a thrower — a barrel — has no muzzle speed and no round
+ * worth firing. A BEAM has neither of those by nature and is a weapon anyway: what it has is power
+ * per tick. Written as "throws a round OR holds a beam" rather than as a list of required fields,
+ * because a list of fields is a definition that quietly excludes the next family: the beam was
+ * built, wired, charged, aimed and reported `operable:false`, and every layer above dutifully
+ * refused to fire it.
+ */
+ public boolean isOperable() {
+ if (partCount <= 0) {
+ return false;
+ }
+ return (muzzleSpeed > 0.0D && impactEnergy > 0) || beamPowerPerTick > 0;
+ }
+
+ /** Blocks per TICK, world frame once the mount has rotated it. */
+ public double getMuzzleSpeed() {
+ return muzzleSpeed;
+ }
+
+ /** What one round is worth on arrival, in shield-energy-equivalent units. */
+ public int getImpactEnergy() {
+ return impactEnergy;
+ }
+
+ /** Ticks between two rounds. Never below one: a gun cannot fire twice in one tick. */
+ public int getFireIntervalTicks() {
+ return fireIntervalTicks;
+ }
+
+ /** Forge Energy burned per round. Paid from the gun's own buffer, network or no network. */
+ public int getEnergyPerShot() {
+ return energyPerShot;
+ }
+
+ public int getHeatPerShot() {
+ return heatPerShot;
+ }
+
+ /** Heat the gun may hold before it must stop firing and let the coolers work. */
+ public int getHeatCapacity() {
+ return heatCapacity;
+ }
+
+ public int getCoolingPerTick() {
+ return coolingPerTick;
+ }
+
+ /** Half-angle of the cone a round may leave in, in degrees. Zero is a perfectly true barrel. */
+ public double getSpreadDegrees() {
+ return spreadDegrees;
+ }
+
+ /** How fast the mount may swing, in degrees per tick. A hard capability, never exceeded. */
+ public double getTraverseDegreesPerTick() {
+ return traverseDegreesPerTick;
+ }
+
+ /** How long a round lives before it expires — this gun's reach, expressed in the shot's own unit. */
+ public int getLifetimeTicks() {
+ return lifetimeTicks;
+ }
+
+ public double getProjectileRadius() {
+ return projectileRadius;
+ }
+
+ public double getProjectileMass() {
+ return projectileMass;
+ }
+
+ public ImpactKind getKind() {
+ return kind;
+ }
+
+ /**
+ * What this build needs delivered to it. Never empty: a gun that declared nothing would be a gun
+ * nothing could be said about, so the floor is Forge Energy — which every build draws anyway.
+ */
+ public java.util.Set getDeclaredInputs() {
+ return java.util.Collections.unmodifiableSet(inputs);
+ }
+
+ /** How many parts were counted. Diagnostics, and the "is this thing built" test's raw material. */
+ public int getPartCount() {
+ return partCount;
+ }
+
+ /**
+ * Accumulates part contributions into a spec.
+ *
+ * Parts add rather than set, which is what keeps the contract open: a part shipped by
+ * an addon contributes on the same terms as one of ours, and no part has to know what else the
+ * build contains. The one exception is spread, where more barrel makes a gun truer — a part may
+ * subtract there, and the result is floored at zero rather than allowed to go negative and
+ * become an aim bonus nobody declared.
+ */
+ public static final class Builder {
+
+ private double muzzleSpeed;
+ private int impactEnergy;
+ private int fireIntervalTicks = 20;
+ private int energyPerShot;
+ private int heatPerShot;
+ private int heatCapacity = 100;
+ private int coolingPerTick = 1;
+ private double spreadDegrees = 6.0D;
+ private double traverseDegreesPerTick = 2.0D;
+ private int lifetimeTicks = 200;
+ private double projectileRadius = 0.25D;
+ private double projectileMass = 1.0D;
+ private ImpactKind kind = ImpactKind.KINETIC;
+ private int beamPowerPerTick;
+ private int partCount;
+ private final java.util.EnumSet inputs = java.util.EnumSet.noneOf(GunInput.class);
+ private double contributionScale = 1.0D;
+
+ /**
+ * How much of the NEXT part's contribution counts, 0..1 — a part in poor condition gives
+ * less of whatever it gives.
+ *
+ * Applied to what a part ADDS, never to what it declares or sets: a battered barrel
+ * still fires the same kind of round, it just does not add the same speed to it. It scales
+ * a negative contribution too, and that is the point — a barrel exists to tighten spread,
+ * so a ruined one tightens it less rather than tightening it as though nothing happened.
+ */
+ public Builder withContributionScale(double scale) {
+ this.contributionScale = scale < 0.0D ? 0.0D : (scale > 1.0D ? 1.0D : scale);
+ return this;
+ }
+
+ private double scaled(double value) {
+ return value * contributionScale;
+ }
+
+ private int scaled(int value) {
+ return (int) Math.round(value * contributionScale);
+ }
+
+ public Builder addMuzzleSpeed(double blocksPerTick) {
+ this.muzzleSpeed += scaled(Math.max(0.0D, blocksPerTick));
+ return this;
+ }
+
+ public Builder addImpactEnergy(int energy) {
+ this.impactEnergy += scaled(Math.max(0, energy));
+ return this;
+ }
+
+ /** Faster feed = shorter interval. Floored at one tick, which is the physical limit. */
+ public Builder speedUpFireIntervalBy(int ticks) {
+ this.fireIntervalTicks = Math.max(1, this.fireIntervalTicks - scaled(Math.max(0, ticks)));
+ return this;
+ }
+
+ /**
+ * Declare — or add to — the power this gun holds on target each tick as a BEAM.
+ *
+ * An emitter contributes power here the way a barrel contributes muzzle speed, so a bigger
+ * laser is a laser with more emitters rather than a laser with a bigger number written beside
+ * it. Anything above zero makes the gun a beam.
+ */
+ public Builder addBeamPowerPerTick(int power) {
+ this.beamPowerPerTick += scaled(Math.max(0, power));
+ return this;
+ }
+
+ public Builder addEnergyPerShot(int fe) {
+ this.energyPerShot += scaled(Math.max(0, fe));
+ return this;
+ }
+
+ public Builder addHeatPerShot(int heat) {
+ this.heatPerShot += scaled(Math.max(0, heat));
+ return this;
+ }
+
+ public Builder addHeatCapacity(int heat) {
+ this.heatCapacity += scaled(Math.max(0, heat));
+ return this;
+ }
+
+ public Builder addCoolingPerTick(int heat) {
+ this.coolingPerTick += scaled(Math.max(0, heat));
+ return this;
+ }
+
+ /** Negative tightens the cone; the result never goes below a true barrel. */
+ public Builder addSpreadDegrees(double degrees) {
+ this.spreadDegrees = Math.max(0.0D, this.spreadDegrees + scaled(degrees));
+ return this;
+ }
+
+ public Builder addTraverseDegreesPerTick(double degrees) {
+ this.traverseDegreesPerTick = Math.max(0.0D, this.traverseDegreesPerTick + scaled(degrees));
+ return this;
+ }
+
+ public Builder addLifetimeTicks(int ticks) {
+ this.lifetimeTicks = Math.max(1, this.lifetimeTicks + scaled(ticks));
+ return this;
+ }
+
+ public Builder setProjectileBody(double radius, double mass) {
+ this.projectileRadius = Math.max(0.0D, radius);
+ this.projectileMass = Math.max(0.0D, mass);
+ return this;
+ }
+
+ /**
+ * The last part to state a kind decides it. A build mixing a kinetic feed and a plasma one
+ * is a build whose last-placed part wins, which is a rule a player can see the result of.
+ */
+ public Builder setKind(ImpactKind kind) {
+ if (kind != null) {
+ this.kind = kind;
+ }
+ return this;
+ }
+
+ /**
+ * State that this part needs something delivered. Additive like everything else: a build with
+ * one gas-fed component declares gas, whatever the rest of it wants.
+ */
+ public Builder declareInput(GunInput input) {
+ if (input != null) {
+ this.inputs.add(input);
+ }
+ return this;
+ }
+
+ /** Called once per part counted, by the assembly walk rather than by the parts themselves. */
+ public Builder countPart() {
+ this.partCount++;
+ return this;
+ }
+
+ public GunSpec build() {
+ return new GunSpec(this);
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/IGunPart.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/IGunPart.java
new file mode 100644
index 000000000..f82e4fd5e
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/IGunPart.java
@@ -0,0 +1,45 @@
+package zmaster587.advancedRocketry.api.weapon;
+
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.World;
+
+/**
+ * A block that is worth something when it is part of a gun.
+ *
+ * Implemented by the BLOCK, deliberately
+ * Parts have no state of their own — a barrel section is a barrel section wherever it is — so
+ * asking the block rather than a tile entity means a hundred-block battery costs a hundred block
+ * lookups and no tile entities at all. It also means an addon adds a gun part by implementing one
+ * interface on a plain block, with nothing to register.
+ *
+ * What a part may say
+ * Only what it contributes. A part is never asked whether the gun is complete, never told what
+ * else is in the build, and never given the chance to veto: completeness is
+ * {@link GunSpec#isOperable()}, decided once, on the sum. That is what keeps a part addable without
+ * the parts already there having to agree.
+ */
+public interface IGunPart {
+
+ /**
+ * Add this part's contribution to the gun being assembled.
+ *
+ * @param builder the accumulating spec; a part adds, and never resets what others contributed
+ * @param world the world the assembly is being walked in
+ * @param pos where this part sits — a part whose contribution depends on its own placement
+ * (a muzzle brake that only counts at the end of a barrel) reads it here
+ * @param state the part's own block state, so a part with variants need not look itself up
+ */
+ void contributeTo(GunSpec.Builder builder, World world, BlockPos pos, IBlockState state);
+
+ /**
+ * Whether the assembly walk may continue THROUGH this part to its neighbours.
+ *
+ * Almost every part conducts: a gun is a connected run of parts and the walk has to reach
+ * the far end of the barrel. A part that answers false is a terminator — an end cap, a breech —
+ * and is counted itself without the walk spilling past it.
+ */
+ default boolean conductsAssembly() {
+ return true;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java
new file mode 100644
index 000000000..285111669
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java
@@ -0,0 +1,93 @@
+package zmaster587.advancedRocketry.api.weapon;
+
+/**
+ * What the traverse drive is doing, including all the ways it can be doing nothing.
+ *
+ * Why a failure has a NAME instead of a boolean
+ * "Broken" is not one behaviour. A drive that seized points somewhere definite forever; a drive
+ * whose brake failed drifts; a drive a player locked is aimed exactly where they left it and is not
+ * a fault at all. The aim path reads the bearing as truth in every one of those cases, so the
+ * difference between them has to survive as far as the code that decides where the round goes —
+ * which it can only do if it is a value rather than an absence.
+ */
+public enum TurretDriveState {
+
+ /** Full commanded rate available. */
+ WORKING(true, 1.0D),
+
+ /** Damaged but still driven — it will get there, slowly. */
+ DERATED(true, 0.35D),
+
+ /**
+ * Seized where it stands. It still AIMS — at whatever bearing it stopped at — and a gun whose
+ * target happens to walk into that bearing will hit it. That is the point of naming this
+ * separately from dead.
+ */
+ JAMMED(false, 0.0D),
+
+ /**
+ * The brake is gone: it holds no bearing and drifts. It cannot be commanded, and where it
+ * points is not a decision anybody made.
+ */
+ FREEWHEELING(false, 0.0D),
+
+ /** Deliberately held by a player or console. Not a fault; the bearing is exactly as left. */
+ LOCKED(false, 0.0D),
+
+ /** No drive at all. The mount does not aim and the gun does not fire. */
+ DEAD(false, 0.0D);
+
+ /**
+ * What condition alone does to a drive: a ladder, deterministic, in this order.
+ *
+ * Not a scalar, because a scalar can only ever produce {@link #DERATED} and would silently
+ * delete {@link #JAMMED} — a mount that has seized and still fires down the bearing it stopped
+ * at, which is a whole class of desperate defence. Not a roll on the hit either: a random
+ * failure has nothing behind it a player can see, and nothing to repair but luck. This way the
+ * next rung is visible in advance in the block's own damage, and walking back down it is what
+ * repairing the block means.
+ *
+ * Destruction is absent on purpose — a destroyed controller is not a gun in a bad state, it
+ * is not a gun.
+ *
+ * @param damageFraction how far gone the mount's own block is, 0..1
+ * @param derateAt the fraction at which it starts turning slowly
+ * @param jamAt the fraction at which it stops turning at all
+ */
+ public static TurretDriveState fromDamage(double damageFraction, double derateAt, double jamAt) {
+ if (damageFraction >= Math.max(derateAt, jamAt)) {
+ return JAMMED;
+ }
+ if (damageFraction >= Math.min(derateAt, jamAt)) {
+ return DERATED;
+ }
+ return WORKING;
+ }
+
+ private final boolean drivable;
+ private final double rateFactor;
+
+ TurretDriveState(boolean drivable, double rateFactor) {
+ this.drivable = drivable;
+ this.rateFactor = rateFactor;
+ }
+
+ /** Whether a commanded bearing moves the mount at all. */
+ public boolean isDrivable() {
+ return drivable;
+ }
+
+ /** Fraction of the declared traverse rate this state actually delivers. */
+ public double getRateFactor() {
+ return rateFactor;
+ }
+
+ /**
+ * Whether the gun may fire in this state. A jammed mount still fires — down its stuck bearing —
+ * because a gun that cannot turn is not a gun that cannot shoot, and pretending otherwise would
+ * quietly delete a whole class of desperate defence.
+ */
+ public boolean permitsFiring() {
+ return this != DEAD;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java
new file mode 100644
index 000000000..b7e0f0b68
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java
@@ -0,0 +1,117 @@
+package zmaster587.advancedRocketry.block;
+
+import net.minecraft.block.material.Material;
+import net.minecraft.init.Blocks;
+import zmaster587.advancedRocketry.damage.StructureDamageEngine;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.api.damage.Contact;
+import zmaster587.advancedRocketry.api.damage.ContactResult;
+import zmaster587.advancedRocketry.api.damage.ImpactKind;
+import zmaster587.advancedRocketry.projectile.ContactResolver;
+
+/**
+ * Mirror plating — glass with a thin film of metal behind it, and what that means when shot at.
+ *
+ * It is a coating, not a wall
+ * A mirror is a surface you apply to a hull, so it clings to whichever face it was put on — see
+ * {@link BlockPlating}. A ship is mostly SIDES, and armour you can only lay on a deck is armour for
+ * one sixth of a ship.
+ *
+ * It reflects a FRACTION, and dies by the rest
+ * No mirror returns everything. What it does not return is absorbed by the film behind the glass,
+ * and a film is thin: past some amount of absorbed energy the metal melts and the plate stops being an
+ * optic at all. The law is the physics, with nothing invented:
+ *
+ * reflected = R x E -> sent back out along the mirrored direction
+ * absorbed = (1 - R) x E -> stays in the film
+ * absorbed > dissipation -> the plating is GONE, in one hit, and reflects nothing again
+ *
+ * Two things follow without another rule being written. A better mirror survives more hits, because
+ * it absorbs less of each. And there is no such thing as a half-working mirror: an optic either is one
+ * or is not, which is why this does not degrade through stages the way a hull plate does.
+ *
+ * It does nothing whatever about a slug
+ * Glass and foil. A solid body goes through it and is not even slowed; the kind carried by the
+ * contact is the only thing separating that case from the one above, which is exactly what the contact
+ * seam exists to make expressible.
+ */
+public class BlockMirrorPlating extends BlockPlating {
+
+ private final double reflectance;
+ private final int dissipation;
+
+ /**
+ * @param reflectance how much of an arriving beam it sends back, in {@code (0,1)} — the tier
+ * @param dissipation how much absorbed energy the film sheds before it melts
+ */
+ public BlockMirrorPlating(double reflectance, int dissipation) {
+ super(Material.IRON);
+ this.reflectance = Math.max(0.0D, Math.min(0.999D, reflectance));
+ this.dissipation = Math.max(1, dissipation);
+ setHardness(1.5F);
+ }
+
+ /** How much of a beam this tier returns. The one statement of it; nothing copies the number. */
+ public double getReflectance() {
+ return reflectance;
+ }
+
+ /** How much absorbed energy the film sheds before it stops being a mirror. */
+ public int getDissipation() {
+ return dissipation;
+ }
+
+ @Override
+ public ContactResult onContact(World world, Contact contact) {
+ if (contact == null) {
+ return null;
+ }
+ if (!isRadiant(contact.getKind())) {
+ // A mirror is glass and foil, and a solid round does not care that it is shiny — but it
+ // does have to get through it. Declining hands the meeting to the default law, which
+ // prices the film off the table and the eighth of a voxel it fills and breaks it like any
+ // other pane. Answering "passed through" here instead would let a round cross for nothing
+ // and leave the plating standing, which made it armour that only its own counter could
+ // remove.
+ return ContactResult.noOpinion();
+ }
+
+ int absorbed = (int) Math.ceil(contact.getEnergy() * (1.0D - reflectance));
+ if (absorbed > dissipation) {
+ // The film melted. What is left of the beam goes on into whatever was behind the glass,
+ // less what the plating managed to shed on its way out of existence.
+ burnOut(world, contact.getPos());
+ int residual = contact.getEnergy() - dissipation;
+ return residual > 0 ? ContactResult.passedThrough(residual) : ContactResult.stopped();
+ }
+
+ // Restitution 1: an optic returns what it reflects, and the fraction it does not return is
+ // already accounted for above. A mirror is not a wall that a body bounces off inelastically.
+ Vec3d away = ContactResolver.mirroredWorldVelocity(world, contact, 1.0D);
+ if (away == null) {
+ // Nobody can say which way "out" points — the ship stopped answering between the crossing
+ // and here. Absorbing is the recoverable mistake; inventing a direction is not.
+ return ContactResult.stopped();
+ }
+ return ContactResult.deflected(away, (int) Math.round(contact.getEnergy() * reflectance));
+ }
+
+ private static boolean isRadiant(ImpactKind kind) {
+ return kind == ImpactKind.BEAM || kind == ImpactKind.THERMAL;
+ }
+
+ /**
+ * Where a plate that has burnt out goes. Only itself: a mirror losing its film is not an explosion,
+ * and the hull it was protecting is still standing.
+ */
+ private void burnOut(World world, BlockPos pos) {
+ if (world != null && !world.isRemote && pos != null) {
+ // Same road as every other block weapon fire takes: ask, and honour a refusal. A film
+ // burning out is still a destruction, and a protected one stays.
+ StructureDamageEngine.removeIfAllowed(world, pos);
+ }
+ }
+
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockPlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockPlating.java
new file mode 100644
index 000000000..4ba980394
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/block/BlockPlating.java
@@ -0,0 +1,107 @@
+package zmaster587.advancedRocketry.block;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.material.Material;
+import net.minecraft.block.properties.PropertyDirection;
+import net.minecraft.block.state.BlockStateContainer;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.entity.EntityLivingBase;
+import net.minecraft.util.EnumFacing;
+import net.minecraft.util.math.AxisAlignedBB;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.IBlockAccess;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.api.damage.IContactResponder;
+
+/**
+ * A thin skin that clings to whichever face it was put on — what "armour is a COATING" has to mean in
+ * a world made of cubes.
+ *
+ * Why a facing, and not a slab
+ * A slab is a half block that lives at the top or the bottom of its own, so cladding a hull with
+ * slabs armours its deck and its keel and leaves every side bare. A ship has six sides and is mostly
+ * sides. So plating carries the direction it was applied in, sits against that face, and can be put on
+ * a wall, a ceiling or a floor with the same block and no variants to choose between.
+ *
+ * What the facing does NOT change
+ * Nothing about armour. What decides whether a body meets structure is whether the VOXEL holds a
+ * block, never what shape it holds — {@code StructureDamageEngine.isStructure} reads air and liquid and
+ * nothing else. So a coating on a wall answers a contact exactly as one on a floor does, and layering
+ * is layering because the layers are neighbouring voxels along the body's path, never because two of
+ * them share one. The facing is where it LOOKS and where you can walk; the armour is the voxel.
+ */
+public abstract class BlockPlating extends Block implements IContactResponder {
+
+ /** The direction the plating is applied IN: it lies against the face on that side of its voxel. */
+ public static final PropertyDirection FACING = PropertyDirection.create("facing");
+
+ /** How thick a coating is, as a fraction of the block it clings to. */
+ private static final double THICKNESS = 0.125D;
+
+ private static final AxisAlignedBB DOWN = new AxisAlignedBB(0, 0, 0, 1, THICKNESS, 1);
+ private static final AxisAlignedBB UP = new AxisAlignedBB(0, 1 - THICKNESS, 0, 1, 1, 1);
+ private static final AxisAlignedBB NORTH = new AxisAlignedBB(0, 0, 0, 1, 1, THICKNESS);
+ private static final AxisAlignedBB SOUTH = new AxisAlignedBB(0, 0, 1 - THICKNESS, 1, 1, 1);
+ private static final AxisAlignedBB WEST = new AxisAlignedBB(0, 0, 0, THICKNESS, 1, 1);
+ private static final AxisAlignedBB EAST = new AxisAlignedBB(1 - THICKNESS, 0, 0, 1, 1, 1);
+
+ protected BlockPlating(Material material) {
+ super(material);
+ setDefaultState(blockState.getBaseState().withProperty(FACING, EnumFacing.DOWN));
+ }
+
+ @Override
+ protected BlockStateContainer createBlockState() {
+ return new BlockStateContainer(this, FACING);
+ }
+
+ @Override
+ public IBlockState getStateFromMeta(int meta) {
+ return getDefaultState().withProperty(FACING, EnumFacing.getFront(meta & 7));
+ }
+
+ @Override
+ public int getMetaFromState(IBlockState state) {
+ return state.getValue(FACING).getIndex();
+ }
+
+ /**
+ * Applied to the surface that was clicked. The face handed in is the side of the NEIGHBOUR that
+ * was hit, so the coating lies against the opposite side of its own voxel — which is the side
+ * touching what it is protecting.
+ */
+ @Override
+ public IBlockState getStateForPlacement(World world, BlockPos pos, EnumFacing facing, float hitX,
+ float hitY, float hitZ, int meta, EntityLivingBase placer) {
+ return getDefaultState().withProperty(FACING, facing.getOpposite());
+ }
+
+ @Override
+ public AxisAlignedBB getBoundingBox(IBlockState state, IBlockAccess source, BlockPos pos) {
+ switch (state.getValue(FACING)) {
+ case UP:
+ return UP;
+ case NORTH:
+ return NORTH;
+ case SOUTH:
+ return SOUTH;
+ case WEST:
+ return WEST;
+ case EAST:
+ return EAST;
+ case DOWN:
+ default:
+ return DOWN;
+ }
+ }
+
+ @Override
+ public boolean isOpaqueCube(IBlockState state) {
+ return false;
+ }
+
+ @Override
+ public boolean isFullCube(IBlockState state) {
+ return false;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java
new file mode 100644
index 000000000..d130b2e63
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java
@@ -0,0 +1,86 @@
+package zmaster587.advancedRocketry.block;
+
+import net.minecraft.block.material.Material;
+import net.minecraft.init.Blocks;
+import zmaster587.advancedRocketry.damage.StructureDamageEngine;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.api.damage.Contact;
+import zmaster587.advancedRocketry.api.damage.ContactResult;
+
+/**
+ * Reactive plating — a charge that spends ITSELF to eat part of a hit, and takes nothing else with it.
+ *
+ * Local, and directed away
+ * It is made of much what an explosive is, arranged differently, and it detonates only where it
+ * stands: outward, into the thing that struck it, never into the hull it is bolted to or the plate
+ * beside it. That is the whole reason a charge is a sane thing to clad a ship in — the alternative is
+ * armour that finishes the enemy's work.
+ *
+ * It eats a PORTION, and the portion is its volume
+ * How much of an impact it swallows is declared per block: heavy plating eats more than light, and
+ * two layers eat more than one because the second is asked after the first is gone — layers being
+ * neighbouring VOXELS along the body's path, which is the only kind of layering a voxel world has. So
+ * the ordering the mechanic exists to produce:
+ *
+ * - machine-gun fire, micrometeorites, splinters — swallowed whole, one charge each;
+ * - a railgun round — punches through a single layer as if it were not there, because what it
+ * carries dwarfs what one charge can take. Against that the answer is a shield, and reactive
+ * plating is honest about not being one.
+ *
+ *
+ * It is spent, not damaged
+ * A charge that has gone off is gone. The block removes itself and the next body through that spot
+ * meets whatever was behind it — which is what makes "the second shot is not stopped" a property of
+ * the thing rather than a number somebody has to keep.
+ */
+public class BlockReactivePlating extends BlockPlating {
+
+ private final int capacity;
+
+ /**
+ * @param capacity how much impact energy this much plating swallows before it is spent — the ONE
+ * thing separating a plate from a block, because what a body meets is the voxel
+ * and never the shape inside it
+ */
+ public BlockReactivePlating(int capacity) {
+ super(Material.IRON);
+ this.capacity = Math.max(1, capacity);
+ setHardness(2.0F);
+ }
+
+ /** How much of an impact this much plating can swallow. */
+ public int getCapacity() {
+ return capacity;
+ }
+
+ @Override
+ public ContactResult onContact(World world, Contact contact) {
+ if (contact == null) {
+ return null;
+ }
+ int eaten = Math.min(contact.getEnergy(), capacity);
+ // It goes off whatever happens next: a charge that met something does not un-meet it, and the
+ // difference between stopping a round and merely blunting one is not the charge's to make.
+ detonate(world, contact.getPos());
+
+ int residual = contact.getEnergy() - eaten;
+ return residual > 0 ? ContactResult.passedThrough(residual) : ContactResult.stopped();
+ }
+
+ /**
+ * The charge spends itself and nothing else. No block break, no explosion in the world, no
+ * neighbour touched — the blast is outward, into what struck it, and the world has no way to
+ * represent that other than by this plate ceasing to exist.
+ */
+ private void detonate(World world, BlockPos pos) {
+ if (world != null && !world.isRemote && pos != null) {
+ // Through the weapon-fire removal, not around it: a charge going off is weapon fire
+ // taking a block, and a claim that refuses that keeps its plate — spent, absorbing and
+ // standing. Removing it directly made the new armour the one content in the mod a
+ // protection system could not cover.
+ StructureDamageEngine.removeIfAllowed(world, pos);
+ }
+ }
+
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockGunPart.java b/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockGunPart.java
new file mode 100644
index 000000000..af8412a0b
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockGunPart.java
@@ -0,0 +1,58 @@
+package zmaster587.advancedRocketry.block.weapon;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.material.Material;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.api.weapon.GunSpec;
+import zmaster587.advancedRocketry.api.weapon.IGunPart;
+import zmaster587.advancedRocketry.weapon.GunAssembly;
+
+import java.util.function.Consumer;
+
+/**
+ * A block that is worth something to the gun it is built into.
+ *
+ * One class, many parts
+ * What distinguishes a barrel section from a cooling jacket is entirely what it contributes, so
+ * the contribution is the constructor argument and there is one class rather than one class per
+ * part. An addon that wants a part we did not think of implements {@link IGunPart} on its own block
+ * instead; nothing here is privileged.
+ */
+public class BlockGunPart extends Block implements IGunPart {
+
+ private final Consumer contribution;
+
+ public BlockGunPart(Consumer contribution) {
+ super(Material.IRON);
+ this.contribution = contribution;
+ setHardness(3.0F);
+ setResistance(10.0F);
+ }
+
+ @Override
+ public void contributeTo(GunSpec.Builder builder, World world, BlockPos pos, IBlockState state) {
+ if (contribution != null) {
+ contribution.accept(builder);
+ }
+ }
+
+ /**
+ * A part cannot enter the world without this running, whoever placed it — a player, a filler
+ * command, another mod's builder. That is why the guns around it are told here rather than from a
+ * player-facing place-event: the world is the thing that knows, and it always knows.
+ */
+ @Override
+ public void onBlockAdded(World world, BlockPos pos, IBlockState state) {
+ super.onBlockAdded(world, pos, state);
+ GunAssembly.markControllersDirty(world, pos);
+ }
+
+ /** The same, for a part leaving. By now the block is gone; the walk goes out through what is left. */
+ @Override
+ public void breakBlock(World world, BlockPos pos, IBlockState state) {
+ super.breakBlock(world, pos, state);
+ GunAssembly.markControllersDirty(world, pos);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockTurret.java b/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockTurret.java
new file mode 100644
index 000000000..61706f4c0
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/block/weapon/BlockTurret.java
@@ -0,0 +1,54 @@
+package zmaster587.advancedRocketry.block.weapon;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.material.Material;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.tileentity.TileEntity;
+import net.minecraft.world.IBlockAccess;
+import net.minecraft.world.World;
+import zmaster587.advancedRocketry.tile.weapon.TileTurret;
+
+import javax.annotation.Nullable;
+
+/**
+ * The gun's controller block: the thing a player builds a gun AROUND.
+ *
+ * Deliberately a plain block with a tile entity and no GUI of its own. A turret is commanded by a
+ * linker, by a console over the weapons network, or by nothing at all — a screen on the mount itself
+ * would be a fourth way to say the same thing, and the one that is hardest to reach in a firefight.
+ */
+public class BlockTurret extends Block {
+
+ public BlockTurret() {
+ super(Material.IRON);
+ setHardness(4.0F);
+ setResistance(15.0F);
+ }
+
+ @Override
+ public boolean hasTileEntity(IBlockState state) {
+ return true;
+ }
+
+ @Override
+ @Nullable
+ public TileEntity createTileEntity(World world, IBlockState state) {
+ return new TileTurret();
+ }
+
+ @Override
+ public boolean isOpaqueCube(IBlockState state) {
+ return false;
+ }
+
+ @Override
+ public boolean isFullCube(IBlockState state) {
+ return false;
+ }
+
+ @Override
+ public boolean shouldSideBeRendered(IBlockState state, IBlockAccess world, net.minecraft.util.math.BlockPos pos,
+ net.minecraft.util.EnumFacing side) {
+ return true;
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java b/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java
new file mode 100644
index 000000000..13ef04213
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java
@@ -0,0 +1,153 @@
+package zmaster587.advancedRocketry.client;
+
+import net.minecraft.util.math.Vec3d;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * The client's own copy of which beams are burning, kept only so they can be drawn.
+ *
+ * It simulates nothing the game reads
+ * Every beam here is a picture of a line the server told this client about. Nothing in the mod
+ * asks this class a question: no damage is resolved from it and no state is derived from it, which
+ * is what makes it safe for it to be a tick or two out of date.
+ *
+ * A beam that stops being mentioned goes out
+ * A held beam ends for reasons a client cannot see — the trigger released, the feed run dry, the
+ * gun destroyed, the chunk unloaded, the player having walked out of range while it burned. Some of
+ * those send an "it went out" packet and some cannot, so the drawing is kept alive by the server
+ * repeating itself: a beam nobody has mentioned for {@link #STALE_TICKS} ticks is dropped. That
+ * makes the worst case a beam drawn for a fraction of a second too long, instead of one burning
+ * across the sky until the player relogs.
+ */
+public final class ClientBeamTracker {
+
+ /**
+ * How long a beam is drawn without being mentioned again. Comfortably more than two heartbeats
+ * of {@code BeamReplication.REFRESH_TICKS}, so a single dropped or delayed packet does not make
+ * a burning beam blink.
+ */
+ private static final int STALE_TICKS = 25;
+
+ /** Keyed by the gun's packed position: a gun holds at most one beam. */
+ private static final Map BEAMS = new ConcurrentHashMap<>();
+
+ private ClientBeamTracker() {
+ }
+
+ /** This gun's beam is burning along this PATH, as of now. */
+ public static void lit(long gun, List path) {
+ if (path == null || path.size() < 2) {
+ return;
+ }
+ ClientBeam beam = BEAMS.get(gun);
+ if (beam == null) {
+ BEAMS.put(gun, new ClientBeam(path));
+ return;
+ }
+ beam.refresh(path);
+ }
+
+ /** This gun's beam has gone out. */
+ public static void extinguished(long gun) {
+ BEAMS.remove(gun);
+ }
+
+ /** Every beam the client currently believes is burning. Read by the renderer, and by nothing else. */
+ public static Collection burning() {
+ return BEAMS.values();
+ }
+
+ /** How many beams the client is drawing. The observable a client test can ask about. */
+ public static int count() {
+ return BEAMS.size();
+ }
+
+ /**
+ * How many of them have a CORNER in them — a beam something turned.
+ *
+ * The second observable, and it exists because the first cannot see the thing that goes
+ * wrong here. A bent beam sent to a client as two ends is still one drawn beam, so a count of
+ * beams is green whether the corner arrived or not; what a player would see is a laser drawn
+ * straight through the mirror that turned it.
+ */
+ public static int bentCount() {
+ int bent = 0;
+ for (ClientBeam beam : BEAMS.values()) {
+ if (beam.isBent()) {
+ bent++;
+ }
+ }
+ return bent;
+ }
+
+ public static void clear() {
+ BEAMS.clear();
+ }
+
+ /**
+ * How long a beam is drawn after the last time it was mentioned.
+ *
+ * Readable because it is half of a two-sided arrangement: the server's heartbeat has to be
+ * quicker than this or a beam that is still burning blinks out and back. A test that pins that
+ * relationship should read both numbers rather than repeat either.
+ */
+ public static int stalenessTicks() {
+ return STALE_TICKS;
+ }
+
+ /** Age every drawing one tick and drop the ones nobody has mentioned lately. */
+ public static void tick() {
+ BEAMS.values().removeIf(ClientBeam::ageAndCheckStale);
+ }
+
+ /**
+ * One drawn beam: the path it occupies, in world coordinates, muzzle first.
+ *
+ * Two points for the ordinary beam, more where something turned it. {@link #getFrom} and
+ * {@link #getTo} are kept because the ends are what most readers want, and because a beam that
+ * has not been bent is exactly its two ends.
+ */
+ public static final class ClientBeam {
+
+ private List path;
+ private int sinceHeard;
+
+ private ClientBeam(List path) {
+ this.path = new ArrayList(path);
+ }
+
+ private void refresh(List newPath) {
+ path = new ArrayList(newPath);
+ sinceHeard = 0;
+ }
+
+ private boolean ageAndCheckStale() {
+ return ++sinceHeard > STALE_TICKS;
+ }
+
+ /** Every point of the line, muzzle first. Never fewer than two. */
+ public List getPath() {
+ return Collections.unmodifiableList(path);
+ }
+
+ /** Whether something turned this beam, which is the only case the path has a corner. */
+ public boolean isBent() {
+ return path.size() > 2;
+ }
+
+ public Vec3d getFrom() {
+ return path.get(0);
+ }
+
+ public Vec3d getTo() {
+ return path.get(path.size() - 1);
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java b/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java
index 273eb8c79..545766eea 100644
--- a/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java
+++ b/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java
@@ -111,6 +111,8 @@ public void registerRenderers() {
ClientRegistry.bindTileEntitySpecialRenderer(TileCuttingMachine.class, new RendererCuttingMachine());
ClientRegistry.bindTileEntitySpecialRenderer(TileCrystallizer.class, new RendererCrystallizer());
ClientRegistry.bindTileEntitySpecialRenderer(TileObservatory.class, new RendererObservatory());
+ ClientRegistry.bindTileEntitySpecialRenderer(zmaster587.advancedRocketry.tile.weapon.TileTurret.class,
+ new zmaster587.advancedRocketry.client.render.RendererTurret());
ClientRegistry.bindTileEntitySpecialRenderer(TileAstrobodyDataProcessor.class, new RenderAstrobodyDataProcessor());
ClientRegistry.bindTileEntitySpecialRenderer(TileLathe.class, new RendererLathe());
ClientRegistry.bindTileEntitySpecialRenderer(TileRollingMachine.class, new RendererRollingMachine());
@@ -410,6 +412,8 @@ public void registerEventHandlers() {
super.registerEventHandlers();
MinecraftForge.EVENT_BUS.register(new RocketEventHandler());
MinecraftForge.EVENT_BUS.register(new DelayedParticleRenderingEventHandler());
+ MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.client.render.RenderShots());
+ MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.client.render.RenderBeams());
MinecraftForge.EVENT_BUS.register(ModuleContainerPan.class);
MinecraftForge.EVENT_BUS.register(new RenderComponents());
diff --git a/src/main/java/zmaster587/advancedRocketry/client/ClientShotTracker.java b/src/main/java/zmaster587/advancedRocketry/client/ClientShotTracker.java
new file mode 100644
index 000000000..a348fca7e
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/client/ClientShotTracker.java
@@ -0,0 +1,158 @@
+package zmaster587.advancedRocketry.client;
+
+import net.minecraft.util.math.Vec3d;
+import zmaster587.advancedRocketry.api.projectile.ShotEndReason;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * The client's own copy of what is in the air, kept only so it can be drawn.
+ *
+ * It simulates nothing the game reads
+ * Every round here is a picture. It is stepped with the same arithmetic the server uses, from the
+ * numbers the server sent, and if the two ever disagree the server is right and this one is simply
+ * wrong for a few frames until an end packet corrects it. Nothing in the mod asks this class a
+ * question — that is what makes it safe for it to be approximate, and it is why the shot layer's
+ * own comment that a client "never steps one" is still true: it steps a drawing, not a shot.
+ *
+ * Held here rather than in the world
+ * A client shot has no block, no entity and no chunk, so there is nowhere in the world for it to
+ * live. It is cleared when the player leaves a world, because a round from the last dimension drawn
+ * over the new one is worse than no round at all.
+ */
+public final class ClientShotTracker {
+
+ /** How long a spent round's flash is kept before it stops being drawn. */
+ private static final int IMPACT_FLASH_TICKS = 10;
+
+ private static final Map SHOTS = new ConcurrentHashMap<>();
+ private static final List IMPACTS = Collections.synchronizedList(new ArrayList());
+
+ private ClientShotTracker() {
+ }
+
+ public static void spawn(long id, Vec3d origin, Vec3d velocity, float radius, int lifetimeTicks,
+ double gravityPerTickSquared) {
+ SHOTS.put(id, new ClientShot(origin, velocity, radius, lifetimeTicks, gravityPerTickSquared));
+ }
+
+ /** A round the server says is over: stop drawing the flight, start drawing the flash. */
+ public static void end(long id, Vec3d point, ShotEndReason reason) {
+ SHOTS.remove(id);
+ IMPACTS.add(new Impact(point, reason));
+ }
+
+ /** Everything the client currently believes is up. Read by the renderer, and by nothing else. */
+ public static Collection inFlight() {
+ return SHOTS.values();
+ }
+
+ public static List impacts() {
+ return IMPACTS;
+ }
+
+ /** How many rounds the client is drawing. The observable a client test can ask about. */
+ public static int count() {
+ return SHOTS.size();
+ }
+
+ public static void clear() {
+ SHOTS.clear();
+ IMPACTS.clear();
+ }
+
+ /**
+ * Advance every drawing one tick. Called from the client tick; a round that outlives what it was
+ * told is dropped, because a server that never sent an end packet is a server whose end packet
+ * did not reach this player.
+ */
+ public static void tick() {
+ SHOTS.values().removeIf(ClientShot::stepAndCheckExpired);
+ synchronized (IMPACTS) {
+ IMPACTS.removeIf(Impact::ageAndCheckDone);
+ }
+ }
+
+ /** One drawn round. Position and velocity in world coordinates, exactly as the server's are. */
+ public static final class ClientShot {
+
+ private Vec3d position;
+ private Vec3d velocity;
+ private Vec3d previous;
+ private final float radius;
+ private final int lifetimeTicks;
+ private final double gravity;
+ private int age;
+
+ private ClientShot(Vec3d origin, Vec3d velocity, float radius, int lifetimeTicks, double gravity) {
+ this.position = origin;
+ this.previous = origin;
+ this.velocity = velocity;
+ this.radius = radius;
+ this.lifetimeTicks = lifetimeTicks;
+ this.gravity = gravity;
+ }
+
+ private boolean stepAndCheckExpired() {
+ age++;
+ if (gravity > 0.0D) {
+ velocity = velocity.addVector(0.0D, -gravity, 0.0D);
+ }
+ previous = position;
+ position = position.add(velocity);
+ return age > lifetimeTicks;
+ }
+
+ public Vec3d getPosition() {
+ return position;
+ }
+
+ /** Where it was last tick — the tail end of the streak a fast round is drawn as. */
+ public Vec3d getPrevious() {
+ return previous;
+ }
+
+ public Vec3d getVelocity() {
+ return velocity;
+ }
+
+ public float getRadius() {
+ return radius;
+ }
+ }
+
+ /** A spent round's flash, and what spent it. */
+ public static final class Impact {
+
+ private final Vec3d point;
+ private final ShotEndReason reason;
+ private int age;
+
+ private Impact(Vec3d point, ShotEndReason reason) {
+ this.point = point;
+ this.reason = reason;
+ }
+
+ private boolean ageAndCheckDone() {
+ return ++age > IMPACT_FLASH_TICKS;
+ }
+
+ public Vec3d getPoint() {
+ return point;
+ }
+
+ public ShotEndReason getReason() {
+ return reason;
+ }
+
+ /** 1 at the moment of impact, falling to 0 as the flash fades. */
+ public float getIntensity() {
+ return Math.max(0.0F, 1.0F - (float) age / IMPACT_FLASH_TICKS);
+ }
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java
new file mode 100644
index 000000000..d56aaf654
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java
@@ -0,0 +1,176 @@
+package zmaster587.advancedRocketry.client.render;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.BufferBuilder;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.Tessellator;
+import net.minecraft.client.renderer.vertex.DefaultVertexFormats;
+import net.minecraft.entity.Entity;
+import net.minecraft.util.math.Vec3d;
+import net.minecraftforge.client.event.RenderWorldLastEvent;
+import net.minecraftforge.event.world.WorldEvent;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+import org.lwjgl.opengl.GL11;
+import zmaster587.advancedRocketry.client.ClientBeamTracker;
+
+/**
+ * Draws the beams the client has been told are burning.
+ *
+ * A ribbon that faces you, not a line
+ * A beam is the one weapon a player is supposed to watch rather than glimpse, so it is drawn with
+ * width: two billboarded ribbons about the same axis — a wide dim halo and a narrow white-hot core —
+ * plus a spot where it lands. A one-pixel {@code GL_LINES} streak is right for a tracer, which is
+ * gone in a tick; held for seconds it reads as a scratch on the screen rather than as power going
+ * somewhere.
+ *
+ * The ribbon is turned to face the camera each frame: the beam has an axis but no natural "up",
+ * so its width is taken across the axis and the view direction, which is what keeps it from
+ * vanishing when looked at edge-on.
+ */
+@SideOnly(Side.CLIENT)
+public class RenderBeams {
+
+ /** Half-width of the white-hot core, in blocks. */
+ private static final double CORE_HALF_WIDTH = 0.045D;
+
+ /** Half-width of the surrounding glow. */
+ private static final double HALO_HALF_WIDTH = 0.16D;
+
+ /** Half-size of the spot drawn where the beam lands. */
+ private static final double SPOT_HALF_SIZE = 0.45D;
+
+ @SubscribeEvent
+ public void onClientTick(TickEvent.ClientTickEvent event) {
+ if (event.phase != TickEvent.Phase.END) {
+ return;
+ }
+ Minecraft mc = Minecraft.getMinecraft();
+ if (mc.world == null || mc.isGamePaused()) {
+ return;
+ }
+ ClientBeamTracker.tick();
+ }
+
+ /** Leaving a world drops every drawing: a beam from the last dimension has no business here. */
+ @SubscribeEvent
+ public void onWorldUnload(WorldEvent.Unload event) {
+ if (event.getWorld() != null && event.getWorld().isRemote) {
+ ClientBeamTracker.clear();
+ }
+ }
+
+ @SubscribeEvent
+ public void onRenderWorldLast(RenderWorldLastEvent event) {
+ if (ClientBeamTracker.count() == 0) {
+ return;
+ }
+ Minecraft mc = Minecraft.getMinecraft();
+ Entity view = mc.getRenderViewEntity();
+ if (view == null) {
+ return;
+ }
+ float partial = event.getPartialTicks();
+ double eyeX = view.lastTickPosX + (view.posX - view.lastTickPosX) * partial;
+ double eyeY = view.lastTickPosY + (view.posY - view.lastTickPosY) * partial;
+ double eyeZ = view.lastTickPosZ + (view.posZ - view.lastTickPosZ) * partial;
+ Vec3d eye = new Vec3d(eyeX, eyeY, eyeZ);
+
+ GlStateManager.pushMatrix();
+ GlStateManager.disableTexture2D();
+ GlStateManager.disableLighting();
+ GlStateManager.disableCull();
+ GlStateManager.enableBlend();
+ GlStateManager.tryBlendFuncSeparate(GlStateManager.SourceFactor.SRC_ALPHA,
+ GlStateManager.DestFactor.ONE, GlStateManager.SourceFactor.ONE,
+ GlStateManager.DestFactor.ZERO);
+ GlStateManager.depthMask(false);
+
+ Tessellator tessellator = Tessellator.getInstance();
+ BufferBuilder buffer = tessellator.getBuffer();
+ buffer.begin(GL11.GL_QUADS, DefaultVertexFormats.POSITION_COLOR);
+
+ for (ClientBeamTracker.ClientBeam beam : ClientBeamTracker.burning()) {
+ // A beam is a PATH: one leg for the ordinary one, more where a mirror turned it. Each
+ // leg is a ribbon of its own because each faces the camera differently, and only the
+ // LAST one ends in a spot — the corners are places the beam went on from, not places it
+ // landed, and a glow at a corner would read as a hit that never happened.
+ java.util.List path = beam.getPath();
+ for (int leg = 0; leg + 1 < path.size(); leg++) {
+ Vec3d from = path.get(leg);
+ Vec3d to = path.get(leg + 1);
+ if (from == null || to == null) {
+ continue;
+ }
+ Vec3d axis = to.subtract(from);
+ if (axis.lengthVector() < 1.0E-6D) {
+ continue;
+ }
+ axis = axis.normalize();
+ Vec3d across = across(axis, from, to, eye);
+ if (across == null) {
+ continue;
+ }
+ ribbon(buffer, from, to, across.scale(HALO_HALF_WIDTH), eye,
+ 1.0F, 0.32F, 0.16F, 0.35F);
+ ribbon(buffer, from, to, across.scale(CORE_HALF_WIDTH), eye,
+ 1.0F, 0.93F, 0.85F, 1.0F);
+ if (leg + 2 == path.size()) {
+ spot(buffer, to, axis, eye);
+ }
+ }
+ }
+
+ tessellator.draw();
+
+ GlStateManager.depthMask(true);
+ GlStateManager.disableBlend();
+ GlStateManager.enableCull();
+ GlStateManager.enableLighting();
+ GlStateManager.enableTexture2D();
+ GlStateManager.popMatrix();
+ }
+
+ /**
+ * The direction the ribbon's width runs in: across both the beam and the line of sight to it, so
+ * the flat side always points at the camera. Null when the beam is aimed straight at the eye —
+ * there is no "across" then, and a beam pointed at your face is a dot rather than a ribbon.
+ */
+ private static Vec3d across(Vec3d axis, Vec3d from, Vec3d to, Vec3d eye) {
+ Vec3d midpoint = from.add(to).scale(0.5D);
+ Vec3d toEye = eye.subtract(midpoint);
+ if (toEye.lengthVector() < 1.0E-6D) {
+ return null;
+ }
+ Vec3d cross = axis.crossProduct(toEye.normalize());
+ return cross.lengthVector() < 1.0E-6D ? null : cross.normalize();
+ }
+
+ private static void ribbon(BufferBuilder buffer, Vec3d from, Vec3d to, Vec3d halfWidth, Vec3d eye,
+ float r, float g, float b, float alpha) {
+ vertex(buffer, from.subtract(halfWidth), eye, r, g, b, alpha);
+ vertex(buffer, to.subtract(halfWidth), eye, r, g, b, alpha);
+ vertex(buffer, to.add(halfWidth), eye, r, g, b, alpha);
+ vertex(buffer, from.add(halfWidth), eye, r, g, b, alpha);
+ }
+
+ /** The glow where the beam lands, drawn square to the beam so it reads as a burning spot. */
+ private static void spot(BufferBuilder buffer, Vec3d at, Vec3d axis, Vec3d eye) {
+ Vec3d any = Math.abs(axis.y) > 0.9D ? new Vec3d(1.0D, 0.0D, 0.0D) : new Vec3d(0.0D, 1.0D, 0.0D);
+ Vec3d u = axis.crossProduct(any).normalize().scale(SPOT_HALF_SIZE);
+ Vec3d v = axis.crossProduct(u).normalize().scale(SPOT_HALF_SIZE);
+ // Lifted off the surface it is burning into, or it fights the block face for the same pixels.
+ Vec3d centre = at.subtract(axis.scale(0.02D));
+ vertex(buffer, centre.subtract(u).subtract(v), eye, 1.0F, 0.75F, 0.35F, 0.55F);
+ vertex(buffer, centre.add(u).subtract(v), eye, 1.0F, 0.75F, 0.35F, 0.55F);
+ vertex(buffer, centre.add(u).add(v), eye, 1.0F, 0.75F, 0.35F, 0.55F);
+ vertex(buffer, centre.subtract(u).add(v), eye, 1.0F, 0.75F, 0.35F, 0.55F);
+ }
+
+ private static void vertex(BufferBuilder buffer, Vec3d point, Vec3d eye,
+ float r, float g, float b, float alpha) {
+ buffer.pos(point.x - eye.x, point.y - eye.y, point.z - eye.z).color(r, g, b, alpha).endVertex();
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/RenderShots.java b/src/main/java/zmaster587/advancedRocketry/client/render/RenderShots.java
new file mode 100644
index 000000000..1d5734fc0
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/RenderShots.java
@@ -0,0 +1,116 @@
+package zmaster587.advancedRocketry.client.render;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.BufferBuilder;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.Tessellator;
+import net.minecraft.client.renderer.vertex.DefaultVertexFormats;
+import net.minecraft.entity.Entity;
+import net.minecraft.util.math.Vec3d;
+import net.minecraftforge.client.event.RenderWorldLastEvent;
+import net.minecraftforge.event.world.WorldEvent;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+import org.lwjgl.opengl.GL11;
+import zmaster587.advancedRocketry.client.ClientShotTracker;
+
+/**
+ * Draws what the client has been told is in the air: a streak per round, a flash where one stopped.
+ *
+ * A streak, not a dot
+ * A round crossing sixty blocks in a tick is never in the same place two frames running, so a
+ * point drawn at its position is a point nobody sees. What is drawn instead is the segment between
+ * where it was and where it is, interpolated by the frame's partial tick — the same thing a tracer
+ * is in life, and for the same reason.
+ */
+@SideOnly(Side.CLIENT)
+public class RenderShots {
+
+ /** How far behind the round the streak trails, as a fraction of one tick's travel. */
+ private static final double STREAK_TICKS = 1.0D;
+
+ @SubscribeEvent
+ public void onClientTick(TickEvent.ClientTickEvent event) {
+ if (event.phase != TickEvent.Phase.END) {
+ return;
+ }
+ Minecraft mc = Minecraft.getMinecraft();
+ if (mc.world == null || mc.isGamePaused()) {
+ return;
+ }
+ ClientShotTracker.tick();
+ }
+
+ /** Leaving a world drops every drawing: a round from the last dimension has no business here. */
+ @SubscribeEvent
+ public void onWorldUnload(WorldEvent.Unload event) {
+ if (event.getWorld() != null && event.getWorld().isRemote) {
+ ClientShotTracker.clear();
+ }
+ }
+
+ @SubscribeEvent
+ public void onRenderWorldLast(RenderWorldLastEvent event) {
+ if (ClientShotTracker.count() == 0 && ClientShotTracker.impacts().isEmpty()) {
+ return;
+ }
+ Minecraft mc = Minecraft.getMinecraft();
+ Entity view = mc.getRenderViewEntity();
+ if (view == null) {
+ return;
+ }
+ float partial = event.getPartialTicks();
+ double eyeX = view.lastTickPosX + (view.posX - view.lastTickPosX) * partial;
+ double eyeY = view.lastTickPosY + (view.posY - view.lastTickPosY) * partial;
+ double eyeZ = view.lastTickPosZ + (view.posZ - view.lastTickPosZ) * partial;
+
+ GlStateManager.pushMatrix();
+ GlStateManager.disableTexture2D();
+ GlStateManager.disableLighting();
+ GlStateManager.enableBlend();
+ GlStateManager.tryBlendFuncSeparate(GlStateManager.SourceFactor.SRC_ALPHA,
+ GlStateManager.DestFactor.ONE, GlStateManager.SourceFactor.ONE,
+ GlStateManager.DestFactor.ZERO);
+ GlStateManager.depthMask(false);
+ GlStateManager.glLineWidth(2.0F);
+
+ Tessellator tessellator = Tessellator.getInstance();
+ BufferBuilder buffer = tessellator.getBuffer();
+ buffer.begin(GL11.GL_LINES, DefaultVertexFormats.POSITION_COLOR);
+
+ for (ClientShotTracker.ClientShot shot : ClientShotTracker.inFlight()) {
+ Vec3d previous = shot.getPrevious();
+ Vec3d current = shot.getPosition();
+ Vec3d head = previous.add(current.subtract(previous).scale(partial));
+ Vec3d tail = head.subtract(shot.getVelocity().scale(STREAK_TICKS));
+ buffer.pos(head.x - eyeX, head.y - eyeY, head.z - eyeZ).color(1.0F, 0.85F, 0.45F, 1.0F).endVertex();
+ buffer.pos(tail.x - eyeX, tail.y - eyeY, tail.z - eyeZ).color(1.0F, 0.35F, 0.1F, 0.0F).endVertex();
+ }
+
+ for (ClientShotTracker.Impact impact : ClientShotTracker.impacts()) {
+ Vec3d point = impact.getPoint();
+ float intensity = impact.getIntensity();
+ double size = 0.6D * intensity;
+ for (int axis = 0; axis < 3; axis++) {
+ double dx = axis == 0 ? size : 0.0D;
+ double dy = axis == 1 ? size : 0.0D;
+ double dz = axis == 2 ? size : 0.0D;
+ buffer.pos(point.x - dx - eyeX, point.y - dy - eyeY, point.z - dz - eyeZ)
+ .color(1.0F, 0.9F, 0.6F, intensity).endVertex();
+ buffer.pos(point.x + dx - eyeX, point.y + dy - eyeY, point.z + dz - eyeZ)
+ .color(1.0F, 0.9F, 0.6F, intensity).endVertex();
+ }
+ }
+
+ tessellator.draw();
+
+ GlStateManager.glLineWidth(1.0F);
+ GlStateManager.depthMask(true);
+ GlStateManager.disableBlend();
+ GlStateManager.enableLighting();
+ GlStateManager.enableTexture2D();
+ GlStateManager.popMatrix();
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/RendererTurret.java b/src/main/java/zmaster587/advancedRocketry/client/render/RendererTurret.java
new file mode 100644
index 000000000..28414a63f
--- /dev/null
+++ b/src/main/java/zmaster587/advancedRocketry/client/render/RendererTurret.java
@@ -0,0 +1,72 @@
+package zmaster587.advancedRocketry.client.render;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.tileentity.TileEntitySpecialRenderer;
+import net.minecraft.util.math.BlockPos;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks;
+import zmaster587.advancedRocketry.tile.weapon.TileTurret;
+
+/**
+ * Draws the barrel a turret cannot have as blocks.
+ *
+ * Why the barrel is drawn rather than built
+ * A block occupies a grid cell at one of a handful of fixed orientations, so a gun made of blocks
+ * cannot point at anything that is not on those axes. The mount's bearing is therefore a pair of
+ * angles the game keeps, and this renderer is the only thing that turns them into something a player
+ * can see. The parts a player builds are the gun's EQUIPMENT — feed, cooling, barrel sections that
+ * lengthen it — and the barrel drawn here is as long as the build earned.
+ *
+ * It draws the client's own mechanism
+ * The client runs the same traverse the server does, from the command it was sent, so the barrel
+ * swings at the declared rate instead of teleporting between synced poses. When the two disagree the
+ * next command corrects the client silently — nothing here is authoritative for anything.
+ */
+@SideOnly(Side.CLIENT)
+public class RendererTurret extends TileEntitySpecialRenderer {
+
+ /** Barrel thickness, as a fraction of a block. */
+ private static final float BORE = 0.42F;
+
+ @Override
+ public void render(TileTurret turret, double x, double y, double z, float partialTicks,
+ int destroyStage, float alpha) {
+ if (turret == null || !turret.getSpec().isOperable() && turret.getBarrelLength() <= 1) {
+ return;
+ }
+
+ GlStateManager.pushMatrix();
+ GlStateManager.translate(x + 0.5D, y + 0.5D, z + 0.5D);
+ // Minecraft's yaw is clockwise from south, which is the opposite sense to a GL rotation about
+ // +Y; the negation is that difference and nothing more.
+ GlStateManager.rotate((float) -turret.getMechanism().getYaw(), 0.0F, 1.0F, 0.0F);
+ GlStateManager.rotate((float) turret.getMechanism().getPitch(), 1.0F, 0.0F, 0.0F);
+
+ int length = turret.getBarrelLength();
+ BlockPos pos = turret.getPos();
+ int brightness = turret.getWorld() == null ? 0xF000F0
+ : turret.getWorld().getCombinedLight(pos.up(), 0);
+ GlStateManager.scale(BORE, BORE, 1.0F);
+ for (int segment = 0; segment < length; segment++) {
+ GlStateManager.pushMatrix();
+ GlStateManager.translate(-0.5D, -0.5D, segment + 0.5D);
+ Minecraft.getMinecraft().getBlockRendererDispatcher().renderBlockBrightness(
+ AdvancedRocketryBlocks.blockGunBarrel.getDefaultState(),
+ brightnessToFloat(brightness));
+ GlStateManager.popMatrix();
+ }
+ GlStateManager.popMatrix();
+ }
+
+ /**
+ * A block's own model is drawn at a brightness, not at a light level. Feeding the packed value
+ * straight in would light every barrel as if it were in the sun.
+ */
+ private static float brightnessToFloat(int combinedLight) {
+ int block = (combinedLight >> 4) & 0xF;
+ int sky = (combinedLight >> 20) & 0xF;
+ return Math.max(0.25F, Math.max(block, sky) / 15.0F);
+ }
+}
diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
index 42a218f96..8f2fb10a1 100644
--- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
+++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java
@@ -238,6 +238,21 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args
case "shield":
handleShield(server, sender, tail(args));
break;
+ case "damage":
+ handleDamage(server, sender, tail(args));
+ break;
+ case "shot":
+ handleShot(server, sender, tail(args));
+ break;
+ case "turret":
+ handleTurret(server, sender, tail(args));
+ break;
+ case "weaponconsole":
+ handleWeaponConsole(server, sender, tail(args));
+ break;
+ case "sensor":
+ handleFireControlSensor(server, sender, tail(args));
+ break;
case "sound":
handleSound(server, sender, tail(args));
break;
@@ -249,6 +264,551 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args
}
}
+ // Projectile substrate probes -----------------------------------------
+
+ /**
+ * {@code /artest shot ...} — fire and observe shots as the substrate holds them.
+ *
+ * - {@code fire [lifetime] [kind]} — admit one shot
+ * through production's own entry point and report the id it was given ({@code -1} = the
+ * launch was refused, which is a real answer and not an error);
+ * - {@code list } — every shot in flight in that world;
+ * - {@code read } — one shot, or {@code present:false} once it has ended;
+ * - {@code clear } — drop everything in flight there (scenario isolation);
+ * - {@code trace [id] [limit]} — what each step DECIDED: the shield distance and the
+ * structure distance as the step saw them, so "the wall was never found" and "the impact
+ * was refused" stop looking alike. Earliest entries first, and {@code matched} says how
+ * many there were;
+ * - {@code traceclear } — a clean instrument for one scenario; the ring is shared and
+ * outlives any single test;
+ * - {@code crossing } — the control query: does production
+ * itself say this segment is blocked, and what does each voxel along it look like. Asks
+ * {@code StructureCrossing} rather than re-deriving an answer beside it.
+ *
+ *
+ * There is deliberately no "step one shot" verb: shots advance through the world tick, so a
+ * test drives them with {@code /artest shield tick }, which posts the real end-phase event.
+ * A probe that stepped a shot privately would prove the integrator works when called by the
+ * probe.
+ */
+ private void handleShot(MinecraftServer server, ICommandSender sender, String[] args) {
+ if (args.length == 0) {
+ send(sender, "{\"error\":\"usage: /artest shot fire|list|read|clear|trace|traceclear|crossing ...\"}");
+ return;
+ }
+ String sub = args[0].toLowerCase(java.util.Locale.ROOT);
+ int dim = args.length >= 2 ? parseIntOr(args[1], Integer.MIN_VALUE) : Integer.MIN_VALUE;
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ zmaster587.advancedRocketry.projectile.ShotRegistry registry =
+ zmaster587.advancedRocketry.projectile.ShotRegistry.get(world);
+
+ if ("fire".equals(sub) && args.length >= 9) {
+ net.minecraft.util.math.Vec3d origin = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[2], 0), parseDoubleOr(args[3], 0), parseDoubleOr(args[4], 0));
+ net.minecraft.util.math.Vec3d velocity = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[5], 0), parseDoubleOr(args[6], 0), parseDoubleOr(args[7], 0));
+ int energy = parseIntOr(args[8], 0);
+ zmaster587.advancedRocketry.api.projectile.ShotSpec spec =
+ zmaster587.advancedRocketry.api.projectile.ShotSpec.kinetic(origin, velocity, energy);
+ if (args.length >= 10) {
+ spec = spec.withLifetime(parseIntOr(args[9], 1200));
+ }
+ if (args.length >= 11) {
+ spec = spec.withKind(zmaster587.advancedRocketry.api.damage.ImpactKind
+ .valueOf(args[10].toUpperCase(java.util.Locale.ROOT)));
+ }
+ // The BODY, optionally: radius then mass. A round's cross-section is what its energy is
+ // spread over, so a test that wants to compare calibres has to be able to state one — the
+ // default is the reference body every other verb fires.
+ if (args.length >= 12) {
+ spec = spec.withBody(parseDoubleOr(args[11], 0.25D),
+ args.length >= 13 ? parseDoubleOr(args[12], 1.0D) : 1.0D);
+ }
+ long id = zmaster587.advancedRocketry.projectile.ShotSubstrate.launch(world, spec);
+ send(sender, "{\"ok\":true,\"id\":" + id + ",\"count\":" + registry.count() + "}");
+ return;
+ }
+ if ("list".equals(sub)) {
+ StringBuilder sb = new StringBuilder("{\"ok\":true,\"count\":")
+ .append(registry.count()).append(",\"shots\":[");
+ boolean first = true;
+ for (zmaster587.advancedRocketry.projectile.Shot shot : registry.inFlight()) {
+ if (!first) {
+ sb.append(',');
+ }
+ first = false;
+ sb.append(shotJson(world, shot));
+ }
+ send(sender, sb.append("]}").toString());
+ return;
+ }
+ if ("read".equals(sub) && args.length >= 3) {
+ zmaster587.advancedRocketry.projectile.Shot shot =
+ registry.get(Long.parseLong(args[2]));
+ if (shot == null) {
+ zmaster587.advancedRocketry.projectile.ShotRegistry.Ending ended =
+ registry.endingOf(Long.parseLong(args[2]));
+ send(sender, "{\"ok\":true,\"present\":false,\"ended\":\""
+ + (ended == null ? "" : ended.getReason().name()) + "\""
+ + (ended == null ? "" : ",\"endX\":" + ended.getPoint().x
+ + ",\"endY\":" + ended.getPoint().y
+ + ",\"endZ\":" + ended.getPoint().z)
+ + ",\"count\":" + registry.count() + "}");
+ return;
+ }
+ send(sender, "{\"ok\":true,\"present\":true,\"shot\":" + shotJson(world, shot) + "}");
+ return;
+ }
+ if ("clear".equals(sub)) {
+ int before = registry.count();
+ registry.clear();
+ send(sender, "{\"ok\":true,\"cleared\":" + before + "}");
+ return;
+ }
+ if ("trace".equals(sub)) {
+ long only = args.length >= 3 ? parseLongOr(args[2], -1L) : -1L;
+ int limit = args.length >= 4 ? parseIntOr(args[3], 24) : 24;
+ send(sender, "{\"ok\":true,\"trace\":"
+ + zmaster587.advancedRocketry.projectile.ShotCrossingTrace.summaryJson(only, limit)
+ + "}");
+ return;
+ }
+ if ("traceclear".equals(sub)) {
+ zmaster587.advancedRocketry.projectile.ShotCrossingTrace.reset();
+ send(sender, "{\"ok\":true,\"cleared\":true}");
+ return;
+ }
+ if ("crossing".equals(sub) && args.length >= 8) {
+ net.minecraft.util.math.Vec3d from = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[2], 0), parseDoubleOr(args[3], 0), parseDoubleOr(args[4], 0));
+ net.minecraft.util.math.Vec3d to = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[5], 0), parseDoubleOr(args[6], 0), parseDoubleOr(args[7], 0));
+ // Production's own answer, not a re-derivation: a probe that re-implemented "is there
+ // structure here" would be a second opinion, and the whole question is whose is wrong.
+ boolean blocked = zmaster587.advancedRocketry.projectile.StructureCrossing
+ .isBlocked(world, from, to);
+ final StringBuilder voxels = new StringBuilder();
+ final int[] listed = new int[1];
+ final net.minecraft.world.WorldServer scanned = world;
+ zmaster587.advancedRocketry.util.SweptVolume.traverse(from, to, 0.0D, 4096,
+ new zmaster587.advancedRocketry.util.SweptVolume.LayerVisitor() {
+ @Override
+ public boolean visit(zmaster587.advancedRocketry.util.SweptVolume.Layer layer) {
+ net.minecraft.util.math.BlockPos pos = layer.axis;
+ boolean loaded = scanned.isBlockLoaded(pos);
+ net.minecraft.block.state.IBlockState state = loaded
+ ? scanned.getBlockState(pos) : null;
+ boolean structure = loaded && zmaster587.advancedRocketry.damage
+ .StructureDamageEngine.isStructure(scanned, pos, state);
+ if (listed[0] < 32) {
+ if (listed[0] > 0) {
+ voxels.append(',');
+ }
+ voxels.append("{\"x\":").append(pos.getX()).append(",\"y\":").append(pos.getY())
+ .append(",\"z\":").append(pos.getZ())
+ .append(",\"loaded\":").append(loaded)
+ .append(",\"block\":\"")
+ .append(state == null ? "" : state.getBlock().getRegistryName())
+ .append("\",\"structure\":").append(structure).append('}');
+ }
+ listed[0]++;
+ return structure; // stop where production would have stopped
+ }
+ });
+ send(sender, "{\"ok\":true,\"blocked\":" + blocked + ",\"walked\":" + listed[0]
+ + ",\"voxels\":[" + voxels + "]}");
+ return;
+ }
+ send(sender, "{\"error\":\"unknown shot subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}");
+ }
+
+ /**
+ * {@code /artest turret ...} — build a gun, point it, and read what it thinks it is.
+ *
+ * - {@code read } — the derived spec, the mount's bearing and drive state,
+ * heat, buffered energy and how many rounds this gun has fired;
+ * - {@code target } — give the gun ITSELF a target, the way a
+ * linker does. Deliberately not routed through a network: the no-network path is the one
+ * that has to work;
+ * - {@code cleartarget };
+ * - {@code charge } — fill the buffer, for a scenario that is about firing
+ * rather than about wiring;
+ * - {@code drive } — set the traverse drive to one of the failure
+ * states, so a killed drive can be observed without breaking blocks.
+ *
+ */
+ private void handleTurret(MinecraftServer server, ICommandSender sender, String[] args) {
+ if (args.length < 5) {
+ send(sender, "{\"error\":\"usage: /artest turret read|target|cleartarget|charge|drive ...\"}");
+ return;
+ }
+ String sub = args[0].toLowerCase(java.util.Locale.ROOT);
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ net.minecraft.util.math.BlockPos pos = new net.minecraft.util.math.BlockPos(
+ parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ net.minecraft.tileentity.TileEntity tile = world.getTileEntity(pos);
+ if (!(tile instanceof zmaster587.advancedRocketry.tile.weapon.TileTurret)) {
+ send(sender, "{\"error\":\"no turret there\",\"x\":" + pos.getX() + ",\"y\":" + pos.getY()
+ + ",\"z\":" + pos.getZ() + "}");
+ return;
+ }
+ zmaster587.advancedRocketry.tile.weapon.TileTurret turret =
+ (zmaster587.advancedRocketry.tile.weapon.TileTurret) tile;
+
+ if ("target".equals(sub) && args.length >= 8) {
+ turret.setTarget(new net.minecraft.util.math.Vec3d(parseDoubleOr(args[5], 0),
+ parseDoubleOr(args[6], 0), parseDoubleOr(args[7], 0)));
+ send(sender, "{\"ok\":true}");
+ return;
+ }
+ if ("cleartarget".equals(sub)) {
+ turret.setTarget(null);
+ turret.setTargetEntity(null);
+ send(sender, "{\"ok\":true}");
+ return;
+ }
+ if ("charge".equals(sub)) {
+ turret.chargeFully();
+ send(sender, "{\"ok\":true,\"energy\":" + turret.getEnergyStored() + "}");
+ return;
+ }
+ if ("drive".equals(sub) && args.length >= 6) {
+ turret.setDriveState(zmaster587.advancedRocketry.api.weapon.TurretDriveState
+ .valueOf(args[5].toUpperCase(java.util.Locale.ROOT)));
+ send(sender, "{\"ok\":true,\"drive\":\"" + turret.getMechanism().getDriveState().name() + "\"}");
+ return;
+ }
+ if ("target-player".equals(sub) && args.length >= 6) {
+ net.minecraft.entity.player.EntityPlayerMP player =
+ server.getPlayerList().getPlayerByUsername(args[5]);
+ if (player == null) {
+ send(sender, jsonError("no such player"));
+ return;
+ }
+ turret.setTargetEntity(player.getUniqueID());
+ send(sender, "{\"ok\":true}");
+ return;
+ }
+ if ("code".equals(sub)) {
+ turret.setAccessCode(args.length >= 6 ? args[5] : "");
+ send(sender, "{\"ok\":true,\"code\":\"" + escapeJson(turret.getEffectiveAccessCode()) + "\"}");
+ return;
+ }
+ if ("manual".equals(sub) && args.length >= 6) {
+ turret.setManualControl(Boolean.parseBoolean(args[5]));
+ send(sender, "{\"ok\":true,\"manual\":" + turret.isManuallyControlled() + "}");
+ return;
+ }
+ if ("bearing".equals(sub) && args.length >= 7) {
+ turret.commandManualBearing(parseDoubleOr(args[5], 0), parseDoubleOr(args[6], 0));
+ send(sender, "{\"ok\":true}");
+ return;
+ }
+ if ("fire".equals(sub)) {
+ send(sender, "{\"ok\":true,\"fired\":" + turret.fireOnce() + "}");
+ return;
+ }
+ if ("read".equals(sub)) {
+ zmaster587.advancedRocketry.api.weapon.GunSpec spec = turret.getSpec();
+ zmaster587.advancedRocketry.weapon.TurretMechanism mount = turret.getMechanism();
+ net.minecraft.util.math.Vec3d target = turret.getEffectiveTarget();
+ send(sender, "{\"ok\":true"
+ + ",\"operable\":" + spec.isOperable()
+ + ",\"parts\":" + spec.getPartCount()
+ + ",\"muzzleSpeed\":" + spec.getMuzzleSpeed()
+ + ",\"impactEnergy\":" + spec.getImpactEnergy()
+ + ",\"fireInterval\":" + spec.getFireIntervalTicks()
+ + ",\"energyPerShot\":" + spec.getEnergyPerShot()
+ + ",\"spread\":" + spec.getSpreadDegrees()
+ + ",\"traverseRate\":" + spec.getTraverseDegreesPerTick()
+ + ",\"heat\":" + turret.getHeat()
+ + ",\"heatCapacity\":" + spec.getHeatCapacity()
+ + ",\"energy\":" + turret.getEnergyStored()
+ // The beam half: lit is "burning right now", recharging is "dark because it
+ // is saving up", and the two are different answers a fire control needs to
+ // tell apart. beamPower is 0 for a gun that throws rounds.
+ + ",\"beamPower\":" + spec.getBeamPowerPerTick()
+ + ",\"weaponsDisabled\":" + turret.isDisabledByConfig()
+ + ",\"beamLit\":" + turret.isBeamLit()
+ + ",\"beamRecharging\":" + turret.isBeamRecharging()
+ + ",\"yaw\":" + mount.getYaw()
+ + ",\"pitch\":" + mount.getPitch()
+ + ",\"saturated\":" + mount.isSaturated()
+ + ",\"onTarget\":" + mount.isOnTarget()
+ + ",\"drive\":\"" + mount.getDriveState().name() + "\""
+ + ",\"shots\":" + turret.getShotsFired()
+ + ",\"lastShot\":" + turret.getLastShotId()
+ + ",\"trackingEntity\":" + (turret.getTargetEntity() != null)
+ + ",\"acquired\":" + (turret.acquiredTrack() != null)
+ + (turret.acquiredTrack() == null ? ""
+ : ",\"acquiredQuality\":" + turret.acquiredTrack().getQuality())
+ + ",\"code\":\"" + escapeJson(turret.getEffectiveAccessCode()) + "\""
+ + ",\"manual\":" + turret.isManuallyControlled()
+ + ",\"inputs\":\"" + escapeJson(spec.getDeclaredInputs().toString()) + "\""
+ + ",\"hasTarget\":" + (target != null)
+ + (target == null ? "" : ",\"targetX\":" + target.x + ",\"targetY\":" + target.y
+ + ",\"targetZ\":" + target.z)
+ + "}");
+ return;
+ }
+ send(sender, "{\"error\":\"unknown turret subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}");
+ }
+
+ /**
+ * {@code /artest weaponconsole ...} — drive the one thing the weapons network adds.
+ *
+ * - {@code read } — the network as this console sees it: status, how many
+ * guns it is commanding, the shared target, hold-fire;
+ * - {@code target } — point every gun on the network;
+ * - {@code cleartarget };
+ * - {@code holdfire } — track without shooting.
+ *
+ * Each command answers {@code applied:false} when the console is on no network — which is a real
+ * answer (a console alone commands nothing), not an error.
+ */
+ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, String[] args) {
+ if (args.length < 5) {
+ send(sender, "{\"error\":\"usage: /artest weaponconsole read|target|cleartarget|holdfire ...\"}");
+ return;
+ }
+ String sub = args[0].toLowerCase(java.util.Locale.ROOT);
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ net.minecraft.util.math.BlockPos pos = new net.minecraft.util.math.BlockPos(
+ parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ net.minecraft.tileentity.TileEntity tile = world.getTileEntity(pos);
+ if (!(tile instanceof zmaster587.advancedRocketry.tile.weapon.TileWeaponConsole)) {
+ send(sender, "{\"error\":\"no weapon console there\",\"x\":" + pos.getX() + ",\"y\":"
+ + pos.getY() + ",\"z\":" + pos.getZ() + "}");
+ return;
+ }
+ zmaster587.advancedRocketry.tile.weapon.TileWeaponConsole console =
+ (zmaster587.advancedRocketry.tile.weapon.TileWeaponConsole) tile;
+
+ if ("target".equals(sub) && args.length >= 8) {
+ boolean applied = console.assignTarget(new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[5], 0), parseDoubleOr(args[6], 0), parseDoubleOr(args[7], 0)));
+ send(sender, "{\"ok\":true,\"applied\":" + applied + "}");
+ return;
+ }
+ if ("cleartarget".equals(sub)) {
+ send(sender, "{\"ok\":true,\"applied\":" + console.clearTarget() + "}");
+ return;
+ }
+ if ("holdfire".equals(sub) && args.length >= 6) {
+ boolean applied = console.setHoldFire(Boolean.parseBoolean(args[5]));
+ send(sender, "{\"ok\":true,\"applied\":" + applied + ",\"holdFire\":"
+ + console.isHoldFire() + "}");
+ return;
+ }
+ if ("target-player".equals(sub) && args.length >= 6) {
+ net.minecraft.entity.player.EntityPlayerMP player =
+ server.getPlayerList().getPlayerByUsername(args[5]);
+ if (player == null) {
+ send(sender, jsonError("no such player"));
+ return;
+ }
+ send(sender, "{\"ok\":true,\"applied\":"
+ + console.assignTargetEntity(player.getUniqueID()) + "}");
+ return;
+ }
+ if ("code".equals(sub)) {
+ boolean applied = console.setAccessCode(args.length >= 6 ? args[5] : "");
+ send(sender, "{\"ok\":true,\"applied\":" + applied + ",\"code\":\""
+ + escapeJson(console.getAccessCode()) + "\"}");
+ return;
+ }
+ if ("read".equals(sub)) {
+ net.minecraft.util.math.Vec3d target = console.getTarget();
+ send(sender, "{\"ok\":true"
+ + ",\"network\":" + (console.network() != null)
+ + ",\"status\":\"" + escapeJson(console.getNetworkStatusToken()) + "\""
+ + ",\"guns\":" + console.getGunCount()
+ + ",\"onTarget\":" + console.getMountTelemetry()[0]
+ + ",\"saturated\":" + console.getMountTelemetry()[1]
+ + ",\"holdFire\":" + console.isHoldFire()
+ + ",\"code\":\"" + escapeJson(console.getAccessCode()) + "\""
+ + ",\"sensorContact\":" + (console.getAcquiredTrack() != null)
+ + (console.getAcquiredTrack() == null ? ""
+ : ",\"sensorQuality\":" + console.getAcquiredTrack().getQuality())
+ + ",\"trackingEntity\":" + (console.getTargetEntity() != null)
+ + ",\"hasTarget\":" + (target != null)
+ + (target == null ? "" : ",\"targetX\":" + target.x + ",\"targetY\":" + target.y
+ + ",\"targetZ\":" + target.z)
+ + "}");
+ return;
+ }
+ send(sender, "{\"error\":\"unknown weaponconsole subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}");
+ }
+
+ /**
+ * {@code /artest sensor ...} — what the battery's eyes can see, and which way they are working.
+ *
+ * - {@code read } — the mode it is in, what it is actually managing (an
+ * unpowered illuminator falls back to listening), how many contacts it holds and how well
+ * it holds the best one;
+ * - {@code mode };
+ * - {@code code [code]} — the credential that keeps a friend out of the
+ * contact list entirely;
+ * - {@code charge } — fill the buffer, for scenarios that are not about
+ * wiring;
+ * - {@code sees } — whether ONE named player is a contact, which
+ * is what a friend-or-foe test needs: a count cannot distinguish "this player was
+ * excluded" from "something else was found instead".
+ *
+ * {@code locked} is the field a test about the passive/active trade watches: a contact can be
+ * present and not lockable, which is the whole of what illuminating buys.
+ */
+ private void handleFireControlSensor(MinecraftServer server, ICommandSender sender, String[] args) {
+ if (args.length < 5) {
+ send(sender, "{\"error\":\"usage: /artest sensor read|mode|code|charge ...\"}");
+ return;
+ }
+ String sub = args[0].toLowerCase(java.util.Locale.ROOT);
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ net.minecraft.util.math.BlockPos pos = new net.minecraft.util.math.BlockPos(
+ parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ net.minecraft.tileentity.TileEntity tile = world.getTileEntity(pos);
+ if (!(tile instanceof zmaster587.advancedRocketry.tile.sensor.TileFireControlSensor)) {
+ send(sender, "{\"error\":\"no fire control sensor there\",\"x\":" + pos.getX() + ",\"y\":"
+ + pos.getY() + ",\"z\":" + pos.getZ() + "}");
+ return;
+ }
+ zmaster587.advancedRocketry.tile.sensor.TileFireControlSensor sensor =
+ (zmaster587.advancedRocketry.tile.sensor.TileFireControlSensor) tile;
+
+ if ("mode".equals(sub) && args.length >= 6) {
+ zmaster587.advancedRocketry.api.sensor.SensorMode wanted;
+ try {
+ wanted = zmaster587.advancedRocketry.api.sensor.SensorMode
+ .valueOf(args[5].toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ send(sender, jsonError("unknown mode: " + args[5]));
+ return;
+ }
+ sensor.setMode(wanted);
+ send(sender, "{\"ok\":true,\"mode\":\"" + sensor.getMode().name() + "\"}");
+ return;
+ }
+ if ("code".equals(sub)) {
+ sensor.setAccessCode(args.length >= 6 ? args[5] : "");
+ send(sender, "{\"ok\":true,\"code\":\"" + escapeJson(sensor.effectiveAccessCode()) + "\"}");
+ return;
+ }
+ if ("charge".equals(sub)) {
+ sensor.chargeFully();
+ send(sender, "{\"ok\":true,\"energy\":" + sensor.getEnergyStored() + "}");
+ return;
+ }
+ if ("sees".equals(sub) && args.length >= 6) {
+ // Whether ONE named player is in the contact list. Asked by name rather than counted,
+ // because "the list is empty" is a different claim: a cave full of mobs a hundred blocks
+ // away would make it false without saying anything about the player this is asking about.
+ net.minecraft.entity.player.EntityPlayerMP player =
+ server.getPlayerList().getPlayerByUsername(args[5]);
+ if (player == null) {
+ send(sender, jsonError("no such player"));
+ return;
+ }
+ boolean seen = false;
+ double quality = 0.0D;
+ for (zmaster587.advancedRocketry.api.sensor.TargetTrack track : sensor.getContacts()) {
+ if (player.getUniqueID().equals(track.getEntity())) {
+ seen = true;
+ quality = track.getQuality();
+ break;
+ }
+ }
+ send(sender, "{\"ok\":true,\"seen\":" + seen + ",\"quality\":" + quality
+ + ",\"contacts\":" + sensor.getContacts().size() + "}");
+ return;
+ }
+ if ("read".equals(sub)) {
+ zmaster587.advancedRocketry.api.sensor.TargetTrack best = sensor.getBestContact();
+ double floor = zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig()
+ .fireControlSensorLockQualityToFire;
+ send(sender, "{\"ok\":true"
+ + ",\"enabled\":" + zmaster587.advancedRocketry.api.ARConfiguration
+ .getCurrentConfig().enableFireControlSensor
+ + ",\"mode\":\"" + sensor.getMode().name() + "\""
+ + ",\"effectiveMode\":\"" + sensor.effectiveMode().name() + "\""
+ + ",\"emitting\":" + sensor.isEmitting()
+ + ",\"underpowered\":" + sensor.isUnderpowered()
+ + ",\"energy\":" + sensor.getEnergyStored()
+ + ",\"network\":" + (sensor.networkState() != null)
+ + ",\"code\":\"" + escapeJson(sensor.effectiveAccessCode()) + "\""
+ + ",\"contacts\":" + sensor.getContacts().size()
+ + ",\"hasContact\":" + (best != null)
+ + (best == null ? "" : ",\"quality\":" + best.getQuality()
+ + ",\"locked\":" + best.isLocked(floor)
+ + ",\"distance\":" + best.getDistance()
+ + ",\"radiance\":" + best.getRadianceWattsPerSquareMetre()
+ + ",\"contactX\":" + best.getPosition().x
+ + ",\"contactY\":" + best.getPosition().y
+ + ",\"contactZ\":" + best.getPosition().z
+ + ",\"speed\":" + best.getVelocity().lengthVector())
+ + "}");
+ return;
+ }
+ send(sender, "{\"error\":\"unknown sensor subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}");
+ }
+
+ /** One-line error payload, so a new verb does not hand-build JSON and get a quote wrong. */
+ private static String jsonError(String message) {
+ return "{\"error\":\"" + escapeJson(message) + "\"}";
+ }
+
+ /**
+ * One shot, in WORLD terms whatever frame it is being kept in — plus, when it is drilling a hull,
+ * the hull's id and the shot's place in that hull's own frame. Both are reported because they
+ * answer different questions: "where is the round" is a world question, and "did it stay put in
+ * the plate while the ship manoeuvred" can only be asked in the plate's frame.
+ */
+ private static String shotJson(net.minecraft.world.World world,
+ zmaster587.advancedRocketry.projectile.Shot shot) {
+ net.minecraft.util.math.Vec3d pos =
+ zmaster587.advancedRocketry.projectile.ShotFrame.worldPosition(world, shot);
+ net.minecraft.util.math.Vec3d vel =
+ zmaster587.advancedRocketry.projectile.ShotFrame.worldVelocity(world, shot);
+ String hull = shot.getHullId();
+ String inHull = hull == null ? ",\"hull\":null"
+ : ",\"hull\":\"" + escapeJson(hull) + "\""
+ + ",\"hullX\":" + shot.getPosition().x
+ + ",\"hullY\":" + shot.getPosition().y
+ + ",\"hullZ\":" + shot.getPosition().z;
+ return "{\"id\":" + shot.getId()
+ + ",\"x\":" + pos.x
+ + ",\"y\":" + pos.y
+ + ",\"z\":" + pos.z
+ + ",\"vx\":" + vel.x
+ + ",\"vy\":" + vel.y
+ + ",\"vz\":" + vel.z
+ + ",\"speed\":" + vel.lengthVector()
+ + ",\"energy\":" + shot.getImpactEnergy()
+ + ",\"age\":" + shot.getAge()
+ + ",\"lifetime\":" + shot.getLifetimeTicks()
+ + ",\"kind\":\"" + shot.getKind().name() + "\""
+ + inHull + "}";
+ }
+
// Vendored AFFS shield probes -----------------------------------------
/**
@@ -309,8 +869,14 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("powered", emitter.isFieldPowered());
info.put("shieldStored", emitter.getEnergyStored());
info.put("shieldMax", emitter.getMaxEnergyStored());
+ // The field it PROJECTS beside the field it was DECLARED to hold, and the bill. A
+ // damaged emitter separates the three: the first shrinks, the second does not, and the
+ // third is charged against the second — so a test can read "damage never pays" off one
+ // response instead of inferring it.
info.put("radius", emitter.getRadius());
- info.put("requested", emitter.getRequestedShieldEnergy());
+ info.put("declaredRadius", emitter.getDeclaredRadius());
+ info.put("cycleCost", emitter.getShieldCycleCost());
+ info.put("requested", emitter.getRequested());
// P2 (D134-3/4): the emitter's tier, its tier-scaled recharge throughput (the per-zone
// regen cap), the passive-maintenance draw this tick, and how much it actually received
// this tick — so a test can assert the throughput cap and the tier scaling.
@@ -327,7 +893,7 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("worldZ", wc.z);
info.put("shipFramed", emitter.isShipFramed());
info.put("frameReady", emitter.isFrameReady());
- info.put("priority", emitter.getShieldPriority());
+ info.put("priority", emitter.getPriority());
// P4 (D134-5/6): the emitter's domain, the priority group that lists it (if any), and its
// carried access credential — so a test can assert group push-down and code rotation.
String domainId = com.github.stannismod.affs.world.shield.ShieldDomains.forBlock(
@@ -346,7 +912,10 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("kind", "generator");
info.put("shieldStored", gen.getShieldStored());
info.put("feStored", gen.getFeStored());
- info.put("available", gen.getAvailableShieldEnergy());
+ info.put("available", gen.getAvailable());
+ // The conversion CAP in this generator's current condition — independent of how full
+ // its FE buffer happens to be, which is what makes it readable as an ordering.
+ info.put("conversionPerTick", gen.getConversionPerTick());
} else if (tile instanceof com.github.stannismod.affs.te.TileEntityShieldCable) {
// P6: a cable's transport cap, so a test can compare the two limiters (transport vs the
// emitter's recharge throughput) without pinning either magnitude.
@@ -360,8 +929,10 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("kind", "accumulator");
info.put("shieldStored", acc.getShieldStored());
info.put("shieldMax", acc.getMaxShieldStored());
- info.put("available", acc.getAvailableShieldEnergy());
- info.put("free", acc.getFreeShieldCapacity());
+ // Rated capacity above, what this bank can still hold in its current condition here.
+ info.put("shieldMaxEffective", acc.getEffectiveMaxShieldStored());
+ info.put("available", acc.getAvailable());
+ info.put("free", acc.getFreeCapacity());
} else {
info.put("error", "not a shield tile");
info.put("tileClass", tile == null ? "null" : tile.getClass().getName());
@@ -411,6 +982,22 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("posY", y);
info.put("posZ", z);
info.put("activeEmitters", emitters.size());
+ // Zone ownership answers WHICH emitter is responsible for a point; coverage answers whether
+ // anything actually reaches it. They come apart exactly when an emitter shrinks — its zone
+ // is still nearest, and the hull inside it is no longer under the shell — which is the
+ // consequence a damaged emitter is supposed to have, so both are reported side by side.
+ // Signed: negative is inside the field, positive is the gap outside it.
+ double hullDistance = com.github.stannismod.affs.world.FieldSurfaceMath
+ .compositeHullDistance(emitters, point);
+ info.put("hullDistance", Double.isInfinite(hullDistance) ? Double.MAX_VALUE : hullDistance);
+ boolean covered = false;
+ for (com.github.stannismod.affs.te.TileEntityFieldGenerator emitter : emitters) {
+ if (emitter.protects(new BlockPos(x, y, z))) {
+ covered = true;
+ break;
+ }
+ }
+ info.put("covered", covered);
if (owner == null) {
info.put("owned", false);
} else {
@@ -515,7 +1102,72 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
if (args.length >= 6) {
emitter.setPriority(parseIntOr(args[5], 0));
}
- send(sender, "{\"ok\":true,\"priority\":" + emitter.getShieldPriority() + "}");
+ send(sender, "{\"ok\":true,\"priority\":" + emitter.getPriority() + "}");
+ return;
+ }
+ if (args.length >= 5 && "console-info".equalsIgnoreCase(args[0])) {
+ // console-info — what a shield CONSOLE is currently displaying, as
+ // opposed to what the network state says. The two can disagree, and that disagreement is
+ // the bug class this verb exists to make visible (ledger #260). Read out of the console's
+ // own writeToNBT, so it reports the same fields production persists rather than a
+ // parallel accessor that could drift from them.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ int x = parseIntOr(args[2], 0);
+ int y = parseIntOr(args[3], 0);
+ int z = parseIntOr(args[4], 0);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ TileEntity tile = world.getTileEntity(new BlockPos(x, y, z));
+ if (!(tile instanceof com.github.stannismod.affs.te.TileEntityShieldConsole)) {
+ send(sender, "{\"error\":\"not a TileEntityShieldConsole\",\"tile\":\""
+ + (tile == null ? "null" : tile.getClass().getName()) + "\"}");
+ return;
+ }
+ net.minecraft.nbt.NBTTagCompound shown =
+ tile.writeToNBT(new net.minecraft.nbt.NBTTagCompound());
+ send(sender, "{\"ok\":true"
+ + ",\"networkConnected\":" + shown.getBoolean("networkConnected")
+ + ",\"networkStatus\":" + shown.getInteger("networkStatus")
+ + ",\"cableCount\":" + shown.getInteger("cableCount")
+ + ",\"sourceAvailable\":" + shown.getInteger("sourceAvailable")
+ + ",\"sinkRequested\":" + shown.getInteger("sinkRequested")
+ + ",\"deliveredFlow\":" + shown.getInteger("deliveredFlow")
+ + ",\"resistanceBias\":" + shown.getDouble("shieldEnergyResistanceBias") + "}");
+ return;
+ }
+ if (args.length >= 6 && "console-bias".equalsIgnoreCase(args[0])) {
+ // console-bias <0..1> — drive the console's own
+ // applyShieldEnergyResistanceBias, the method its GUI slider calls. The setting is
+ // console-OWNED and console-persisted, which is the property a restart test pins.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ int x = parseIntOr(args[2], 0);
+ int y = parseIntOr(args[3], 0);
+ int z = parseIntOr(args[4], 0);
+ double bias;
+ try {
+ bias = Double.parseDouble(args[5]);
+ } catch (NumberFormatException badNumber) {
+ send(sender, "{\"error\":\"bias must be a number\",\"got\":\"" + escapeJson(args[5]) + "\"}");
+ return;
+ }
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ TileEntity tile = world.getTileEntity(new BlockPos(x, y, z));
+ if (!(tile instanceof com.github.stannismod.affs.te.TileEntityShieldConsole)) {
+ send(sender, "{\"error\":\"not a TileEntityShieldConsole\"}");
+ return;
+ }
+ com.github.stannismod.affs.te.TileEntityShieldConsole consoleTile =
+ (com.github.stannismod.affs.te.TileEntityShieldConsole) tile;
+ consoleTile.applyShieldEnergyResistanceBias(bias);
+ send(sender, "{\"ok\":true,\"resistanceBias\":"
+ + consoleTile.getShieldEnergyResistanceBias() + "}");
return;
}
if (args.length >= 6 && "group".equalsIgnoreCase(args[0])) {
@@ -611,11 +1263,14 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
return;
}
if (args.length >= 11 && "strike".equalsIgnoreCase(args[0])) {
- // strike — fire a
- // cooperative D134-2 tier-1 strike (a declared-energy beam) at the field along a ray and
- // report what the ShieldStrikeService absorbed: intercepted / fullyAbsorbed / spent shield
- // energy / residual impact energy that passed / the shell hit point. This is the honest
- // server-tier verification of the strike seam AR turrets will implement.
+ // strike [bvx bvy bvz]
+ // — fire a cooperative D134-2 tier-1 strike at the field along a ray and report what the
+ // ShieldStrikeService absorbed: intercepted / fullyAbsorbed / spent shield energy / residual
+ // impact energy that passed / the shell hit point. This is the honest server-tier
+ // verification of the strike seam AR turrets will implement.
+ // The optional trailing triple DECLARES A TRAVELLING BODY at that world velocity — a shot
+ // that exists as a record rather than as an entity. A fully absorbed KINETIC strike carrying
+ // one is mirrored off the shell instead of stopped, and the reply reports the new velocity.
int dim = parseIntOr(args[1], Integer.MIN_VALUE);
net.minecraft.world.WorldServer world = server.getWorld(dim);
if (world == null) {
@@ -632,12 +1287,19 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
"KINETIC".equalsIgnoreCase(args[10])
? com.github.stannismod.affs.world.shield.ShieldStrikeKind.KINETIC
: com.github.stannismod.affs.world.shield.ShieldStrikeKind.RADIANT;
+ net.minecraft.util.math.Vec3d bodyVelocity = null;
+ if (args.length >= 14) {
+ bodyVelocity = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[11], 0), parseDoubleOr(args[12], 0), parseDoubleOr(args[13], 0));
+ }
com.github.stannismod.affs.world.shield.ShieldStrike strike =
- com.github.stannismod.affs.world.shield.ShieldStrike.beam(origin, dir, maxDist, impactEnergy, kind);
+ new com.github.stannismod.affs.world.shield.ShieldStrike(
+ origin, dir, maxDist, impactEnergy, kind, false, bodyVelocity);
com.github.stannismod.affs.world.shield.ShieldStrikeResult result =
com.github.stannismod.affs.world.shield.ShieldStrikeService.resolve(world, strike);
Map info = new LinkedHashMap<>();
info.put("dim", dim);
+ info.put("declaredBody", strike.hasBody());
info.put("intercepted", result.isIntercepted());
info.put("fullyAbsorbed", result.isFullyAbsorbed());
info.put("absorbed", result.getAbsorbedShieldEnergy());
@@ -649,10 +1311,274 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[
info.put("hitY", hit.y);
info.put("hitZ", hit.z);
}
+ // Emitted in EVERY state (zeros when nothing was reflected) so a consumer parsing them never
+ // meets a dropped key; "reflected" is what says whether the numbers mean anything.
+ net.minecraft.util.math.Vec3d newVel = result.getReflectedVelocity();
+ info.put("reflected", result.isReflected());
+ info.put("newVx", newVel != null ? newVel.x : 0.0D);
+ info.put("newVy", newVel != null ? newVel.y : 0.0D);
+ info.put("newVz", newVel != null ? newVel.z : 0.0D);
+ send(sender, jsonMap(info));
+ return;
+ }
+ send(sender, "{\"error\":\"unknown shield subcommand — try tick | read | explode [strength] | zone | emitters | charge | priority [value] | strike [bvx] [bvy] [bvz] | group [...] | rotate-code \"}");
+ }
+
+ /**
+ * Structural damage probes. Declares a real impact through the production service and reports the
+ * report it hands back, so a test drives the same seam a weapon will.
+ */
+ private void handleDamage(MinecraftServer server, ICommandSender sender, String[] args) {
+ if (args.length == 0) {
+ send(sender, "{\"error\":\"missing damage subcommand\"}");
+ return;
+ }
+ if ("guard".equalsIgnoreCase(args[0])) {
+ // guard — stand in for a claim mod: an ordinary
+ // BreakEvent subscriber that refuses this position, so a test can drive the refusal
+ // path weapon fire takes with every protection system it will ever meet.
+ if (args.length < 6) {
+ send(sender, "{\"error\":\"usage: damage guard \"}");
+ return;
+ }
+ int dim = parseIntOr(args[1], 0);
+ net.minecraft.util.math.BlockPos pos = new net.minecraft.util.math.BlockPos(
+ parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ boolean guarded = Boolean.parseBoolean(args[5]);
+ int now = WeaponFireVetoProbe.guard(dim, pos, guarded);
+ send(sender, "{\"ok\":true,\"guarded\":" + guarded + ",\"count\":" + now + "}");
+ return;
+ }
+ if ("unguard-all".equalsIgnoreCase(args[0])) {
+ WeaponFireVetoProbe.clear();
+ send(sender, "{\"ok\":true,\"cleared\":true}");
+ return;
+ }
+ if ("clear-impacts".equalsIgnoreCase(args[0])) {
+ // The dedup memory outlives a scenario on a shared server; this is its reset.
+ int before = zmaster587.advancedRocketry.damage.ShipDamageService.rememberedImpactCount();
+ zmaster587.advancedRocketry.damage.ShipDamageService.clearRecentImpacts();
+ send(sender, "{\"ok\":true,\"cleared\":" + before + "}");
+ return;
+ }
+ if ("occurrences".equalsIgnoreCase(args[0])) {
+ // occurrences [clear] — what the damage service TOLD the units. The recorder attaches to
+ // every tile on a harness server, so "nothing recorded" means nothing was delivered, not
+ // that nobody was listening.
+ DamageOccurrenceRecorder.ensureRegistered();
+ if (args.length >= 2 && "clear".equalsIgnoreCase(args[1])) {
+ send(sender, "{\"ok\":true,\"cleared\":" + DamageOccurrenceRecorder.clear() + "}");
+ return;
+ }
+ send(sender, DamageOccurrenceRecorder.json());
+ return;
+ }
+ if (args.length >= 3 && "impact-memory".equalsIgnoreCase(args[0])) {
+ // impact-memory — is this identity already spent, and since when. A
+ // refusal reports only that it was seen; WHEN it was seen is what names the other caller.
+ int memDim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer memWorld = server.getWorld(memDim);
+ if (memWorld == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + memDim + "}");
+ return;
+ }
+ long askedId = parseLongOr(args[2], 0L);
+ Long at = zmaster587.advancedRocketry.damage.ShipDamageService.rememberedTickOf(memWorld, askedId);
+ send(sender, "{\"ok\":true,\"impactId\":" + askedId
+ + ",\"remembered\":" + (at != null)
+ + ",\"at\":" + (at == null ? "null" : at.toString())
+ + ",\"now\":" + memWorld.getTotalWorldTime()
+ + ",\"size\":" + zmaster587.advancedRocketry.damage.ShipDamageService
+ .rememberedImpactCount() + "}");
+ return;
+ }
+ if (args.length >= 5 && "stage".equalsIgnoreCase(args[0])) {
+ // stage — the unified stage reader, whichever home owns it.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ BlockPos pos = new BlockPos(parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ Map info = new LinkedHashMap<>();
+ info.put("ok", true);
+ info.put("stage", zmaster587.advancedRocketry.damage.DamageState.getStage(world, pos));
+ info.put("maxStage", zmaster587.advancedRocketry.damage.DamageState.getMaxStage(world, pos));
+ info.put("stageCost", zmaster587.advancedRocketry.damage.StructureDamageEngine.stageCost(world, pos));
+ // What the SAME stage costs a beam: the ablation column, so a test can see which column
+ // it is being charged from rather than inferring it from a depth.
+ info.put("stageCostBeam", zmaster587.advancedRocketry.damage.StructureDamageEngine.stageCost(
+ world, pos, 1.0D, zmaster587.advancedRocketry.api.damage.ImpactKind.BEAM));
+ info.put("block", String.valueOf(world.getBlockState(pos).getBlock().getRegistryName()));
+ String destroyed = zmaster587.advancedRocketry.damage.BlockDamageSavedData.get(world)
+ .getDestroyedBlockName(pos);
+ info.put("wasDestroyed", destroyed != null);
+ info.put("destroyedBlock", destroyed == null ? "" : destroyed);
+ send(sender, jsonMap(info));
+ return;
+ }
+ if (args.length >= 7 && "weld".equalsIgnoreCase(args[0])) {
+ // weld [count] — one use of the repair welder
+ // against the block at (x,y,z), by a player carrying exactly what this call says: the
+ // tool at FE and of . Drives production's own decision
+ // (ItemRepairWelder.weld), so a refusal here is the item's refusal, not the probe's.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ BlockPos pos = new BlockPos(parseIntOr(args[2], 0), parseIntOr(args[3], 0), parseIntOr(args[4], 0));
+ int charge = parseIntOr(args[5], 0);
+ String materialId = args[6];
+ int materialCount = args.length >= 8 ? parseIntOr(args[7], 0) : 0;
+
+ net.minecraft.entity.player.EntityPlayerMP welder = weldingPlayer(server, world, pos);
+ welder.inventory.clear();
+ net.minecraft.item.ItemStack tool = new net.minecraft.item.ItemStack(
+ zmaster587.advancedRocketry.api.AdvancedRocketryItems.itemRepairWelder);
+ zmaster587.advancedRocketry.item.ItemRepairWelder.setStoredEnergy(tool, charge);
+ welder.inventory.addItemStackToInventory(tool);
+ net.minecraft.item.Item material = "none".equalsIgnoreCase(materialId)
+ ? null : net.minecraft.item.Item.getByNameOrId(materialId);
+ if (material != null && materialCount > 0) {
+ welder.inventory.addItemStackToInventory(
+ new net.minecraft.item.ItemStack(material, materialCount));
+ }
+
+ int stageBefore = zmaster587.advancedRocketry.damage.DamageState.getStage(world, pos);
+ int materialBefore = countOf(welder, material);
+ zmaster587.advancedRocketry.item.ItemRepairWelder.Outcome outcome =
+ zmaster587.advancedRocketry.item.ItemRepairWelder.weld(welder, world, pos, tool);
+ Map m = new LinkedHashMap<>();
+ m.put("ok", true);
+ m.put("outcome", outcome.name());
+ m.put("stageBefore", stageBefore);
+ m.put("stageAfter", zmaster587.advancedRocketry.damage.DamageState.getStage(world, pos));
+ m.put("energyBefore", charge);
+ m.put("energyAfter", zmaster587.advancedRocketry.item.ItemRepairWelder.storedEnergy(tool));
+ m.put("materialBefore", materialBefore);
+ m.put("materialAfter", countOf(welder, material));
+ m.put("block", String.valueOf(world.getBlockState(pos).getBlock().getRegistryName()));
+ send(sender, jsonMap(m));
+ return;
+ }
+ if (args.length >= 8 && "records".equalsIgnoreCase(args[0])) {
+ // records — every damage record the world
+ // holds inside the inclusive box. A single position's reading is "stage"; this is what a
+ // whole STRUCTURE carries, which is the only way to ask whether a relocation lost some of
+ // it. Positions come back sorted so two readings of the same structure compare directly,
+ // and "count" is emitted as 0 with an empty list rather than the list being dropped.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + ",\"count\":0,\"entries\":[]}");
+ return;
+ }
+ int minX = parseIntOr(args[2], 0), minY = parseIntOr(args[3], 0), minZ = parseIntOr(args[4], 0);
+ int maxX = parseIntOr(args[5], 0), maxY = parseIntOr(args[6], 0), maxZ = parseIntOr(args[7], 0);
+ zmaster587.advancedRocketry.damage.BlockDamageSavedData data =
+ zmaster587.advancedRocketry.damage.BlockDamageSavedData.get(world);
+ java.util.List found = data.positionsIn(minX, minY, minZ, maxX, maxY, maxZ);
+ found.sort(java.util.Comparator.comparingInt(BlockPos::getX)
+ .thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ));
+ StringBuilder sb = new StringBuilder();
+ sb.append("{\"ok\":true,\"count\":").append(found.size()).append(",\"entries\":[");
+ for (int i = 0; i < found.size(); i++) {
+ BlockPos p = found.get(i);
+ String was = data.getDestroyedBlockName(p);
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"x\":").append(p.getX()).append(",\"y\":").append(p.getY())
+ .append(",\"z\":").append(p.getZ())
+ .append(",\"stage\":").append(data.getStage(p))
+ .append(",\"wasDestroyed\":").append(was != null)
+ .append(",\"destroyedBlock\":\"").append(was == null ? "" : was).append("\"}");
+ }
+ sb.append("]}");
+ send(sender, sb.toString());
+ return;
+ }
+ if (args.length >= 10 && "impact".equalsIgnoreCase(args[0])) {
+ // impact [kind] [impactId] — declare one impact
+ // against whatever structure occupies the point and report what the engine did with it.
+ int dim = parseIntOr(args[1], Integer.MIN_VALUE);
+ net.minecraft.world.WorldServer world = server.getWorld(dim);
+ if (world == null) {
+ send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}");
+ return;
+ }
+ net.minecraft.util.math.Vec3d point = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[2], 0), parseDoubleOr(args[3], 0), parseDoubleOr(args[4], 0));
+ net.minecraft.util.math.Vec3d dir = new net.minecraft.util.math.Vec3d(
+ parseDoubleOr(args[5], 0), parseDoubleOr(args[6], 0), parseDoubleOr(args[7], 0));
+ int budget = parseIntOr(args[8], 0);
+ zmaster587.advancedRocketry.api.damage.ImpactKind kind =
+ zmaster587.advancedRocketry.api.damage.ImpactKind.KINETIC;
+ if (args.length >= 10) {
+ try {
+ kind = zmaster587.advancedRocketry.api.damage.ImpactKind.valueOf(args[9].toUpperCase());
+ } catch (IllegalArgumentException ignored) {
+ // keep KINETIC; the reply echoes what was used so a typo is visible
+ }
+ }
+ // A hand-declared identity is moved into a band production never mints. Production hands
+ // out 1, 2, 3 ... from the world's own counter, and a test that picks "7000" is picking a
+ // number that counter will reach on a long-lived shared server — at which point the test's
+ // impact is refused as a repeat of a shot's, spends nothing, and reads as a gun that did
+ // no damage. The offset is injective over the non-negative values anyone passes, so a
+ // repeated declaration is still a repeat, which is what the dedup tests are about.
+ long declaredId = args.length >= 11 ? (long) parseDoubleOr(args[10], 0)
+ : world.getTotalWorldTime();
+ long impactId = Long.MIN_VALUE + Math.max(0L, declaredId);
+
+ zmaster587.advancedRocketry.api.damage.DamageReport report =
+ zmaster587.advancedRocketry.damage.ShipDamageService.apply(world,
+ zmaster587.advancedRocketry.api.damage.ImpactRequest.penetrating(
+ impactId, point, dir, budget, kind));
+
+ Map info = new LinkedHashMap<>();
+ info.put("ok", true);
+ info.put("kind", kind.name());
+ // Both, because they are two different facts: what the caller asked for, and the identity
+ // the service was actually given. A probe that reported only the first would be hiding the
+ // one a memory query has to be made with.
+ info.put("declaredId", declaredId);
+ info.put("impactId", impactId);
+ // Which target the service resolved, and how many ships even offered themselves. Without
+ // these a miss cannot be told apart from a hit on the wrong thing: the report names no
+ // ship on purpose, so the instrument has to.
+ info.put("candidateShips", zmaster587.advancedRocketry.integration.vs.VSIntegration
+ .shipIdsAt(world, point.x, point.y, point.z).size());
+ String resolved = zmaster587.advancedRocketry.damage.ShipDamageService
+ .resolveTargetShip(world, point, dir);
+ info.put("onShip", resolved != null);
+ info.put("shipId", resolved == null ? "" : resolved);
+ info.put("outcome", report.getOutcome().name());
+ info.put("stopReason", report.getStopReason().name());
+ info.put("spent", report.getBudgetSpent());
+ info.put("left", report.getBudgetLeft());
+ info.put("staged", report.getBlocksStaged());
+ info.put("destroyed", report.getBlocksDestroyed());
+ info.put("depth", report.getPenetrationDepth());
+ // Points are emitted in every state, with a flag saying whether they mean anything, so a
+ // consumer never meets a dropped key on the uninteresting answer.
+ net.minecraft.util.math.Vec3d entry = report.getEntryPoint();
+ net.minecraft.util.math.Vec3d exit = report.getExitPoint();
+ info.put("hasEntry", entry != null);
+ info.put("entryX", entry != null ? entry.x : 0.0D);
+ info.put("entryY", entry != null ? entry.y : 0.0D);
+ info.put("entryZ", entry != null ? entry.z : 0.0D);
+ info.put("hasExit", exit != null);
+ info.put("exitX", exit != null ? exit.x : 0.0D);
+ info.put("exitY", exit != null ? exit.y : 0.0D);
+ info.put("exitZ", exit != null ? exit.z : 0.0D);
send(sender, jsonMap(info));
return;
}
- send(sender, "{\"error\":\"unknown shield subcommand — try tick | read | explode [strength] | zone | emitters | charge | priority [value] | strike | group [...] | rotate-code \"}");
+ send(sender, "{\"error\":\"unknown damage subcommand — try impact [kind] [impactId] | stage