From 638bc7d9cdf3d272e78b11019cf8e392b5b6325e Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 14 Aug 2026 16:41:35 +0300 Subject: [PATCH 01/35] feat: reflect a declared travelling body off the shield - add optional body velocity to ShieldStrike - add reflected outcome carrying the new velocity - mirror using the entity path's own law - add shieldStrikeReflectionRestitution tunable, default 1.0 - key kinetic semantics on a body, not Entity - extend artest shield strike with a body triple - pin reflection, pricing and short-pay penetration --- .../stannismod/affs/config/ModConfig.java | 19 +++ .../affs/te/TileEntityFieldGenerator.java | 25 ++++ .../affs/world/shield/ShieldStrike.java | 36 ++++++ .../affs/world/shield/ShieldStrikeKind.java | 14 ++- .../affs/world/shield/ShieldStrikeResult.java | 38 +++++- .../world/shield/ShieldStrikeService.java | 14 ++- .../command/test/TestProbeCommand.java | 31 +++-- .../server/ShieldStrikeAbsorptionTest.java | 116 +++++++++++++++++- 8 files changed, 276 insertions(+), 17 deletions(-) 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..9ea9939a2 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 @@ -65,10 +65,17 @@ public final class ModConfig { // 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() { } @@ -293,6 +300,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/te/TileEntityFieldGenerator.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java index 810455a8d..076ef894d 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 @@ -208,6 +208,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. */ 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}: + * + * */ 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..d0f3820d8 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).

@@ -69,9 +73,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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index da3905fc0..4fcbff96b 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -611,11 +611,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 +635,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 +659,17 @@ 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 | group [...] | rotate-code \"}"); + send(sender, "{\"error\":\"unknown shield subcommand — try tick | read | explode [strength] | zone | emitters | charge | priority [value] | strike [bvx] [bvy] [bvz] | group [...] | rotate-code \"}"); } // Valkyrien Skies integration probes ---------------------------------- diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldStrikeAbsorptionTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldStrikeAbsorptionTest.java index 779b96d0b..e47548ae0 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldStrikeAbsorptionTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldStrikeAbsorptionTest.java @@ -19,7 +19,12 @@ * nothing spent), the D134-2 "shield is a barrier only while up" rule; *
  • a strike that outmatches the shield is gracefully penetrated: the shield spends * everything it has, the remainder passes downstream, and the shield drops toward zero — the same - * "shields fall" degrade as the kinetic path.
  • + * "shields fall" degrade as the kinetic path; + *
  • a fully absorbed kinetic strike that declares a travelling body is reflected — the + * body leaves along the outward normal and no faster than it arrived — while an otherwise + * identical strike carrying no body is stopped at the shell, and neither costs more than the + * other. A body is a body whether or not it happens to be a Forge entity: a shot that lives as a + * record has to reach the same reflection as a thrown block does.
  • * * *

    The strike is driven with {@code /artest shield strike ...}, which calls the real service on the @@ -108,11 +113,110 @@ public void strikeGracefullyPenetratesAShieldItOutmatches() throws Exception { + " before=" + storedBefore + "):\n" + result, storedAfter < storedBefore / 4L); } + @Test + public void aDeclaredBodyIsReflectedWhereAnIdenticalBodilessStrikeIsStopped() throws Exception { + int gx = 1010, gz = 834; + int ex = gx + 1; + place("affs:shield_generator", gx, gz); + place("affs:field_generator", ex, gz); + for (int i = 0; i < 15; i++) { + chargeIteration(gx, gz); + } + assertTrue("emitter never powered:\n" + read(ex, gz), read(ex, gz).contains("\"powered\":true")); + + // Both strikes are the same ray, the same declared energy and the same KINETIC kind, fired at a + // shield that can afford either. The ONLY difference is that one declares the body it carries — + // a shot travelling inward at 2 b/t — so the two outcomes can differ for exactly one reason. + int impactEnergy = 2000; + double inwardSpeed = 2.0D; + String withBody = strike(ex, gz, impactEnergy, "KINETIC", 0.0D, 0.0D, -inwardSpeed); + assertTrue("a declared body was not reported as declared — the probe never handed one to the " + + "service, so nothing below tests reflection:\n" + withBody, + withBody.contains("\"declaredBody\":true")); + assertTrue("a shield that could pay did not fully absorb the strike:\n" + withBody, + withBody.contains("\"fullyAbsorbed\":true")); + assertTrue("a fully absorbed kinetic strike carrying a travelling body was not reflected — a " + + "shot that lives as a record must bounce like a thrown body does:\n" + withBody, + withBody.contains("\"reflected\":true")); + + // It came in along -Z, so it must leave along +Z: the shell reverses the inward component. + double newVz = readDouble(withBody, "newVz"); + assertTrue("the reflected body still travels inward (newVz=" + newVz + ", it arrived at " + + (-inwardSpeed) + "): the shell did not turn it around:\n" + withBody, newVz > 0.0D); + // And it may never leave faster than it arrived — the shell cannot hand out energy it never + // absorbed. This holds at any restitution setting; only the perfect-mirror default is an equality. + double speed = Math.sqrt(square(readDouble(withBody, "newVx")) + square(readDouble(withBody, "newVy")) + + square(newVz)); + assertTrue("the shell accelerated the body it reflected (out=" + speed + " in=" + inwardSpeed + + "): a mirror returns energy, it does not create it:\n" + withBody, + speed <= inwardSpeed + 1.0E-6D); + + String bodiless = strike(ex, gz, impactEnergy, "KINETIC"); + assertTrue("a bodiless declared strike was reported as carrying a body:\n" + bodiless, + bodiless.contains("\"declaredBody\":false")); + assertTrue("an abstract kinetic source with no travelling body was not fully absorbed:\n" + bodiless, + bodiless.contains("\"fullyAbsorbed\":true")); + assertTrue("a strike with no travelling body was reflected — there is nothing there to reflect:\n" + + bodiless, bodiless.contains("\"reflected\":false")); + + // One impact, one pricing path: reflecting is not a surcharge. Same declared energy, same kind, + // same shell => the same bill, whether or not a body came back out. + long bodyCost = readLong(withBody, "absorbed"); + long bodilessCost = readLong(bodiless, "absorbed"); + assertTrue("reflecting a body was billed differently from stopping one (" + bodyCost + " vs " + + bodilessCost + "): the reflection must scale speed, never the cost.", + bodyCost == bodilessCost); + } + + @Test + public void aBodyThatOutmatchesTheShieldPenetratesInsteadOfBouncing() throws Exception { + int gx = 1010, gz = 846; + int ex = gx + 1; + place("affs:shield_generator", gx, gz); + place("affs:field_generator", ex, gz); + for (int i = 0; i < 15; i++) { + chargeIteration(gx, gz); + } + assertTrue("emitter never powered:\n" + read(ex, gz), read(ex, gz).contains("\"powered\":true")); + long storedBefore = readStored(read(ex, gz)); + + // The other side of the condition the reflection rule straddles. Same declared body, but an + // energy the shield cannot cover: the shield spends what it has, the remainder passes, and the + // body keeps going the way it was going. A short pay must never bounce anything back. + int impactEnergy = (int) (storedBefore * 3L); + String result = strike(ex, gz, impactEnergy, "KINETIC", 0.0D, 0.0D, -2.0D); + // Without this the whole test passes on a strike that never carried a body at all — "did not + // reflect" is the trivial answer to "there was nothing there". + assertTrue("the body this test declares never reached the service:\n" + result, + result.contains("\"declaredBody\":true")); + assertTrue("an overmatching strike was reported fully absorbed — the shield cannot afford it:\n" + + result, result.contains("\"fullyAbsorbed\":false")); + assertTrue("a shield that could not pay still reflected the body: graceful penetration means the " + + "body carries on, not that it bounces for free:\n" + result, + result.contains("\"reflected\":false")); + assertTrue("no residual impact passed a shield that could not fully pay:\n" + result, + readLong(result, "residual") > 0); + } + private String strike(int ex, int gz, int impactEnergy, String kind) throws Exception { + return exec(strikeCommand(ex, gz, impactEnergy, kind)); + } + + /** The same strike, additionally DECLARING the travelling body it carries at that world velocity. */ + private String strike(int ex, int gz, int impactEnergy, String kind, double vx, double vy, double vz) + throws Exception { + return exec(strikeCommand(ex, gz, impactEnergy, kind) + " " + vx + " " + vy + " " + vz); + } + + private String strikeCommand(int ex, int gz, int impactEnergy, String kind) { double cx = ex + 0.5D, cy = Y + 0.5D, cz = gz + 0.5D; double ox = cx, oy = cy, oz = cz + RADIUS + 3.0D; // outside the +Z shell - return exec("artest shield strike " + DIM + " " + ox + " " + oy + " " + oz - + " 0 0 -1 10 " + impactEnergy + " " + kind); + return "artest shield strike " + DIM + " " + ox + " " + oy + " " + oz + + " 0 0 -1 10 " + impactEnergy + " " + kind; + } + + private static double square(double v) { + return v * v; } private String read(int x, int z) throws Exception { @@ -137,6 +241,12 @@ private static long readStored(String json) { return Long.parseLong(m.group(1)); } + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[0-9][0-9.eE+-]*)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + private static long readLong(String json, String key) { Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); assertTrue("no " + key + " field in: " + json, m.find()); From 61dc62458662f7fc1e02793598fcf929d6961b42 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 14 Aug 2026 23:26:44 +0300 Subject: [PATCH 02/35] feat: damage structure with a declared impact - add the impact request/report pair as public API - add ImpactKind with one declared shield mapping - spend a budget along a voxel ray into blocks - add a toughness column to the weight tables - store stages for blocks with no tile - record what a destroyed block was - resolve a ship target along the impact ray - convert world to subspace inside the service - refuse a repeated impact identity - add artest damage probes --- .../api/damage/DamageOutcome.java | 14 ++ .../api/damage/DamageReport.java | 91 +++++++ .../api/damage/ImpactKind.java | 28 +++ .../api/damage/ImpactRequest.java | 91 +++++++ .../api/damage/SelectionMode.java | 32 +++ .../api/damage/StopReason.java | 31 +++ .../command/test/TestProbeCommand.java | 109 +++++++++ .../damage/BlockDamageSavedData.java | 177 ++++++++++++++ .../advancedRocketry/damage/DamageState.java | 76 ++++++ .../damage/ImpactKindMapping.java | 39 +++ .../damage/ShipDamageService.java | 230 ++++++++++++++++++ .../damage/StructureDamageEngine.java | 203 ++++++++++++++++ .../advancedRocketry/util/WeightEngine.java | 113 ++++++++- .../server/StructuralDamageContractTest.java | 184 ++++++++++++++ .../server/VSShipStructuralDamageE2ETest.java | 203 ++++++++++++++++ .../unit/ImpactDeclarationContractTest.java | 92 +++++++ 16 files changed, 1712 insertions(+), 1 deletion(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/DamageOutcome.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/ImpactKind.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/SelectionMode.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/DamageState.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/ImpactKindMapping.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/VSShipStructuralDamageE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/ImpactDeclarationContractTest.java 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..f483540ec --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java @@ -0,0 +1,91 @@ +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; + + public DamageReport(DamageOutcome outcome, StopReason stopReason, int budgetSpent, int budgetLeft, + int blocksStaged, int blocksDestroyed, Vec3d entryPoint, Vec3d exitPoint, + int penetrationDepth) { + 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; + } + + /** 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. */ + public int getPenetrationDepth() { + return penetrationDepth; + } +} 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..4d4e1b3c6 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java @@ -0,0 +1,91 @@ +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; + + public ImpactRequest(long impactId, Vec3d point, Vec3d direction, int budget, ImpactKind kind, + SelectionMode selectionMode) { + 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; + } + + /** 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); + } + + /** 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; + } + + 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..576d69a8e --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java @@ -0,0 +1,31 @@ +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 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 4fcbff96b..382a8812d 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -238,6 +238,9 @@ 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 "sound": handleSound(server, sender, tail(args)); break; @@ -672,6 +675,112 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[ 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 ("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 (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)); + 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 >= 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 + } + } + long impactId = args.length >= 11 ? (long) parseDoubleOr(args[10], 0) : world.getTotalWorldTime(); + + 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()); + 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 damage subcommand — try impact [kind] [impactId] | stage | clear-impacts\"}"); + } + // Valkyrien Skies integration probes ---------------------------------- /** diff --git a/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java b/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java new file mode 100644 index 000000000..e6a78bdfa --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java @@ -0,0 +1,177 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.block.Block; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.storage.MapStorage; +import net.minecraft.world.storage.WorldSavedData; + +import java.util.HashMap; +import java.util.Map; + +/** + * Damage stages of blocks that cannot hold their own, stored per world. + * + *

    A tile entity able to carry a wear stage keeps it, as it always has. Everything else — plain + * hull, plating, a wall — has nowhere to put a stage, and giving every damaged block a tile entity is + * not available: vanilla stores a tile only when the block itself declares one, so an injected tile is + * dropped on the floor. Hence this map.

    + * + *

    Shape

    + *

    Keyed by the packed {@link BlockPos} long, because the access pattern that matters is a turret + * burst: many cheap reads against blocks that are mostly pristine. A miss must therefore cost nothing, + * which is why nothing here builds an object to answer "no damage".

    + * + *

    Provenance

    + *

    When a block is destroyed its original state is recorded, so a repair can put back what was + * there rather than a guess. It is stored as a registry name plus metadata rather than a numeric + * state id: ids are an install-local encoding and a save that outlives one registry order would + * otherwise rebuild a hull out of whatever now occupies that number.

    + * + *

    Known limit — this store is per WORLD, and a ship can leave its world

    + *

    Entries are keyed by position in the world the blocks currently occupy. A ship that crosses into + * another world is re-pasted at fresh coordinates, and these entries do not follow it: its damage is + * left behind. Carrying the map across a crossing is owed work, not a decision — until it lands, a + * crossed ship reads as pristine.

    + */ +public class BlockDamageSavedData extends WorldSavedData { + + public static final String DATA_NAME = "advancedRocketryBlockDamage"; + + private final Map entries = new HashMap<>(); + + public BlockDamageSavedData() { + super(DATA_NAME); + } + + public BlockDamageSavedData(String name) { + super(name); + } + + /** The damage map of THIS world (not a global one — a position means nothing without its world). */ + public static BlockDamageSavedData get(World world) { + MapStorage storage = world.getPerWorldStorage(); + BlockDamageSavedData data = + (BlockDamageSavedData) storage.getOrLoadData(BlockDamageSavedData.class, DATA_NAME); + if (data == null) { + data = new BlockDamageSavedData(); + storage.setData(DATA_NAME, data); + } + return data; + } + + /** Current stage at {@code pos}, or 0 when this position has never been damaged. */ + public int getStage(BlockPos pos) { + Entry entry = entries.get(pos.toLong()); + return entry == null ? 0 : entry.stage; + } + + /** Record a new stage. A stage of 0 clears the entry rather than storing "undamaged". */ + public void setStage(BlockPos pos, int stage) { + long key = pos.toLong(); + if (stage <= 0) { + if (entries.remove(key) != null) { + markDirty(); + } + return; + } + Entry entry = entries.get(key); + if (entry == null) { + entry = new Entry(); + entries.put(key, entry); + } + entry.stage = stage; + markDirty(); + } + + /** + * Record what stood at {@code pos} before it was destroyed. Called with the state as it was, at + * the moment it stops being readable from the world. + */ + public void recordDestroyed(BlockPos pos, Block block, int meta) { + if (block == null || block.getRegistryName() == null) { + return; + } + long key = pos.toLong(); + Entry entry = entries.get(key); + if (entry == null) { + entry = new Entry(); + entries.put(key, entry); + } + entry.originalBlock = block.getRegistryName().toString(); + entry.originalMeta = meta; + markDirty(); + } + + /** Registry name of what was destroyed here, or null if nothing was. */ + public String getDestroyedBlockName(BlockPos pos) { + Entry entry = entries.get(pos.toLong()); + return entry == null ? null : entry.originalBlock; + } + + /** Metadata of what was destroyed here; meaningless unless {@link #getDestroyedBlockName} is set. */ + public int getDestroyedMeta(BlockPos pos) { + Entry entry = entries.get(pos.toLong()); + return entry == null ? 0 : entry.originalMeta; + } + + /** Forget this position entirely — what a completed repair does. */ + public void clear(BlockPos pos) { + if (entries.remove(pos.toLong()) != null) { + markDirty(); + } + } + + /** How many positions this world currently holds damage for (diagnostics and tests). */ + public int size() { + return entries.size(); + } + + @Override + public void readFromNBT(NBTTagCompound nbt) { + entries.clear(); + NBTTagList list = nbt.getTagList("entries", 10); + for (int i = 0; i < list.tagCount(); i++) { + NBTTagCompound tag = list.getCompoundTagAt(i); + Entry entry = new Entry(); + entry.stage = tag.getInteger("stage"); + if (tag.hasKey("block")) { + entry.originalBlock = tag.getString("block"); + entry.originalMeta = tag.getInteger("meta"); + } + entries.put(tag.getLong("pos"), entry); + } + } + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound nbt) { + NBTTagList list = new NBTTagList(); + for (Map.Entry mapEntry : entries.entrySet()) { + Entry entry = mapEntry.getValue(); + NBTTagCompound tag = new NBTTagCompound(); + tag.setLong("pos", mapEntry.getKey()); + tag.setInteger("stage", entry.stage); + if (entry.originalBlock != null) { + tag.setString("block", entry.originalBlock); + tag.setInteger("meta", entry.originalMeta); + } + list.appendTag(tag); + } + nbt.setTag("entries", list); + return nbt; + } + + /** Resolve a recorded provenance name back to a block, or null if that block is no longer present. */ + public static Block blockFromName(String registryName) { + return registryName == null ? null : Block.REGISTRY.getObject(new ResourceLocation(registryName)); + } + + private static final class Entry { + private int stage; + private String originalBlock; + private int originalMeta; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java new file mode 100644 index 000000000..51f0fb477 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java @@ -0,0 +1,76 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.capability.CapabilityWear; +import zmaster587.advancedRocketry.api.capability.IPartWear; + +/** + * The one place that answers "how damaged is the block at this position", whichever of the two homes + * its stage lives in: a tile entity that carries wear, or the per-world damage map for everything + * else. Damage and wear are the same axis on purpose — a part worn by use and a part hit by a shot are + * degraded in one counter, so there is one repair and one consequence formula rather than two that + * disagree. + * + *

    Consumers and rendering read through here and never touch either home directly; that is what + * keeps the split an implementation detail rather than a thing every call site has to know.

    + */ +public final class DamageState { + + /** Stage of a block with no tile of its own. Blocks are not infinitely damageable; this is the cap. */ + public static final int DEFAULT_MAX_STAGE = 4; + + private DamageState() { + } + + /** Current stage at {@code pos}: 0 = pristine, {@link #getMaxStage} = destroyed. */ + public static int getStage(World world, BlockPos pos) { + if (world == null || pos == null) { + return 0; + } + IPartWear wear = wearAt(world, pos); + if (wear != null) { + return wear.getStage(); + } + return world.isRemote ? 0 : BlockDamageSavedData.get(world).getStage(pos); + } + + /** The stage at which the block at {@code pos} is destroyed. */ + public static int getMaxStage(World world, BlockPos pos) { + IPartWear wear = wearAt(world, pos); + return wear != null ? wear.getMaxStage() : DEFAULT_MAX_STAGE; + } + + /** + * Write a stage back to whichever home owns it. Server side only — the client is told about damage + * through the block's own sync, never by writing a stage of its own. + */ + public static void setStage(World world, BlockPos pos, int stage) { + if (world == null || pos == null || world.isRemote) { + return; + } + IPartWear wear = wearAt(world, pos); + if (wear != null) { + wear.setStage(stage); + TileEntity te = world.getTileEntity(pos); + if (te != null) { + te.markDirty(); + } + return; + } + BlockDamageSavedData.get(world).setStage(pos, stage); + } + + /** True when this position is at its terminal stage — the block is gone, not merely cracked. */ + public static boolean isDestroyed(World world, BlockPos pos) { + return getStage(world, pos) >= getMaxStage(world, pos); + } + + private static IPartWear wearAt(World world, BlockPos pos) { + if (world == null || pos == null || !world.isBlockLoaded(pos)) { + return null; + } + return CapabilityWear.get(world.getTileEntity(pos)); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ImpactKindMapping.java b/src/main/java/zmaster587/advancedRocketry/damage/ImpactKindMapping.java new file mode 100644 index 000000000..73b2a3b4b --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/ImpactKindMapping.java @@ -0,0 +1,39 @@ +package zmaster587.advancedRocketry.damage; + +import com.github.stannismod.affs.world.shield.ShieldStrikeKind; +import zmaster587.advancedRocketry.api.damage.ImpactKind; + +/** + * The single declared mapping from the hull's impact kinds to the shield's two. + * + *

    It lives here rather than on {@link ImpactKind} itself so that AR's public API does not drag the + * shield package in behind it: a mod that only wants to damage a hull should not have to know a shield + * mod exists. The mapping is many-to-two on purpose and will stay that way — the hull may grow kinds + * the shell has no opinion about, and every one of them still has to be billable by a shell.

    + */ +public final class ImpactKindMapping { + + private ImpactKindMapping() { + } + + /** + * How a shell bills this kind of impact. Physical for anything that arrives as matter or blast, + * energy for anything that arrives as radiation — the only distinction a resistance bias makes. + */ + public static ShieldStrikeKind toShieldKind(ImpactKind kind) { + if (kind == null) { + return ShieldStrikeKind.KINETIC; + } + switch (kind) { + case KINETIC: + case EXPLOSIVE: + return ShieldStrikeKind.KINETIC; + case THERMAL: + case BEAM: + return ShieldStrikeKind.RADIANT; + default: + throw new IllegalArgumentException("no shield billing declared for impact kind " + kind + + " — every hull kind must state how a shell charges for it"); + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java new file mode 100644 index 000000000..0aae2c5bd --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java @@ -0,0 +1,230 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.DamageReport; +import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.api.damage.SelectionMode; +import zmaster587.advancedRocketry.api.damage.StopReason; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The one entry point for damaging structure. A weapon, a hazard or a collision declares an impact + * here and reads back what happened; it names no block, no stage and no toughness, and it is told no + * decision — only facts it can decide from. + * + *

    One call for a ship and for a building

    + *

    The call takes a WORLD and a point, never a ship id. Nearly every ship system has to work on a + * planetary base as well, and ground batteries have to be able to engage ships; if the caller had to + * know which kind of thing it was shooting at, every weapon would grow two code paths that drift + * apart. So this service resolves the target itself: a ship whose blocks actually occupy the impact + * point, or, when there is none, the ordinary blocks of that world.

    + * + *

    Frames

    + *

    Callers work in the world frame. Ship blocks do not live there — they live at fixed addresses in + * a shipyard subspace while the ship flies around — so the point and the direction are mapped into + * that frame here, once, and the report's points are mapped back. No caller and no engine below sees + * two frames.

    + */ +public final class ShipDamageService { + + /** + * How long an applied impact identity is remembered, in ticks. Long enough to cover the retries + * that make duplicates real (an impact deferred because its region was unloaded, a shot + * re-examined across a load transition), short enough that the set stays small. + */ + private static final long IMPACT_MEMORY_TICKS = 600L; + + /** Hard cap on remembered identities, so a runaway caller cannot grow this without bound. */ + private static final int IMPACT_MEMORY_MAX = 4096; + + /** + * How far along its own direction an impact may look for the ship it is hitting, when the declared + * point itself is not yet inside one. Small on purpose: it exists to forgive a point declared just + * off the plating, not to let an impact reach out and find a hull it never met. + */ + private static final double TARGET_LEAD_BLOCKS = 8.0D; + private static final double LEAD_STEP = 0.5D; + + /** + * Recently applied impact identities → the tick they were applied on. Written only from here. + * It outlives a scenario: on a server shared by several tests, an id used by one is still + * refused for the next, so a test that reuses ids must call {@link #clearRecentImpacts()} between + * them rather than assume a fresh service. + */ + private static final Map RECENT_IMPACTS = new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > IMPACT_MEMORY_MAX; + } + }; + + private ShipDamageService() { + } + + /** + * Apply a declared impact to whatever structure occupies its point. + * + *

    Server side only: damage is world state, and a client that computed its own would be + * describing a different game from the one everybody else is playing.

    + */ + public static DamageReport apply(World world, ImpactRequest request) { + if (world == null || request == null || world.isRemote) { + return DamageReport.nothingStruck(request == null ? 0 : request.getBudget(), + StopReason.NO_CANDIDATES); + } + if (request.getBudget() <= 0) { + return DamageReport.nothingStruck(0, StopReason.NO_CANDIDATES); + } + if (request.getSelectionMode() != SelectionMode.PENETRATING) { + throw new UnsupportedOperationException("selection mode " + request.getSelectionMode() + + " is not implemented yet; only PENETRATING resolves today. Failing loudly rather" + + " than reporting an undamaged hull, which would read as a miss."); + } + if (isDuplicate(world, request.getImpactId())) { + return DamageReport.duplicate(request.getBudget()); + } + + Vec3d point = request.getPoint(); + String shipId = shipAt(world, point, request.getDirection()); + if (shipId == null) { + remember(world, request.getImpactId()); + return toReport(StructureDamageEngine.penetrate(world, point, request.getDirection(), + request.getBudget()), null, world); + } + + double[] shipPoint = VSIntegration.toShipFrameFor(world, shipId, point.x, point.y, point.z); + Vec3d direction = request.getDirection(); + double[] shipDir = VSIntegration.rotateToShipFrameFor(world, shipId, direction.x, direction.y, + direction.z); + if (shipPoint == null || shipDir == null) { + // The ship was there a moment ago and its transform is not answering now: that is "ask + // again", not "clean miss". + return DamageReport.nothingStruck(request.getBudget(), StopReason.TARGET_UNLOADED); + } + + remember(world, request.getImpactId()); + StructureDamageEngine.WalkResult walk = StructureDamageEngine.penetrate(world, + new Vec3d(shipPoint[0], shipPoint[1], shipPoint[2]), + new Vec3d(shipDir[0], shipDir[1], shipDir[2]), request.getBudget()); + return toReport(walk, shipId, world); + } + + /** + * The non-geometric overload, for a caller that already knows the ship and has no impact point at + * all — a failing drive tearing its own hull apart from the inside. Not implemented yet: it needs + * the subsystem-weighted selection mode, and answering with an undamaged hull would read as "the + * ship is fine". + */ + public static DamageReport apply(World world, String shipId, ImpactRequest request) { + throw new UnsupportedOperationException("the by-ship overload needs POWER_BIASED selection," + + " which is not implemented yet"); + } + + /** + * Forget every remembered impact identity. Owned here because the memory is owned here; a shared + * server hands it from one scenario to the next otherwise. + */ + public static void clearRecentImpacts() { + RECENT_IMPACTS.clear(); + } + + /** How many identities are currently remembered (diagnostics and tests). */ + public static int rememberedImpactCount() { + return RECENT_IMPACTS.size(); + } + + /** + * Which ship an impact at this point would be charged to, or null for "the world's own blocks". + * Exposed for diagnostics: the report itself deliberately does not name a ship, but an instrument + * that cannot say which target was resolved cannot tell a wrong target from no target. + */ + public static String resolveTargetShip(World world, Vec3d point, Vec3d direction) { + return world == null || point == null ? null : shipAt(world, point, direction); + } + + /** + * The ship whose blocks the impact meets, or null for "no ship here, use the world's own blocks". + * + *

    Two things this has to get right, and the second is not obvious. Candidate ships come from + * their grown world boxes, which overlap and overstate, so each candidate is asked whether its own + * subspace actually holds a block there — a near miss past one hull is not charged to it.

    + * + *

    And the search runs along the ray, not only at the declared point. A caller says "the + * impact happened here", and here is a point in the air a little off the plating as often as it is + * the plating itself — a shot resolves its crossing geometrically and hands over where it met the + * surface, give or take. Resolving only at the origin would answer "no ship" for such an impact and + * walk the world frame instead, where the ship has no blocks at all; the shot would read as a clean + * miss while sitting on the hull. So the ray is sampled forward a short lead distance and the first + * ship whose blocks it enters wins.

    + */ + private static String shipAt(World world, Vec3d point, Vec3d direction) { + String found = shipManagingPoint(world, point); + if (found != null || direction == null + || (direction.x == 0.0D && direction.y == 0.0D && direction.z == 0.0D)) { + return found; + } + for (double t = LEAD_STEP; t <= TARGET_LEAD_BLOCKS; t += LEAD_STEP) { + found = shipManagingPoint(world, point.add(new Vec3d(direction.x * t, direction.y * t, + direction.z * t))); + if (found != null) { + return found; + } + } + return null; + } + + /** The ship holding a block of its own at exactly this world point, or null. */ + private static String shipManagingPoint(World world, Vec3d point) { + for (String candidate : VSIntegration.shipIdsAt(world, point.x, point.y, point.z)) { + double[] local = VSIntegration.toShipFrameFor(world, candidate, point.x, point.y, point.z); + if (local == null) { + continue; + } + BlockPos pos = new BlockPos(Math.floor(local[0]), Math.floor(local[1]), Math.floor(local[2])); + if (candidate.equals(VSIntegration.shipIdManagingBlock(world, pos))) { + return candidate; + } + } + return null; + } + + private static DamageReport toReport(StructureDamageEngine.WalkResult walk, String shipId, World world) { + Vec3d entry = toWorld(world, shipId, walk.entryPoint); + Vec3d exit = walk.outcome == DamageOutcome.EXITED ? toWorld(world, shipId, walk.exitPoint) : null; + return new DamageReport(walk.outcome, walk.stopReason, walk.budgetSpent, walk.budgetLeft, + walk.blocksStaged, walk.blocksDestroyed, entry, exit, walk.penetrationDepth); + } + + private static Vec3d toWorld(World world, String shipId, Vec3d local) { + if (local == null) { + return null; + } + if (shipId == null) { + return local; + } + double[] w = VSIntegration.toWorldFrameFor(world, shipId, local.x, local.y, local.z); + return w == null ? null : new Vec3d(w[0], w[1], w[2]); + } + + private static boolean isDuplicate(World world, long impactId) { + Long appliedAt = RECENT_IMPACTS.get(impactId); + if (appliedAt == null) { + return false; + } + if (world.getTotalWorldTime() - appliedAt > IMPACT_MEMORY_TICKS) { + RECENT_IMPACTS.remove(impactId); + return false; + } + return true; + } + + private static void remember(World world, long impactId) { + RECENT_IMPACTS.put(impactId, world.getTotalWorldTime()); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java new file mode 100644 index 000000000..c38ef45f6 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -0,0 +1,203 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.StopReason; +import zmaster587.advancedRocketry.util.WeightEngine; + +/** + * The budget-and-spend loop: a damage budget is walked into blocks until it runs out. + * + *

    Advancing one block by one stage costs {@code base + toughness x mult}, and the terminal stage is + * destruction. That is the whole model — a bigger budget does not "do more damage" to one block, it + * reaches further and takes more of them, which is what makes a heavy slug and a light round behave + * differently at the same energy instead of being one number with two names.

    + * + *

    Frame

    + *

    This class walks in the frame it is handed and knows nothing about which one that is. On a + * ship the caller hands it subspace coordinates, because that is where ship blocks live; off a ship + * the two frames are the same thing. Nothing here converts, so nothing here can convert wrongly; the + * one conversion lives at the seam above.

    + */ +public final class StructureDamageEngine { + + /** + * Cost of advancing any block by one stage before toughness is counted, and the multiplier on + * toughness. Both are balance numbers in shield-energy-equivalent units: with the defaults a + * pane of glass gives way for a fraction of what a plated hull costs, and neither is pinned by a + * test. They live here rather than in the config file until there is balance work to spend on + * them. + */ + private static final double STAGE_COST_BASE = 250.0D; + private static final double STAGE_COST_TOUGHNESS_MULT = 250.0D; + + /** + * How far a single impact is willing to bore. Not a physical limit — a limit on how much world one + * impact may walk before the engine hands the remaining budget back to the caller and stops. A + * shot with budget left at this point is reported as having exited, carrying that budget: better + * that it re-enters as a fresh impact than that the engine silently swallows it. + */ + private static final int MAX_PATH_BLOCKS = 64; + + /** + * How many empty blocks in a row mean "out the far side" rather than "an internal cavity". A hull + * with a corridor behind it is one target, not two; a shot that crosses a room and hits the + * opposite wall should still be one impact. + */ + private static final int GAP_TOLERANCE = 6; + + private StructureDamageEngine() { + } + + /** + * Walk from {@code entry} along {@code direction}, spending {@code budget}. Every coordinate in + * and out is in the caller's frame. + */ + public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget) { + WalkResult result = new WalkResult(); + result.budgetLeft = budget; + if (world == null || entry == null || direction == null + || (direction.x == 0.0D && direction.y == 0.0D && direction.z == 0.0D)) { + result.outcome = DamageOutcome.NOTHING_STRUCK; + result.stopReason = StopReason.NO_CANDIDATES; + return result; + } + + boolean enteredStructure = false; + int consecutiveEmpty = 0; + Vec3d lastSolidExit = null; + BlockPos previous = null; + + for (int step = 0; step < MAX_PATH_BLOCKS; step++) { + Vec3d samplePoint = entry.add(scale(direction, step + 0.5D)); + BlockPos pos = new BlockPos(Math.floor(samplePoint.x), Math.floor(samplePoint.y), + Math.floor(samplePoint.z)); + if (pos.equals(previous)) { + continue; + } + previous = pos; + + if (!world.isBlockLoaded(pos)) { + // Not "there is nothing here" — nobody looked. A caller that can retry should. + result.outcome = enteredStructure ? DamageOutcome.ABSORBED : DamageOutcome.NOTHING_STRUCK; + result.stopReason = StopReason.TARGET_UNLOADED; + return result; + } + + IBlockState state = world.getBlockState(pos); + if (!isDamageable(world, pos, state)) { + if (enteredStructure && ++consecutiveEmpty >= GAP_TOLERANCE) { + result.outcome = DamageOutcome.EXITED; + result.stopReason = StopReason.EXITED_FAR_SIDE; + result.exitPoint = lastSolidExit; + return result; + } + continue; + } + + consecutiveEmpty = 0; + if (!enteredStructure) { + enteredStructure = true; + result.entryPoint = samplePoint; + } + result.penetrationDepth++; + lastSolidExit = entry.add(scale(direction, step + 1.0D)); + + if (isIndestructible(world, pos, state)) { + // Nothing gets through this. The budget dies here rather than tunnelling past it. + result.budgetSpent += result.budgetLeft; + result.budgetLeft = 0; + result.outcome = DamageOutcome.ABSORBED; + result.stopReason = StopReason.BUDGET_EXHAUSTED; + return result; + } + + spendInto(world, pos, state, result); + if (result.budgetLeft <= 0) { + result.outcome = DamageOutcome.ABSORBED; + result.stopReason = StopReason.BUDGET_EXHAUSTED; + return result; + } + } + + if (!enteredStructure) { + result.outcome = DamageOutcome.NOTHING_STRUCK; + result.stopReason = StopReason.NO_CANDIDATES; + return result; + } + // Budget still in hand at the path limit: hand it back rather than absorb it silently. + result.outcome = DamageOutcome.EXITED; + result.stopReason = StopReason.EXITED_FAR_SIDE; + result.exitPoint = lastSolidExit; + return result; + } + + /** Spend as much of the remaining budget into one block as its stages will take. */ + private static void spendInto(World world, BlockPos pos, IBlockState state, WalkResult result) { + int maxStage = DamageState.getMaxStage(world, pos); + int stage = DamageState.getStage(world, pos); + int stageCost = stageCost(world, pos); + + boolean advanced = false; + while (stage < maxStage && result.budgetLeft >= stageCost) { + result.budgetLeft -= stageCost; + result.budgetSpent += stageCost; + stage++; + advanced = true; + } + if (!advanced) { + return; + } + + if (stage >= maxStage) { + BlockDamageSavedData.get(world).recordDestroyed(pos, state.getBlock(), + state.getBlock().getMetaFromState(state)); + DamageState.setStage(world, pos, stage); + world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + result.blocksDestroyed++; + } else { + DamageState.setStage(world, pos, stage); + result.blocksStaged++; + } + } + + /** + * What one stage of damage costs here. A block already part-way gone is cheaper to finish: damage + * that has been taken is damage the next hit does not have to do again. + */ + public static int stageCost(World world, BlockPos pos) { + double toughness = WeightEngine.INSTANCE.getToughness(world, pos); + int maxStage = Math.max(1, DamageState.getMaxStage(world, pos)); + double perStage = (STAGE_COST_BASE + toughness * STAGE_COST_TOUGHNESS_MULT) / maxStage; + return Math.max(1, (int) Math.ceil(perStage)); + } + + private static boolean isDamageable(World world, BlockPos pos, IBlockState state) { + return !state.getBlock().isAir(state, world, pos) && !state.getMaterial().isLiquid(); + } + + private static boolean isIndestructible(World world, BlockPos pos, IBlockState state) { + return state.getBlockHardness(world, pos) < 0.0F; + } + + private static Vec3d scale(Vec3d v, double s) { + return new Vec3d(v.x * s, v.y * s, v.z * s); + } + + /** What one walk did, in the frame it walked. The seam above maps the points back to world. */ + public static final class WalkResult { + public DamageOutcome outcome = DamageOutcome.NOTHING_STRUCK; + public StopReason stopReason = StopReason.NO_CANDIDATES; + public int budgetSpent; + public int budgetLeft; + public int blocksStaged; + public int blocksDestroyed; + public int penetrationDepth; + public Vec3d entryPoint; + public Vec3d exitPoint; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java index ca8981ec0..fa89e2fbc 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java @@ -69,6 +69,20 @@ public enum WeightEngine { private double fallback = 0.1; private double fluidFallback = 0.001; + // Toughness — a second column over the same keys, resolved by the same chain (individual -> + // byRegex -> material -> fallback) and living in the same file. It answers "how much does it cost + // to damage this block", where weight answers "how much does it mass"; the two are correlated but + // are not the same question, which is why anvils are not simply heavy glass. + // + // CALIBRATION, and a bet worth stating out loud: these numbers are spent against a budget + // denominated in the SAME unit as shield impact energy. The muzzle-side damage->energy factor + // therefore scales BOTH what a shot costs a shield and what it costs a hull — anyone retuning it + // is retuning hull lethality at the same time, in the same direction. + private Map toughnessIndividual = new HashMap<>(); + private Map toughnessByRegex = new LinkedHashMap<>(); + private Map toughnessMaterials = new HashMap<>(); + private double toughnessFallback = 2.0; + // Transient runtime caches (not persisted; cleared on load()). private final Map resolvedItemCache = new HashMap<>(); private final Map compiledRegex = new HashMap<>(); @@ -143,7 +157,13 @@ private float componentOrMaterialWeight(String key, ItemStack stack) { } private Double matchRegex(String key) { - for (Map.Entry e : byRegex.entrySet()) { + return matchRegex(byRegex, key); + } + + /** First matching regex rule of {@code table}, or null. The compiled-pattern cache is shared: + * the same pattern string means the same pattern whichever column it rules. */ + private Double matchRegex(Map table, String key) { + for (Map.Entry e : table.entrySet()) { Pattern p = compiledRegex.get(e.getKey()); if (p == null) { try { @@ -160,6 +180,40 @@ private Double matchRegex(String key) { return null; } + /** + * How hard the block at {@code pos} is to damage. Same resolution chain as weight, over the same + * registry names, so a pack that has already tuned one has half the work done for the other. + * Air and anything unrecognised resolve to the fallback rather than to zero: a block that costs + * nothing to break would let one shot walk an entire hull. + */ + public float getToughness(World world, BlockPos pos) { + return world == null || pos == null ? (float) toughnessFallback + : getToughness(world.getBlockState(pos).getBlock()); + } + + public float getToughness(Block block) { + if (block == null || block.getRegistryName() == null) { + return (float) toughnessFallback; + } + String key = block.getRegistryName().toString(); + + Double override = toughnessIndividual.get(key); + if (override != null) { + return override.floatValue(); + } + Double regex = matchRegex(toughnessByRegex, key); + if (regex != null) { + return regex.floatValue(); + } + Double byMaterial = toughnessMaterials.get(materialName(block.getDefaultState().getMaterial())); + return byMaterial != null ? byMaterial.floatValue() : (float) toughnessFallback; + } + + /** Register an explicit per-registry-name toughness (highest precedence). */ + public void setIndividualToughness(String registryName, double toughness) { + toughnessIndividual.put(registryName, toughness); + } + public float getWeight(Collection stacks) { return stacks.stream().map(this::getWeight).reduce(0.0F, Float::sum); } @@ -247,6 +301,16 @@ public void load() { if (root.has("fluidFallback")) { fluidFallback = root.get("fluidFallback").getAsDouble(); } + + toughnessIndividual = readMap(gson, root, "toughnessIndividual", mapType); + toughnessByRegex = readMap(gson, root, "toughnessByRegex", linkedType); + toughnessMaterials = readMap(gson, root, "toughnessMaterials", mapType); + if (toughnessMaterials.isEmpty()) { + toughnessMaterials = defaultToughnessMaterials(); + } + if (root.has("toughnessFallback")) { + toughnessFallback = root.get("toughnessFallback").getAsDouble(); + } } catch (Exception e) { e.printStackTrace(); seedDefaults(); @@ -271,6 +335,10 @@ private void seedDefaults() { materials = defaultMaterials(); fallback = 0.1; fluidFallback = 0.001; + toughnessIndividual = new HashMap<>(); + toughnessByRegex = new LinkedHashMap<>(); + toughnessMaterials = defaultToughnessMaterials(); + toughnessFallback = 2.0; } // ---- Runtime / test mutation hooks -------------------------------------- @@ -324,6 +392,10 @@ public void save() { json.add("materials", gson.toJsonTree(materials)); json.addProperty("fallback", fallback); json.addProperty("fluidFallback", fluidFallback); + json.add("toughnessIndividual", gson.toJsonTree(toughnessIndividual)); + json.add("toughnessByRegex", gson.toJsonTree(toughnessByRegex)); + json.add("toughnessMaterials", gson.toJsonTree(toughnessMaterials)); + json.addProperty("toughnessFallback", toughnessFallback); w.write(gson.toJson(json)); } catch (Exception e) { e.printStackTrace(); @@ -364,6 +436,45 @@ private static Map defaultMaterials() { return m; } + /** + * Toughness by material — how much a block of this stuff resists being damaged. Ordered so the + * ratios read at a glance: glass is not armour, rock is a wall, iron is a hull, an anvil is a + * deliberate outlier. Every one of these is tunable and none is pinned by a test; what IS meant to + * survive retuning is the ordering, because that is what a player perceives when a shot goes + * through a window and stops in the plating. + */ + private static Map defaultToughnessMaterials() { + Map m = new LinkedHashMap<>(); + m.put("AIR", 0.0); + m.put("CLOTH", 0.2); + m.put("CARPET", 0.2); + m.put("WEB", 0.1); + m.put("PLANTS", 0.1); + m.put("VINE", 0.1); + m.put("LEAVES", 0.1); + m.put("CACTUS", 0.2); + m.put("GOURD", 0.3); + m.put("SNOW", 0.2); + m.put("CRAFTED_SNOW", 0.4); + m.put("SAND", 0.8); + m.put("GROUND", 0.8); + m.put("GRASS", 0.8); + m.put("CLAY", 1.0); + m.put("WOOD", 1.2); + m.put("GLASS", 0.5); + m.put("ICE", 0.6); + m.put("PACKED_ICE", 0.9); + m.put("CORAL", 0.6); + m.put("CAKE", 0.1); + m.put("CIRCUITS", 1.0); + m.put("REDSTONE_LIGHT", 1.0); + m.put("TNT", 0.5); + m.put("ROCK", 3.0); + m.put("IRON", 6.0); + m.put("ANVIL", 9.0); + return m; + } + private static final Map MATERIAL_NAMES = buildMaterialNames(); private static Map buildMaterialNames() { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java new file mode 100644 index 000000000..bbb79479c --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java @@ -0,0 +1,184 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * What a declared impact does to real blocks in a real world — the damage engine driven through the + * same service a weapon will call. + * + *

    Four contracts, each one a thing a weapon has to be able to rely on:

    + * + *
      + *
    • an impact that meets a wall spends into it and says so: something is staged or + * destroyed, the report names how deep it reached and where it entered;
    • + *
    • an impact whose budget outlasts the wall exits carrying the rest — that is what lets a + * shot continue instead of being silently swallowed by the first thing it touches;
    • + *
    • the same impact identity applied twice damages once: retries are real on the resolution + * path, and double damage is invisible in a diff;
    • + *
    • a wall of tougher stuff is not penetrated further than a flimsy one at equal budget — + * the ordering the toughness table exists to express, pinned as an ordering rather than as any + * particular number, all of which are tunable.
    • + *
    + * + *

    Impacts are declared with {@code /artest damage impact ...}, which calls the production service + * on the logical server; the stage of any block is read back through the same unified reader + * production uses.

    + */ +public class StructuralDamageContractTest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 70; + + @Test + public void anImpactIntoAWallSpendsIntoItAndReportsWhereItReached() throws Exception { + int x = 1200, z = 1200; + buildWall("minecraft:stone", x, z, 4); + clearImpactMemory(); + + // Fired from outside the wall's near face, straight along +X into it, with a budget big + // enough to matter but far too small to walk four blocks of stone. + String result = impact(x - 2.5D, z + 0.5D, 1, 0, 0, 3000, "KINETIC", 9001); + assertTrue("an impact into a solid wall reported striking nothing:\n" + result, + !result.contains("\"outcome\":\"NOTHING_STRUCK\"")); + assertTrue("an impact into a wall spent none of its budget:\n" + result, + readLong(result, "spent") > 0); + assertTrue("the report names no depth, so no weapon could tell a slug from a pellet:\n" + result, + readLong(result, "depth") > 0); + assertTrue("the report names no entry point, so a continuing shot has nowhere to resume:\n" + + result, result.contains("\"hasEntry\":true")); + assertTrue("nothing was staged and nothing destroyed, yet budget was spent:\n" + result, + readLong(result, "staged") + readLong(result, "destroyed") > 0); + } + + @Test + public void anImpactThatOutlastsTheWallExitsCarryingTheRest() throws Exception { + int x = 1200, z = 1220; + buildWall("minecraft:glass", x, z, 1); + clearImpactMemory(); + + // One pane of glass against a budget sized for a great deal more than one pane. + String result = impact(x - 2.5D, z + 0.5D, 1, 0, 0, 400000, "KINETIC", 9002); + assertTrue("a budget that dwarfs a single pane did not report exiting:\n" + result, + result.contains("\"outcome\":\"EXITED\"")); + assertTrue("an exiting impact must say it left the far side:\n" + result, + result.contains("\"stopReason\":\"EXITED_FAR_SIDE\"")); + assertTrue("an exiting impact carries no budget onward — the shot was silently swallowed:\n" + + result, readLong(result, "left") > 0); + assertTrue("an exiting impact names no exit point, so a continuing shot cannot resume:\n" + + result, result.contains("\"hasExit\":true")); + assertTrue("the pane survived a budget that should have taken it:\n" + result, + readLong(result, "destroyed") > 0); + } + + @Test + public void theSameImpactIdentityAppliedTwiceDamagesOnce() throws Exception { + int x = 1200, z = 1240; + buildWall("minecraft:stone", x, z, 4); + clearImpactMemory(); + + long id = 9003L; + String first = impact(x - 2.5D, z + 0.5D, 1, 0, 0, 3000, "KINETIC", id); + long firstSpend = readLong(first, "spent"); + assertTrue("the first application of the impact did nothing, so the second proves nothing:\n" + + first, firstSpend > 0); + + String second = impact(x - 2.5D, z + 0.5D, 1, 0, 0, 3000, "KINETIC", id); + assertTrue("the same impact identity was applied a second time — a retry on the resolution " + + "path would therefore damage twice, and no diff would show it:\n" + second, + second.contains("\"stopReason\":\"DUPLICATE_IMPACT\"")); + assertTrue("a refused duplicate spent budget:\n" + second, readLong(second, "spent") == 0); + assertTrue("a refused duplicate did not hand the budget back:\n" + second, + readLong(second, "left") == 3000); + + // A DIFFERENT identity at the same place is a genuinely new impact and must still land — + // otherwise the dedup would have turned into "one impact per position, ever". + String third = impact(x - 2.5D, z + 0.5D, 1, 0, 0, 3000, "KINETIC", id + 1); + assertTrue("a fresh impact identity was refused as a duplicate:\n" + third, + !third.contains("\"stopReason\":\"DUPLICATE_IMPACT\"")); + assertTrue("a fresh impact identity spent nothing:\n" + third, readLong(third, "spent") > 0); + } + + @Test + public void aTougherWallIsNotPenetratedFurtherThanAFlimsyOneAtEqualBudget() throws Exception { + int thickness = 8; + int glassX = 1200, glassZ = 1260; + int ironX = 1200, ironZ = 1280; + buildWall("minecraft:glass", glassX, glassZ, thickness); + buildWall("minecraft:iron_block", ironX, ironZ, thickness); + clearImpactMemory(); + + // The budget is derived from what production itself charges, not from a number written here: + // exactly enough to take the whole glass wall. Every cost in this engine is tunable, so a + // budget picked by hand would pin the tuning instead of the ordering, and would go red the + // day someone rebalances armour without breaking anything a player would notice. + String glassProbe = stage(glassX, glassZ); + String ironProbe = stage(ironX, ironZ); + long glassStageCost = readLong(glassProbe, "stageCost"); + long ironStageCost = readLong(ironProbe, "stageCost"); + long maxStage = readLong(glassProbe, "maxStage"); + assertTrue("iron is not costed above glass, so the toughness table orders nothing and the " + + "comparison below is empty (glass=" + glassProbe + " iron=" + ironProbe + ")", + ironStageCost > glassStageCost); + + int budget = (int) (glassStageCost * maxStage * thickness); + String glass = impact(glassX - 2.5D, glassZ + 0.5D, 1, 0, 0, budget, "KINETIC", 9010); + String iron = impact(ironX - 2.5D, ironZ + 0.5D, 1, 0, 0, budget, "KINETIC", 9011); + long glassDestroyed = readLong(glass, "destroyed"); + long ironDestroyed = readLong(iron, "destroyed"); + + // The control: the cheap wall must actually give way, or "iron resisted" is vacuous. + assertTrue("a budget sized to take the whole glass wall did not take it (destroyed " + + glassDestroyed + " of " + thickness + "), so this comparison measures nothing:\n" + + glass, glassDestroyed == thickness); + assertTrue("the same budget went as far into iron as into glass (glass destroyed " + + glassDestroyed + ", iron destroyed " + ironDestroyed + "): a hull's material buys " + + "its crew nothing.\niron=" + iron, ironDestroyed < glassDestroyed); + } + + /** The unified stage reader at a wall's first block: stage, max stage, and what a stage costs there. */ + private String stage(int x, int z) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + z); + } + + private String impact(double x, double z, double dx, double dy, double dz, int budget, String kind, + long impactId) throws Exception { + return exec("artest damage impact " + DIM + " " + x + " " + (Y + 0.5D) + " " + z + + " " + dx + " " + dy + " " + dz + " " + budget + " " + kind + " " + impactId); + } + + /** A run of blocks along +X at the test's own row, the wall an impact is fired into. */ + private void buildWall(String block, int x, int z, int thickness) throws Exception { + String resp = exec("artest fill " + DIM + " " + x + " " + Y + " " + z + " " + + (x + thickness - 1) + " " + Y + " " + z + " " + block); + assertTrue("failed to build the " + block + " wall at " + x + "," + Y + "," + z + ": " + resp, + resp.contains("\"ok\":true")); + assertTrue("the " + block + " wall placed no blocks, so every assertion below would be about " + + "an empty row of air: " + resp, readLong(resp, "placed") == thickness); + } + + private void clearImpactMemory() throws Exception { + // The dedup memory is server-lifetime state on a shared server; a scenario that does not + // clear it can be refused for an id another scenario happened to use. + exec("artest damage clear-impacts"); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static String exec(String command) throws Exception { + return join(client().execute(command)); + } + + private static String join(List resp) { + return String.join("\n", resp); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VSShipStructuralDamageE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipStructuralDamageE2ETest.java new file mode 100644 index 000000000..a8140c13c --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VSShipStructuralDamageE2ETest.java @@ -0,0 +1,203 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Damaging a block that belongs to a SHIP — the case the whole damage engine exists for, and the one + * its world-block tests cannot reach. + * + *

    A ship's blocks do not live where the ship appears to be. They sit at fixed addresses in a distant + * shipyard subspace while the hull flies around, so an impact arriving in world coordinates has to be + * mapped into that frame before anything can be looked up, and the report's points mapped back out. In + * an ordinary world the two frames are the same thing, which means every assertion fired at ordinary + * blocks would pass just as happily with the mapping deleted — that is precisely why this test exists + * as a separate class rather than another case beside them.

    + * + *

    The arrangement has to MOVE the ship

    + *

    A freshly assembled ship sits at its build site with an identity transform, where world and + * subspace still coincide. Testing there would be the same empty test with more steps. So the ship is + * rigid-teleported far away first, and the test asserts as a control that the two frames have + * genuinely diverged before it draws any conclusion from what follows.

    + */ +public class VSShipStructuralDamageE2ETest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + + /** Build site, well clear of the other ship scenarios on this shared server. */ + private static final int SRC_X = 7200, SRC_Y = 80, SRC_Z = 7200; + /** Where the ship is moved to. Far enough that no world-frame accident could reach the hull. */ + private static final int FAR_X = 7200, FAR_Y = 240, FAR_Z = 9600; + /** Below this the two frames have not diverged enough for the control to mean anything. */ + private static final double MIN_FRAME_DIVERGENCE = 100.0D; + + @Test + public void aBlockOfAMovedShipIsDamagedThroughItsWorldPosition() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest damage clear-impacts"); + + // Build a ship and move it, so world and subspace no longer coincide. + clearArea(SRC_X, SRC_Z); + String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat"); + String asm = exec("artest rocket assemble 0 " + coords); + assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm, + asm.contains("\"rocketCount\":0")); + assertTrue("the ship never loaded", waitForLoadedShip(0) >= 1); + + String info = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z); + assertTrue("ship not managed by VS: " + info, info.contains("\"managed\":true")); + String shipId = extractString(info, "id"); + String tp = exec("artest vs teleport-ship 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z + + " " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the ship could not be moved, so the frames never diverged: " + tp, + tp.contains("\"ok\":true")); + exec("artest vs unpark 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + + // A block of this ship whose subspace address we know: its pilot seat. + String seat = exec("artest vs find-seat 0 id " + shipId); + assertTrue("could not locate the ship's seat, so there is no known block to aim at: " + seat, + seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), subZ = extractInt(seat, "seatZ"); + + String mapped = exec("artest vs to-world 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z + + " " + subX + " " + subY + " " + subZ); + assertTrue("the seat's subspace address could not be mapped to a world point: " + mapped, + mapped.contains("\"ok\":true")); + double worldX = extractDouble(mapped, "worldX"); + double worldY = extractDouble(mapped, "worldY"); + double worldZ = extractDouble(mapped, "worldZ"); + + // ARRANGEMENT CONTROL. The mapped point must actually be on the ship as the world sees it; + // if it is not, everything below measures a broken fixture rather than the damage engine. + String moved = exec("artest vs ship-info 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the moved ship is not managed at its new position: " + moved, + moved.contains("\"managed\":true")); + double shipX = extractDouble(moved, "posX"), shipY = extractDouble(moved, "posY"), + shipZ = extractDouble(moved, "posZ"); + double offHull = Math.sqrt(sq(worldX - shipX) + sq(worldY - shipY) + sq(worldZ - shipZ)); + assertTrue("the seat's mapped world point (" + worldX + "," + worldY + "," + worldZ + ") is " + + offHull + " blocks from the ship's own world position (" + shipX + "," + shipY + "," + + shipZ + "): the fixture, not the engine, is what this run would be measuring." + + " subspace seat=" + subX + "," + subY + "," + subZ + " mapped=" + mapped, + offHull < 64.0D); + + // THE CONTROL. Everything below is only evidence if the two frames actually differ: at the + // build site they coincide, and an impact declared in world coordinates would land on the + // right block by accident, with the conversion deleted. + double divergence = Math.sqrt(sq(worldX - subX) + sq(worldY - subY) + sq(worldZ - subZ)); + assertTrue("world and subspace frames are only " + divergence + " blocks apart (world " + + worldX + "," + worldY + "," + worldZ + " vs subspace " + subX + "," + subY + "," + + subZ + "): this arrangement cannot tell a correct conversion from no conversion", + divergence > MIN_FRAME_DIVERGENCE); + + // The subject block, read at its SUBSPACE address — where a ship's blocks actually are. + String before = stage(subX, subY, subZ); + assertTrue("the seat's subspace address holds no block, so nothing below is about the ship: " + + before, !before.contains("\"block\":\"minecraft:air\"")); + assertTrue("the subject block is damaged before the impact: " + before, + readLong(before, "stage") == 0); + + // Fire straight down through the seat's WORLD position with a budget that will not be spent + // in one block, and give it an identity of its own. + String result = exec("artest damage impact 0 " + worldX + " " + (worldY + 3.0D) + " " + worldZ + + " 0 -1 0 200000 KINETIC 77001"); + assertTrue("the impact point resolved to no ship at all (candidates offered: " + + readLong(result, "candidateShips") + "), so the engine walked the world frame where " + + "this ship has no blocks:\n" + result, result.contains("\"onShip\":true")); + assertTrue("an impact at the ship's world position struck nothing — the world point was not " + + "mapped into the frame the ship's blocks live in:\n" + result, + !result.contains("\"outcome\":\"NOTHING_STRUCK\"")); + assertTrue("the impact spent nothing on the ship:\n" + result, readLong(result, "spent") > 0); + + // The damage landed on the SHIP's own block, at its subspace address. + String after = stage(subX, subY, subZ); + boolean staged = readLong(after, "stage") > 0; + boolean destroyed = after.contains("\"wasDestroyed\":true") + || after.contains("\"block\":\"minecraft:air\""); + assertTrue("the impact reported damage but the ship's own block is untouched at its subspace " + + "address (before=" + before + " after=" + after + "):\n" + result, staged || destroyed); + + // And the report comes back in WORLD coordinates: a shot that resumes on a subspace point + // would carry on inside a shipyard nobody can see. + assertTrue("the report names no entry point:\n" + result, result.contains("\"hasEntry\":true")); + double entryY = extractDouble(result, "entryY"); + assertTrue("the entry point came back at " + entryY + ", nowhere near the world position it " + + "was fired at (" + worldY + "): the report was not mapped back out of the ship frame", + Math.abs(entryY - worldY) < 8.0D); + } + + private String stage(int x, int y, int z) throws Exception { + return exec("artest damage stage 0 " + x + " " + y + " " + z); + } + + private static double sq(double v) { + return v * v; + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + private int waitForLoadedShip(int dim) throws Exception { + for (int i = 0; i < 40; i++) { + if (extractInt(exec("artest vs ship-count-all " + dim), "count") >= 1) { + exec("artest vs load-ships " + dim); + int loaded = extractInt(exec("artest vs ship-count " + dim), "count"); + if (loaded >= 1) { + return loaded; + } + } + Thread.sleep(250); + } + return 0; + } + + private void clearArea(int baseX, int baseZ) throws Exception { + int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; + int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; + assertTrue("chunk warmup failed", + exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2).contains("\"ok\":true")); + assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (SRC_Y - 2) + " " + (baseZ - 4) + + " " + (baseX + 20) + " " + (SRC_Y + 12) + " " + (baseZ + 20) + " minecraft:air").contains("\"ok\":true")); + } + + private String placeFixture(int baseX, int baseY, int baseZ, String variant) throws Exception { + String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " " + variant); + assertTrue("fixture (" + variant + ") failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("fixture (" + variant + ") missing builderPos: " + fixture, bp.find()); + return bp.group(1) + " " + bp.group(2) + " " + bp.group(3); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static double extractDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : 0.0; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactDeclarationContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactDeclarationContractTest.java new file mode 100644 index 000000000..9c163043d --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactDeclarationContractTest.java @@ -0,0 +1,92 @@ +package zmaster587.advancedRocketry.test.unit; + +import com.github.stannismod.affs.world.shield.ShieldStrikeKind; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.DamageReport; +import zmaster587.advancedRocketry.api.damage.ImpactKind; +import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.api.damage.SelectionMode; +import zmaster587.advancedRocketry.api.damage.StopReason; +import zmaster587.advancedRocketry.damage.ImpactKindMapping; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * What a declared impact promises before any world is involved — the half of the damage seam that a + * weapon can rely on without a server running. + * + *

    The load-bearing one is the kind mapping. A hull kind that no shell knows how to bill is not a + * missing feature, it is a shot that costs a shield nothing and a hull everything; the mapping is + * many-to-two on purpose, and the test that matters is that it is total, not what any + * particular row says.

    + */ +public class ImpactDeclarationContractTest { + + @Test + public void everyHullImpactKindDeclaresHowAShellBillsIt() { + for (ImpactKind kind : ImpactKind.values()) { + ShieldStrikeKind billed = ImpactKindMapping.toShieldKind(kind); + assertNotNull("impact kind " + kind + " has no shield billing — a kind a shell cannot " + + "charge for passes a raised shield free of charge", billed); + } + } + + @Test + public void matterBearingKindsBillAsPhysicalAndRadiationAsEnergy() { + // Not a pin on the individual rows for their own sake: this is the property the resistance + // bias exists to express. A ship tuned against beams must not thereby resist slugs. + assertEquals(ShieldStrikeKind.KINETIC, ImpactKindMapping.toShieldKind(ImpactKind.KINETIC)); + assertEquals(ShieldStrikeKind.KINETIC, ImpactKindMapping.toShieldKind(ImpactKind.EXPLOSIVE)); + assertEquals(ShieldStrikeKind.RADIANT, ImpactKindMapping.toShieldKind(ImpactKind.THERMAL)); + assertEquals(ShieldStrikeKind.RADIANT, ImpactKindMapping.toShieldKind(ImpactKind.BEAM)); + } + + @Test + public void aDeclaredImpactCarriesAUnitDirectionWhateverTheCallerHandedIt() { + ImpactRequest request = ImpactRequest.penetrating(1L, new Vec3d(0, 64, 0), + new Vec3d(0, 0, -37.5D), 5000, ImpactKind.KINETIC); + Vec3d dir = request.getDirection(); + double length = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z); + assertEquals("a direction reaches the engine as a unit vector, so nothing downstream has to " + + "guess whether the caller's magnitude meant anything", 1.0D, length, 1.0E-9D); + assertTrue("the sense of the direction must survive normalisation", dir.z < 0.0D); + } + + @Test + public void aDegenerateDirectionDoesNotBecomeAnArbitraryOne() { + // The engine refuses to walk a zero direction. Inventing one here would send a shot off in a + // direction nobody asked for, which is worse than doing nothing. + ImpactRequest request = ImpactRequest.penetrating(1L, new Vec3d(0, 64, 0), + new Vec3d(0, 0, 0), 5000, ImpactKind.KINETIC); + Vec3d dir = request.getDirection(); + assertEquals(0.0D, dir.x + dir.y + dir.z, 0.0D); + } + + @Test + public void aRefusedDuplicateSpendsNothingAndHandsTheWholeBudgetBack() { + DamageReport report = DamageReport.duplicate(7000); + assertEquals(DamageOutcome.NOTHING_STRUCK, report.getOutcome()); + assertEquals(StopReason.DUPLICATE_IMPACT, report.getStopReason()); + assertEquals("a refused duplicate must not charge the caller a second time", 0, + report.getBudgetSpent()); + assertEquals("the caller keeps its budget, so a retry that meets the refusal is not a loss", + 7000, report.getBudgetLeft()); + assertEquals(0, report.getBlocksStaged()); + assertEquals(0, report.getBlocksDestroyed()); + assertNull(report.getEntryPoint()); + } + + @Test + public void aRequestWithNoModeStatedResolvesAsPenetrating() { + // The by-point call is the one weapons make; defaulting it to the geometric mode keeps a + // caller that forgot from silently getting the star's flank-bathing behaviour instead. + ImpactRequest request = new ImpactRequest(1L, new Vec3d(0, 64, 0), new Vec3d(1, 0, 0), + 100, ImpactKind.KINETIC, null); + assertEquals(SelectionMode.PENETRATING, request.getSelectionMode()); + } +} From a5e1750c689c1f4ee7c3c03a39100b663d71aa55 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 15 Aug 2026 12:19:41 +0300 Subject: [PATCH 03/35] feat: damage travels with the structure, not with a block - keep the damage map per-world; no block owns it - carry records through capture/paste, VS assembly and deconstruction - sweep destroyed positions as a region: holes have no block to ride - select over the cut box, measure from the block bounds - forget a record when a player breaks or replaces that block - add a damage records probe, a relocation e2e and layer unit tests --- .../advancedRocketry/AdvancedRocketry.java | 2 + .../command/test/TestProbeCommand.java | 37 ++ .../damage/BlockDamageSavedData.java | 64 ++- .../damage/DamageInvalidationHandler.java | 45 +++ .../advancedRocketry/damage/DamageLayer.java | 162 ++++++++ .../advancedRocketry/damage/DamageState.java | 43 ++ .../advancedRocketry/util/StorageChunk.java | 46 ++- .../ShipDamageSurvivesRelocationE2ETest.java | 368 ++++++++++++++++++ .../test/unit/DamageLayerTest.java | 121 ++++++ .../ships/block_relocation/MoveBlocks.java | 5 + .../ship_world/WorldServerShipManager.java | 36 ++ 11 files changed, 923 insertions(+), 6 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/DamageInvalidationHandler.java create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/DamageLayer.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShipDamageSurvivesRelocationE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/DamageLayerTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index c6728d3bb..e6c59c931 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -1188,6 +1188,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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 382a8812d..b278afacd 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -713,6 +713,43 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ send(sender, jsonMap(info)); 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. diff --git a/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java b/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java index e6a78bdfa..019ae8b55 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/BlockDamageSavedData.java @@ -9,7 +9,9 @@ import net.minecraft.world.storage.MapStorage; import net.minecraft.world.storage.WorldSavedData; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -31,11 +33,12 @@ * state id: ids are an install-local encoding and a save that outlives one registry order would * otherwise rebuild a hull out of whatever now occupies that number.

    * - *

    Known limit — this store is per WORLD, and a ship can leave its world

    - *

    Entries are keyed by position in the world the blocks currently occupy. A ship that crosses into - * another world is re-pasted at fresh coordinates, and these entries do not follow it: its damage is - * left behind. Carrying the map across a crossing is owed work, not a decision — until it lands, a - * crossed ship reads as pristine.

    + *

    This store is per WORLD, and a structure can leave its world

    + *

    Entries are keyed by position in the world the blocks currently occupy, so they are stable for + * as long as the blocks are: a ship moves by transform, not by moving its blocks. A structure that is + * relocated IS re-pasted at fresh coordinates, and there the entries are carried by + * {@link DamageLayer} — harvested into the capture, expressed as offsets, and replayed at the far + * end. No block owns the map, so no block can be broken to reset it.

    */ public class BlockDamageSavedData extends WorldSavedData { @@ -125,11 +128,62 @@ public void clear(BlockPos pos) { } } + /** + * Give {@code to} exactly what {@code from} had, and leave {@code from} with nothing — for a block + * that was relocated rather than repaired or rebuilt. + * + *

    "Exactly what it had" includes having had NOTHING: an undamaged block arriving at a position + * some earlier structure left a record at must read as undamaged, so the absent case clears the + * destination instead of returning early. Neither argument is retained, so a caller may pass the + * mutable cursor it is iterating with.

    + */ + public void move(BlockPos from, BlockPos to) { + if (from == null || to == null || from.equals(to)) { + return; + } + Entry entry = entries.remove(from.toLong()); + if (entry == null) { + clear(to); + return; + } + entries.put(to.toLong(), entry); + markDirty(); + } + /** How many positions this world currently holds damage for (diagnostics and tests). */ public int size() { return entries.size(); } + /** + * Every damaged position inside the inclusive box. Walks the entries rather than the volume: a + * capture box is tens of thousands of positions and almost none of them are damaged, so the cost + * belongs to what is recorded, not to how big the structure is. + */ + public List positionsIn(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + List found = new ArrayList<>(); + for (Long key : entries.keySet()) { + BlockPos pos = BlockPos.fromLong(key); + if (pos.getX() >= minX && pos.getX() <= maxX + && pos.getY() >= minY && pos.getY() <= maxY + && pos.getZ() >= minZ && pos.getZ() <= maxZ) { + found.add(pos); + } + } + return found; + } + + /** + * Forget every position inside the inclusive box — what a relocation's CUT does to the region it + * empties. Without it the vacated coordinates keep their damage, and the next structure pasted + * over them inherits somebody else's holes. + */ + public void clearBox(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + for (BlockPos pos : positionsIn(minX, minY, minZ, maxX, maxY, maxZ)) { + clear(pos); + } + } + @Override public void readFromNBT(NBTTagCompound nbt) { entries.clear(); diff --git a/src/main/java/zmaster587/advancedRocketry/damage/DamageInvalidationHandler.java b/src/main/java/zmaster587/advancedRocketry/damage/DamageInvalidationHandler.java new file mode 100644 index 000000000..85faacd82 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/DamageInvalidationHandler.java @@ -0,0 +1,45 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.event.world.BlockEvent; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** + * Keeps the damage map honest about the blocks a PLAYER changes under it. + * + *

    Stages of plain blocks are stored by position, and a position outlives the block that was + * standing there. Nothing else notices a player's pickaxe: the damage engine writes through + * {@code setBlockState}, which fires neither of these events, and neither does a relocation's cut. + * So without this handler a record simply stays behind, and it is wrong in both directions — + * a freshly placed block reads as cracked (or as destroyed, if the record said so), and the crack + * a player mined out is still counted against the hull he just repaired.

    + * + *

    Clearing here also decides what hand-repair COSTS: replacing a damaged block is a real repair, + * paid for with the block itself, rather than an accounting trick that leaves the hull recorded as + * broken. The machine-driven repair path is a separate mechanic and is not affected.

    + * + *

    Lowest priority so that a break or place another handler vetoes never clears anything: a + * cancelled event is not delivered here at all, and running last means the veto has already + * happened.

    + */ +public class DamageInvalidationHandler { + + @SubscribeEvent(priority = EventPriority.LOWEST) + public void onBlockBroken(BlockEvent.BreakEvent event) { + forget(event.getWorld(), event.getPos()); + } + + @SubscribeEvent(priority = EventPriority.LOWEST) + public void onBlockPlaced(BlockEvent.PlaceEvent event) { + forget(event.getWorld(), event.getPos()); + } + + private static void forget(World world, BlockPos pos) { + if (world == null || pos == null || world.isRemote) { + return; + } + BlockDamageSavedData.get(world).clear(pos); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/DamageLayer.java b/src/main/java/zmaster587/advancedRocketry/damage/DamageLayer.java new file mode 100644 index 000000000..273c47308 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/DamageLayer.java @@ -0,0 +1,162 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.common.util.Constants.NBT; + +import java.util.ArrayList; +import java.util.List; + +/** + * The damage a captured structure carries with it: the stages and destruction provenance of its + * plain blocks, expressed as offsets from the capture box rather than as world coordinates. + * + *

    Why this exists at all

    + *

    A block's stage lives in one of two homes — a tile entity that can hold its own wear, or the + * per-world {@link BlockDamageSavedData} for everything else. The tile half already travels: a + * relocation copies tile NBT verbatim. The other half does not, because it is keyed by position in + * the world the blocks occupy, and a relocated structure is re-pasted at fresh coordinates. Without + * this layer a ship that jumps arrives pristine — a free repair for the price of a jump.

    + * + *

    Why the damage is NOT hung on a block

    + *

    Handing the whole map to one tile's NBT (the flight computer, say) would make it travel for + * free, and would also make a single breakable block the custodian of every other block's state: + * mine that block, put it back, and the hull is whole again. Damage is a property of the structure, + * so it moves through the channel the structure itself moves through and no block owns it.

    + * + *

    Frame

    + *

    Offsets are relative to the minimum corner of the box that was captured — the same origin the + * block array and the tile coordinates use — so the layer survives being pasted anywhere, in any + * world, exactly as the blocks do. It deliberately carries destroyed positions too (air in the + * snapshot, provenance in the entry), or a rebuild at the far end would have nothing to put back.

    + */ +public final class DamageLayer { + + private static final String NBT_LIST = "damageLayer"; + + private final List entries = new ArrayList<>(); + + /** + * Everything {@code world} records inside the SELECTION box, as offsets from the ORIGIN. + * + *

    The two are given separately because they are genuinely different boxes, and conflating them + * silently drops damage. A capture's origin is its tight block bounds — that is where the blocks + * will be laid down again — but those bounds are computed from blocks that still EXIST, and the + * interesting records are exactly the positions whose block does not. Shoot the outermost column + * of a hull away and the tight bounds shrink inside it, leaving that column's records outside a + * box drawn from them, while the cut that follows still clears the wider region. Selection must + * therefore cover everything the caller is about to empty; an offset outside the block volume is + * legitimate, and negative components are expected.

    + */ + public static DamageLayer harvest(World world, int minX, int minY, int minZ, + int maxX, int maxY, int maxZ, + int originX, int originY, int originZ) { + if (world == null || world.isRemote) { + return new DamageLayer(); + } + return harvest(BlockDamageSavedData.get(world), minX, minY, minZ, maxX, maxY, maxZ, + originX, originY, originZ); + } + + /** The same, against the map itself — the world is only how the map is found. */ + public static DamageLayer harvest(BlockDamageSavedData data, int minX, int minY, int minZ, + int maxX, int maxY, int maxZ, + int originX, int originY, int originZ) { + DamageLayer layer = new DamageLayer(); + for (BlockPos pos : data.positionsIn(minX, minY, minZ, maxX, maxY, maxZ)) { + Entry entry = new Entry(); + entry.offset = new BlockPos(pos.getX() - originX, pos.getY() - originY, pos.getZ() - originZ); + entry.stage = data.getStage(pos); + entry.originalBlock = data.getDestroyedBlockName(pos); + entry.originalMeta = data.getDestroyedMeta(pos); + layer.entries.add(entry); + } + return layer; + } + + /** True when the captured structure was pristine — the common case, and the cheap one. */ + public boolean isEmpty() { + return entries.isEmpty(); + } + + /** How many damaged positions this layer carries (diagnostics and tests). */ + public int size() { + return entries.size(); + } + + /** + * Write these entries into {@code world}'s damage map, with the layer's origin placed at + * {@code (x,y,z)}. Callers paste blocks first: a position this layer does not mention keeps + * whatever the destination already said about it, which is why a paste also clears the + * positions it overwrites. + */ + public void applyAt(World world, int x, int y, int z) { + if (world == null || world.isRemote || entries.isEmpty()) { + return; + } + applyTo(BlockDamageSavedData.get(world), x, y, z); + } + + /** The same, against the map itself. */ + public void applyTo(BlockDamageSavedData data, int x, int y, int z) { + for (Entry entry : entries) { + BlockPos pos = new BlockPos(x + entry.offset.getX(), + y + entry.offset.getY(), + z + entry.offset.getZ()); + data.setStage(pos, entry.stage); + if (entry.originalBlock != null) { + data.recordDestroyed(pos, BlockDamageSavedData.blockFromName(entry.originalBlock), + entry.originalMeta); + } + } + } + + /** Absent key means "this structure was captured undamaged", not a malformed tag. */ + public void writeToNBT(NBTTagCompound nbt) { + if (entries.isEmpty()) { + return; + } + NBTTagList list = new NBTTagList(); + for (Entry entry : entries) { + NBTTagCompound tag = new NBTTagCompound(); + tag.setLong("off", entry.offset.toLong()); + tag.setInteger("stage", entry.stage); + if (entry.originalBlock != null) { + tag.setString("block", entry.originalBlock); + tag.setInteger("meta", entry.originalMeta); + } + list.appendTag(tag); + } + nbt.setTag(NBT_LIST, list); + } + + /** The inverse of {@link #writeToNBT}; an empty layer when the tag carries none. */ + public static DamageLayer readFromNBT(NBTTagCompound nbt) { + DamageLayer layer = new DamageLayer(); + if (nbt == null || !nbt.hasKey(NBT_LIST)) { + return layer; + } + NBTTagList list = nbt.getTagList(NBT_LIST, NBT.TAG_COMPOUND); + for (int i = 0; i < list.tagCount(); i++) { + NBTTagCompound tag = list.getCompoundTagAt(i); + Entry entry = new Entry(); + entry.offset = BlockPos.fromLong(tag.getLong("off")); + entry.stage = tag.getInteger("stage"); + if (tag.hasKey("block")) { + entry.originalBlock = tag.getString("block"); + entry.originalMeta = tag.getInteger("meta"); + } + layer.entries.add(entry); + } + return layer; + } + + private static final class Entry { + private BlockPos offset; + private int stage; + private String originalBlock; + private int originalMeta; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java index 51f0fb477..3603e013b 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java @@ -62,6 +62,49 @@ public static void setStage(World world, BlockPos pos, int stage) { BlockDamageSavedData.get(world).setStage(pos, stage); } + /** + * A block was RELOCATED from {@code from} to {@code to} in the same world: carry its damage with + * it. The seam every block-moving mechanism owes this map, and the reason it must exist is that + * the map is keyed by position while the thing it describes is a block — move one without the + * other and the damage is either lost (a free repair) or inherited by an innocent block. + * + *

    A block that carries its own wear needs nothing here: its stage lives in its tile, and a + * relocation that does not carry tile state has lost far more than a crack. The call is still + * correct for it — clearing whatever the destination's map said is exactly right.

    + */ + public static void blockMoved(World world, BlockPos from, BlockPos to) { + if (world == null || world.isRemote || from == null || to == null) { + return; + } + BlockDamageSavedData.get(world).move(from, to); + } + + /** + * Carry the records of positions that hold no block — the HOLES a weapon left in a structure — + * from the region {@code min..max} to the same region {@code offset} away. + * + *

    {@link #blockMoved} covers everything a relocation enumerates, and a relocation enumerates + * blocks. A destroyed position has none: what it keeps is the note of what used to stand there, + * which is what lets a repair put the right block back. Without this the note stays on the empty + * ground the structure left, and a hull that crossed can be patched only with guesses.

    + * + *

    Call AFTER the per-block moves, so that what is left in the region is holes rather than + * blocks already carried. A position still holding a block belongs to something that was not + * relocated — a neighbour standing inside the same box — and is deliberately left alone.

    + */ + public static void holesMoved(World world, BlockPos min, BlockPos max, BlockPos offset) { + if (world == null || world.isRemote || min == null || max == null || offset == null) { + return; + } + BlockDamageSavedData data = BlockDamageSavedData.get(world); + for (BlockPos pos : data.positionsIn(min.getX(), min.getY(), min.getZ(), + max.getX(), max.getY(), max.getZ())) { + if (world.isAirBlock(pos)) { + data.move(pos, pos.add(offset)); + } + } + } + /** True when this position is at its terminal stage — the block is gone, not merely cracked. */ public static boolean isDestroyed(World world, BlockPos pos) { return getStage(world, pos) >= getMaxStage(world, pos); diff --git a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java index 3cefd3b8e..70213721e 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java +++ b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java @@ -40,6 +40,8 @@ import zmaster587.advancedRocketry.api.stations.IStorageChunk; import zmaster587.advancedRocketry.atmosphere.AtmosphereHandler; import zmaster587.advancedRocketry.block.*; +import zmaster587.advancedRocketry.damage.BlockDamageSavedData; +import zmaster587.advancedRocketry.damage.DamageLayer; import zmaster587.advancedRocketry.item.ItemPackedStructure; import zmaster587.advancedRocketry.api.capability.CapabilityWear; import zmaster587.advancedRocketry.api.capability.IPartWear; @@ -88,6 +90,14 @@ public static boolean isRelocationInProgress() { private float weight; private boolean hasServiceMonitor; + /** + * The stages and destruction provenance of the captured blocks that cannot hold their own — the + * half of a structure's damage that does not ride along in tile NBT. Captured with the blocks, + * carried in this chunk's NBT and replayed at the paste site, so a relocated structure arrives + * as battered as it left. Empty for a pristine capture, which is the ordinary case. + */ + private DamageLayer damage = new DamageLayer(); + public Block[][][] getblocks() { return blocks; } @@ -451,6 +461,14 @@ public static StorageChunk copyWorldBB(World world, AxisAlignedBB bb) { } ret.weight = weight; + // SELECTED over the caller's whole box, because a cut empties all of it and a record left + // out here is a record destroyed. MEASURED from the tight bounds, because that is the origin + // the block array and the transformed tile coordinates above already use. The two differ + // exactly where it matters: tight bounds are drawn around blocks that still exist, and a + // shot-away outer column leaves its records outside them. + ret.damage = DamageLayer.harvest(world, (int) bb.minX, (int) bb.minY, (int) bb.minZ, + (int) bb.maxX, (int) bb.maxY, (int) bb.maxZ, + actualMinX, actualMinY, actualMinZ); return ret; } @@ -482,6 +500,14 @@ public static StorageChunk cutWorldBB(World worldObj, AxisAlignedBB bb) { relocationDepth--; } + // The cut region is air now, and its damage went into the copy above. Leaving the entries + // behind would hand them to whatever is pasted here next - in a shipyard, that is the very + // next ship to be assembled at these coordinates. + if (!worldObj.isRemote) { + BlockDamageSavedData.get(worldObj).clearBox((int) bb.minX, (int) bb.minY, (int) bb.minZ, + (int) bb.maxX, (int) bb.maxY, (int) bb.maxZ); + } + //Carpenter's block's dupe for (Entity entity : worldObj.getEntitiesWithinAABB(EntityItem.class, bb.grow(5, 5, 5))) { entity.setDead(); @@ -702,6 +728,7 @@ public void writeToNBT(NBTTagCompound nbt) { nbt.setTag("idList", idList); nbt.setTag("metaList", metaList); nbt.setTag("tiles", tileList); + damage.writeToNBT(nbt); } public void readFromNBT(NBTTagCompound nbt) { @@ -713,6 +740,7 @@ public void readFromNBT(NBTTagCompound nbt) { sizeZ = nbt.getInteger("zSize"); weight = nbt.getFloat("weight"); hasServiceMonitor = nbt.getBoolean("hasServiceMonitor"); + damage = DamageLayer.readFromNBT(nbt); blocks = new Block[sizeX][sizeY][sizeZ]; metas = new short[sizeX][sizeY][sizeZ]; @@ -785,6 +813,13 @@ public void pasteInWorld(World world, int xCoord, int yCoord, int zCoord) { // rocket cargo carrying any of them lost those blocks the moment it landed: the pilot seat // (cloth) was replaced by fire before its tile was restored, which left the arriving craft // with no seat at all and its crew with nowhere to sit. + // A position this paste WRITES gets a block that has never been shot, so whatever the + // destination recorded there belongs to something that used to stand here. Cleared per + // written block rather than over the whole footprint: the air gaps of an arriving structure + // are not its business, and a damaged wall standing inside them keeps its record. + BlockDamageSavedData destinationDamage = + world.isRemote ? null : BlockDamageSavedData.get(world); + AtmosphereHandler.beginStructurePaste(); try { //Set all the blocks @@ -793,12 +828,21 @@ public void pasteInWorld(World world, int xCoord, int yCoord, int zCoord) { for (int y = 0; y < sizeY; y++) { if (blocks[x][y][z] != Blocks.AIR) { - world.setBlockState(new BlockPos(xCoord + x, yCoord + y, zCoord + z), blocks[x][y][z].getStateFromMeta(metas[x][y][z]), 2); + BlockPos target = new BlockPos(xCoord + x, yCoord + y, zCoord + z); + world.setBlockState(target, blocks[x][y][z].getStateFromMeta(metas[x][y][z]), 2); + if (destinationDamage != null) { + destinationDamage.clear(target); + } } } } } + // Now the structure's own damage, on top of the clean slate just laid down. After the + // blocks, because a destroyed position is air here and carries only its provenance - + // there is no block arriving to clear it. + damage.applyAt(world, xCoord, yCoord, zCoord); + //Set tiles for each block for (TileEntity tile : tileEntities) { NBTTagCompound nbt = new NBTTagCompound(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShipDamageSurvivesRelocationE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShipDamageSurvivesRelocationE2ETest.java new file mode 100644 index 000000000..5979c8870 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShipDamageSurvivesRelocationE2ETest.java @@ -0,0 +1,368 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A damaged ship that is RELOCATED stays damaged, and leaves nothing of its damage behind. + * + *

    The stage of a block with no tile of its own is held by position, in the world the blocks + * occupy. A ship's blocks live at fixed addresses in a shipyard subspace, and a relocation does not + * move them — it cuts them out, pastes copies at fresh coordinates and assembles those into a new + * ship at a new subspace address. Every one of those steps is a place where a position-keyed record + * can be left behind, and a hull that arrives pristine is a repair the player did not pay for.

    + * + *

    Why the count, and not one block's stage

    + *

    The subject is what the STRUCTURE carries. Asserting one known block's stage would pass a + * relocation that carried that block and dropped the other eleven, and the damage engine spreads a + * shot over whatever the ray meets. So the reading is the whole record set of the ship's own yard, + * compared as a multiset of stages before and after — position-independent on purpose, since the + * whole point is that the positions change.

    + * + *

    The controls

    + *

    Two, and the test is worthless without them. The ship's subspace address must genuinely CHANGE + * across the relocation, or both readings are of the same box and nothing was proven. And the source + * yard must be EMPTY afterwards: a reading that only checks the destination cannot tell a carry from + * a copy, and a copy leaves records at coordinates the next ship assembled there would inherit.

    + */ +public class ShipDamageSurvivesRelocationE2ETest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + + /** Build site, clear of the other ship scenarios on this shared server. */ + private static final int SRC_X = 7600, SRC_Y = 80, SRC_Z = 7200; + /** Where the relocation puts the ship down. Far enough that its new yard cannot be the old one. */ + private static final int DST_X = 7600, DST_Y = 96, DST_Z = 7400; + /** A second build site, for the scenario that mines a block out of a ship instead of moving it. */ + private static final int AFC_X = 7800, AFC_Y = 80, AFC_Z = 7200; + + /** + * Half-width of the box a yard is read through, around a block known to belong to the ship. + * Derived from the fixture (~20 blocks across) against the separation between shipyards, which + * are a chunk claim apart: a 64-block cube around one of this ship's blocks lies inside this + * ship's own yard and cannot reach a neighbour's. + */ + private static final int YARD_PROBE_RADIUS = 64; + + /** + * How far from a build site a "nearest ship" answer may sit and still be the ship this test + * built. Derived from the fixture's ~20-block span against the 200-block spacing between the + * sites this class uses; beyond it the reply is about a neighbour. + */ + private static final double NEAREST_SHIP_IS_OURS_WITHIN = 64.0D; + + /** + * The subject block, and it has to be ADDED: the rocket fixture is built entirely out of machines, + * every one of which carries its own wear in its own tile NBT — which travels with the tile and so + * says nothing about the map this test is about. A plain block has nowhere to put a stage, which is + * the whole reason the map exists, so the craft is given one. + * + *

    Placed face-adjacent to the pilot seat, before assembly, so the assembly flood-fill welds it + * into the ship instead of leaving it standing on the pad. The fixture's own geometry is fixed + * ({@code rocket = base + (3,1,3)}, seat at {@code rocket + (0,4,0)}), so this cell is one step + * east of the seat both on the pad and, since assembly shifts every block by the same offset, in + * the shipyard. + */ + private static final String PLAIN_SUBJECT_BLOCK = "minecraft:iron_block"; + + @Test + public void aRelocatedShipCarriesItsDamageAndLeavesNoneBehind() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest damage clear-impacts"); + + clearArea(SRC_X, SRC_Y, SRC_Z); + clearArea(DST_X, DST_Y, DST_Z); + String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat"); + addPlainSubjectBlock(SRC_X, SRC_Y, SRC_Z); + String asm = exec("artest rocket assemble 0 " + coords); + assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm, + asm.contains("\"rocketCount\":0")); + assertTrue("the ship never loaded", waitForLoadedShip(0) >= 1); + + double[] pose = shipPose(SRC_X, SRC_Y, SRC_Z); + + // A block of this ship whose subspace address we know, used only as the anchor the yard is + // read around. The shot below is aimed away from it so that it survives to be that anchor. + String seat = exec("artest vs find-seat 0 " + (int) pose[0] + " " + (int) pose[1] + " " + (int) pose[2]); + assertTrue("could not locate the ship's seat, so there is no anchor for the yard: " + seat, + seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), subZ = extractInt(seat, "seatZ"); + + String result = shootThePlainBlock(pose, subX, subY, subZ, 77101); + assertTrue("the impact spent nothing, so there is no damage to carry:\n" + result, + readLong(result, "spent") > 0); + + List before = yardDamage(subX, subY, subZ); + assertTrue("the shot left no record in the ship's yard, so this run measures nothing: it " + + "struck only blocks that carry their own wear, or none at all.\n" + result, + !before.isEmpty()); + String beforeRaw = rawYardDamage(subX, subY, subZ); + + // THE RELOCATION — production's own crossing recipe: cut, paste, re-assemble. + String repack = exec("artest vs ship-repack 0 " + pose[0] + " " + pose[1] + " " + pose[2] + + " " + DST_X + " " + DST_Y + " " + DST_Z); + assertTrue("the ship was not relocated, so there is nothing to measure: " + repack, + repack.contains("\"ok\":true")); + assertTrue("the relocated ship never loaded", waitForLoadedShip(0) >= 1); + + double[] movedPose = shipPose(DST_X, DST_Y, DST_Z); + String movedSeat = exec("artest vs find-seat 0 " + (int) movedPose[0] + " " + + (int) movedPose[1] + " " + (int) movedPose[2]); + assertTrue("could not locate the relocated ship's seat: " + movedSeat, + movedSeat.contains("\"seatFound\":true")); + int newSubX = extractInt(movedSeat, "seatX"); + int newSubY = extractInt(movedSeat, "seatY"); + int newSubZ = extractInt(movedSeat, "seatZ"); + + // ARRANGEMENT CONTROL. If the ship came back at the same subspace address, both readings are + // of the same box and this test would pass with every carry deleted. + assertTrue("the relocated ship kept its old subspace address (" + subX + "," + subY + "," + + subZ + "): both readings are of the same box, so nothing here is evidence", + newSubX != subX || newSubY != subY || newSubZ != subZ); + + List after = yardDamage(newSubX, newSubY, newSubZ); + // The paste site is named in the failure message because "the new yard is empty" has two very + // different causes — the capture never carried the records, or the assembly that followed did + // not take them along — and only a reading between the two steps tells them apart. + assertEquals("the relocated ship does not carry the damage it left with." + + "\n seat subspace before: " + subX + "," + subY + "," + subZ + + " after: " + newSubX + "," + newSubY + "," + newSubZ + + "\n records before: " + beforeRaw + + "\n records now in the new yard: " + rawYardDamage(newSubX, newSubY, newSubZ) + + "\n records now at the paste site (" + DST_X + "," + DST_Y + "," + DST_Z + "): " + + rawYardDamage(DST_X, DST_Y, DST_Z) + + "\n records now in the OLD yard: " + rawYardDamage(subX, subY, subZ), + before, after); + + // CONTROL. Carried, not copied: what stayed behind would be inherited by the next ship built + // at those coordinates. + List leftBehind = yardDamage(subX, subY, subZ); + assertTrue("the relocation left " + leftBehind.size() + " damage records at the vacated " + + "subspace address " + subX + "," + subY + "," + subZ + ": " + leftBehind, + leftBehind.isEmpty()); + } + + /** + * No single block is the custodian of the hull's condition — specifically not the flight computer, + * the one block a player can always reach and replace. + * + *

    The obvious home for a ship's damage map is the flight computer's NBT, because a relocation + * copies tile NBT verbatim and the map would travel for free. It would also mean that mining that + * one block and putting it back returns a wrecked hull to the showroom, which is why the map lives + * nowhere a player can pick up. This test is the pin on that: it fails the moment the map is moved + * onto any block's tile.

    + */ + @Test + public void breakingAndReplacingTheFlightComputerDoesNotRepairTheHull() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest damage clear-impacts"); + + clearArea(AFC_X, AFC_Y, AFC_Z); + String coords = placeFixture(AFC_X, AFC_Y, AFC_Z, "with-pilot-seat"); + addPlainSubjectBlock(AFC_X, AFC_Y, AFC_Z); + assertTrue("the build did not become a ship", + exec("artest rocket assemble 0 " + coords).contains("\"rocketCount\":0")); + assertTrue("the ship never loaded", waitForLoadedShip(0) >= 1); + + double[] pose = shipPose(AFC_X, AFC_Y, AFC_Z); + String seat = exec("artest vs find-seat 0 " + (int) pose[0] + " " + (int) pose[1] + " " + (int) pose[2]); + assertTrue("could not locate the ship's seat: " + seat, seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), subZ = extractInt(seat, "seatZ"); + + // The fixture puts the flight computer one block west of and one below the seat, and an + // assembly shifts every block of the craft by the same offset — so the relative layout, and + // this offset with it, is the same in the shipyard as it was on the pad. + int afcX = subX - 1, afcY = subY - 1, afcZ = subZ; + String afcState = exec("artest damage stage 0 " + afcX + " " + afcY + " " + afcZ); + String afcBlock = extractString(afcState, "block"); + // ARRANGEMENT CONTROL: without this the test would happily break a hull plate and prove nothing. + assertTrue("the derived offset does not point at the flight computer but at " + afcBlock + + " — the fixture layout this test assumes has changed", afcBlock != null + && afcBlock.toLowerCase(java.util.Locale.ROOT).contains("flightcomputer")); + + String result = shootThePlainBlock(pose, subX, subY, subZ, 77102); + assertTrue("the impact spent nothing, so there is no damage to preserve:\n" + result, + readLong(result, "spent") > 0); + + List before = yardDamage(subX, subY, subZ); + assertTrue("the shot left no record, so this run measures nothing:\n" + result, !before.isEmpty()); + + // Mine the flight computer out, and put an identical one back — the whole exploit, in two calls. + String afcPos = afcX + " " + afcY + " " + afcZ + " " + afcX + " " + afcY + " " + afcZ; + assertTrue("could not remove the flight computer", + exec("artest fill 0 " + afcPos + " minecraft:air").contains("\"ok\":true")); + assertEquals("the flight computer is still standing, so nothing was removed", + "minecraft:air", extractString(exec("artest damage stage 0 " + afcX + " " + afcY + + " " + afcZ), "block")); + assertTrue("could not put the flight computer back", + exec("artest fill 0 " + afcPos + " " + afcBlock).contains("\"ok\":true")); + assertEquals("the replacement flight computer is not there", afcBlock, + extractString(exec("artest damage stage 0 " + afcX + " " + afcY + " " + afcZ), "block")); + + assertEquals("replacing the flight computer changed the hull's damage (before=" + before + + " after=" + yardDamage(subX, subY, subZ) + ")", before, yardDamage(subX, subY, subZ)); + } + + /** + * Weld one plain block onto the craft, face-adjacent to the pilot seat, before it is assembled. + * See {@link #PLAIN_SUBJECT_BLOCK} for why the fixture cannot supply one. + */ + private void addPlainSubjectBlock(int baseX, int baseY, int baseZ) throws Exception { + int x = baseX + 4, y = baseY + 5, z = baseZ + 3; // rocket+(1,4,0) — one east of the seat + String fill = exec("artest fill 0 " + x + " " + y + " " + z + " " + x + " " + y + " " + z + + " " + PLAIN_SUBJECT_BLOCK); + assertTrue("could not add the plain subject block: " + fill, fill.contains("\"ok\":true")); + } + + /** + * Fire straight down through the plain block welded beside the seat, and confirm the shot reached + * the ship. Aimed at that column rather than the seat's so the seat survives to be the anchor the + * yard is read around. + */ + private String shootThePlainBlock(double[] pose, int seatSubX, int seatSubY, int seatSubZ, int impactId) + throws Exception { + int subjX = seatSubX + 1, subjY = seatSubY, subjZ = seatSubZ; + String subject = exec("artest damage stage 0 " + subjX + " " + subjY + " " + subjZ); + // ARRANGEMENT CONTROL: if this is not the block we welded on, the shot below is aimed at + // whatever the fixture happens to put there, and a green run would prove nothing. + assertEquals("the cell east of the seat is not the plain block this test welded on — the " + + "fixture layout it assumes has changed", PLAIN_SUBJECT_BLOCK, + extractString(subject, "block")); + + String mapped = exec("artest vs to-world 0 " + pose[0] + " " + pose[1] + " " + pose[2] + + " " + subjX + " " + subjY + " " + subjZ); + assertTrue("the subject's subspace address could not be mapped to a world point: " + mapped, + mapped.contains("\"ok\":true")); + String result = exec("artest damage impact 0 " + extractDouble(mapped, "worldX") + " " + + (extractDouble(mapped, "worldY") + 6.0D) + " " + extractDouble(mapped, "worldZ") + + " 0 -1 0 200000 KINETIC " + impactId); + assertTrue("the impact resolved to no ship, so nothing on this hull was damaged:\n" + result, + result.contains("\"onShip\":true")); + return result; + } + + /** + * Where the ship built near the site actually IS, as VS sees it — which is not the build site: + * assembly gives the craft a pose of its own, and the frame conversions refuse a point no ship + * occupies. + * + *

    `ship-info` answers about the ship NEAREST the point, so the reply is checked against the + * site before it is believed. The bound is the fixture's own size (~20 blocks across) against the + * 200-block spacing between this class's build sites: a pose further away than this is a + * neighbouring scenario's ship, and every number derived from it would be about the wrong hull.

    + */ + private double[] shipPose(int siteX, int siteY, int siteZ) throws Exception { + String info = exec("artest vs ship-info 0 " + siteX + " " + siteY + " " + siteZ); + assertTrue("no ship is managed near (" + siteX + "," + siteY + "," + siteZ + "): " + info, + info.contains("\"managed\":true")); + double px = extractDouble(info, "posX"); + double py = extractDouble(info, "posY"); + double pz = extractDouble(info, "posZ"); + double away = Math.sqrt((px - siteX) * (px - siteX) + (py - siteY) * (py - siteY) + + (pz - siteZ) * (pz - siteZ)); + assertTrue("the nearest ship to (" + siteX + "," + siteY + "," + siteZ + ") sits " + away + + " blocks away at (" + px + "," + py + "," + pz + "): that is another scenario's " + + "ship, not the one this test built", away < NEAREST_SHIP_IS_OURS_WITHIN); + return new double[]{px, py, pz}; + } + + /** + * What the ship's yard holds, as a sorted multiset of "stage/destroyed" readings — deliberately + * without positions, because the positions are what a relocation changes. + */ + private List yardDamage(int anchorX, int anchorY, int anchorZ) throws Exception { + String json = rawYardDamage(anchorX, anchorY, anchorZ); + assertTrue("the damage records could not be read: " + json, json.contains("\"ok\":true")); + List readings = new ArrayList<>(); + Matcher m = Pattern.compile("\"stage\":(-?\\d+),\"wasDestroyed\":(true|false)," + + "\"destroyedBlock\":\"([^\"]*)\"").matcher(json); + while (m.find()) { + readings.add(m.group(1) + "/" + m.group(2) + "/" + m.group(3)); + } + assertEquals("the entry list and the count disagree: " + json, + extractInt(json, "count"), readings.size()); + Collections.sort(readings); + return readings; + } + + /** The same reading with its POSITIONS intact — for failure messages, where they are the evidence. */ + private String rawYardDamage(int anchorX, int anchorY, int anchorZ) throws Exception { + return exec("artest damage records 0 " + + (anchorX - YARD_PROBE_RADIUS) + " " + Math.max(0, anchorY - YARD_PROBE_RADIUS) + " " + + (anchorZ - YARD_PROBE_RADIUS) + " " + + (anchorX + YARD_PROBE_RADIUS) + " " + (anchorY + YARD_PROBE_RADIUS) + " " + + (anchorZ + YARD_PROBE_RADIUS)); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + private int waitForLoadedShip(int dim) throws Exception { + for (int i = 0; i < 40; i++) { + if (extractInt(exec("artest vs ship-count-all " + dim), "count") >= 1) { + exec("artest vs load-ships " + dim); + int loaded = extractInt(exec("artest vs ship-count " + dim), "count"); + if (loaded >= 1) { + return loaded; + } + } + Thread.sleep(250); + } + return 0; + } + + private void clearArea(int baseX, int baseY, int baseZ) throws Exception { + int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; + int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; + assertTrue("chunk warmup failed", + exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2).contains("\"ok\":true")); + assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (baseY - 2) + " " + (baseZ - 4) + + " " + (baseX + 20) + " " + (baseY + 12) + " " + (baseZ + 20) + " minecraft:air").contains("\"ok\":true")); + } + + private String placeFixture(int baseX, int baseY, int baseZ, String variant) throws Exception { + String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " " + variant); + assertTrue("fixture (" + variant + ") failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("fixture (" + variant + ") missing builderPos: " + fixture, bp.find()); + return bp.group(1) + " " + bp.group(2) + " " + bp.group(3); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static double extractDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : 0.0; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/DamageLayerTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/DamageLayerTest.java new file mode 100644 index 000000000..57a11f9a7 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/DamageLayerTest.java @@ -0,0 +1,121 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.BlockPos; +import org.junit.Test; + +import zmaster587.advancedRocketry.damage.BlockDamageSavedData; +import zmaster587.advancedRocketry.damage.DamageLayer; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The bookkeeping a relocation's damage carry is built out of, at the tier where it is cheap to check. + * + *

    The e2e above it needs a real server, a ship and five minutes; these are the same rules stated + * where a wrong answer costs a second. What they pin is deliberately narrow: which records a box + * selects, what a move does to the destination, and that a layer survives a round trip through NBT + * and lands where its new origin says. The behaviour of the relocation itself is not their business.

    + */ +public class DamageLayerTest { + + /** Shipyard-scale coordinates, because that is where a ship's blocks actually live. */ + private static final int YARD_X = 5_119_888, YARD_Y = 96, YARD_Z = 40_112; + + @Test + public void aBoxSelectsTheRecordsInsideItAndNoOthers() { + BlockDamageSavedData data = new BlockDamageSavedData(); + data.setStage(new BlockPos(YARD_X, YARD_Y, YARD_Z), 2); + data.setStage(new BlockPos(YARD_X + 3, YARD_Y + 1, YARD_Z - 2), 1); + data.setStage(new BlockPos(YARD_X + 500, YARD_Y, YARD_Z), 3); // another ship's yard + + List inside = data.positionsIn(YARD_X - 64, YARD_Y - 64, YARD_Z - 64, + YARD_X + 64, YARD_Y + 64, YARD_Z + 64); + + assertEquals("a 64-block box around one ship's yard must not reach the next one: " + inside, + 2, inside.size()); + } + + @Test + public void aRecordAtShipyardScaleSurvivesBeingKeyed() { + // The packed-position key has 26 bits for X and Z: a shipyard address is millions of blocks + // out, which is exactly where a naive key would wrap and put the record somewhere else. + BlockDamageSavedData data = new BlockDamageSavedData(); + BlockPos far = new BlockPos(YARD_X, YARD_Y, YARD_Z); + data.setStage(far, 3); + + assertEquals("the stage did not come back from a shipyard-scale address", 3, data.getStage(far)); + assertEquals("the record is not selectable at the address it was written to", 1, + data.positionsIn(YARD_X, YARD_Y, YARD_Z, YARD_X, YARD_Y, YARD_Z).size()); + } + + @Test + public void movingARecordOntoAPositionThatHasNoneClearsTheDestination() { + BlockDamageSavedData data = new BlockDamageSavedData(); + BlockPos stale = new BlockPos(10, 70, 10); + BlockPos arriving = new BlockPos(20, 70, 20); + data.setStage(stale, 4); + + // Nothing at `arriving`, so the move must ERASE what `stale` said — a block relocated onto a + // position an earlier structure damaged must not inherit that damage. + data.move(arriving, stale); + + assertEquals("the destination kept a record its arriving block never earned", + 0, data.getStage(stale)); + } + + @Test + public void aRecordOutsideTheBLOCKBoundsIsStillCarried() { + // The case that made this file exist. A capture's origin is its tight BLOCK bounds, and those + // are drawn around blocks that still exist — so the record of a column that was shot away + // entirely lies outside them. Selecting by the origin box drops exactly the records the carry + // is for, while the cut that follows clears them anyway: the damage is destroyed by the act of + // moving the ship. + BlockDamageSavedData source = new BlockDamageSavedData(); + source.setStage(new BlockPos(YARD_X + 12, YARD_Y + 4, YARD_Z + 2), 4); + + DamageLayer layer = DamageLayer.harvest(source, + YARD_X, YARD_Y, YARD_Z, YARD_X + 40, YARD_Y + 40, YARD_Z + 40, // what the cut empties + YARD_X, YARD_Y, YARD_Z); // where the blocks start + assertEquals("the record of a shot-away column was not captured", 1, layer.size()); + + BlockDamageSavedData destination = new BlockDamageSavedData(); + layer.applyTo(destination, 100, 64, 100); + assertEquals("the record did not keep its place relative to the blocks it travelled with", + 4, destination.getStage(new BlockPos(112, 68, 102))); + } + + @Test + public void aCapturedLayerLandsAtItsNewOriginThroughNbt() { + BlockDamageSavedData source = new BlockDamageSavedData(); + // Two records inside a capture whose origin is the yard corner. Provenance is not exercised + // here: it resolves a registry name back to a block, and there is no block registry at this + // tier — that half is the e2e's, which runs against a real one. + source.setStage(new BlockPos(YARD_X + 2, YARD_Y + 1, YARD_Z + 3), 2); + source.setStage(new BlockPos(YARD_X + 5, YARD_Y + 4, YARD_Z + 1), 4); + + DamageLayer layer = DamageLayer.harvest(source, YARD_X, YARD_Y, YARD_Z, + YARD_X + 20, YARD_Y + 20, YARD_Z + 20, YARD_X, YARD_Y, YARD_Z); + assertEquals("the capture did not take both records", 2, layer.size()); + + NBTTagCompound nbt = new NBTTagCompound(); + layer.writeToNBT(nbt); + DamageLayer reloaded = DamageLayer.readFromNBT(nbt); + assertEquals("the layer did not survive NBT", 2, reloaded.size()); + + // Landed at a new origin, on the far side of the world from where it was captured. + BlockDamageSavedData destination = new BlockDamageSavedData(); + reloaded.applyTo(destination, 100, 64, 100); + assertEquals("the damaged block did not land at its offset from the new origin", + 2, destination.getStage(new BlockPos(102, 65, 103))); + assertEquals("the second record did not land at its own offset", + 4, destination.getStage(new BlockPos(105, 68, 101))); + + // And an empty capture must say so as a value rather than as a malformed tag. + assertTrue("an undamaged structure's layer is not empty", + DamageLayer.readFromNBT(new NBTTagCompound()).isEmpty()); + } +} diff --git a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/block_relocation/MoveBlocks.java b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/block_relocation/MoveBlocks.java index 232516295..31a734697 100644 --- a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/block_relocation/MoveBlocks.java +++ b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/block_relocation/MoveBlocks.java @@ -48,6 +48,11 @@ public static void copyBlockToPos(World world, BlockPos oldPos, BlockPos newPos, physicsObject.getShipData().activeForcePositions.add(newPos); } + // The block's damage record is held by position, not by the block, so a deconstruction that + // moved only the block would hand the ship back to its owner repaired. Same treatment as the + // tile entity on the next line, and for the same reason. + zmaster587.advancedRocketry.damage.DamageState.blockMoved(world, oldPos, newPos); + // Now that we've copied the block to the position, copy the tile entity copyTileEntityToPos(world, oldPos, newPos, physicsObject); } diff --git a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/ship_world/WorldServerShipManager.java b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/ship_world/WorldServerShipManager.java index 1153ff496..96b9a45cf 100644 --- a/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/ship_world/WorldServerShipManager.java +++ b/valkyrienskies/src/main/java/org/valkyrienskies/mod/common/ships/ship_world/WorldServerShipManager.java @@ -32,6 +32,14 @@ import java.util.*; public class WorldServerShipManager implements IPhysObjectWorld { + + /** + * How far outside a spawning structure's surviving blocks its damage records are still collected + * and carried. A destroyed position keeps the note of what stood there, has no block to travel + * with, and can therefore sit outside bounds computed from blocks that remain — one full hull + * layer's worth is the case worth covering, and this is that in blocks. + */ + private static final int HOLE_SWEEP_MARGIN = 8; private final WorldServer world; private final VSWorldPhysicsLoop physicsLoop; private final Thread physicsThread; @@ -164,6 +172,11 @@ private void spawnNewShips() { BlockPos centerDifference = toSpawn.getChunkClaim().getRegionCenter().subtract(physicsInfuserPos); MutableBlockPos pasteLocationPos = new MutableBlockPos(); Map copiedChunksMap = new HashMap<>(); + // Bounds of the region being taken out of the world, tracked so that the damage records + // of positions holding no block (the holes a weapon left) can be carried too - they have + // nothing to ride along with, since this loop enumerates blocks. + int damageMinX = Integer.MAX_VALUE, damageMinY = Integer.MAX_VALUE, damageMinZ = Integer.MAX_VALUE; + int damageMaxX = Integer.MIN_VALUE, damageMaxY = Integer.MIN_VALUE, damageMaxZ = Integer.MIN_VALUE; // First, copy the blocks and tiles to the new chunks TIntIterator blocksIterator = detector.foundSet.iterator(); while (blocksIterator.hasNext()) { @@ -200,6 +213,17 @@ private void spawnNewShips() { newChunk.storageArrays[newChunkStorageIndex] = new ExtendedBlockStorage(newChunkStorageIndex << 4, true); } newChunk.storageArrays[newChunkStorageIndex].set(pasteLocationPos.getX() & 15, pasteLocationPos.getY() & 15, pasteLocationPos.getZ() & 15, srcState); + // Carry the block's damage record to its new address, for the same reason the tile + // entity below is carried: the stage of a block that has no tile of its own is held + // by position, so an assembly that moves only the block would build a pristine ship + // out of a wrecked structure - and leave the wreck's records on the empty ground. + zmaster587.advancedRocketry.damage.DamageState.blockMoved(world, srcLocationPos, pasteLocationPos); + damageMinX = Math.min(damageMinX, srcLocationPos.getX()); + damageMinY = Math.min(damageMinY, srcLocationPos.getY()); + damageMinZ = Math.min(damageMinZ, srcLocationPos.getZ()); + damageMaxX = Math.max(damageMaxX, srcLocationPos.getX()); + damageMaxY = Math.max(damageMaxY, srcLocationPos.getY()); + damageMaxZ = Math.max(damageMaxZ, srcLocationPos.getZ()); // If this block is force block, then add it to the activeForcePositions list of the ship. if (BlockPhysicsDetails.isBlockProvidingForce(srcState)) { toSpawn.activeForcePositions.add(pasteLocationPos); @@ -224,6 +248,18 @@ private void spawnNewShips() { newChunk.addTileEntity(pasteTile); } } + // The blocks are carried; now the holes between them, which the loop above could not see. + // Widened by a margin because the bounds above are drawn around blocks that still EXIST: + // a structure whose outermost layer was shot away keeps its records outside them. The + // margin is a bound, not a proof - a hole further out than this than the surviving hull + // is left behind, and the structural-damage subsystem doc says so. + if (damageMinX != Integer.MAX_VALUE) { + final int m = HOLE_SWEEP_MARGIN; + zmaster587.advancedRocketry.damage.DamageState.holesMoved(world, + new BlockPos(damageMinX - m, damageMinY - m, damageMinZ - m), + new BlockPos(damageMaxX + m, damageMaxY + m, damageMaxZ + m), + centerDifference); + } for (final Chunk chunk : copiedChunksMap.values()) { chunk.generateSkylightMap(); } From 026d76ac690049fd368c22e2dae63d6fc56f8b64 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 15 Aug 2026 13:55:01 +0300 Subject: [PATCH 04/35] feat: a hand welder, the bottom rung of the repair ladder - one use removes one stage, priced from the block's own recipe - Forge Energy on the tool; any charger in the pack fills it - five distinct outcomes; a refusal takes neither material nor charge - add an artest damage weld probe and a server e2e --- .../advancedRocketry/AdvancedRocketry.java | 2 + .../advancedRocketry/api/ARConfiguration.java | 15 ++ .../api/AdvancedRocketryItems.java | 1 + .../command/test/TestProbeCommand.java | 86 +++++++ .../advancedRocketry/damage/RepairCost.java | 143 ++++++++++++ .../item/ItemRepairWelder.java | 217 ++++++++++++++++++ .../assets/advancedrocketry/lang/en_US.lang | 6 + .../models/item/repairWelder.json | 6 + .../recipes/repairwelder.json | 32 +++ .../test/server/RepairWelderE2ETest.java | 139 +++++++++++ 10 files changed, 647 insertions(+) create mode 100644 src/main/java/zmaster587/advancedRocketry/damage/RepairCost.java create mode 100644 src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java create mode 100644 src/main/resources/assets/advancedrocketry/models/item/repairWelder.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/repairwelder.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/RepairWelderE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index e6c59c931..027ee0c5b 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -538,6 +538,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 +596,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")); diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 97b837224..ccb78490c 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -373,6 +373,18 @@ 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; @ConfigProperty(needsSync = true) public double wearTankLeakChanceMax = 0.5; @ConfigProperty(needsSync = true) @@ -614,6 +626,9 @@ public static void loadPreInit() { 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(); 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index b278afacd..c5cafadd7 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -713,6 +713,52 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ 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 @@ -17498,6 +17544,46 @@ private static double parseDoubleOr(String s, double dflt) { try { return Double.parseDouble(s); } catch (NumberFormatException nfe) { return dflt; } } + /** + * A player for the welding probe, its OWN and not the shared fake one: this player is handed a + * cleared inventory on every call, which would rob whatever else the shared player is carrying. + * Connectionless like the shared one, so nothing may send it a packet — which is why the probe + * drives {@code ItemRepairWelder.weld} (silent) rather than {@code onItemUse} (speaks). + */ + private static net.minecraft.entity.player.EntityPlayerMP weldingPlayer( + MinecraftServer server, net.minecraft.world.WorldServer world, BlockPos near) { + if (weldTestPlayer == null) { + weldTestPlayer = new net.minecraft.entity.player.EntityPlayerMP(server, world, + new com.mojang.authlib.GameProfile( + java.util.UUID.nameUUIDFromBytes("ARWeldTestPlayer".getBytes()), + "ARWeldTestPlayer"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + weldTestPlayer.capabilities.disableDamage = true; + } + weldTestPlayer.setWorld(world); + weldTestPlayer.dimension = world.provider.getDimension(); + weldTestPlayer.setLocationAndAngles(near.getX() + 0.5, near.getY() + 1.0, near.getZ() + 0.5, 0, 0); + return weldTestPlayer; + } + + private static net.minecraft.entity.player.EntityPlayerMP weldTestPlayer; + + /** How many of {@code item} the player is carrying, counting every slot; 0 for a null item. */ + private static int countOf(net.minecraft.entity.player.EntityPlayerMP player, + net.minecraft.item.Item item) { + if (item == null) { + return 0; + } + int total = 0; + for (int i = 0; i < player.inventory.getSizeInventory(); i++) { + net.minecraft.item.ItemStack stack = player.inventory.getStackInSlot(i); + if (!stack.isEmpty() && stack.getItem() == item) { + total += stack.getCount(); + } + } + return total; + } + /** * Player-state probe. Used by the testClient e2e pin for * the {@code MixinEntityPlayer(MP)InventoryAccess} {@code @Redirect}: diff --git a/src/main/java/zmaster587/advancedRocketry/damage/RepairCost.java b/src/main/java/zmaster587/advancedRocketry/damage/RepairCost.java new file mode 100644 index 000000000..270234de1 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/damage/RepairCost.java @@ -0,0 +1,143 @@ +package zmaster587.advancedRocketry.damage; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.InventoryPlayer; +import net.minecraft.item.ItemStack; +import net.minecraft.item.crafting.CraftingManager; +import net.minecraft.item.crafting.IRecipe; +import net.minecraft.item.crafting.Ingredient; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.NonNullList; +import net.minecraft.world.World; +import net.minecraftforge.oredict.OreDictionary; + +import zmaster587.advancedRocketry.api.ARConfiguration; + +import java.util.ArrayList; +import java.util.List; + +/** + * What one stage of repair costs, in materials, and whether a player is carrying it. + * + *

    The price is the block's own recipe, scaled

    + *

    A repair is a partial rebuild, so it is priced out of what building the thing costs: the + * ingredients of the block's crafting recipe times a per-stage fraction. That keeps repair inside the + * material economy the ship already runs on rather than inventing a currency for it, and it scales + * with the block automatically — a hull plate is cheap to weld and a machine is not, without anybody + * writing a second table.

    + * + *

    What it deliberately does NOT promise

    + *

    That welding is always the cheap option. For a plain, cheaply-crafted block, breaking it and + * placing a fresh one is a repair too, and often a cheaper one — that is a legitimate outcome, not a + * balance failure. Welding buys something replacement cannot: the block, and everything its tile + * entity is holding, stays where it is. Replacing a machine to fix a crack empties it.

    + * + *

    Blocks with no recipe

    + *

    Stone, ore, and anything else that is gathered rather than crafted has no ingredient list to + * price against, and this refuses them rather than inventing a cost. The caller reports that as its + * own outcome; it is not a silent no-op.

    + */ +public final class RepairCost { + + private RepairCost() { + } + + /** + * The materials one stage of repair at {@code pos} costs, or {@code null} when this block cannot + * be priced — no crafting recipe, or nothing there to repair. + * + *

    Ingredient counts are rounded UP, so the cheapest possible recipe still costs one item per + * stage: a repair is never free. Where several recipes make the same block the first registered + * one wins, which is arbitrary but stable; a block whose recipes differ wildly in cost would need + * a rule of its own, and none does today.

    + */ + public static List perStage(World world, BlockPos pos) { + if (world == null || pos == null) { + return null; + } + IBlockState state = world.getBlockState(pos); + ItemStack asItem = state.getBlock().getItem(world, pos, state); + if (asItem.isEmpty()) { + return null; + } + IRecipe recipe = recipeFor(asItem); + if (recipe == null) { + return null; + } + int stages = Math.max(1, DamageState.getMaxStage(world, pos)); + double fraction = ARConfiguration.getCurrentConfig().repairCostPerStageFraction / stages; + + List cost = new ArrayList<>(); + for (Ingredient ingredient : recipe.getIngredients()) { + ItemStack[] variants = ingredient.getMatchingStacks(); + if (variants.length == 0) { + continue; + } + int needed = (int) Math.ceil(variants[0].getCount() * fraction); + if (needed <= 0) { + continue; + } + ItemStack charge = variants[0].copy(); + charge.setCount(needed); + cost.add(charge); + } + return cost.isEmpty() ? null : cost; + } + + /** + * Take {@code cost} out of the player's inventory, or answer false having taken nothing. + * + *

    Simulated first by the caller and then taken, rather than taken optimistically and refunded: + * a partial charge for a repair that then could not happen is the shape that quietly eats + * materials. Creative players are charged nothing, as everywhere else.

    + */ + public static boolean consume(EntityPlayer player, List cost, boolean simulate) { + if (player == null || cost == null) { + return false; + } + if (player.capabilities.isCreativeMode) { + return true; + } + InventoryPlayer inventory = player.inventory; + // Counted against a scratch copy so a simulate never touches the real inventory and a real + // take can still be abandoned half-way without having moved anything. + NonNullList scratch = NonNullList.withSize(inventory.getSizeInventory(), ItemStack.EMPTY); + for (int i = 0; i < inventory.getSizeInventory(); i++) { + scratch.set(i, inventory.getStackInSlot(i).copy()); + } + + for (ItemStack wanted : cost) { + int remaining = wanted.getCount(); + for (int i = 0; i < scratch.size() && remaining > 0; i++) { + ItemStack inSlot = scratch.get(i); + if (inSlot.isEmpty() || !OreDictionary.itemMatches(wanted, inSlot, false)) { + continue; + } + int take = Math.min(remaining, inSlot.getCount()); + inSlot.shrink(take); + remaining -= take; + } + if (remaining > 0) { + return false; + } + } + if (!simulate) { + for (int i = 0; i < inventory.getSizeInventory(); i++) { + inventory.setInventorySlotContents(i, scratch.get(i)); + } + } + return true; + } + + /** The first registered crafting recipe whose output is this item, or null if nothing crafts it. */ + private static IRecipe recipeFor(ItemStack output) { + for (IRecipe recipe : CraftingManager.REGISTRY) { + ItemStack result = recipe.getRecipeOutput(); + if (!result.isEmpty() && OreDictionary.itemMatches(result, output, false)) { + return recipe; + } + } + return null; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java b/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java new file mode 100644 index 000000000..a40060b77 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java @@ -0,0 +1,217 @@ +package zmaster587.advancedRocketry.item; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumActionResult; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumHand; +import net.minecraft.util.SoundCategory; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.TextComponentTranslation; +import net.minecraft.world.World; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.ICapabilityProvider; +import net.minecraftforge.energy.CapabilityEnergy; +import net.minecraftforge.energy.IEnergyStorage; + +import javax.annotation.Nullable; + +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.damage.DamageState; +import zmaster587.advancedRocketry.damage.RepairCost; + +import java.util.List; + +/** + * The hand tool at the bottom of the repair ladder: right-click a damaged block to take one stage of + * damage off it, paid for in the block's own materials and in charge. + * + *

    What it is for, given that breaking the block also repairs it

    + *

    Replacing a damaged block by hand is a working repair and costs one block. The welder exists + * because that is not the same operation: a block that is broken and replaced comes back EMPTY. Its + * tile entity — an inventory, a linked seat, a machine's own accumulated wear — does not survive the + * round trip. Welding leaves the block, and everything it is holding, exactly where it is. On plain + * hull plate the pickaxe is often the cheaper option, and that is fine.

    + * + *

    Charge

    + *

    Holds Forge Energy and exposes the standard capability, so it charges in whatever charger the + * pack provides rather than in a block Advanced Rocketry has to ship. Energy is the reason a welder + * is a machine and not a hammer; running out is a state the player can see on the durability bar + * before the tool refuses.

    + * + *

    Refusals are spoken

    + *

    Undamaged, unpriceable, out of materials and out of charge are four different answers, and each + * says which it is. A tool that does nothing quietly is a tool players stop trusting.

    + */ +public class ItemRepairWelder extends Item { + + private static final String NBT_ENERGY = "energy"; + + public ItemRepairWelder() { + setMaxStackSize(1); + } + + /** + * Every way one use of the welder can end. A type rather than four message strings, because the + * four are genuinely different answers and callers — the player, a test, a future automated + * rung — all need to tell them apart, not just read different words. + */ + public enum Outcome { + REPAIRED("msg.welder.repaired"), + UNDAMAGED("msg.welder.undamaged"), + NO_RECIPE("msg.welder.nocost"), + NO_MATERIALS("msg.welder.nomaterials"), + NO_CHARGE("msg.welder.nocharge"); + + public final String messageKey; + + Outcome(String messageKey) { + this.messageKey = messageKey; + } + } + + /** + * One stage of repair at {@code pos}, paid for out of {@code player}'s inventory and {@code + * tool}'s charge. Server-side, silent, and the whole decision — the item below only turns the + * answer into words. + * + *

    Nothing is taken unless everything can be: the two charges are checked before either is + * made, so a use that ends in a refusal costs the player nothing at all.

    + */ + public static Outcome weld(EntityPlayer player, World world, BlockPos pos, ItemStack tool) { + int stage = DamageState.getStage(world, pos); + if (stage <= 0) { + return Outcome.UNDAMAGED; + } + List cost = RepairCost.perStage(world, pos); + if (cost == null) { + return Outcome.NO_RECIPE; + } + boolean free = player.capabilities.isCreativeMode; + int energyCost = ARConfiguration.getCurrentConfig().repairWelderEnergyPerStage; + if (!free && storedEnergy(tool) < energyCost) { + return Outcome.NO_CHARGE; + } + if (!RepairCost.consume(player, cost, true)) { + return Outcome.NO_MATERIALS; + } + + RepairCost.consume(player, cost, false); + if (!free) { + setStoredEnergy(tool, storedEnergy(tool) - energyCost); + } + DamageState.setStage(world, pos, stage - 1); + world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 3); + return Outcome.REPAIRED; + } + + @Override + public EnumActionResult onItemUse(EntityPlayer player, World world, BlockPos pos, EnumHand hand, + EnumFacing facing, float hitX, float hitY, float hitZ) { + if (world.isRemote) { + // The client is told what happened by the block's own sync; deciding here would let it + // predict a repair the server may refuse. + return EnumActionResult.PASS; + } + Outcome outcome = weld(player, world, pos, player.getHeldItem(hand)); + player.sendStatusMessage(new TextComponentTranslation(outcome.messageKey), true); + if (outcome != Outcome.REPAIRED) { + return EnumActionResult.FAIL; + } + world.playSound(null, pos, net.minecraft.init.SoundEvents.BLOCK_ANVIL_USE, + SoundCategory.BLOCKS, 0.4F, 1.6F); + player.swingArm(hand); + return EnumActionResult.SUCCESS; + } + + // --- charge, on the stack -------------------------------------------------------------------- + + public static int storedEnergy(ItemStack stack) { + NBTTagCompound nbt = stack.getTagCompound(); + return nbt == null ? 0 : nbt.getInteger(NBT_ENERGY); + } + + public static void setStoredEnergy(ItemStack stack, int energy) { + if (!stack.hasTagCompound()) { + stack.setTagCompound(new NBTTagCompound()); + } + stack.getTagCompound().setInteger(NBT_ENERGY, + Math.max(0, Math.min(energy, ARConfiguration.getCurrentConfig().repairWelderCapacity))); + } + + @Override + public boolean showDurabilityBar(ItemStack stack) { + return storedEnergy(stack) < ARConfiguration.getCurrentConfig().repairWelderCapacity; + } + + @Override + public double getDurabilityForDisplay(ItemStack stack) { + int capacity = Math.max(1, ARConfiguration.getCurrentConfig().repairWelderCapacity); + return 1.0D - (storedEnergy(stack) / (double) capacity); + } + + @Override + @Nullable + public ICapabilityProvider initCapabilities(ItemStack stack, @Nullable NBTTagCompound unused) { + return new EnergyProvider(stack); + } + + /** Forge Energy on the stack itself, so any charger in the pack can fill it. */ + private static final class EnergyProvider implements ICapabilityProvider, IEnergyStorage { + + private final ItemStack stack; + + private EnergyProvider(ItemStack stack) { + this.stack = stack; + } + + @Override + public boolean hasCapability(Capability capability, @Nullable EnumFacing facing) { + return capability == CapabilityEnergy.ENERGY; + } + + @Override + @Nullable + @SuppressWarnings("unchecked") + public T getCapability(Capability capability, @Nullable EnumFacing facing) { + return capability == CapabilityEnergy.ENERGY ? (T) this : null; + } + + @Override + public int receiveEnergy(int maxReceive, boolean simulate) { + int room = getMaxEnergyStored() - getEnergyStored(); + int accepted = Math.min(room, Math.max(0, maxReceive)); + if (!simulate && accepted > 0) { + setStoredEnergy(stack, getEnergyStored() + accepted); + } + return accepted; + } + + @Override + public int extractEnergy(int maxExtract, boolean simulate) { + return 0; // a welder spends its charge on repairs, not into the grid + } + + @Override + public int getEnergyStored() { + return storedEnergy(stack); + } + + @Override + public int getMaxEnergyStored() { + return ARConfiguration.getCurrentConfig().repairWelderCapacity; + } + + @Override + public boolean canExtract() { + return false; + } + + @Override + public boolean canReceive() { + return true; + } + } +} diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 6d8c298f2..a96f93319 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -911,6 +911,12 @@ msg.shipentry.autotakeoff.blocked=§eAuto-takeoff aborted - the path to orbit is msg.pilotseat.notassembled=§eShip not assembled - assemble it to fly. msg.pilotseat.occupied=§e%s is already in this seat. msg.pilotseat.taken=§eYour seat was taken by %s while you were away. +item.advancedrocketry:repairWelder.name=Repair Welder +msg.welder.repaired=Welded: one stage of damage removed. +msg.welder.undamaged=This block is not damaged. +msg.welder.nocost=§cNothing crafts this block, so there is no repair to price. +msg.welder.nomaterials=§cNot carrying the materials this repair costs. +msg.welder.nocharge=§cThe welder is out of charge. msg.pilotseat.afcdestroyed=§cFlight computer destroyed - the ship is adrift. msg.loginrestore.shipunknown=§cYour ship could not be found - the server has no record of it, so you have been placed at your spawn point. msg.shipdescent.refused=§cThe descent could not start - the destination is not ready yet. Wait a moment and try again. diff --git a/src/main/resources/assets/advancedrocketry/models/item/repairWelder.json b/src/main/resources/assets/advancedrocketry/models/item/repairWelder.json new file mode 100644 index 000000000..c21662d7b --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/models/item/repairWelder.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "advancedrocketry:items/jackhammer" + } +} diff --git a/src/main/resources/assets/advancedrocketry/recipes/repairwelder.json b/src/main/resources/assets/advancedrocketry/recipes/repairwelder.json new file mode 100644 index 000000000..af04cace5 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/repairwelder.json @@ -0,0 +1,32 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": + [ + " c ", + "iri", + " i " + ], + "key": + { + "c": + { + "type": "forge:ore_dict", + "ore": "circuitBasic" + }, + "i": + { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "r": + { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": + { + "item": "advancedrocketry:repairWelder", + "count": 1 + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/RepairWelderE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RepairWelderE2ETest.java new file mode 100644 index 000000000..889caeb57 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/RepairWelderE2ETest.java @@ -0,0 +1,139 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The bottom rung of the repair ladder: one use of the welder takes one stage of damage off a block, + * paid for in that block's own materials and in charge — and every way it can refuse is a different, + * visible answer that costs the player nothing. + * + *

    What is pinned here is the CONTRACT (C20 REPAIR-1, REPAIR-3, REPAIR-7), not the price list. The + * assertions say that material left the inventory, never how much: the fraction charged per stage is + * a tuned number, and a test that pinned it would fail the first time somebody balanced the game + * rather than the first time somebody broke it.

    + */ +public class RepairWelderE2ETest extends AbstractSharedServerTest { + + /** A site of this class's own, clear of the ship scenarios. */ + private static final int X = 8000, Y = 80, Z = 7200; + + /** Crafted from nine ingots, so it has a recipe to be priced against. */ + private static final String SUBJECT = "minecraft:iron_block"; + private static final String MATERIAL = "minecraft:iron_ingot"; + /** Smelted, never crafted — so nothing can price a repair of it. */ + private static final String UNPRICEABLE = "minecraft:stone"; + + private static final int PLENTY_OF_CHARGE = 100000; + private static final int PLENTY_OF_MATERIAL = 64; + + @Test + public void oneUseTakesOneStageAndIsPaidForTwice() throws Exception { + int x = X, y = Y, z = Z; + int damaged = placeAndDamage(x, y, z, SUBJECT, 2, 78001); + assertTrue("the subject must be damaged but not destroyed, or the welder has nothing to do " + + "or nothing to do it to (stage " + damaged + ")", damaged >= 1); + + String weld = weld(x, y, z, PLENTY_OF_CHARGE, MATERIAL, PLENTY_OF_MATERIAL); + assertEquals("the welder refused a damaged, priceable block: " + weld, + "REPAIRED", extractString(weld, "outcome")); + assertEquals("one use must remove exactly one stage: " + weld, + damaged - 1, extractInt(weld, "stageAfter")); + assertTrue("the repair took no material — nothing may be created from nothing: " + weld, + extractInt(weld, "materialAfter") < extractInt(weld, "materialBefore")); + assertTrue("the repair took no charge: " + weld, + extractInt(weld, "energyAfter") < extractInt(weld, "energyBefore")); + } + + @Test + public void everyRefusalIsItsOwnAnswerAndCostsNothing() throws Exception { + // Each case gets its own block: a refusal that quietly consumed something would otherwise be + // hidden by the next case's fresh inventory. + int damaged = placeAndDamage(X + 4, Y, Z, SUBJECT, 2, 78002); + String noMaterials = weld(X + 4, Y, Z, PLENTY_OF_CHARGE, "none", 0); + assertEquals("an empty inventory must be told apart from every other refusal: " + noMaterials, + "NO_MATERIALS", extractString(noMaterials, "outcome")); + assertEquals("a refused repair changed the block anyway: " + noMaterials, + damaged, extractInt(noMaterials, "stageAfter")); + assertEquals("a refused repair spent charge: " + noMaterials, + extractInt(noMaterials, "energyBefore"), extractInt(noMaterials, "energyAfter")); + + int stillDamaged = placeAndDamage(X + 8, Y, Z, SUBJECT, 2, 78003); + String noCharge = weld(X + 8, Y, Z, 0, MATERIAL, PLENTY_OF_MATERIAL); + assertEquals("a flat tool must be told apart from an empty inventory: " + noCharge, + "NO_CHARGE", extractString(noCharge, "outcome")); + assertEquals("a refused repair changed the block anyway: " + noCharge, + stillDamaged, extractInt(noCharge, "stageAfter")); + assertEquals("a refused repair took materials: " + noCharge, + extractInt(noCharge, "materialBefore"), extractInt(noCharge, "materialAfter")); + + place(X + 12, Y, Z, SUBJECT); + String undamaged = weld(X + 12, Y, Z, PLENTY_OF_CHARGE, MATERIAL, PLENTY_OF_MATERIAL); + assertEquals("an undamaged block must not read as a failed repair: " + undamaged, + "UNDAMAGED", extractString(undamaged, "outcome")); + assertEquals("welding an undamaged block took materials: " + undamaged, + extractInt(undamaged, "materialBefore"), extractInt(undamaged, "materialAfter")); + + placeAndDamage(X + 16, Y, Z, UNPRICEABLE, 2, 78004); + String noRecipe = weld(X + 16, Y, Z, PLENTY_OF_CHARGE, MATERIAL, PLENTY_OF_MATERIAL); + assertEquals("a block nothing crafts must say so rather than be repaired for free or refused " + + "as if the player were empty-handed: " + noRecipe, + "NO_RECIPE", extractString(noRecipe, "outcome")); + } + + /** + * Put {@code block} down and shoot it for {@code stages} stages, returning the stage it ended at. + * The budget comes from what production itself charges per stage, so this survives retuning + * instead of pinning today's number. + */ + private int placeAndDamage(int x, int y, int z, String block, int stages, int impactId) throws Exception { + place(x, y, z, block); + int stageCost = extractInt(exec("artest damage stage 0 " + x + " " + y + " " + z), "stageCost"); + assertTrue("no stage cost for " + block, stageCost > 0); + String shot = exec("artest damage impact 0 " + (x + 0.5) + " " + (y + 4) + " " + (z + 0.5) + + " 0 -1 0 " + (stageCost * stages) + " KINETIC " + impactId); + assertTrue("the shot missed the subject block: " + shot, readLong(shot, "spent") > 0); + return extractInt(exec("artest damage stage 0 " + x + " " + y + " " + z), "stage"); + } + + private void place(int x, int y, int z, String block) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((x - 2) >> 4) + " " + + ((z - 2) >> 4) + " " + ((x + 20) >> 4) + " " + ((z + 2) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill 0 " + (x - 1) + " " + y + " " + (z - 1) + + " " + (x + 1) + " " + (y + 6) + " " + (z + 1) + " minecraft:air").contains("\"ok\":true")); + assertTrue("could not place " + block, exec("artest fill 0 " + x + " " + y + " " + z + + " " + x + " " + y + " " + z + " " + block).contains("\"ok\":true")); + } + + private String weld(int x, int y, int z, int charge, String material, int count) throws Exception { + String reply = exec("artest damage weld 0 " + x + " " + y + " " + z + " " + charge + + " " + material + " " + count); + assertTrue("the weld probe failed: " + reply, reply.contains("\"ok\":true")); + return reply; + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} From 4dfe1bcda09d3767a588368fb0ea7d885c19efda Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 15 Aug 2026 15:23:02 +0300 Subject: [PATCH 05/35] feat: a shot is a record that crosses distance nobody is watching - add the shot record, its per-world registry and the tick integrator - test every step as a swept segment, never a point in solid - order the field and structure layers by earliest crossing, geometrically - probe the shield before committing, through one shell search - end a shot below the reflection speed floor instead of parking it - share one definition of "there is structure here" with the damage engine - add /artest shot and a read-only chunk-loaded probe --- .../world/shield/ShieldStrikeService.java | 48 +++- .../advancedRocketry/api/ARConfiguration.java | 25 ++ .../api/projectile/ShotEndReason.java | 28 +++ .../api/projectile/ShotEnvironment.java | 57 +++++ .../api/projectile/ShotSpec.java | 151 +++++++++++ .../command/test/TestProbeCommand.java | 131 +++++++++- .../damage/StructureDamageEngine.java | 12 +- .../integration/vs/VSBridge.java | 21 ++ .../integration/vs/VSIntegration.java | 8 + .../advancedRocketry/projectile/Shot.java | 235 ++++++++++++++++++ .../projectile/ShotRegistry.java | 189 ++++++++++++++ .../projectile/ShotSubstrate.java | 200 +++++++++++++++ .../projectile/ShotSubstrateEvents.java | 36 +++ .../projectile/StructureCrossing.java | 156 ++++++++++++ .../projectile/SweptSegment.java | 117 +++++++++ .../test/server/ShotSubstrateE2ETest.java | 188 ++++++++++++++ .../test/unit/SweptSegmentTest.java | 132 ++++++++++ 17 files changed, 1722 insertions(+), 12 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEnvironment.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/projectile/ShotSpec.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/Shot.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrateEvents.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java 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 d0f3820d8..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 @@ -34,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, diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index ccb78490c..5d7c59d45 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"; @@ -385,6 +386,27 @@ public class ARConfiguration { public int repairWelderEnergyPerStage = 2000; @ConfigProperty(needsSync = true) public int repairWelderCapacity = 100000; + /** + * Whether shots exist as tracked records at all. With this off nothing is admitted to a world's + * registry and nothing already there is stepped, so a weapon built on the substrate fires and + * nothing travels — which is the whole of the mechanic gone, not half of it. + */ + @ConfigProperty(needsSync = true) + public boolean enableProjectileSubstrate = 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; + /** + * 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; @ConfigProperty(needsSync = true) public double wearTankLeakChanceMax = 0.5; @ConfigProperty(needsSync = true) @@ -632,6 +654,9 @@ public static void loadPreInit() { 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.enableProjectileSubstrate = config.get(WEAPONS, "enableProjectileSubstrate", true, "Track fired shots as server-side records that fly across loaded and unloaded space alike. Turn off to disable long-range fire entirely: nothing is admitted and nothing in flight is stepped").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.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.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Enable rocket part wear and exploding chance.").getBoolean(); arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble(); 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..2e659ce2d --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java @@ -0,0 +1,28 @@ +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 +} 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index c5cafadd7..e48cd3200 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -241,6 +241,9 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args case "damage": handleDamage(server, sender, tail(args)); break; + case "shot": + handleShot(server, sender, tail(args)); + break; case "sound": handleSound(server, sender, tail(args)); break; @@ -252,6 +255,109 @@ 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).
    • + *
    + * + *

    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 ...\"}"); + 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))); + } + 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(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.api.projectile.ShotEndReason ended = + registry.endReasonOf(Long.parseLong(args[2])); + send(sender, "{\"ok\":true,\"present\":false,\"ended\":\"" + + (ended == null ? "" : ended.name()) + "\",\"count\":" + registry.count() + "}"); + return; + } + send(sender, "{\"ok\":true,\"present\":true,\"shot\":" + shotJson(shot) + "}"); + return; + } + if ("clear".equals(sub)) { + int before = registry.count(); + registry.clear(); + send(sender, "{\"ok\":true,\"cleared\":" + before + "}"); + return; + } + send(sender, "{\"error\":\"unknown shot subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}"); + } + + private static String shotJson(zmaster587.advancedRocketry.projectile.Shot shot) { + return "{\"id\":" + shot.getId() + + ",\"x\":" + shot.getPosition().x + + ",\"y\":" + shot.getPosition().y + + ",\"z\":" + shot.getPosition().z + + ",\"vx\":" + shot.getVelocity().x + + ",\"vy\":" + shot.getVelocity().y + + ",\"vz\":" + shot.getVelocity().z + + ",\"speed\":" + shot.getSpeed() + + ",\"energy\":" + shot.getImpactEnergy() + + ",\"age\":" + shot.getAge() + + ",\"lifetime\":" + shot.getLifetimeTicks() + + ",\"kind\":\"" + shot.getKind().name() + "\"}"; + } + // Vendored AFFS shield probes ----------------------------------------- /** @@ -20112,7 +20218,7 @@ private static String ticketKey(int dim, int cx, int cz) { private void handleChunk(MinecraftServer server, ICommandSender sender, String[] args) { if (args.length == 0) { - send(sender, "{\"error\":\"usage: /artest chunk forceload | cycle | release | release-all | list\"}"); + send(sender, "{\"error\":\"usage: /artest chunk forceload | cycle | loaded [cx cz] | release | release-all | list\"}"); return; } String sub = args[0].toLowerCase(java.util.Locale.ROOT); @@ -20202,6 +20308,29 @@ private void handleChunk(MinecraftServer server, ICommandSender sender, String[] + ",\"sameInstance\":" + (fresh == loaded) + "}"); return; } + if ("loaded".equals(sub) && args.length >= 2) { + // loaded [cx cz] — read-only: is that chunk in memory, and how many are. Asked + // WITHOUT loading anything (getLoadedChunk, never provideChunk), because the question + // "did this cause a load" cannot be answered by an instrument that loads. + 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.world.gen.ChunkProviderServer provider = world.getChunkProvider(); + if (args.length >= 4) { + int cx = parseIntOr(args[2], Integer.MIN_VALUE); + int cz = parseIntOr(args[3], Integer.MIN_VALUE); + boolean present = provider.id2ChunkMap.containsKey( + net.minecraft.util.math.ChunkPos.asLong(cx, cz)); + send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"cx\":" + cx + ",\"cz\":" + cz + + ",\"loaded\":" + present + ",\"count\":" + provider.id2ChunkMap.size() + "}"); + return; + } + send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"count\":" + provider.id2ChunkMap.size() + "}"); + return; + } if ("release".equals(sub) && args.length >= 4) { int dim = parseIntOr(args[1], Integer.MIN_VALUE); int cx = parseIntOr(args[2], Integer.MIN_VALUE); diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index c38ef45f6..3a751d094 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -176,10 +176,20 @@ public static int stageCost(World world, BlockPos pos) { return Math.max(1, (int) Math.ceil(perStage)); } - private static boolean isDamageable(World world, BlockPos pos, IBlockState state) { + /** + * Whether there is structure at {@code pos} — the one definition of "something is here", shared + * with whatever decides where an impact happens. A travelling body that stopped at a + * different set of blocks from the ones this engine is willing to spend budget on would either + * halt in mid-air or bore through a wall it had already passed. + */ + public static boolean isStructure(World world, BlockPos pos, IBlockState state) { return !state.getBlock().isAir(state, world, pos) && !state.getMaterial().isLiquid(); } + private static boolean isDamageable(World world, BlockPos pos, IBlockState state) { + return isStructure(world, pos, state); + } + private static boolean isIndestructible(World world, BlockPos pos, IBlockState state) { return state.getBlockHardness(world, pos) < 0.0F; } diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java index e42f410ad..5b4c3ee5d 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java @@ -1242,6 +1242,27 @@ static java.util.List shipIdsAt(World world, double x, double y, double return ids; } + /** + * Every loaded ship in {@code world} as uuid string → its grown world AABB. The SEGMENT-shaped + * sibling of {@link #shipIdsAt}: a body that moves a long way in one tick has no single point to + * ask about, and asking about its endpoints would miss every ship it passed through in between. + * Boxes overlap and overstate, exactly as they do for the point query, so a caller still has to + * confirm in each candidate's own frame. + */ + static java.util.Map loadedShipWorldBounds(World world) { + java.util.Map out = new java.util.LinkedHashMap<>(); + try { + for (PhysicsObject physo : ValkyrienUtils.getPhysosLoadedInWorld(world)) { + AxisAlignedBB bb = physo.getShipBB(); + if (bb != null) { + out.put(physo.getShipData().getUuid().toString(), bb.grow(ABOARD_MARGIN)); + } + } + } catch (Throwable ignored) { + } + return out; + } + /** World point -> ship-frame point, for the ship {@code shipId}. Null when it is not loaded. */ static double[] toShipFrameFor(World world, String shipId, double x, double y, double z) { try { diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java index d4d1b53fb..735ae14f7 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java @@ -788,6 +788,14 @@ public static java.util.List shipIdsAt(World world, double x, double y, ? java.util.Collections.emptyList() : VSBridge.shipIdsAt(world, x, y, z); } + /** Every loaded ship's grown world AABB, by uuid string — the candidate list for a swept SEGMENT + * rather than a point (possibly empty; never null). */ + public static java.util.Map loadedShipWorldBounds(World world) { + return (!isAvailable() || world == null) + ? java.util.Collections.emptyMap() + : VSBridge.loadedShipWorldBounds(world); + } + /** World point to ship-frame point, for the anchored ship. See the anchored-access note. */ public static double[] toShipFrameFor(World world, String shipId, double x, double y, double z) { return (!isAvailable() || world == null) ? null : VSBridge.toShipFrameFor(world, shipId, x, y, z); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java new file mode 100644 index 000000000..5b11d0dd7 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java @@ -0,0 +1,235 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.api.damage.ImpactKind; +import zmaster587.advancedRocketry.api.projectile.ShotEnvironment; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; + +import java.util.UUID; + +/** + * A shot in flight — a record in a registry, deliberately not an entity. + * + *

    Why not an entity

    + *

    An entity is only simulated where the world is loaded and only tracked near a player. A round + * that has to cross kilometres therefore has two futures as an entity, and both are wrong: it dies + * quietly the moment it leaves the shooter's bubble, which makes long-range fire a lie, or the server + * keeps a corridor of world loaded along every trajectory, which makes firing an attack on the host. + * A record is simulated by its world's own tick regardless of who is watching, and costs three + * vectors.

    + * + *

    Mutable, and owned by exactly one thing

    + *

    Position, velocity, age and the remaining impact energy change every tick; everything else is + * fixed at the muzzle. The record is owned by the {@link ShotRegistry} of one world and is mutated + * only by {@link ShotSubstrate} while stepping it — server side. A client never simulates a shot: it + * would be describing a different flight from the one that is going to hit somebody.

    + */ +public final class Shot { + + private final long id; + private final double radius; + private final double mass; + private final ImpactKind kind; + private final UUID owner; + private final String faction; + private final String guidance; + private final ShotEnvironment environment; + private final int lifetimeTicks; + + private Vec3d position; + private Vec3d velocity; + private int age; + private int impactEnergy; + + /** + * How many impacts this shot has already declared. It is part of the impact identity so that a + * shot which strikes twice — a shell it was let through, then the hull behind it — is not refused + * the second time by the damage service's duplicate memory. + */ + private int impactSequence; + + Shot(long id, ShotSpec spec) { + this.id = id; + this.radius = spec.getRadius(); + this.mass = spec.getMass(); + this.kind = spec.getKind(); + this.owner = spec.getOwner(); + this.faction = spec.getFaction(); + this.guidance = spec.getGuidance(); + this.environment = spec.getEnvironment(); + this.lifetimeTicks = spec.getLifetimeTicks(); + this.position = spec.getOrigin(); + this.velocity = spec.getVelocity(); + this.impactEnergy = spec.getImpactEnergy(); + this.age = 0; + this.impactSequence = 0; + } + + private Shot(long id, double radius, double mass, ImpactKind kind, UUID owner, String faction, + String guidance, ShotEnvironment environment, int lifetimeTicks, Vec3d position, + Vec3d velocity, int age, int impactEnergy, int impactSequence) { + this.id = id; + this.radius = radius; + this.mass = mass; + this.kind = kind; + this.owner = owner; + this.faction = faction; + this.guidance = guidance; + this.environment = environment; + this.lifetimeTicks = lifetimeTicks; + this.position = position; + this.velocity = velocity; + this.age = age; + this.impactEnergy = impactEnergy; + this.impactSequence = impactSequence; + } + + public long getId() { + return id; + } + + /** WORLD position. */ + public Vec3d getPosition() { + return position; + } + + /** WORLD velocity, blocks per tick. */ + public Vec3d getVelocity() { + return velocity; + } + + public double getSpeed() { + return velocity.lengthVector(); + } + + public int getAge() { + return age; + } + + public int getLifetimeTicks() { + return lifetimeTicks; + } + + /** What is left to spend on arrival; a shell that pays only part of the cost lowers this. */ + public int getImpactEnergy() { + return impactEnergy; + } + + public ImpactKind getKind() { + return kind; + } + + public double getRadius() { + return radius; + } + + public double getMass() { + return mass; + } + + public UUID getOwner() { + return owner; + } + + public String getFaction() { + return faction; + } + + /** The reserved guidance token, or null. Nothing reads it in stage 1; it round-trips a save. */ + public String getGuidance() { + return guidance; + } + + public ShotEnvironment getEnvironment() { + return environment; + } + + void setPosition(Vec3d newPosition) { + this.position = newPosition; + } + + void setVelocity(Vec3d newVelocity) { + this.velocity = newVelocity; + } + + void setImpactEnergy(int newImpactEnergy) { + this.impactEnergy = Math.max(0, newImpactEnergy); + } + + void incrementAge() { + this.age++; + } + + /** + * An identity for the next impact this shot declares, distinct from every other impact by any + * shot in this world. The dimension is not mixed in: the damage service is asked about one world + * at a time and two worlds cannot share a shot. + */ + long nextImpactId() { + return (id << 8) ^ (impactSequence++); + } + + NBTTagCompound writeToNBT() { + NBTTagCompound nbt = new NBTTagCompound(); + nbt.setLong("id", id); + nbt.setDouble("radius", radius); + nbt.setDouble("mass", mass); + nbt.setString("kind", kind.name()); + if (owner != null) { + nbt.setString("owner", owner.toString()); + } + if (faction != null) { + nbt.setString("faction", faction); + } + if (guidance != null) { + nbt.setString("guidance", guidance); + } + nbt.setDouble("gravity", environment.getGravityPerTickSquared()); + nbt.setInteger("lifetime", lifetimeTicks); + nbt.setDouble("posX", position.x); + nbt.setDouble("posY", position.y); + nbt.setDouble("posZ", position.z); + nbt.setDouble("velX", velocity.x); + nbt.setDouble("velY", velocity.y); + nbt.setDouble("velZ", velocity.z); + nbt.setInteger("age", age); + nbt.setInteger("energy", impactEnergy); + nbt.setInteger("impactSeq", impactSequence); + return nbt; + } + + static Shot readFromNBT(NBTTagCompound nbt) { + ImpactKind kind; + try { + kind = ImpactKind.valueOf(nbt.getString("kind")); + } catch (IllegalArgumentException wrongName) { + // A save written by a build that knew a kind this one does not. Losing the shot is worse + // than billing it as the commonest kind there is. + kind = ImpactKind.KINETIC; + } + UUID owner = nbt.hasKey("owner") ? parseUuid(nbt.getString("owner")) : null; + return new Shot(nbt.getLong("id"), nbt.getDouble("radius"), nbt.getDouble("mass"), kind, owner, + nbt.hasKey("faction") ? nbt.getString("faction") : null, + nbt.hasKey("guidance") ? nbt.getString("guidance") : null, + ShotEnvironment.gravity(nbt.getDouble("gravity")), + nbt.getInteger("lifetime"), + new Vec3d(nbt.getDouble("posX"), nbt.getDouble("posY"), nbt.getDouble("posZ")), + new Vec3d(nbt.getDouble("velX"), nbt.getDouble("velY"), nbt.getDouble("velZ")), + nbt.getInteger("age"), nbt.getInteger("energy"), nbt.getInteger("impactSeq")); + } + + private static UUID parseUuid(String value) { + try { + return UUID.fromString(value); + } catch (IllegalArgumentException notAUuid) { + return null; + } + } + + @Override + public String toString() { + return "Shot#" + id + "[pos=" + position + " vel=" + velocity + " energy=" + impactEnergy + + " age=" + age + "/" + lifetimeTicks + "]"; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java new file mode 100644 index 000000000..fa19f422c --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java @@ -0,0 +1,189 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import net.minecraft.world.storage.MapStorage; +import net.minecraft.world.storage.WorldSavedData; +import zmaster587.advancedRocketry.api.projectile.ShotEndReason; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Every shot in flight in one world, and the only thing that owns one. + * + *

    Per world, and that is the whole of the isolation

    + *

    Two shots in different worlds cannot interact, and they cannot because they are held by two + * different objects with no reference to each other — not because anything compares dimension ids on + * the way past. A shot fired in a space cell and a shot fired on a planet are stepped by their own + * world's tick, tested against their own world's blocks, and stored in their own world's save.

    + * + *

    Why a WorldSavedData

    + *

    The state needs an owner whose lifetime is the world's: attached when the world loads, written + * when it saves, gone when it unloads. A static map keyed by dimension would be all three of the + * things that make a static a defect — mutable, depended upon, and written by more than one place — + * and would additionally lose every round in flight across a restart, which for a weapon with a + * minute of flight time is a visible lie rather than a technicality.

    + */ +public class ShotRegistry extends WorldSavedData { + + public static final String DATA_NAME = "advancedRocketryShots"; + + /** How many recently ended shots keep their reason. Enough for a burst; bounded so it cannot grow. */ + private static final int ENDINGS_REMEMBERED = 64; + + private final Map shots = new LinkedHashMap<>(); + + /** + * Why recently ended shots ended. Not world state and not saved — the oldest is dropped once the + * map is full, so a caller that waits too long is told nothing rather than told a guess. + */ + private final Map endings = new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > ENDINGS_REMEMBERED; + } + }; + + private long nextId = 1L; + + public ShotRegistry() { + super(DATA_NAME); + } + + public ShotRegistry(String name) { + super(name); + } + + /** The registry of THIS world. Server side: a client has no shots because it simulates none. */ + public static ShotRegistry get(World world) { + MapStorage storage = world.getPerWorldStorage(); + ShotRegistry data = (ShotRegistry) storage.getOrLoadData(ShotRegistry.class, DATA_NAME); + if (data == null) { + data = new ShotRegistry(); + storage.setData(DATA_NAME, data); + } + return data; + } + + /** + * Admit a new shot and answer its id, or {@code -1} when the world is already carrying as many as + * it is allowed. Refusing is deliberate: evicting somebody else's round to make room for this one + * would make a burst of cheap fire a way to delete incoming fire. + */ + public long add(ShotSpec spec, int maxShots) { + if (shots.size() >= maxShots) { + return -1L; + } + long id = nextId++; + shots.put(id, new Shot(id, spec)); + markDirty(); + return id; + } + + public Shot get(long id) { + return shots.get(id); + } + + public void remove(long id) { + if (shots.remove(id) != null) { + markDirty(); + } + } + + /** + * Take a shot out of the air and say why. The reason is kept for a while after the shot itself is + * gone: a weapon asks about its round after the fact, and "it is not in the registry" cannot tell + * a hit from a round that timed out half a kilometre short. + */ + void end(long id, ShotEndReason reason) { + remove(id); + endings.put(id, reason); + } + + /** + * Why the shot with this id ended, or null if it is still up or was forgotten. Deliberately NOT + * persisted: it is an answer to a question asked seconds later, not world state. + */ + public ShotEndReason endReasonOf(long id) { + return endings.get(id); + } + + /** + * Drop everything in flight. Not a game action — nothing in the mod calls it. It exists because a + * shared test server hands one scenario's rounds to the next, and a suite that has to reason + * about which of them are still up is measuring the harness. + */ + public void clear() { + endings.clear(); + if (!shots.isEmpty()) { + shots.clear(); + markDirty(); + } + } + + /** + * A snapshot of the shots to step this tick. A copy, because stepping one can end it and a shot + * that lands may in future spawn another; iterating the live map would then be a concurrent + * modification in the middle of somebody's impact. + */ + List snapshot() { + return new ArrayList<>(shots.values()); + } + + /** Everything currently in flight here. Read-only view for diagnostics, probes and tests. */ + public Collection inFlight() { + return java.util.Collections.unmodifiableCollection(shots.values()); + } + + public int count() { + return shots.size(); + } + + /** The shot nearest a world point, or null when nothing is in flight. Diagnostics and probes. */ + public Shot nearest(Vec3d point) { + Shot best = null; + double bestSq = Double.POSITIVE_INFINITY; + for (Shot shot : shots.values()) { + double sq = shot.getPosition().squareDistanceTo(point); + if (sq < bestSq) { + bestSq = sq; + best = shot; + } + } + return best; + } + + @Override + public void readFromNBT(NBTTagCompound nbt) { + shots.clear(); + nextId = Math.max(1L, nbt.getLong("nextId")); + NBTTagList list = nbt.getTagList("shots", 10); + for (int i = 0; i < list.tagCount(); i++) { + Shot shot = Shot.readFromNBT(list.getCompoundTagAt(i)); + shots.put(shot.getId(), shot); + if (shot.getId() >= nextId) { + // A save whose counter is behind its own contents would hand out an id that is + // already in flight, and the newcomer would silently replace it in the map. + nextId = shot.getId() + 1L; + } + } + } + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound nbt) { + nbt.setLong("nextId", nextId); + NBTTagList list = new NBTTagList(); + for (Shot shot : shots.values()) { + list.appendTag(shot.writeToNBT()); + } + nbt.setTag("shots", list); + return nbt; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java new file mode 100644 index 000000000..1d12df152 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -0,0 +1,200 @@ +package zmaster587.advancedRocketry.projectile; + +import com.github.stannismod.affs.world.shield.ShieldStrike; +import com.github.stannismod.affs.world.shield.ShieldStrikeResult; +import com.github.stannismod.affs.world.shield.ShieldStrikeService; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.damage.ImpactKind; +import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.api.projectile.ShotEndReason; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; +import zmaster587.advancedRocketry.damage.ShipDamageService; + +import java.util.List; + +/** + * What a shot does between muzzle and impact. Fire one with {@link #launch}; everything after that + * happens in this world's own tick, whether or not anybody is watching. + * + *

    The layer this owns, and the two it does not

    + *
      + *
    • Here: what a shot IS while it travels — a record, its integration, and which layer it + * meets FIRST. That last one is this class's real content.
    • + *
    • The field layer (the shield's strike seam) owns what a shell does to a body that + * reaches it: how much it absorbs, and where a mirrored body goes. This class hands a strike + * over and reads the answer; it never computes a deflection or spends shield energy itself.
    • + *
    • The structure layer (the damage service) owns what an impact does to blocks. This + * class hands over a point, a direction and a budget; it names no block, no stage, no ship.
    • + *
    + * + *

    Ordering is geometric, not a pipeline

    + *

    Shield first, then hull, would be a rule that is wrong whenever the geometry says otherwise — a + * shot fired from inside a shell meets the hull with no shield in between, and a shot that + * passes a friendly bubble on the way to its target should not be billed to it. So every layer is + * asked where it would be crossed, in blocks along this tick's segment, and the smallest distance + * wins. The field layer answers {@code -1} for a ray that starts inside a shell, which is the same + * statement in its own vocabulary.

    + * + *

    Server only

    + *

    A client that simulated shots would be describing a different flight from the one about to hit + * somebody. It is told about shots; it never steps one.

    + */ +public final class ShotSubstrate { + + /** + * How many crossings one shot may resolve within a single tick. A reflected shot resumes inside + * the same tick with what is left of its step, and two shells facing each other would otherwise + * bounce it forever inside one tick. At the cap the shot simply stops advancing this tick and + * carries on next one — it is a bound on work, not on how many times a shot may bounce. + */ + private static final int MAX_CROSSINGS_PER_TICK = 4; + + /** + * How far past a crossing the shot is nudged before the next test. A body left exactly on a + * surface is at the mercy of the last bit of a double: the same crossing can be found again, at + * distance zero, and the tick makes no progress. + */ + private static final double CROSSING_EPSILON = 1.0E-4D; + + private ShotSubstrate() { + } + + /** + * Admit a shot into {@code world} and answer its id, or {@code -1} when it was refused — the + * substrate is switched off, the world is a client's, or that world is already carrying as many + * shots as it is allowed. A weapon that fired and got {@code -1} did not fire. + */ + public static long launch(World world, ShotSpec spec) { + if (world == null || world.isRemote || spec == null + || !ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + return -1L; + } + return ShotRegistry.get(world).add(spec, ARConfiguration.getCurrentConfig().maxShotsPerWorld); + } + + /** Advance every shot in this world by one tick. Driven by {@link ShotSubstrateEvents}. */ + public static void tick(World world) { + if (world == null || world.isRemote + || !ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + return; + } + ShotRegistry registry = ShotRegistry.get(world); + if (registry.count() == 0) { + return; + } + List shots = registry.snapshot(); + for (Shot shot : shots) { + ShotEndReason end = step(world, shot); + if (end != null) { + registry.end(shot.getId(), end); + } + } + registry.markDirty(); + } + + /** + * One tick of one shot: why it ended, or null if it is still in the air. + * + *

    Package-visible so a test can step a single shot deterministically instead of waiting on a + * server tick and then having to explain which tick it was looking at.

    + */ + static ShotEndReason step(World world, Shot shot) { + shot.incrementAge(); + if (shot.getAge() > shot.getLifetimeTicks()) { + return ShotEndReason.EXPIRED; + } + + Vec3d position = shot.getPosition(); + Vec3d velocity = shot.getVelocity(); + double gravity = shot.getEnvironment().getGravityPerTickSquared(); + if (gravity > 0.0D) { + // Semi-implicit Euler: the tick's own acceleration is applied before the step, so a shot + // fired flat starts falling in the tick it is fired rather than the one after. + velocity = velocity.addVector(0.0D, -gravity, 0.0D); + } + + double timeLeft = 1.0D; + for (int crossing = 0; crossing < MAX_CROSSINGS_PER_TICK && timeLeft > 1.0E-6D; crossing++) { + double speed = velocity.lengthVector(); + if (speed <= 1.0E-9D) { + break; // going nowhere; it still ages out + } + Vec3d direction = velocity.scale(1.0D / speed); + double reach = speed * timeLeft; + Vec3d segmentEnd = position.add(velocity.scale(timeLeft)); + + double fieldDistance = ShieldStrikeService.nearestShellCrossing(world, position, direction, + reach); + StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, position, segmentEnd); + double structureDistance = structure == null ? -1.0D : structure.distance; + + boolean fieldFirst = fieldDistance >= 0.0D + && (structureDistance < 0.0D || fieldDistance <= structureDistance); + boolean structureFirst = structureDistance >= 0.0D && !fieldFirst; + + if (structureFirst) { + shot.setPosition(structure.point); + shot.setVelocity(velocity); + strikeStructure(world, shot, structure.point, direction); + return ShotEndReason.STRUCTURE_IMPACT; + } + if (!fieldFirst) { + position = segmentEnd; + timeLeft = 0.0D; + break; + } + + ShieldStrikeResult result = ShieldStrikeService.resolve(world, + ShieldStrike.kineticBody(position, direction, reach, shot.getImpactEnergy(), + velocity)); + if (!result.isIntercepted()) { + // The shell was crossed but paid nothing — it went down between the two questions. + // Carry on through where it used to be rather than stopping in mid-air. + position = position.add(direction.scale(fieldDistance + CROSSING_EPSILON)); + timeLeft -= (fieldDistance + CROSSING_EPSILON) / speed; + continue; + } + + double consumed = (fieldDistance + CROSSING_EPSILON) / speed; + timeLeft -= consumed; + Vec3d hitPoint = result.getHitPoint() == null + ? position.add(direction.scale(fieldDistance)) : result.getHitPoint(); + + if (result.isReflected()) { + Vec3d bounced = result.getReflectedVelocity(); + if (bounced == null + || bounced.lengthVector() + < ARConfiguration.getCurrentConfig().shotReflectionSpeedFloor) { + shot.setPosition(hitPoint); + shot.setVelocity(new Vec3d(0.0D, 0.0D, 0.0D)); + return ShotEndReason.REFLECTED_TOO_SLOW; + } + velocity = bounced; + position = hitPoint.add(bounced.normalize().scale(CROSSING_EPSILON)); + continue; + } + if (result.getResidualImpactEnergy() <= 0) { + shot.setPosition(hitPoint); + shot.setVelocity(velocity); + return ShotEndReason.FIELD_ABSORBED; + } + // Graceful penetration: the shell spent everything it had and could not cover the cost. + // The body carries on, worth less. + shot.setImpactEnergy(result.getResidualImpactEnergy()); + position = hitPoint.add(direction.scale(CROSSING_EPSILON)); + } + + shot.setPosition(position); + shot.setVelocity(velocity); + return null; + } + + /** Hand the impact over. One call, one identity, and no opinion about what it means. */ + private static void strikeStructure(World world, Shot shot, Vec3d point, Vec3d direction) { + ImpactKind kind = shot.getKind(); + ShipDamageService.apply(world, ImpactRequest.penetrating(shot.nextImpactId(), point, direction, + shot.getImpactEnergy(), kind)); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrateEvents.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrateEvents.java new file mode 100644 index 000000000..66586d6af --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrateEvents.java @@ -0,0 +1,36 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.world.World; +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; + +/** + * When the substrate runs, and nothing else. Every decision it makes lives in {@link ShotSubstrate} + * and every piece of state it touches belongs to a world's {@link ShotRegistry}; this class answers + * only "which event, and when". + * + *

    Phase END, so a shot is stepped against the world as the tick leaves it: a shell raised this + * tick is up when the round arrives, and a ship that moved this tick is tested where it now is + * rather than where it was. There is no unload handler because there is nothing to clean up — the + * shots belong to the world's own saved data and go where it goes.

    + */ +@Mod.EventBusSubscriber(modid = Constants.modId) +public final class ShotSubstrateEvents { + + private ShotSubstrateEvents() { + } + + @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; + } + ShotSubstrate.tick(world); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java new file mode 100644 index 000000000..d9fe325b1 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -0,0 +1,156 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.damage.StructureDamageEngine; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; + +import java.util.Map; + +/** + * Where along a swept segment the first solid thing is — and nothing about what happens next. + * + *

    Two places blocks live, one answer

    + *

    A world's own blocks sit in the world frame. A ship's blocks do not: they stay at fixed + * addresses in a shipyard subspace while the ship flies elsewhere, so a traversal of the world frame + * finds nothing where a hull visibly is. Both are searched here and the earlier crossing wins, which + * is what lets everything above this class hold a single question — "what did I hit first" — instead + * of a branch on target class.

    + * + *

    The ship segment is transformed, not sampled

    + *

    A ship's transform is rigid, so a straight world segment is a straight subspace segment: both + * endpoints are mapped and the same exact traversal runs there. Sampling the world segment and asking + * "is there a ship block under this point" would reintroduce, one layer up, the skipping that the + * traversal exists to remove.

    + * + *

    What this does NOT see — stage 1

    + *

    An unloaded region. A voxel whose chunk is not loaded is skipped, not treated as solid and + * not treated as empty — nobody looked. That is honest only because stage 1 fires in the band ships + * fly in, where there are no world blocks at all; on a planet it means a shot crosses unloaded terrain + * untouched. Closing it is stage 2's conservative occupancy summary, and until that lands this + * limitation is the reason a ground battery is not yet a shipped feature.

    + */ +final class StructureCrossing { + + /** + * How many voxels one segment may be examined over in one tick, per frame searched. A bound on + * work: a shot faster than this per tick stops being tested part way along its step. Nothing in + * stage 1 travels that fast, and the cap logs rather than lies when something does. + */ + private static final int MAX_VOXELS_PER_SEGMENT = 4096; + + /** The crossing, in the caller's own (world) terms. */ + static final class Hit { + /** Distance from the segment start, in blocks. */ + final double distance; + /** Where it happened, in WORLD coordinates. */ + final Vec3d point; + /** The block struck, in the frame it was found in — diagnostics only. */ + final BlockPos block; + /** The ship whose blocks were struck, or null for the world's own. Diagnostics only. */ + final String shipId; + + private Hit(double distance, Vec3d point, BlockPos block, String shipId) { + this.distance = distance; + this.point = point; + this.block = block; + this.shipId = shipId; + } + } + + private StructureCrossing() { + } + + /** The first structure the segment {@code from -> to} meets, or null when it meets none. */ + static Hit firstAlong(World world, Vec3d from, Vec3d to) { + if (world == null || from == null || to == null) { + return null; + } + double length = to.subtract(from).lengthVector(); + if (length <= 0.0D) { + return null; + } + + Hit best = worldFrameHit(world, from, to, length); + // The segment's own bounding box, min-first: AxisAlignedBB#intersects reads its six doubles + // as an ordered box and quietly answers "no" for one given the other way round. + double minX = Math.min(from.x, to.x); + double minY = Math.min(from.y, to.y); + double minZ = Math.min(from.z, to.z); + double maxX = Math.max(from.x, to.x); + double maxY = Math.max(from.y, to.y); + double maxZ = Math.max(from.z, to.z); + Map ships = VSIntegration.loadedShipWorldBounds(world); + for (Map.Entry ship : ships.entrySet()) { + if (!ship.getValue().intersects(minX, minY, minZ, maxX, maxY, maxZ)) { + continue; + } + Hit hit = shipFrameHit(world, ship.getKey(), from, to, length); + if (hit != null && (best == null || hit.distance < best.distance)) { + best = hit; + } + } + return best; + } + + private static Hit worldFrameHit(World world, Vec3d from, Vec3d to, double length) { + // Above or below the build height there are no world blocks by construction, and the pose + // band ships fly in is entirely up there. Skipping the traversal is not an optimisation for + // its own sake: it is what keeps a shot crossing a cell from touching the chunk system at all. + double minY = Math.min(from.y, to.y); + double maxY = Math.max(from.y, to.y); + if (maxY < 0.0D || minY > world.getHeight()) { + return null; + } + return traverse(world, from, to, length, null); + } + + private static Hit shipFrameHit(World world, String shipId, Vec3d from, Vec3d to, double length) { + double[] localFrom = VSIntegration.toShipFrameFor(world, shipId, from.x, from.y, from.z); + double[] localTo = VSIntegration.toShipFrameFor(world, shipId, to.x, to.y, to.z); + if (localFrom == null || localTo == null) { + return null; + } + return traverse(world, new Vec3d(localFrom[0], localFrom[1], localFrom[2]), + new Vec3d(localTo[0], localTo[1], localTo[2]), length, shipId); + } + + /** + * Traverse one frame's voxels and report the first solid one, expressed back in world terms. + * {@code worldLength} is the segment's length in the WORLD frame: a ship's transform is rigid so + * lengths are equal, and using the world length keeps every distance this class hands out + * comparable with the field layer's, which is measured in the world frame. + */ + private static Hit traverse(World world, Vec3d from, Vec3d to, double worldLength, + final String shipId) { + final Hit[] found = new Hit[1]; + final Vec3d segFrom = from; + final Vec3d segTo = to; + SweptSegment.traverse(from, to, MAX_VOXELS_PER_SEGMENT, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos pos, double tEnter) { + if (!world.isBlockLoaded(pos)) { + return false; // nobody looked; see the class note + } + if (!StructureDamageEngine.isStructure(world, pos, world.getBlockState(pos))) { + return false; + } + Vec3d localPoint = segFrom.add(segTo.subtract(segFrom).scale(tEnter)); + Vec3d worldPoint = localPoint; + if (shipId != null) { + double[] w = VSIntegration.toWorldFrameFor(world, shipId, localPoint.x, + localPoint.y, localPoint.z); + if (w == null) { + return true; // the ship stopped answering mid-traversal: stop, claim nothing + } + worldPoint = new Vec3d(w[0], w[1], w[2]); + } + found[0] = new Hit(tEnter * worldLength, worldPoint, pos, shipId); + return true; + } + }); + return found[0]; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java b/src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java new file mode 100644 index 000000000..02fb36970 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java @@ -0,0 +1,117 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +/** + * Every block a straight segment passes through, in the order it passes through them. + * + *

    Why not "sample the path every block"

    + *

    Because sampling misses things and nobody can say which. Stepping a unit distance along the ray + * and reading the block at each sample walks past a voxel whenever the ray clips its corner, and the + * shape of what it misses depends on the angle — so a wall that stops a shot from the front lets it + * through at forty degrees, intermittently. This is an exact traversal (Amanatides & Woo): it + * yields the voxels the segment actually enters, in order, with the parameter at which it enters + * each. No step size, so no speed at which a wall becomes transparent.

    + * + *

    Pure

    + *

    Nothing here touches a world. It is geometry, and it is unit-testable as geometry — which is the + * point, because the property that matters ("a fast segment does not skip a block") is a property of + * the traversal, not of the blocks it happens to find.

    + */ +public final class SweptSegment { + + /** + * Told about each voxel the segment enters. + */ + public interface Visitor { + /** + * @param pos the voxel + * @param tEnter the parameter in {@code [0,1]} along {@code from -> to} at which the segment + * enters it ({@code 0} for the voxel the segment starts in) + * @return true to stop the traversal here + */ + boolean visit(BlockPos pos, double tEnter); + } + + private SweptSegment() { + } + + /** + * Walk the voxels of {@code from -> to}, at most {@code maxVoxels} of them. + * + *

    The cap is a bound on work, not a physical statement: a segment longer than the cap stops + * being examined part way, and a caller that must not silently under-test has to notice it hit + * the cap. It returns the number of voxels visited so the caller can.

    + */ + public static int traverse(Vec3d from, Vec3d to, int maxVoxels, Visitor visitor) { + if (from == null || to == null || visitor == null || maxVoxels <= 0) { + return 0; + } + double dx = to.x - from.x; + double dy = to.y - from.y; + double dz = to.z - from.z; + + int x = floor(from.x); + int y = floor(from.y); + int z = floor(from.z); + + int stepX = signum(dx); + int stepY = signum(dy); + int stepZ = signum(dz); + + double tMaxX = firstBoundary(from.x, dx, x, stepX); + double tMaxY = firstBoundary(from.y, dy, y, stepY); + double tMaxZ = firstBoundary(from.z, dz, z, stepZ); + + double tDeltaX = stepX == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0D / dx); + double tDeltaY = stepY == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0D / dy); + double tDeltaZ = stepZ == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0D / dz); + + double t = 0.0D; + int visited = 0; + while (visited < maxVoxels) { + visited++; + if (visitor.visit(new BlockPos(x, y, z), t)) { + return visited; + } + double next = Math.min(tMaxX, Math.min(tMaxY, tMaxZ)); + if (next > 1.0D || next == Double.POSITIVE_INFINITY) { + return visited; // the segment ends inside the voxel we are in + } + t = next; + // A corner crossing advances one axis here and the other on the next iteration at the + // same t: one extra voxel is visited, which over-includes rather than skips. For a hit + // test that is the safe direction to be wrong in. + if (next == tMaxX) { + x += stepX; + tMaxX += tDeltaX; + } else if (next == tMaxY) { + y += stepY; + tMaxY += tDeltaY; + } else { + z += stepZ; + tMaxZ += tDeltaZ; + } + } + return visited; + } + + /** The parameter at which the segment first leaves the voxel it starts in, along one axis. */ + private static double firstBoundary(double origin, double delta, int voxel, int step) { + if (step == 0) { + return Double.POSITIVE_INFINITY; + } + double boundary = step > 0 ? voxel + 1 : voxel; + return (boundary - origin) / delta; + } + + private static int signum(double value) { + return value > 0.0D ? 1 : (value < 0.0D ? -1 : 0); + } + + private static int floor(double value) { + int i = (int) value; + return value < i ? i - 1 : i; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java new file mode 100644 index 000000000..b1ca553d3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java @@ -0,0 +1,188 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * What a shot IS between muzzle and impact: a record that crosses distance the world is not loaded + * for, and that cannot be outrun by a wall. + * + *

    Both properties are about the substrate, not about weapons or damage. The first says a flight + * costs the host nothing but three vectors — a shot that quietly force-loaded a corridor of chunks + * would pass every "did it arrive" test ever written and still be the thing this design exists to + * avoid. The second says the integration is swept: at a step longer than a wall is thick, a + * position-by-position simulation reports a clean miss through solid stone, and the faster the round + * the more reliably it lies.

    + */ +public class ShotSubstrateE2ETest extends AbstractSharedServerTest { + + /** + * The band ships fly in — millions of blocks up, where a cell's contents live and the world has no + * blocks of its own. Firing here is what makes "nothing was loaded" a statement about the + * substrate rather than about an empty patch of overworld somebody might later build in. + */ + private static final double POSE_BAND_Y = 2_000_064.5D; + + /** A site of this class's own, clear of the other server scenarios. */ + private static final int X = 9200, Y = 80, Z = 9200; + + @Test + public void aShotCrossesEmptySpaceWithoutLoadingAnyWorld() throws Exception { + exec("artest shot clear 0"); + double originX = 40_000.5D; + double originZ = 40_000.5D; + double perTick = 50.0D; + + long id = readLong(exec("artest shot fire 0 " + originX + " " + POSE_BAND_Y + " " + originZ + + " " + perTick + " 0 0 4000 400"), "id"); + assertTrue("the launch was refused, so nothing else here means anything", id > 0); + + for (int tick = 0; tick < 10; tick++) { + exec("artest shield tick 0"); + } + + String flying = exec("artest shot read 0 " + id); + assertTrue("the shot ended in mid-flight across empty space: " + flying, + flying.contains("\"present\":true")); + // Against its OWN age, not against a tick count: this server is really running, so the + // number of steps a shot has taken is not something a test gets to decide. What is pinned is + // the rate — one velocity per tick of age, whoever did the ticking. + int age = extractInt(flying, "age"); + assertTrue("the shot never advanced at all: " + flying, age >= 10); + assertEquals("a shot must move by its velocity once per tick of its own age: " + flying, + originX + perTick * age, readDouble(flying, "x"), 1.0E-6D); + assertEquals("nothing acts on it out there, so it must not have been deflected: " + flying, + POSE_BAND_Y, readDouble(flying, "y"), 1.0E-6D); + assertEquals(originZ, readDouble(flying, "z"), 1.0E-6D); + + // The precise claim: it crossed those chunks and none of them came into memory. A count of + // all loaded chunks would move for reasons that have nothing to do with this shot. + int chunkZ = (int) Math.floor(originZ) >> 4; + for (int blocksAlong = 0; blocksAlong <= 480; blocksAlong += 160) { + int chunkX = (int) Math.floor(originX + blocksAlong) >> 4; + String loaded = exec("artest chunk loaded 0 " + chunkX + " " + chunkZ); + assertTrue("the flight loaded the world under it at chunk " + chunkX + "," + chunkZ + + ": " + loaded + ". A shot that pulls a corridor of chunks along with it is an" + + " attack on the host, which is the whole reason it is not an entity", + loaded.contains("\"loaded\":false")); + } + } + + @Test + public void aShotEndsForAStatedReasonRatherThanJustDisappearing() throws Exception { + exec("artest shot clear 0"); + long id = readLong(exec("artest shot fire 0 " + 41_000.5D + " " + POSE_BAND_Y + " " + 41_000.5D + + " 10 0 0 4000 3"), "id"); + assertTrue("the launch was refused", id > 0); + + for (int tick = 0; tick < 5; tick++) { + exec("artest shield tick 0"); + } + + String gone = exec("artest shot read 0 " + id); + assertTrue("a shot with a three-tick lifetime was still up after five: " + gone, + gone.contains("\"present\":false")); + assertEquals("a shot that timed out must say so — a weapon that cannot tell a miss from a hit" + + " cannot report either: " + gone, "EXPIRED", extractString(gone, "ended")); + } + + @Test + public void aFastShotCannotPassThroughAOneBlockWall() throws Exception { + exec("artest shot clear 0"); + exec("artest damage clear-impacts"); + int wallX = X + 40; + buildWall(wallX); + + int stageCost = extractInt(exec("artest damage stage 0 " + wallX + " " + Y + " " + Z), + "stageCost"); + assertTrue("no stage cost for the wall, so nothing here could show damage", stageCost > 0); + + // 60 blocks a tick against a wall one block thick: a per-tick position test looks before the + // wall and then well past it, and sees stone at neither. + long id = readLong(exec("artest shot fire 0 " + (X + 0.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D) + + " 60 0 0 " + stageCost + " 40"), "id"); + assertTrue("the launch was refused", id > 0); + exec("artest shield tick 0"); + + String after = exec("artest shot read 0 " + id); + assertTrue("the shot flew straight through a solid wall — the step is longer than the wall is" + + " thick, which is exactly the case a swept segment exists for: " + after, + after.contains("\"present\":false")); + assertEquals("it stopped, but not by hitting anything: " + after, + "STRUCTURE_IMPACT", extractString(after, "ended")); + + String wall = exec("artest damage stage 0 " + wallX + " " + Y + " " + Z); + assertTrue("the shot stopped at the wall but the wall took nothing: " + wall, + extractInt(wall, "stage") > 0 || wall.contains("\"wasDestroyed\":true")); + } + + @Test + public void aShotIsOnlyEverInTheWorldItWasFiredIn() throws Exception { + // The isolation is structural — one registry per world, with no reference between them — so + // what is worth pinning is that firing into one world leaves the other's count alone. + assertTrue("could not bring the second dimension up", + exec("artest chunk forceload -1 0 0").contains("\"ok\":true")); + exec("artest shot clear 0"); + exec("artest shot clear -1"); + + long id = readLong(exec("artest shot fire 0 " + 42_000.5D + " " + POSE_BAND_Y + " " + 42_000.5D + + " 20 0 0 4000 200"), "id"); + assertTrue("the launch was refused", id > 0); + + assertEquals("the shot was fired into the overworld and is not there: " + + exec("artest shot list 0"), 1, extractInt(exec("artest shot list 0"), "count")); + assertEquals("a shot fired in one world turned up in another: " + exec("artest shot list -1"), + 0, extractInt(exec("artest shot list -1"), "count")); + + exec("artest shield tick -1"); + assertTrue("ticking the other world stepped this world's shot: " + exec("artest shot read 0 " + id), + exec("artest shot read 0 " + id).contains("\"present\":true")); + + exec("artest shot clear 0"); + exec("artest chunk release -1 0 0"); + } + + /** A wall one block thick, three by three, with clear air on both sides of it. */ + private void buildWall(int wallX) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((X - 4) >> 4) + " " + + ((Z - 4) >> 4) + " " + ((wallX + 8) >> 4) + " " + ((Z + 4) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the range", exec("artest fill 0 " + (X - 2) + " " + (Y - 2) + " " + + (Z - 2) + " " + (wallX + 8) + " " + (Y + 3) + " " + (Z + 2) + " minecraft:air") + .contains("\"ok\":true")); + assertTrue("could not build the wall", exec("artest fill 0 " + wallX + " " + (Y - 1) + " " + + (Z - 1) + " " + wallX + " " + (Y + 1) + " " + (Z + 1) + " minecraft:stone") + .contains("\"ok\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[\\d.eE+]+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java new file mode 100644 index 000000000..0206a2781 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java @@ -0,0 +1,132 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.projectile.SweptSegment; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The one promise a swept traversal makes: nothing between the two ends is passed unseen. + * + *

    That is the whole reason the substrate does not step along its path reading blocks — and it is a + * property of the geometry, so it is checkable here, at a cost of a millisecond, rather than by + * firing rounds at walls on a server and hoping the angle that fails is one somebody tried. These + * tests say nothing about blocks, worlds or shots: a traversal that reported the right voxels would + * satisfy them whatever it was later used to look up.

    + */ +public class SweptSegmentTest { + + /** Collect every voxel a segment enters, with the parameter it entered at. */ + private static List walk(Vec3d from, Vec3d to) { + final List visits = new ArrayList<>(); + SweptSegment.traverse(from, to, 100_000, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos pos, double tEnter) { + visits.add(new Visit(pos, tEnter)); + return false; + } + }); + return visits; + } + + @Test + public void aOneBlockWallIsEnteredHoweverFastTheSegmentAndFromWhateverAngle() { + // The wall is the plane x = 40, one block thick. A segment starting well short of it and + // ending well past it MUST enter a voxel of that plane — that is what "cannot be passed + // through" means, and it has to hold at any speed, because speed is only segment length. + int failures = 0; + StringBuilder detail = new StringBuilder(); + for (int speed : new int[]{2, 17, 60, 400, 3000}) { + for (double slope = -1.5D; slope <= 1.5D; slope += 0.17D) { + Vec3d from = new Vec3d(38.5D, 64.5D, 12.5D); + Vec3d to = new Vec3d(38.5D + speed, 64.5D + speed * slope, 12.5D + speed * slope * 0.3D); + boolean entered = false; + for (Visit visit : walk(from, to)) { + if (visit.pos.getX() == 40) { + entered = true; + break; + } + } + if (!entered) { + failures++; + detail.append("\n speed=").append(speed).append(" slope=").append(slope); + } + } + } + assertEquals("a segment crossing the plane x=40 skipped it:" + detail, 0, failures); + } + + @Test + public void consecutiveVoxelsTouchFaceToFace() { + // A gap between two reported voxels is a hole in the path: whatever lives there was never + // asked about. Every step must therefore move exactly one block along exactly one axis. + List visits = walk(new Vec3d(0.3D, 0.7D, 0.1D), new Vec3d(53.9D, -21.4D, 37.2D)); + assertTrue("a long diagonal should cross many voxels, got " + visits.size(), + visits.size() > 50); + for (int i = 1; i < visits.size(); i++) { + BlockPos previous = visits.get(i - 1).pos; + BlockPos current = visits.get(i).pos; + int delta = Math.abs(current.getX() - previous.getX()) + + Math.abs(current.getY() - previous.getY()) + + Math.abs(current.getZ() - previous.getZ()); + assertEquals("step " + i + " jumped from " + previous + " to " + current, 1, delta); + } + } + + @Test + public void entryParametersRunForwardAndStayInsideTheSegment() { + // The parameter is what the caller turns into a distance and a point. Out of order, or + // outside [0,1], and a crossing gets compared against another layer's at the wrong place. + List visits = walk(new Vec3d(2.25D, 70.5D, -4.75D), new Vec3d(-31.5D, 58.0D, 19.25D)); + double previous = -1.0D; + for (Visit visit : visits) { + assertTrue("entry parameter " + visit.t + " at " + visit.pos + " is outside the segment", + visit.t >= 0.0D && visit.t <= 1.0D); + assertTrue("entry parameters went backwards at " + visit.pos, visit.t >= previous); + previous = visit.t; + } + assertEquals("the traversal must start in the voxel the segment starts in", 0.0D, + visits.get(0).t, 0.0D); + } + + @Test + public void aSegmentThatEndsWhereItStartedReportsOnlyItsOwnVoxel() { + // A shot that is not going anywhere this tick must not be told it crossed something. + List visits = walk(new Vec3d(10.5D, 64.5D, 10.5D), new Vec3d(10.5D, 64.5D, 10.5D)); + assertEquals("a zero-length segment covers one voxel", 1, visits.size()); + assertEquals(new BlockPos(10, 64, 10), visits.get(0).pos); + } + + @Test + public void theVoxelCapBoundsTheWorkAndSaysHowMuchItDid() { + // The cap has to be visible: a caller that silently examined a tenth of its path would + // report "nothing there" about a stretch nobody looked at. + final int[] seen = {0}; + int visited = SweptSegment.traverse(new Vec3d(0.5D, 0.5D, 0.5D), new Vec3d(900.5D, 0.5D, 0.5D), + 7, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos pos, double tEnter) { + seen[0]++; + return false; + } + }); + assertEquals("the traversal must stop at the cap", 7, visited); + assertEquals("and must report exactly what it examined", 7, seen[0]); + } + + private static final class Visit { + private final BlockPos pos; + private final double t; + + private Visit(BlockPos pos, double t) { + this.pos = pos; + this.t = t; + } + } +} From 55f874bb05a9f454e80171e3a978083c453b65fd Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 15 Aug 2026 18:21:33 +0300 Subject: [PATCH 06/35] feat: a shot that ends says where, in coordinates a player can see - carry the end point beside the end reason, world-frame always - pin a shot stopping at the hull of a ship that has moved - pin a shot bouncing off a charged shell and staying up - report endX/endY/endZ from the shot probe --- .../command/test/TestProbeCommand.java | 10 +- .../projectile/ShotRegistry.java | 39 +++- .../projectile/ShotSubstrate.java | 5 +- .../test/server/ShotHitsShipHullE2ETest.java | 221 ++++++++++++++++++ .../test/server/ShotSubstrateE2ETest.java | 63 +++++ 5 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index e48cd3200..0bc3a9e48 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -325,10 +325,14 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] zmaster587.advancedRocketry.projectile.Shot shot = registry.get(Long.parseLong(args[2])); if (shot == null) { - zmaster587.advancedRocketry.api.projectile.ShotEndReason ended = - registry.endReasonOf(Long.parseLong(args[2])); + zmaster587.advancedRocketry.projectile.ShotRegistry.Ending ended = + registry.endingOf(Long.parseLong(args[2])); send(sender, "{\"ok\":true,\"present\":false,\"ended\":\"" - + (ended == null ? "" : ended.name()) + "\",\"count\":" + registry.count() + "}"); + + (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(shot) + "}"); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java index fa19f422c..dc7fa6fd9 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java @@ -41,12 +41,12 @@ public class ShotRegistry extends WorldSavedData { private final Map shots = new LinkedHashMap<>(); /** - * Why recently ended shots ended. Not world state and not saved — the oldest is dropped once the + * How recently ended shots ended. Not world state and not saved — the oldest is dropped once the * map is full, so a caller that waits too long is told nothing rather than told a guess. */ - private final Map endings = new LinkedHashMap() { + private final Map endings = new LinkedHashMap() { @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { return size() > ENDINGS_REMEMBERED; } }; @@ -102,19 +102,44 @@ public void remove(long id) { * gone: a weapon asks about its round after the fact, and "it is not in the registry" cannot tell * a hit from a round that timed out half a kilometre short. */ - void end(long id, ShotEndReason reason) { + void end(long id, ShotEndReason reason, Vec3d where) { remove(id); - endings.put(id, reason); + endings.put(id, new Ending(reason, where)); } /** - * Why the shot with this id ended, or null if it is still up or was forgotten. Deliberately NOT + * How the shot with this id ended, or null if it is still up or was forgotten. Deliberately NOT * persisted: it is an answer to a question asked seconds later, not world state. */ - public ShotEndReason endReasonOf(long id) { + public Ending endingOf(long id) { return endings.get(id); } + /** + * How a shot ended: the reason, and the WORLD point it ended at. The point is world-frame even + * when the thing it hit was a ship's block, which lives millions of blocks away in a shipyard + * subspace — a weapon showing an impact where its round actually stopped needs the place the + * player can see, not the address the block is filed under. + */ + public static final class Ending { + private final ShotEndReason reason; + private final Vec3d point; + + private Ending(ShotEndReason reason, Vec3d point) { + this.reason = reason; + this.point = point; + } + + public ShotEndReason getReason() { + return reason; + } + + /** WORLD point, never null: a shot that ended is always somewhere. */ + public Vec3d getPoint() { + return point; + } + } + /** * Drop everything in flight. Not a game action — nothing in the mod calls it. It exists because a * shared test server hands one scenario's rounds to the next, and a suite that has to reason diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 1d12df152..6df62ba2a 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -88,7 +88,10 @@ public static void tick(World world) { for (Shot shot : shots) { ShotEndReason end = step(world, shot); if (end != null) { - registry.end(shot.getId(), end); + // The shot's own position IS where it ended: every terminal branch of the step sets + // it to the crossing point before returning, so there is one place that decides + // where a round stopped rather than two that could disagree. + registry.end(shot.getId(), end, shot.getPosition()); } } registry.markDirty(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java new file mode 100644 index 000000000..2bebbd425 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java @@ -0,0 +1,221 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A shot that meets a SHIP — the case the whole substrate exists for, and the one its world-block + * tests cannot reach. + * + *

    A ship's blocks are not where the ship appears to be: they sit at fixed addresses in a shipyard + * subspace millions of blocks away while the hull flies around. So a swept segment computed in world + * coordinates crosses nothing — the world frame is empty air where the hull visibly is. The + * substrate therefore maps both ends of the segment into each candidate ship's frame, traverses + * there, and maps the crossing point back out. Three conversions, none of which announces itself when + * it is wrong: a frame error here is not an exception, it is a round that flies through a hull.

    + * + *

    What makes this evidence rather than a coincidence

    + *

    Two controls, both asserted before any conclusion is drawn. The world frame at the target must + * genuinely hold air, so a hit cannot have come from the world-frame traversal; and the + * subject block must be undamaged at its subspace address beforehand, so "damaged afterwards" is + * about this shot. The end point is then checked against the ship's WORLD position — with the + * mapping-back-out leg deleted, a shot would report ending five million blocks away in a shipyard + * nobody can see, and every other assertion here would still pass.

    + */ +public class ShotHitsShipHullE2ETest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + + /** A build site of this class's own, clear of the other ship scenarios on this shared server. */ + private static final int SRC_X = 6400, SRC_Y = 80, SRC_Z = 6400; + /** Where the ship is moved to: in the air, INSIDE build height, so "the world is air there" is a + * measurement rather than a consequence of being above the world's ceiling. */ + private static final int FAR_X = 6400, FAR_Y = 150, FAR_Z = 8800; + + /** Fast enough that one tick's segment crosses the whole hull — the case a point test misses. */ + private static final double SPEED = 40.0D; + /** Enough budget to be spent on more than the first block it meets. */ + private static final int ENERGY = 200000; + + @Test + public void aShotStopsAtTheHullOfAMovedShipAndDamagesItsOwnBlock() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest damage clear-impacts"); + exec("artest shot clear 0"); + + String shipId = buildAndMoveShip(); + + // A block of this ship whose subspace address we know: its pilot seat. + String seat = exec("artest vs find-seat 0 id " + shipId); + assertTrue("could not locate the ship's seat, so there is no known block to aim at: " + seat, + seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), + subZ = extractInt(seat, "seatZ"); + + String mapped = exec("artest vs to-world 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z + + " " + subX + " " + subY + " " + subZ); + assertTrue("the seat's subspace address could not be mapped to a world point: " + mapped, + mapped.contains("\"ok\":true")); + double worldX = extractDouble(mapped, "worldX"); + double worldY = extractDouble(mapped, "worldY"); + double worldZ = extractDouble(mapped, "worldZ"); + + // ARRANGEMENT CONTROL — the mapped point is actually on the ship as the world sees it. If it + // is not, everything below measures a broken fixture rather than the substrate. + String moved = exec("artest vs ship-info 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the moved ship is not managed at its new position: " + moved, + moved.contains("\"managed\":true")); + double shipX = extractDouble(moved, "posX"), shipY = extractDouble(moved, "posY"), + shipZ = extractDouble(moved, "posZ"); + double offHull = Math.sqrt(sq(worldX - shipX) + sq(worldY - shipY) + sq(worldZ - shipZ)); + assertTrue("the seat's mapped world point (" + worldX + "," + worldY + "," + worldZ + ") is " + + offHull + " blocks from the ship's own world position: the fixture, not the" + + " substrate, is what this run would be measuring. mapped=" + mapped, offHull < 64.0D); + + // CONTROL 1 — the WORLD frame is air along the line of fire. A hit therefore cannot have come + // from the world-frame traversal, which is the only other way this substrate finds anything. + for (int drop = -4; drop <= 4; drop++) { + String worldBlock = exec("artest damage stage 0 " + (int) Math.floor(worldX) + " " + + ((int) Math.floor(worldY) + drop) + " " + (int) Math.floor(worldZ)); + assertTrue("the world frame holds a block at the target, " + drop + " blocks off the seat: " + + worldBlock + ". A shot stopping here would prove nothing about ship frames", + worldBlock.contains("\"block\":\"minecraft:air\"")); + } + + // CONTROL 2 — the subject is undamaged at its SUBSPACE address, where the ship's blocks are. + String before = stage(subX, subY, subZ); + assertTrue("the seat's subspace address holds no block, so nothing below is about the ship: " + + before, !before.contains("\"block\":\"minecraft:air\"")); + assertTrue("the subject block is already damaged before the shot: " + before, + readLong(before, "stage") == 0); + + // Fire straight down through the seat's WORLD position, from clear air above it. + long id = readLong(exec("artest shot fire 0 " + worldX + " " + (worldY + 30.0D) + " " + worldZ + + " 0 " + (-SPEED) + " 0 " + ENERGY + " 40"), "id"); + assertTrue("the launch was refused, so nothing else here means anything", id > 0); + exec("artest shield tick 0"); + + String after = exec("artest shot read 0 " + id); + assertTrue("the shot is still in flight after a step that crossed the hull — a segment computed" + + " in the world frame finds nothing where a ship visibly is, which is exactly what" + + " this substrate maps around: " + after, after.contains("\"present\":false")); + assertTrue("the shot stopped, but not by meeting structure: " + after, + "STRUCTURE_IMPACT".equals(extractString(after, "ended"))); + + // The damage landed on the SHIP's own block, at its subspace address. + String hull = stage(subX, subY, subZ); + boolean staged = readLong(hull, "stage") > 0; + boolean destroyed = hull.contains("\"wasDestroyed\":true") + || hull.contains("\"block\":\"minecraft:air\""); + assertTrue("the shot reported hitting structure but the ship's own block is untouched at its" + + " subspace address (before=" + before + " after=" + hull + "): the impact was handed" + + " over in the wrong frame, or to the wrong target", staged || destroyed); + + // And the shot ended in WORLD coordinates. Without the mapping back out it would report + // ending at a shipyard address millions of blocks from anything a player can see — and every + // assertion above would still have passed. + double endX = readDouble(after, "endX"), endY = readDouble(after, "endY"), + endZ = readDouble(after, "endZ"); + double offSeat = Math.sqrt(sq(endX - worldX) + sq(endY - worldY) + sq(endZ - worldZ)); + assertTrue("the shot ended at (" + endX + "," + endY + "," + endZ + "), " + offSeat + + " blocks from the world point it was fired through: the crossing point was never" + + " mapped out of the ship's frame", offSeat < 16.0D); + } + + /** Build the fixture, assemble it into a ship and move it far from where it was built. */ + private String buildAndMoveShip() throws Exception { + clearArea(SRC_X, SRC_Z); + String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat"); + String asm = exec("artest rocket assemble 0 " + coords); + assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm, + asm.contains("\"rocketCount\":0")); + + String info = null; + for (int attempt = 0; attempt < 40; attempt++) { + exec("artest vs load-ships 0"); + info = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z); + if (info.contains("\"managed\":true")) { + break; + } + Thread.sleep(250); + } + assertTrue("the build never became a ship managed at its build site: " + info, + info != null && info.contains("\"managed\":true")); + + String tp = exec("artest vs teleport-ship 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z + + " " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the ship could not be moved, so it never left the world blocks it was built from: " + + tp, tp.contains("\"ok\":true")); + exec("artest vs unpark 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + return extractString(info, "id"); + } + + private String stage(int x, int y, int z) throws Exception { + return exec("artest damage stage 0 " + x + " " + y + " " + z); + } + + private void clearArea(int baseX, int baseZ) throws Exception { + int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; + int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + + cx2 + " " + cz2).contains("\"ok\":true")); + assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (SRC_Y - 2) + " " + + (baseZ - 4) + " " + (baseX + 20) + " " + (SRC_Y + 12) + " " + (baseZ + 20) + + " minecraft:air").contains("\"ok\":true")); + } + + private String placeFixture(int baseX, int baseY, int baseZ, String variant) throws Exception { + String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " " + + variant); + assertTrue("fixture (" + variant + ") failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("fixture (" + variant + ") missing builderPos: " + fixture, bp.find()); + return bp.group(1) + " " + bp.group(2) + " " + bp.group(3); + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + private static double sq(double v) { + return v * v; + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[0-9][0-9.eE+-]*)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static double extractDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : 0.0; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java index b1ca553d3..385165a2d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java @@ -31,6 +31,9 @@ public class ShotSubstrateE2ETest extends AbstractSharedServerTest { /** A site of this class's own, clear of the other server scenarios. */ private static final int X = 9200, Y = 80, Z = 9200; + /** The emitter's mid-shell radius, as the shield tests use it — the face a round bounces off. */ + private static final double SHELL_RADIUS = 4.0D; + @Test public void aShotCrossesEmptySpaceWithoutLoadingAnyWorld() throws Exception { exec("artest shot clear 0"); @@ -147,6 +150,66 @@ public void aShotIsOnlyEverInTheWorldItWasFiredIn() throws Exception { exec("artest chunk release -1 0 0"); } + @Test + public void aShotThatMeetsAChargedShellBouncesOffItAndStaysUp() throws Exception { + // The one place the substrate calls the shield and reads a velocity back. The shell owns the + // reflection law — this pins that the answer is USED: the round turns around, resumes from + // the crossing and is still in the air, rather than stopping at the shield or ploughing on. + exec("artest shot clear 0"); + int gx = 1040, gz = 880, gy = 96; + int ex = gx + 1; + clearShieldSite(gx, gy, gz); + place("affs:shield_generator", gx, gy, gz); + place("affs:field_generator", ex, gy, gz); + for (int i = 0; i < 15; i++) { + exec("artest energy inject 0 " + gx + " " + gy + " " + gz + " 4000"); + exec("artest tile force-tick 0 " + gx + " " + gy + " " + gz + " 1"); + exec("artest shield tick 0"); + } + String emitter = exec("artest shield read 0 " + ex + " " + gy + " " + gz); + assertTrue("the emitter never powered, so there is no shell to bounce off: " + emitter, + emitter.contains("\"powered\":true")); + + // Fired from outside the +Z shell straight inward, at a speed that reaches it this tick. + double cz = gz + 0.5D; + double startZ = cz + SHELL_RADIUS + 3.0D; + long id = readLong(exec("artest shot fire 0 " + (ex + 0.5D) + " " + (gy + 0.5D) + " " + startZ + + " 0 0 -4 2000 300"), "id"); + assertTrue("the launch was refused", id > 0); + exec("artest shield tick 0"); + + String after = exec("artest shot read 0 " + id); + assertTrue("a shell that could afford the round consumed it instead of mirroring it — a " + + "kinetic body declared to the shield must come back out: " + after, + after.contains("\"present\":true")); + double vz = readDouble(after, "vz"); + assertTrue("the round is still travelling inward (vz=" + vz + ", it arrived at -4): the shell's" + + " answer was read but not applied: " + after, vz > 0.0D); + double z = readDouble(after, "z"); + assertTrue("the round bounced but is still inside the shell (z=" + z + ", shell face at " + + (cz + SHELL_RADIUS) + "): it resumed on the wrong side of the crossing: " + after, + z >= cz + SHELL_RADIUS - 1.0D); + assertTrue("the round left faster than it arrived (vz=" + vz + " vs 4): a mirror returns" + + " energy, it does not create it: " + after, vz <= 4.0D + 1.0E-6D); + + exec("artest shot clear 0"); + } + + private void clearShieldSite(int gx, int gy, int gz) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((gx - 16) >> 4) + " " + + ((gz - 16) >> 4) + " " + ((gx + 16) >> 4) + " " + ((gz + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill 0 " + (gx - 12) + " " + (gy - 4) + " " + + (gz - 12) + " " + (gx + 12) + " " + (gy + 8) + " " + (gz + 12) + " minecraft:air") + .contains("\"ok\":true")); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + y + "," + z + ": " + resp, + resp.contains("\"placed\":true")); + } + /** A wall one block thick, three by three, with clear air on both sides of it. */ private void buildWall(int wallX) throws Exception { assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((X - 4) >> 4) + " " From db6ef31c43fea23953fe36d33018f4c769cb186a Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 15 Aug 2026 19:11:21 +0300 Subject: [PATCH 07/35] fix: bore the path a round crossed, not the points sampled along it - traverse the impact path voxel-exactly instead of sampling it - move SweptSegment to util so damage need not depend on the substrate - pin that a bore has no untouched block inside it, at any angle - keep the axis-aligned case as the control that nothing else moved --- .../damage/StructureDamageEngine.java | 136 +++++++++---- .../projectile/StructureCrossing.java | 1 + .../{projectile => util}/SweptSegment.java | 2 +- .../test/server/DiagonalBoreE2ETest.java | 191 ++++++++++++++++++ .../test/unit/SweptSegmentTest.java | 2 +- 5 files changed, 286 insertions(+), 46 deletions(-) rename src/main/java/zmaster587/advancedRocketry/{projectile => util}/SweptSegment.java (98%) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/DiagonalBoreE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 3a751d094..dac03d451 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -7,6 +7,7 @@ import net.minecraft.world.World; import zmaster587.advancedRocketry.api.damage.DamageOutcome; import zmaster587.advancedRocketry.api.damage.StopReason; +import zmaster587.advancedRocketry.util.SweptSegment; import zmaster587.advancedRocketry.util.WeightEngine; /** @@ -50,12 +51,28 @@ public final class StructureDamageEngine { */ private static final int GAP_TOLERANCE = 6; + /** + * Hard bound on how many blocks one walk may examine, derived from {@link #MAX_PATH_BLOCKS} and + * never reached before it. A segment of length L crosses at most about 1.74 L voxels (the sum of + * a unit vector's components), so three per block of reach is slack, not a second limit: the + * geometric end of the path always comes first. It exists so a traversal cannot run away. + */ + private static final int MAX_VOXELS_EXAMINED = MAX_PATH_BLOCKS * 3 + 3; + private StructureDamageEngine() { } /** * Walk from {@code entry} along {@code direction}, spending {@code budget}. Every coordinate in * and out is in the caller's frame. + * + *

    The path is TRAVERSED, not sampled

    + *

    Every block the ray passes through is offered the budget, in order. The obvious alternative — + * step one unit along the ray and read the block under each sample — is wrong for any ray that is + * not parallel to an axis, because one unit of RAY is not one block of GRID: at thirty degrees it + * walks past roughly a third of what it crosses, and the blocks it walks past keep their budget + * and stand pristine inside the crater. Worse, each one is then counted as EMPTY, so six of them + * in a row convince the walk it has come out the far side of a hull it is still inside.

    */ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget) { WalkResult result = new WalkResult(); @@ -67,73 +84,108 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in return result; } - boolean enteredStructure = false; - int consecutiveEmpty = 0; - Vec3d lastSolidExit = null; - BlockPos previous = null; - - for (int step = 0; step < MAX_PATH_BLOCKS; step++) { - Vec3d samplePoint = entry.add(scale(direction, step + 0.5D)); - BlockPos pos = new BlockPos(Math.floor(samplePoint.x), Math.floor(samplePoint.y), - Math.floor(samplePoint.z)); - if (pos.equals(previous)) { - continue; + Walk walk = new Walk(world, entry, direction, result); + SweptSegment.traverse(entry, walk.farEnd, MAX_VOXELS_EXAMINED, walk); + return walk.finish(); + } + + /** + * One walk's state, told about each block the ray enters. It is an object rather than a loop only + * because the traversal calls back; every decision is the one the loop made. + */ + private static final class Walk implements SweptSegment.Visitor { + + private final World world; + private final Vec3d entry; + private final WalkResult result; + /** The far end of the reach, {@link #MAX_PATH_BLOCKS} blocks of RAY along the direction. */ + private final Vec3d farEnd; + + private boolean entered; + private boolean decided; + private int consecutiveEmpty; + private boolean previousWasSolid; + private Vec3d lastSolidExit; + + private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result) { + this.world = world; + this.entry = entry; + this.result = result; + double length = Math.sqrt(direction.x * direction.x + direction.y * direction.y + + direction.z * direction.z); + Vec3d unit = length <= 1.0E-9D ? direction : scale(direction, 1.0D / length); + this.farEnd = entry.add(scale(unit, MAX_PATH_BLOCKS)); + } + + @Override + public boolean visit(BlockPos pos, double tEnter) { + Vec3d here = entry.add(scale(farEnd.subtract(entry), tEnter)); + if (previousWasSolid) { + // The ray left the previous solid block exactly where it entered this one. + lastSolidExit = here; + previousWasSolid = false; } - previous = pos; if (!world.isBlockLoaded(pos)) { // Not "there is nothing here" — nobody looked. A caller that can retry should. - result.outcome = enteredStructure ? DamageOutcome.ABSORBED : DamageOutcome.NOTHING_STRUCK; - result.stopReason = StopReason.TARGET_UNLOADED; - return result; + return decide(entered ? DamageOutcome.ABSORBED : DamageOutcome.NOTHING_STRUCK, + StopReason.TARGET_UNLOADED, null); } IBlockState state = world.getBlockState(pos); - if (!isDamageable(world, pos, state)) { - if (enteredStructure && ++consecutiveEmpty >= GAP_TOLERANCE) { - result.outcome = DamageOutcome.EXITED; - result.stopReason = StopReason.EXITED_FAR_SIDE; - result.exitPoint = lastSolidExit; - return result; + if (!isStructure(world, pos, state)) { + if (entered && ++consecutiveEmpty >= GAP_TOLERANCE) { + return decide(DamageOutcome.EXITED, StopReason.EXITED_FAR_SIDE, lastSolidExit); } - continue; + return false; } consecutiveEmpty = 0; - if (!enteredStructure) { - enteredStructure = true; - result.entryPoint = samplePoint; + if (!entered) { + entered = true; + result.entryPoint = here; } result.penetrationDepth++; - lastSolidExit = entry.add(scale(direction, step + 1.0D)); + previousWasSolid = true; if (isIndestructible(world, pos, state)) { // Nothing gets through this. The budget dies here rather than tunnelling past it. result.budgetSpent += result.budgetLeft; result.budgetLeft = 0; - result.outcome = DamageOutcome.ABSORBED; - result.stopReason = StopReason.BUDGET_EXHAUSTED; - return result; + return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); } spendInto(world, pos, state, result); if (result.budgetLeft <= 0) { - result.outcome = DamageOutcome.ABSORBED; - result.stopReason = StopReason.BUDGET_EXHAUSTED; - return result; + return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); } + return false; } - if (!enteredStructure) { - result.outcome = DamageOutcome.NOTHING_STRUCK; - result.stopReason = StopReason.NO_CANDIDATES; + private boolean decide(DamageOutcome outcome, StopReason reason, Vec3d exitPoint) { + result.outcome = outcome; + result.stopReason = reason; + result.exitPoint = exitPoint; + decided = true; + return true; + } + + /** The outcome for a walk that ran off the end of its reach without deciding anything. */ + private WalkResult finish() { + if (decided) { + return result; + } + if (!entered) { + result.outcome = DamageOutcome.NOTHING_STRUCK; + result.stopReason = StopReason.NO_CANDIDATES; + return result; + } + // Budget still in hand at the path limit: hand it back rather than absorb it silently. + result.outcome = DamageOutcome.EXITED; + result.stopReason = StopReason.EXITED_FAR_SIDE; + result.exitPoint = previousWasSolid ? farEnd : lastSolidExit; return result; } - // Budget still in hand at the path limit: hand it back rather than absorb it silently. - result.outcome = DamageOutcome.EXITED; - result.stopReason = StopReason.EXITED_FAR_SIDE; - result.exitPoint = lastSolidExit; - return result; } /** Spend as much of the remaining budget into one block as its stages will take. */ @@ -186,10 +238,6 @@ public static boolean isStructure(World world, BlockPos pos, IBlockState state) return !state.getBlock().isAir(state, world, pos) && !state.getMaterial().isLiquid(); } - private static boolean isDamageable(World world, BlockPos pos, IBlockState state) { - return isStructure(world, pos, state); - } - private static boolean isIndestructible(World world, BlockPos pos, IBlockState state) { return state.getBlockHardness(world, pos) < 0.0F; } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java index d9fe325b1..efca2fd84 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -5,6 +5,7 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.damage.StructureDamageEngine; +import zmaster587.advancedRocketry.util.SweptSegment; import zmaster587.advancedRocketry.integration.vs.VSIntegration; import java.util.Map; diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java b/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java similarity index 98% rename from src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java rename to src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java index 02fb36970..323be96fe 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/SweptSegment.java +++ b/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java @@ -1,4 +1,4 @@ -package zmaster587.advancedRocketry.projectile; +package zmaster587.advancedRocketry.util; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/DiagonalBoreE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/DiagonalBoreE2ETest.java new file mode 100644 index 000000000..1570cc92a --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/DiagonalBoreE2ETest.java @@ -0,0 +1,191 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * An impact arriving at an ANGLE must not leave blocks it passed through untouched. + * + *

    The engine bores by walking its own ray, and what "one step" means along that ray is not what one + * step means on the block grid unless the ray is parallel to an axis. Every damage test written before + * this one fires straight down or straight along Z, where the two coincide exactly — so a walk that + * samples its path instead of traversing it passes all of them and still lets an oblique round through + * blocks it physically crossed.

    + * + *

    What is asserted, and why it is not a restatement of the implementation

    + *

    Not "the engine visits voxel list L" — that would pin whichever traversal happens to be in the + * code. The claim is about the RESULT a player can see: the damaged blocks form an unbroken + * chain. A block left pristine between two damaged ones is a hole in the bore, and it means the + * budget was never offered to it: something the round went through did not have to pay. The same + * property holds at every angle, which is the point.

    + */ +public class DiagonalBoreE2ETest extends AbstractSharedServerTest { + + /** + * A site per case, not one shared site. Damage records are keyed by POSITION and survive a + * re-fill — nothing fires a break event for a probe's {@code setBlockState} — so two cases in one + * place would read each other's craters, and which one read which would depend on JUnit's method + * ordering. + */ + private static final int OBLIQUE_X = 11_600, STRAIGHT_X = 11_800; + private static final int Y = 84, Z = 11_600; + /** How deep the solid block of stone runs along the line of fire. */ + private static final int DEPTH = 28; + /** Half-width either side, so a shallow angle stays inside the target for its whole path. */ + private static final int HALF = 20; + + /** About 30 degrees off the X axis, in the XZ plane: shallow enough to skip, steep enough to see. */ + private static final double DIR_X = 0.866D, DIR_Z = 0.5D; + + /** Below this the chain is too short for "unbroken" to mean anything. */ + private static final int MIN_BLOCKS_DAMAGED = 6; + + @Test + public void anObliqueImpactLeavesNoUntouchedBlockInsideItsOwnBore() throws Exception { + exec("artest damage clear-impacts"); + int X = OBLIQUE_X; + buildSolidTarget(X); + + int stageCost = extractInt(exec("artest damage stage 0 " + (X + 4) + " " + Y + " " + Z), + "stageCost"); + int maxStage = extractInt(exec("artest damage stage 0 " + (X + 4) + " " + Y + " " + Z), + "maxStage"); + assertTrue("no stage cost inside the target, so nothing here could be damaged", stageCost > 0); + assertTrue("no stages inside the target", maxStage > 0); + + // Enough budget to work through a run of blocks, so the chain is long enough to have holes. + int budget = 10 * maxStage * stageCost; + String report = exec("artest damage impact 0 " + (X - 2.5D) + " " + (Y + 0.5D) + " " + + (Z + 0.5D) + " " + DIR_X + " 0 " + DIR_Z + " " + budget + " KINETIC 91001"); + assertTrue("the oblique impact struck nothing at all — the arrangement, not the engine, is what" + + " this run would be measuring:\n" + report, + !report.contains("\"outcome\":\"NOTHING_STRUCK\"")); + + List damaged = damagedBlocks(X); + assertTrue("only " + damaged.size() + " blocks were damaged; a chain that short cannot show a" + + " hole. report:\n" + report, damaged.size() >= MIN_BLOCKS_DAMAGED); + + // Order them the way the round met them: by how far along its own direction each one sits. + Collections.sort(damaged, new Comparator() { + @Override + public int compare(int[] a, int[] b) { + return Double.compare(along(a), along(b)); + } + }); + + List holes = new ArrayList<>(); + for (int i = 1; i < damaged.size(); i++) { + int[] previous = damaged.get(i - 1); + int[] current = damaged.get(i); + int step = Math.abs(current[0] - previous[0]) + Math.abs(current[1] - previous[1]) + + Math.abs(current[2] - previous[2]); + if (step != 1) { + holes.add(describe(previous) + " -> " + describe(current) + " (" + step + " apart)"); + } + } + assertEquals("the bore has " + holes.size() + " hole(s): blocks the round passed through were" + + " never offered any of its budget, so they stand pristine inside a crater. This is" + + " what a path SAMPLED every unit of ray does at any angle that is not axis-aligned." + + "\n " + String.join("\n ", holes) + + "\n damaged chain: " + describeAll(damaged), + 0, holes.size()); + } + + @Test + public void anAxisAlignedImpactIsUnaffected() throws Exception { + // The control for the change, and the reason it is safe: where the ray is parallel to an axis + // a sampled path and a traversed one are the same list of blocks. If this ever moves, the fix + // changed something it had no business changing. + exec("artest damage clear-impacts"); + int X = STRAIGHT_X; + buildSolidTarget(X); + + int stageCost = extractInt(exec("artest damage stage 0 " + (X + 4) + " " + Y + " " + Z), + "stageCost"); + int maxStage = extractInt(exec("artest damage stage 0 " + (X + 4) + " " + Y + " " + Z), + "maxStage"); + int wanted = 5; + String report = exec("artest damage impact 0 " + (X - 2.5D) + " " + (Y + 0.5D) + " " + + (Z + 0.5D) + " 1 0 0 " + (wanted * maxStage * stageCost) + " KINETIC 91002"); + assertTrue("the straight impact struck nothing:\n" + report, + !report.contains("\"outcome\":\"NOTHING_STRUCK\"")); + + List damaged = damagedBlocks(X); + assertEquals("a straight shot must damage exactly the blocks its budget covers, in one row:" + + " " + describeAll(damaged) + "\nreport:\n" + report, wanted, damaged.size()); + for (int[] block : damaged) { + assertEquals("a straight shot along X wandered off its row: " + describe(block), Z, + block[2]); + assertEquals("a straight shot along X changed height: " + describe(block), Y, block[1]); + } + } + + private static double along(int[] block) { + return DIR_X * block[0] + DIR_Z * block[2]; + } + + private static String describe(int[] block) { + return "(" + block[0] + "," + block[1] + "," + block[2] + ")"; + } + + private static String describeAll(List blocks) { + StringBuilder sb = new StringBuilder(); + for (int[] block : blocks) { + sb.append(describe(block)).append(' '); + } + return sb.toString(); + } + + /** Every damage record inside the target, as {x,y,z}. */ + private List damagedBlocks(int X) throws Exception { + String records = exec("artest damage records 0 " + (X - 4) + " " + (Y - 2) + " " + (Z - HALF) + + " " + (X + DEPTH + 4) + " " + (Y + 2) + " " + (Z + HALF)); + assertTrue("the records probe failed: " + records, records.contains("\"ok\":true")); + List out = new ArrayList<>(); + Matcher m = Pattern.compile("\\{\"x\":(-?\\d+),\"y\":(-?\\d+),\"z\":(-?\\d+)").matcher(records); + while (m.find()) { + out.add(new int[]{Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), + Integer.parseInt(m.group(3))}); + } + return out; + } + + /** + * A solid block of stone, rebuilt from scratch each time so a previous case's crater — and its + * damage records — cannot be read as this one's. + */ + private void buildSolidTarget(int X) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((X - 8) >> 4) + " " + + ((Z - HALF - 4) >> 4) + " " + ((X + DEPTH + 8) >> 4) + " " + ((Z + HALF + 4) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the approach", exec("artest fill 0 " + (X - 8) + " " + (Y - 2) + + " " + (Z - HALF - 2) + " " + (X - 1) + " " + (Y + 2) + " " + (Z + HALF + 2) + + " minecraft:air").contains("\"ok\":true")); + // Air first, then stone: filling straight over a previous crater would leave that crater's + // damage records attached to the fresh blocks standing in the same positions. + assertTrue("could not clear the target", exec("artest fill 0 " + X + " " + (Y - 2) + " " + + (Z - HALF) + " " + (X + DEPTH) + " " + (Y + 2) + " " + (Z + HALF) + + " minecraft:air").contains("\"ok\":true")); + assertTrue("could not build the target", exec("artest fill 0 " + X + " " + (Y - 2) + " " + + (Z - HALF) + " " + (X + DEPTH) + " " + (Y + 2) + " " + (Z + HALF) + + " minecraft:stone").contains("\"ok\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java index 0206a2781..e0cfe052f 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java @@ -3,7 +3,7 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import org.junit.Test; -import zmaster587.advancedRocketry.projectile.SweptSegment; +import zmaster587.advancedRocketry.util.SweptSegment; import java.util.ArrayList; import java.util.List; From 82f4df0c6406c4ac968280953a0a420f3c766023 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 10:22:26 +0300 Subject: [PATCH 08/35] feat: a gun that needs no network, and a round you can see - add the gun contract: parts derive a built gun's numbers - assemble by connectivity, re-walked when blocks change - express traverse as a mechanism with a failure vocabulary - convert muzzle point and aim separately, inherit ship motion - do nothing aboard a ship VS has not named yet - hold fire when the shooter's own structure blocks the muzzle - replicate shots by path proximity, draw tracers and impacts - draw the barrel, replicate the command rather than the pose - cherry-pick the shared subsystem network from life_support - add the turret probe verb and a client tile-nbt read --- .../advancedRocketry/AdvancedRocketry.java | 23 + .../advancedRocketry/api/ARConfiguration.java | 10 + .../api/AdvancedRocketryBlocks.java | 9 + .../advancedRocketry/api/weapon/GunSpec.java | 240 +++++++ .../advancedRocketry/api/weapon/IGunPart.java | 45 ++ .../api/weapon/TurretDriveState.java | 66 ++ .../block/weapon/BlockGunPart.java | 58 ++ .../block/weapon/BlockTurret.java | 54 ++ .../advancedRocketry/client/ClientProxy.java | 3 + .../client/ClientShotTracker.java | 158 +++++ .../client/render/RenderShots.java | 116 ++++ .../client/render/RendererTurret.java | 72 ++ .../command/test/TestProbeCommand.java | 95 +++ .../integration/vs/VSBridge.java | 13 + .../integration/vs/VSIntegration.java | 32 + .../network/PacketRegistry.java | 2 + .../network/PacketShotEnd.java | 77 +++ .../network/PacketShotSpawn.java | 102 +++ .../projectile/ShotReplication.java | 115 ++++ .../projectile/ShotSubstrate.java | 7 +- .../projectile/StructureCrossing.java | 14 +- .../subsystem/network/ISubsystemCable.java | 24 + .../network/ISubsystemNetworkController.java | 12 + .../network/ISubsystemNetworkNode.java | 28 + .../subsystem/network/ISubsystemSink.java | 35 + .../subsystem/network/ISubsystemSource.java | 28 + .../network/SubsystemNetworkDomain.java | 50 ++ .../network/SubsystemNetworkManager.java | 634 ++++++++++++++++++ .../network/SubsystemNetworkRegistry.java | 90 +++ .../network/SubsystemNetworkState.java | 167 +++++ .../network/SubsystemNetworkStatus.java | 25 + .../tile/weapon/TileTurret.java | 493 ++++++++++++++ .../advancedRocketry/weapon/GunAssembly.java | 167 +++++ .../weapon/TurretFireControl.java | 197 ++++++ .../weapon/TurretMechanism.java | 229 +++++++ .../weapon/WeaponNetworkDomain.java | 57 ++ .../weapon/WeaponNetworkState.java | 54 ++ .../blockstates/gunammofeed.json | 18 + .../blockstates/gunbarrel.json | 18 + .../blockstates/guncooling.json | 18 + .../advancedrocketry/blockstates/turret.json | 18 + .../assets/advancedrocketry/lang/en_US.lang | 4 + .../advancedrocketry/recipes/gunammofeed.json | 22 + .../advancedrocketry/recipes/gunbarrel.json | 18 + .../advancedrocketry/recipes/guncooling.json | 18 + .../advancedrocketry/recipes/turret.json | 26 + .../test/client/ShotReachesClientE2ETest.java | 76 +++ .../client/TurretAimReachesClientE2ETest.java | 91 +++ .../test/server/TurretStandaloneE2ETest.java | 329 +++++++++ .../test/unit/GunSpecTest.java | 87 +++ .../test/unit/TurretMechanismTest.java | 170 +++++ .../forge/testing/client/ClientBot.java | 15 + .../bridge/ForgeTestClientBootstrap.java | 20 + 53 files changed, 4547 insertions(+), 2 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/weapon/IGunPart.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java create mode 100644 src/main/java/zmaster587/advancedRocketry/block/weapon/BlockGunPart.java create mode 100644 src/main/java/zmaster587/advancedRocketry/block/weapon/BlockTurret.java create mode 100644 src/main/java/zmaster587/advancedRocketry/client/ClientShotTracker.java create mode 100644 src/main/java/zmaster587/advancedRocketry/client/render/RenderShots.java create mode 100644 src/main/java/zmaster587/advancedRocketry/client/render/RendererTurret.java create mode 100644 src/main/java/zmaster587/advancedRocketry/network/PacketShotEnd.java create mode 100644 src/main/java/zmaster587/advancedRocketry/network/PacketShotSpawn.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemCable.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkController.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkNode.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSink.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSource.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkDomain.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkManager.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkRegistry.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkState.java create mode 100644 src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkStatus.java create mode 100644 src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java create mode 100644 src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java create mode 100644 src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java create mode 100644 src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java create mode 100644 src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkDomain.java create mode 100644 src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/gunammofeed.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/gunbarrel.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/guncooling.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/turret.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/gunammofeed.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/gunbarrel.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/guncooling.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/turret.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/ShotReachesClientE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/TurretAimReachesClientE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/GunSpecTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/TurretMechanismTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 027ee0c5b..986da7a8c 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -455,6 +455,8 @@ 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")); if (zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().enableGravityController) GameRegistry.registerTileEntity(TileAreaGravityController.class, "ARGravityMachine"); @@ -726,6 +728,23 @@ 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)) + .setUnlocalizedName("gunAmmoFeed").setCreativeTab(tabAdvRocketry); + AdvancedRocketryBlocks.blockGunCooling = new zmaster587.advancedRocketry.block.weapon.BlockGunPart( + builder -> builder.addHeatCapacity(40).addCoolingPerTick(2).addTraverseDegreesPerTick(0.5D)) + .setUnlocalizedName("gunCooling").setCreativeTab(tabAdvRocketry); 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 @@ -916,6 +935,10 @@ 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.blockGunBarrel.setRegistryName("gunBarrel")); + LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunAmmoFeed.setRegistryName("gunAmmoFeed")); + LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunCooling.setRegistryName("gunCooling")); LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGuidanceComputer.setRegistryName("guidanceComputer")); LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockAdvancedFlightComputer.setRegistryName("advancedFlightComputer")); LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockNavigationComputer.setRegistryName("navigationComputer")); diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 5d7c59d45..31a7b61ae 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -407,6 +407,15 @@ public class ARConfiguration { */ @ConfigProperty(needsSync = true) public int maxShotsPerWorld = 256; + /** + * How near a player's eye a round's PATH must pass before that player is told about it, in + * blocks. A shot is a server record, so being told is the only way a client can draw one; 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 + * replication off: the mechanic still works and nothing is drawn. + */ + @ConfigProperty(needsSync = true) + public int shotVisibilityRadius = 256; @ConfigProperty(needsSync = true) public double wearTankLeakChanceMax = 0.5; @ConfigProperty(needsSync = true) @@ -657,6 +666,7 @@ public static void loadPreInit() { arConfig.enableProjectileSubstrate = config.get(WEAPONS, "enableProjectileSubstrate", true, "Track fired shots as server-side records that fly across loaded and unloaded space alike. Turn off to disable long-range fire entirely: nothing is admitted and nothing in flight is stepped").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.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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, nothing is drawn", 0, Integer.MAX_VALUE).getInt(); arConfig.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Enable rocket part wear and exploding chance.").getBoolean(); arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java index 972e92819..c162ba824 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java +++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java @@ -39,6 +39,15 @@ 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 blockGunCooling; /** 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/weapon/GunSpec.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java new file mode 100644 index 000000000..7516a5ec8 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java @@ -0,0 +1,240 @@ +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 partCount; + + 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; + } + + /** + * Whether this assembly can fire at all. A build missing the one part that makes it a gun — a + * barrel — has no muzzle speed and no round worth firing, and saying so here means every call + * site asks one question instead of each inventing its own idea of "complete". + */ + public boolean isOperable() { + return muzzleSpeed > 0.0D && impactEnergy > 0 && partCount > 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; + } + + /** 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 partCount; + + public Builder addMuzzleSpeed(double blocksPerTick) { + this.muzzleSpeed += Math.max(0.0D, blocksPerTick); + return this; + } + + public Builder addImpactEnergy(int energy) { + this.impactEnergy += 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 - Math.max(0, ticks)); + return this; + } + + public Builder addEnergyPerShot(int fe) { + this.energyPerShot += Math.max(0, fe); + return this; + } + + public Builder addHeatPerShot(int heat) { + this.heatPerShot += Math.max(0, heat); + return this; + } + + public Builder addHeatCapacity(int heat) { + this.heatCapacity += Math.max(0, heat); + return this; + } + + public Builder addCoolingPerTick(int heat) { + this.coolingPerTick += 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 + degrees); + return this; + } + + public Builder addTraverseDegreesPerTick(double degrees) { + this.traverseDegreesPerTick = Math.max(0.0D, this.traverseDegreesPerTick + degrees); + return this; + } + + public Builder addLifetimeTicks(int ticks) { + this.lifetimeTicks = Math.max(1, this.lifetimeTicks + 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; + } + + /** 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..b90e92344 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java @@ -0,0 +1,66 @@ +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); + + 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/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/ClientProxy.java b/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java index 273eb8c79..b65b500a2 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,7 @@ 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(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/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 0bc3a9e48..c2e3e35b7 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -244,6 +244,9 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args case "shot": handleShot(server, sender, tail(args)); break; + case "turret": + handleTurret(server, sender, tail(args)); + break; case "sound": handleSound(server, sender, tail(args)); break; @@ -347,6 +350,98 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] 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); + 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 ("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() + + ",\"yaw\":" + mount.getYaw() + + ",\"pitch\":" + mount.getPitch() + + ",\"saturated\":" + mount.isSaturated() + + ",\"onTarget\":" + mount.isOnTarget() + + ",\"drive\":\"" + mount.getDriveState().name() + "\"" + + ",\"shots\":" + turret.getShotsFired() + + ",\"lastShot\":" + turret.getLastShotId() + + ",\"hasTarget\":" + (target != null) + + (target == null ? "" : ",\"targetX\":" + target.x + ",\"targetY\":" + target.y + + ",\"targetZ\":" + target.z) + + "}"); + return; + } + send(sender, "{\"error\":\"unknown turret subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}"); + } + private static String shotJson(zmaster587.advancedRocketry.projectile.Shot shot) { return "{\"id\":" + shot.getId() + ",\"x\":" + shot.getPosition().x diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java index 5b4c3ee5d..332613573 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSBridge.java @@ -580,6 +580,19 @@ static java.util.Map registeredShipPoses(World world) { return out; } + /** + * Whether {@code pos} lies in the region Valkyrien Skies reserves for ship blocks. + * + *

    Pure arithmetic on the chunk allocator's own constants, so it answers correctly whether or + * not VS is running — which is the case that matters. A world that once had ships still has + * their blocks out there, and their tiles still tick; code that asks "am I on a ship" and gets + * "no" because VS is absent must not conclude "then I am in the ordinary world".

    + */ + static boolean isBlockInShipyard(BlockPos pos) { + return pos != null && org.valkyrienskies.mod.common.ships.chunk_claims.ShipChunkAllocator + .isBlockInShipyard(pos); + } + /** * The uuid of the registered ship whose SHIPYARD claim owns the blocks at world point * {@code (x,y,z)}... which is not a question the claim can answer, so this asks the one that is diff --git a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java index 735ae14f7..7782468cf 100644 --- a/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java +++ b/src/main/java/zmaster587/advancedRocketry/integration/vs/VSIntegration.java @@ -772,6 +772,38 @@ public static String shipIdManagingBlock(World world, BlockPos pos) { ? null : VSBridge.shipIdManagingBlock(world, pos); } + /** + * Whether this block sits in the shipyard and NO ship claims it — the window in which a machine + * aboard a ship must do nothing at all. + * + *

    Why the window exists

    + *

    A ship's chunks and its ship object do not arrive together. The manager queues a background + * load and the chunks come into memory before the {@code PhysicsObject} exists, and a shipyard + * chunk can also be pulled in by anything that force-loads a region. In that window the blocks + * are there, their tiles tick, and every coordinate they hold is a SHIPYARD address — millions + * of blocks from where the ship actually is. A machine that acts on those numbers is acting on a + * position no player can reach.

    + * + *

    The rule

    + *

    Nothing aboard a ship runs until the ship is named. Not "runs carefully", not "runs with a + * fallback": a subsystem that cannot say where it is has nothing correct to do, and the cheapest + * correct behaviour is to wait. This is the one question it has to ask to know that.

    + */ + public static boolean isOnUnnamedShip(World world, BlockPos pos) { + return isBlockInShipyard(pos) && registeredShipIdManagingBlock(world, pos) == null; + } + + /** + * Whether {@code pos} is inside the shipyard region — the far-off block range VS keeps ship + * blocks in. Answered from the allocator's constants, so unlike every other method here it is + * NOT gated on {@link #isAvailable()}: a world whose ships exist while VS is switched off still + * has those blocks, and a caller that needs to know "are these coordinates really world + * coordinates" needs a true answer in exactly that case. + */ + public static boolean isBlockInShipyard(BlockPos pos) { + return VSBridge.isBlockInShipyard(pos); + } + /** UUID string of the ship whose subspace claim manages {@code pos} as the REGISTRY knows it — * answered whether or not that ship is currently simulated. Use this for questions about a * ship's IDENTITY; {@link #shipIdManagingBlock} answers about its live physics and is null for diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java b/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java index cc027a244..7ff165d8b 100644 --- a/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java @@ -53,6 +53,8 @@ public final class PacketRegistry { PacketSystemBodiesSync.class, PacketNavBodyInfo.class, PacketSpaceClockSync.class, + PacketShotSpawn.class, + PacketShotEnd.class, }; private PacketRegistry() { diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketShotEnd.java b/src/main/java/zmaster587/advancedRocketry/network/PacketShotEnd.java new file mode 100644 index 000000000..ae83b8cd5 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketShotEnd.java @@ -0,0 +1,77 @@ +package zmaster587.advancedRocketry.network; + +import io.netty.buffer.ByteBuf; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.network.PacketBuffer; +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.api.projectile.ShotEndReason; +import zmaster587.advancedRocketry.client.ClientShotTracker; +import zmaster587.libVulpes.network.BasePacket; + +/** + * Server→client: a round stopped here, for this reason. + * + *

    The point matters as much as the fact. A client stepping its own copy of a flight has no way to + * know a shell absorbed it or a hull stopped it — those are decisions taken in a world the client + * does not simulate — so without this the round would sail on through the thing it hit, and the + * player would watch a miss that was actually a kill. The reason travels with it because an impact + * on a hull and an absorption at a shield want different effects, and deciding which is which from + * the position alone is guesswork.

    + */ +public class PacketShotEnd extends BasePacket { + + private long id; + private double x, y, z; + private byte reason; + + public PacketShotEnd() { + } + + public static PacketShotEnd of(long id, Vec3d point, ShotEndReason reason) { + PacketShotEnd packet = new PacketShotEnd(); + packet.id = id; + packet.x = point.x; + packet.y = point.y; + packet.z = point.z; + packet.reason = (byte) reason.ordinal(); + return packet; + } + + @Override + public void write(ByteBuf out) { + PacketBuffer buffer = new PacketBuffer(out); + buffer.writeLong(id); + buffer.writeDouble(x); + buffer.writeDouble(y); + buffer.writeDouble(z); + buffer.writeByte(reason); + } + + @Override + public void readClient(ByteBuf in) { + PacketBuffer buffer = new PacketBuffer(in); + id = buffer.readLong(); + x = buffer.readDouble(); + y = buffer.readDouble(); + z = buffer.readDouble(); + reason = buffer.readByte(); + } + + @Override + public void read(ByteBuf in) { + // never sent to the server + } + + @Override + public void executeClient(EntityPlayer player) { + ShotEndReason[] reasons = ShotEndReason.values(); + ShotEndReason ended = reason >= 0 && reason < reasons.length ? reasons[reason] + : ShotEndReason.EXPIRED; + ClientShotTracker.end(id, new Vec3d(x, y, z), ended); + } + + @Override + public void executeServer(EntityPlayerMP player) { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketShotSpawn.java b/src/main/java/zmaster587/advancedRocketry/network/PacketShotSpawn.java new file mode 100644 index 000000000..8a7b62470 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketShotSpawn.java @@ -0,0 +1,102 @@ +package zmaster587.advancedRocketry.network; + +import io.netty.buffer.ByteBuf; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.network.PacketBuffer; +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; +import zmaster587.advancedRocketry.client.ClientShotTracker; +import zmaster587.libVulpes.network.BasePacket; + +/** + * Server→client: a round has left a muzzle, and here is everything needed to draw its whole + * flight. + * + *

    One packet per shot, not one per tick

    + *

    A shot's path is completely determined by where it started, how fast it was going and what acts + * on it — the server integrates exactly that and nothing else. So the client is told once and steps + * its own copy with the same arithmetic, instead of being sent a position twenty times a second for + * a minute of flight. A round that is deflected or stopped early gets a + * {@link PacketShotEnd}; until one arrives, the client's copy is right because it is running the + * same integration on the same numbers.

    + * + *

    It is a drawing, and it is honest about that

    + *

    Nothing the client does with this affects the game: the client has no registry, steps nothing + * the server reads back, and resolves no hit. A client that never received one of these plays the + * same game — worse-looking, not different.

    + */ +public class PacketShotSpawn extends BasePacket { + + private long id; + private double x, y, z; + private double vx, vy, vz; + private float radius; + private int lifetimeTicks; + private double gravityPerTickSquared; + + public PacketShotSpawn() { + } + + public static PacketShotSpawn of(long id, ShotSpec spec) { + PacketShotSpawn packet = new PacketShotSpawn(); + packet.id = id; + Vec3d origin = spec.getOrigin(); + Vec3d velocity = spec.getVelocity(); + packet.x = origin.x; + packet.y = origin.y; + packet.z = origin.z; + packet.vx = velocity.x; + packet.vy = velocity.y; + packet.vz = velocity.z; + packet.radius = (float) spec.getRadius(); + packet.lifetimeTicks = spec.getLifetimeTicks(); + packet.gravityPerTickSquared = spec.getEnvironment().getGravityPerTickSquared(); + return packet; + } + + @Override + public void write(ByteBuf out) { + PacketBuffer buffer = new PacketBuffer(out); + buffer.writeLong(id); + buffer.writeDouble(x); + buffer.writeDouble(y); + buffer.writeDouble(z); + buffer.writeDouble(vx); + buffer.writeDouble(vy); + buffer.writeDouble(vz); + buffer.writeFloat(radius); + buffer.writeInt(lifetimeTicks); + buffer.writeDouble(gravityPerTickSquared); + } + + @Override + public void readClient(ByteBuf in) { + PacketBuffer buffer = new PacketBuffer(in); + id = buffer.readLong(); + x = buffer.readDouble(); + y = buffer.readDouble(); + z = buffer.readDouble(); + vx = buffer.readDouble(); + vy = buffer.readDouble(); + vz = buffer.readDouble(); + radius = buffer.readFloat(); + lifetimeTicks = buffer.readInt(); + gravityPerTickSquared = buffer.readDouble(); + } + + @Override + public void read(ByteBuf in) { + // never sent to the server + } + + @Override + public void executeClient(EntityPlayer player) { + ClientShotTracker.spawn(id, new Vec3d(x, y, z), new Vec3d(vx, vy, vz), radius, lifetimeTicks, + gravityPerTickSquared); + } + + @Override + public void executeServer(EntityPlayerMP player) { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java new file mode 100644 index 000000000..c2f110604 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java @@ -0,0 +1,115 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.projectile.ShotEndReason; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; +import zmaster587.advancedRocketry.network.PacketShotEnd; +import zmaster587.advancedRocketry.network.PacketShotSpawn; +import zmaster587.libVulpes.network.PacketHandler; + +/** + * Who gets told about a round, and who does not. + * + *

    Told once, and only if it goes past you

    + *

    A shot is a server record; a client can only draw one it was told about. Telling everybody + * about every round would put a battery's whole rate of fire on every connection in the world, + * including players on the far side of a planet who will never see it — so the test is geometric: + * a player is told when the round's PATH passes within {@code shotVisibilityRadius} of them, not + * when its muzzle does. That is what makes a round fired from four kilometres away visible to the + * person it is fired AT, which is the case that matters most and the one a muzzle-distance filter + * gets exactly backwards.

    + * + *

    Two packets for a minute of flight

    + *

    The path is determined by the numbers in the first packet, so there is no per-tick stream: the + * client integrates its own copy and is corrected once, at the end. A player who came into range + * mid-flight sees nothing — a gap that costs one missed tracer and saves a per-tick proximity scan + * of every player against every round in the air.

    + */ +public final class ShotReplication { + + /** + * How far along its path a round is considered for visibility, in ticks of flight. A round with + * a minute of lifetime would otherwise be tested against a segment tens of thousands of blocks + * long, most of which it will never reach because something stops it first. + */ + private static final int PATH_HORIZON_TICKS = 200; + + private ShotReplication() { + } + + /** Tell everybody whose view the round will pass through. */ + public static void announceSpawn(World world, long id, ShotSpec spec) { + int radius = ARConfiguration.getCurrentConfig().shotVisibilityRadius; + if (world == null || world.isRemote || spec == null || radius <= 0) { + return; + } + Vec3d origin = spec.getOrigin(); + int horizon = Math.min(spec.getLifetimeTicks(), PATH_HORIZON_TICKS); + Vec3d far = origin.add(spec.getVelocity().scale(horizon)); + double radiusSq = (double) radius * radius; + + PacketShotSpawn packet = null; + for (EntityPlayer player : world.playerEntities) { + if (!(player instanceof EntityPlayerMP)) { + continue; + } + if (distanceSqToSegment(player.posX, player.posY, player.posZ, origin, far) > radiusSq) { + continue; + } + if (packet == null) { + packet = PacketShotSpawn.of(id, spec); + } + PacketHandler.sendToPlayer(packet, (EntityPlayerMP) player); + } + } + + /** + * Tell everybody near where it stopped. Deliberately keyed on the END point rather than on who + * was told about the launch: a player far enough away to be out of range here cannot see the + * impact either, and their own copy of the round ages out on its stated lifetime. + */ + public static void announceEnd(World world, long id, Vec3d point, ShotEndReason reason) { + int radius = ARConfiguration.getCurrentConfig().shotVisibilityRadius; + if (world == null || world.isRemote || point == null || radius <= 0) { + return; + } + double radiusSq = (double) radius * radius; + PacketShotEnd packet = null; + for (EntityPlayer player : world.playerEntities) { + if (!(player instanceof EntityPlayerMP)) { + continue; + } + double dx = player.posX - point.x; + double dy = player.posY - point.y; + double dz = player.posZ - point.z; + if (dx * dx + dy * dy + dz * dz > radiusSq) { + continue; + } + if (packet == null) { + packet = PacketShotEnd.of(id, point, reason); + } + PacketHandler.sendToPlayer(packet, (EntityPlayerMP) player); + } + } + + /** Squared distance from a point to the segment {@code from..to}. */ + static double distanceSqToSegment(double px, double py, double pz, Vec3d from, Vec3d to) { + double dx = to.x - from.x; + double dy = to.y - from.y; + double dz = to.z - from.z; + double lengthSq = dx * dx + dy * dy + dz * dz; + double t = 0.0D; + if (lengthSq > 1.0E-9D) { + t = ((px - from.x) * dx + (py - from.y) * dy + (pz - from.z) * dz) / lengthSq; + t = Math.max(0.0D, Math.min(1.0D, t)); + } + double cx = from.x + dx * t - px; + double cy = from.y + dy * t - py; + double cz = from.z + dz * t - pz; + return cx * cx + cy * cy + cz * cz; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 6df62ba2a..2476a9ca0 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -71,7 +71,11 @@ public static long launch(World world, ShotSpec spec) { || !ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { return -1L; } - return ShotRegistry.get(world).add(spec, ARConfiguration.getCurrentConfig().maxShotsPerWorld); + long id = ShotRegistry.get(world).add(spec, ARConfiguration.getCurrentConfig().maxShotsPerWorld); + if (id >= 0L) { + ShotReplication.announceSpawn(world, id, spec); + } + return id; } /** Advance every shot in this world by one tick. Driven by {@link ShotSubstrateEvents}. */ @@ -92,6 +96,7 @@ public static void tick(World world) { // it to the crossing point before returning, so there is one place that decides // where a round stopped rather than two that could disagree. registry.end(shot.getId(), end, shot.getPosition()); + ShotReplication.announceEnd(world, shot.getId(), shot.getPosition(), end); } } registry.markDirty(); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java index efca2fd84..59db3cbd6 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -33,7 +33,7 @@ * untouched. Closing it is stage 2's conservative occupancy summary, and until that lands this * limitation is the reason a ground battery is not yet a shipped feature.

    */ -final class StructureCrossing { +public final class StructureCrossing { /** * How many voxels one segment may be examined over in one tick, per frame searched. A bound on @@ -64,6 +64,18 @@ private Hit(double distance, Vec3d point, BlockPos block, String shipId) { private StructureCrossing() { } + /** + * Whether anything solid stands between two WORLD points — the question a weapon asks about its + * own line of fire before it commits a round to it. + * + *

    Exposed rather than re-derived because a second implementation of "is there structure here" + * is a second answer: a gun that cleared a path the substrate then found blocked would fire into + * its own hull for reasons nobody could reproduce.

    + */ + public static boolean isBlocked(World world, Vec3d from, Vec3d to) { + return firstAlong(world, from, to) != null; + } + /** The first structure the segment {@code from -> to} meets, or null when it meets none. */ static Hit firstAlong(World world, Vec3d from, Vec3d to) { if (world == null || from == null || to == null) { diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemCable.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemCable.java new file mode 100644 index 000000000..f661ce653 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemCable.java @@ -0,0 +1,24 @@ +package zmaster587.advancedRocketry.subsystem.network; + +/** + * A transport node: it neither produces nor consumes, it only limits. + *

    + * Cables are a scaling and reach tool, not a requirement — two adjacent nodes form a network with + * no cable between them, and that link is unthrottled. A cable is the only place a finite capacity + * enters the graph, which is what makes "add another line" a meaningful build decision. + */ +public interface ISubsystemCable extends ISubsystemNetworkNode { + + /** The most this cable will carry in one tick. */ + int getThroughputPerTick(); + + /** Report what actually went through, after the solve. */ + void addTransferred(int amount); + + /** + * The network's own report, delivered to a cable that wants to display it (a readout block, a + * console face). No-op by default: most cables are pipe. + */ + default void onNetworkStats(SubsystemNetworkState state) { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkController.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkController.java new file mode 100644 index 000000000..c10f5d4f6 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkController.java @@ -0,0 +1,12 @@ +package zmaster587.advancedRocketry.subsystem.network; + +/** + * A console: it reads and edits the network's state without carrying any of the commodity. + *

    + * Controllers are stateless editors — the network state is the single source of truth, so two + * consoles on one network cannot disagree, they can only both be looking at it. + */ +public interface ISubsystemNetworkController extends ISubsystemNetworkNode { + + void applyNetworkState(SubsystemNetworkState state); +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkNode.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkNode.java new file mode 100644 index 000000000..c85e884f5 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemNetworkNode.java @@ -0,0 +1,28 @@ +package zmaster587.advancedRocketry.subsystem.network; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; + +/** + * A block that takes part in a subsystem network: shields, ventilation, heat, turret control. + *

    + * A network is a connected component over block adjacency of its own domain's nodes. Membership is + * all this interface establishes — what a node then DOES is decided by which of + * {@link ISubsystemSource}, {@link ISubsystemSink}, {@link ISubsystemCable} and + * {@link ISubsystemNetworkController} it also implements. The roles are not exclusive: a store is + * both a source and a sink, and reads as both. + */ +public interface ISubsystemNetworkNode { + + /** + * Which commodity this node deals in. A node states its own domain rather than being told one + * at registration, so a block cannot end up in a graph it does not belong to, and so anything + * holding a node — a cable deciding whether to connect to its neighbour, a readout walking the + * world — can ask without a per-domain marker interface to test against. + */ + SubsystemNetworkDomain getNetworkDomain(); + + World getNodeWorld(); + + BlockPos getNodePos(); +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSink.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSink.java new file mode 100644 index 000000000..ad0d9f60a --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSink.java @@ -0,0 +1,35 @@ +package zmaster587.advancedRocketry.subsystem.network; + +/** + * A node the network delivers the commodity TO. + */ +public interface ISubsystemSink extends ISubsystemNetworkNode { + + /** How much this node is asking for this tick. */ + int getRequested(); + + /** How much it could still hold, for consumers that buffer. */ + int getFreeCapacity(); + + /** Take delivery of the amount the solve settled on; returns what was actually accepted. */ + int receive(int amount); + + /** + * Redistribution priority. Under a deficit the network satisfies higher priorities first, so a + * player can pour a starved supply into what matters ("all power to the rear shields", "keep + * the bridge breathable"). Equal priority shares what is left. Default 0 = normal; a bulk store + * keeps the default so real consumers, when raised, out-rank it. + */ + default int getPriority() { + return 0; + } + + /** + * What this node CONSUMES per tick, as opposed to what it is currently asking for. A consumer + * topping up a buffer requests far more than it burns, and a readout that cannot tell them + * apart reads a healthy network as overloaded. + */ + default int getConsumptionPerTick() { + return Math.max(0, getRequested()); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSource.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSource.java new file mode 100644 index 000000000..aee2a817c --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/ISubsystemSource.java @@ -0,0 +1,28 @@ +package zmaster587.advancedRocketry.subsystem.network; + +/** + * A node the network can take the commodity FROM. + *

    + * The commodity is an integer per tick and the network never learns what it measures — shield + * energy, air exchange, heat. Its unit belongs to the {@link SubsystemNetworkDomain}, and every + * node in one domain must speak the same one. + */ +public interface ISubsystemSource extends ISubsystemNetworkNode { + + /** How much this node can give up this tick. */ + int getAvailable(); + + /** Take the amount the solve settled on; returns what was actually taken. */ + int extract(int amount); + + /** + * What this node PRODUCES per tick, as opposed to what it currently holds. + *

    + * A generator's production and its buffer are different quantities, and a readout that shows a + * full buffer cannot tell a running plant from a stopped one with a full tank. Defaults to the + * available amount for nodes where the distinction does not exist. + */ + default int getGenerationPerTick() { + return Math.max(0, getAvailable()); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkDomain.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkDomain.java new file mode 100644 index 000000000..cff33f87f --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkDomain.java @@ -0,0 +1,50 @@ +package zmaster587.advancedRocketry.subsystem.network; + +import org.apache.logging.log4j.Logger; + +import java.util.List; + +/** + * One commodity, and the identity that keeps it apart from the others. + *

    + * Domains do not merge: a shield cable and a ventilation duct may be laid through the same wall and + * will never see each other, because the graph is built per domain. The domain also owns the UNIT — + * the network solves in whole integers per tick and never learns whether they are joules, litres of + * air exchange or watts of heat. + *

    + * Everything else here is a hook a domain MAY override; a domain that overrides nothing gets the + * plain behaviour, which is the point of the primitive. + */ +public abstract class SubsystemNetworkDomain { + + private final String name; + + protected SubsystemNetworkDomain(String name) { + this.name = name; + } + + /** For logs and readouts. Not persisted anywhere. */ + public final String getName() { + return name; + } + + /** + * A fresh state object. Override to attach domain settings a console can edit — the state is + * where they belong, because it is the thing that survives a topology rebuild. + */ + public SubsystemNetworkState newState() { + return new SubsystemNetworkState(); + } + + /** + * Called once per component after its membership is rebuilt and before it is solved, with that + * component's controllers. The place to seed state from a console's saved settings. + */ + public void onComponentRebuilt(SubsystemNetworkState state, List controllers) { + } + + /** Null to stay silent; topology rebuilds are logged through it when present. */ + public Logger getLogger() { + return null; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkManager.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkManager.java new file mode 100644 index 000000000..2659f34ea --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkManager.java @@ -0,0 +1,634 @@ +package zmaster587.advancedRocketry.subsystem.network; + +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.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * The shared subsystem-network primitive: sources, sinks and capacity-limited lines, solved once a + * tick per connected component. + *

    + * Every subsystem that distributes something over built blocks — shields, ventilation, heat, + * turret control — is one {@link SubsystemNetworkDomain} over this one solver, rather than its own + * copy of the same graph code. A network is a connected component over block adjacency of that + * domain's nodes: two touching nodes are one network with no cable between them, so cables are a + * reach-and-capacity tool rather than a requirement. + *

    + * Delivery is a max flow, not a share-out. That matters: it means a route that cannot carry the + * commodity does not silently reduce the whole network to its own capacity, and it means the + * per-tick answer names WHICH constraint bound it ({@link SubsystemNetworkStatus}) instead of only + * how much arrived. Under a deficit, sink demand is opened in descending priority tiers, so a + * starved supply fills what the player marked important first and equal priorities share the rest. + */ +@Mod.EventBusSubscriber(modid = Constants.modId) +public final class SubsystemNetworkManager { + + private static final int INF = 1_000_000_000; + + private static final Map> WORLD_STATES = new HashMap<>(); + + private SubsystemNetworkManager() { + } + + /** Call when the topology changed — a node placed, broken, or its connectivity altered. */ + public static void markDirty(SubsystemNetworkDomain domain, World world) { + if (domain == null || world == null || world.isRemote) { + return; + } + getState(domain, world).dirty = true; + } + + /** The network the block at this position belongs to, or null if it is in none. */ + public static SubsystemNetworkState getState(SubsystemNetworkDomain domain, World world, BlockPos pos) { + if (domain == null || world == null || pos == null) { + return null; + } + Map byDim = WORLD_STATES.get(domain); + if (byDim == null) { + return null; + } + WorldState state = byDim.get(world.provider.getDimension()); + return state == null ? null : state.stateByPos.get(pos); + } + + @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; + } + for (SubsystemNetworkDomain domain : SubsystemNetworkRegistry.domains()) { + tick(domain, world); + } + } + + /** + * One domain's rebuild-if-dirty plus solve, for this world. Extracted from the tick handler so + * the work has a name a caller can invoke: the event is one caller, and anything that needs the + * network advanced without waiting on the natural tick loop is another. + */ + public static void tick(SubsystemNetworkDomain domain, World world) { + if (domain == null || world == null || world.isRemote) { + return; + } + WorldState state = getState(domain, world); + if (state.dirty) { + // Topology changes are expensive; only rebuild adjacency when the network actually changed. + state.rebuild(domain, world); + } + // Capacities and demands still change every tick, so max-flow is solved against the cached + // topology each tick. + state.solve(); + } + + @SubscribeEvent + public static void onWorldUnload(WorldEvent.Unload event) { + World world = event.getWorld(); + if (world == null || world.isRemote) { + return; + } + for (SubsystemNetworkDomain domain : SubsystemNetworkRegistry.domains()) { + Map byDim = WORLD_STATES.get(domain); + if (byDim != null) { + byDim.remove(world.provider.getDimension()); + } + SubsystemNetworkRegistry.clearWorld(domain, world); + } + } + + private static WorldState getState(SubsystemNetworkDomain domain, World world) { + Map byDim = WORLD_STATES.computeIfAbsent(domain, key -> new HashMap<>()); + return byDim.computeIfAbsent(world.provider.getDimension(), key -> new WorldState()); + } + + private static final class WorldState { + private boolean dirty = true; + private final List components = new ArrayList<>(); + private final Map stateByPos = new HashMap<>(); + + private void rebuild(SubsystemNetworkDomain domain, World world) { + components.clear(); + Map previousStateByPos = new HashMap<>(stateByPos); + Set consumedStates = new HashSet<>(); + stateByPos.clear(); + + Set nodes = SubsystemNetworkRegistry.snapshot(domain); + Map cables = new HashMap<>(); + Map sources = new HashMap<>(); + Map sinks = new HashMap<>(); + Map controllers = new HashMap<>(); + int skippedNull = 0; + int skippedWorld = 0; + + for (ISubsystemNetworkNode 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: a store 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 ISubsystemCable) { + cables.put(nodePos, (ISubsystemCable) node); + } + if (node instanceof ISubsystemSource) { + sources.put(nodePos, (ISubsystemSource) node); + } + if (node instanceof ISubsystemSink) { + sinks.put(nodePos, (ISubsystemSink) node); + } + if (node instanceof ISubsystemNetworkController) { + controllers.put(nodePos, (ISubsystemNetworkController) node); + } + } + + if (domain.getLogger() != null) { + domain.getLogger().info( + "[{}Network] rebuild dim={} snapshot={} skippedNull={} skippedWorld={} cables={} sources={} sinks={} controllers={}", + domain.getName(), + world.provider.getDimension(), + nodes.size(), + skippedNull, + skippedWorld, + cables.size(), + sources.size(), + sinks.size(), + controllers.size() + ); + } + + 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) { + ISubsystemCable cable = cables.get(pos); + if (cable != null) { + componentCables.add(new CableNode(pos, cable)); + } + ISubsystemSource source = sources.get(pos); + if (source != null) { + componentSources.add(new SourceNode(pos, source)); + } + ISubsystemSink sink = sinks.get(pos); + if (sink != null) { + componentSinks.add(new SinkNode(pos, sink)); + } + ISubsystemNetworkController controller = controllers.get(pos); + if (controller != null) { + componentControllers.add(controller); + } + } + + List componentMemberPositions = new ArrayList<>(component); + BlockPos anchor = componentCables.isEmpty() ? startPos : componentCables.get(0).pos; + + SubsystemNetworkState state = + findExistingState(componentMemberPositions, previousStateByPos, consumedStates); + if (state == null) { + state = domain.newState(); + } + domain.onComponentRebuilt(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 (domain.getLogger() != null) { + domain.getLogger().info( + "[{}Network] component anchor={} cables={} sources={} sinks={} controllers={}", + domain.getName(), + anchor, + componentCables.size(), + componentSources.size(), + componentSinks.size(), + componentControllers.size() + ); + } + } + + dirty = false; + } + + /** + * A rebuilt component inherits the state of whichever old network its members came from, so + * console settings survive re-laying a line. A component that split takes a copy, because + * two networks may not share one settings object. + */ + private SubsystemNetworkState findExistingState(List memberPositions, + Map previousStateByPos, + Set consumedStates) { + SubsystemNetworkState best = null; + for (BlockPos pos : memberPositions) { + SubsystemNetworkState 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 solve() { + for (ComponentTopology component : components) { + component.solve(); + } + } + } + + private static final class ComponentTopology { + private final SubsystemNetworkState state; + private final List cables; + private final List sources; + private final List sinks; + private final List controllers; + + private ComponentTopology(SubsystemNetworkState 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(); + return; + } + + MaxFlowSolver solver = new MaxFlowSolver(); + int superSource = solver.addNode(); + int superSink = solver.addNode(); + + // Unified port model: every node exposes a supply port (the commodity leaves here) and/or + // a demand port (it enters here). A cable owns both, joined internally by its throughput + // edge; a source owns only supply; a sink only demand; a store 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.getAvailable()); + totalSourceAvailable += available; + totalGenerationPerTick += Math.max(0, source.source.getGenerationPerTick()); + 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.getRequested()); + totalSinkRequested += requested; + totalConsumptionPerTick += Math.max(0, sink.sink.getConsumptionPerTick()); + // 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.getPriority()}); + } + + // 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: open the sink demand edges in descending priority order, + // augmenting the flow at each tier, so a scarce supply fills the highest-priority + // consumers first and equal-priority ones share what remains. With a single priority (the + // default — everything in one implicit group) this is one pass, identical to plain max-flow. + TreeSet priorityTiers = new TreeSet<>(Collections.reverseOrder()); + for (int[] demand : sinkDemand) { + priorityTiers.add(demand[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) { + ((ISubsystemSource) ref.owner).extract(used); + } + } + + for (MaxFlowSolver.EdgeRef ref : sinkRefs) { + int used = ref.edge.flow; + if (used > 0) { + ((ISubsystemSink) ref.owner).receive(used); + } + } + + for (Map.Entry entry : cableThroughputRefs.entrySet()) { + int used = Math.max(0, entry.getValue().edge.flow); + if (used > 0) { + ((ISubsystemCable) entry.getValue().owner).addTransferred(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 + ); + + publish(); + } + + private void publishDisconnected() { + BlockPos anchor = state.getRoot(); + state.setStatistics(false, SubsystemNetworkStatus.DISCONNECTED, anchor, + cables.size(), sources.size(), sinks.size(), 0, 0, 0, 0, 0, anchor, 0, 0, 0); + publish(); + } + + /** + * One report, to everything that displays it — controllers AND cables, on every path. + *

    + * The disconnected path used to skip controllers, so a console whose network had just lost + * its last source went on showing the readout from the tick before it died. A display that + * stops following its subject and keeps looking authoritative is the same defect twice over + * here, because the console is also where the domain's settings are persisted. + */ + private void publish() { + for (ISubsystemNetworkController controller : controllers) { + controller.applyNetworkState(state); + } + for (CableNode cable : cables) { + cable.cable.onNetworkStats(state); + } + } + + private int statusFor(int sourceAvailable, int sinkRequested, int maxFlow, int cableCapacity, boolean hasCables) { + if (sourceAvailable <= 0 || sinkRequested <= 0) { + return SubsystemNetworkStatus.DISCONNECTED; + } + int limiting = Math.min(sourceAvailable, sinkRequested); + if (hasCables && maxFlow < limiting && maxFlow < cableCapacity) { + return SubsystemNetworkStatus.CABLE_LIMITED; + } + if (sourceAvailable <= sinkRequested && maxFlow >= sourceAvailable) { + return SubsystemNetworkStatus.SOURCE_LIMITED; + } + if (sinkRequested < sourceAvailable && maxFlow >= sinkRequested) { + return SubsystemNetworkStatus.SINK_LIMITED; + } + return SubsystemNetworkStatus.BALANCED; + } + } + + private static final class CableNode { + private final BlockPos pos; + private final ISubsystemCable cable; + + private CableNode(BlockPos pos, ISubsystemCable cable) { + this.pos = pos; + this.cable = cable; + } + } + + private static final class SourceNode { + private final BlockPos pos; + private final ISubsystemSource source; + + private SourceNode(BlockPos pos, ISubsystemSource source) { + this.pos = pos; + this.source = source; + } + } + + private static final class SinkNode { + private final BlockPos pos; + private final ISubsystemSink sink; + + private SinkNode(BlockPos pos, ISubsystemSink sink) { + this.pos = pos; + this.sink = sink; + } + } + + /** Dinic's algorithm. Small graphs (one network's blocks), rebuilt per solve. */ + 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; + } + } + + 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; + } + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkRegistry.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkRegistry.java new file mode 100644 index 000000000..8871f2977 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkRegistry.java @@ -0,0 +1,90 @@ +package zmaster587.advancedRocketry.subsystem.network; + +import net.minecraft.world.World; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Which nodes exist, per domain. A tile registers itself when it joins the world and unregisters + * when it leaves; the manager reads a snapshot when it rebuilds. + *

    + * Keyed by domain so the graphs stay apart; a node names its own domain, so registering one into + * the wrong graph is not expressible. Synchronized because tiles are created and invalidated off + * the tick that reads them. + */ +public final class SubsystemNetworkRegistry { + + private static final Map> NODES = new HashMap<>(); + + private SubsystemNetworkRegistry() { + } + + public static synchronized void register(ISubsystemNetworkNode node) { + SubsystemNetworkDomain domain = node == null ? null : node.getNetworkDomain(); + if (domain == null) { + return; + } + nodesOf(domain).add(node); + log(domain, "register", node); + } + + public static synchronized void unregister(ISubsystemNetworkNode node) { + SubsystemNetworkDomain domain = node == null ? null : node.getNetworkDomain(); + if (domain == null) { + return; + } + nodesOf(domain).remove(node); + log(domain, "unregister", node); + } + + public static synchronized Set snapshot(SubsystemNetworkDomain domain) { + if (domain == null) { + return Collections.emptySet(); + } + return Collections.unmodifiableSet(new HashSet<>(nodesOf(domain))); + } + + /** Every domain that has ever registered a node — what the manager ticks. */ + public static synchronized Set domains() { + return new LinkedHashSet<>(NODES.keySet()); + } + + public static synchronized void clearWorld(SubsystemNetworkDomain domain, World world) { + if (domain == null || world == null) { + return; + } + int dim = world.provider.getDimension(); + Set nodes = nodesOf(domain); + int before = nodes.size(); + nodes.removeIf(node -> node != null && matchesDimension(node.getNodeWorld(), dim)); + if (before != nodes.size() && domain.getLogger() != null) { + domain.getLogger().info("[{}Network] clearWorld dim={} removed={} remaining={}", + domain.getName(), dim, before - nodes.size(), nodes.size()); + } + } + + private static Set nodesOf(SubsystemNetworkDomain domain) { + return NODES.computeIfAbsent(domain, key -> new HashSet<>()); + } + + private static void log(SubsystemNetworkDomain domain, String action, ISubsystemNetworkNode node) { + if (domain.getLogger() == null) { + return; + } + String worldInfo = node.getNodeWorld() == null + ? "null" + : "dim=" + node.getNodeWorld().provider.getDimension(); + domain.getLogger().info("[{}NetworkRegistry] {} {} pos={} {} total={}", + domain.getName(), action, node.getClass().getSimpleName(), node.getNodePos(), + worldInfo, nodesOf(domain).size()); + } + + private static boolean matchesDimension(World nodeWorld, int dimension) { + return nodeWorld != null && nodeWorld.provider.getDimension() == dimension; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkState.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkState.java new file mode 100644 index 000000000..e7ec7e52b --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkState.java @@ -0,0 +1,167 @@ +package zmaster587.advancedRocketry.subsystem.network; + +import net.minecraft.util.math.BlockPos; + +import java.util.HashSet; +import java.util.Set; + +/** + * What one network knows about itself: who is in it, and what last tick's solve found. + *

    + * This object is the network's single source of truth. Consoles edit it and read it; they never + * hold a private copy, so two consoles on one network cannot disagree. It survives a topology + * rebuild by being re-attached to the component that inherited its members, which is what lets a + * player's settings live through breaking and re-laying a cable. + *

    + * Every quantity here is in the domain's commodity unit per tick; the network itself never learns + * what that unit measures. + */ +public class SubsystemNetworkState { + + 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; + + /** + * A fresh state of the same concrete class, for a domain that has subclassed this one. The + * default keeps subclass-specific settings intact by copying through {@link #copyInto}. + */ + public SubsystemNetworkState copy() { + SubsystemNetworkState copy = new SubsystemNetworkState(); + copyInto(copy); + return copy; + } + + /** Copies every field of THIS class into the target; a subclass overrides to add its own. */ + protected void copyInto(SubsystemNetworkState copy) { + 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; + } + + 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; + } + + /** One of {@link SubsystemNetworkStatus}. */ + 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 BlockPos getBottleneck() { + return bottleneck; + } + + public int getBottleneckUtilizationPermille() { + return bottleneckUtilizationPermille; + } + + 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; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkStatus.java b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkStatus.java new file mode 100644 index 000000000..1f2d9d1bc --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/subsystem/network/SubsystemNetworkStatus.java @@ -0,0 +1,25 @@ +package zmaster587.advancedRocketry.subsystem.network; + +/** + * Why the network is delivering what it delivers — the one thing a player needs before deciding + * whether to build another generator, another consumer, or another line. + *

    + * Deliberately int constants rather than an enum: these values are already on the wire and in + * saved console state. + */ +public final class SubsystemNetworkStatus { + + /** Nothing to solve: no source, or no sink. */ + public static final int DISCONNECTED = 1; + /** Demand exceeds what the sources can give — build generation. */ + public static final int SOURCE_LIMITED = 2; + /** Supply exceeds demand — the network is idling, not straining. */ + public static final int SINK_LIMITED = 3; + /** Both ends could do more; the lines between them cannot — build another route. */ + public static final int CABLE_LIMITED = 4; + /** Supply, demand and transport all meet. */ + public static final int BALANCED = 5; + + private SubsystemNetworkStatus() { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java new file mode 100644 index 000000000..cd6bda16a --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -0,0 +1,493 @@ +package zmaster587.advancedRocketry.tile.weapon; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.ItemStack; +import net.minecraft.block.state.IBlockState; +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.EnumFacing; +import net.minecraft.util.ITickable; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.energy.CapabilityEnergy; +import net.minecraftforge.energy.EnergyStorage; +import zmaster587.advancedRocketry.api.weapon.GunSpec; +import zmaster587.advancedRocketry.api.weapon.TurretDriveState; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; +import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink; +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 zmaster587.advancedRocketry.weapon.GunAssembly; +import zmaster587.advancedRocketry.weapon.TurretFireControl; +import zmaster587.advancedRocketry.weapon.TurretMechanism; +import zmaster587.advancedRocketry.weapon.WeaponNetworkDomain; +import zmaster587.advancedRocketry.weapon.WeaponNetworkState; +import zmaster587.libVulpes.interfaces.ILinkableTile; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Random; +import java.util.UUID; + +/** + * A gun's controller: the one block that knows what was built around it, where it is pointing, and + * when it may shoot. + * + *

    It works alone

    + *

    Nothing below asks whether a network exists before deciding to aim or to fire. A turret with a + * target, a charged buffer and a barrel shoots — on the ground, on a ship, wired to nothing. A + * network, when there is one, can point it somewhere else and can feed it faster; that is the whole + * of the difference, and it is a difference in convenience rather than in capability.

    + * + *

    The build is re-read when it CHANGES

    + *

    A block cannot enter or leave the world without its own {@code onBlockAdded} / {@code breakBlock} + * running, so "the build changed" is a fact the world hands us rather than something to go looking + * for: a part marks every controller it can reach, and the walk happens on the next tick. There is no + * polling — a gun that nobody is building is a gun that costs one boolean check per tick.

    + */ +public class TileTurret extends TileEntity implements ITickable, ISubsystemSink, ILinkableTile { + + /** Smallest buffer a turret keeps, so a cheap gun still holds a few rounds' worth. */ + private static final int MIN_ENERGY_BUFFER = 20_000; + + /** How far the command must move before the client is told again. */ + private static final double COMMAND_SYNC_DEGREES = 2.0D; + + /** The longest barrel the renderer draws, however many parts were built. */ + private static final int MAX_DRAWN_BARREL = 5; + + private final TurretMechanism mechanism = TurretMechanism.standard(); + private final Random random = new Random(); + + private EnergyStorage energy = new EnergyStorage(MIN_ENERGY_BUFFER, MIN_ENERGY_BUFFER, + MIN_ENERGY_BUFFER); + private GunSpec spec = GunSpec.EMPTY; + private int assemblyReach; + private boolean assemblyDirty = true; + private int fireCooldown; + private int heat; + private boolean registered; + + private Vec3d localTarget; + private String faction; + private UUID owner; + + private long lastShotId = -1L; + private int shotsFired; + + /** What the client was last told, so the server can tell when telling it again is worth a packet. */ + private double sentYaw = Double.NaN; + private double sentPitch = Double.NaN; + private TurretDriveState sentDrive; + + /** Client-side only: the rate and barrel length that came with the last update. */ + private double clientTraverseRate = 2.0D; + private int clientBarrelLength = 1; + + @Override + public void update() { + if (world == null) { + return; + } + if (world.isRemote) { + // The client runs the SAME mechanism on the command it was sent, rather than being fed a + // bearing every tick. A mount turns for several seconds and is commanded rarely, so + // replicating the command costs one packet per engagement and replicating the pose would + // cost one per tick — for a picture that would still be a tick behind. + mechanism.tick(clientTraverseRate); + return; + } + if (VSIntegration.isOnUnnamedShip(world, pos)) { + // The blocks are loaded but the ship they belong to is not named yet, so every coordinate + // this gun holds is a shipyard address rather than a place in the world. It does not aim, + // does not fire, does not cool, and does not join a network: a subsystem that cannot say + // where it is has nothing correct to do, and waiting is the cheapest correct behaviour. + return; + } + if (!registered) { + SubsystemNetworkRegistry.register(this); + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + registered = true; + assemblyDirty = true; + } + + if (assemblyDirty) { + GunAssembly assembly = GunAssembly.scan(world, pos); + spec = assembly.getSpec(); + assemblyReach = assembly.getReach(); + resizeBufferFor(spec); + assemblyDirty = false; + } + + if (heat > 0) { + heat = Math.max(0, heat - spec.getCoolingPerTick()); + } + if (fireCooldown > 0) { + fireCooldown--; + } + + Vec3d target = getEffectiveTarget(); + if (target == null) { + mechanism.clearCommand(); + return; + } + + String shipId = TurretFireControl.shipIdAt(world, pos); + Vec3d aim = TurretFireControl.aimDirection(world, pos, shipId, target); + if (aim != null) { + // A null aim means the ship's transform is not available; the mount then holds the + // bearing it already has rather than swinging to one derived from a stale pose. + mechanism.commandDirection(aim); + } + boolean onTarget = mechanism.tick(spec.getTraverseDegreesPerTick()); + syncCommandIfChanged(); + if (!onTarget || isHoldingFire() || !canFireNow()) { + return; + } + + long id = TurretFireControl.fire(world, pos, shipId, mechanism.getAimDirection(), spec, + assemblyReach, owner, faction, random); + if (id >= 0L) { + lastShotId = id; + shotsFired++; + fireCooldown = spec.getFireIntervalTicks(); + heat += spec.getHeatPerShot(); + energy.extractEnergy(spec.getEnergyPerShot(), false); + markDirty(); + } + } + + /** Everything that must be true before a round leaves, other than pointing the right way. */ + private boolean canFireNow() { + return spec.isOperable() + && mechanism.getDriveState().permitsFiring() + && fireCooldown <= 0 + && heat + spec.getHeatPerShot() <= spec.getHeatCapacity() + && energy.getEnergyStored() >= spec.getEnergyPerShot(); + } + + /** + * Where this gun is pointing: what the network was told, or failing that what the gun itself was + * told. The network wins when it has an opinion — that is what "one console commands the + * battery" means — and its silence is not an instruction to stop. + */ + public Vec3d getEffectiveTarget() { + WeaponNetworkState state = networkState(); + if (state != null && state.getTarget() != null) { + return state.getTarget(); + } + return localTarget; + } + + private boolean isHoldingFire() { + WeaponNetworkState state = networkState(); + return state != null && state.isHoldFire(); + } + + private WeaponNetworkState networkState() { + SubsystemNetworkState state = SubsystemNetworkManager.getState(WeaponNetworkDomain.INSTANCE, + world, pos); + return state instanceof WeaponNetworkState ? (WeaponNetworkState) state : null; + } + + /** + * Grow the buffer with the gun, keeping what is in it. A gun that got bigger should not have to + * refill from empty, and one that shrank should not be holding more than it can. + */ + private void resizeBufferFor(GunSpec newSpec) { + int wanted = Math.max(MIN_ENERGY_BUFFER, newSpec.getEnergyPerShot() * 40); + if (wanted == energy.getMaxEnergyStored()) { + return; + } + int carried = Math.min(energy.getEnergyStored(), wanted); + energy = new EnergyStorage(wanted, wanted, wanted, carried); + } + + /** + * Re-count the build on the next tick. Called by a part entering or leaving the world; deferred + * by one tick on purpose, because a player laying a run of barrel sections would otherwise pay + * for a full walk per block placed, and because the walk during {@code breakBlock} would be + * reading a world in the middle of being changed. + */ + public void markAssemblyDirty() { + assemblyDirty = true; + } + + // ---- the gun's own controls, which no network is needed to reach + + /** Point this gun at a world point. Cleared with null. */ + public void setTarget(Vec3d target) { + this.localTarget = target; + markDirty(); + } + + public Vec3d getTarget() { + return localTarget; + } + + public GunSpec getSpec() { + return spec; + } + + public TurretMechanism getMechanism() { + return mechanism; + } + + public int getHeat() { + return heat; + } + + public int getEnergyStored() { + return energy.getEnergyStored(); + } + + /** Test and diagnostic reads: how many rounds this gun has fired, and the last one's id. */ + public int getShotsFired() { + return shotsFired; + } + + public long getLastShotId() { + return lastShotId; + } + + public void setDriveState(TurretDriveState state) { + mechanism.setDriveState(state); + markDirty(); + } + + /** Whose side it is on. Travels with every round it fires so an impact can be attributed. */ + public void setFaction(String faction) { + this.faction = faction; + markDirty(); + } + + public String getFaction() { + return faction; + } + + public void setOwner(UUID owner) { + this.owner = owner; + markDirty(); + } + + /** Fills the buffer directly. For creative placement and for tests that are not about wiring. */ + public void chargeFully() { + energy.receiveEnergy(energy.getMaxEnergyStored(), false); + } + + /** + * Tell the client where this gun has been TOLD to point, when that has meaningfully changed. + * + *

    Thresholded rather than sent every tick: a mount tracking a slowly moving target would + * otherwise be a packet per tick per gun, and a battery would put its whole rate of fire on the + * connection twice — once for the rounds and once for the barrels. Two degrees is finer than any + * player can see at the distance a turret is watched from.

    + */ + private void syncCommandIfChanged() { + double yaw = mechanism.getCommandedYaw(); + double pitch = mechanism.getCommandedPitch(); + TurretDriveState drive = mechanism.getDriveState(); + boolean moved = Double.isNaN(sentYaw) + || Math.abs(wrapDegrees(yaw - sentYaw)) > COMMAND_SYNC_DEGREES + || Math.abs(pitch - sentPitch) > COMMAND_SYNC_DEGREES + || drive != sentDrive; + if (!moved) { + return; + } + sentYaw = yaw; + sentPitch = pitch; + sentDrive = drive; + IBlockState state = world.getBlockState(pos); + world.notifyBlockUpdate(pos, state, state, 2); + } + + private static double wrapDegrees(double degrees) { + double wrapped = degrees % 360.0D; + if (wrapped <= -180.0D) { + wrapped += 360.0D; + } + if (wrapped > 180.0D) { + wrapped -= 360.0D; + } + return wrapped; + } + + /** How long a barrel the renderer should draw, in blocks. Derived from the build, sent with it. */ + public int getBarrelLength() { + return world != null && world.isRemote ? clientBarrelLength + : Math.max(1, Math.min(MAX_DRAWN_BARREL, spec.getPartCount() / 2)); + } + + // ---- subsystem network: a sink, and only while it wants energy + + @Override + public SubsystemNetworkDomain getNetworkDomain() { + return WeaponNetworkDomain.INSTANCE; + } + + @Override + public World getNodeWorld() { + return world; + } + + @Override + public BlockPos getNodePos() { + return pos; + } + + @Override + public int getRequested() { + return getFreeCapacity(); + } + + @Override + public int getFreeCapacity() { + return energy.getMaxEnergyStored() - energy.getEnergyStored(); + } + + @Override + public int receive(int amount) { + return energy.receiveEnergy(Math.max(0, amount), false); + } + + @Override + public int getConsumptionPerTick() { + int interval = Math.max(1, spec.getFireIntervalTicks()); + return spec.getEnergyPerShot() / interval; + } + + // ---- lifecycle + + @Override + public void invalidate() { + super.invalidate(); + SubsystemNetworkRegistry.unregister(this); + if (world != null && !world.isRemote) { + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + } + registered = false; + } + + @Override + public void onChunkUnload() { + super.onChunkUnload(); + SubsystemNetworkRegistry.unregister(this); + registered = false; + } + + // ---- linker: the no-network way to give a gun a target + + @Override + public boolean onLinkStart(@Nonnull ItemStack item, TileEntity entity, EntityPlayer player, World world) { + return true; + } + + @Override + public boolean onLinkComplete(@Nonnull ItemStack item, TileEntity entity, EntityPlayer player, World world) { + if (entity == null) { + return false; + } + setTarget(TurretFireControl.center(entity.getPos())); + return true; + } + + // ---- energy capability + + @Override + public boolean hasCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + return capability == CapabilityEnergy.ENERGY || super.hasCapability(capability, facing); + } + + @Override + @Nullable + public T getCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + if (capability == CapabilityEnergy.ENERGY) { + return CapabilityEnergy.ENERGY.cast(energy); + } + return super.getCapability(capability, facing); + } + + // ---- what the client is told: the COMMAND, not the pose + + @Override + public NBTTagCompound getUpdateTag() { + NBTTagCompound nbt = super.getUpdateTag(); + NBTTagCompound mount = new NBTTagCompound(); + mechanism.writeToNBT(mount); + nbt.setTag("mount", mount); + nbt.setDouble("rate", spec.getTraverseDegreesPerTick()); + nbt.setInteger("barrel", getBarrelLength()); + return nbt; + } + + @Override + public SPacketUpdateTileEntity getUpdatePacket() { + return new SPacketUpdateTileEntity(pos, 1, getUpdateTag()); + } + + @Override + public void onDataPacket(NetworkManager net, SPacketUpdateTileEntity packet) { + handleUpdateTag(packet.getNbtCompound()); + } + + @Override + public void handleUpdateTag(NBTTagCompound nbt) { + if (nbt.hasKey("mount")) { + mechanism.readFromNBT(nbt.getCompoundTag("mount")); + } + clientTraverseRate = nbt.getDouble("rate"); + clientBarrelLength = Math.max(1, nbt.getInteger("barrel")); + } + + // ---- persistence + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound nbt) { + super.writeToNBT(nbt); + NBTTagCompound mount = new NBTTagCompound(); + mechanism.writeToNBT(mount); + nbt.setTag("mount", mount); + nbt.setInteger("energy", energy.getEnergyStored()); + nbt.setInteger("energyMax", energy.getMaxEnergyStored()); + nbt.setInteger("heat", heat); + nbt.setInteger("cooldown", fireCooldown); + nbt.setInteger("shots", shotsFired); + if (localTarget != null) { + nbt.setDouble("targetX", localTarget.x); + nbt.setDouble("targetY", localTarget.y); + nbt.setDouble("targetZ", localTarget.z); + nbt.setBoolean("hasTarget", true); + } + if (faction != null) { + nbt.setString("faction", faction); + } + if (owner != null) { + nbt.setUniqueId("owner", owner); + } + return nbt; + } + + @Override + public void readFromNBT(NBTTagCompound nbt) { + super.readFromNBT(nbt); + if (nbt.hasKey("mount")) { + mechanism.readFromNBT(nbt.getCompoundTag("mount")); + } + int max = Math.max(MIN_ENERGY_BUFFER, nbt.getInteger("energyMax")); + energy = new EnergyStorage(max, max, max, Math.min(max, nbt.getInteger("energy"))); + heat = nbt.getInteger("heat"); + fireCooldown = nbt.getInteger("cooldown"); + shotsFired = nbt.getInteger("shots"); + localTarget = nbt.getBoolean("hasTarget") + ? new Vec3d(nbt.getDouble("targetX"), nbt.getDouble("targetY"), nbt.getDouble("targetZ")) + : null; + faction = nbt.hasKey("faction") ? nbt.getString("faction") : null; + owner = nbt.hasUniqueId("owner") ? nbt.getUniqueId("owner") : null; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java b/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java new file mode 100644 index 000000000..44334926e --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java @@ -0,0 +1,167 @@ +package zmaster587.advancedRocketry.weapon; + +import net.minecraft.block.Block; +import net.minecraft.block.state.IBlockState; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; +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.tile.weapon.TileTurret; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; + +/** + * Walks what was built around a gun's controller and answers what it adds up to. + * + *

    Connectivity, not a template

    + *

    A gun is the connected run of parts touching its controller — there is no fixed shape to + * match. That is what makes the contract addable-to: a part shipped by somebody else joins a gun by + * being placed against one, and the walk that finds it was not written knowing it exists. It also + * means "bigger gun" is a thing a player builds rather than a tier they unlock.

    + * + *

    Bounded on purpose

    + *

    The walk stops at {@link #MAX_PARTS}. Without a bound, a player who paves a hull in barrel + * sections gets one gun with a five-figure part count and a rebuild that walks it every time a + * block changes. The cap is a bound on WORK, and a build that reaches it is still a working gun — + * it is simply not credited for parts past the limit.

    + */ +public final class GunAssembly { + + /** The most parts one gun may be built from. */ + public static final int MAX_PARTS = 256; + + private final GunSpec spec; + private final int reach; + + private GunAssembly(GunSpec spec, int reach) { + this.spec = spec; + this.reach = reach; + } + + /** What this build is worth. Never null; an unbuilt controller answers a spec that is not operable. */ + public GunSpec getSpec() { + return spec; + } + + /** + * How far the furthest part sits from the controller, in blocks along the axes. The muzzle is + * put past this so a round is not born inside the gun that fired it — a shot spawned in its own + * barrel would resolve a structure crossing on its first tick and destroy the weapon. + */ + public int getReach() { + return reach; + } + + /** + * Walk the assembly rooted at {@code origin} — the controller's own position, which is NOT + * counted as a part. + */ + public static GunAssembly scan(World world, BlockPos origin) { + GunSpec.Builder builder = new GunSpec.Builder(); + if (world == null || origin == null) { + return new GunAssembly(builder.build(), 0); + } + + Set visited = new HashSet<>(); + Deque frontier = new ArrayDeque<>(); + visited.add(origin); + for (EnumFacing facing : EnumFacing.VALUES) { + frontier.add(origin.offset(facing)); + } + + int counted = 0; + int reach = 0; + while (!frontier.isEmpty() && counted < MAX_PARTS) { + BlockPos pos = frontier.poll(); + if (!visited.add(pos)) { + continue; + } + // An unloaded chunk is not "no part here" — it is an unknown, and generating chunks to + // answer a per-tick question would be a far worse bargain than under-counting a gun + // whose far end nobody is near. + if (!world.isBlockLoaded(pos)) { + continue; + } + IBlockState state = world.getBlockState(pos); + Block block = state.getBlock(); + if (!(block instanceof IGunPart)) { + continue; + } + IGunPart part = (IGunPart) block; + part.contributeTo(builder, world, pos, state); + builder.countPart(); + counted++; + reach = Math.max(reach, axisDistance(origin, pos)); + if (!part.conductsAssembly()) { + continue; + } + for (EnumFacing facing : EnumFacing.VALUES) { + BlockPos next = pos.offset(facing); + if (!visited.contains(next)) { + frontier.add(next); + } + } + } + return new GunAssembly(builder.build(), reach); + } + + /** + * A part entered or left the world at {@code changed}: mark every controller that could have been + * counting it. + * + *

    The search is the assembly walk run backwards — out through the parts still standing, looking + * for controllers beside any of them. It has to be a walk rather than a look at the six + * neighbours, because a barrel section added at the far end of a ten-block barrel is nowhere near + * the controller whose numbers it changes.

    + * + *

    Note the asymmetry with a break: by the time {@code breakBlock} runs, the part is already + * gone from the world, so the walk starts from its neighbours and the run it used to join is + * whatever is still connected. A part whose removal SPLITS a gun in two therefore marks only the + * halves still reachable — which is the right answer, because the other half's controller no + * longer had it either.

    + */ + public static void markControllersDirty(World world, BlockPos changed) { + if (world == null || world.isRemote || changed == null) { + return; + } + Set visited = new HashSet<>(); + Set marked = new HashSet<>(); + Deque frontier = new ArrayDeque<>(); + visited.add(changed); + frontier.add(changed); + + int walked = 0; + while (!frontier.isEmpty() && walked < MAX_PARTS) { + BlockPos pos = frontier.poll(); + for (EnumFacing facing : EnumFacing.VALUES) { + BlockPos next = pos.offset(facing); + if (!world.isBlockLoaded(next)) { + continue; + } + TileEntity tile = world.getTileEntity(next); + if (tile instanceof TileTurret && marked.add(next)) { + ((TileTurret) tile).markAssemblyDirty(); + continue; + } + if (!visited.add(next)) { + continue; + } + Block block = world.getBlockState(next).getBlock(); + if (block instanceof IGunPart && ((IGunPart) block).conductsAssembly()) { + frontier.add(next); + walked++; + } + } + } + } + + private static int axisDistance(BlockPos from, BlockPos to) { + return Math.max(Math.abs(to.getX() - from.getX()), + Math.max(Math.abs(to.getY() - from.getY()), Math.abs(to.getZ() - from.getZ()))); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java new file mode 100644 index 000000000..a6c9f468a --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java @@ -0,0 +1,197 @@ +package zmaster587.advancedRocketry.weapon; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.projectile.ShotEnvironment; +import zmaster587.advancedRocketry.api.projectile.ShotSpec; +import zmaster587.advancedRocketry.api.weapon.GunSpec; +import zmaster587.advancedRocketry.dimension.DimensionManager; +import zmaster587.advancedRocketry.dimension.DimensionProperties; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; +import zmaster587.advancedRocketry.projectile.ShotSubstrate; +import zmaster587.advancedRocketry.projectile.StructureCrossing; + +import java.util.Random; +import java.util.UUID; + +/** + * The seam between a gun that sits somewhere and a round that flies through the world. + * + *

    Two frames, and the gun only ever knows one

    + *

    A turret bolted to a ship has a position in that ship's SUBSPACE and turns with the hull; its + * bearing is therefore held in the ship's frame, and a hull that rolls carries the barrel with it + * exactly as it carries the deck. A turret on the ground has no such frame and holds its bearing in + * the world's. Both are the same code: the ship id is either there or null, and every conversion + * below is a no-op in the second case.

    + * + *

    The round leaves in world coordinates, always

    + *

    The projectile substrate integrates in the world frame — that is where the target, the shields + * and everybody watching are. So the last thing this class does before handing a shot over is + * convert, and it converts the muzzle POINT and the aim DIRECTION separately, because a rotation + * applied to a position is one of the two mistakes this seam exists to prevent. The other is + * forgetting that a gun on a moving ship inherits its motion: a round fired from a hull doing 40 + * blocks a tick and given only its own muzzle speed is a round fired backwards.

    + */ +public final class TurretFireControl { + + /** Vanilla's own surface projectile gravity, the number every thrown thing in the game falls by. */ + private static final double SURFACE_GRAVITY_PER_TICK_SQUARED = 0.03D; + + /** + * How far past the muzzle the line of fire must be clear, in blocks. Short on purpose: it is a + * check for the shooter's OWN structure sitting immediately in front of the barrel, not a + * clear-shot guarantee — a target behind a wall is a miss, which is a legitimate outcome, while a + * hull one block in front of the muzzle is a build that shells itself. + */ + private static final double LINE_OF_FIRE_BLOCKS = 3.0D; + + private TurretFireControl() { + } + + /** + * The ship whose subspace holds this block, or null for a turret standing on the ground. Asked + * of the REGISTRY rather than of the live physics, because a gun does not stop being part of its + * ship when no player happens to be near enough for it to be simulated. + */ + public static String shipIdAt(World world, BlockPos pos) { + return VSIntegration.registeredShipIdManagingBlock(world, pos); + } + + /** + * Turn a world-frame target into the direction the mount must take, in the mount's OWN frame. + * Null when the ship is not loaded and its transform therefore cannot be trusted — the caller + * holds its last bearing rather than swinging to a bearing computed from a stale pose. + */ + public static Vec3d aimDirection(World world, BlockPos mountPos, String shipId, Vec3d worldTarget) { + if (world == null || mountPos == null || worldTarget == null) { + return null; + } + Vec3d mount = center(mountPos); + if (shipId == null) { + return worldTarget.subtract(mount); + } + double[] localTarget = VSIntegration.toShipFrameFor(world, shipId, worldTarget.x, worldTarget.y, + worldTarget.z); + if (localTarget == null) { + return null; + } + return new Vec3d(localTarget[0], localTarget[1], localTarget[2]).subtract(mount); + } + + /** + * Fire one round along {@code localAim} and answer the shot id, or {@code -1} if the substrate + * refused it. {@code localAim} is in the mount's own frame — the same frame + * {@link #aimDirection} answers in. + * + * @param reach how many blocks of gun sit between the controller and open space + */ + public static long fire(World world, BlockPos mountPos, String shipId, Vec3d localAim, GunSpec spec, + int reach, UUID owner, String faction, Random random) { + if (world == null || world.isRemote || mountPos == null || localAim == null || spec == null + || !spec.isOperable() || localAim.lengthVector() < 1.0E-9D) { + return -1L; + } + + if (shipId == null && VSIntegration.isBlockInShipyard(mountPos)) { + // Depth, not the primary guard: a gun aboard an unnamed ship should never have reached a + // tick at all (its tile waits on VSIntegration.isOnUnnamedShip). This is here because + // this method is callable from anywhere, and the failure it prevents is severe out of all + // proportion to the check — treating a shipyard address as world coordinates puts a live + // round in the middle of the region every parked hull in the world sits in. + return -1L; + } + + Vec3d direction = spread(localAim.normalize(), spec.getSpreadDegrees(), random); + // Clear of the gun's own blocks: a round born inside the barrel resolves a structure + // crossing on its first tick and the weapon shoots itself apart. + double standoff = reach + 1.5D; + Vec3d localMuzzle = center(mountPos).add(direction.scale(standoff)); + + Vec3d worldMuzzle; + Vec3d worldDirection; + Vec3d carried = Vec3d.ZERO; + if (shipId == null) { + worldMuzzle = localMuzzle; + worldDirection = direction; + } else { + double[] point = VSIntegration.toWorldFrameFor(world, shipId, localMuzzle.x, localMuzzle.y, + localMuzzle.z); + double[] dir = VSIntegration.rotateToWorldFrameFor(world, shipId, direction.x, direction.y, + direction.z); + if (point == null || dir == null) { + return -1L; + } + worldMuzzle = new Vec3d(point[0], point[1], point[2]); + worldDirection = new Vec3d(dir[0], dir[1], dir[2]).normalize(); + double[] shipVelocity = VSIntegration.shipVelocityAtPointFor(world, shipId, worldMuzzle.x, + worldMuzzle.y, worldMuzzle.z); + if (shipVelocity != null) { + carried = new Vec3d(shipVelocity[0], shipVelocity[1], shipVelocity[2]); + } + } + + if (StructureCrossing.isBlocked(world, worldMuzzle, + worldMuzzle.add(worldDirection.scale(LINE_OF_FIRE_BLOCKS)))) { + // Something of the shooter's own is in the way — a hull the turret is recessed into, a + // superstructure its arc crosses, the wall a ground battery was mounted behind. The gun + // holds rather than demolishing it: a build that cannot fire safely is a problem the + // player can see, and a gun that shells its own deck is a mystery. + return -1L; + } + + Vec3d velocity = worldDirection.scale(spec.getMuzzleSpeed()).add(carried); + ShotSpec shot = new ShotSpec(worldMuzzle, velocity, spec.getProjectileRadius(), + spec.getProjectileMass(), spec.getLifetimeTicks(), spec.getImpactEnergy(), + spec.getKind(), owner, faction, environmentOf(world), null); + return ShotSubstrate.launch(world, shot); + } + + /** + * What acts on a round fired in this world. Read once, at the muzzle, because the shot carries + * its environment rather than looking one up while it flies. + */ + public static ShotEnvironment environmentOf(World world) { + if (world == null) { + return ShotEnvironment.VACUUM; + } + int dimension = world.provider.getDimension(); + if (!DimensionManager.getInstance().isDimensionCreated(dimension)) { + // Not one of ours: a vanilla world, where things fall at vanilla's rate. + return ShotEnvironment.gravity(SURFACE_GRAVITY_PER_TICK_SQUARED); + } + DimensionProperties properties = DimensionManager.getInstance().getDimensionProperties(dimension); + if (properties == null) { + return ShotEnvironment.gravity(SURFACE_GRAVITY_PER_TICK_SQUARED); + } + return ShotEnvironment.gravity(SURFACE_GRAVITY_PER_TICK_SQUARED + * properties.getGravitationalMultiplier()); + } + + /** The block's middle, which is where a gun's axis runs — not its lower north-west corner. */ + public static Vec3d center(BlockPos pos) { + return new Vec3d(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D); + } + + /** + * Scatter a direction inside a cone of the given half-angle. A zero spread returns the direction + * untouched — a true barrel is exact, not "very nearly exact", so a test of the aim path is not + * fighting a random number. + */ + static Vec3d spread(Vec3d direction, double halfAngleDegrees, Random random) { + if (halfAngleDegrees <= 0.0D || random == null) { + return direction; + } + // Two small rotations about axes perpendicular to the shot are indistinguishable from a + // proper cone sample at the angles a barrel actually scatters by, and cost no trigonometry + // beyond what is already here. + Vec3d reference = Math.abs(direction.y) > 0.9D ? new Vec3d(1.0D, 0.0D, 0.0D) + : new Vec3d(0.0D, 1.0D, 0.0D); + Vec3d right = direction.crossProduct(reference).normalize(); + Vec3d up = right.crossProduct(direction).normalize(); + double radians = Math.toRadians(halfAngleDegrees); + double a = (random.nextDouble() * 2.0D - 1.0D) * radians; + double b = (random.nextDouble() * 2.0D - 1.0D) * radians; + return direction.add(right.scale(Math.tan(a))).add(up.scale(Math.tan(b))).normalize(); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java b/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java new file mode 100644 index 000000000..971824926 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java @@ -0,0 +1,229 @@ +package zmaster587.advancedRocketry.weapon; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.api.weapon.TurretDriveState; + +/** + * The traverse: a bearing, the limits it may take, the rate it may change at, and what happens when + * the drive stops working. + * + *

    A mechanism, not a pair of angles

    + *

    The mount is the first of several things in this mod that are commanded rather than set — an + * engine gimbal and a landing gear are the same shape. So the state here is deliberately the whole + * shape: where it IS, where it was TOLD to go, whether that command was inside what the build can + * do, and which failure it is in. A caller reads the current bearing and gets the truth in every + * one of those cases, including the ones where the truth is "wherever it seized".

    + * + *

    Saturation is visible, never a silent clamp

    + *

    Commanding a bearing outside the declared arc does not quietly snap to the nearest legal one + * and report success. The mount goes as far as it may and {@link #isSaturated()} stays true for as + * long as the command is out of reach, so a console can show a player that the target is behind the + * hull rather than leaving them to wonder why nothing is being hit.

    + * + *

    Angles

    + *

    Yaw is degrees clockwise from south in Minecraft's own convention, so a bearing computed from + * a direction vector here matches one computed anywhere else in the game. Pitch is degrees, positive + * DOWN — again Minecraft's convention — and the elevation limits are expressed in it, so a limit + * read off this class means the same thing as one read off an entity.

    + */ +public class TurretMechanism { + + /** How close counts as on target. Half a degree is finer than any barrel's spread. */ + public static final double AIM_TOLERANCE_DEGREES = 0.5D; + + /** Degrees per tick a freewheeling mount drifts. Slow, constant, and never a command. */ + private static final double FREEWHEEL_DRIFT_DEGREES = 0.75D; + + private double yaw; + private double pitch; + private double commandedYaw; + private double commandedPitch; + private boolean commanded; + private boolean saturated; + private TurretDriveState driveState = TurretDriveState.WORKING; + + private final double minPitch; + private final double maxPitch; + + /** + * @param minPitch most upward the barrel may point, in Minecraft pitch (negative is up) + * @param maxPitch most downward the barrel may point + */ + public TurretMechanism(double minPitch, double maxPitch) { + this.minPitch = Math.min(minPitch, maxPitch); + this.maxPitch = Math.max(minPitch, maxPitch); + } + + /** A mount that may point anywhere above the horizontal and a little below it. */ + public static TurretMechanism standard() { + return new TurretMechanism(-90.0D, 20.0D); + } + + /** + * Point at this world direction. The command is remembered as given: a target that later moves + * back inside the arc is reached without anybody having to re-issue anything. + */ + public void commandDirection(Vec3d direction) { + if (direction == null || direction.lengthVector() < 1.0E-9D) { + return; + } + Vec3d unit = direction.normalize(); + double horizontal = Math.sqrt(unit.x * unit.x + unit.z * unit.z); + commandBearing(Math.toDegrees(Math.atan2(-unit.x, unit.z)), + Math.toDegrees(-Math.atan2(unit.y, horizontal))); + } + + public void commandBearing(double yawDegrees, double pitchDegrees) { + this.commandedYaw = wrapDegrees(yawDegrees); + this.commandedPitch = pitchDegrees; + this.commanded = true; + } + + /** Stop asking for anything. The mount holds where it is; it does not return to a home bearing. */ + public void clearCommand() { + this.commanded = false; + this.saturated = false; + } + + public boolean hasCommand() { + return commanded; + } + + /** + * Advance the mount one tick towards its command at no more than {@code ratePerTick} degrees, + * derated by the drive state. Answers whether the mount is now pointing where it was told. + */ + public boolean tick(double ratePerTick) { + if (driveState == TurretDriveState.FREEWHEELING) { + // No brake: it turns because nothing is holding it, not because anybody asked. + yaw = wrapDegrees(yaw + FREEWHEEL_DRIFT_DEGREES); + saturated = false; + return false; + } + if (!commanded || !driveState.isDrivable()) { + return commanded && isOnTarget(); + } + + double reachablePitch = clampPitch(commandedPitch); + // Saturation is decided against what was ASKED FOR, before the arc clamps it — a mount that + // reports "on target" at the edge of its arc while the target is beyond it is a mount that + // lies once per engagement. + saturated = Math.abs(reachablePitch - commandedPitch) > 1.0E-6D; + + double step = Math.max(0.0D, ratePerTick) * driveState.getRateFactor(); + if (step <= 0.0D) { + return false; + } + yaw = approach(yaw, commandedYaw, step, true); + pitch = approach(pitch, reachablePitch, step, false); + return isOnTarget(); + } + + /** + * Whether the barrel is within tolerance of what it was ASKED for — not of the arc-clamped + * version of it. A mount at the edge of its arc with the target beyond it is not on target, and + * saying otherwise would let a gun fire happily into its own hull once per engagement. + */ + public boolean isOnTarget() { + if (!commanded) { + return false; + } + double dYaw = Math.abs(wrapDegrees(commandedYaw - yaw)); + double dPitch = Math.abs(commandedPitch - pitch); + return dYaw <= AIM_TOLERANCE_DEGREES && dPitch <= AIM_TOLERANCE_DEGREES; + } + + /** The direction the barrel actually points, whatever the reason it points there. */ + public Vec3d getAimDirection() { + double yawRad = Math.toRadians(yaw); + double pitchRad = Math.toRadians(pitch); + double horizontal = Math.cos(pitchRad); + return new Vec3d(-Math.sin(yawRad) * horizontal, -Math.sin(pitchRad), Math.cos(yawRad) * horizontal); + } + + /** What the mount was TOLD, as opposed to where it has got to. What a client is sent. */ + public double getCommandedYaw() { + return commandedYaw; + } + + public double getCommandedPitch() { + return commandedPitch; + } + + public double getYaw() { + return yaw; + } + + public double getPitch() { + return pitch; + } + + /** True while the command asks for a bearing the build cannot reach. */ + public boolean isSaturated() { + return saturated; + } + + public TurretDriveState getDriveState() { + return driveState; + } + + /** + * Change the drive state. A mount entering a state that holds no command keeps its bearing — + * the whole point of the failure vocabulary is that a killed drive leaves the barrel somewhere + * definite rather than nowhere. + */ + public void setDriveState(TurretDriveState state) { + if (state != null) { + this.driveState = state; + if (!state.isDrivable()) { + this.saturated = false; + } + } + } + + public void writeToNBT(NBTTagCompound nbt) { + nbt.setDouble("yaw", yaw); + nbt.setDouble("pitch", pitch); + nbt.setDouble("cmdYaw", commandedYaw); + nbt.setDouble("cmdPitch", commandedPitch); + nbt.setBoolean("commanded", commanded); + nbt.setInteger("drive", driveState.ordinal()); + } + + public void readFromNBT(NBTTagCompound nbt) { + yaw = nbt.getDouble("yaw"); + pitch = nbt.getDouble("pitch"); + commandedYaw = nbt.getDouble("cmdYaw"); + commandedPitch = nbt.getDouble("cmdPitch"); + commanded = nbt.getBoolean("commanded"); + int drive = nbt.getInteger("drive"); + TurretDriveState[] states = TurretDriveState.values(); + driveState = drive >= 0 && drive < states.length ? states[drive] : TurretDriveState.WORKING; + } + + private double clampPitch(double value) { + return Math.max(minPitch, Math.min(maxPitch, value)); + } + + private static double approach(double current, double target, double step, boolean wrapping) { + double delta = wrapping ? wrapDegrees(target - current) : target - current; + if (Math.abs(delta) <= step) { + return wrapping ? wrapDegrees(target) : target; + } + double moved = current + Math.copySign(step, delta); + return wrapping ? wrapDegrees(moved) : moved; + } + + /** To (-180, 180]. Written out rather than borrowed so this class stays testable off-thread. */ + private static double wrapDegrees(double degrees) { + double wrapped = degrees % 360.0D; + if (wrapped <= -180.0D) { + wrapped += 360.0D; + } + if (wrapped > 180.0D) { + wrapped -= 360.0D; + } + return wrapped; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkDomain.java b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkDomain.java new file mode 100644 index 000000000..01b0d9eef --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkDomain.java @@ -0,0 +1,57 @@ +package zmaster587.advancedRocketry.weapon; + +import org.apache.logging.log4j.Logger; +import zmaster587.advancedRocketry.AdvancedRocketry; +import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkController; +import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain; +import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState; + +import java.util.List; + +/** + * The weapons network: energy shared between guns, and one place to point them all. + * + *

    The network is a convenience, and nothing depends on it

    + *

    Every gun works alone. It holds its own energy buffer, picks its own target and fires with no + * cable, console or network attached — a battery of one is a supported build, not a degraded one. + * What joining a network buys is what a player would otherwise do by hand: one console aiming a + * dozen guns at the same thing, and a shared supply that fills the guns that matter first under a + * deficit. Losing the network loses those conveniences and nothing else, which is why no code path + * below asks whether a state exists before deciding whether a gun may fire.

    + * + *

    The commodity is Forge Energy

    + *

    Guns are sinks, generators and capacitor banks are sources, and the unit is FE per tick — the + * same unit the rest of the mod's power is in, so a player wiring a gun into a ship's supply is not + * learning a second kind of energy.

    + */ +public final class WeaponNetworkDomain extends SubsystemNetworkDomain { + + public static final WeaponNetworkDomain INSTANCE = new WeaponNetworkDomain(); + + private WeaponNetworkDomain() { + super("Weapon"); + } + + @Override + public SubsystemNetworkState newState() { + return new WeaponNetworkState(); + } + + @Override + public void onComponentRebuilt(SubsystemNetworkState state, List controllers) { + if (!(state instanceof WeaponNetworkState)) { + return; + } + // A network with no console left commands nothing. Keeping the last console's target would + // leave a battery firing at a point nobody can retract, which is the one failure mode a + // player cannot fix by breaking something. + if (controllers.isEmpty()) { + ((WeaponNetworkState) state).clearTarget(); + } + } + + @Override + public Logger getLogger() { + return AdvancedRocketry.logger; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java new file mode 100644 index 000000000..2576b8195 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java @@ -0,0 +1,54 @@ +package zmaster587.advancedRocketry.weapon; + +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState; + +/** + * What a weapons network agrees on: where it is pointing, and whether it may shoot. + * + *

    One target, held by the network rather than by a console

    + *

    Two consoles on one network cannot disagree, because neither of them owns this — they both + * edit it. That also survives the console being broken and rebuilt, and it is what makes "assign a + * target" a network-level act rather than a message a console has to keep re-sending to each gun.

    + * + *

    Hold-fire is a separate switch from having a target

    + *

    Aiming and shooting are different decisions: a battery tracking an approaching ship without + * firing on it is the normal state of a defended station. So clearing the target is not how one + * stops the shooting, and holding fire does not make the guns forget where the enemy is.

    + */ +public class WeaponNetworkState extends SubsystemNetworkState { + + private Vec3d target; + private boolean holdFire; + + /** Where the network's guns are pointed, in WORLD coordinates, or null when nothing is assigned. */ + public Vec3d getTarget() { + return target; + } + + public void setTarget(Vec3d target) { + this.target = target; + } + + public void clearTarget() { + this.target = null; + } + + /** True while the network's guns must track but not shoot. */ + public boolean isHoldFire() { + return holdFire; + } + + public void setHoldFire(boolean holdFire) { + this.holdFire = holdFire; + } + + @Override + public SubsystemNetworkState copy() { + WeaponNetworkState copy = new WeaponNetworkState(); + copyInto(copy); + copy.target = target; + copy.holdFire = holdFire; + return copy; + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/gunammofeed.json b/src/main/resources/assets/advancedrocketry/blockstates/gunammofeed.json new file mode 100644 index 000000000..bc13d4bdb --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/gunammofeed.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:cube_all", + "textures": { + "all": "advancedrocketry:blocks/intake" + } + }, + "variants": { + "normal": [ + {} + ], + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/gunbarrel.json b/src/main/resources/assets/advancedrocketry/blockstates/gunbarrel.json new file mode 100644 index 000000000..22a674dbe --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/gunbarrel.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:cube_all", + "textures": { + "all": "advancedrocketry:blocks/railgun" + } + }, + "variants": { + "normal": [ + {} + ], + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/guncooling.json b/src/main/resources/assets/advancedrocketry/blockstates/guncooling.json new file mode 100644 index 000000000..dfd1c8914 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/guncooling.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:cube_all", + "textures": { + "all": "advancedrocketry:blocks/machinevent" + } + }, + "variants": { + "normal": [ + {} + ], + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/turret.json b/src/main/resources/assets/advancedrocketry/blockstates/turret.json new file mode 100644 index 000000000..be6cd8af6 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/turret.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:cube_all", + "textures": { + "all": "advancedrocketry:blocks/machineorientationcontrol" + } + }, + "variants": { + "normal": [ + {} + ], + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index a96f93319..273eef9cd 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -66,6 +66,10 @@ tile.chipStorage.name=Satellite ID Storage tile.planetanalyser.name=Astrobody Data Processor tile.lunaranalyser.name=Lunar Analyser tile.guidanceComputer.name=Guidance Computer +tile.turret.name=Turret Mount +tile.gunBarrel.name=Gun Barrel Section +tile.gunAmmoFeed.name=Gun Ammunition Feed +tile.gunCooling.name=Gun Cooling Jacket tile.advancedFlightComputer.name=Advanced Flight Computer tile.navigationComputer.name=Navigation Computer tile.electricArcFurnace.name=Electric Arc Furnace diff --git a/src/main/resources/assets/advancedrocketry/recipes/gunammofeed.json b/src/main/resources/assets/advancedrocketry/recipes/gunammofeed.json new file mode 100644 index 000000000..aae43c894 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/gunammofeed.json @@ -0,0 +1,22 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " i ", + "iri", + " i " + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "r": { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": { + "item": "advancedrocketry:gunAmmoFeed", + "count": 1 + } +} diff --git a/src/main/resources/assets/advancedrocketry/recipes/gunbarrel.json b/src/main/resources/assets/advancedrocketry/recipes/gunbarrel.json new file mode 100644 index 000000000..98f965b6f --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/gunbarrel.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "i i", + "i i", + "iii" + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + } + }, + "result": { + "item": "advancedrocketry:gunBarrel", + "count": 2 + } +} diff --git a/src/main/resources/assets/advancedrocketry/recipes/guncooling.json b/src/main/resources/assets/advancedrocketry/recipes/guncooling.json new file mode 100644 index 000000000..c177184d2 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/guncooling.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "i i", + " i ", + "i i" + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + } + }, + "result": { + "item": "advancedrocketry:gunCooling", + "count": 2 + } +} diff --git a/src/main/resources/assets/advancedrocketry/recipes/turret.json b/src/main/resources/assets/advancedrocketry/recipes/turret.json new file mode 100644 index 000000000..3302256e0 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/turret.json @@ -0,0 +1,26 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " c ", + "iri", + "iii" + ], + "key": { + "c": { + "type": "forge:ore_dict", + "ore": "circuitBasic" + }, + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "r": { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": { + "item": "advancedrocketry:turret", + "count": 1 + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ShotReachesClientE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ShotReachesClientE2ETest.java new file mode 100644 index 000000000..b8c4d9f86 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ShotReachesClientE2ETest.java @@ -0,0 +1,76 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Whether a fired round reaches the person it is fired near. + * + *

    A shot is a server-side record: no entity, no chunk, nothing vanilla replicates on its own. So + * "you can see the gun firing" is entirely a claim about a packet, and it is checkable on the real + * client — the client's own tracker is asked how many rounds it is drawing, on the client thread, + * after the server fired one. The control is the half that makes it worth running: a round fired far + * enough away must NOT arrive, or the filter that keeps a battery off every connection in the world + * is not doing anything.

    + * + *

    Gated by {@code forge.test.client.enabled=true}; auto-skips on headless CI.

    + */ +public class ShotReachesClientE2ETest extends AbstractClientE2ETest { + + private static final String TRACKER = "zmaster587.advancedRocketry.client.ClientShotTracker"; + + /** Where the player stands for both halves. */ + private static final double PX = 8.5D, PY = 79.0D, PZ = 8.5D; + + /** Comfortably inside the default 256-block visibility radius. */ + private static final double NEAR = 40.0D; + + /** Comfortably outside it, and travelling further away. */ + private static final double FAR = 4_000.0D; + + @Test + public void aRoundFiredNearbyIsDrawnByTheClientAndOneFiredFarAwayIsNot() throws Exception { + serverClient().execute("tp @a " + PX + " " + PY + " " + PZ); + bot().waitTicks(5); + clearTracker(); + assertEquals("the client tracker did not start empty", 0, trackedShots()); + + // Fired 40 blocks away, across the player's view. The launch is production's own entry + // point — the same call a turret makes. + String fired = String.join("\n", serverClient().execute("artest shot fire 0 " + + (PX + NEAR) + " " + PY + " " + PZ + " 0 0 4 2000 200")); + assertTrue("the launch was refused, so nothing else here means anything: " + fired, + fired.contains("\"ok\":true")); + + int drawn = 0; + for (int waited = 0; waited < 60 && drawn == 0; waited += 10) { + bot().waitTicks(10); + drawn = trackedShots(); + } + assertTrue("a round fired 40 blocks from the player never reached the client: a shot is a" + + " server record, so a client that is not told about one cannot draw it and the" + + " turret fires invisibly", drawn >= 1); + + // The control. Without this the test would pass just as well against a replication layer + // that told everybody about everything. + clearTracker(); + String distant = String.join("\n", serverClient().execute("artest shot fire 0 " + + (PX + FAR) + " " + PY + " " + (PZ + FAR) + " 4 0 4 2000 200")); + assertTrue("the distant launch was refused: " + distant, distant.contains("\"ok\":true")); + bot().waitTicks(40); + assertEquals("a round fired four kilometres away was replicated to this client anyway —" + + " the visibility filter is not filtering", 0, trackedShots()); + } + + private int trackedShots() throws Exception { + return Integer.parseInt(bot().invokeStaticInt(TRACKER, "count") + .get("returned").getAsString()); + } + + private void clearTracker() throws Exception { + bot().invokeStaticInt(TRACKER, "clear"); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/TurretAimReachesClientE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/TurretAimReachesClientE2ETest.java new file mode 100644 index 000000000..10b7db487 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/TurretAimReachesClientE2ETest.java @@ -0,0 +1,91 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * Whether a player can see which way a turret is pointing. + * + *

    Why this is a client test and not a unit one

    + *

    A block cannot be turned — it sits in a grid cell at one of a handful of fixed orientations — so + * a turret's bearing exists only as numbers on the server and as a drawing on the client. The whole + * question is therefore whether those numbers arrive, and that is answerable only on a real client. + * What is asserted is the state the renderer draws FROM (the client tile's own update tag), because a + * renderer's output cannot be read from a test; what is NOT asserted is that the barrel looks right, + * which stays a human's judgement.

    + * + *

    The command travels, not the pose

    + *

    The client runs the same traverse the server does, from the command it was sent. So the test + * waits for the client's bearing to converge on the direction the gun was pointed rather than + * expecting a particular angle at a particular tick — the pose is the client's own arithmetic, and + * pinning it would be pinning the harness's timing.

    + * + *

    Gated by {@code forge.test.client.enabled=true}; auto-skips on headless CI.

    + */ +public class TurretAimReachesClientE2ETest extends AbstractClientE2ETest { + + private static final int X = 120, Y = 79, Z = 120; + + /** Long enough for the mount to swing 90 degrees at the reference gun's rate, with room to spare. */ + private static final long AIM_TIMEOUT_MS = 25_000L; + + @Test + public void theBearingTheServerCommandsArrivesAtTheClient() throws Exception { + server("artest chunk warmup 0 " + ((X - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + + ((X + 16) >> 4) + " " + ((Z + 16) >> 4)); + server("artest fill 0 " + (X - 3) + " " + (Y - 1) + " " + (Z - 3) + " " + (X + 3) + " " + + (Y + 6) + " " + (Z + 3) + " minecraft:air"); + server("artest place 0 " + X + " " + Y + " " + Z + " advancedrocketry:turret"); + for (int i = 1; i <= 4; i++) { + server("artest place 0 " + X + " " + (Y + i) + " " + Z + " advancedrocketry:gunBarrel"); + } + // Stand next to it, so the client is tracking this chunk and its tile. + server("tp @a " + (X + 4) + ".5 " + Y + " " + (Z + 0.5D)); + bot().waitTicks(20); + + String before = clientMountNbt(); + assertTrue("the client has no turret tile to draw: " + before, before.contains("mount")); + double startYaw = tagDouble(before, "yaw"); + + // Point it hard to one side: a bearing the mount has to travel to, not one it is already at. + server("artest turret target 0 " + X + " " + Y + " " + Z + " " + (X + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + + long deadline = System.currentTimeMillis() + AIM_TIMEOUT_MS; + String nbt = clientMountNbt(); + while (System.currentTimeMillis() < deadline && Math.abs(tagDouble(nbt, "yaw") - startYaw) < 45.0D) { + bot().waitTicks(20); + nbt = clientMountNbt(); + } + + double yaw = tagDouble(nbt, "yaw"); + assertNotEquals("the client's turret never turned: the server commanded a bearing 90 degrees" + + " away and the client is still at its start. A gun whose barrel does not move is a" + + " gun a player cannot read: " + nbt, startYaw, yaw, 45.0D); + // -90 is due +X in Minecraft's yaw convention, which is where the target was put. + assertTrue("the client turned, but not towards the target (yaw=" + yaw + ", expected about" + + " -90): " + nbt, Math.abs(yaw + 90.0D) < 15.0D); + } + + private String clientMountNbt() throws Exception { + JsonObject tile = bot().tileNbt(X, Y, Z); + return tile.has("nbt") ? tile.get("nbt").getAsString() : ""; + } + + /** Pull one double out of a stringified NBT compound ({@code key:12.5d}). */ + private static double tagDouble(String nbt, String key) { + Matcher m = Pattern.compile(key + ":(-?[\\d.eE+]+)d?").matcher(nbt); + return m.find() ? Double.parseDouble(m.group(1)) : Double.NaN; + } + + private void server(String command) throws Exception { + serverClient().execute(command); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java new file mode 100644 index 000000000..3d64ee1cf --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java @@ -0,0 +1,329 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * A gun with nothing attached to it. + * + *

    No cable, no console, no network — the configuration a player who has just built their first + * turret is in, and the one a design that leans on a control network is most likely to leave broken. + * Everything asserted here is about that gun alone: that its numbers come from what was built around + * it, that it fires at what it was pointed at, and that it stops firing for reasons it states. If a + * later wave makes any of this depend on a network being present, these go red — which is the point + * of writing them before the network has a console at all.

    + */ +public class TurretStandaloneE2ETest extends AbstractSharedServerTest { + + /** This class's own site, clear of the other server scenarios. */ + private static final int X = 9400, Y = 80, Z = 9400; + + /** How long a gun is given to get a round away before the test calls it broken. */ + private static final long FIRE_TIMEOUT_MS = 20_000L; + + /** Vanilla's surface projectile gravity — what a round fired in the overworld falls by per tick. */ + private static final double SURFACE_GRAVITY_PER_TICK_SQUARED = 0.03D; + + /** + * Inside the region Valkyrien Skies allocates ship blocks in — its chunk allocator starts at + * chunk X 320000, so anything past block X ~5.12 million is shipyard. + */ + private static final int SHIPYARD_X = 5_120_400; + + @Test + public void aGunWithNoNetworkFiresAtWhatItWasPointedAt() throws Exception { + int bx = X; + buildSite(bx); + buildGun(bx); + + String built = awaitOperable(bx); + assertTrue("what was built is not a gun: " + built, built.contains("\"operable\":true")); + assertEquals("every part placed should have been counted: " + built, 8, extractInt(built, "parts")); + assertTrue("a built gun must have a muzzle speed: " + built, readDouble(built, "muzzleSpeed") > 0.0D); + + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + // A point 40 blocks away, level with the mount: reachable, and nothing of the gun's own is + // in the way. + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + + String fired = awaitShots(bx, 1); + assertTrue("a gun with a target, a charge and no network never fired: " + fired, + extractInt(fired, "shots") >= 1); + assertTrue("a gun that fired must name the round it fired: " + fired, + readLong(fired, "lastShot") > 0L); + } + + /** + * The round is a real one: it exists in the substrate, it is going the way the gun is pointing, + * and it is worth what the build says it is worth. + */ + @Test + public void theRoundItFiresIsTheRoundItsBuildDescribes() throws Exception { + int bx = X + 100; + buildSite(bx); + buildGun(bx); + awaitOperable(bx); + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + // Straight up: nothing to hit, so the round is still in the air to be read. + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 0.5D) + " " + + (Y + 200.5D) + " " + (Z + 0.5D)); + + String fired = awaitShots(bx, 1); + long shotId = readLong(fired, "lastShot"); + int declaredEnergy = extractInt(fired, "impactEnergy"); + double declaredSpeed = readDouble(fired, "muzzleSpeed"); + + String shot = exec("artest shot read 0 " + shotId); + assertTrue("the gun reported a shot the substrate does not have: " + shot, + shot.contains("\"present\":true")); + assertEquals("the round is not worth what the build says it is worth: " + shot, + declaredEnergy, extractInt(shot, "energy")); + // Not an equality: by the time a test can read it, the round has been in the air for a few + // ticks and the world's gravity has been acting on it — which is the substrate doing its + // job. What is pinned is that it LEFT at the build's muzzle speed and that nothing other + // than the declared environment has touched it since. + int age = extractInt(shot, "age"); + double speed = readDouble(shot, "speed"); + double gravityLoss = SURFACE_GRAVITY_PER_TICK_SQUARED * age; + assertTrue("the round is faster than the build can fire (" + speed + " vs " + declaredSpeed + + "): " + shot, speed <= declaredSpeed + 1.0E-6D); + assertTrue("the round is slower than gravity alone can explain (" + speed + " after " + age + + " ticks, muzzle " + declaredSpeed + "): something other than the declared" + + " environment is acting on it: " + shot, + speed >= declaredSpeed - gravityLoss - 1.0E-3D); + assertTrue("a gun aimed straight up fired something that is not going up: " + shot, + readDouble(shot, "vy") > 0.0D); + } + + /** A controller with nothing built around it is not a gun and does not fire. */ + @Test + public void anUnbuiltControllerIsNotAGunAndFiresNothing() throws Exception { + int bx = X + 200; + buildSite(bx); + place("advancedrocketry:turret", bx, Y, Z); + + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + Thread.sleep(3_000L); + + String state = read(bx); + assertFalse("a bare controller must not report itself operable: " + state, + state.contains("\"operable\":true")); + assertEquals("a bare controller fired something: " + state, 0, extractInt(state, "shots")); + } + + /** A dead drive is the one failure that stops the shooting as well as the turning. */ + @Test + public void aDeadDriveStopsTheGunFiring() throws Exception { + int bx = X + 300; + buildSite(bx); + buildGun(bx); + // Wait for the build to be counted BEFORE killing the drive: a gun that was never assembled + // fires nothing either, and this test would then pass without ever exercising its subject. + String armed = awaitOperable(bx); + assertTrue("the gun was never assembled, so a silent gun proves nothing: " + armed, + armed.contains("\"operable\":true")); + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + exec("artest turret drive 0 " + bx + " " + Y + " " + Z + " DEAD"); + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + Thread.sleep(3_000L); + + String state = read(bx); + assertEquals("a gun with a dead drive fired: " + state, 0, extractInt(state, "shots")); + assertEquals("the drive state was not the one that was set: " + state, "DEAD", + extractString(state, "drive")); + } + + /** + * A gun whose own hull is in front of the barrel holds fire instead of demolishing it. + * + *

    Every other scenario in this class mounts the gun in open air, which is exactly the + * arrangement that cannot exhibit the defect this pins: the muzzle sits a few blocks along the + * aim and nothing asks what is there, so a turret recessed into a hull shells its own ship one + * round at a time.

    + */ + @Test + public void aGunWithItsOwnHullInFrontOfTheBarrelHoldsFire() throws Exception { + int bx = X + 400; + buildSite(bx); + buildGun(bx); + awaitOperable(bx); + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + + // A wall across the line of fire, just past where the muzzle sits. + assertTrue("could not build the wall", exec("artest fill 0 " + (bx + 6) + " " + (Y - 1) + " " + + (Z - 2) + " " + (bx + 7) + " " + (Y + 2) + " " + (Z + 2) + " minecraft:stone") + .contains("\"ok\":true")); + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + Thread.sleep(4_000L); + + String blocked = read(bx); + assertEquals("a gun fired into the structure it is built into: " + blocked, 0, + extractInt(blocked, "shots")); + assertTrue("the gun was not even aiming, so the silence proves nothing: " + blocked, + blocked.contains("\"onTarget\":true")); + + // The control: take the wall away and the same gun, same target, fires. + assertTrue("could not clear the wall", exec("artest fill 0 " + (bx + 6) + " " + (Y - 1) + " " + + (Z - 2) + " " + (bx + 7) + " " + (Y + 2) + " " + (Z + 2) + " minecraft:air") + .contains("\"ok\":true")); + String firing = awaitShots(bx, 1); + assertTrue("with the obstruction gone the gun still refuses to fire, so the hold was not" + + " about the wall: " + firing, extractInt(firing, "shots") >= 1); + } + + /** + * A gun standing in the shipyard that no ship claims does NOTHING — it does not even count its + * own build. + * + *

    Valkyrien Skies keeps ship blocks in a far-off region (block X past ~5.12 million), and a + * ship's chunks load before its ship object exists. In that window every coordinate a machine + * aboard holds is a shipyard address rather than a place in the world, so there is no partial + * behaviour that is correct — only waiting. The control that keeps this from passing for the + * wrong reason is {@link #aGunWithNoNetworkFiresAtWhatItWasPointedAt}: the same eight blocks, + * placed the same way at ordinary coordinates, assemble and fire.

    + */ + @Test + public void aGunAboardAnUnnamedShipDoesNothingAtAll() throws Exception { + int bx = SHIPYARD_X; + buildSite(bx); + buildGun(bx); + + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + Thread.sleep(4_000L); + + String state = read(bx); + assertFalse("a gun aboard an unnamed ship counted its build: it is ticking when it should be" + + " waiting: " + state, state.contains("\"operable\":true")); + assertEquals("a gun aboard an unnamed ship fired: " + state, 0, extractInt(state, "shots")); + assertEquals("a gun aboard an unnamed ship turned: " + state, 0.0D, readDouble(state, "yaw"), + 1.0E-9D); + + // NOT a global count: this class shares one server and its other scenarios have rounds of + // their own in the air. The precise claim is that nothing is flying out THERE — a round + // fired from the shipyard address would be, by tens of thousands of blocks. + String inFlight = exec("artest shot list 0"); + double furthest = furthestShotX(inFlight); + assertTrue("a round is in the air in the shipyard (x=" + furthest + "), so the gun acted on" + + " an address no player can reach: " + inFlight, furthest < 1_000_000.0D); + } + + // ---- scenario construction + + /** + * The reference gun: a controller with four barrel sections, two feeds and two cooling jackets + * around it. Every one of them touches the run, which is all the assembly asks of a build. + */ + private void buildGun(int bx) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", bx, Y + i, Z); + } + place("advancedrocketry:gunAmmoFeed", bx + 1, Y, Z); + place("advancedrocketry:gunAmmoFeed", bx - 1, Y, Z); + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + /** Air around the site, and a chunk that stays loaded so the gun's own tile actually ticks. */ + private void buildSite(int bx) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((bx - 16) >> 4) + " " + + ((Z - 16) >> 4) + " " + ((bx + 64) >> 4) + " " + ((Z + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill 0 " + (bx - 4) + " " + (Y - 2) + " " + + (Z - 4) + " " + (bx + 60) + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air") + .contains("\"ok\":true")); + assertTrue("could not hold the chunk", exec("artest chunk forceload 0 " + (bx >> 4) + " " + + (Z >> 4)).contains("\"ok\":true")); + } + + /** + * Wait for the gun to have fired at least {@code wanted} rounds, and answer its state. + * + *

    Polled rather than counted in ticks: the harness server really runs, so how many ticks pass + * while a command round-trips is not something a test gets to decide. What is asserted is that + * it fired at all, within a bound generous enough that a slow host is not a failure.

    + */ + private String awaitShots(int bx, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + FIRE_TIMEOUT_MS; + String state = read(bx); + while (System.currentTimeMillis() < deadline && extractInt(state, "shots") < wanted) { + Thread.sleep(250L); + state = read(bx); + } + return state; + } + + /** + * Wait until the gun has counted what was built around it. The assembly is re-walked on its own + * cadence rather than on every block change, so a read taken the instant the last part lands is + * reading a gun that has not looked at itself yet. + */ + private String awaitOperable(int bx) throws Exception { + long deadline = System.currentTimeMillis() + FIRE_TIMEOUT_MS; + String state = read(bx); + while (System.currentTimeMillis() < deadline && !state.contains("\"operable\":true")) { + Thread.sleep(250L); + state = read(bx); + } + return state; + } + + private String read(int bx) throws Exception { + return exec("artest turret read 0 " + bx + " " + Y + " " + Z); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + y + "," + z + ": " + resp, + resp.contains("\"placed\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + /** The largest x any shot in flight reports, or 0 when nothing is up. */ + private static double furthestShotX(String json) { + Matcher m = Pattern.compile("\"x\":(-?[\\d.eE+]+)").matcher(json); + double furthest = 0.0D; + while (m.find()) { + furthest = Math.max(furthest, Math.abs(Double.parseDouble(m.group(1)))); + } + return furthest; + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[\\d.eE+]+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/GunSpecTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/GunSpecTest.java new file mode 100644 index 000000000..e0bb6a281 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/GunSpecTest.java @@ -0,0 +1,87 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.api.weapon.GunSpec; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * What a gun's numbers promise the player who builds it. + * + *

    The promise is that parts add up: two barrels are worth more than one, a part never + * silently overrides what another contributed, and a build that is not a gun says so instead of + * firing something worthless. These are the properties an addon's part depends on when it joins a + * build it knows nothing about, so they are pinned here rather than left to whatever the first + * caller happened to observe.

    + */ +public class GunSpecTest { + + private static final double EPSILON = 1.0E-9D; + + /** A controller with nothing built around it is not a gun, and does not pretend to be one. */ + @Test + public void anEmptyBuildIsNotOperable() { + assertFalse("an empty assembly must not be operable", GunSpec.EMPTY.isOperable()); + assertEquals(0, GunSpec.EMPTY.getPartCount()); + } + + /** Two of a part are worth twice one of it. Nothing else in the contract is as load-bearing. */ + @Test + public void partsAddUp() { + GunSpec one = new GunSpec.Builder().addMuzzleSpeed(0.9D).addImpactEnergy(8).countPart().build(); + GunSpec two = new GunSpec.Builder().addMuzzleSpeed(0.9D).addImpactEnergy(8).countPart() + .addMuzzleSpeed(0.9D).addImpactEnergy(8).countPart().build(); + + assertEquals(one.getMuzzleSpeed() * 2.0D, two.getMuzzleSpeed(), EPSILON); + assertEquals(one.getImpactEnergy() * 2, two.getImpactEnergy()); + assertEquals(2, two.getPartCount()); + } + + /** A build with a barrel and a round worth firing is a gun. */ + @Test + public void aBarrelAndAChargeMakeAnOperableGun() { + GunSpec gun = new GunSpec.Builder().addMuzzleSpeed(0.9D).addImpactEnergy(8).countPart().build(); + assertTrue("a barrel section should make an operable gun", gun.isOperable()); + } + + /** Speed with nothing behind it is not a gun: a round worth zero is not a round. */ + @Test + public void speedWithoutAChargeIsNotAGun() { + GunSpec noCharge = new GunSpec.Builder().addMuzzleSpeed(2.0D).countPart().build(); + assertFalse("a gun with no impact energy must not be operable", noCharge.isOperable()); + } + + /** More barrel makes a gun truer, but never better than true. */ + @Test + public void spreadTightensTowardsZeroAndStopsThere() { + GunSpec.Builder builder = new GunSpec.Builder(); + for (int part = 0; part < 100; part++) { + builder.addSpreadDegrees(-0.8D).countPart(); + } + assertEquals("spread must floor at a true barrel", 0.0D, builder.build().getSpreadDegrees(), EPSILON); + } + + /** However much feed is stacked, a gun cannot fire twice in one tick. */ + @Test + public void theFireIntervalFloorsAtOneTick() { + GunSpec.Builder builder = new GunSpec.Builder(); + for (int part = 0; part < 50; part++) { + builder.speedUpFireIntervalBy(3).countPart(); + } + assertEquals(1, builder.build().getFireIntervalTicks()); + } + + /** A part cannot contribute a negative, whatever it passes in. */ + @Test + public void negativeContributionsAreRefusedRatherThanSubtracted() { + GunSpec spec = new GunSpec.Builder() + .addMuzzleSpeed(1.0D).addMuzzleSpeed(-5.0D) + .addImpactEnergy(10).addImpactEnergy(-100) + .countPart().build(); + + assertEquals(1.0D, spec.getMuzzleSpeed(), EPSILON); + assertEquals(10, spec.getImpactEnergy()); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TurretMechanismTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretMechanismTest.java new file mode 100644 index 000000000..5c82946ad --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretMechanismTest.java @@ -0,0 +1,170 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.api.weapon.TurretDriveState; +import zmaster587.advancedRocketry.weapon.TurretMechanism; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * What a commanded mount promises, independent of anything that shoots. + * + *

    Three promises, and each test here fails only if one of them is broken: a declared rate is + * never exceeded; a command the mount cannot reach is visibly saturated rather than quietly + * clamped; and a drive that stops working leaves the barrel at a bearing the aim path can still read + * as the truth. Nothing below asserts a step count, an internal field or how the angles are + * interpolated — a rewrite that keeps those three promises keeps these tests green.

    + */ +public class TurretMechanismTest { + + private static final double EPSILON = 1.0E-6D; + + /** The declared rate is a hard ceiling: one tick may not move the mount further than it. */ + @Test + public void aTickNeverTurnsFurtherThanTheDeclaredRate() { + TurretMechanism mount = TurretMechanism.standard(); + mount.commandBearing(170.0D, 0.0D); + + double previous = mount.getYaw(); + for (int tick = 0; tick < 20; tick++) { + mount.tick(3.0D); + double moved = Math.abs(wrap(mount.getYaw() - previous)); + assertTrue("turned " + moved + " degrees in one tick against a declared 3", moved <= 3.0D + EPSILON); + previous = mount.getYaw(); + } + } + + /** Given enough ticks it gets there, and says so. */ + @Test + public void aReachableCommandIsEventuallyMet() { + TurretMechanism mount = TurretMechanism.standard(); + mount.commandBearing(90.0D, -10.0D); + + boolean onTarget = false; + for (int tick = 0; tick < 200 && !onTarget; tick++) { + onTarget = mount.tick(2.0D); + } + assertTrue("a reachable bearing was never reached", onTarget); + assertFalse("a reachable bearing must not report saturation", mount.isSaturated()); + } + + /** + * A target below the arc is not silently turned into the lowest legal bearing and called a hit: + * the mount reports saturation for as long as the command is out of reach. + */ + @Test + public void anUnreachableCommandSaturatesInsteadOfClampingSilently() { + TurretMechanism mount = new TurretMechanism(-90.0D, 20.0D); + mount.commandBearing(0.0D, 80.0D); + + for (int tick = 0; tick < 200; tick++) { + mount.tick(5.0D); + } + assertTrue("an out-of-arc command must be visibly saturated", mount.isSaturated()); + assertEquals("the mount should sit at the edge of its arc", 20.0D, mount.getPitch(), EPSILON); + assertFalse("a saturated mount is not on target", mount.isOnTarget()); + } + + /** A seized drive keeps its bearing and keeps its gun: it aims where it stopped, and may fire. */ + @Test + public void aJammedDriveHoldsItsBearingAndStillFires() { + TurretMechanism mount = TurretMechanism.standard(); + mount.commandBearing(45.0D, 0.0D); + for (int tick = 0; tick < 100; tick++) { + mount.tick(2.0D); + } + double seizedYaw = mount.getYaw(); + Vec3d seizedAim = mount.getAimDirection(); + + mount.setDriveState(TurretDriveState.JAMMED); + mount.commandBearing(-135.0D, 0.0D); + for (int tick = 0; tick < 100; tick++) { + mount.tick(2.0D); + } + + assertEquals("a jammed mount moved", seizedYaw, mount.getYaw(), EPSILON); + assertEquals("a jammed mount's aim is still readable", seizedAim.x, mount.getAimDirection().x, EPSILON); + assertTrue("a jammed gun may still fire down its stuck bearing", + mount.getDriveState().permitsFiring()); + } + + /** A dead drive is the one state that stops the shooting as well as the turning. */ + @Test + public void aDeadDriveNeitherTurnsNorFires() { + TurretMechanism mount = TurretMechanism.standard(); + mount.setDriveState(TurretDriveState.DEAD); + mount.commandBearing(120.0D, 0.0D); + for (int tick = 0; tick < 50; tick++) { + mount.tick(5.0D); + } + assertEquals("a dead mount turned", 0.0D, mount.getYaw(), EPSILON); + assertFalse("a dead gun must not fire", mount.getDriveState().permitsFiring()); + } + + /** A derated drive is slower than a working one, and still arrives. */ + @Test + public void aDeratedDriveIsSlowerThanAWorkingOne() { + TurretMechanism working = TurretMechanism.standard(); + TurretMechanism derated = TurretMechanism.standard(); + derated.setDriveState(TurretDriveState.DERATED); + working.commandBearing(90.0D, 0.0D); + derated.commandBearing(90.0D, 0.0D); + + working.tick(4.0D); + derated.tick(4.0D); + + assertTrue("a derated drive turned at least as fast as a working one", + Math.abs(derated.getYaw()) < Math.abs(working.getYaw())); + } + + /** A bearing survives a save: a gun reloaded is a gun still pointing where it was left. */ + @Test + public void theBearingSurvivesARoundTrip() { + TurretMechanism mount = TurretMechanism.standard(); + mount.commandBearing(33.0D, -12.0D); + for (int tick = 0; tick < 100; tick++) { + mount.tick(2.0D); + } + mount.setDriveState(TurretDriveState.DERATED); + + NBTTagCompound nbt = new NBTTagCompound(); + mount.writeToNBT(nbt); + TurretMechanism restored = TurretMechanism.standard(); + restored.readFromNBT(nbt); + + assertEquals(mount.getYaw(), restored.getYaw(), EPSILON); + assertEquals(mount.getPitch(), restored.getPitch(), EPSILON); + assertEquals(mount.getDriveState(), restored.getDriveState()); + assertTrue("a restored mount forgot what it was told to do", restored.hasCommand()); + } + + /** Aiming at a direction and reading the aim back gives the same direction. */ + @Test + public void aCommandedDirectionIsTheDirectionItEndsUpPointing() { + TurretMechanism mount = TurretMechanism.standard(); + Vec3d wanted = new Vec3d(1.0D, 0.0D, 1.0D).normalize(); + mount.commandDirection(wanted); + for (int tick = 0; tick < 400; tick++) { + mount.tick(2.0D); + } + Vec3d aim = mount.getAimDirection(); + assertEquals(wanted.x, aim.x, 1.0E-3D); + assertEquals(wanted.y, aim.y, 1.0E-3D); + assertEquals(wanted.z, aim.z, 1.0E-3D); + } + + private static double wrap(double degrees) { + double wrapped = degrees % 360.0D; + if (wrapped <= -180.0D) { + wrapped += 360.0D; + } + if (wrapped > 180.0D) { + wrapped -= 360.0D; + } + return wrapped; + } +} diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 1b2e86f5e..d805665e1 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -291,6 +291,21 @@ public JsonObject readStaticField(String className, String fieldName) throws IOE return assertOk(execute(command)); } + /** + * What the CLIENT's tile entity at these coordinates holds, as its own update tag. + * + *

    The honest observable for "did a server-side change reach the client": a renderer's output + * cannot be read from a test, but the state it draws from can. Answers {@code present}, + * {@code tile} (class name) and {@code nbt} (the update tag, stringified).

    + */ + public JsonObject tileNbt(int x, int y, int z) throws IOException { + JsonObject command = command("tile_nbt"); + command.addProperty("x", x); + command.addProperty("y", y); + command.addProperty("z", z); + return assertOk(execute(command)); + } + /** * Call a static {@code void}/value method with {@code int} parameters on the CLIENT thread. * diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index a4b90cafe..c1110645d 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -1114,6 +1114,26 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "tile_nbt": + // What the CLIENT's own tile entity holds, as the tile itself describes it. + // block_state answers which class is there; this answers what that instance believes, + // which is the only honest way to ask whether a server-side change reached the client + // — a renderer's output is not readable from here, but the state it draws from is. + // Reads getUpdateTag() rather than writeToNBT(): the update tag is exactly the subset + // a tile chooses to replicate, so a test that reads it is asking about the wire and + // not about the tile's private bookkeeping. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + BlockPos pos = new BlockPos(requireInt(request, "x"), requireInt(request, "y"), + requireInt(request, "z")); + JsonObject response = ok(); + net.minecraft.tileentity.TileEntity tile = + mc.world == null ? null : mc.world.getTileEntity(pos); + response.addProperty("present", tile != null); + response.addProperty("tile", tile == null ? "" : tile.getClass().getName()); + response.addProperty("nbt", tile == null ? "" : tile.getUpdateTag().toString()); + return response; + }); case "invoke_static_int": // Drive a mod's own CLIENT-side input entry point on the client thread. The sibling of // set_key: that one writes KeyBinding state rather than feeding the LWJGL key queue, From 965d8fcb0f6098b47cd78767c4cdc343c20e6503 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 10:49:38 +0300 Subject: [PATCH 09/35] feat: one console points a whole battery, and owns nothing - add the weapons console as a stateless network editor - assign a target by linker, hold fire without losing it - clear the battery's target when its last console goes - add the weaponconsole probe verb and its e2e --- .../advancedRocketry/AdvancedRocketry.java | 6 + .../api/AdvancedRocketryBlocks.java | 2 + .../command/test/TestProbeCommand.java | 70 +++++ .../tile/weapon/TileWeaponConsole.java | 283 ++++++++++++++++++ .../blockstates/weaponconsole.json | 47 +++ .../assets/advancedrocketry/lang/en_US.lang | 3 + .../recipes/weaponconsole.json | 26 ++ .../test/server/WeaponConsoleE2ETest.java | 204 +++++++++++++ 8 files changed, 641 insertions(+) create mode 100644 src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/weaponconsole.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/weaponconsole.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/WeaponConsoleE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 986da7a8c..bedc5bc32 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -457,6 +457,8 @@ public void preInit(FMLPreInitializationEvent event) { 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")); if (zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().enableGravityController) GameRegistry.registerTileEntity(TileAreaGravityController.class, "ARGravityMachine"); @@ -745,6 +747,9 @@ public void registerBlocks(RegistryEvent.Register evt) { 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); 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 @@ -936,6 +941,7 @@ public void registerBlocks(RegistryEvent.Register evt) { 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.blockGunBarrel.setRegistryName("gunBarrel")); LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunAmmoFeed.setRegistryName("gunAmmoFeed")); LibVulpesBlocks.registerBlock(AdvancedRocketryBlocks.blockGunCooling.setRegistryName("gunCooling")); diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java index c162ba824..45178d782 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java +++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java @@ -48,6 +48,8 @@ public class AdvancedRocketryBlocks { public static Block blockGunBarrel; public static Block blockGunAmmoFeed; public static Block blockGunCooling; + /** The one thing the weapons network adds: a place to point every gun at once. */ + public static Block blockWeaponConsole; /** 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index c2e3e35b7..f5519c2b2 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -247,6 +247,9 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args case "turret": handleTurret(server, sender, tail(args)); break; + case "weaponconsole": + handleWeaponConsole(server, sender, tail(args)); + break; case "sound": handleSound(server, sender, tail(args)); break; @@ -442,6 +445,73 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ 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 ("read".equals(sub)) { + net.minecraft.util.math.Vec3d target = console.getTarget(); + send(sender, "{\"ok\":true" + + ",\"network\":" + (console.network() != null) + + ",\"status\":\"" + escapeJson(console.getNetworkStatusText()) + "\"" + + ",\"guns\":" + console.getGunCount() + + ",\"holdFire\":" + console.isHoldFire() + + ",\"hasTarget\":" + (target != null) + + (target == null ? "" : ",\"targetX\":" + target.x + ",\"targetY\":" + target.y + + ",\"targetZ\":" + target.z) + + "}"); + return; + } + send(sender, "{\"error\":\"unknown weaponconsole subcommand\",\"sub\":\"" + escapeJson(sub) + "\"}"); + } + private static String shotJson(zmaster587.advancedRocketry.projectile.Shot shot) { return "{\"id\":" + shot.getId() + ",\"x\":" + shot.getPosition().x diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java new file mode 100644 index 000000000..a43324616 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java @@ -0,0 +1,283 @@ +package zmaster587.advancedRocketry.tile.weapon; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.ITickable; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; +import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkController; +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 zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkStatus; +import zmaster587.advancedRocketry.weapon.TurretFireControl; +import zmaster587.advancedRocketry.weapon.WeaponNetworkDomain; +import zmaster587.advancedRocketry.weapon.WeaponNetworkState; +import zmaster587.libVulpes.LibVulpes; +import zmaster587.libVulpes.inventory.modules.IButtonInventory; +import zmaster587.libVulpes.inventory.modules.IModularInventory; +import zmaster587.libVulpes.inventory.modules.ModuleBase; +import zmaster587.libVulpes.inventory.modules.ModuleButton; +import zmaster587.libVulpes.inventory.modules.ModuleText; +import zmaster587.libVulpes.interfaces.ILinkableTile; +import zmaster587.libVulpes.inventory.TextureResources; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * One place to point a battery, and the only thing the weapons network adds that a gun cannot do + * alone. + * + *

    It owns nothing

    + *

    A console is a stateless editor of the network's own state: it holds no target, no hold-fire + * flag and no copy of anything. Two consoles on one network therefore cannot disagree — they are + * both looking at the same object — and breaking one loses nothing but the window onto it. That is + * why every button below writes to {@link WeaponNetworkState} and every readout reads from it.

    + * + *

    What it is FOR

    + *

    Convenience, not capability. Every gun on the network already aims and fires by itself; what a + * console buys is doing it to a dozen guns at once, and being able to say "track but do not shoot" + * without walking to each of them. A network with no console is a working battery whose guns are + * commanded individually — which is exactly what the guns' own tests pin.

    + */ +public class TileWeaponConsole extends TileEntity implements ITickable, ISubsystemNetworkController, + ILinkableTile, IModularInventory, IButtonInventory { + + private static final int BUTTON_HOLD_FIRE = 0; + private static final int BUTTON_CLEAR_TARGET = 1; + + private boolean registered; + + /** Client-side text, rebuilt each time the GUI is opened. */ + private final List readouts = new ArrayList<>(); + + @Override + public void update() { + if (world == null || world.isRemote) { + return; + } + if (VSIntegration.isOnUnnamedShip(world, pos)) { + // Same rule as a gun's: aboard a ship nobody has named, this console's own position is a + // shipyard address, so it must not join a network or command anything. + return; + } + if (!registered) { + SubsystemNetworkRegistry.register(this); + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + registered = true; + } + } + + // ---- network membership + + @Override + public SubsystemNetworkDomain getNetworkDomain() { + return WeaponNetworkDomain.INSTANCE; + } + + @Override + public World getNodeWorld() { + return world; + } + + @Override + public BlockPos getNodePos() { + return pos; + } + + /** + * The network hands its state over after every rebuild. Nothing is copied out of it: a console + * that cached the target would be a second source of truth, and the two would disagree the first + * time somebody used the other console. + */ + @Override + public void applyNetworkState(SubsystemNetworkState state) { + } + + /** The network this console is on, or null when it stands alone. */ + public WeaponNetworkState network() { + SubsystemNetworkState state = SubsystemNetworkManager.getState(WeaponNetworkDomain.INSTANCE, + world, pos); + return state instanceof WeaponNetworkState ? (WeaponNetworkState) state : null; + } + + // ---- the commands a console exists to give + + /** Point every gun on this network at a world point. */ + public boolean assignTarget(Vec3d target) { + WeaponNetworkState state = network(); + if (state == null) { + return false; + } + state.setTarget(target); + return true; + } + + public boolean clearTarget() { + WeaponNetworkState state = network(); + if (state == null) { + return false; + } + state.clearTarget(); + return true; + } + + /** + * Track but do not shoot. Deliberately a separate switch from having a target: a battery + * watching an approaching ship without firing on it is the normal state of a defended station, + * and clearing the target to stop the shooting would lose the tracking too. + */ + public boolean setHoldFire(boolean hold) { + WeaponNetworkState state = network(); + if (state == null) { + return false; + } + state.setHoldFire(hold); + return true; + } + + public boolean isHoldFire() { + WeaponNetworkState state = network(); + return state != null && state.isHoldFire(); + } + + public Vec3d getTarget() { + WeaponNetworkState state = network(); + return state == null ? null : state.getTarget(); + } + + /** How many guns this console is commanding, as the last solve counted them. */ + public int getGunCount() { + WeaponNetworkState state = network(); + return state == null ? 0 : state.getSinkCount(); + } + + public String getNetworkStatusText() { + WeaponNetworkState state = network(); + if (state == null) { + return "no network"; + } + switch (state.getStatus()) { + case SubsystemNetworkStatus.DISCONNECTED: + return "disconnected"; + case SubsystemNetworkStatus.SOURCE_LIMITED: + return "power limited"; + case SubsystemNetworkStatus.SINK_LIMITED: + return "idle"; + case SubsystemNetworkStatus.CABLE_LIMITED: + return "cable limited"; + case SubsystemNetworkStatus.BALANCED: + return "balanced"; + default: + return "unknown"; + } + } + + // ---- linker: the way a player names a target without typing coordinates + + @Override + public boolean onLinkStart(@Nonnull ItemStack item, TileEntity entity, EntityPlayer player, World world) { + return true; + } + + @Override + public boolean onLinkComplete(@Nonnull ItemStack item, TileEntity entity, EntityPlayer player, World world) { + return entity != null && assignTarget(TurretFireControl.center(entity.getPos())); + } + + // ---- GUI + + @Override + public List getModules(int id, EntityPlayer player) { + List modules = new ArrayList<>(); + readouts.clear(); + + modules.add(new ModuleButton(10, 20, BUTTON_HOLD_FIRE, + LibVulpes.proxy.getLocalizedString("msg.weaponConsole.holdFire"), this, + TextureResources.buttonBuild, 80, 18)); + modules.add(new ModuleButton(10, 42, BUTTON_CLEAR_TARGET, + LibVulpes.proxy.getLocalizedString("msg.weaponConsole.clearTarget"), this, + TextureResources.buttonBuild, 80, 18)); + + addReadout(modules, 10, 68, statusLine()); + addReadout(modules, 10, 80, gunLine()); + addReadout(modules, 10, 92, targetLine()); + return modules; + } + + private void addReadout(List modules, int x, int y, String text) { + ModuleText module = new ModuleText(x, y, text, 0x2b2b2b); + readouts.add(module); + modules.add(module); + } + + private String statusLine() { + return "Network: " + getNetworkStatusText() + (isHoldFire() ? " (holding fire)" : ""); + } + + private String gunLine() { + return "Guns: " + getGunCount(); + } + + private String targetLine() { + Vec3d target = getTarget(); + return target == null ? "Target: none" + : String.format("Target: %.0f, %.0f, %.0f", target.x, target.y, target.z); + } + + @Override + public void onInventoryButtonPressed(int buttonId) { + if (buttonId == BUTTON_HOLD_FIRE) { + setHoldFire(!isHoldFire()); + } else if (buttonId == BUTTON_CLEAR_TARGET) { + clearTarget(); + } + } + + @Override + public String getModularInventoryName() { + return AdvancedRocketryBlocks.blockWeaponConsole.getLocalizedName(); + } + + @Override + public boolean canInteractWithContainer(EntityPlayer entity) { + return true; + } + + // ---- lifecycle + + @Override + public void invalidate() { + super.invalidate(); + SubsystemNetworkRegistry.unregister(this); + if (world != null && !world.isRemote) { + // The domain clears the target when a component loses its last console: a battery left + // firing at a point nobody can retract is the one failure a player cannot fix by + // breaking something. + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + } + registered = false; + } + + @Override + public void onChunkUnload() { + super.onChunkUnload(); + SubsystemNetworkRegistry.unregister(this); + registered = false; + } + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound nbt) { + // Nothing of its own to save: the network owns the target and the hold-fire switch, and a + // console that persisted a copy would come back disagreeing with them. + return super.writeToNBT(nbt); + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/weaponconsole.json b/src/main/resources/assets/advancedrocketry/blockstates/weaponconsole.json new file mode 100644 index 000000000..485c932be --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/weaponconsole.json @@ -0,0 +1,47 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:orientable", + "textures": { + "top": "libvulpes:blocks/machinegeneric", + "front": "advancedrocketry:blocks/monitorfront", + "side": "libvulpes:blocks/machinegeneric" + } + }, + "variants": { + "facing=north,state=false": [ + {} + ], + "facing=south,state=false": { + "model": "minecraft:orientable", + "y": 180 + }, + "facing=west,state=false": { + "model": "minecraft:orientable", + "y": 270 + }, + "facing=east,state=false": { + "model": "minecraft:orientable", + "y": 90 + }, + "facing=north,state=true": [ + {} + ], + "facing=south,state=true": { + "model": "minecraft:orientable", + "y": 180 + }, + "facing=west,state=true": { + "model": "minecraft:orientable", + "y": 270 + }, + "facing=east,state=true": { + "model": "minecraft:orientable", + "y": 90 + }, + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 273eef9cd..b370d6e57 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -70,6 +70,9 @@ tile.turret.name=Turret Mount tile.gunBarrel.name=Gun Barrel Section tile.gunAmmoFeed.name=Gun Ammunition Feed tile.gunCooling.name=Gun Cooling Jacket +tile.weaponConsole.name=Weapons Console +msg.weaponConsole.holdFire=Hold Fire +msg.weaponConsole.clearTarget=Clear Target tile.advancedFlightComputer.name=Advanced Flight Computer tile.navigationComputer.name=Navigation Computer tile.electricArcFurnace.name=Electric Arc Furnace diff --git a/src/main/resources/assets/advancedrocketry/recipes/weaponconsole.json b/src/main/resources/assets/advancedrocketry/recipes/weaponconsole.json new file mode 100644 index 000000000..eeb6b7f5e --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/weaponconsole.json @@ -0,0 +1,26 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "ici", + "iri", + "iii" + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "c": { + "type": "forge:ore_dict", + "ore": "circuitBasic" + }, + "r": { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": { + "item": "advancedrocketry:weaponConsole", + "count": 1 + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeaponConsoleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponConsoleE2ETest.java new file mode 100644 index 000000000..1a79b59bc --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponConsoleE2ETest.java @@ -0,0 +1,204 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * What a console buys, and what it must never buy. + * + *

    It buys CONVENIENCE: one target for a whole battery, and a way to say "track but do not shoot" + * without walking to each gun. It must never buy CAPABILITY — every gun here fires perfectly well + * alone, which is what {@link TurretStandaloneE2ETest} pins, so nothing in this class may be the + * reason a gun works.

    + * + *

    The last test is the one that would be easy to leave out: a console that is destroyed must not + * leave its battery firing at a point nobody can retract.

    + */ +public class WeaponConsoleE2ETest extends AbstractSharedServerTest { + + /** This class's own site. */ + private static final int X = 9800, Y = 80, Z = 9800; + + private static final long TIMEOUT_MS = 20_000L; + + /** + * A console points two guns at once, and the guns were not commanded individually. + * + *

    The layout is a chain over block adjacency — gun, console, gun — because that is what makes + * one network out of three nodes. No cable is involved: two touching nodes are one network, and + * a cable is a reach tool rather than a requirement.

    + */ + @Test + public void aConsolePointsEveryGunOnItsNetwork() throws Exception { + int base = X; + buildSite(base); + buildGun(base); + place("advancedrocketry:weaponConsole", base + 1, Y, Z); + buildGun(base + 2); + awaitOperable(base); + awaitOperable(base + 2); + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + exec("artest turret charge 0 " + (base + 2) + " " + Y + " " + Z); + + String seen = awaitGuns(base + 1, 2); + assertEquals("the console is not commanding both guns — the three blocks did not form one" + + " network: " + seen, 2, extractInt(seen, "guns")); + + String applied = exec("artest weaponconsole target 0 " + (base + 1) + " " + Y + " " + Z + " " + + (base + 40.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + assertTrue("the console refused the target: " + applied, applied.contains("\"applied\":true")); + + assertTrue("the first gun never fired on the console's target", + awaitShots(base, 1) >= 1); + assertTrue("the second gun never fired on the console's target: one console must point the" + + " whole battery, not the nearest gun", awaitShots(base + 2, 1) >= 1); + } + + /** Hold fire stops the shooting without losing the target. */ + @Test + public void holdFireStopsTheShootingAndKeepsTheTarget() throws Exception { + int base = X + 100; + buildSite(base); + buildGun(base); + place("advancedrocketry:weaponConsole", base + 1, Y, Z); + awaitOperable(base); + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + awaitGuns(base + 1, 1); + + exec("artest weaponconsole target 0 " + (base + 1) + " " + Y + " " + Z + " " + + (base + 40.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + assertTrue("the gun never fired before hold-fire, so the test would prove nothing", + awaitShots(base, 1) >= 1); + + exec("artest weaponconsole holdfire 0 " + (base + 1) + " " + Y + " " + Z + " true"); + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + int before = shotsOf(base); + Thread.sleep(4_000L); + int after = shotsOf(base); + assertEquals("the battery kept firing while holding fire: " + before + " -> " + after, + before, after); + + String state = exec("artest weaponconsole read 0 " + (base + 1) + " " + Y + " " + Z); + assertTrue("holding fire lost the target: tracking and shooting are separate decisions, so a" + + " battery watching an approaching ship must not have to forget it to stop" + + " shooting: " + state, state.contains("\"hasTarget\":true")); + + // And releasing it resumes, which is what says the hold was the reason. + exec("artest weaponconsole holdfire 0 " + (base + 1) + " " + Y + " " + Z + " false"); + assertTrue("the battery did not resume when hold-fire was released", + awaitShots(base, after + 1) > after); + } + + /** + * Breaking the last console clears the target rather than leaving the battery firing at a point + * nobody can retract — the one failure a player cannot fix by breaking something. + */ + @Test + public void losingTheLastConsoleClearsTheTarget() throws Exception { + int base = X + 200; + buildSite(base); + buildGun(base); + place("advancedrocketry:weaponConsole", base + 1, Y, Z); + awaitOperable(base); + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + awaitGuns(base + 1, 1); + + exec("artest weaponconsole target 0 " + (base + 1) + " " + Y + " " + Z + " " + + (base + 40.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + assertTrue("the gun never fired on the console's target, so its removal proves nothing", + awaitShots(base, 1) >= 1); + + assertTrue("could not remove the console", exec("artest fill 0 " + (base + 1) + " " + Y + " " + + Z + " " + (base + 1) + " " + Y + " " + Z + " minecraft:air").contains("\"ok\":true")); + // The rebuild that notices the console is gone happens on the network's own tick. + Thread.sleep(3_000L); + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + int before = shotsOf(base); + Thread.sleep(4_000L); + int after = shotsOf(base); + + assertEquals("the battery is still firing at the target of a console that no longer exists: " + + before + " -> " + after, before, after); + String gun = exec("artest turret read 0 " + base + " " + Y + " " + Z); + assertTrue("the gun still holds a target it cannot be told to drop: " + gun, + gun.contains("\"hasTarget\":false")); + } + + // ---- scenario construction + + private void buildGun(int bx) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + private void buildSite(int bx) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((bx - 16) >> 4) + " " + + ((Z - 16) >> 4) + " " + ((bx + 64) >> 4) + " " + ((Z + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill 0 " + (bx - 4) + " " + (Y - 2) + " " + + (Z - 4) + " " + (bx + 60) + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air") + .contains("\"ok\":true")); + assertTrue("could not hold the chunk", exec("artest chunk forceload 0 " + (bx >> 4) + " " + + (Z >> 4)).contains("\"ok\":true")); + } + + private String awaitOperable(int bx) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = exec("artest turret read 0 " + bx + " " + Y + " " + Z); + while (System.currentTimeMillis() < deadline && !state.contains("\"operable\":true")) { + Thread.sleep(250L); + state = exec("artest turret read 0 " + bx + " " + Y + " " + Z); + } + assertTrue("a gun at " + bx + " never assembled: " + state, state.contains("\"operable\":true")); + return state; + } + + /** Wait until the console reports it is commanding at least this many guns. */ + private String awaitGuns(int consoleX, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = exec("artest weaponconsole read 0 " + consoleX + " " + Y + " " + Z); + while (System.currentTimeMillis() < deadline && extractInt(state, "guns") < wanted) { + Thread.sleep(250L); + state = exec("artest weaponconsole read 0 " + consoleX + " " + Y + " " + Z); + } + return state; + } + + private int awaitShots(int bx, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + int shots = shotsOf(bx); + while (System.currentTimeMillis() < deadline && shots < wanted) { + Thread.sleep(250L); + shots = shotsOf(bx); + } + return shots; + } + + private int shotsOf(int bx) throws Exception { + return extractInt(exec("artest turret read 0 " + bx + " " + Y + " " + Z), "shots"); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + y + "," + z + ": " + resp, + resp.contains("\"placed\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} From f59cce94d9ed57b3594746be5c96fa25983afe27 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 11:30:58 +0300 Subject: [PATCH 10/35] feat: a gun that tracks a target and knows whose side it is on - follow an entity, not only a point, and lose it when it dies - spare a target carrying the installation's access code - declare what a build needs delivered, gas included and reserved - add manual control as a mode through the same launch path - read mount telemetry off the guns for the console readout - pin the ship-frame case: a gun in the shipyard fires into the world --- .../advancedRocketry/AdvancedRocketry.java | 3 +- .../advancedRocketry/api/weapon/GunInput.java | 26 ++ .../advancedRocketry/api/weapon/GunSpec.java | 23 ++ .../command/test/TestProbeCommand.java | 61 +++++ .../tile/weapon/TileTurret.java | 159 +++++++++++- .../tile/weapon/TileWeaponConsole.java | 74 +++++- .../weapon/WeaponNetworkState.java | 32 +++ .../test/client/TurretFriendOrFoeE2ETest.java | 149 ++++++++++++ .../test/server/TurretOnAShipE2ETest.java | 228 ++++++++++++++++++ .../test/server/TurretStandaloneE2ETest.java | 49 ++++ 10 files changed, 793 insertions(+), 11 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/weapon/GunInput.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/TurretFriendOrFoeE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/TurretOnAShipE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index bedc5bc32..5d01dd0e8 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -742,7 +742,8 @@ public void registerBlocks(RegistryEvent.Register evt) { .setUnlocalizedName("gunBarrel").setCreativeTab(tabAdvRocketry); AdvancedRocketryBlocks.blockGunAmmoFeed = new zmaster587.advancedRocketry.block.weapon.BlockGunPart( builder -> builder.speedUpFireIntervalBy(3).addImpactEnergy(6).addEnergyPerShot(75) - .addHeatPerShot(2)) + .addHeatPerShot(2) + .declareInput(zmaster587.advancedRocketry.api.weapon.GunInput.FORGE_ENERGY)) .setUnlocalizedName("gunAmmoFeed").setCreativeTab(tabAdvRocketry); AdvancedRocketryBlocks.blockGunCooling = new zmaster587.advancedRocketry.block.weapon.BlockGunPart( builder -> builder.addHeatCapacity(40).addCoolingPerTick(2).addTraverseDegreesPerTick(0.5D)) 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 index 7516a5ec8..7aa27f550 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java @@ -40,6 +40,7 @@ public final class GunSpec { private final double projectileMass; private final ImpactKind kind; private final int partCount; + private final java.util.EnumSet inputs; private GunSpec(Builder builder) { this.muzzleSpeed = builder.muzzleSpeed; @@ -56,6 +57,8 @@ private GunSpec(Builder builder) { 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); } /** @@ -127,6 +130,14 @@ 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; @@ -157,6 +168,7 @@ public static final class Builder { private double projectileMass = 1.0D; private ImpactKind kind = ImpactKind.KINETIC; private int partCount; + private final java.util.EnumSet inputs = java.util.EnumSet.noneOf(GunInput.class); public Builder addMuzzleSpeed(double blocksPerTick) { this.muzzleSpeed += Math.max(0.0D, blocksPerTick); @@ -227,6 +239,17 @@ public Builder setKind(ImpactKind 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++; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index f5519c2b2..0c5b497c4 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -399,6 +399,7 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ } if ("cleartarget".equals(sub)) { turret.setTarget(null); + turret.setTargetEntity(null); send(sender, "{\"ok\":true}"); return; } @@ -413,6 +414,36 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ 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(); @@ -436,6 +467,10 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ + ",\"drive\":\"" + mount.getDriveState().name() + "\"" + ",\"shots\":" + turret.getShotsFired() + ",\"lastShot\":" + turret.getLastShotId() + + ",\"trackingEntity\":" + (turret.getTargetEntity() != null) + + ",\"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) @@ -496,13 +531,34 @@ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, + 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.getNetworkStatusText()) + "\"" + ",\"guns\":" + console.getGunCount() + + ",\"onTarget\":" + console.getMountTelemetry()[0] + + ",\"saturated\":" + console.getMountTelemetry()[1] + ",\"holdFire\":" + console.isHoldFire() + + ",\"code\":\"" + escapeJson(console.getAccessCode()) + "\"" + + ",\"trackingEntity\":" + (console.getTargetEntity() != null) + ",\"hasTarget\":" + (target != null) + (target == null ? "" : ",\"targetX\":" + target.x + ",\"targetY\":" + target.y + ",\"targetZ\":" + target.z) @@ -512,6 +568,11 @@ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, send(sender, "{\"error\":\"unknown weaponconsole 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) + "\"}"; + } + private static String shotJson(zmaster587.advancedRocketry.projectile.Shot shot) { return "{\"id\":" + shot.getId() + ",\"x\":" + shot.getPosition().x diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index cd6bda16a..40e3438a6 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.tile.weapon; +import net.minecraft.entity.Entity; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.item.ItemStack; import net.minecraft.block.state.IBlockState; @@ -12,6 +13,7 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; +import net.minecraft.world.WorldServer; import net.minecraftforge.common.capabilities.Capability; import net.minecraftforge.energy.CapabilityEnergy; import net.minecraftforge.energy.EnergyStorage; @@ -70,11 +72,14 @@ public class TileTurret extends TileEntity implements ITickable, ISubsystemSink, private GunSpec spec = GunSpec.EMPTY; private int assemblyReach; private boolean assemblyDirty = true; + private boolean manualControl; private int fireCooldown; private int heat; private boolean registered; private Vec3d localTarget; + private UUID localTargetEntity; + private String accessCode = ""; private String faction; private UUID owner; @@ -132,6 +137,14 @@ public void update() { fireCooldown--; } + if (manualControl) { + // Under a hand: the mount obeys the bearing it was given and nothing chooses a target + // for it. Firing is a separate, deliberate act — see fireOnce. + mechanism.tick(spec.getTraverseDegreesPerTick()); + syncCommandIfChanged(); + return; + } + Vec3d target = getEffectiveTarget(); if (target == null) { mechanism.clearCommand(); @@ -151,21 +164,75 @@ public void update() { return; } + launch(shipId); + } + + /** + * Send one round down the current bearing and answer whether it left, spending nothing unless it + * did. Extracted so the automatic path and the manual one cannot drift apart: a manned gun that + * skipped the heat or the cooldown would be strictly better than the same gun on a console, + * which is a balance decision nobody made. + */ + private boolean launch(String shipId) { + String stamped = faction != null ? faction : getEffectiveAccessCode(); long id = TurretFireControl.fire(world, pos, shipId, mechanism.getAimDirection(), spec, - assemblyReach, owner, faction, random); - if (id >= 0L) { - lastShotId = id; - shotsFired++; - fireCooldown = spec.getFireIntervalTicks(); - heat += spec.getHeatPerShot(); - energy.extractEnergy(spec.getEnergyPerShot(), false); - markDirty(); + assemblyReach, owner, stamped, random); + if (id < 0L) { + return false; + } + lastShotId = id; + shotsFired++; + fireCooldown = spec.getFireIntervalTicks(); + heat += spec.getHeatPerShot(); + energy.extractEnergy(spec.getEnergyPerShot(), false); + markDirty(); + return true; + } + + // ---- the manual seam: present at the API, driven by nothing that ships today + + /** + * Take the gun out of automatic control, or give it back. + * + *

    The seat, the first-person view and the trigger are a later wave; what is here is the part + * that has to exist for them not to be a rewrite — a mode in which nothing assigns a target, the + * mount obeys a bearing it is handed, and firing is an explicit act. A gun left in manual with + * nobody driving it simply holds still, which is the correct behaviour for an abandoned seat.

    + */ + public void setManualControl(boolean manual) { + this.manualControl = manual; + if (manual) { + mechanism.clearCommand(); + } + markDirty(); + } + + public boolean isManuallyControlled() { + return manualControl; + } + + /** Point the mount by hand. Ignored unless the gun is in manual control. */ + public void commandManualBearing(double yaw, double pitch) { + if (manualControl) { + mechanism.commandBearing(yaw, pitch); + } + } + + /** + * Pull the trigger once. Answers whether a round left — the same conditions the automatic path + * checks apply, including friend-or-foe, heat, charge and the line of fire. + */ + public boolean fireOnce() { + if (world == null || world.isRemote || !manualControl || !canFireNow()) { + return false; } + return launch(TurretFireControl.shipIdAt(world, pos)); } /** Everything that must be true before a round leaves, other than pointing the right way. */ private boolean canFireNow() { - return spec.isOperable() + return !targetIsFriendly() + && spec.isOperable() && mechanism.getDriveState().permitsFiring() && fireCooldown <= 0 && heat + spec.getHeatPerShot() <= spec.getHeatCapacity() @@ -178,6 +245,12 @@ private boolean canFireNow() { * battery" means — and its silence is not an instruction to stop. */ public Vec3d getEffectiveTarget() { + Entity tracked = trackedEntity(); + if (tracked != null) { + // Aim at the middle of the body rather than its feet: a round at foot height passes + // under everything that is not standing on flat ground. + return tracked.getPositionVector().addVector(0.0D, tracked.height * 0.5D, 0.0D); + } WeaponNetworkState state = networkState(); if (state != null && state.getTarget() != null) { return state.getTarget(); @@ -185,6 +258,50 @@ public Vec3d getEffectiveTarget() { return localTarget; } + /** + * The entity this gun is following, or null. The network's order wins over the gun's own, the + * same way a point target does; a target that has died or logged out simply stops being found, + * which leaves the gun holding its bearing rather than swinging to a remembered position. + */ + private Entity trackedEntity() { + UUID id = null; + WeaponNetworkState state = networkState(); + if (state != null && state.getTargetEntity() != null) { + id = state.getTargetEntity(); + } else if (localTargetEntity != null) { + id = localTargetEntity; + } + if (id == null || !(world instanceof WorldServer)) { + return null; + } + Entity entity = ((WorldServer) world).getEntityFromUuid(id); + return entity == null || entity.isDead ? null : entity; + } + + /** + * Whether the thing this gun is pointed at has proved it is on our side. + * + *

    The credential is carried by the TARGET, not held about it: an entity presenting the + * installation's access code is a friend for exactly as long as it carries it, and nothing here + * keeps a list of who is friendly. A gun with no code set recognises nobody — deliberately, since + * a battery that shoots nothing is indistinguishable from a broken one.

    + */ + private boolean targetIsFriendly() { + Entity tracked = trackedEntity(); + return tracked != null + && com.github.stannismod.affs.util.CodeUtils.entityHasMatchingCode(tracked, + getEffectiveAccessCode()); + } + + /** The network's code when it has one, otherwise this gun's own. */ + public String getEffectiveAccessCode() { + WeaponNetworkState state = networkState(); + if (state != null && !state.getAccessCode().isEmpty()) { + return state.getAccessCode(); + } + return accessCode; + } + private boolean isHoldingFire() { WeaponNetworkState state = networkState(); return state != null && state.isHoldFire(); @@ -231,6 +348,22 @@ public Vec3d getTarget() { return localTarget; } + /** Follow an entity. Cleared with null; a point target set later replaces it. */ + public void setTargetEntity(UUID entity) { + this.localTargetEntity = entity; + markDirty(); + } + + public UUID getTargetEntity() { + return localTargetEntity; + } + + /** This gun's own access code, used when it is on no network or the network has none. */ + public void setAccessCode(String code) { + this.accessCode = code == null ? "" : code; + markDirty(); + } + public GunSpec getSpec() { return spec; } @@ -467,6 +600,11 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { if (faction != null) { nbt.setString("faction", faction); } + if (localTargetEntity != null) { + nbt.setUniqueId("targetEntity", localTargetEntity); + } + nbt.setString("accessCode", accessCode); + nbt.setBoolean("manual", manualControl); if (owner != null) { nbt.setUniqueId("owner", owner); } @@ -488,6 +626,9 @@ public void readFromNBT(NBTTagCompound nbt) { ? new Vec3d(nbt.getDouble("targetX"), nbt.getDouble("targetY"), nbt.getDouble("targetZ")) : null; faction = nbt.hasKey("faction") ? nbt.getString("faction") : null; + localTargetEntity = nbt.hasUniqueId("targetEntity") ? nbt.getUniqueId("targetEntity") : null; + accessCode = nbt.getString("accessCode"); + manualControl = nbt.getBoolean("manual"); owner = nbt.hasUniqueId("owner") ? nbt.getUniqueId("owner") : null; } } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java index a43324616..63cd9ea0f 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java @@ -17,6 +17,7 @@ import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState; import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkStatus; import zmaster587.advancedRocketry.weapon.TurretFireControl; +import zmaster587.advancedRocketry.weapon.TurretMechanism; import zmaster587.advancedRocketry.weapon.WeaponNetworkDomain; import zmaster587.advancedRocketry.weapon.WeaponNetworkState; import zmaster587.libVulpes.LibVulpes; @@ -121,6 +122,40 @@ public boolean assignTarget(Vec3d target) { return true; } + /** Point every gun on this network at an entity, and keep pointing as it moves. */ + public boolean assignTargetEntity(java.util.UUID entity) { + WeaponNetworkState state = network(); + if (state == null) { + return false; + } + state.setTargetEntity(entity); + return true; + } + + /** + * The credential a target may present to be recognised as friendly. Set on the network rather + * than per gun, because "who is on our side" is a property of the installation, and a battery + * whose guns disagreed about it would shoot its own crew at random. + */ + public boolean setAccessCode(String code) { + WeaponNetworkState state = network(); + if (state == null) { + return false; + } + state.setAccessCode(code); + return true; + } + + public String getAccessCode() { + WeaponNetworkState state = network(); + return state == null ? "" : state.getAccessCode(); + } + + public java.util.UUID getTargetEntity() { + WeaponNetworkState state = network(); + return state == null ? null : state.getTargetEntity(); + } + public boolean clearTarget() { WeaponNetworkState state = network(); if (state == null) { @@ -160,6 +195,41 @@ public int getGunCount() { return state == null ? 0 : state.getSinkCount(); } + /** + * How many of this network's guns are pointing where they were told, and how many are asking for + * a bearing they cannot reach. + * + *

    Read off the member tiles rather than accumulated into the network state: the mounts already + * know, and a second copy updated on a different cadence would be a readout that disagrees with + * the guns it describes. A saturated count above zero is the console's answer to "why is nothing + * being hit" — the target is outside somebody's arc, which is a fact about the BUILD, not a + * fault.

    + * + * @return {@code [onTarget, saturated, total]} + */ + public int[] getMountTelemetry() { + WeaponNetworkState state = network(); + int onTarget = 0, saturated = 0, total = 0; + if (state == null || world == null) { + return new int[] {0, 0, 0}; + } + for (BlockPos member : state.getMemberPositions()) { + TileEntity tile = world.getTileEntity(member); + if (!(tile instanceof TileTurret)) { + continue; + } + total++; + TurretMechanism mount = ((TileTurret) tile).getMechanism(); + if (mount.isOnTarget()) { + onTarget++; + } + if (mount.isSaturated()) { + saturated++; + } + } + return new int[] {onTarget, saturated, total}; + } + public String getNetworkStatusText() { WeaponNetworkState state = network(); if (state == null) { @@ -224,7 +294,9 @@ private String statusLine() { } private String gunLine() { - return "Guns: " + getGunCount(); + int[] mounts = getMountTelemetry(); + return "Guns: " + getGunCount() + " on target: " + mounts[0] + + (mounts[1] > 0 ? " out of arc: " + mounts[1] : ""); } private String targetLine() { diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java index 2576b8195..66639d5c5 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java @@ -19,6 +19,8 @@ public class WeaponNetworkState extends SubsystemNetworkState { private Vec3d target; + private java.util.UUID targetEntity; + private String accessCode = ""; private boolean holdFire; /** Where the network's guns are pointed, in WORLD coordinates, or null when nothing is assigned. */ @@ -32,6 +34,34 @@ public void setTarget(Vec3d target) { public void clearTarget() { this.target = null; + this.targetEntity = null; + } + + /** + * The entity every gun on this network is following, or null. Kept beside the point target + * rather than replacing it: a battery told to shell a position and a battery told to track a + * ship are different orders, and one of them survives the target moving. + */ + public java.util.UUID getTargetEntity() { + return targetEntity; + } + + public void setTargetEntity(java.util.UUID entity) { + this.targetEntity = entity; + } + + /** + * The network's access code — the credential a target may present to be recognised as friendly. + * Empty means "no code set", which recognises nobody: an unarmed default that shoots everything + * is safer than one that shoots nothing, because the second is indistinguishable from a broken + * gun. + */ + public String getAccessCode() { + return accessCode == null ? "" : accessCode; + } + + public void setAccessCode(String code) { + this.accessCode = code == null ? "" : code; } /** True while the network's guns must track but not shoot. */ @@ -48,6 +78,8 @@ public SubsystemNetworkState copy() { WeaponNetworkState copy = new WeaponNetworkState(); copyInto(copy); copy.target = target; + copy.targetEntity = targetEntity; + copy.accessCode = accessCode; copy.holdFire = holdFire; return copy; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/TurretFriendOrFoeE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/TurretFriendOrFoeE2ETest.java new file mode 100644 index 000000000..01ecf1f57 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/TurretFriendOrFoeE2ETest.java @@ -0,0 +1,149 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Whether a gun can tell a friend from a target. + * + *

    Why this is a client test

    + *

    The credential is CARRIED, not held about somebody: an entity is friendly for exactly as long + * as it has the installation's code on it, and the only entity that can carry one is a player. A + * dedicated-server test has no players, so the whole mechanic is unreachable there — which is + * precisely why it stayed unbuilt while everything around it was pinned.

    + * + *

    Both halves, or neither means anything

    + *

    A gun that never fires passes "does not shoot friendlies" trivially. So the test makes the same + * gun, pointed at the same player, fire once the code stops matching — the refusal is only evidence + * if the shot is the control.

    + * + *

    Gated by {@code forge.test.client.enabled=true}; auto-skips on headless CI.

    + */ +public class TurretFriendOrFoeE2ETest extends AbstractClientE2ETest { + + /** The harness's single client always joins under this name. */ + private static final String PLAYER = "ForgeTestClient"; + + /** Far enough that the gun is not firing into the player's own block, near enough to track. */ + private static final int GUN_OFFSET = 20; + + private static final long TIMEOUT_MS = 25_000L; + + @Test + public void aGunHoldsFireOnAPlayerCarryingItsCodeAndFiresOnOneWhoIsNot() throws Exception { + // Build the gun AROUND the player rather than teleporting the player to the gun. A tp into + // a freshly cleared site drops him, and a gun tracking a falling target pins its elevation + // arc and stops firing — which is indistinguishable from the refusal this test is about. + // (That is exactly how the first two runs failed: pitch +20, saturated, shots 0.) + double[] player = playerPosition(); + int px = (int) Math.floor(player[0]); + int py = (int) Math.floor(player[1]); + int pz = (int) Math.floor(player[2]); + int gx = px + GUN_OFFSET; + + server("artest chunk warmup 0 " + ((px - 16) >> 4) + " " + ((pz - 16) >> 4) + " " + + ((gx + 16) >> 4) + " " + ((pz + 16) >> 4)); + // Clear the whole corridor between the player and the gun, not just the gun's own footprint. + // The muzzle sits `reach + 1.5` blocks along the aim — about five and a half blocks towards + // the player — and the line-of-fire check refuses a shot into terrain, so a two-block + // clearing leaves the gun holding fire for a reason that has nothing to do with the target. + server("artest fill 0 " + (px - 2) + " " + py + " " + (pz - 2) + " " + (gx + 4) + " " + + (py + 8) + " " + (pz + 2) + " minecraft:air"); + server("artest chunk forceload 0 " + (gx >> 4) + " " + (pz >> 4)); + buildGun(gx, py, pz); + + String built = awaitOperable(gx, py, pz); + assertTrue("the gun never assembled: " + built, built.contains("\"operable\":true")); + server("artest turret charge 0 " + gx + " " + py + " " + pz); + server("artest turret code 0 " + gx + " " + py + " " + pz + " ALPHA"); + + // The player carries the installation's own code, and is therefore a friend. + server("clear " + PLAYER); + server("give " + PLAYER + " affs:code_device 1 0 {affs_code:\"ALPHA\"}"); + bot().waitTicks(10); + String targeted = server("artest turret target-player 0 " + gx + " " + py + " " + pz + " " + PLAYER); + assertTrue("the probe could not point the gun at the player: " + targeted, + targeted.contains("\"ok\":true")); + + bot().waitTicks(80); + String tracking = read(gx, py, pz); + assertEquals("the gun shot a player carrying its own access code: " + tracking, 0, + shots(gx, py, pz)); + assertTrue("the gun is not tracking the player, so its silence says nothing about" + + " friend-or-foe: " + tracking, tracking.contains("\"trackingEntity\":true")); + assertTrue("the gun is not even pointing at him (saturated arc or lost bearing), so the" + + " silence is about geometry rather than the credential: " + tracking, + tracking.contains("\"onTarget\":true")); + + // Same gun, same player, same target — only the credential changes. + server("clear " + PLAYER); + server("give " + PLAYER + " affs:code_device 1 0 {affs_code:\"BRAVO\"}"); + bot().waitTicks(10); + server("artest turret charge 0 " + gx + " " + py + " " + pz); + + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + int fired = shots(gx, py, pz); + while (System.currentTimeMillis() < deadline && fired == 0) { + bot().waitTicks(20); + fired = shots(gx, py, pz); + } + assertTrue("the gun would not fire on a player carrying somebody else's code either — the" + + " hold was not about the credential: " + read(gx, py, pz), fired >= 1); + } + + /** Where the harness's player actually is. Nothing here moves him. */ + private double[] playerPosition() throws Exception { + String json = server("artest player position-of " + PLAYER); + return new double[] {readDouble(json, "playerPosX"), readDouble(json, "playerPosY"), + readDouble(json, "playerPosZ")}; + } + + private void buildGun(int gx, int gy, int gz) throws Exception { + place("advancedrocketry:turret", gx, gy, gz); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", gx, gy + i, gz); + } + place("advancedrocketry:gunCooling", gx, gy, gz + 1); + place("advancedrocketry:gunCooling", gx, gy, gz - 1); + } + + private String awaitOperable(int gx, int gy, int gz) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(gx, gy, gz); + while (System.currentTimeMillis() < deadline && !state.contains("\"operable\":true")) { + bot().waitTicks(10); + state = read(gx, gy, gz); + } + return state; + } + + private int shots(int gx, int gy, int gz) throws Exception { + Matcher m = Pattern.compile("\"shots\":(-?\\d+)").matcher(read(gx, gy, gz)); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private String read(int gx, int gy, int gz) throws Exception { + return server("artest turret read 0 " + gx + " " + gy + " " + gz); + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[\\d.eE+]+)").matcher(json); + assertTrue("no " + key + " in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = server("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private String server(String command) throws Exception { + return String.join("\n", serverClient().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TurretOnAShipE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TurretOnAShipE2ETest.java new file mode 100644 index 000000000..77521843f --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TurretOnAShipE2ETest.java @@ -0,0 +1,228 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A gun bolted to a ship, which is the configuration every other turret test cannot reach. + * + *

    The whole difficulty in one sentence

    + *

    A turret on a ship stands in the SHIPYARD — a fixed address millions of blocks from where its + * hull visibly is — while the target it is given, and the round it fires, belong to the world. So + * the gun holds its bearing in the ship's frame and converts at the muzzle: the point through + * {@code toWorldFrameFor}, the direction through {@code rotateToWorldFrameFor}, plus the hull's own + * velocity. None of those three announces itself when it is wrong; the failure is simply a round + * that appears somewhere nobody can see.

    + * + *

    What makes this evidence

    + *

    The round is located after the shot. If any leg of the conversion were missing it would be in + * the shipyard, five million blocks out — and every other assertion here (the gun assembled, it was + * charged, it fired) would still pass. That distance is the discriminator, and it is asserted + * explicitly rather than inferred from a hit.

    + */ +public class TurretOnAShipE2ETest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + + /** This class's own build site and destination, clear of the other ship scenarios. */ + private static final int SRC_X = 6800, SRC_Y = 80, SRC_Z = 6800; + private static final int FAR_X = 6800, FAR_Y = 150, FAR_Z = 9200; + + /** Anything past this is a shipyard address rather than a place in the world. */ + private static final double SHIPYARD_THRESHOLD = 1_000_000.0D; + + private static final long TIMEOUT_MS = 25_000L; + + @Test + public void aGunOnAShipFiresIntoTheWorldRatherThanIntoTheShipyard() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest shot clear 0"); + + String shipId = buildAndMoveShip(); + + // A block of this ship whose SUBSPACE address we know: its pilot seat. The gun goes beside it. + String seat = exec("artest vs find-seat 0 id " + shipId); + assertTrue("could not locate the ship's seat, so there is nowhere known to mount a gun: " + + seat, seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), + subZ = extractInt(seat, "seatZ"); + assertTrue("the seat is not at a shipyard address (" + subX + "), so this is not the case" + + " the test is about", Math.abs(subX) > SHIPYARD_THRESHOLD); + + int gunX = subX + 3, gunY = subY, gunZ = subZ; + buildGun(gunX, gunY, gunZ); + + String built = awaitOperable(gunX, gunY, gunZ); + assertTrue("a gun aboard a named ship never assembled — it is being treated as if the ship" + + " were unnamed: " + built, built.contains("\"operable\":true")); + + // Where the hull actually is, this tick. + String info = exec("artest vs ship-info 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the ship is not where it was moved to: " + info, info.contains("\"managed\":true")); + double worldX = readDouble(info, "posX"), worldY = readDouble(info, "posY"), + worldZ = readDouble(info, "posZ"); + + exec("artest turret charge 0 " + gunX + " " + gunY + " " + gunZ); + // A target in the WORLD, well clear of the hull. + exec("artest turret target 0 " + gunX + " " + gunY + " " + gunZ + " " + (worldX + 60.0D) + + " " + worldY + " " + worldZ); + + String fired = awaitShots(gunX, gunY, gunZ, 1); + assertTrue("a gun aboard a ship never fired: " + fired, extractInt(fired, "shots") >= 1); + + // THE assertion: the round is in the world, near the hull — not at the shipyard address the + // gun's own BlockPos would have given it. + String flight = exec("artest shot list 0"); + double furthest = furthestShotX(flight); + assertTrue("a round is in the air at x=" + furthest + ", which is a shipyard address: the" + + " muzzle point was never mapped out of the ship's frame, so the gun is shelling a" + + " place no player can reach: " + flight, furthest < SHIPYARD_THRESHOLD); + double nearest = nearestShotDistance(flight, worldX, worldY, worldZ); + assertTrue("the nearest round is " + nearest + " blocks from the hull that fired it — it is" + + " in the world, but not where this ship is: " + flight, nearest < 400.0D); + } + + // ---- fixture + + /** Build the fixture, assemble it into a ship, and move it far from where it was built. */ + private String buildAndMoveShip() throws Exception { + clearArea(SRC_X, SRC_Z); + String coords = placeFixture(SRC_X, SRC_Y, SRC_Z); + String asm = exec("artest rocket assemble 0 " + coords); + assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm, + asm.contains("\"rocketCount\":0")); + + String info = null; + for (int attempt = 0; attempt < 40; attempt++) { + exec("artest vs load-ships 0"); + info = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z); + if (info.contains("\"managed\":true")) { + break; + } + Thread.sleep(250L); + } + assertTrue("the build never became a ship managed at its build site: " + info, + info != null && info.contains("\"managed\":true")); + + String tp = exec("artest vs teleport-ship 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z + + " " + FAR_X + " " + FAR_Y + " " + FAR_Z); + assertTrue("the ship could not be moved: " + tp, tp.contains("\"ok\":true")); + exec("artest vs unpark 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + return extractString(info, "id"); + } + + private String placeFixture(int baseX, int baseY, int baseZ) throws Exception { + String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + + " with-pilot-seat"); + Matcher m = BUILDER_POS.matcher(fixture); + assertTrue("fixture did not report a builder position: " + fixture, m.find()); + return m.group(1) + " " + m.group(2) + " " + m.group(3); + } + + private void clearArea(int baseX, int baseZ) throws Exception { + int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; + int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + + cx2 + " " + cz2).contains("\"ok\":true")); + assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (SRC_Y - 2) + " " + + (baseZ - 4) + " " + (baseX + 20) + " " + (SRC_Y + 12) + " " + (baseZ + 20) + + " minecraft:air").contains("\"ok\":true")); + } + + /** The same reference gun the ground tests use, placed at SUBSPACE coordinates. */ + private void buildGun(int gx, int gy, int gz) throws Exception { + place("advancedrocketry:turret", gx, gy, gz); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", gx, gy + i, gz); + } + place("advancedrocketry:gunCooling", gx, gy, gz + 1); + place("advancedrocketry:gunCooling", gx, gy, gz - 1); + } + + // ---- reads + + private String awaitOperable(int gx, int gy, int gz) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(gx, gy, gz); + while (System.currentTimeMillis() < deadline && !state.contains("\"operable\":true")) { + Thread.sleep(250L); + state = read(gx, gy, gz); + } + return state; + } + + private String awaitShots(int gx, int gy, int gz, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(gx, gy, gz); + while (System.currentTimeMillis() < deadline && extractInt(state, "shots") < wanted) { + Thread.sleep(250L); + state = read(gx, gy, gz); + } + return state; + } + + private String read(int gx, int gy, int gz) throws Exception { + return exec("artest turret read 0 " + gx + " " + gy + " " + gz); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + y + "," + z + ": " + resp, + resp.contains("\"placed\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private boolean serverHasVs() throws Exception { + return exec("artest vs available").contains("\"available\":true"); + } + + /** The largest |x| any shot in flight reports, or 0 when nothing is up. */ + private static double furthestShotX(String json) { + Matcher m = Pattern.compile("\"x\":(-?[\\d.eE+]+)").matcher(json); + double furthest = 0.0D; + while (m.find()) { + furthest = Math.max(furthest, Math.abs(Double.parseDouble(m.group(1)))); + } + return furthest; + } + + /** How close the nearest shot is to a world point. */ + private static double nearestShotDistance(String json, double x, double y, double z) { + Matcher m = Pattern.compile("\"x\":(-?[\\d.eE+]+),\"y\":(-?[\\d.eE+]+),\"z\":(-?[\\d.eE+]+)") + .matcher(json); + double best = Double.POSITIVE_INFINITY; + while (m.find()) { + double dx = Double.parseDouble(m.group(1)) - x; + double dy = Double.parseDouble(m.group(2)) - y; + double dz = Double.parseDouble(m.group(3)) - z; + best = Math.min(best, Math.sqrt(dx * dx + dy * dy + dz * dz)); + } + return best; + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[\\d.eE+]+)").matcher(json); + assertTrue("no " + key + " field in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static String extractString(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java index 3d64ee1cf..4ccbbeba1 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java @@ -220,6 +220,55 @@ public void aGunAboardAnUnnamedShipDoesNothingAtAll() throws Exception { + " an address no player can reach: " + inFlight, furthest < 1_000_000.0D); } + /** + * The manual seam: a gun under a hand chooses no target, obeys the bearing it is given, and fires + * only when told — on exactly the same conditions the automatic path checks. + * + *

    This is the half of the manned gun that has to exist for the seat and the first-person view + * to be an addition rather than a rewrite. It is pinned now, while there is nothing driving it, + * because a seam nobody exercises is a seam that quietly stops working.

    + */ + @Test + public void aGunUnderManualControlIgnoresItsTargetAndFiresOnlyWhenTold() throws Exception { + int bx = X + 500; + buildSite(bx); + buildGun(bx); + awaitOperable(bx); + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + + // A target it WOULD engage on its own, so "did not fire" is about the mode. + exec("artest turret target 0 " + bx + " " + Y + " " + Z + " " + (bx + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + exec("artest turret manual 0 " + bx + " " + Y + " " + Z + " true"); + Thread.sleep(4_000L); + + String held = read(bx); + assertEquals("a gun in manual control fired on an assigned target by itself: " + held, 0, + extractInt(held, "shots")); + assertTrue("the gun did not enter manual control: " + held, held.contains("\"manual\":true")); + + // It obeys a hand-given bearing... + exec("artest turret bearing 0 " + bx + " " + Y + " " + Z + " -90 0"); + Thread.sleep(3_000L); + String aimed = read(bx); + assertEquals("the mount ignored the bearing it was handed: " + aimed, -90.0D, + readDouble(aimed, "yaw"), 2.0D); + + // ...and fires when the trigger is pulled, once per pull. + String shot = exec("artest turret fire 0 " + bx + " " + Y + " " + Z); + assertTrue("the trigger did nothing: " + shot + " state: " + read(bx), + shot.contains("\"fired\":true")); + assertEquals("one pull fired more than one round: " + read(bx), 1, + extractInt(read(bx), "shots")); + + // Returning it to automatic re-engages the target it was given. + exec("artest turret manual 0 " + bx + " " + Y + " " + Z + " false"); + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + String resumed = awaitShots(bx, 2); + assertTrue("the gun never went back to firing on its own: " + resumed, + extractInt(resumed, "shots") >= 2); + } + // ---- scenario construction /** From f68278531576ac0c6c3a9a17dd4c680f3d351b01 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 15:16:09 +0300 Subject: [PATCH 11/35] feat: a battery that finds its own target, and knows when it cannot hold it - a fire-control sensor: sweep, classify, hand the network one contact - range from total radiated power, lock quality from radiance alone - passive listens and emits nothing; active illuminates and pays for it - an ally never enters the contact list, nor does anyone aboard our ship - guns lead an acquired target and hold fire on a lock too poor to hit --- .../advancedRocketry/AdvancedRocketry.java | 9 + .../advancedRocketry/api/ARConfiguration.java | 56 ++ .../api/AdvancedRocketryBlocks.java | 6 + .../api/sensor/ITargetSignature.java | 32 ++ .../api/sensor/SensorMode.java | 41 ++ .../api/sensor/TargetTrack.java | 90 +++ .../command/test/TestProbeCommand.java | 141 ++++- .../sensor/SignatureModel.java | 152 +++++ .../advancedRocketry/sensor/TacticalScan.java | 154 +++++ .../tile/sensor/TileFireControlSensor.java | 543 ++++++++++++++++++ .../tile/weapon/TileTurret.java | 118 +++- .../tile/weapon/TileWeaponConsole.java | 25 + .../weapon/TurretFireControl.java | 56 ++ .../weapon/WeaponNetworkState.java | 34 ++ .../blockstates/firecontrolsensor.json | 47 ++ .../assets/advancedrocketry/lang/en_US.lang | 2 + .../recipes/firecontrolsensor.json | 30 + .../SensorFriendIsNeverAcquiredE2ETest.java | 162 ++++++ .../test/server/FireControlSensorE2ETest.java | 264 +++++++++ .../test/unit/SignatureModelTest.java | 126 ++++ .../test/unit/TurretInterceptTest.java | 89 +++ 21 files changed, 2171 insertions(+), 6 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/sensor/ITargetSignature.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/sensor/SensorMode.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/sensor/TargetTrack.java create mode 100644 src/main/java/zmaster587/advancedRocketry/sensor/SignatureModel.java create mode 100644 src/main/java/zmaster587/advancedRocketry/sensor/TacticalScan.java create mode 100644 src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/firecontrolsensor.json create mode 100644 src/main/resources/assets/advancedrocketry/recipes/firecontrolsensor.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/SensorFriendIsNeverAcquiredE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/FireControlSensorE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/SignatureModelTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/TurretInterceptTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 5d01dd0e8..94c2f99a6 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -459,6 +459,8 @@ public void preInit(FMLPreInitializationEvent event) { 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"); @@ -751,6 +753,12 @@ public void registerBlocks(RegistryEvent.Register evt) { 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 @@ -943,6 +951,7 @@ public void registerBlocks(RegistryEvent.Register evt) { 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.blockGunCooling.setRegistryName("gunCooling")); diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 31a7b61ae..d642ad141 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -416,6 +416,54 @@ public class ARConfiguration { */ @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; @ConfigProperty(needsSync = true) public double wearTankLeakChanceMax = 0.5; @ConfigProperty(needsSync = true) @@ -667,6 +715,14 @@ public static void loadPreInit() { 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.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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, 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.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Enable rocket part wear and exploding chance.").getBoolean(); arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java index 45178d782..e66deef42 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java +++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java @@ -50,6 +50,12 @@ public class AdvancedRocketryBlocks { 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/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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 0c5b497c4..5c639f2a0 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -250,6 +250,9 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args case "weaponconsole": handleWeaponConsole(server, sender, tail(args)); break; + case "sensor": + handleFireControlSensor(server, sender, tail(args)); + break; case "sound": handleSound(server, sender, tail(args)); break; @@ -468,6 +471,9 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ + ",\"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()) + "\"" @@ -558,6 +564,9 @@ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, + ",\"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 @@ -568,6 +577,123 @@ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, 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) + "\"}"; @@ -11313,7 +11439,20 @@ private void handleMachineTickUntil(MinecraftServer server, ICommandSender sende "telescopeSurveyDataPerStep", // The research master switch. A survey is instant without it and paced by the // time curve with it, so both halves of boundary B need it flippable at runtime. - "planetsMustBeDiscovered")); + "planetsMustBeDiscovered", + // Fire control. The master switch is here so a test can watch the SAME battery + // and the SAME target with acquisition off and then on — a control in the same + // run, rather than the hope that a gun which fired did so because of the sensor. + // The rest are the tuning a lock/no-lock scenario has to state rather than + // assume: a test that silently depended on the shipped radius would go red the + // day somebody rebalanced it, for a reason that has nothing to do with it. + "enableFireControlSensor", + "fireControlSensorRadius", + "fireControlSensorScanIntervalTicks", + "fireControlSensorActiveEnergyPerTick", + "fireControlSensorActiveLockQuality", + "fireControlSensorLockQualityToFire", + "fireControlSensorAcquireHostilesOnly")); private void handleConfig(ICommandSender sender, String[] args) { if (args.length == 0) { diff --git a/src/main/java/zmaster587/advancedRocketry/sensor/SignatureModel.java b/src/main/java/zmaster587/advancedRocketry/sensor/SignatureModel.java new file mode 100644 index 000000000..2771fb5dd --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/sensor/SignatureModel.java @@ -0,0 +1,152 @@ +package zmaster587.advancedRocketry.sensor; + +import net.minecraft.entity.Entity; +import zmaster587.advancedRocketry.api.sensor.ITargetSignature; + +/** + * How well a thing can be heard, and how well it can be held — the two halves of a signature, kept + * apart on purpose. + * + *

    The law

    + *

    A body radiates {@code σT⁴} watts per square metre of surface. Two consequences, and the whole + * of this class is them:

    + *
      + *
    • Detection range comes from TOTAL radiated power, {@code σT⁴·A}, and grows with its + * square root — the ordinary inverse-square falloff read backwards. A big cool object is easy + * to notice from far away.
    • + *
    • Lock quality comes from RADIANCE, {@code σT⁴}, which depends on temperature alone. + * A small hot object is a point beacon: it is precisely locatable, however little of it there + * is.
    • + *
    + *

    So a compact chiller-boosted array and a large cool one that shed identical watts are entirely + * different targets, and a player who has understood that has understood the trade this mechanic is + * made of. Merging the two into one "signature" number would delete it.

    + * + *

    Where a temperature comes from today

    + *

    Nowhere, yet: the heat subsystem that will give a hull a radiator temperature is unbuilt. Until + * it lands, anything that does not state its own signature is ESTIMATED here — a body at roughly + * living-thing temperature, a burning one at flame temperature, area from its own bounding box. The + * estimate is deliberately crude and deliberately isolated to one method: the shape of the law is + * the part that is meant to survive, and when a ship can say how hot it is it says so through + * {@link ITargetSignature} and none of this is consulted.

    + */ +public final class SignatureModel { + + /** Stefan–Boltzmann, W·m⁻²·K⁻⁴. */ + public static final double SIGMA = 5.670374419E-8D; + + /** + * The temperature at which a target locks perfectly at the reference range. Set at the + * temperature of a working machine rather than of a living body, so that the ordinary warm + * things walking around a planet are trackable close in and poor targets at range — which is + * what makes the active mode worth its emission. + */ + public static final double REFERENCE_TEMPERATURE_KELVIN = 500.0D; + + /** The range, in blocks, at which a body at the reference temperature is perfectly resolved. */ + public static final double REFERENCE_LOCK_RANGE_BLOCKS = 32.0D; + + /** + * Blocks of detection range per square root of a watt. The only constant here that is pure + * bookkeeping: it converts the physics into the scale a Minecraft world is built at. + */ + public static final double DETECTION_BLOCKS_PER_SQRT_WATT = 3.0D; + + /** What an ordinary warm body is estimated at, in kelvin, until something says otherwise. */ + public static final double AMBIENT_BODY_KELVIN = 300.0D; + + /** What a burning body is estimated at. A thing on fire is a beacon, and should be one. */ + public static final double BURNING_BODY_KELVIN = 1200.0D; + + private SignatureModel() { + } + + /** Radiance in W/m²: {@code σT⁴}. Temperature alone — area does not appear, and must not. */ + public static double radiance(double temperatureKelvin) { + double temperature = Math.max(0.0D, temperatureKelvin); + return SIGMA * temperature * temperature * temperature * temperature; + } + + /** Total radiated power in watts: radiance times how much surface is doing the radiating. */ + public static double radiatedPower(double temperatureKelvin, double areaSquareMetres) { + return radiance(temperatureKelvin) * Math.max(0.0D, areaSquareMetres); + } + + /** + * How far away this target can be NOTICED at all, in blocks. Square root of total power: a + * target with four times the output is noticed twice as far away, which is the inverse-square + * law read from the other end. + */ + public static double detectionRangeBlocks(double temperatureKelvin, double areaSquareMetres) { + return DETECTION_BLOCKS_PER_SQRT_WATT + * Math.sqrt(radiatedPower(temperatureKelvin, areaSquareMetres)); + } + + /** + * How well a listening sensor resolves a target at this distance, 0..1. + * + *

    {@code (T/T_ref)⁴ · (d_ref/d)²} — the target's radiance against the reference, falling off + * with the square of the range. Area is absent by design: this is the term a compact hot object + * wins and a large cool one loses.

    + */ + public static double passiveQuality(double temperatureKelvin, double distanceBlocks) { + if (distanceBlocks <= 0.0D) { + return 1.0D; + } + double temperatureRatio = radiance(temperatureKelvin) / radiance(REFERENCE_TEMPERATURE_KELVIN); + double rangeRatio = REFERENCE_LOCK_RANGE_BLOCKS / distanceBlocks; + return clamp01(temperatureRatio * rangeRatio * rangeRatio); + } + + /** + * How well an illuminating sensor resolves a target at this distance, 0..1. + * + *

    The target's own temperature does not appear: the sensor is providing the light, which is + * exactly why this is the only way to hold a cold, silent thing. Quality is the plateau the + * installation is tuned for, tapering over the last quarter of the sensor's radius so that the + * edge of the envelope is a place where things are held badly rather than a wall.

    + */ + public static double activeQuality(double distanceBlocks, double radiusBlocks, double plateau) { + if (radiusBlocks <= 0.0D || distanceBlocks > radiusBlocks) { + return 0.0D; + } + double taperStart = radiusBlocks * 0.75D; + if (distanceBlocks <= taperStart) { + return clamp01(plateau); + } + double fade = 1.0D - (distanceBlocks - taperStart) / (radiusBlocks - taperStart); + return clamp01(plateau * fade); + } + + /** + * What this entity looks like, when it has not said. A living thing is warm, a burning thing is + * a beacon, and the radiating area is the surface of the box it occupies — all three are things + * the world already knows, so nothing here invents a number the player cannot see the reason for. + */ + public static double estimatedTemperatureKelvin(Entity entity) { + if (entity == null) { + return AMBIENT_BODY_KELVIN; + } + if (entity instanceof ITargetSignature) { + return Math.max(0.0D, ((ITargetSignature) entity).getRadiatorTemperatureKelvin()); + } + return entity.isBurning() ? BURNING_BODY_KELVIN : AMBIENT_BODY_KELVIN; + } + + /** The radiating surface of an entity's own box, in square metres — a block being a metre. */ + public static double estimatedAreaSquareMetres(Entity entity) { + if (entity == null) { + return 1.0D; + } + if (entity instanceof ITargetSignature) { + return Math.max(0.0D, ((ITargetSignature) entity).getRadiatingAreaSquareMetres()); + } + double width = Math.max(0.1D, entity.width); + double height = Math.max(0.1D, entity.height); + return 2.0D * width * width + 4.0D * width * height; + } + + private static double clamp01(double value) { + return value < 0.0D ? 0.0D : (value > 1.0D ? 1.0D : value); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/sensor/TacticalScan.java b/src/main/java/zmaster587/advancedRocketry/sensor/TacticalScan.java new file mode 100644 index 000000000..1fc7c7277 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/sensor/TacticalScan.java @@ -0,0 +1,154 @@ +package zmaster587.advancedRocketry.sensor; + +import com.github.stannismod.affs.util.CodeUtils; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.entity.monster.IMob; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.sensor.SensorMode; +import zmaster587.advancedRocketry.api.sensor.TargetTrack; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * One sweep: everything a sensor can currently hold, in the order it would shoot at them. + * + *

    Classification happens HERE, not at the trigger

    + *

    A friend is not something a gun declines to fire at — a friend never becomes a contact in the + * first place. That is a stronger statement than it looks: a battery cannot be talked into shelling + * its own crew by a race, a stale target or a console left pointing at the wrong thing, because the + * name of the friendly was never written down anywhere a gun could read it. The two exclusions are + * the credential a target carries, and simply being aboard the ship the sensor is bolted to.

    + * + *

    World frame, always

    + *

    Entities live in the world's coordinates and a ship's blocks live in the ship's, so a sensor + * aboard a hull converts its OWN position out to the world before looking around, and the tracks it + * produces are in world coordinates. A gun consuming them converts back, once, using the same seam + * it already uses for every other target.

    + */ +public final class TacticalScan { + + private TacticalScan() { + } + + /** + * Look around and answer what is out there, best contact first. + * + * @param world the server world the sensor sits in + * @param origin the sensor's own position, in WORLD coordinates + * @param ownShipId the ship the sensor is bolted to, or null for a ground installation + * @param radiusBlocks how far the device can look at all + * @param mode listening, or illuminating + * @param activePlateau the quality an illuminated contact is held at inside the envelope + * @param friendlyCode the installation's access code; anything carrying it is not a contact + * @param hostilesOnly whether harmless livestock and villagers may be acquired + * @param maxTracks how many contacts the device can hold at once + */ + public static List sweep(World world, Vec3d origin, String ownShipId, + double radiusBlocks, SensorMode mode, double activePlateau, + String friendlyCode, boolean hostilesOnly, int maxTracks) { + if (world == null || world.isRemote || origin == null || radiusBlocks <= 0.0D || maxTracks <= 0) { + return Collections.emptyList(); + } + + AxisAlignedBB envelope = new AxisAlignedBB( + origin.x - radiusBlocks, origin.y - radiusBlocks, origin.z - radiusBlocks, + origin.x + radiusBlocks, origin.y + radiusBlocks, origin.z + radiusBlocks); + + List tracks = new ArrayList<>(); + for (EntityLivingBase candidate : world.getEntitiesWithinAABB(EntityLivingBase.class, envelope)) { + TargetTrack track = trackOf(candidate, origin, ownShipId, radiusBlocks, mode, activePlateau, + friendlyCode, hostilesOnly, world); + if (track != null) { + tracks.add(track); + } + } + + // Best held first, nearest breaking a tie: a battery that has to choose should choose the + // one it can actually hit, and among equals the one that will arrive first. + tracks.sort(Comparator.comparingDouble(t -> -t.getQuality()) + .thenComparingDouble(TargetTrack::getDistance)); + return tracks.size() <= maxTracks ? tracks : new ArrayList<>(tracks.subList(0, maxTracks)); + } + + /** One candidate, or null if it is not a contact at all. */ + private static TargetTrack trackOf(EntityLivingBase candidate, Vec3d origin, String ownShipId, + double radiusBlocks, SensorMode mode, double activePlateau, + String friendlyCode, boolean hostilesOnly, World world) { + if (candidate == null || candidate.isDead) { + return null; + } + if (hostilesOnly && !(candidate instanceof IMob) && !(candidate instanceof EntityPlayer)) { + // A defensive battery that opens up on passing livestock is a battery a player switches + // off, and a switched-off battery defends nothing. + return null; + } + if (CodeUtils.entityHasMatchingCode(candidate, friendlyCode)) { + return null; + } + if (isAboard(world, ownShipId, candidate)) { + // Standing on our own deck. The crew of a ship carry no credential by default and are + // not going to acquire one mid-boarding-action; being aboard IS the credential. + return null; + } + + Vec3d position = bodyCentre(candidate); + double distance = position.distanceTo(origin); + if (distance > radiusBlocks) { + // The bounding box is a cube and the envelope is a sphere. + return null; + } + + double temperature = SignatureModel.estimatedTemperatureKelvin(candidate); + double area = SignatureModel.estimatedAreaSquareMetres(candidate); + double radiance = SignatureModel.radiance(temperature); + + double quality; + if (mode == SensorMode.ACTIVE) { + quality = SignatureModel.activeQuality(distance, radiusBlocks, activePlateau); + } else { + if (distance > SignatureModel.detectionRangeBlocks(temperature, area)) { + // Not heard at all: a listening sensor's reach is the target's own output, not the + // device's rating. Silence never makes a thing invisible, but it does move this line. + return null; + } + quality = SignatureModel.passiveQuality(temperature, distance); + } + if (quality <= 0.0D) { + return null; + } + + return new TargetTrack(candidate.getUniqueID(), position, velocityOf(candidate), quality, mode, + radiance, distance); + } + + /** The middle of the body: a round at foot height passes under everything on uneven ground. */ + private static Vec3d bodyCentre(Entity entity) { + return entity.getPositionVector().addVector(0.0D, entity.height * 0.5D, 0.0D); + } + + /** + * How fast it is actually going, in blocks per tick, taken from where it WAS rather than from + * its motion fields — a player's motion is decided on their own client and a mob's is spent + * before it is read, while the distance covered since the last tick is true for both. + */ + private static Vec3d velocityOf(Entity entity) { + return new Vec3d(entity.posX - entity.lastTickPosX, entity.posY - entity.lastTickPosY, + entity.posZ - entity.lastTickPosZ); + } + + /** Whether this entity is standing on the ship the sensor is part of. */ + private static boolean isAboard(World world, String ownShipId, Entity entity) { + if (ownShipId == null) { + return false; + } + return VSIntegration.shipIdsAt(world, entity.posX, entity.posY, entity.posZ).contains(ownShipId); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java b/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java new file mode 100644 index 000000000..b2dc15da6 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java @@ -0,0 +1,543 @@ +package zmaster587.advancedRocketry.tile.sensor; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.player.EntityPlayer; +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.EnumFacing; +import net.minecraft.util.ITickable; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.energy.CapabilityEnergy; +import net.minecraftforge.energy.EnergyStorage; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks; +import zmaster587.advancedRocketry.api.sensor.SensorMode; +import zmaster587.advancedRocketry.api.sensor.TargetTrack; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; +import zmaster587.advancedRocketry.sensor.TacticalScan; +import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink; +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 zmaster587.advancedRocketry.weapon.TurretFireControl; +import zmaster587.advancedRocketry.weapon.WeaponNetworkDomain; +import zmaster587.advancedRocketry.weapon.WeaponNetworkState; +import zmaster587.libVulpes.LibVulpes; +import zmaster587.libVulpes.inventory.TextureResources; +import zmaster587.libVulpes.inventory.modules.IButtonInventory; +import zmaster587.libVulpes.inventory.modules.IModularInventory; +import zmaster587.libVulpes.inventory.modules.ModuleBase; +import zmaster587.libVulpes.inventory.modules.ModuleButton; +import zmaster587.libVulpes.inventory.modules.ModuleText; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The thing that finds a target, so that a human does not have to name one. + * + *

    It senses; it does not shoot

    + *

    This block owns no gun and gives no order. It publishes ONE contact — the best it is currently + * holding — into the weapons network's shared state, and the guns on that network use it exactly as + * they use anything else they were told. Which means a battery with no sensor is unchanged, a sensor + * with no battery is a radar screen with nothing wired to it, and neither of them is a degraded + * version of the pair.

    + * + *

    Listening or illuminating

    + *

    {@link SensorMode#PASSIVE} emits nothing and is bounded by what the target itself radiates: a + * hot or burning thing is held well, a cool quiet one may be detected and never resolved well enough + * to shoot at. {@link SensorMode#ACTIVE} illuminates — steady quality against anything in range + * including a cold one — and costs power and, once the EM layer exists, your own silence. A sensor + * that cannot pay for the active mode falls back to listening rather than reporting a lock it does + * not have.

    + * + *

    On a ship, on the ground, same device

    + *

    Bolted to a hull it converts its own position out to the world before looking, so a + * planetary-defence radar and a warship's fire control are one block and one code path.

    + */ +public class TileFireControlSensor extends TileEntity implements ITickable, ISubsystemSink, + IModularInventory, IButtonInventory { + + private static final int BUTTON_MODE = 0; + + /** Enough for a few seconds of illumination, so a momentary supply dip is not a lost lock. */ + private static final int MIN_ENERGY_BUFFER = 8_000; + + /** + * How many scan intervals a published contact stays good for. Longer than one so a battery is + * not blinking between "target" and "no target" between sweeps; short enough that a sensor which + * stops publishing — broken, unloaded, unpowered — takes its battery's target with it. + */ + private static final int TRACK_HOLD_INTERVALS = 3; + + private EnergyStorage energy = new EnergyStorage(MIN_ENERGY_BUFFER, MIN_ENERGY_BUFFER, + MIN_ENERGY_BUFFER); + private SensorMode mode = SensorMode.PASSIVE; + private String accessCode = ""; + private boolean registered; + private int scanCooldown; + + /** What the last sweep saw, best first. Diagnostics and the readout; the network gets the best. */ + private List contacts = Collections.emptyList(); + private boolean poweredForActive = true; + + /** + * What the client was last told, and what it is holding. A sweep happens on the server and the + * panel is read on a client, so the numbers a player looks at have to travel — a readout composed + * from server-only state renders as zeroes over a real connection and only looks right in single + * player, where both sides happen to share one JVM. + */ + private SensorMode clientMode = SensorMode.PASSIVE; + private SensorMode clientEffectiveMode = SensorMode.PASSIVE; + private boolean clientUnderpowered; + private int clientContacts; + private double clientQuality; + private double clientDistance; + private boolean clientLocked; + private int sentContacts = -1; + private double sentQuality = -1.0D; + private SensorMode sentMode; + private boolean sentUnderpowered; + + @Override + public void update() { + if (world == null || world.isRemote) { + return; + } + if (VSIntegration.isOnUnnamedShip(world, pos)) { + // The same rule a gun follows: aboard a ship nobody has named yet, this block's position + // is a shipyard address rather than a place in the world, so every contact it produced + // would be measured from the wrong point. Waiting is the only correct behaviour. + return; + } + if (!ARConfiguration.getCurrentConfig().enableFireControlSensor) { + // Switched off means OFF: no acquisition, nothing published, no power drawn and not even + // a place in the network — a disabled sensor is not a node that quietly keeps its buffer + // topped up. Anything it had already published expires on its own. + contacts = Collections.emptyList(); + if (registered) { + SubsystemNetworkRegistry.unregister(this); + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + registered = false; + } + return; + } + if (!registered) { + SubsystemNetworkRegistry.register(this); + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + registered = true; + } + + poweredForActive = payForMode(); + if (scanCooldown > 0) { + scanCooldown--; + return; + } + scanCooldown = Math.max(1, ARConfiguration.getCurrentConfig().fireControlSensorScanIntervalTicks); + sweep(); + publish(); + syncReadoutIfChanged(); + } + + /** + * Tell the client what its panel is supposed to say, when that has meaningfully changed. + * + *

    Thresholded rather than sent every sweep: a sensor holding a slowly closing contact would + * otherwise be a packet every few ticks per device forever, for a number a player reads to two + * decimal places. A contact appearing or disappearing, the mode changing, the illuminator losing + * its power, or the lock moving by more than a twentieth are the changes worth a packet.

    + */ + private void syncReadoutIfChanged() { + TargetTrack best = getBestContact(); + int count = contacts.size(); + double quality = best == null ? 0.0D : best.getQuality(); + SensorMode effective = effectiveMode(); + boolean changed = count != sentContacts + || effective != sentMode + || isUnderpowered() != sentUnderpowered + || Math.abs(quality - sentQuality) > 0.05D; + if (!changed) { + return; + } + sentContacts = count; + sentQuality = quality; + sentMode = effective; + sentUnderpowered = isUnderpowered(); + IBlockState state = world.getBlockState(pos); + world.notifyBlockUpdate(pos, state, state, 2); + } + + /** + * Draw what this tick's mode costs, and answer whether it was affordable. Listening is free; + * illuminating is a machine that is running. An unaffordable active mode degrades to listening + * for as long as it stays unaffordable — visibly, through the readout, rather than by quietly + * producing worse tracks with no stated reason. + */ + private boolean payForMode() { + if (mode != SensorMode.ACTIVE) { + return true; + } + int cost = Math.max(0, ARConfiguration.getCurrentConfig().fireControlSensorActiveEnergyPerTick); + if (cost == 0) { + return true; + } + if (energy.getEnergyStored() < cost) { + return false; + } + energy.extractEnergy(cost, false); + return true; + } + + private void sweep() { + ARConfiguration config = ARConfiguration.getCurrentConfig(); + Vec3d origin = worldPosition(); + if (origin == null) { + // Aboard a ship whose transform is not available: every distance measured from here + // would be measured from a stale pose, so the sweep does not happen at all. + contacts = Collections.emptyList(); + return; + } + contacts = TacticalScan.sweep(world, origin, shipId(), config.fireControlSensorRadius, + effectiveMode(), config.fireControlSensorActiveLockQuality, effectiveAccessCode(), + config.fireControlSensorAcquireHostilesOnly, config.fireControlSensorMaxTracks); + } + + /** + * Hand the network the best contact, or take away the one it was holding. Both halves matter: + * a sensor that has stopped seeing anything must say so, or a battery goes on firing at the + * place something used to be. + */ + private void publish() { + WeaponNetworkState state = networkState(); + if (state == null) { + return; + } + if (contacts.isEmpty()) { + state.clearAcquiredTrack(); + return; + } + int hold = TRACK_HOLD_INTERVALS + * Math.max(1, ARConfiguration.getCurrentConfig().fireControlSensorScanIntervalTicks); + state.setAcquiredTrack(contacts.get(0), world.getTotalWorldTime(), hold); + } + + /** What this device is actually doing, as opposed to what it was set to. */ + public SensorMode effectiveMode() { + if (isClient()) { + return clientEffectiveMode; + } + return mode == SensorMode.ACTIVE && poweredForActive ? SensorMode.ACTIVE : SensorMode.PASSIVE; + } + + public SensorMode getMode() { + return isClient() ? clientMode : mode; + } + + private boolean isClient() { + return world != null && world.isRemote; + } + + public void setMode(SensorMode mode) { + if (mode != null) { + this.mode = mode; + markDirty(); + } + } + + /** True while this sensor is set to illuminate and cannot afford to. */ + public boolean isUnderpowered() { + return isClient() ? clientUnderpowered : mode == SensorMode.ACTIVE && !poweredForActive; + } + + /** How many contacts the panel should show — the client's copy on a client. */ + public int getContactCount() { + return isClient() ? clientContacts : contacts.size(); + } + + /** The best contact's quality, distance and whether it is a lock, for the panel on either side. */ + public double getBestQuality() { + TargetTrack best = getBestContact(); + return isClient() ? clientQuality : (best == null ? 0.0D : best.getQuality()); + } + + public double getBestDistance() { + TargetTrack best = getBestContact(); + return isClient() ? clientDistance : (best == null ? 0.0D : best.getDistance()); + } + + public boolean isBestLocked() { + if (isClient()) { + return clientLocked; + } + TargetTrack best = getBestContact(); + return best != null && best.isLocked( + ARConfiguration.getCurrentConfig().fireControlSensorLockQualityToFire); + } + + /** + * Whether this device is currently emitting something another sensor could hear. Nothing + * consumes it yet — the EM-signature layer is a separate subsystem — and it is stated anyway + * because it is the whole price of the active mode, and because the "you are being locked" + * warning a target gets is supposed to fall out of hearing this rather than being written. + */ + public boolean isEmitting() { + return effectiveMode().isEmitting(); + } + + public List getContacts() { + return contacts; + } + + /** The contact this sensor is handing its battery, or null when it holds nothing. */ + public TargetTrack getBestContact() { + return contacts.isEmpty() ? null : contacts.get(0); + } + + /** This sensor's own code, used when it is on no network or the network has none. */ + public void setAccessCode(String code) { + this.accessCode = code == null ? "" : code; + markDirty(); + } + + /** + * The network's code when it has one, otherwise this sensor's own — the same rule a gun follows, + * because "whose side are we on" is a property of the installation and a sensor that disagreed + * with the guns it feeds would hand them their own crew. + */ + public String effectiveAccessCode() { + WeaponNetworkState state = networkState(); + if (state != null && !state.getAccessCode().isEmpty()) { + return state.getAccessCode(); + } + return accessCode; + } + + private String shipId() { + return TurretFireControl.shipIdAt(world, pos); + } + + /** This block's position in WORLD coordinates: its own, or its ship's idea of where its own is. */ + private Vec3d worldPosition() { + return TurretFireControl.worldPositionOf(world, pos, shipId()); + } + + public WeaponNetworkState networkState() { + SubsystemNetworkState state = SubsystemNetworkManager.getState(WeaponNetworkDomain.INSTANCE, + world, pos); + return state instanceof WeaponNetworkState ? (WeaponNetworkState) state : null; + } + + // ---- subsystem network: a sink, in the domain it feeds + + @Override + public SubsystemNetworkDomain getNetworkDomain() { + return WeaponNetworkDomain.INSTANCE; + } + + @Override + public World getNodeWorld() { + return world; + } + + @Override + public BlockPos getNodePos() { + return pos; + } + + @Override + public int getRequested() { + return getFreeCapacity(); + } + + @Override + public int getFreeCapacity() { + return energy.getMaxEnergyStored() - energy.getEnergyStored(); + } + + @Override + public int receive(int amount) { + return energy.receiveEnergy(Math.max(0, amount), false); + } + + @Override + public int getConsumptionPerTick() { + return mode == SensorMode.ACTIVE + ? Math.max(0, ARConfiguration.getCurrentConfig().fireControlSensorActiveEnergyPerTick) + : 0; + } + + /** + * Ahead of the guns under a deficit. A battery that keeps its rounds and loses its eyes is a + * battery firing at nothing; one that keeps its eyes and runs a round short still knows where + * the enemy is when the supply comes back. + */ + @Override + public int getPriority() { + return 1; + } + + public int getEnergyStored() { + return energy.getEnergyStored(); + } + + /** Fills the buffer directly. For creative placement and for tests that are not about wiring. */ + public void chargeFully() { + energy.receiveEnergy(energy.getMaxEnergyStored(), false); + } + + // ---- lifecycle + + @Override + public void invalidate() { + super.invalidate(); + SubsystemNetworkRegistry.unregister(this); + if (world != null && !world.isRemote) { + SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); + } + registered = false; + } + + @Override + public void onChunkUnload() { + super.onChunkUnload(); + SubsystemNetworkRegistry.unregister(this); + registered = false; + } + + // ---- energy capability + + @Override + public boolean hasCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + return capability == CapabilityEnergy.ENERGY || super.hasCapability(capability, facing); + } + + @Override + @Nullable + public T getCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + if (capability == CapabilityEnergy.ENERGY) { + return CapabilityEnergy.ENERGY.cast(energy); + } + return super.getCapability(capability, facing); + } + + // ---- GUI + + @Override + public List getModules(int id, EntityPlayer player) { + List modules = new ArrayList<>(); + modules.add(new ModuleButton(10, 20, BUTTON_MODE, + LibVulpes.proxy.getLocalizedString("msg.fireControlSensor.mode"), this, + TextureResources.buttonBuild, 80, 18)); + modules.add(new ModuleText(10, 46, modeLine(), 0x2b2b2b)); + modules.add(new ModuleText(10, 58, contactLine(), 0x2b2b2b)); + modules.add(new ModuleText(10, 70, lockLine(), 0x2b2b2b)); + return modules; + } + + private String modeLine() { + return "Mode: " + effectiveMode().name().toLowerCase(java.util.Locale.ROOT) + + (isUnderpowered() ? " (no power to illuminate)" : ""); + } + + private String contactLine() { + return "Contacts: " + getContactCount(); + } + + private String lockLine() { + if (getContactCount() <= 0) { + return "Lock: none"; + } + return String.format("Lock: %.2f at %.0fm%s", getBestQuality(), getBestDistance(), + isBestLocked() ? "" : " (too poor to fire)"); + } + + @Override + public void onInventoryButtonPressed(int buttonId) { + if (buttonId == BUTTON_MODE) { + setMode(mode == SensorMode.ACTIVE ? SensorMode.PASSIVE : SensorMode.ACTIVE); + } + } + + @Override + public String getModularInventoryName() { + return AdvancedRocketryBlocks.blockFireControlSensor.getLocalizedName(); + } + + @Override + public boolean canInteractWithContainer(EntityPlayer entity) { + return true; + } + + // ---- what the client is told: the READOUT, because the sweep happens where it cannot see + + @Override + public NBTTagCompound getUpdateTag() { + NBTTagCompound nbt = super.getUpdateTag(); + nbt.setInteger("mode", mode.ordinal()); + nbt.setInteger("effMode", effectiveMode().ordinal()); + nbt.setBoolean("underpowered", isUnderpowered()); + nbt.setInteger("contacts", contacts.size()); + TargetTrack best = getBestContact(); + nbt.setDouble("quality", best == null ? 0.0D : best.getQuality()); + nbt.setDouble("distance", best == null ? 0.0D : best.getDistance()); + nbt.setBoolean("locked", isBestLocked()); + return nbt; + } + + @Override + public SPacketUpdateTileEntity getUpdatePacket() { + return new SPacketUpdateTileEntity(pos, 1, getUpdateTag()); + } + + @Override + public void onDataPacket(NetworkManager net, SPacketUpdateTileEntity packet) { + handleUpdateTag(packet.getNbtCompound()); + } + + @Override + public void handleUpdateTag(NBTTagCompound nbt) { + SensorMode[] modes = SensorMode.values(); + clientMode = modeOf(modes, nbt.getInteger("mode")); + clientEffectiveMode = modeOf(modes, nbt.getInteger("effMode")); + clientUnderpowered = nbt.getBoolean("underpowered"); + clientContacts = nbt.getInteger("contacts"); + clientQuality = nbt.getDouble("quality"); + clientDistance = nbt.getDouble("distance"); + clientLocked = nbt.getBoolean("locked"); + } + + private static SensorMode modeOf(SensorMode[] modes, int ordinal) { + return ordinal >= 0 && ordinal < modes.length ? modes[ordinal] : SensorMode.PASSIVE; + } + + // ---- persistence + + @Override + public NBTTagCompound writeToNBT(NBTTagCompound nbt) { + super.writeToNBT(nbt); + nbt.setInteger("mode", mode.ordinal()); + nbt.setInteger("energy", energy.getEnergyStored()); + nbt.setString("accessCode", accessCode); + return nbt; + } + + @Override + public void readFromNBT(NBTTagCompound nbt) { + super.readFromNBT(nbt); + SensorMode[] modes = SensorMode.values(); + int stored = nbt.getInteger("mode"); + mode = stored >= 0 && stored < modes.length ? modes[stored] : SensorMode.PASSIVE; + energy = new EnergyStorage(MIN_ENERGY_BUFFER, MIN_ENERGY_BUFFER, MIN_ENERGY_BUFFER, + Math.min(MIN_ENERGY_BUFFER, nbt.getInteger("energy"))); + accessCode = nbt.getString("accessCode"); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index 40e3438a6..3da6dd64d 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -17,6 +17,8 @@ import net.minecraftforge.common.capabilities.Capability; import net.minecraftforge.energy.CapabilityEnergy; import net.minecraftforge.energy.EnergyStorage; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.api.weapon.GunSpec; import zmaster587.advancedRocketry.api.weapon.TurretDriveState; import zmaster587.advancedRocketry.integration.vs.VSIntegration; @@ -232,6 +234,7 @@ public boolean fireOnce() { /** Everything that must be true before a round leaves, other than pointing the right way. */ private boolean canFireNow() { return !targetIsFriendly() + && isLockedWellEnoughToFire() && spec.isOperable() && mechanism.getDriveState().permitsFiring() && fireCooldown <= 0 @@ -249,13 +252,98 @@ public Vec3d getEffectiveTarget() { if (tracked != null) { // Aim at the middle of the body rather than its feet: a round at foot height passes // under everything that is not standing on flat ground. - return tracked.getPositionVector().addVector(0.0D, tracked.height * 0.5D, 0.0D); + return bodyCentre(tracked); } WeaponNetworkState state = networkState(); if (state != null && state.getTarget() != null) { return state.getTarget(); } - return localTarget; + if (localTarget != null) { + return localTarget; + } + // Nobody has said anything, so what the installation's sensor found is what there is. Last + // deliberately: an order a player gave stands until they retract it, and a sensor refreshing + // its contact every few ticks would otherwise overrule them continuously. + TargetTrack acquired = acquiredTrack(); + return acquired == null ? null : interceptOf(acquired); + } + + /** + * Where to point so the round and an acquired target arrive together. + * + *

    The contact's POSITION is refreshed from the entity itself when it can still be found — + * the sensor sweeps every few ticks and the mount follows every tick — while the VELOCITY comes + * from the sensor, because measuring it is what a fire-control sensor is for. Aboard a moving + * hull the shooter's own motion is taken out first: the round inherits the ship's velocity, so + * the lead that matters is the target's motion relative to the gun and not its motion over the + * ground.

    + */ + private Vec3d interceptOf(TargetTrack track) { + Vec3d position = track.getPosition(); + Entity live = entityById(track.getEntity()); + if (live != null) { + position = bodyCentre(live); + } + String shipId = TurretFireControl.shipIdAt(world, pos); + Vec3d muzzle = TurretFireControl.worldPositionOf(world, pos, shipId); + if (muzzle == null) { + return position; + } + Vec3d relative = track.getVelocity(); + if (shipId != null) { + double[] carried = VSIntegration.shipVelocityAtPointFor(world, shipId, muzzle.x, muzzle.y, + muzzle.z); + if (carried != null) { + relative = relative.subtract(new Vec3d(carried[0], carried[1], carried[2])); + } + } + return TurretFireControl.interceptPoint(muzzle, position, relative, spec.getMuzzleSpeed()); + } + + /** The contact the installation's sensor is currently handing this gun, or null. */ + public TargetTrack acquiredTrack() { + WeaponNetworkState state = networkState(); + return state == null || world == null ? null + : state.getAcquiredTrack(world.getTotalWorldTime()); + } + + /** + * Whether this gun is going on an acquisition rather than on an order. False under a hand and + * false whenever anybody — a console, a linker, this gun's own controls — has named a target: + * the acquisition is what is left when nothing else has been said. + */ + private boolean engagementIsAcquired() { + if (manualControl || trackedEntity() != null || localTarget != null) { + return false; + } + WeaponNetworkState state = networkState(); + if (state != null && state.getTarget() != null) { + return false; + } + return acquiredTrack() != null; + } + + /** + * Whether the contact is resolved well enough to shoot at. + * + *

    Only ever asked of an ACQUISITION. A target a player named is a target a player named — the + * sensor's opinion of how well it is resolved is not a veto over an order — so this gate exists + * for exactly the case the sensor created: a battery that can see something out there and cannot + * yet hold it well enough to hit it. That state is the reason to turn the illuminator on, and + * turning it on is the reason it costs you your silence.

    + */ + private boolean isLockedWellEnoughToFire() { + if (!engagementIsAcquired()) { + return true; + } + TargetTrack acquired = acquiredTrack(); + return acquired != null && acquired.isLocked( + ARConfiguration.getCurrentConfig().fireControlSensorLockQualityToFire); + } + + /** The middle of a body: a round at foot height passes under everything on uneven ground. */ + private static Vec3d bodyCentre(Entity entity) { + return entity.getPositionVector().addVector(0.0D, entity.height * 0.5D, 0.0D); } /** @@ -271,6 +359,11 @@ private Entity trackedEntity() { } else if (localTargetEntity != null) { id = localTargetEntity; } + return entityById(id); + } + + /** One entity by id, or null if it has died, logged out or was never there. */ + private Entity entityById(UUID id) { if (id == null || !(world instanceof WorldServer)) { return null; } @@ -285,14 +378,29 @@ private Entity trackedEntity() { * installation's access code is a friend for exactly as long as it carries it, and nothing here * keeps a list of who is friendly. A gun with no code set recognises nobody — deliberately, since * a battery that shoots nothing is indistinguishable from a broken one.

    + * + *

    An acquired contact was screened for this before it ever became a contact, so asking again + * here is depth rather than the primary check — and it is the half that answers within a tick + * rather than within a sweep, which is the difference between a boarder who produced the code + * and a boarder who produced it and was shot anyway.

    */ private boolean targetIsFriendly() { - Entity tracked = trackedEntity(); - return tracked != null - && com.github.stannismod.affs.util.CodeUtils.entityHasMatchingCode(tracked, + Entity engaged = engagedEntity(); + return engaged != null + && com.github.stannismod.affs.util.CodeUtils.entityHasMatchingCode(engaged, getEffectiveAccessCode()); } + /** The entity this gun is shooting at, whether it was named or found. Null for a point target. */ + private Entity engagedEntity() { + Entity named = trackedEntity(); + if (named != null) { + return named; + } + TargetTrack acquired = engagementIsAcquired() ? acquiredTrack() : null; + return acquired == null ? null : entityById(acquired.getEntity()); + } + /** The network's code when it has one, otherwise this gun's own. */ public String getEffectiveAccessCode() { WeaponNetworkState state = networkState(); diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java index 63cd9ea0f..2611ddeed 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java @@ -8,7 +8,9 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.AdvancedRocketryBlocks; +import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.integration.vs.VSIntegration; import zmaster587.advancedRocketry.subsystem.network.ISubsystemNetworkController; import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain; @@ -189,6 +191,17 @@ public Vec3d getTarget() { return state == null ? null : state.getTarget(); } + /** + * What the installation's sensor is currently holding, or null. Shown beside the assigned target + * rather than instead of it: the two are different things, and a crew that cannot see which one + * their guns are going on cannot tell an acquisition they want from one they need to override. + */ + public TargetTrack getAcquiredTrack() { + WeaponNetworkState state = network(); + return state == null || world == null ? null + : state.getAcquiredTrack(world.getTotalWorldTime()); + } + /** How many guns this console is commanding, as the last solve counted them. */ public int getGunCount() { WeaponNetworkState state = network(); @@ -280,6 +293,7 @@ public List getModules(int id, EntityPlayer player) { addReadout(modules, 10, 68, statusLine()); addReadout(modules, 10, 80, gunLine()); addReadout(modules, 10, 92, targetLine()); + addReadout(modules, 10, 104, sensorLine()); return modules; } @@ -305,6 +319,17 @@ private String targetLine() { : String.format("Target: %.0f, %.0f, %.0f", target.x, target.y, target.z); } + private String sensorLine() { + TargetTrack acquired = getAcquiredTrack(); + if (acquired == null) { + return "Sensor: no contact"; + } + boolean locked = acquired.isLocked(ARConfiguration.getCurrentConfig() + .fireControlSensorLockQualityToFire); + return String.format("Sensor: contact at %.0fm, lock %.2f%s", acquired.getDistance(), + acquired.getQuality(), locked ? "" : " (too poor to fire)"); + } + @Override public void onInventoryButtonPressed(int buttonId) { if (buttonId == BUTTON_HOLD_FIRE) { diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java index a6c9f468a..a8c908cf1 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java @@ -79,6 +79,62 @@ public static Vec3d aimDirection(World world, BlockPos mountPos, String shipId, return new Vec3d(localTarget[0], localTarget[1], localTarget[2]).subtract(mount); } + /** + * Where a block of a gun's installation actually is, in WORLD coordinates. The block's own + * position is its ship's if it has one, and a distance measured between a subspace address and a + * world one is a number with no meaning — so anything comparing a mount against something out in + * the world converts first. Null when the ship's transform is unavailable, which is the caller's + * cue to do nothing rather than to fall back on the unconverted position. + */ + public static Vec3d worldPositionOf(World world, BlockPos pos, String shipId) { + if (world == null || pos == null) { + return null; + } + if (shipId == null) { + return center(pos); + } + Vec3d local = center(pos); + double[] point = VSIntegration.toWorldFrameFor(world, shipId, local.x, local.y, local.z); + return point == null ? null : new Vec3d(point[0], point[1], point[2]); + } + + /** + * Where to point so that the round and the target arrive together. + * + *

    A gun handed a POINT misses a moving target by however far it moves during the round's + * flight, and no amount of tracking fixes that — by the time the mount has followed the target, + * the round is still going where the target was. The fix needs one thing a gun cannot know on + * its own: how fast the target is going. That comes from the sensor, and this is where it is + * spent.

    + * + *

    Solved by iteration rather than by the quadratic: four passes converge to well inside a + * block at any speed a gun is worth firing, and a target moving faster than the round simply + * fails to converge — which is the truth, and better than a closed form that returns a confident + * aim point at an interception that cannot happen.

    + * + * @param targetVelocity the target's velocity RELATIVE to the shooter, blocks per tick + * @param projectileSpeed the round's own muzzle speed, blocks per tick + */ + public static Vec3d interceptPoint(Vec3d muzzle, Vec3d targetPosition, Vec3d targetVelocity, + double projectileSpeed) { + if (muzzle == null || targetPosition == null) { + return targetPosition; + } + if (targetVelocity == null || projectileSpeed <= 0.0D + || targetVelocity.lengthVector() < 1.0E-6D) { + // A target that is not moving is its own intercept point. Said explicitly so that a + // still target is aimed at exactly, rather than at the result of four rounds of + // arithmetic on a zero. + return targetPosition; + } + Vec3d aim = targetPosition; + for (int pass = 0; pass < 4; pass++) { + double flightTicks = aim.distanceTo(muzzle) / projectileSpeed; + aim = targetPosition.add(targetVelocity.scale(flightTicks)); + } + return aim; + } + /** * Fire one round along {@code localAim} and answer the shot id, or {@code -1} if the substrate * refused it. {@code localAim} is in the mount's own frame — the same frame diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java index 66639d5c5..a8dc33b55 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/WeaponNetworkState.java @@ -1,6 +1,7 @@ package zmaster587.advancedRocketry.weapon; import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkState; /** @@ -15,6 +16,12 @@ *

    Aiming and shooting are different decisions: a battery tracking an approaching ship without * firing on it is the normal state of a defended station. So clearing the target is not how one * stops the shooting, and holding fire does not make the guns forget where the enemy is.

    + * + *

    What a human said outranks what a machine found

    + *

    An assigned target and an acquired one are kept in separate fields rather than one field + * written by both. A sensor refreshing its contact every few ticks would otherwise silently overrule + * the order a player gave a minute ago, and the player would have no way to hold a target the sensor + * disagrees about. So the acquisition is only consulted when nobody has said anything.

    */ public class WeaponNetworkState extends SubsystemNetworkState { @@ -22,6 +29,8 @@ public class WeaponNetworkState extends SubsystemNetworkState { private java.util.UUID targetEntity; private String accessCode = ""; private boolean holdFire; + private TargetTrack acquiredTrack; + private long acquiredExpiryTick; /** Where the network's guns are pointed, in WORLD coordinates, or null when nothing is assigned. */ public Vec3d getTarget() { @@ -73,6 +82,29 @@ public void setHoldFire(boolean holdFire) { this.holdFire = holdFire; } + /** + * What the network's sensor is currently holding, or null when it is holding nothing. + * + *

    Takes the world's clock because a track EXPIRES. A sensor publishes on its own cadence and + * can stop publishing for reasons a gun cannot see — its chunk unloaded, its block was broken, + * the whole installation lost power — and none of those should leave a battery firing at where + * something used to be. An acquisition that nobody is refreshing goes quiet by itself.

    + */ + public TargetTrack getAcquiredTrack(long worldTime) { + return acquiredTrack == null || worldTime > acquiredExpiryTick ? null : acquiredTrack; + } + + /** Publish a contact, good for {@code holdTicks} from now. Called by the sensor, nobody else. */ + public void setAcquiredTrack(TargetTrack track, long worldTime, int holdTicks) { + this.acquiredTrack = track; + this.acquiredExpiryTick = worldTime + Math.max(0, holdTicks); + } + + public void clearAcquiredTrack() { + this.acquiredTrack = null; + this.acquiredExpiryTick = 0L; + } + @Override public SubsystemNetworkState copy() { WeaponNetworkState copy = new WeaponNetworkState(); @@ -81,6 +113,8 @@ public SubsystemNetworkState copy() { copy.targetEntity = targetEntity; copy.accessCode = accessCode; copy.holdFire = holdFire; + copy.acquiredTrack = acquiredTrack; + copy.acquiredExpiryTick = acquiredExpiryTick; return copy; } } diff --git a/src/main/resources/assets/advancedrocketry/blockstates/firecontrolsensor.json b/src/main/resources/assets/advancedrocketry/blockstates/firecontrolsensor.json new file mode 100644 index 000000000..f421a3f70 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/firecontrolsensor.json @@ -0,0 +1,47 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:orientable", + "textures": { + "top": "libvulpes:blocks/machinegeneric", + "front": "advancedrocketry:blocks/atmospheredetector", + "side": "libvulpes:blocks/machinegeneric" + } + }, + "variants": { + "facing=north,state=false": [ + {} + ], + "facing=south,state=false": { + "model": "minecraft:orientable", + "y": 180 + }, + "facing=west,state=false": { + "model": "minecraft:orientable", + "y": 270 + }, + "facing=east,state=false": { + "model": "minecraft:orientable", + "y": 90 + }, + "facing=north,state=true": [ + {} + ], + "facing=south,state=true": { + "model": "minecraft:orientable", + "y": 180 + }, + "facing=west,state=true": { + "model": "minecraft:orientable", + "y": 270 + }, + "facing=east,state=true": { + "model": "minecraft:orientable", + "y": 90 + }, + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index b370d6e57..836dd9ab9 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -73,6 +73,8 @@ tile.gunCooling.name=Gun Cooling Jacket tile.weaponConsole.name=Weapons Console msg.weaponConsole.holdFire=Hold Fire msg.weaponConsole.clearTarget=Clear Target +tile.fireControlSensor.name=Fire Control Sensor +msg.fireControlSensor.mode=Passive/Active tile.advancedFlightComputer.name=Advanced Flight Computer tile.navigationComputer.name=Navigation Computer tile.electricArcFurnace.name=Electric Arc Furnace diff --git a/src/main/resources/assets/advancedrocketry/recipes/firecontrolsensor.json b/src/main/resources/assets/advancedrocketry/recipes/firecontrolsensor.json new file mode 100644 index 000000000..fa7580948 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/firecontrolsensor.json @@ -0,0 +1,30 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "igi", + "ici", + "iri" + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "g": { + "type": "forge:ore_dict", + "ore": "blockGlass" + }, + "c": { + "type": "forge:ore_dict", + "ore": "circuitBasic" + }, + "r": { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": { + "item": "advancedrocketry:fireControlSensor", + "count": 1 + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/SensorFriendIsNeverAcquiredE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/SensorFriendIsNeverAcquiredE2ETest.java new file mode 100644 index 000000000..d73f2cdeb --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/SensorFriendIsNeverAcquiredE2ETest.java @@ -0,0 +1,162 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Where a friend is spared: before there is a target, not at the trigger. + * + *

    A gun already declines to fire on somebody carrying the installation's code. This is the + * stronger statement one level up — an ally never becomes a CONTACT, so their name is never written + * anywhere a gun could read it, and no race, stale order or second console can talk the battery into + * shooting them. The two are deliberately both there: the sensor keeps allies out of the list, and + * the gun checks again at the trigger.

    + * + *

    Why this is a client test

    + *

    The credential is CARRIED, and only a player can carry one. A dedicated server has no players, + * so the whole mechanic is unreachable there.

    + * + *

    Asked about the player by name

    + *

    "The contact list is empty" would be the wrong question — a mob in a cave eighty blocks away + * would answer it without saying anything about this player. The probe is asked whether THIS player + * is a contact, and the control flips only the code they are carrying.

    + * + *

    Gated by {@code forge.test.client.enabled=true}; auto-skips on headless CI.

    + */ +public class SensorFriendIsNeverAcquiredE2ETest extends AbstractClientE2ETest { + + /** The harness's single client always joins under this name. */ + private static final String PLAYER = "ForgeTestClient"; + + /** Near enough to be held well by listening alone, far enough not to be inside the battery. */ + private static final int BATTERY_OFFSET = 20; + + private static final long TIMEOUT_MS = 25_000L; + + @Test + public void aPlayerCarryingTheCodeNeverBecomesAContactAndOneWhoIsNotDoes() throws Exception { + // Built around the player rather than the player moved to it: a tp into a cleared site drops + // him, and a battery tracking a falling target is a different experiment. + double[] player = playerPosition(); + int px = (int) Math.floor(player[0]); + int py = (int) Math.floor(player[1]); + int pz = (int) Math.floor(player[2]); + int bx = px + BATTERY_OFFSET; + + server("gamerule doMobSpawning false"); + server("artest chunk warmup 0 " + ((px - 16) >> 4) + " " + ((pz - 16) >> 4) + " " + + ((bx + 16) >> 4) + " " + ((pz + 16) >> 4)); + // The whole corridor, not just the battery's footprint: the muzzle sits five and a half + // blocks along the aim and the line-of-fire check refuses a shot into terrain. + server("artest fill 0 " + (px - 2) + " " + py + " " + (pz - 2) + " " + (bx + 4) + " " + + (py + 8) + " " + (pz + 2) + " minecraft:air"); + server("artest chunk forceload 0 " + (bx >> 4) + " " + (pz >> 4)); + buildBattery(bx, py, pz); + + String built = awaitOperable(bx, py, pz); + assertTrue("the gun never assembled, so nothing below would mean anything: " + built, + built.contains("\"operable\":true")); + server("artest turret charge 0 " + bx + " " + py + " " + pz); + server("artest sensor charge 0 " + (bx + 1) + " " + py + " " + pz); + server("artest turret code 0 " + bx + " " + py + " " + pz + " ALPHA"); + server("artest sensor code 0 " + (bx + 1) + " " + py + " " + pz + " ALPHA"); + + // The player carries the installation's own code, and is therefore not a target at all. + server("clear " + PLAYER); + server("give " + PLAYER + " affs:code_device 1 0 {affs_code:\"ALPHA\"}"); + bot().waitTicks(80); + + String friendly = sees(bx + 1, py, pz); + assertTrue("a player carrying the installation's code entered the target list: " + friendly, + friendly.contains("\"seen\":false")); + String gunOnFriend = read(bx, py, pz); + assertEquals("the battery fired at a player carrying its own code: " + gunOnFriend, 0, + extractInt(gunOnFriend, "shots")); + + // Same player, same place, same battery. Only the code changes. + server("clear " + PLAYER); + server("give " + PLAYER + " affs:code_device 1 0 {affs_code:\"BRAVO\"}"); + + String hostile = await(() -> sees(bx + 1, py, pz), s -> s.contains("\"seen\":true")); + assertTrue("the sensor would not acquire the player carrying somebody else's code either —" + + " then the exclusion above was not about the credential: " + hostile, + hostile.contains("\"seen\":true")); + + String engaged = await(() -> read(bx, py, pz), s -> extractInt(s, "shots") >= 1); + assertTrue("the battery never fired on a contact its own sensor had acquired: " + engaged, + extractInt(engaged, "shots") >= 1); + assertTrue("it fired, but not on an acquisition: " + engaged, + engaged.contains("\"acquired\":true")); + } + + // ---- scenario construction + + private void buildBattery(int bx, int by, int bz) throws Exception { + place("advancedrocketry:turret", bx, by, bz); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", bx, by + i, bz); + } + place("advancedrocketry:gunCooling", bx, by, bz + 1); + place("advancedrocketry:gunCooling", bx, by, bz - 1); + place("advancedrocketry:fireControlSensor", bx + 1, by, bz); + } + + /** Where the harness's player actually is. Nothing here moves him. */ + private double[] playerPosition() throws Exception { + String json = server("artest player position-of " + PLAYER); + return new double[] {readDouble(json, "playerPosX"), readDouble(json, "playerPosY"), + readDouble(json, "playerPosZ")}; + } + + private String awaitOperable(int bx, int by, int bz) throws Exception { + return await(() -> read(bx, by, bz), s -> s.contains("\"operable\":true")); + } + + private String await(ProbeRead probe, java.util.function.Predicate done) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = probe.read(); + while (System.currentTimeMillis() < deadline && !done.test(state)) { + bot().waitTicks(10); + state = probe.read(); + } + return state; + } + + private interface ProbeRead { + String read() throws Exception; + } + + private String sees(int bx, int by, int bz) throws Exception { + return server("artest sensor sees 0 " + bx + " " + by + " " + bz + " " + PLAYER); + } + + private String read(int bx, int by, int bz) throws Exception { + return server("artest turret read 0 " + bx + " " + by + " " + bz); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = server("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } + + private static double readDouble(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?[\\d.eE+]+)").matcher(json); + assertTrue("no " + key + " in: " + json, m.find()); + return Double.parseDouble(m.group(1)); + } + + private String server(String command) throws Exception { + return String.join("\n", serverClient().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/FireControlSensorE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/FireControlSensorE2ETest.java new file mode 100644 index 000000000..f2005022f --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/FireControlSensorE2ETest.java @@ -0,0 +1,264 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The step that was missing: nobody names the target. + * + *

    Everything the gun system could do before this class needed a human in the loop — a linker, a + * console, a probe. A battery could track what it was told about and could not notice anything. The + * sensor is the block that closes that, and these two tests are the two things it has to be true of: + * it finds a hostile on its own, and it cannot hold everything equally well.

    + * + *

    Each test carries its own control

    + *

    "The gun fired" is worthless on its own — a gun fires for a dozen reasons. So the first test + * watches the SAME battery and the SAME zombie with acquisition switched off and then on, and the + * second watches the same pair through a listening sensor and then an illuminating one. Only the one + * variable moves, and the state before it moves is asserted rather than assumed: an unbuilt gun, a + * flat battery or a mount stuck at the edge of its arc is silent too, and none of those is what is + * being measured here.

    + * + *

    Why the site is roofed and floored

    + *

    A zombie in daylight burns, and a burning body is a beacon — it would sail over any lock + * threshold and turn the second test green for exactly the wrong reason. Building a box removes the + * question rather than relying on the world's clock.

    + */ +public class FireControlSensorE2ETest extends AbstractSharedServerTest { + + /** This class's own site, clear of every other server test's. */ + private static final int X = 9400, Y = 80, Z = 9400; + + /** Where a contact is comfortably lockable by listening alone. */ + private static final int NEAR_TARGET = 18; + + /** Far enough that a cool body's own radiance no longer resolves it. */ + private static final int FAR_TARGET = 60; + + private static final long TIMEOUT_MS = 25_000L; + + /** Long enough for a gun that is going to fire to have fired several times. */ + private static final long QUIET_WATCH_MS = 5_000L; + + /** + * A battery nobody has told anything acquires a hostile that walks into range, and stops doing + * so the moment acquisition is switched off — which is what says the sensor is the reason. + */ + @Test + public void aSensorAcquiresAHostileThatNobodyNamed() throws Exception { + int base = X; + buildSite(base); + buildBattery(base); + String gun = awaitOperable(base); + assertTrue("the gun never assembled, so its silence would say nothing: " + gun, + gun.contains("\"operable\":true")); + assertTrue("something has already given this gun a target — then nothing below is about" + + " acquisition: " + gun, gun.contains("\"hasTarget\":false")); + + // The control first: the same battery, the same target, acquisition switched off. + config("enableFireControlSensor", "false"); + spawnZombie(base + NEAR_TARGET); + charge(base); + Thread.sleep(QUIET_WATCH_MS); + String silent = read(base); + assertEquals("a battery with acquisition disabled fired at something nobody named it — the" + + " config flag does not disable the mechanic: " + silent, 0, extractInt(silent, "shots")); + assertTrue("and it should not be holding a contact either: " + silent, + silent.contains("\"acquired\":false")); + + // One variable moves. + config("enableFireControlSensor", "true"); + String sensor = awaitSensorContact(base + 1); + assertTrue("the sensor never found the zombie standing " + NEAR_TARGET + " blocks in front" + + " of it: " + sensor, sensor.contains("\"hasContact\":true")); + + charge(base); + assertTrue("the battery never fired on a target its own sensor was holding: " + + read(base), awaitShots(base, 1) >= 1); + assertTrue("the gun fired, but not on an acquisition — something else gave it a target: " + + read(base), read(base).contains("\"acquired\":true")); + } + + /** + * The trade the whole passive/active split exists for: a cool body far enough away is SEEN by a + * listening sensor and cannot be held well enough to shoot at. Illuminating it holds it — and + * that is the only thing that changes between the two halves of this test. + */ + @Test + public void aCoolTargetTooFarToHoldByListeningIsHeldByIlluminating() throws Exception { + int base = X + 200; + buildSite(base); + buildBattery(base); + config("enableFireControlSensor", "true"); + config("fireControlSensorRadius", "96.0"); + config("fireControlSensorLockQualityToFire", "0.25"); + config("fireControlSensorActiveLockQuality", "0.95"); + + String gun = awaitOperable(base); + assertTrue("the gun never assembled: " + gun, gun.contains("\"operable\":true")); + spawnZombie(base + FAR_TARGET); + charge(base); + + // Listening. It hears the zombie and cannot resolve it. + String listening = awaitSensorContact(base + 1); + assertTrue("the listening sensor did not even detect the zombie, so nothing below is about" + + " the LOCK: " + listening, listening.contains("\"hasContact\":true")); + assertTrue("a cool body at " + FAR_TARGET + " blocks was locked by listening alone — then" + + " illuminating buys nothing and the mode is decoration: " + listening, + listening.contains("\"locked\":false")); + + String tracking = awaitGunAcquired(base); + assertTrue("the gun is not holding the sensor's contact: " + tracking, + tracking.contains("\"acquired\":true")); + assertTrue("the gun is not even pointing at it — then its silence is about geometry rather" + + " than about the lock: " + tracking, tracking.contains("\"onTarget\":true")); + + int before = extractInt(read(base), "shots"); + Thread.sleep(QUIET_WATCH_MS); + assertEquals("the battery fired on a contact it cannot hold: a poor track must mean tracking" + + " without shooting, or the lock threshold is not doing anything: " + read(base), + before, extractInt(read(base), "shots")); + + // Same sensor, same zombie, same distance — it switches the light on. + exec("artest sensor charge 0 " + (base + 1) + " " + Y + " " + Z); + assertTrue("the probe could not switch the sensor to active", + exec("artest sensor mode 0 " + (base + 1) + " " + Y + " " + Z + " active") + .contains("\"ok\":true")); + + String illuminating = awaitSensorLocked(base + 1); + assertTrue("illuminating did not produce a lock on the same target at the same range: " + + illuminating, illuminating.contains("\"locked\":true")); + assertTrue("an actively illuminating sensor must be emitting — that is its whole price: " + + illuminating, illuminating.contains("\"emitting\":true")); + + charge(base); + assertTrue("the battery still would not fire once the contact was properly held: " + + read(base), awaitShots(base, before + 1) > before); + } + + // ---- scenario construction + + /** + * A gun and a sensor, touching, which is all it takes to be one network: no cable, no console. + * The sensor is the only thing here that was not already possible. + */ + private void buildBattery(int bx) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + place("advancedrocketry:fireControlSensor", bx + 1, Y, Z); + exec("artest sensor charge 0 " + (bx + 1) + " " + Y + " " + Z); + } + + /** A floored, roofed, cleared corridor: no daylight, no terrain in the line of fire, no falling. */ + private void buildSite(int bx) throws Exception { + int far = bx + FAR_TARGET + 12; + // A roofed corridor is a dark room, and a dark room breeds contacts nobody put there. The + // only thing this battery is allowed to notice is the zombie this test spawns. + exec("gamerule doMobSpawning false"); + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((bx - 16) >> 4) + " " + + ((Z - 16) >> 4) + " " + ((far + 16) >> 4) + " " + ((Z + 16) >> 4)) + .contains("\"ok\":true")); + fill(bx - 4, Y, Z - 4, far, Y + 6, Z + 4, "minecraft:air"); + fill(bx - 4, Y - 1, Z - 4, far, Y - 1, Z + 4, "minecraft:stone"); + fill(bx - 4, Y + 7, Z - 4, far, Y + 7, Z + 4, "minecraft:stone"); + // Everything has to keep ticking, including the zombie at the far end. + for (int cx = (bx - 16) >> 4; cx <= (far + 16) >> 4; cx++) { + assertTrue("could not hold a chunk", exec("artest chunk forceload 0 " + cx + " " + + (Z >> 4)).contains("\"ok\":true")); + } + } + + private void spawnZombie(int bx) throws Exception { + String resp = exec("artest entity spawn 0 " + (bx + 0.5D) + " " + Y + " " + (Z + 0.5D) + + " minecraft:zombie"); + assertTrue("could not spawn the target: " + resp, resp.contains("\"spawned\":true")); + } + + private void config(String key, String value) throws Exception { + String resp = exec("artest config set " + key + " " + value); + assertTrue("could not set " + key + ": " + resp, resp.contains("\"ok\":true")); + } + + private void charge(int bx) throws Exception { + exec("artest turret charge 0 " + bx + " " + Y + " " + Z); + } + + // ---- waiting on the world + + private String awaitOperable(int bx) throws Exception { + return await(() -> read(bx), state -> state.contains("\"operable\":true")); + } + + private String awaitGunAcquired(int bx) throws Exception { + return await(() -> read(bx), state -> state.contains("\"acquired\":true") + && state.contains("\"onTarget\":true")); + } + + private String awaitSensorContact(int bx) throws Exception { + return await(() -> sensorRead(bx), state -> state.contains("\"hasContact\":true")); + } + + private String awaitSensorLocked(int bx) throws Exception { + return await(() -> sensorRead(bx), state -> state.contains("\"locked\":true")); + } + + private int awaitShots(int bx, int wanted) throws Exception { + String state = await(() -> read(bx), s -> extractInt(s, "shots") >= wanted); + return extractInt(state, "shots"); + } + + /** Poll one probe until it says what we are waiting for, or the budget runs out. */ + private String await(ProbeRead probe, java.util.function.Predicate done) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = probe.read(); + while (System.currentTimeMillis() < deadline && !done.test(state)) { + Thread.sleep(250L); + state = probe.read(); + } + return state; + } + + private interface ProbeRead { + String read() throws Exception; + } + + // ---- probes + + private String read(int bx) throws Exception { + return exec("artest turret read 0 " + bx + " " + Y + " " + Z); + } + + private String sensorRead(int bx) throws Exception { + return exec("artest sensor read 0 " + bx + " " + Y + " " + Z); + } + + private void fill(int x1, int y1, int z1, int x2, int y2, int z2, String block) throws Exception { + String resp = exec("artest fill 0 " + x1 + " " + y1 + " " + z1 + " " + x2 + " " + y2 + " " + + z2 + " " + block); + assertTrue("could not fill with " + block + ": " + resp, resp.contains("\"ok\":true")); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + y + "," + z + ": " + resp, + resp.contains("\"placed\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SignatureModelTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SignatureModelTest.java new file mode 100644 index 000000000..900b38c1f --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SignatureModelTest.java @@ -0,0 +1,126 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.sensor.SignatureModel; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The one claim this model exists to make: how far away a thing can be noticed and how well it + * can be held are different questions with different answers. + * + *

    Everything below is a way of failing if those two ever collapse into one number. Nothing here + * pins a constant — the reference temperature, the reference range and the blocks-per-watt scale are + * balance, and a test that pinned them would go red the first time somebody tuned the mechanic + * without changing anything about how it works. What is pinned is the SHAPE: which variable each + * term depends on, and which it must be blind to.

    + */ +public class SignatureModelTest { + + private static final double EPSILON = 1.0E-9D; + + /** + * The whole design in one scenario. Two ships shed exactly the same watts — one a large + * cool array, the other a compact array sixteen times hotter and sixteen times smaller. They are + * therefore noticed at the same range, and they are not remotely the same target: the compact + * hot one is held sixteen times better at any distance. + * + *

    This fails the moment either term is computed from the other's input — if range stopped + * depending on area, or quality started to.

    + */ + @Test + public void twoTargetsSheddingTheSameWattsAreNoticedAlikeAndHeldNothingAlike() { + double coolTemperature = 300.0D, coolArea = 160.0D; + double hotTemperature = coolTemperature * 2.0D; // radiance ×16 + double hotArea = coolArea / 16.0D; // so the total power is identical + + assertEquals("the scenario is only about the difference between the two terms if the total" + + " power really is equal", + SignatureModel.radiatedPower(coolTemperature, coolArea), + SignatureModel.radiatedPower(hotTemperature, hotArea), 1.0E-6D); + + assertEquals("equal total power must mean an equal detection range — that term may not see" + + " temperature except through the power it produces", + SignatureModel.detectionRangeBlocks(coolTemperature, coolArea), + SignatureModel.detectionRangeBlocks(hotTemperature, hotArea), 1.0E-6D); + + double range = 200.0D; + double coolLock = SignatureModel.passiveQuality(coolTemperature, range); + double hotLock = SignatureModel.passiveQuality(hotTemperature, range); + assertTrue("this comparison means nothing if either quality is clamped", + coolLock > 0.0D && hotLock > 0.0D && hotLock < 1.0D); + assertEquals("the hotter array must be sixteen times the lock at the same range: quality is" + + " radiance, and radiance is temperature to the fourth — if area leaked into this" + + " term, these two would be equal and the build trade would be gone", + 16.0D, hotLock / coolLock, 1.0E-6D); + } + + /** + * The two terms disagree about which of two targets is the better one, and that disagreement is + * the mechanic. A large cool radiator sheds MORE total power than a small hot one — so it is + * noticed from further away — while the small hot one is the better lock at any given range. + */ + @Test + public void aLargeCoolTargetIsSeenFurtherOffAndHeldWorseThanASmallHotOne() { + double coolTemperature = 350.0D, coolArea = 400.0D; + double hotTemperature = 900.0D, hotArea = 1.0D; + assertTrue("this scenario needs the cool one to be the brighter total emitter, or it is not" + + " testing the disagreement at all", + SignatureModel.radiatedPower(coolTemperature, coolArea) + > SignatureModel.radiatedPower(hotTemperature, hotArea)); + + assertTrue("the bigger total emitter must be detectable further away", + SignatureModel.detectionRangeBlocks(coolTemperature, coolArea) + > SignatureModel.detectionRangeBlocks(hotTemperature, hotArea)); + assertTrue("and the hotter one must still be the better lock at the same range — if the" + + " brighter total emitter also locked better, there would be one number here", + SignatureModel.passiveQuality(hotTemperature, 60.0D) + > SignatureModel.passiveQuality(coolTemperature, 60.0D)); + } + + /** Range falls off with the square: twice as far is a quarter as well held. */ + @Test + public void passiveQualityFallsWithTheSquareOfTheRange() { + double near = SignatureModel.passiveQuality(400.0D, 40.0D); + double far = SignatureModel.passiveQuality(400.0D, 80.0D); + assertTrue("this scenario needs a quality that is not already clamped at either end", + near > 0.0D && near < 1.0D && far > 0.0D); + assertEquals("doubling the range must quarter the quality", near / 4.0D, far, near * 1.0E-6D); + } + + /** + * The reason the active mode exists: a cold, quiet thing that passive listening cannot hold is + * held perfectly well the moment you illuminate it — at the price of illuminating. + */ + @Test + public void illuminatingHoldsAColdTargetThatListeningCannot() { + double coldTarget = 280.0D; + double range = 90.0D; + double listening = SignatureModel.passiveQuality(coldTarget, range); + double illuminating = SignatureModel.activeQuality(range, 128.0D, 0.95D); + + assertTrue("a cold target at range must be nearly unresolvable by listening alone, or going" + + " dark buys a target nothing: " + listening, listening < 0.1D); + assertTrue("and illuminating must hold it, or going active buys the shooter nothing: " + + illuminating, illuminating > 0.5D); + } + + /** Nothing is invisible: silence moves the line at which a thing is noticed, it does not erase it. */ + @Test + public void aSilentColdBodyIsStillDetectableSomewhere() { + double range = SignatureModel.detectionRangeBlocks(SignatureModel.AMBIENT_BODY_KELVIN, 2.0D); + assertTrue("an ordinary warm body radiates above the background and must be detectable at" + + " some finite range: " + range, range > 0.0D); + } + + /** Outside the envelope an illuminator holds nothing at all — the radius is a real limit. */ + @Test + public void illuminationStopsAtTheEdgeOfTheEnvelope() { + assertEquals("a contact beyond the sensor's radius is not held, however bright the beam", + 0.0D, SignatureModel.activeQuality(130.0D, 128.0D, 0.95D), EPSILON); + assertTrue("and just inside it is held, so the zero above is about the edge and not about" + + " the whole mode being dead", + SignatureModel.activeQuality(120.0D, 128.0D, 0.95D) > 0.0D); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TurretInterceptTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretInterceptTest.java new file mode 100644 index 000000000..2601946e4 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretInterceptTest.java @@ -0,0 +1,89 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.weapon.TurretFireControl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Leading a moving target: the arithmetic that turns "where it is" into "where to shoot". + * + *

    The contract is not a formula, it is an arrival: a round leaving at the muzzle speed and the + * target travelling at its own velocity must reach the aim point at the same moment. Each test + * below checks that arrival, so any solver that gets there — iterative, closed-form, or something + * else entirely — passes.

    + */ +public class TurretInterceptTest { + + private static final Vec3d MUZZLE = new Vec3d(0.0D, 0.0D, 0.0D); + + /** + * A target that is not moving is its own aim point. Worth its own test because a lead applied to + * a stationary target is a miss that looks exactly like a correct implementation right up until + * somebody checks. + */ + @Test + public void aStillTargetIsAimedAtExactly() { + Vec3d target = new Vec3d(40.0D, 0.0D, 0.0D); + Vec3d aim = TurretFireControl.interceptPoint(MUZZLE, target, Vec3d.ZERO, 2.0D); + assertEquals("a still target must be shot at, not led", 0.0D, aim.distanceTo(target), 1.0E-9D); + } + + /** + * A target crossing the line of fire is led — and led by the right amount: the round and the + * target arrive together, which is the only statement that is true of a correct lead and false of + * a plausible one. + */ + @Test + public void theRoundAndACrossingTargetArriveTogether() { + Vec3d target = new Vec3d(60.0D, 0.0D, 0.0D); + Vec3d velocity = new Vec3d(0.0D, 0.0D, 0.35D); + double muzzleSpeed = 2.0D; + + Vec3d aim = TurretFireControl.interceptPoint(MUZZLE, target, velocity, muzzleSpeed); + assertTrue("nothing was led at all — the aim point is still the target's own position", + aim.distanceTo(target) > 1.0D); + + double roundFlightTicks = aim.distanceTo(MUZZLE) / muzzleSpeed; + Vec3d whereTheTargetWillBe = target.add(velocity.scale(roundFlightTicks)); + assertEquals("the round arrives at a place the target is not: aim " + aim + " vs target " + + whereTheTargetWillBe, 0.0D, aim.distanceTo(whereTheTargetWillBe), 0.05D); + } + + /** A target running away is led further than one crossing; a closer one, less. Both arrive. */ + @Test + public void aRecedingTargetIsLedFurtherThanAnApproachingOne() { + Vec3d target = new Vec3d(50.0D, 0.0D, 0.0D); + double muzzleSpeed = 3.0D; + + Vec3d receding = TurretFireControl.interceptPoint(MUZZLE, target, + new Vec3d(0.4D, 0.0D, 0.0D), muzzleSpeed); + Vec3d approaching = TurretFireControl.interceptPoint(MUZZLE, target, + new Vec3d(-0.4D, 0.0D, 0.0D), muzzleSpeed); + + assertTrue("a target running away must be shot at further out than where it is: " + receding, + receding.distanceTo(MUZZLE) > target.distanceTo(MUZZLE)); + assertTrue("and one closing must be shot at nearer: " + approaching, + approaching.distanceTo(MUZZLE) < target.distanceTo(MUZZLE)); + + for (Vec3d aim : new Vec3d[] {receding, approaching}) { + double flight = aim.distanceTo(MUZZLE) / muzzleSpeed; + Vec3d along = aim.subtract(target); + assertEquals("the arrival does not line up for " + aim, flight * 0.4D, + along.lengthVector(), 0.05D); + } + } + + /** + * A gun with no muzzle speed cannot lead anything, and must say so by aiming at the target + * rather than by dividing by zero. + */ + @Test + public void aGunWithNoMuzzleSpeedAimsAtTheTargetItself() { + Vec3d target = new Vec3d(20.0D, 0.0D, 0.0D); + Vec3d aim = TurretFireControl.interceptPoint(MUZZLE, target, new Vec3d(0.5D, 0.0D, 0.0D), 0.0D); + assertEquals(0.0D, aim.distanceTo(target), 1.0E-9D); + } +} From d21cba109d84773d63ee5f316c5873fd35f5a0a6 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 17:37:12 +0300 Subject: [PATCH 12/35] feat: a shot-up gun turns slowly, then seizes, and still shoots - a mount reads its own block's condition; nothing notifies it - damaged, then seized: a ladder of named states, not a scalar - a seized mount holds its bearing and keeps firing down it - a damaged part gives less of what it gives, negatives included - the wear flag gates accrual only, so battle damage is never free --- .../advancedRocketry/api/ARConfiguration.java | 14 ++ .../advancedRocketry/api/weapon/GunSpec.java | 43 +++- .../api/weapon/TurretDriveState.java | 27 +++ .../advancedRocketry/damage/DamageState.java | 20 ++ .../advancedRocketry/entity/EntityRocket.java | 8 +- .../tile/weapon/TileTurret.java | 29 +++ .../advancedRocketry/util/StorageChunk.java | 15 +- .../advancedRocketry/weapon/GunAssembly.java | 7 + .../weapon/TurretMechanism.java | 32 ++- .../server/TurretDamageDegradesE2ETest.java | 189 ++++++++++++++++++ .../test/unit/GunPartConditionTest.java | 93 +++++++++ .../test/unit/TurretConditionTest.java | 113 +++++++++++ 12 files changed, 569 insertions(+), 21 deletions(-) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/TurretDamageDegradesE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/GunPartConditionTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/TurretConditionTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index d642ad141..152ece12c 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -464,6 +464,18 @@ public class ARConfiguration { */ @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) @@ -723,6 +735,8 @@ public static void loadPreInit() { 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, "Enable rocket part wear and exploding chance.").getBoolean(); arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java index 7aa27f550..6fa14b1ca 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java @@ -169,56 +169,79 @@ public static final class Builder { private ImpactKind kind = ImpactKind.KINETIC; 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 += Math.max(0.0D, blocksPerTick); + this.muzzleSpeed += scaled(Math.max(0.0D, blocksPerTick)); return this; } public Builder addImpactEnergy(int energy) { - this.impactEnergy += Math.max(0, 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 - Math.max(0, ticks)); + this.fireIntervalTicks = Math.max(1, this.fireIntervalTicks - scaled(Math.max(0, ticks))); return this; } public Builder addEnergyPerShot(int fe) { - this.energyPerShot += Math.max(0, fe); + this.energyPerShot += scaled(Math.max(0, fe)); return this; } public Builder addHeatPerShot(int heat) { - this.heatPerShot += Math.max(0, heat); + this.heatPerShot += scaled(Math.max(0, heat)); return this; } public Builder addHeatCapacity(int heat) { - this.heatCapacity += Math.max(0, heat); + this.heatCapacity += scaled(Math.max(0, heat)); return this; } public Builder addCoolingPerTick(int heat) { - this.coolingPerTick += Math.max(0, 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 + 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 + 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 + ticks); + this.lifetimeTicks = Math.max(1, this.lifetimeTicks + scaled(ticks)); return this; } diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java index b90e92344..285111669 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/TurretDriveState.java @@ -37,6 +37,33 @@ public enum TurretDriveState { /** 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; diff --git a/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java index 3603e013b..9fbe70dbe 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/DamageState.java @@ -42,6 +42,26 @@ public static int getMaxStage(World world, BlockPos pos) { return wear != null ? wear.getMaxStage() : DEFAULT_MAX_STAGE; } + /** + * How far gone the block at {@code pos} is, on 0..1 — pristine to destroyed. + * + *

    The one number a SUBSYSTEM should read. A machine that degrades with condition does not + * care how many stages this particular block happens to have, and two blocks with different + * stage counts must degrade comparably; asking in stages would put that arithmetic in every + * consumer and let them disagree.

    + * + *

    It says nothing about what did the damage, deliberately: there is ONE stage axis, so a + * shell and a thousand hours of use are the same fact by the time they reach here.

    + */ + public static double getDamageFraction(World world, BlockPos pos) { + int max = getMaxStage(world, pos); + if (max <= 0) { + return 0.0D; + } + double fraction = (double) getStage(world, pos) / (double) max; + return fraction < 0.0D ? 0.0D : (fraction > 1.0D ? 1.0D : fraction); + } + /** * Write a stage back to whichever home owns it. Server side only — the client is told about damage * through the block's own sync, never by writing a stage of its own. diff --git a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java index 7e3e4cafb..d6d3fd094 100644 --- a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java +++ b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java @@ -2683,10 +2683,14 @@ public void launch() { } } - if (ARConfiguration.getCurrentConfig().partsWearSystem) { + // Condition consequences, unconditionally. There is ONE stage axis: what put a stage on a + // seat or a tank — a long career or a shell — is not knowable here and must not change the + // answer. `partsWearSystem` gates where wear ACCRUES; a rocket shot up on the pad has to + // fly like a rocket shot up on the pad whatever that flag says. + { ARConfiguration cfg = ARConfiguration.getCurrentConfig(); - // A worn seat is unsafe: refuse a CREWED launch (automated rockets fly). + // A damaged seat is unsafe: refuse a CREWED launch (automated rockets fly). if (!this.getPassengers().isEmpty() && storage.hasCriticallyWornSeat(cfg.wearSeatBlockStageFraction)) { setError("error.rocket.seatWorn"); return; diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index 3da6dd64d..2b9c0de79 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -21,6 +21,7 @@ import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.api.weapon.GunSpec; import zmaster587.advancedRocketry.api.weapon.TurretDriveState; +import zmaster587.advancedRocketry.damage.DamageState; import zmaster587.advancedRocketry.integration.vs.VSIntegration; import zmaster587.advancedRocketry.subsystem.network.ISubsystemSink; import zmaster587.advancedRocketry.subsystem.network.SubsystemNetworkDomain; @@ -78,6 +79,8 @@ public class TileTurret extends TileEntity implements ITickable, ISubsystemSink, private int fireCooldown; private int heat; private boolean registered; + /** The condition this gun last saw itself in, so a change can re-walk the build exactly once. */ + private double lastConditionFraction; private Vec3d localTarget; private UUID localTargetEntity; @@ -124,6 +127,8 @@ public void update() { assemblyDirty = true; } + readOwnCondition(); + if (assemblyDirty) { GunAssembly assembly = GunAssembly.scan(world, pos); spec = assembly.getSpec(); @@ -169,6 +174,30 @@ public void update() { launch(shipId); } + /** + * Read this mount's own condition and let it drive the traverse. + * + *

    PULLED, not pushed: nothing tells a gun it was hit. The stage of the block it lives in is a + * fact sitting in the world, and one lookup a tick is cheaper than a subscription — it also + * survives a save, a chunk reload and the ship being reassembled for free, because the stage + * does. Nothing about damage appears in this class beyond the two lines below; nothing about + * guns appears in the damage engine at all.

    + * + *

    A change in the controller's own condition also re-walks the build, because the parts' + * conditions are read during that walk and a shell that reached the controller has almost + * certainly been through some of them.

    + */ + private void readOwnCondition() { + double fraction = DamageState.getDamageFraction(world, pos); + if (Math.abs(fraction - lastConditionFraction) > 1.0E-6D) { + lastConditionFraction = fraction; + assemblyDirty = true; + } + ARConfiguration config = ARConfiguration.getCurrentConfig(); + mechanism.setDamageDriveState(TurretDriveState.fromDamage(fraction, + config.turretDerateDamageFraction, config.turretJamDamageFraction)); + } + /** * Send one round down the current bearing and answer whether it left, spending nothing unless it * did. Extracted so the automatic path and the manual one cannot drift apart: a manned gun that diff --git a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java index 70213721e..d2a2c4998 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java +++ b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java @@ -962,14 +962,17 @@ public TileGuidanceComputer getGuidanceComputer() { } /** - * Thrust multiplier for a motor at the given position based on its wear - * stage: 1.0 when pristine, (1 - wearThrustPenaltyMax) when fully worn. - * Returns 1.0 when the wear system is off or the block has no wear state. + * Thrust multiplier for a motor at the given position based on its condition: 1.0 when pristine, + * (1 - wearThrustPenaltyMax) when fully gone. Returns 1.0 when the block has no stage. + * + *

    Deliberately NOT gated on {@code partsWearSystem}. There is one stage axis — a stage put + * there by a thousand hours of flying and one put there by a shell are the same number, and this + * method cannot tell them apart nor should it. The flag gates where wear ACCRUES + * ({@link #damageParts}); reading it here as well would mean a modpack that turned wear off got + * motors that shrug off battle damage, which is a different and much larger decision than the one + * the flag advertises.

    */ private float wearThrustFactor(BlockPos pos) { - if (!ARConfiguration.getCurrentConfig().partsWearSystem) { - return 1f; - } double maxPenalty = ARConfiguration.getCurrentConfig().wearThrustPenaltyMax; if (maxPenalty <= 0) { return 1f; diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java b/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java index 44334926e..adb7147cd 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/GunAssembly.java @@ -8,6 +8,7 @@ import net.minecraft.world.World; import zmaster587.advancedRocketry.api.weapon.GunSpec; import zmaster587.advancedRocketry.api.weapon.IGunPart; +import zmaster587.advancedRocketry.damage.DamageState; import zmaster587.advancedRocketry.tile.weapon.TileTurret; import java.util.ArrayDeque; @@ -93,7 +94,13 @@ public static GunAssembly scan(World world, BlockPos origin) { continue; } IGunPart part = (IGunPart) block; + // A part in poor condition gives less of what it gives. Read here rather than pushed + // from the damage engine: the stage is a fact in the world, and the walk is already + // standing on the block. A part damaged to nothing still COUNTS — it is bolted on, it + // is in the way, and it is something to repair; it simply contributes almost nothing. + builder.withContributionScale(1.0D - DamageState.getDamageFraction(world, pos)); part.contributeTo(builder, world, pos, state); + builder.withContributionScale(1.0D); builder.countPart(); counted++; reach = Math.max(reach, axisDistance(origin, pos)); diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java b/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java index 971824926..11d881f74 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/TurretMechanism.java @@ -42,6 +42,7 @@ public class TurretMechanism { private boolean commanded; private boolean saturated; private TurretDriveState driveState = TurretDriveState.WORKING; + private TurretDriveState damageDriveState = TurretDriveState.WORKING; private final double minPitch; private final double maxPitch; @@ -95,13 +96,14 @@ public boolean hasCommand() { * derated by the drive state. Answers whether the mount is now pointing where it was told. */ public boolean tick(double ratePerTick) { - if (driveState == TurretDriveState.FREEWHEELING) { + TurretDriveState drive = getDriveState(); + if (drive == TurretDriveState.FREEWHEELING) { // No brake: it turns because nothing is holding it, not because anybody asked. yaw = wrapDegrees(yaw + FREEWHEEL_DRIFT_DEGREES); saturated = false; return false; } - if (!commanded || !driveState.isDrivable()) { + if (!commanded || !drive.isDrivable()) { return commanded && isOnTarget(); } @@ -111,7 +113,7 @@ public boolean tick(double ratePerTick) { // lies once per engagement. saturated = Math.abs(reachablePitch - commandedPitch) > 1.0E-6D; - double step = Math.max(0.0D, ratePerTick) * driveState.getRateFactor(); + double step = Math.max(0.0D, ratePerTick) * drive.getRateFactor(); if (step <= 0.0D) { return false; } @@ -164,7 +166,31 @@ public boolean isSaturated() { return saturated; } + /** + * What the drive is actually doing: whatever it was explicitly put into, or failing that what + * its condition allows. + * + *

    Why two fields and not one

    + *

    An explicit state is a DECISION or a fault from somewhere else — a player locking the + * mount, a probe killing the drive, whatever wave adds a power failure later. Condition is a + * fact re-read from the block every tick. Writing the second into the first would mean a + * repaired gun could never get its lock back, and a locked gun would silently unlock the moment + * it was scratched. So the explicit state wins while it says anything at all, and condition + * speaks only for a drive nobody has said anything about — which is the normal case.

    + */ public TurretDriveState getDriveState() { + return driveState == TurretDriveState.WORKING ? damageDriveState : driveState; + } + + /** What this mount's own condition allows, re-read from the block rather than remembered. */ + public void setDamageDriveState(TurretDriveState state) { + if (state != null) { + this.damageDriveState = state; + } + } + + /** What the mount was explicitly put into, ignoring its condition. Diagnostics and the probe. */ + public TurretDriveState getCommandedDriveState() { return driveState; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TurretDamageDegradesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TurretDamageDegradesE2ETest.java new file mode 100644 index 000000000..ec78d8650 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TurretDamageDegradesE2ETest.java @@ -0,0 +1,189 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The first time losing a fight costs anything but holes. + * + *

    A gun is shot at through the damage engine's own entry point — no probe writes a drive state — + * and the mount walks down its ladder: it turns, it turns slowly, it seizes. The last rung is the + * one worth having a test for: a seized gun still fires down the bearing it stopped at, which + * is the whole reason the ladder ends in a named state and not in a rate of zero.

    + * + *

    Each claim is checked against the state BEFORE it: a gun that never turned, never fired, or was + * already broken would satisfy half of this by accident.

    + */ +public class TurretDamageDegradesE2ETest extends AbstractSharedServerTest { + + private static final int X = 9000, Y = 80, Z = 9000; + private static final long TIMEOUT_MS = 25_000L; + + /** + * How many stages one impact is allowed to buy. Sized from the block's OWN stage cost, read off + * the probe rather than guessed: the cost comes from the toughness table, which is balance and + * will move, and a hard-coded budget silently stops damaging anything the day it does. + */ + private static final double STAGES_PER_IMPACT = 1.5D; + + /** + * Impact identities, never reused. The service refuses a repeated id and answers + * {@code DUPLICATE_IMPACT} — correct behaviour, and it silently ends a scenario that walks the + * ladder in two passes if both passes number their shots from the same place. + */ + private int nextImpactId = 1000; + + @Test + public void aGunShotUpTurnsSlowlyThenSeizesAndStillFires() throws Exception { + int base = X; + buildSite(base); + buildGun(base); + String built = awaitOperable(base); + assertTrue("the gun never assembled: " + built, built.contains("\"operable\":true")); + assertEquals("a pristine gun must report a working drive: " + built, "WORKING", drive(base)); + + // It works: pointed at something, it turns onto it and fires. Without this the degradation + // below would be indistinguishable from a gun that never did anything. + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + exec("artest turret target 0 " + base + " " + Y + " " + Z + " " + (base + 40.5D) + " " + + (Y + 0.5D) + " " + (Z + 0.5D)); + assertTrue("the pristine gun never fired, so nothing below is about damage", + awaitShots(base, 1) >= 1); + + // Now shoot the mount itself, through production's own path. + String derated = awaitDrive(base, "DERATED"); + assertEquals("a damaged mount must turn slowly rather than either working perfectly or" + + " dying outright: stage " + stage(base) + " of " + maxStage(base) + ", drive " + + drive(base), "DERATED", derated); + + String jammed = awaitDrive(base, "JAMMED"); + assertEquals("a wrecked mount must SEIZE: stage " + stage(base) + " of " + maxStage(base), + "JAMMED", jammed); + + // The rung that earns its own name: it stopped turning, it did not stop shooting. + exec("artest turret charge 0 " + base + " " + Y + " " + Z); + int before = shotsOf(base); + assertTrue("a seized gun stopped firing — then JAMMED is just a slower way of saying DEAD," + + " and a whole class of desperate defence is gone: " + read(base), + awaitShots(base, before + 1) > before); + } + + // ---- driving the world + + /** + * Hit the mount until its drive reaches {@code wanted}, or the budget of attempts runs out. + * Every impact carries its own identity, because the service refuses a repeat. + */ + private String awaitDrive(int bx, String wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + for (int shot = 0; shot < 40 && System.currentTimeMillis() < deadline; shot++) { + if (wanted.equals(drive(bx))) { + return wanted; + } + int budget = (int) Math.ceil(stageCost(bx) * STAGES_PER_IMPACT); + // From the SIDE, at the mount's own height, through cleared air: the mount is the first + // solid thing the ray meets. From above it would go through the barrels first and take + // the gun apart before the drive ever degraded — which is a different experiment, and + // the one the first version of this test accidentally ran. + String resp = exec("artest damage impact 0 " + (bx - 2.5D) + " " + (Y + 0.5D) + " " + + (Z + 0.5D) + " 1 0 0 " + budget + " KINETIC " + (nextImpactId++)); + assertTrue("the impact was refused, so the mount is not being damaged at all: " + resp, + resp.contains("\"ok\":true")); + assertTrue("the impact spent nothing — it is not reaching the mount, and every" + + " assertion after this would be about an undamaged gun: " + resp, + extractInt(resp, "spent") > 0); + assertTrue("the gun's own block was destroyed before it could seize: " + resp, + read(bx).contains("\"operable\":true")); + Thread.sleep(300L); + } + return drive(bx); + } + + private void buildGun(int bx) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 4; i++) { + place("advancedrocketry:gunBarrel", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + private void buildSite(int bx) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((bx - 16) >> 4) + " " + + ((Z - 16) >> 4) + " " + ((bx + 64) >> 4) + " " + ((Z + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill 0 " + (bx - 4) + " " + (Y - 2) + " " + + (Z - 4) + " " + (bx + 60) + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air") + .contains("\"ok\":true")); + assertTrue("could not hold the chunk", exec("artest chunk forceload 0 " + (bx >> 4) + " " + + (Z >> 4)).contains("\"ok\":true")); + } + + // ---- reading the world + + private String drive(int bx) throws Exception { + Matcher m = Pattern.compile("\"drive\":\"([A-Z]+)\"").matcher(read(bx)); + return m.find() ? m.group(1) : "?"; + } + + private int stage(int bx) throws Exception { + return extractInt(exec("artest damage stage 0 " + bx + " " + Y + " " + Z), "stage"); + } + + private int maxStage(int bx) throws Exception { + return extractInt(exec("artest damage stage 0 " + bx + " " + Y + " " + Z), "maxStage"); + } + + /** What one stage of this block costs, as the toughness table prices it today. */ + private int stageCost(int bx) throws Exception { + int cost = extractInt(exec("artest damage stage 0 " + bx + " " + Y + " " + Z), "stageCost"); + return cost > 0 ? cost : 100; + } + + private String awaitOperable(int bx) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(bx); + while (System.currentTimeMillis() < deadline && !state.contains("\"operable\":true")) { + Thread.sleep(250L); + state = read(bx); + } + return state; + } + + private int awaitShots(int bx, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + int shots = shotsOf(bx); + while (System.currentTimeMillis() < deadline && shots < wanted) { + Thread.sleep(250L); + shots = shotsOf(bx); + } + return shots; + } + + private int shotsOf(int bx) throws Exception { + return extractInt(read(bx), "shots"); + } + + private String read(int bx) throws Exception { + return exec("artest turret read 0 " + bx + " " + Y + " " + Z); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place 0 " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static int extractInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/GunPartConditionTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/GunPartConditionTest.java new file mode 100644 index 000000000..7e15e93f1 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/GunPartConditionTest.java @@ -0,0 +1,93 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.api.weapon.GunSpec; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A part in poor condition gives less of what it gives. + * + *

    The claim is about DIRECTION, not about a formula: whatever a part contributes, a damaged one + * contributes less of it — including the contributions that are negative, because a barrel exists to + * tighten the cone and a ruined barrel must tighten it less rather than as though nothing had + * happened. That last one is the case a naive "multiply the number" gets backwards, so it has its + * own test.

    + */ +public class GunPartConditionTest { + + private static final double EPSILON = 1.0E-9D; + + /** A whole part contributes wholly — the scale is a modifier, not a new pricing. */ + @Test + public void aPristinePartContributesExactlyWhatItSays() { + GunSpec pristine = new GunSpec.Builder() + .withContributionScale(1.0D) + .addMuzzleSpeed(2.0D).addImpactEnergy(40).countPart() + .build(); + GunSpec unscaled = new GunSpec.Builder() + .addMuzzleSpeed(2.0D).addImpactEnergy(40).countPart() + .build(); + + assertEquals(unscaled.getMuzzleSpeed(), pristine.getMuzzleSpeed(), EPSILON); + assertEquals(unscaled.getImpactEnergy(), pristine.getImpactEnergy()); + } + + /** Halve the condition, halve what it adds. */ + @Test + public void aDamagedPartAddsLess() { + GunSpec whole = new GunSpec.Builder() + .addMuzzleSpeed(2.0D).addImpactEnergy(40).countPart().build(); + GunSpec half = new GunSpec.Builder() + .withContributionScale(0.5D) + .addMuzzleSpeed(2.0D).addImpactEnergy(40).countPart().build(); + + assertTrue("a damaged part must not add as much speed: " + half.getMuzzleSpeed(), + half.getMuzzleSpeed() < whole.getMuzzleSpeed()); + assertTrue("nor as much energy: " + half.getImpactEnergy(), + half.getImpactEnergy() < whole.getImpactEnergy()); + } + + /** + * The one that is easy to get backwards. Spread is contributed NEGATIVELY — a barrel makes a gun + * truer — so a damaged barrel must leave the cone WIDER than a whole one, never tighter. + */ + @Test + public void aDamagedBarrelTightensTheConeLessRatherThanMore() { + GunSpec whole = new GunSpec.Builder() + .addSpreadDegrees(-2.0D).countPart().build(); + GunSpec battered = new GunSpec.Builder() + .withContributionScale(0.25D) + .addSpreadDegrees(-2.0D).countPart().build(); + + assertTrue("a battered barrel made the gun MORE accurate than a whole one: " + + battered.getSpreadDegrees() + " vs " + whole.getSpreadDegrees(), + battered.getSpreadDegrees() > whole.getSpreadDegrees()); + } + + /** A part damaged to nothing is still bolted on: it counts, and contributes almost nothing. */ + @Test + public void aPartDamagedToNothingStillCounts() { + GunSpec ruined = new GunSpec.Builder() + .withContributionScale(0.0D) + .addMuzzleSpeed(2.0D).addImpactEnergy(40).countPart() + .build(); + + assertEquals("it is still part of the build", 1, ruined.getPartCount()); + assertEquals("and it gives nothing", 0.0D, ruined.getMuzzleSpeed(), EPSILON); + assertEquals(0, ruined.getImpactEnergy()); + } + + /** The scale applies to the part it was set for, and does not leak into the next one. */ + @Test + public void theScaleIsPerPart() { + GunSpec mixed = new GunSpec.Builder() + .withContributionScale(0.0D).addImpactEnergy(40).countPart() + .withContributionScale(1.0D).addImpactEnergy(40).countPart() + .build(); + + assertEquals("a ruined part must not silence the intact one beside it", 40, + mixed.getImpactEnergy()); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/TurretConditionTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretConditionTest.java new file mode 100644 index 000000000..3ed5c8ee3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/TurretConditionTest.java @@ -0,0 +1,113 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.api.weapon.TurretDriveState; +import zmaster587.advancedRocketry.weapon.TurretMechanism; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * What a mount's own condition does to it, and what it is not allowed to do. + * + *

    Two claims, and neither is a number. A drive degrades in one direction through named + * states — it turns, then turns slowly, then does not turn and still shoots. And condition + * never overrules a decision: a player's lock and an explicit fault outrank it, or a repaired + * gun could never be locked again and a locked gun would come unlocked the first time it was + * scratched.

    + * + *

    Nothing here pins where the rungs sit. Those are balance, and a test that pinned them would go + * red the first time somebody tuned the mechanic without changing how it works.

    + */ +public class TurretConditionTest { + + private static final double DERATE_AT = 0.25D; + private static final double JAM_AT = 0.75D; + + /** The ladder goes one way, and each rung delivers strictly less traverse than the one above. */ + @Test + public void conditionWalksTheDriveDownAndNeverBackUp() { + assertEquals("a pristine mount is not degraded", TurretDriveState.WORKING, + TurretDriveState.fromDamage(0.0D, DERATE_AT, JAM_AT)); + assertEquals("a damaged mount turns slowly", TurretDriveState.DERATED, + TurretDriveState.fromDamage(DERATE_AT, DERATE_AT, JAM_AT)); + assertEquals("a wrecked mount seizes", TurretDriveState.JAMMED, + TurretDriveState.fromDamage(JAM_AT, DERATE_AT, JAM_AT)); + + double previous = Double.MAX_VALUE; + for (double fraction = 0.0D; fraction <= 1.0D; fraction += 0.05D) { + double rate = TurretDriveState.fromDamage(fraction, DERATE_AT, JAM_AT).getRateFactor(); + assertTrue("traverse must never IMPROVE as a mount is damaged further: " + rate + + " after " + previous + " at " + fraction, rate <= previous + 1.0E-9D); + previous = rate; + } + } + + /** + * A seized mount still fires. This is the whole reason the ladder ends at a named state rather + * than at a scalar going to zero — a gun that cannot turn is not a gun that cannot shoot. + */ + @Test + public void aMountSeizedByDamageStillFires() { + TurretDriveState seized = TurretDriveState.fromDamage(1.0D, DERATE_AT, JAM_AT); + assertFalse("a seized mount must not turn", seized.isDrivable()); + assertTrue("a seized mount must still be able to fire down the bearing it stopped at", + seized.permitsFiring()); + } + + /** Condition speaks for a mount nobody has said anything about — the normal case. */ + @Test + public void conditionDrivesAMountUnderNoExplicitOrder() { + TurretMechanism mount = TurretMechanism.standard(); + assertEquals(TurretDriveState.WORKING, mount.getDriveState()); + + mount.setDamageDriveState(TurretDriveState.DERATED); + assertEquals("a damaged mount nobody has touched must report itself damaged", + TurretDriveState.DERATED, mount.getDriveState()); + + // ...and it actually turns less, which is the part a player feels. + assertTrue("a derated mount must turn less than a working one in the same tick", + degreesTurnedInOneTick(TurretDriveState.DERATED) + < degreesTurnedInOneTick(TurretDriveState.WORKING)); + } + + /** + * An explicit state outranks condition, in both directions: a locked mount stays locked when it + * is damaged, and a repaired one does not silently forget it was locked. + */ + @Test + public void aDecisionOutranksCondition() { + TurretMechanism mount = TurretMechanism.standard(); + mount.setDriveState(TurretDriveState.LOCKED); + mount.setDamageDriveState(TurretDriveState.DERATED); + assertEquals("damage unlocked a mount a player had locked", TurretDriveState.LOCKED, + mount.getDriveState()); + + mount.setDamageDriveState(TurretDriveState.WORKING); + assertEquals("repairing the block quietly released the lock", TurretDriveState.LOCKED, + mount.getDriveState()); + } + + /** Repair walks it back: the state is a re-read fact, not a scar the mount remembers. */ + @Test + public void repairingTheBlockRestoresTheDrive() { + TurretMechanism mount = TurretMechanism.standard(); + mount.setDamageDriveState(TurretDriveState.JAMMED); + assertEquals(TurretDriveState.JAMMED, mount.getDriveState()); + + mount.setDamageDriveState(TurretDriveState.WORKING); + assertEquals("a repaired mount must work again — condition is read, never accumulated", + TurretDriveState.WORKING, mount.getDriveState()); + assertTrue("and it must turn again", degreesTurnedInOneTick(TurretDriveState.WORKING) > 0.0D); + } + + private static double degreesTurnedInOneTick(TurretDriveState condition) { + TurretMechanism mount = TurretMechanism.standard(); + mount.setDamageDriveState(condition); + mount.commandBearing(90.0D, 0.0D); + double before = mount.getYaw(); + mount.tick(3.0D); + return Math.abs(mount.getYaw() - before); + } +} From e09f0f769fe48c55c494fc927bdf635464f3f2c6 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 17:57:44 +0300 Subject: [PATCH 13/35] refactor: shields ride the shared subsystem network, not their own copy - AFFS solved its own max flow with the same status vocabulary; that copy is gone - tiles implement the subsystem node interfaces directly, the bridge layer with them - a console keeps its own setting when its network disappears - our strike path is untouched: reflection, restitution and the impact tests all stand Cherry-picked from feature/life_support: 2a492f3f9 refactor: one network for every subsystem, not one per subsystem 3d2ad59e9 fix: a console no longer wipes its own setting when its network is gone bd6262dcd refactor: collapse the AFFS shield-network bridge layer Ventilation-only files and probes were left behind; those blocks do not exist here. --- .../affs/block/BlockShieldCable.java | 8 +- .../affs/te/TileEntityContourInjector.java | 37 +- .../affs/te/TileEntityFieldGenerator.java | 48 +- .../affs/te/TileEntityShieldAccumulator.java | 45 +- .../affs/te/TileEntityShieldCable.java | 87 ++- .../affs/te/TileEntityShieldConsole.java | 116 +-- .../affs/te/TileEntityShieldGenerator.java | 39 +- .../contour/ContourFieldExplosionHandler.java | 9 +- .../affs/world/shield/IShieldCable.java | 8 - .../shield/IShieldNetworkController.java | 12 +- .../affs/world/shield/IShieldNetworkNode.java | 11 - .../affs/world/shield/IShieldSink.java | 20 - .../affs/world/shield/IShieldSource.java | 8 - .../affs/world/shield/ShieldControl.java | 2 +- .../world/shield/ShieldNetworkManager.java | 691 ++---------------- .../world/shield/ShieldNetworkRegistry.java | 64 -- .../affs/world/shield/ShieldNetworkState.java | 140 +--- .../command/test/TestProbeCommand.java | 77 +- .../ShieldConsoleReportsCollapseTest.java | 79 ++ 19 files changed, 455 insertions(+), 1046 deletions(-) delete mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldCable.java delete mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldNetworkNode.java delete mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSink.java delete mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/IShieldSource.java delete mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldNetworkRegistry.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShieldConsoleReportsCollapseTest.java 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/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 076ef894d..90b47f9cf 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,7 @@ 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.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 +37,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,7 +58,7 @@ 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. @@ -141,7 +143,7 @@ public String getAccessCode() { } @Override - public int getShieldPriority() { + public int getPriority() { return priority; } @@ -152,7 +154,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 +165,8 @@ 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); refreshFieldPowerState(true); } } @@ -245,6 +247,11 @@ public void setShieldEnergyForTest(int amount) { refreshFieldPowerState(true); } + @Override + public SubsystemNetworkDomain getNetworkDomain() { + return ShieldNetworkManager.DOMAIN; + } + @Override public BlockPos getNodePos() { return pos; @@ -276,13 +283,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()); } /** @@ -298,12 +305,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; } @@ -464,6 +471,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; @@ -639,8 +655,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(); } @@ -649,8 +665,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(); } 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..5392b0951 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() { + public int getFreeCapacity() { return Math.max(0, storage.getMaxEnergyStored() - storage.getEnergyStored()); } @Override - public int receiveShieldEnergy(int amount) { + public int receive(int amount) { if (world == null || world.isRemote || amount <= 0) { return 0; } 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..57f9508cf 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; @@ -100,24 +108,33 @@ public int getThroughputPerTick() { } @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 +142,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..117bf22f5 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,7 @@ 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.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 +15,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; @@ -69,8 +71,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 +82,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 +94,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 +103,11 @@ public void onChunkUnload() { super.onChunkUnload(); } + @Override + public SubsystemNetworkDomain getNetworkDomain() { + return ShieldNetworkManager.DOMAIN; + } + @Override public BlockPos getNodePos() { return pos; @@ -112,12 +119,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; } @@ -146,6 +153,16 @@ public int getShieldProductionPotential() { return Math.max(0, Math.min(CONVERSION_PER_TICK, 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() { return feStorage.getMaxEnergyStored(); } 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/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/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 5c639f2a0..ea7c0e4db 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -775,7 +775,7 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[ info.put("shieldStored", emitter.getEnergyStored()); info.put("shieldMax", emitter.getMaxEnergyStored()); info.put("radius", emitter.getRadius()); - info.put("requested", emitter.getRequestedShieldEnergy()); + 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. @@ -792,7 +792,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( @@ -811,7 +811,7 @@ 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()); } 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. @@ -825,8 +825,8 @@ 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()); + 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()); @@ -980,7 +980,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])) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldConsoleReportsCollapseTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldConsoleReportsCollapseTest.java new file mode 100644 index 000000000..6be8f7edf --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldConsoleReportsCollapseTest.java @@ -0,0 +1,79 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static zmaster587.advancedRocketry.test.server.WorldCommandFixtures.exec; + +/** + * A console reports its network as dead once the network dies. + * + *

    This started life as a regression test for a defect that turned out not to exist: the solver's + * disconnected path does not notify controllers, but the console never depended on that push — its + * own tick pulls the network state and falls back to a cleared readout when there is none. The test + * was kept because the property it actually pins is worth pinning, and it was RE-AIMED at that: the + * fallback path, not the push. It fails if the console stops clearing itself, which is the way this + * display can really go stale.

    + * + *

    Recorded in ledger #260, including the wrong version — it was caught only by re-running this + * test with the "fix" reverted and watching it pass anyway.

    + */ +public class ShieldConsoleReportsCollapseTest extends AbstractSharedServerTest { + + private static final Pattern STATUS = Pattern.compile("\"networkStatus\":(-?\\d+)"); + private static final Pattern CONNECTED = Pattern.compile("\"networkConnected\":(true|false)"); + + /** `SubsystemNetworkStatus.DISCONNECTED` — no source, or no sink, so nothing can flow. */ + private static final int DISCONNECTED = 1; + + private static final int DIM = 0; + private static final int Y = 64; + + @Test + public void aConsoleStopsReportingANetworkThatLostItsLastSource() throws Exception { + int z = 900; + int source = 1200; + int sink = source + 1; + int console = source + 2; + + place("affs:shield_generator", source, z); + place("affs:field_generator", sink, z); + place("affs:shield_console", console, z); + exec("artest energy inject " + DIM + " " + source + " " + Y + " " + z + " 1000000"); + + exec("artest shield tick " + DIM); + String working = consoleInfo(console, z); + assertTrue("premise: with a source and a sink the console must report a live network: " + + working, extract(working, CONNECTED).equals("true")); + + // Take the source away. The network can no longer move anything, and the console must say so. + exec("artest fill " + DIM + " " + source + " " + Y + " " + z + " " + + source + " " + Y + " " + z + " minecraft:air"); + exec("artest shield tick " + DIM); + + String collapsed = consoleInfo(console, z); + assertEquals("a console whose network lost its last source must stop reporting it as live: " + + collapsed, "false", extract(collapsed, CONNECTED)); + assertEquals("and must report the disconnected status rather than the previous one: " + + collapsed, DISCONNECTED, Integer.parseInt(extract(collapsed, STATUS))); + } + + private void place(String block, int x, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + Y + " " + z + " " + block); + assertTrue(block + " place failed: " + resp, resp.contains("\"placed\":true")); + } + + private String consoleInfo(int x, int z) throws Exception { + return exec("artest shield console-info " + DIM + " " + x + " " + Y + " " + z); + } + + private static String extract(String src, Pattern pattern) { + Matcher m = pattern.matcher(src); + assertTrue("pattern " + pattern.pattern() + " not found in: " + src, m.find()); + return m.group(1); + } +} From 2789c189c6423a5b814c50746d8b753cef8006df Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 19:34:29 +0300 Subject: [PATCH 14/35] feat: a shot-up shield covers less, and still pays full price - emitter radius splits into declared (billed) and projected (geometry) - projected radius derived from the block's own damage stage, replicated - generator, cable and accumulator each deliver less when damaged - two shield config coefficients, either at zero disables its consequence - probe reports both radii, the cycle cost and per-point coverage --- .../stannismod/affs/config/ModConfig.java | 31 ++ .../affs/gui/GuiFieldGenerator.java | 10 +- .../affs/te/TileEntityFieldGenerator.java | 74 ++++- .../affs/te/TileEntityShieldAccumulator.java | 15 +- .../affs/te/TileEntityShieldCable.java | 5 +- .../affs/te/TileEntityShieldGenerator.java | 14 +- .../affs/world/shield/ShieldCondition.java | 79 +++++ .../command/test/TestProbeCommand.java | 27 ++ .../test/server/ShieldDamageDegradesTest.java | 283 ++++++++++++++++++ .../test/unit/ShieldConditionTest.java | 103 +++++++ 10 files changed, 629 insertions(+), 12 deletions(-) create mode 100644 affs/src/main/java/com/github/stannismod/affs/world/shield/ShieldCondition.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/ShieldConditionTest.java 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 9ea9939a2..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,6 +60,15 @@ 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). @@ -242,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, 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/TileEntityFieldGenerator.java b/affs/src/main/java/com/github/stannismod/affs/te/TileEntityFieldGenerator.java index 90b47f9cf..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,6 +12,7 @@ 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.ShieldCondition; import com.github.stannismod.affs.world.shield.ShieldNetworkManager; import com.github.stannismod.affs.world.shield.ShieldNetworkState; import com.github.stannismod.affs.world.shield.ShieldStrikeKind; @@ -63,7 +64,14 @@ public class TileEntityFieldGenerator extends TileEntity implements ITickable, F // - 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. @@ -97,6 +105,7 @@ public void update() { shieldReceivedThisTick = 0; shieldConsumedThisTick = 0; + refreshEffectiveRadius(); refreshFieldPowerState(true); if (fieldPowered) { int requiredEnergy = getShieldDrainThisTick(); @@ -118,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; } @@ -133,11 +157,36 @@ 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; } @@ -167,6 +216,7 @@ public void onLoad() { ACTIVE_GENERATORS.add(this); SubsystemNetworkRegistry.register(this); SubsystemNetworkManager.markDirty(ShieldNetworkManager.DOMAIN, world); + refreshEffectiveRadius(); refreshFieldPowerState(true); } } @@ -329,7 +379,7 @@ public boolean ownsFieldBlock(BlockPos target) { } private double getFieldRadiusSq() { - double fieldRadius = radius + 0.5D; + double fieldRadius = getRadius() + 0.5D; return fieldRadius * fieldRadius; } @@ -394,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; @@ -502,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; @@ -526,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); } @@ -616,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); } @@ -770,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) { @@ -845,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); @@ -859,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 5392b0951..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 @@ -103,7 +103,7 @@ public int getRequested() { @Override public int getFreeCapacity() { - return Math.max(0, storage.getMaxEnergyStored() - storage.getEnergyStored()); + return Math.max(0, getEffectiveMaxShieldStored() - storage.getEnergyStored()); } @Override @@ -129,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 57f9508cf..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 @@ -104,7 +104,10 @@ 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 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 117bf22f5..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,6 +1,7 @@ package com.github.stannismod.affs.te; import com.github.stannismod.affs.config.ModConfig; +import com.github.stannismod.affs.world.shield.ShieldCondition; import com.github.stannismod.affs.world.shield.ShieldNetworkManager; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.network.NetworkManager; @@ -53,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); @@ -149,8 +150,17 @@ 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()))); } /** 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/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index ea7c0e4db..c366434d1 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -774,7 +774,13 @@ 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("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 @@ -812,6 +818,9 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[ info.put("shieldStored", gen.getShieldStored()); info.put("feStored", gen.getFeStored()); 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. @@ -825,6 +834,8 @@ private void handleShield(MinecraftServer server, ICommandSender sender, String[ info.put("kind", "accumulator"); info.put("shieldStored", acc.getShieldStored()); info.put("shieldMax", acc.getMaxShieldStored()); + // 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 { @@ -876,6 +887,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 { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java new file mode 100644 index 000000000..5db13d282 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java @@ -0,0 +1,283 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * What being shot does to a shield, other than draining it. + * + *

    Three claims, and the first two are a pair that only mean something together. A damaged emitter + * covers less ground — the shell draws in and a stretch of hull it used to hold stops being + * held — and it is still billed for the field it was told to project, because a shield that + * got cheaper the more it was shot would make taking fire a way to save energy. Then the third: a + * neighbour that still reaches closes the hole, and one that does not leaves it open, which is + * nothing anybody implemented — it is what a smooth union of spheres already does, and the test + * exists so it stays true.

    + * + *

    Everything here is driven through production's own doors: the damage arrives as a declared + * impact through the damage engine, never as a probe writing a radius, and the coverage is read from + * the emitter's own predicate rather than recomputed by the test.

    + */ +public class ShieldDamageDegradesTest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 64; + private static final int Z = 830; + private static final long TIMEOUT_MS = 25_000L; + + /** How many stages one impact is allowed to buy, sized from the block's OWN stage cost. */ + private static final double STAGES_PER_IMPACT = 1.2D; + + private static final Pattern RADIUS = Pattern.compile("\"radius\":(-?\\d+)"); + private static final Pattern DECLARED = Pattern.compile("\"declaredRadius\":(-?\\d+)"); + private static final Pattern CYCLE_COST = Pattern.compile("\"cycleCost\":(-?\\d+)"); + private static final Pattern CONVERSION = Pattern.compile("\"conversionPerTick\":(-?\\d+)"); + private static final Pattern THROUGHPUT = Pattern.compile("\"throughput\":(-?\\d+)"); + private static final Pattern CAPACITY = Pattern.compile("\"shieldMaxEffective\":(-?\\d+)"); + private static final Pattern STAGE = Pattern.compile("\"stage\":(-?\\d+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); + + /** + * Impact identities, never reused — the service refuses a repeat and answers DUPLICATE_IMPACT, + * which silently ends a scenario. STATIC because JUnit builds a fresh instance per method: a + * per-instance counter restarts at the same number for every test in the class, and every method + * after the first would be shooting ids the first one already spent. + */ + private static int nextImpactId = 7000; + + @Test + public void aDamagedEmitterCoversLessAndIsStillBilledForWhatItDeclared() throws Exception { + int gx = 1200, ex = 1201; + clearSite(gx - 6, gx + 12); + place("affs:shield_generator", gx); + place("affs:field_generator", ex); + powerUp(gx, ex); + + String pristine = readShield(ex); + int declared = (int) readInt(DECLARED, pristine); + int radiusBefore = (int) readInt(RADIUS, pristine); + long costBefore = readInt(CYCLE_COST, pristine); + assertEquals("precondition: an undamaged emitter must project the field it declared:\n" + + pristine, declared, radiusBefore); + + // A block on the shell's edge: covered while the emitter is whole, and the first thing it + // drops when the field draws in. Derived from the radius the emitter reports, not guessed. + int edge = ex + radiusBefore; + assertTrue("precondition: the edge of a pristine field must be covered, or the loss below is" + + " about nothing:\n" + readZone(edge), covered(edge)); + + int radiusAfter = shootUntilFieldShrinks(ex, radiusBefore); + String damaged = readShield(ex); + assertTrue("a damaged emitter must project a SMALLER field than it did pristine (" + radiusAfter + + " vs " + radiusBefore + "): the consequence a player is supposed to SEE coming did" + + " not happen:\n" + damaged, radiusAfter < radiusBefore); + + // It must still be lit, or "no longer covered" would be about the power, not the radius. + assertTrue("the shield went dark, so the coverage assertions below would pass for the wrong" + + " reason:\n" + damaged, damaged.contains("\"powered\":true")); + assertTrue("the shell drew in and the hull it uncovered is still reported as covered:\n" + + readZone(edge), !covered(edge)); + + assertEquals("damage moved the DECLARED radius: the setting is the player's, and a repair has" + + " nothing to restore to if a shell can edit it:\n" + damaged, + declared, (int) readInt(DECLARED, damaged)); + assertEquals("a shrunken emitter was billed less than the field it declared (" + costBefore + + " -> " + readInt(CYCLE_COST, damaged) + "): being shot at now SAVES energy, which" + + " is a reward wearing a consequence's clothes:\n" + damaged, + costBefore, readInt(CYCLE_COST, damaged)); + + // The other half of "re-read, never accumulated" — that mending the block gives the field + // back — is pinned one tier down, in ShieldConditionTest. It cannot be driven here: no shield + // block has a crafting recipe, and the welder prices a repair out of one, so it answers + // NO_RECIPE for every block in this subsystem. + } + + @Test + public void aNeighbourThatStillReachesClosesTheHoleAndOneThatDoesNotLeavesIt() throws Exception { + // Two independent single-emitter shields eight blocks apart, so their fields just meet. + int aGx = 1229, aEx = 1230, bEx = 1238, bGx = 1239; + clearSite(aGx - 8, bGx + 8); + place("affs:shield_generator", aGx); + place("affs:field_generator", aEx); + place("affs:field_generator", bEx); + place("affs:shield_generator", bGx); + powerUp(aGx, aEx); + powerUp(bGx, bEx); + + int radiusBefore = (int) readInt(RADIUS, readShield(aEx)); + int between = aEx + radiusBefore; // on A's edge, and inside B's reach + int outboard = aEx - radiusBefore; // on A's edge, and nowhere near B + assertTrue("precondition: the point between the two emitters must start covered:\n" + + readZone(between), covered(between)); + assertTrue("precondition: the point on A's far side must start covered:\n" + readZone(outboard), + covered(outboard)); + + int radiusAfter = shootUntilFieldShrinks(aEx, radiusBefore); + assertTrue("precondition: emitter A's field never shrank (" + radiusAfter + " vs " + + radiusBefore + "), so neither point below was ever uncovered by anything:\n" + + readShield(aEx), radiusAfter < radiusBefore); + assertTrue("precondition: emitter B must still be lit for its coverage to mean anything:\n" + + readShield(bEx), readShield(bEx).contains("\"powered\":true")); + + assertTrue("a hole left by a damaged emitter must be closed by the neighbour that still" + + " reaches it — the field is one blended surface, not a set of private bubbles:\n" + + readZone(between), covered(between)); + assertTrue("the far side, which only the damaged emitter ever reached, is still reported as" + + " covered: then nothing was actually lost and the shrink costs a player nothing:\n" + + readZone(outboard), !covered(outboard)); + } + + @Test + public void aDamagedGeneratorCableAndAccumulatorEachDeliverLess() throws Exception { + int genX = 1260, cableX = 1268, accX = 1276; + clearSite(genX - 6, accX + 6); + place("affs:shield_generator", genX); + place("affs:shield_cable", cableX); + place("affs:shield_accumulator", accX); + + long conversionBefore = readInt(CONVERSION, readShield(genX)); + long throughputBefore = readInt(THROUGHPUT, readShield(cableX)); + long capacityBefore = readInt(CAPACITY, readShield(accX)); + + shootUntilStaged(genX); + shootUntilStaged(cableX); + shootUntilStaged(accX); + + long conversionAfter = readInt(CONVERSION, readShield(genX)); + long throughputAfter = readInt(THROUGHPUT, readShield(cableX)); + long capacityAfter = readInt(CAPACITY, readShield(accX)); + + assertTrue("a damaged shield generator must convert less than an intact one (" + + conversionAfter + " vs " + conversionBefore + "):\n" + readShield(genX), + conversionAfter < conversionBefore); + assertTrue("a damaged cable must carry less than an intact one (" + throughputAfter + " vs " + + throughputBefore + "):\n" + readShield(cableX), throughputAfter < throughputBefore); + assertTrue("a damaged accumulator must hold less than an intact one (" + capacityAfter + + " vs " + capacityBefore + "):\n" + readShield(accX), capacityAfter < capacityBefore); + } + + // ---- driving the world + + /** + * Shoot the emitter's own block until the field it projects draws in, and answer with the radius + * it settled at. Bounded: an emitter that never shrinks fails on the caller's assertion with the + * numbers in hand rather than hanging here. + */ + private int shootUntilFieldShrinks(int ex, int radiusBefore) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + for (int shot = 0; shot < 12 && System.currentTimeMillis() < deadline; shot++) { + int radius = (int) readInt(RADIUS, readShield(ex)); + if (radius < radiusBefore) { + return radius; + } + // Stop one rung short of destruction and let the CALLER's claim fail with the numbers in + // hand. Shooting on until the block is gone would replace "the field never shrank" with + // "there is no emitter", which is a different sentence and a worse one to read. + String damage = readStage(ex); + if (readInt(STAGE, damage) >= readInt(Pattern.compile("\"maxStage\":(-?\\d+)"), damage) - 1) { + break; + } + hit(ex); + // The emitter re-reads its own condition on its tick, and a lit field costs energy to + // hold, so keep feeding it: a dark shield covers nothing for reasons unrelated to damage. + exec("artest tile force-tick " + DIM + " " + ex + " " + Y + " " + Z + " 2"); + exec("artest shield tick " + DIM); + } + return (int) readInt(RADIUS, readShield(ex)); + } + + /** Shoot a block until the world records a stage against it. */ + private void shootUntilStaged(int x) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + for (int shot = 0; shot < 12 && System.currentTimeMillis() < deadline; shot++) { + if (readInt(STAGE, readStage(x)) > 0) { + return; + } + hit(x); + } + assertTrue("the block at " + x + " never took a stage, so nothing below is about damage:\n" + + readStage(x), readInt(STAGE, readStage(x)) > 0); + } + + /** + * One declared impact against the block at {@code x}, from the -Z side at its own height: that + * block is the first solid thing the ray meets, and what is behind it is cleared air, so no + * neighbour is quietly damaged by the leftover budget. + */ + private void hit(int x) throws Exception { + int budget = (int) Math.ceil(readInt(STAGE_COST, readStage(x)) * STAGES_PER_IMPACT); + String resp = exec("artest damage impact " + DIM + " " + (x + 0.5D) + " " + (Y + 0.5D) + " " + + (Z - 2.5D) + " 0 0 1 " + budget + " KINETIC " + (nextImpactId++)); + assertTrue("the impact was refused, so the block is not being damaged at all: " + resp, + resp.contains("\"ok\":true")); + assertTrue("the impact spent nothing — it is not reaching the block, and every assertion" + + " after this would be about an undamaged one: " + resp, + readInt(Pattern.compile("\"spent\":(-?\\d+)"), resp) > 0); + assertTrue("the block was destroyed rather than damaged, so there is nothing left to degrade: " + + readStage(x), !readStage(x).contains("\"wasDestroyed\":true")); + } + + /** Feed the generator until its emitter lights up. */ + private void powerUp(int gx, int ex) throws Exception { + for (int i = 0; i < 16 && !readShield(ex).contains("\"powered\":true"); i++) { + exec("artest energy inject " + DIM + " " + gx + " " + Y + " " + Z + " 4000"); + exec("artest tile force-tick " + DIM + " " + gx + " " + Y + " " + Z + " 1"); + exec("artest shield tick " + DIM); + } + assertTrue("precondition: the shield at " + ex + " never powered up:\n" + readShield(ex), + readShield(ex).contains("\"powered\":true")); + } + + private void clearSite(int minX, int maxX) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + (minX >> 4) + " " + + ((Z - 8) >> 4) + " " + (maxX >> 4) + " " + ((Z + 8) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + minX + " " + (Y - 2) + + " " + (Z - 6) + " " + maxX + " " + (Y + 6) + " " + (Z + 6) + " minecraft:air") + .contains("\"ok\":true")); + } + + // ---- reading the world + + /** Whether ANY live emitter still holds the block at {@code x} — the emitter's own predicate. */ + private boolean covered(int x) throws Exception { + return readZone(x).contains("\"covered\":true"); + } + + private String readZone(int x) throws Exception { + return exec("artest shield zone " + DIM + " " + x + " " + Y + " " + Z); + } + + private String readShield(int x) throws Exception { + return exec("artest shield read " + DIM + " " + x + " " + Y + " " + Z); + } + + private String readStage(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private void place(String block, int x) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + Y + " " + Z + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + Y + "," + Z + ": " + resp, + resp.contains("\"placed\":true")); + } + + private static long readInt(Pattern pattern, String json) { + Matcher m = pattern.matcher(json); + assertTrue("no " + pattern.pattern() + " field in probe response: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + + private static String exec(String command) throws Exception { + return join(client().execute(command)); + } + + private static String join(List resp) { + return String.join("\n", resp); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ShieldConditionTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ShieldConditionTest.java new file mode 100644 index 000000000..02eec685d --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ShieldConditionTest.java @@ -0,0 +1,103 @@ +package zmaster587.advancedRocketry.test.unit; + +import com.github.stannismod.affs.world.shield.ShieldCondition; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * What a shield block's condition is allowed to do to it. + * + *

    Two laws, and neither of them is a magnitude. A damaged node delivers less, monotonically + * — a shell that ate more fire never buys back capability. And an emitter under fire covers less + * ground, visibly: the shrink is not allowed to vanish into rounding, and it is not allowed to + * take the field away entirely while the block is still standing.

    + * + *

    Where the coefficients sit is balance and is deliberately not pinned here; the tests pass their + * own so a tuning pass never reddens them.

    + */ +public class ShieldConditionTest { + + private static final double PENALTY = 0.5D; + private static final int MIN_RADIUS = 1; + + /** A node's delivery only ever walks downward, and it stops at nothing rather than at less. */ + @Test + public void deliveryFallsWithConditionAndNeverBelowNothing() { + assertEquals("a pristine node must deliver everything it is rated for", 1.0D, + ShieldCondition.scale(0.0D, PENALTY), 1.0E-9D); + + double previous = Double.MAX_VALUE; + for (double fraction = 0.0D; fraction <= 1.0D; fraction += 0.05D) { + double factor = ShieldCondition.scale(fraction, PENALTY); + assertTrue("a shield node must never deliver MORE as it is damaged further: " + factor + + " after " + previous + " at " + fraction, factor <= previous + 1.0E-9D); + assertTrue("a wrecked node delivers nothing; it must never deliver a negative amount and" + + " start consuming: " + factor + " at " + fraction, factor >= 0.0D); + previous = factor; + } + + assertTrue("with the whole rating on the line, a node one step from destruction must deliver" + + " strictly less than a pristine one", + ShieldCondition.scale(1.0D, 1.0D) < ShieldCondition.scale(0.0D, 1.0D)); + } + + /** Turn the consequence off and a shield stops caring about damage — the disable path is real. */ + @Test + public void aZeroPenaltyLeavesEverythingUntouched() { + assertEquals("with the penalty at zero a wrecked node must still deliver its full rating", + 1.0D, ShieldCondition.scale(1.0D, 0.0D), 1.0E-9D); + assertEquals("with the penalty at zero a wrecked emitter must still project its whole field", + 12, ShieldCondition.shrinkRadius(12, 1.0D, 0.0D, MIN_RADIUS)); + } + + /** + * The claim the whole consequence rests on: a damaged emitter covers less than the same emitter + * pristine — as an ordering, not as a radius. + */ + @Test + public void aDamagedEmitterProjectsASmallerFieldThanAPristineOne() { + int declared = 8; + int pristine = ShieldCondition.shrinkRadius(declared, 0.0D, PENALTY, MIN_RADIUS); + int battered = ShieldCondition.shrinkRadius(declared, 0.5D, PENALTY, MIN_RADIUS); + + assertEquals("an undamaged emitter must project exactly the field it was told to hold", + declared, pristine); + assertTrue("a damaged emitter must project a SMALLER field than the same emitter pristine (" + + battered + " vs " + pristine + "): the one consequence a player can see coming is" + + " the field drawing in", battered < pristine); + } + + /** Damage that is real must be visible, not absorbed by rounding to the same integer. */ + @Test + public void theSmallestRealDamageAlreadyShows() { + int declared = 8; + int oneStageOfFour = ShieldCondition.shrinkRadius(declared, 0.25D, PENALTY, MIN_RADIUS); + assertTrue("an emitter with a stage on it still projected its full field (" + oneStageOfFour + + " of " + declared + "): a consequence that rounds away is one a player cannot read", + oneStageOfFour < declared); + } + + /** It shrinks; it does not switch off. Losing the field entirely is what destruction is for. */ + @Test + public void aStandingEmitterAlwaysProjectsSomething() { + for (int declared = 1; declared <= 16; declared++) { + int shrunk = ShieldCondition.shrinkRadius(declared, 1.0D, 1.0D, MIN_RADIUS); + assertTrue("an emitter that is still standing must still project a field, however small" + + " (declared " + declared + " gave " + shrunk + ")", shrunk >= MIN_RADIUS); + assertTrue("damage must never GROW the field (declared " + declared + " gave " + shrunk + + ")", shrunk <= declared); + } + } + + /** Repair is a re-read, so the same declared radius and no damage gives the field back whole. */ + @Test + public void repairRestoresTheWholeFieldBecauseNothingIsAccumulated() { + int declared = 10; + ShieldCondition.shrinkRadius(declared, 0.75D, PENALTY, MIN_RADIUS); + assertEquals("the field must come back whole once the block is mended — the radius is derived" + + " from the declared one every time, never chipped away at", + declared, ShieldCondition.shrinkRadius(declared, 0.0D, PENALTY, MIN_RADIUS)); + } +} From a065d3f8e855d9d1a87f4a8314cab17c61e3d8b8 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 17 Aug 2026 21:57:03 +0300 Subject: [PATCH 15/35] feat: the block a shot meets answers what happens to it - Contact, ContactResult and IContactResponder in the public damage API - the substrate obeys the answer; the default law is the old behaviour - SweptSegment reports the face each voxel was entered through - contact geometry rides the block's own frame, not the world's --- .../advancedRocketry/api/damage/Contact.java | 144 ++++++++++++++++++ .../api/damage/ContactResult.java | 80 ++++++++++ .../api/damage/IContactResponder.java | 25 +++ .../damage/StructureDamageEngine.java | 2 +- .../projectile/ContactResolver.java | 124 +++++++++++++++ .../projectile/ShotSubstrate.java | 37 +++-- .../projectile/StructureCrossing.java | 18 ++- .../advancedRocketry/util/SweptSegment.java | 16 +- .../test/unit/ContactGeometryTest.java | 140 +++++++++++++++++ .../test/unit/SweptSegmentTest.java | 55 ++++++- 10 files changed, 616 insertions(+), 25 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/Contact.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/ContactGeometryTest.java 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..1d3b901ac --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java @@ -0,0 +1,80 @@ +package zmaster587.advancedRocketry.api.damage; + +import net.minecraft.util.math.Vec3d; + +/** + * What a block answered when a travelling body met it. + * + *

    Three states, four behaviours

    + *
      + *
    • {@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 { + + private final boolean stopped; + private final int residualEnergy; + private final Vec3d deflectedVelocity; + + private ContactResult(boolean stopped, int residualEnergy, Vec3d deflectedVelocity) { + this.stopped = stopped; + this.residualEnergy = Math.max(0, residualEnergy); + this.deflectedVelocity = deflectedVelocity; + } + + /** The body carries on along its own course with {@code residualEnergy} left. */ + public static ContactResult passedThrough(int residualEnergy) { + return new ContactResult(false, residualEnergy, null); + } + + /** Nothing continues past this block. */ + public static ContactResult stopped() { + return new ContactResult(true, 0, null); + } + + /** + * 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); + } + + /** 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/IContactResponder.java b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java new file mode 100644 index 000000000..439bc1acd --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java @@ -0,0 +1,25 @@ +package zmaster587.advancedRocketry.api.damage; + +/** + * 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#passedThrough(int)} to decline having an opinion. + */ + ContactResult onContact(Contact contact); +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index dac03d451..509a5198a 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -118,7 +118,7 @@ private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result) { } @Override - public boolean visit(BlockPos pos, double tEnter) { + public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing entryFace) { Vec3d here = entry.add(scale(farEnd.subtract(entry), tEnter)); if (previousWasSolid) { // The ray left the previous solid block exactly where it entered this one. diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java new file mode 100644 index 000000000..952413435 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -0,0 +1,124 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.block.Block; +import net.minecraft.tileentity.TileEntity; +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.IContactResponder; +import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.damage.ShipDamageService; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; + +/** + * Asks the block a shot just met what happens, and answers for it when it has nothing to say. + * + *

    Why this lives in the shot's layer and not in the damage engine

    + *

    A contact is a fact about a travelling body — how fast, how wide, at what angle — and the damage + * engine is deliberately ignorant of all three: it is also driven by explosions and collisions, which + * have none of them. So the body's own layer resolves the meeting, and only what the answer says was + * absorbed goes to the engine, through the door that already existed.

    + * + *

    The default law reproduces what the game did before there was a contract

    + *

    A block that says nothing gets the behaviour it always had: the impact is declared against + * structure with the shot's full energy and the shot ends there. That is the property this layer can + * be held to before any armour exists — if the plain-hull tests move, the seam was built wrong, + * whatever the armour does later.

    + */ +public final class ContactResolver { + + private ContactResolver() { + } + + /** + * Resolve one shot meeting one block. + * + * @param hit the crossing, carrying the block's own frame, the entry face in that frame and the + * world point + * @param worldVelocity the shot's velocity in WORLD terms + */ + public static ContactResult resolve(World world, Shot shot, StructureCrossing.Hit hit, + Vec3d worldVelocity) { + if (world == null || shot == null || hit == null) { + return ContactResult.stopped(); + } + + Contact contact = new Contact(hit.block, hit.point, hit.entryFace, + inBlockFrame(world, hit, worldVelocity), shot.getKind(), shot.getImpactEnergy(), + shot.getRadius(), 1.0D, hit.shipId); + + IContactResponder responder = responderAt(world, hit.block); + if (responder != null) { + ContactResult answer = responder.onContact(contact); + if (answer != null) { + return answer; + } + } + return defaultLaw(world, shot, contact); + } + + /** + * What an ordinary block does: absorb the impact through the damage service and stop the body. + * + *

    The whole energy is declared, exactly as before this seam existed — a shot does not yet + * survive a hull, and making it survive is a separate decision with its own consequences (the + * deceleration law, a speed floor, an identity per tick). Wiring it here would have smuggled that + * change in under a refactor.

    + */ + private static ContactResult defaultLaw(World world, Shot shot, Contact contact) { + ShipDamageService.apply(world, ImpactRequest.penetrating(shot.nextImpactId(), + contact.getPoint(), directionOf(contact, shot), contact.getEnergy(), + contact.getKind())); + return ContactResult.stopped(); + } + + /** + * The world-frame direction the impact is declared along. Taken from the shot rather than from the + * contact's own velocity, because the contact carries a BLOCK-frame velocity and the damage + * service works in world terms — mixing the two is the frame bug this separation exists to make + * impossible. + */ + private static Vec3d directionOf(Contact contact, Shot shot) { + Vec3d v = shot.getVelocity(); + double speed = v.lengthVector(); + return speed <= 1.0E-9D ? new Vec3d(0.0D, -1.0D, 0.0D) : v.scale(1.0D / speed); + } + + /** + * The shot's velocity expressed in the frame the block lives in — itself off a ship, rotated into + * subspace on one. Done by mapping two world points a velocity apart and subtracting: a ship's + * transform is rigid, so the difference of two mapped points IS the mapped vector, and it needs no + * port surface beyond the one the crossing already uses. + */ + private static Vec3d inBlockFrame(World world, StructureCrossing.Hit hit, Vec3d worldVelocity) { + if (worldVelocity == null) { + return null; + } + if (hit.shipId == null) { + return worldVelocity; + } + double[] base = VSIntegration.toShipFrameFor(world, hit.shipId, hit.point.x, hit.point.y, + hit.point.z); + double[] tip = VSIntegration.toShipFrameFor(world, hit.shipId, hit.point.x + worldVelocity.x, + hit.point.y + worldVelocity.y, hit.point.z + worldVelocity.z); + if (base == null || tip == null) { + // The ship stopped answering between the crossing and here. A world-frame velocity against + // a subspace face would be a plausible-looking angle about nothing, so answer with no + // velocity at all: the incidence reads square-on, which is the reading that never bounces. + return null; + } + return new Vec3d(tip[0] - base[0], tip[1] - base[1], tip[2] - base[2]); + } + + /** The block's own answer, then its tile's; null when neither has one. */ + private static IContactResponder responderAt(World world, BlockPos pos) { + Block block = world.getBlockState(pos).getBlock(); + if (block instanceof IContactResponder) { + return (IContactResponder) block; + } + TileEntity tile = world.getTileEntity(pos); + return tile instanceof IContactResponder ? (IContactResponder) tile : null; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 2476a9ca0..84ff87a88 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -6,11 +6,9 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.ARConfiguration; -import zmaster587.advancedRocketry.api.damage.ImpactKind; -import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.api.damage.ContactResult; import zmaster587.advancedRocketry.api.projectile.ShotEndReason; import zmaster587.advancedRocketry.api.projectile.ShotSpec; -import zmaster587.advancedRocketry.damage.ShipDamageService; import java.util.List; @@ -25,8 +23,9 @@ *
  • The field layer (the shield's strike seam) owns what a shell does to a body that * reaches it: how much it absorbs, and where a mirrored body goes. This class hands a strike * over and reads the answer; it never computes a deflection or spends shield energy itself.
  • - *
  • The structure layer (the damage service) owns what an impact does to blocks. This - * class hands over a point, a direction and a budget; it names no block, no stage, no ship.
  • + *
  • The structure layer owns what an impact does to blocks, and the BLOCK it met owns + * what happens to the body: this class asks (through {@link ContactResolver}) and obeys the + * answer. It still names no stage and no ship, and it never decides a deflection itself.
  • * * *

    Ordering is geometric, not a pipeline

    @@ -145,8 +144,26 @@ static ShotEndReason step(World world, Shot shot) { if (structureFirst) { shot.setPosition(structure.point); shot.setVelocity(velocity); - strikeStructure(world, shot, structure.point, direction); - return ShotEndReason.STRUCTURE_IMPACT; + + // The block decides, this loop obeys — the same relationship the shell above already + // has with the field layer. Today every ordinary block answers "stopped", which is + // exactly what happened before there was a contract; armour is what makes the other + // two answers reachable. + ContactResult contact = ContactResolver.resolve(world, shot, structure, velocity); + if (contact.isStopped()) { + return ShotEndReason.STRUCTURE_IMPACT; + } + + double consumed = (structure.distance + CROSSING_EPSILON) / speed; + timeLeft -= consumed; + shot.setImpactEnergy(contact.getResidualEnergy()); + if (contact.isDeflected()) { + velocity = contact.getDeflectedVelocity(); + position = structure.point.add(velocity.normalize().scale(CROSSING_EPSILON)); + } else { + position = structure.point.add(direction.scale(CROSSING_EPSILON)); + } + continue; } if (!fieldFirst) { position = segmentEnd; @@ -199,10 +216,4 @@ static ShotEndReason step(World world, Shot shot) { return null; } - /** Hand the impact over. One call, one identity, and no opinion about what it means. */ - private static void strikeStructure(World world, Shot shot, Vec3d point, Vec3d direction) { - ImpactKind kind = shot.getKind(); - ShipDamageService.apply(world, ImpactRequest.penetrating(shot.nextImpactId(), point, direction, - shot.getImpactEnergy(), kind)); - } } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java index 59db3cbd6..2d1f3d78e 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.projectile; +import net.minecraft.util.EnumFacing; import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; @@ -48,16 +49,23 @@ static final class Hit { final double distance; /** Where it happened, in WORLD coordinates. */ final Vec3d point; - /** The block struck, in the frame it was found in — diagnostics only. */ + /** The block struck, in the frame it was found in. */ final BlockPos block; - /** The ship whose blocks were struck, or null for the world's own. Diagnostics only. */ + /** The ship whose blocks were struck, or null for the world's own. */ final String shipId; + /** + * The face the segment came in through, as an outward normal IN THAT FRAME — so on a ship it + * is a subspace face, which is what makes it comparable with a subspace-expressed velocity. + * Null when the segment began already inside the block it struck. + */ + final EnumFacing entryFace; - private Hit(double distance, Vec3d point, BlockPos block, String shipId) { + private Hit(double distance, Vec3d point, BlockPos block, String shipId, EnumFacing entryFace) { this.distance = distance; this.point = point; this.block = block; this.shipId = shipId; + this.entryFace = entryFace; } } @@ -143,7 +151,7 @@ private static Hit traverse(World world, Vec3d from, Vec3d to, double worldLengt final Vec3d segTo = to; SweptSegment.traverse(from, to, MAX_VOXELS_PER_SEGMENT, new SweptSegment.Visitor() { @Override - public boolean visit(BlockPos pos, double tEnter) { + public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing entryFace) { if (!world.isBlockLoaded(pos)) { return false; // nobody looked; see the class note } @@ -160,7 +168,7 @@ public boolean visit(BlockPos pos, double tEnter) { } worldPoint = new Vec3d(w[0], w[1], w[2]); } - found[0] = new Hit(tEnter * worldLength, worldPoint, pos, shipId); + found[0] = new Hit(tEnter * worldLength, worldPoint, pos, shipId, entryFace); return true; } }); diff --git a/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java b/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java index 323be96fe..5e5eda1eb 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java +++ b/src/main/java/zmaster587/advancedRocketry/util/SweptSegment.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.util; +import net.minecraft.util.EnumFacing; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; @@ -29,9 +30,13 @@ public interface Visitor { * @param pos the voxel * @param tEnter the parameter in {@code [0,1]} along {@code from -> to} at which the segment * enters it ({@code 0} for the voxel the segment starts in) + * @param entryFace the face of {@code pos} the segment came in through, as an OUTWARD normal + * — it points back the way the segment came, which is the surface normal + * anything answering a contact needs. {@code null} for the voxel the segment + * starts in: nothing was crossed to get there. * @return true to stop the traversal here */ - boolean visit(BlockPos pos, double tEnter); + boolean visit(BlockPos pos, double tEnter, EnumFacing entryFace); } private SweptSegment() { @@ -70,9 +75,13 @@ public static int traverse(Vec3d from, Vec3d to, int maxVoxels, Visitor visitor) double t = 0.0D; int visited = 0; + // Null for the first voxel and then the face last crossed: the segment is always reported + // WITH the way it got in, so a caller never has to re-derive it from the entry point — which + // at a corner cannot be done unambiguously. + EnumFacing entryFace = null; while (visited < maxVoxels) { visited++; - if (visitor.visit(new BlockPos(x, y, z), t)) { + if (visitor.visit(new BlockPos(x, y, z), t, entryFace)) { return visited; } double next = Math.min(tMaxX, Math.min(tMaxY, tMaxZ)); @@ -86,12 +95,15 @@ public static int traverse(Vec3d from, Vec3d to, int maxVoxels, Visitor visitor) if (next == tMaxX) { x += stepX; tMaxX += tDeltaX; + entryFace = stepX > 0 ? EnumFacing.WEST : EnumFacing.EAST; } else if (next == tMaxY) { y += stepY; tMaxY += tDeltaY; + entryFace = stepY > 0 ? EnumFacing.DOWN : EnumFacing.UP; } else { z += stepZ; tMaxZ += tDeltaZ; + entryFace = stepZ > 0 ? EnumFacing.NORTH : EnumFacing.SOUTH; } } return visited; diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ContactGeometryTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ContactGeometryTest.java new file mode 100644 index 000000000..d875765d8 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ContactGeometryTest.java @@ -0,0 +1,140 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.api.damage.Contact; +import zmaster587.advancedRocketry.api.damage.ContactResult; +import zmaster587.advancedRocketry.api.damage.ImpactKind; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * What a block is told when something hits it, and what it is allowed to answer. + * + *

    Two claims. The angle means what its name says: square-on is zero and a graze approaches + * ninety, in that direction — a ricochet rule written against a reversed convention would bounce + * exactly the shots that should punch through, and nothing else in the game would contradict it. + * And an answer cannot promise what it has not got: a deflection with nowhere to go is a stop, + * and a stop carries nothing onward.

    + * + *

    No angle threshold is pinned here. Where a ricochet begins is balance; that the angle grows as + * the hit gets flatter is the contract.

    + */ +public class ContactGeometryTest { + + private static final Vec3d POINT = new Vec3d(10.0D, 64.0D, 10.0D); + + /** Straight into the face: the flattest possible statement of "not a graze". */ + @Test + public void aSquareHitReadsZeroDegrees() { + // Travelling +X into a block entered through its WEST face (whose outward normal is -X). + Contact contact = contact(EnumFacing.WEST, new Vec3d(2.0D, 0.0D, 0.0D)); + assertEquals("a body arriving square-on must read zero degrees of incidence, or every rule" + + " written against this angle is inverted", 0.0D, contact.getIncidenceDegrees(), 1.0E-6D); + } + + /** Along the face: the flattest possible graze, and the far end of the same scale. */ + @Test + public void aGrazeReadsNinetyDegrees() { + Contact contact = contact(EnumFacing.WEST, new Vec3d(0.0D, 0.0D, 3.0D)); + assertEquals("a body travelling ALONG the face must read ninety degrees", 90.0D, + contact.getIncidenceDegrees(), 1.0E-6D); + } + + /** The scale between the ends is monotone: flatter hit, larger angle. Never a magnitude. */ + @Test + public void theAngleGrowsAsTheHitGetsFlatter() { + // Forward is held at 2 and sideways is walked to 20, so the last sample really is the ten-to-one + // graze the assertion below names — the first version of this loop stopped at two-to-one and + // its message described an experiment it was not running. + double previous = -1.0D; + for (int sideways = 0; sideways <= 10; sideways++) { + double degrees = contact(EnumFacing.WEST, new Vec3d(2.0D, 0.0D, sideways * 2.0D)) + .getIncidenceDegrees(); + assertTrue("incidence must never FALL as the same hit is made flatter: " + degrees + + " after " + previous + " at sideways=" + sideways, degrees >= previous - 1.0E-9D); + previous = degrees; + } + assertTrue("a hit ten times more sideways than forward must read as a graze, not a square" + + " hit: " + previous, previous > 80.0D); + } + + /** Whichever face is met, the normal points back at whoever fired. */ + @Test + public void theNormalPointsBackTheWayTheBodyCame() { + for (EnumFacing face : EnumFacing.values()) { + Vec3d normal = contact(face, new Vec3d(1.0D, 0.0D, 0.0D)).getNormal(); + assertEquals("the outward normal must be the entry face's own direction", face.getFrontOffsetX(), + normal.x, 1.0E-9D); + assertEquals(face.getFrontOffsetY(), normal.y, 1.0E-9D); + assertEquals(face.getFrontOffsetZ(), normal.z, 1.0E-9D); + } + } + + /** + * A body that began the step already inside the block has no face to have crossed. It must read as + * a square hit rather than throwing or inventing a normal: the reading that never ricochets is the + * safe one for a case nobody can compute an angle for. + */ + @Test + public void aBodyWithNoEntryFaceIsNotAGraze() { + Contact contact = contact(null, new Vec3d(1.0D, 0.0D, 1.0D)); + assertNull(contact.getNormal()); + assertEquals(0.0D, contact.getIncidenceDegrees(), 1.0E-9D); + } + + /** An answer that cannot deliver a deflection is a stop, not a deflection with a null course. */ + @Test + public void aDeflectionWithNowhereToGoIsAStop() { + assertTrue("a null course must degrade to stopped", + ContactResult.deflected(null, 500).isStopped()); + assertTrue("a motionless course must degrade to stopped", + ContactResult.deflected(new Vec3d(0.0D, 0.0D, 0.0D), 500).isStopped()); + assertEquals("and a stop carries nothing onward", 0, + ContactResult.deflected(null, 500).getResidualEnergy()); + } + + /** The three states answer about themselves consistently, so a caller can branch on any one. */ + @Test + public void eachStateReportsItselfAndNotAnother() { + ContactResult through = ContactResult.passedThrough(1200); + assertFalse(through.isStopped()); + assertFalse("passing through is not a deflection: the body kept its own course", + through.isDeflected()); + assertEquals(1200, through.getResidualEnergy()); + + ContactResult stopped = ContactResult.stopped(); + assertTrue(stopped.isStopped()); + assertFalse(stopped.isDeflected()); + assertNull(stopped.getDeflectedVelocity()); + + ContactResult bounced = ContactResult.deflected(new Vec3d(0.0D, 1.0D, 0.0D), 800); + assertFalse("a deflected body did not stop — that is the whole difference", + bounced.isStopped()); + assertTrue(bounced.isDeflected()); + assertEquals(800, bounced.getResidualEnergy()); + } + + /** Energy and share are clamped where they are built, so no consumer has to re-check them. */ + @Test + public void aContactCannotCarryNonsense() { + Contact negative = new Contact(BlockPos.ORIGIN, POINT, EnumFacing.UP, new Vec3d(0, -1, 0), + ImpactKind.KINETIC, -500, -2.0D, 4.0D, null); + assertEquals("negative energy is no energy", 0, negative.getEnergy()); + assertEquals("a negative cross-section is a point", 0.0D, negative.getRadius(), 1.0E-9D); + assertEquals("a share above the whole body is the whole body", 1.0D, negative.getShare(), + 1.0E-9D); + assertEquals("negative residual energy is no energy", 0, + ContactResult.passedThrough(-7).getResidualEnergy()); + } + + private static Contact contact(EnumFacing face, Vec3d velocity) { + return new Contact(BlockPos.ORIGIN, POINT, face, velocity, ImpactKind.KINETIC, 5000, 0.5D, + 1.0D, null); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java index e0cfe052f..b5bc8f47b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptSegmentTest.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.test.unit; +import net.minecraft.util.EnumFacing; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import org.junit.Test; @@ -9,6 +10,8 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** @@ -27,8 +30,8 @@ private static List walk(Vec3d from, Vec3d to) { final List visits = new ArrayList<>(); SweptSegment.traverse(from, to, 100_000, new SweptSegment.Visitor() { @Override - public boolean visit(BlockPos pos, double tEnter) { - visits.add(new Visit(pos, tEnter)); + public boolean visit(BlockPos pos, double tEnter, EnumFacing entryFace) { + visits.add(new Visit(pos, tEnter, entryFace)); return false; } }); @@ -111,7 +114,7 @@ public void theVoxelCapBoundsTheWorkAndSaysHowMuchItDid() { int visited = SweptSegment.traverse(new Vec3d(0.5D, 0.5D, 0.5D), new Vec3d(900.5D, 0.5D, 0.5D), 7, new SweptSegment.Visitor() { @Override - public boolean visit(BlockPos pos, double tEnter) { + public boolean visit(BlockPos pos, double tEnter, EnumFacing entryFace) { seen[0]++; return false; } @@ -120,13 +123,57 @@ public boolean visit(BlockPos pos, double tEnter) { assertEquals("and must report exactly what it examined", 7, seen[0]); } + /** + * Every voxel is reported WITH the way the segment got in, and the way in points back the way it + * came. Anything answering a contact needs that normal, and deriving it afterwards from the entry + * point is exactly what cannot be done at a corner — which is why the traversal, the only thing + * that knows which axis it stepped, is what says it. + */ + @Test + public void everyVoxelIsReportedWithTheFaceItWasEnteredThrough() { + List east = walk(new Vec3d(0.5D, 0.5D, 0.5D), new Vec3d(4.5D, 0.5D, 0.5D)); + assertNull("the voxel the segment starts in was not entered through anything", + east.get(0).entryFace); + for (int i = 1; i < east.size(); i++) { + assertEquals("a segment travelling +X enters each next voxel through its WEST face, whose" + + " normal points back at where the segment came from", EnumFacing.WEST, + east.get(i).entryFace); + } + + List down = walk(new Vec3d(0.5D, 8.5D, 0.5D), new Vec3d(0.5D, 4.5D, 0.5D)); + for (int i = 1; i < down.size(); i++) { + assertEquals("a segment travelling down enters through the UP face", EnumFacing.UP, + down.get(i).entryFace); + } + } + + /** On a diagonal the face changes with the axis actually crossed, never staying on one. */ + @Test + public void aDiagonalReportsWhicheverAxisItActuallyCrossed() { + List diagonal = walk(new Vec3d(0.5D, 0.5D, 0.5D), new Vec3d(6.5D, 0.5D, 6.5D)); + boolean sawWest = false; + boolean sawNorth = false; + for (int i = 1; i < diagonal.size(); i++) { + EnumFacing face = diagonal.get(i).entryFace; + assertNotNull("every entered voxel carries the face it was entered through", face); + sawWest |= face == EnumFacing.WEST; + sawNorth |= face == EnumFacing.NORTH; + assertTrue("a segment moving +X +Z can only enter through a WEST or a NORTH face, never" + + " the ones it is travelling away from: " + face, + face == EnumFacing.WEST || face == EnumFacing.NORTH); + } + assertTrue("a 45-degree segment must cross both axes, not just one", sawWest && sawNorth); + } + private static final class Visit { private final BlockPos pos; private final double t; + private final EnumFacing entryFace; - private Visit(BlockPos pos, double t) { + private Visit(BlockPos pos, double t, EnumFacing entryFace) { this.pos = pos; this.t = t; + this.entryFace = entryFace; } } } From 81d8be5dc5ff6c12b740f2c051fa2731c4cb139a Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 18 Aug 2026 08:06:17 +0300 Subject: [PATCH 16/35] feat: a round bores over ticks instead of ending at the surface - a hull crossing grants only the path travelled this tick - spending energy costs speed, for bodies whose speed it is coupled to - stage price scales with the body's cross-section, reference area unchanged - a resumed bore is not charged twice for the block it stands in --- .../advancedRocketry/api/ARConfiguration.java | 2 + .../api/damage/DamageReport.java | 19 ++ .../api/damage/ImpactRequest.java | 75 ++++++ .../command/test/TestProbeCommand.java | 7 + .../damage/ShipDamageService.java | 11 +- .../damage/StructureDamageEngine.java | 79 ++++++- .../projectile/ContactResolver.java | 90 ++++++-- .../projectile/ShotSubstrate.java | 78 ++++++- .../test/server/ShieldDamageDegradesTest.java | 5 + .../test/server/ShotBoresOverTimeE2ETest.java | 213 ++++++++++++++++++ .../test/server/ShotHitsShipHullE2ETest.java | 59 +++-- 11 files changed, 571 insertions(+), 67 deletions(-) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 152ece12c..c4977af68 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -401,6 +401,7 @@ public class ARConfiguration { */ @ConfigProperty(needsSync = true) public double shotReflectionSpeedFloor = 0.05; + public double shotPenetrationSpeedFloor = 0.05; /** * 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. @@ -725,6 +726,7 @@ public static void loadPreInit() { 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.enableProjectileSubstrate = config.get(WEAPONS, "enableProjectileSubstrate", true, "Track fired shots as server-side records that fly across loaded and unloaded space alike. Turn off to disable long-range fire entirely: nothing is admitted and nothing in flight is stepped").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.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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, 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(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java index f483540ec..ce1b14cc1 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/DamageReport.java @@ -22,10 +22,18 @@ public final class DamageReport { 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; @@ -35,6 +43,7 @@ public DamageReport(DamageOutcome outcome, StopReason stopReason, int budgetSpen this.entryPoint = entryPoint; this.exitPoint = exitPoint; this.penetrationDepth = penetrationDepth; + this.distanceWalked = Math.max(0.0D, distanceWalked); } /** Nothing damageable was met: no spend, no change. */ @@ -85,6 +94,16 @@ public Vec3d getExitPoint() { } /** 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/ImpactRequest.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java index 4d4e1b3c6..809671291 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java @@ -33,15 +33,39 @@ public final class ImpactRequest { private final int budget; private final ImpactKind kind; private final SelectionMode selectionMode; + private final double reachBlocks; + private final double crossSectionArea; + private final boolean resumesInside; + + /** 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; } /** A solid body striking at a point and boring along its direction of travel. */ @@ -50,6 +74,33 @@ public static ImpactRequest penetrating(long impactId, Vec3d point, Vec3d direct 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); + } + /** Identity for retry refusal; see the class note. */ public long getImpactId() { return impactId; @@ -78,6 +129,30 @@ 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); diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index c366434d1..c752a750c 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -312,6 +312,13 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] 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; diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java index 0aae2c5bd..7b8047862 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java @@ -95,7 +95,8 @@ public static DamageReport apply(World world, ImpactRequest request) { if (shipId == null) { remember(world, request.getImpactId()); return toReport(StructureDamageEngine.penetrate(world, point, request.getDirection(), - request.getBudget()), null, world); + request.getBudget(), request.getReachBlocks(), request.getCrossSectionArea(), + request.resumesInside()), null, world); } double[] shipPoint = VSIntegration.toShipFrameFor(world, shipId, point.x, point.y, point.z); @@ -111,7 +112,8 @@ public static DamageReport apply(World world, ImpactRequest request) { remember(world, request.getImpactId()); StructureDamageEngine.WalkResult walk = StructureDamageEngine.penetrate(world, new Vec3d(shipPoint[0], shipPoint[1], shipPoint[2]), - new Vec3d(shipDir[0], shipDir[1], shipDir[2]), request.getBudget()); + new Vec3d(shipDir[0], shipDir[1], shipDir[2]), request.getBudget(), + request.getReachBlocks(), request.getCrossSectionArea(), request.resumesInside()); return toReport(walk, shipId, world); } @@ -197,8 +199,11 @@ private static String shipManagingPoint(World world, Vec3d point) { private static DamageReport toReport(StructureDamageEngine.WalkResult walk, String shipId, World world) { Vec3d entry = toWorld(world, shipId, walk.entryPoint); Vec3d exit = walk.outcome == DamageOutcome.EXITED ? toWorld(world, shipId, walk.exitPoint) : null; + // The distance needs no frame conversion: a ship's transform is rigid, so a length in its + // subspace is that same length in the world. return new DamageReport(walk.outcome, walk.stopReason, walk.budgetSpent, walk.budgetLeft, - walk.blocksStaged, walk.blocksDestroyed, entry, exit, walk.penetrationDepth); + walk.blocksStaged, walk.blocksDestroyed, entry, exit, walk.penetrationDepth, + walk.distanceWalked); } private static Vec3d toWorld(World world, String shipId, Vec3d local) { diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 509a5198a..81a860952 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -6,6 +6,7 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.api.damage.StopReason; import zmaster587.advancedRocketry.util.SweptSegment; import zmaster587.advancedRocketry.util.WeightEngine; @@ -75,6 +76,39 @@ private StructureDamageEngine() { * in a row convince the walk it has come out the far side of a hull it is still inside.

    */ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget) { + return penetrate(world, entry, direction, budget, ImpactRequest.UNBOUNDED_REACH, + ImpactRequest.REFERENCE_AREA); + } + + /** + * The same walk, bounded by how far the body actually got this time and priced against its + * cross-section. + * + *

    Reach

    + *

    A body that penetrates over time may only spend the path it travelled - the alternative, + * resolving a whole bore in the tick it started, is what made a shot's whole life happen inside + * one impact. {@link #MAX_PATH_BLOCKS} stays as the backstop for a caller with no notion of reach + * (an explosion, a collision), so nothing walks away forever.

    + * + *

    Area

    + *

    Material resists with a pressure, so the energy per unit of depth is that pressure times the + * body's cross-section: the same energy behind a wider face bores less far, and behind a narrower + * one bores further. At {@link ImpactRequest#REFERENCE_AREA} the price is exactly what it was + * before any of this was priced, which is what keeps every shipped weapon where it was.

    + */ + public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget, + double reachBlocks, double crossSectionArea) { + return penetrate(world, entry, direction, budget, reachBlocks, crossSectionArea, false); + } + + /** + * The same walk, told whether the body is CONTINUING a bore through the block it starts in. A body + * that is already inside one has paid for it on an earlier tick; charging it again every tick it + * fails to leave would make a slow round strictly deadlier than a fast one. + */ + public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget, + double reachBlocks, double crossSectionArea, + boolean resumesInside) { WalkResult result = new WalkResult(); result.budgetLeft = budget; if (world == null || entry == null || direction == null @@ -84,7 +118,8 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in return result; } - Walk walk = new Walk(world, entry, direction, result); + Walk walk = new Walk(world, entry, direction, result, reachBlocks, crossSectionArea, + resumesInside); SweptSegment.traverse(entry, walk.farEnd, MAX_VOXELS_EXAMINED, walk); return walk.finish(); } @@ -101,20 +136,30 @@ private static final class Walk implements SweptSegment.Visitor { /** The far end of the reach, {@link #MAX_PATH_BLOCKS} blocks of RAY along the direction. */ private final Vec3d farEnd; + private final double areaFactor; + /** True while the first voxel is still to come: it is already paid for, so it is not charged. */ + private boolean skipThisVoxel; + /** How far the far end is, in blocks: what a parameter along the segment is measured against. */ + private final double reach; + private boolean entered; private boolean decided; private int consecutiveEmpty; private boolean previousWasSolid; private Vec3d lastSolidExit; - private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result) { + private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result, double reachBlocks, + double crossSectionArea, boolean resumesInside) { + this.skipThisVoxel = resumesInside; this.world = world; this.entry = entry; this.result = result; + this.areaFactor = crossSectionArea / ImpactRequest.REFERENCE_AREA; double length = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); Vec3d unit = length <= 1.0E-9D ? direction : scale(direction, 1.0D / length); - this.farEnd = entry.add(scale(unit, MAX_PATH_BLOCKS)); + this.reach = Math.min(reachBlocks, MAX_PATH_BLOCKS); + this.farEnd = entry.add(scale(unit, this.reach)); } @Override @@ -146,8 +191,15 @@ public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing result.entryPoint = here; } result.penetrationDepth++; + result.distanceWalked = tEnter * reach; previousWasSolid = true; + if (skipThisVoxel) { + // The block this bore is standing in, already bought on an earlier tick. + skipThisVoxel = false; + return false; + } + if (isIndestructible(world, pos, state)) { // Nothing gets through this. The budget dies here rather than tunnelling past it. result.budgetSpent += result.budgetLeft; @@ -155,7 +207,7 @@ public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); } - spendInto(world, pos, state, result); + spendInto(world, pos, state, result, areaFactor); if (result.budgetLeft <= 0) { return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); } @@ -189,10 +241,11 @@ private WalkResult finish() { } /** Spend as much of the remaining budget into one block as its stages will take. */ - private static void spendInto(World world, BlockPos pos, IBlockState state, WalkResult result) { + private static void spendInto(World world, BlockPos pos, IBlockState state, WalkResult result, + double areaFactor) { int maxStage = DamageState.getMaxStage(world, pos); int stage = DamageState.getStage(world, pos); - int stageCost = stageCost(world, pos); + int stageCost = stageCost(world, pos, areaFactor); boolean advanced = false; while (stage < maxStage && result.budgetLeft >= stageCost) { @@ -222,10 +275,20 @@ private static void spendInto(World world, BlockPos pos, IBlockState state, Walk * that has been taken is damage the next hit does not have to do again. */ public static int stageCost(World world, BlockPos pos) { + return stageCost(world, pos, 1.0D); + } + + /** + * What one stage costs a body of a given cross-section, as a multiple of the reference one. The + * material resists with a pressure; a wider body pushes that pressure over more area and pays + * proportionally more for the same depth, which is where sectional density comes from without + * anybody writing it down as a rule. + */ + public static int stageCost(World world, BlockPos pos, double areaFactor) { double toughness = WeightEngine.INSTANCE.getToughness(world, pos); int maxStage = Math.max(1, DamageState.getMaxStage(world, pos)); double perStage = (STAGE_COST_BASE + toughness * STAGE_COST_TOUGHNESS_MULT) / maxStage; - return Math.max(1, (int) Math.ceil(perStage)); + return Math.max(1, (int) Math.ceil(perStage * Math.max(0.0D, areaFactor))); } /** @@ -255,6 +318,8 @@ public static final class WalkResult { public int blocksStaged; public int blocksDestroyed; public int penetrationDepth; + /** How far along its direction the walk got before it stopped, in blocks. */ + public double distanceWalked; public Vec3d entryPoint; public Vec3d exitPoint; } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index 952413435..b6042be7e 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -8,6 +8,7 @@ import zmaster587.advancedRocketry.api.damage.Contact; import zmaster587.advancedRocketry.api.damage.ContactResult; import zmaster587.advancedRocketry.api.damage.IContactResponder; +import zmaster587.advancedRocketry.api.damage.DamageReport; import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.damage.ShipDamageService; import zmaster587.advancedRocketry.integration.vs.VSIntegration; @@ -39,10 +40,26 @@ private ContactResolver() { * world point * @param worldVelocity the shot's velocity in WORLD terms */ - public static ContactResult resolve(World world, Shot shot, StructureCrossing.Hit hit, - Vec3d worldVelocity) { + /** + * What happened, AND how far along its own direction the body got while it happened. The distance + * is not part of {@link ContactResult} on purpose: a block answering a contact says what becomes + * of the body, not how the substrate should move it, and giving armour a way to state a distance + * would be giving it a way to teleport a round. + */ + public static final class Resolution { + public final ContactResult result; + public final double distance; + + Resolution(ContactResult result, double distance) { + this.result = result; + this.distance = Math.max(0.0D, distance); + } + } + + public static Resolution resolve(World world, Shot shot, StructureCrossing.Hit hit, + Vec3d worldVelocity, double reachBlocks, boolean resumingBore) { if (world == null || shot == null || hit == null) { - return ContactResult.stopped(); + return new Resolution(ContactResult.stopped(), 0.0D); } Contact contact = new Contact(hit.block, hit.point, hit.entryFace, @@ -53,25 +70,53 @@ public static ContactResult resolve(World world, Shot shot, StructureCrossing.Hi if (responder != null) { ContactResult answer = responder.onContact(contact); if (answer != null) { - return answer; + // A block that answered for itself did not walk anything, so the body is advanced past + // the block it was answered by — otherwise the next test finds the same block, asks + // again, and a round argues with one plate until the tick's crossing budget runs out. + return new Resolution(answer, answer.isStopped() ? 0.0D : 1.0D); } } - return defaultLaw(world, shot, contact); + return defaultLaw(world, shot, contact, reachBlocks, resumingBore); + } + + /** How far a body of this radius reaches across, in square blocks. */ + public static double areaOf(double radius) { + double r = Math.max(radius, 0.0D); + return r <= 0.0D ? ImpactRequest.REFERENCE_AREA : Math.PI * r * r; } /** - * What an ordinary block does: absorb the impact through the damage service and stop the body. + * What an ordinary block does: resist with a pressure, and let through whatever the body still has + * after paying for the depth it managed. + * + *

    Penetration takes time. The impact is granted only the path the body actually + * travelled this tick, so boring through a hull is a thing that happens over several ticks rather + * than an event resolved in the tick it began. What comes back is the budget the walk could not + * spend, and that is what the body carries on with: a round that ran out inside the armour is + * stopped, and one that still has something left keeps going.

    * - *

    The whole energy is declared, exactly as before this seam existed — a shot does not yet - * survive a hull, and making it survive is a separate decision with its own consequences (the - * deceleration law, a speed floor, an identity per tick). Wiring it here would have smuggled that - * change in under a refactor.

    + *

    The body's cross-section rides along, because the material resists with a pressure: the same + * energy behind a wider face buys less depth. At the reference cross-section the price is what it + * always was.

    */ - private static ContactResult defaultLaw(World world, Shot shot, Contact contact) { - ShipDamageService.apply(world, ImpactRequest.penetrating(shot.nextImpactId(), - contact.getPoint(), directionOf(contact, shot), contact.getEnergy(), - contact.getKind())); - return ContactResult.stopped(); + private static Resolution defaultLaw(World world, Shot shot, Contact contact, double reachBlocks, + boolean resumingBore) { + ImpactRequest request = resumingBore + ? ImpactRequest.resuming(shot.nextImpactId(), contact.getPoint(), + directionOf(contact, shot), contact.getEnergy(), contact.getKind(), + reachBlocks, areaOf(contact.getRadius())) + : ImpactRequest.penetrating(shot.nextImpactId(), contact.getPoint(), + directionOf(contact, shot), contact.getEnergy(), contact.getKind(), + reachBlocks, areaOf(contact.getRadius())); + DamageReport report = ShipDamageService.apply(world, request); + + int residual = report.getBudgetLeft(); + if (residual <= 0) { + return new Resolution(ContactResult.stopped(), report.getDistanceWalked()); + } + // It got through what it met, or as far as this tick's travel allowed. Either way it is still + // a shot, and the substrate advances it by what the walk says it covered. + return new Resolution(ContactResult.passedThrough(residual), report.getDistanceWalked()); } /** @@ -88,9 +133,8 @@ private static Vec3d directionOf(Contact contact, Shot shot) { /** * The shot's velocity expressed in the frame the block lives in — itself off a ship, rotated into - * subspace on one. Done by mapping two world points a velocity apart and subtracting: a ship's - * transform is rigid, so the difference of two mapped points IS the mapped vector, and it needs no - * port surface beyond the one the crossing already uses. + * subspace on one, through the port's own vector rotation rather than a difference of two mapped + * points (which is the same thing when the transform is rigid, and one more place to drift). */ private static Vec3d inBlockFrame(World world, StructureCrossing.Hit hit, Vec3d worldVelocity) { if (worldVelocity == null) { @@ -99,17 +143,15 @@ private static Vec3d inBlockFrame(World world, StructureCrossing.Hit hit, Vec3d if (hit.shipId == null) { return worldVelocity; } - double[] base = VSIntegration.toShipFrameFor(world, hit.shipId, hit.point.x, hit.point.y, - hit.point.z); - double[] tip = VSIntegration.toShipFrameFor(world, hit.shipId, hit.point.x + worldVelocity.x, - hit.point.y + worldVelocity.y, hit.point.z + worldVelocity.z); - if (base == null || tip == null) { + double[] rotated = VSIntegration.rotateToShipFrameFor(world, hit.shipId, worldVelocity.x, + worldVelocity.y, worldVelocity.z); + if (rotated == null) { // The ship stopped answering between the crossing and here. A world-frame velocity against // a subspace face would be a plausible-looking angle about nothing, so answer with no // velocity at all: the incidence reads square-on, which is the reading that never bounces. return null; } - return new Vec3d(tip[0] - base[0], tip[1] - base[1], tip[2] - base[2]); + return new Vec3d(rotated[0], rotated[1], rotated[2]); } /** The block's own answer, then its tile's; null when neither has one. */ diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 84ff87a88..253eb4b15 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -6,7 +6,7 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.ARConfiguration; -import zmaster587.advancedRocketry.api.damage.ContactResult; +import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.api.projectile.ShotEndReason; import zmaster587.advancedRocketry.api.projectile.ShotSpec; @@ -77,6 +77,34 @@ public static long launch(World world, ShotSpec spec) { return id; } + /** + * What is left of a body's velocity after it has spent energy boring. + * + *

    Kinetic energy goes as the square of speed, so a body that has spent a fraction of its energy + * keeps the square root of what remains: {@code v' = v·sqrt(E'/E)}. Written as a RATIO rather than + * from {@code sqrt(2E/m)} on purpose — the ratio needs no mass, and a mass of zero is a legitimate + * declaration for a body that is not a lump of metal.

    + * + *

    Speed and energy are coupled only for a body with mass. A beam pays for depth like + * anything else — it is the same pressure over the same area — but it does not decelerate, because + * a beam that has spent half its energy is DIMMER, not slower: its energy is amplitude and its + * speed is its own. A mass of zero is how the formula announces that the relationship does not + * exist for this thing, rather than a physical claim about how fast a massless body travels.

    + */ + private static Vec3d slowedByWorkDone(Vec3d velocity, int energyBefore, int energyAfter, + ImpactKind kind) { + if (!carriesMass(kind) || energyBefore <= 0 || energyAfter >= energyBefore) { + return velocity; + } + double ratio = Math.sqrt(Math.max(0.0D, (double) energyAfter / (double) energyBefore)); + return velocity.scale(ratio); + } + + /** Which kinds are a lump of something travelling, as opposed to energy arriving. */ + private static boolean carriesMass(ImpactKind kind) { + return kind == ImpactKind.KINETIC || kind == ImpactKind.EXPLOSIVE; + } + /** Advance every shot in this world by one tick. Driven by {@link ShotSubstrateEvents}. */ public static void tick(World world) { if (world == null || world.isRemote @@ -146,23 +174,49 @@ static ShotEndReason step(World world, Shot shot) { shot.setVelocity(velocity); // The block decides, this loop obeys — the same relationship the shell above already - // has with the field layer. Today every ordinary block answers "stopped", which is - // exactly what happened before there was a contract; armour is what makes the other - // two answers reachable. - ContactResult contact = ContactResolver.resolve(world, shot, structure, velocity); - if (contact.isStopped()) { + // has with the field layer. What it is granted is only the path still left in THIS + // tick after reaching the surface: boring is a thing that takes time, so a round that + // meets armour spends the rest of the tick inside it rather than resolving its whole + // life at the moment of contact. + int energyBefore = shot.getImpactEnergy(); + double reachInside = Math.max(0.0D, reach - structure.distance); + // A crossing found at zero distance is a bore this shot began on an earlier tick: it + // is standing in that block, and it paid for it then. + boolean resuming = structure.distance <= CROSSING_EPSILON * 2.0D; + ContactResolver.Resolution contact = ContactResolver.resolve(world, shot, structure, + velocity, reachInside, resuming); + if (contact.result.isStopped()) { + // It came to rest where the walk stopped, not where it went in. + shot.setPosition(structure.point.add(direction.scale(contact.distance))); + shot.setVelocity(new Vec3d(0.0D, 0.0D, 0.0D)); return ShotEndReason.STRUCTURE_IMPACT; } - double consumed = (structure.distance + CROSSING_EPSILON) / speed; - timeLeft -= consumed; - shot.setImpactEnergy(contact.getResidualEnergy()); - if (contact.isDeflected()) { - velocity = contact.getDeflectedVelocity(); + shot.setImpactEnergy(contact.result.getResidualEnergy()); + + if (contact.result.isDeflected()) { + timeLeft -= (structure.distance + CROSSING_EPSILON) / speed; + velocity = contact.result.getDeflectedVelocity(); position = structure.point.add(velocity.normalize().scale(CROSSING_EPSILON)); } else { - position = structure.point.add(direction.scale(CROSSING_EPSILON)); + // It is still going, so it used this tick's travel: it is as deep as its speed + // took it, and no deeper. That is the whole of "penetration takes time" — the + // depth per tick is the distance per tick, and the next tick starts from here. + position = structure.point.add(direction.scale(reachInside)); + timeLeft = 0.0D; + velocity = slowedByWorkDone(velocity, energyBefore, shot.getImpactEnergy(), + shot.getKind()); + } + + if (velocity.lengthVector() + < ARConfiguration.getCurrentConfig().shotPenetrationSpeedFloor) { + // It is still inside something and no longer travelling: it came to rest there. + shot.setPosition(position); + shot.setVelocity(new Vec3d(0.0D, 0.0D, 0.0D)); + return ShotEndReason.STRUCTURE_IMPACT; } + shot.setPosition(position); + shot.setVelocity(velocity); continue; } if (!fieldFirst) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java index 5db13d282..17a01c45c 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShieldDamageDegradesTest.java @@ -186,6 +186,11 @@ private int shootUntilFieldShrinks(int ex, int radiusBefore) throws Exception { hit(ex); // The emitter re-reads its own condition on its tick, and a lit field costs energy to // hold, so keep feeding it: a dark shield covers nothing for reasons unrelated to damage. + // The FEED is the point — an emitter left to drain goes dark on a slow loop, and this + // test then fails about power while claiming to be about radius (seen under parallel load + // 2026-08-17, green serially). + exec("artest energy inject " + DIM + " " + (ex - 1) + " " + Y + " " + Z + " 8000"); + exec("artest tile force-tick " + DIM + " " + (ex - 1) + " " + Y + " " + Z + " 1"); exec("artest tile force-tick " + DIM + " " + ex + " " + Y + " " + Z + " 2"); exec("artest shield tick " + DIM); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java new file mode 100644 index 000000000..f4a3b8850 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java @@ -0,0 +1,213 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A round does not finish its whole life in the tick it touches a hull. + * + *

    Before this, meeting structure was terminal: one call resolved a bore up to sixty-four blocks + * deep and the round ceased to exist at the surface. Now it keeps being a round while it bores + * — it is still there next tick, deeper and worth less, until it either comes out the far side or + * runs out inside. That is one claim and it is the one worth a server test, because nothing smaller + * can exhibit it: it is a statement about what is true BETWEEN two ticks.

    + * + *

    The second claim is the only body ordering the penetration law makes on its own: at the same + * energy, the narrower round goes deeper. Not a depth — an ordering, because the depth is balance.

    + */ +public class ShotBoresOverTimeE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 70; + private static final int Z = 870; + private static final long TIMEOUT_MS = 25_000L; + + /** Slow on purpose: a round that crosses a block per tick cannot be caught in the middle of one. */ + private static final double BORE_SPEED = 0.45D; + + private static final Pattern PRESENT = Pattern.compile("\"present\":(true|false)"); + private static final Pattern ENERGY = Pattern.compile("\"energy\":(-?\\d+)"); + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern STAGE = Pattern.compile("\"stage\":(-?\\d+)"); + + @Test + public void aRoundKeepsBoringAcrossTicksInsteadOfEndingAtTheSurface() throws Exception { + int wallX = 1400; + prepare(wallX); + buildWall(wallX, 10); + + // Sized from what a block of this wall actually costs, read off the probe rather than guessed: + // the price comes from the toughness table, which is balance and will move, and a hard-coded + // budget silently becomes "sails clean through" the day it does. + int budget = budgetForBlocks(wallX, 3.5D); + long id = fire(wallX - 3.5D, BORE_SPEED, budget, 0.25D); + assertTrue("the substrate refused the shot, so there is nothing to observe: id=" + id, id >= 0); + + // The moment of contact: energy starts falling. The round must still EXIST at that moment — + // this is the whole difference from the behaviour this replaces. + String duringBore = awaitEnergyBelow(id, budget); + assertTrue("the round ended in the tick it met the wall, which is exactly the behaviour" + + " penetration-over-time replaces:\n" + duringBore, isPresent(duringBore)); + long energyInside = energyOf(duringBore); + + // ...and it is still spending, tick after tick, while it is in there. + String later = awaitEnergyBelow(id, energyInside); + assertTrue("the round stopped paying for its depth while still inside the wall — then it is" + + " not boring, it is parked:\n" + later, energyOf(later) < energyInside); + + // It ends inside rather than sailing through: the wall is thicker than its budget. + assertTrue("a round with a fraction of the budget the wall costs came out the other side:\n" + + read(id), awaitGone(id)); + + // And it left a bore, not a crater: the front of the wall is gone or damaged, and the far + // side of it was never reached. + assertTrue("the wall's front block is untouched, so the round never actually spent anything" + + " into it: " + stageAt(wallX), stageOf(stageAt(wallX)) > 0 || destroyed(wallX)); + assertTrue("the round reached the far side of a wall it could not afford: " + stageAt(wallX + 9), + stageOf(stageAt(wallX + 9)) == 0 && !destroyed(wallX + 9)); + } + + /** + * The one body ordering the law makes by itself: energy buys depth against the material's + * resistance ACROSS THE BODY'S FACE, so the same energy through a narrower round goes further. + */ + @Test + public void aNarrowerRoundOutrunsAWiderOneOnTheSameEnergy() throws Exception { + int narrowX = 1440, wideX = 1470; + prepare(narrowX); + prepare(wideX); + buildWall(narrowX, 10); + buildWall(wideX, 10); + + int budget = budgetForBlocks(narrowX, 3.5D); + long narrow = fire(narrowX - 3.5D, BORE_SPEED, budget, 0.25D); + long wide = fire(wideX - 3.5D, BORE_SPEED, budget, 0.75D); + assertTrue("both rounds must be admitted or the comparison is about one of them: " + narrow + + " / " + wide, narrow >= 0 && wide >= 0); + + awaitGone(narrow); + awaitGone(wide); + + int narrowDepth = boreDepth(narrowX); + int wideDepth = boreDepth(wideX); + assertTrue("the narrow round must bore at least as deep as the wide one on the same energy" + + " (narrow=" + narrowDepth + " wide=" + wideDepth + "): the material resists across" + + " the body's face, so a wider face buys less depth per unit of energy", + narrowDepth > wideDepth); + assertTrue("the narrow round did not get into the wall at all, so the comparison is between" + + " two zeroes", narrowDepth > 0); + } + + // ---- driving + + private long fire(double x, double speed, int energy, double radius) throws Exception { + String resp = exec("artest shot fire " + DIM + " " + x + " " + (Y + 0.5D) + " " + (Z + 0.5D) + + " " + speed + " 0 0 " + energy + " 1200 KINETIC " + radius + " 1.0"); + Matcher m = ID.matcher(resp); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private void buildWall(int fromX, int depth) throws Exception { + for (int i = 0; i < depth; i++) { + place("minecraft:stone", fromX + i); + } + } + + private void prepare(int wallX) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + + ((wallX - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + ((wallX + 24) >> 4) + " " + + ((Z + 16) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (wallX - 8) + " " + + (Y - 2) + " " + (Z - 3) + " " + (wallX + 20) + " " + (Y + 4) + " " + (Z + 3) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + /** Poll until the round's energy drops below {@code above}, or the budget runs out. */ + private String awaitEnergyBelow(long id, long above) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(id); + while (System.currentTimeMillis() < deadline && isPresent(state) && energyOf(state) >= above) { + Thread.sleep(120L); + state = read(id); + } + return state; + } + + private boolean awaitGone(long id) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (System.currentTimeMillis() < deadline && isPresent(read(id))) { + Thread.sleep(150L); + } + return !isPresent(read(id)); + } + + /** How many blocks deep into the wall took damage: the bore's own length. */ + private int boreDepth(int wallX) throws Exception { + int depth = 0; + for (int i = 0; i < 10; i++) { + String stage = stageAt(wallX + i); + if (stageOf(stage) > 0 || destroyed(wallX + i)) { + depth = i + 1; + } + } + return depth; + } + + /** What boring {@code blocks} of this wall costs at the reference cross-section, priced by the game. */ + private int budgetForBlocks(int wallX, double blocks) throws Exception { + String stage = stageAt(wallX); + int cost = readInt(stage, "stageCost"); + int stages = Math.max(1, readInt(stage, "maxStage")); + assertTrue("the wall block has no stage cost, so nothing below is priced: " + stage, cost > 0); + return (int) Math.round(cost * stages * blocks); + } + + private static int readInt(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : -1; + } + + private String stageAt(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private boolean destroyed(int x) throws Exception { + return stageAt(x).contains("\"wasDestroyed\":true"); + } + + private static int stageOf(String json) { + Matcher m = STAGE.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 0; + } + + private String read(long id) throws Exception { + return exec("artest shot read " + DIM + " " + id); + } + + private static boolean isPresent(String json) { + Matcher m = PRESENT.matcher(json); + return m.find() && "true".equals(m.group(1)); + } + + private static long energyOf(String json) { + Matcher m = ENERGY.matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private void place(String block, int x) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + Y + " " + Z + " " + block); + assertTrue("failed to place " + block + " at " + x + ": " + resp, + resp.contains("\"placed\":true")); + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java index 2bebbd425..9a65306d8 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java @@ -23,9 +23,15 @@ *

    Two controls, both asserted before any conclusion is drawn. The world frame at the target must * genuinely hold air, so a hit cannot have come from the world-frame traversal; and the * subject block must be undamaged at its subspace address beforehand, so "damaged afterwards" is - * about this shot. The end point is then checked against the ship's WORLD position — with the - * mapping-back-out leg deleted, a shot would report ending five million blocks away in a shipyard - * nobody can see, and every other assertion here would still pass.

    + * about this shot. The shot's own position after the crossing is then checked against the ship's + * WORLD position — with the mapping-back-out leg deleted it would be five million blocks away in a + * shipyard nobody can see, and every other assertion here would still pass.

    + * + *

    Retired 2026-08-17, and worth saying why: this used to assert that the round ENDED at the + * hull. It no longer does, because penetration takes time — a round carrying more than the hull costs + * is now correct to punch through and fly on, and a test that kept the old clause would be pinning + * behaviour the game deliberately dropped. What replaced it says the same thing about the frame + * without saying anything about stopping power: the round's budget fell by what the hull cost it.

    */ public class ShotHitsShipHullE2ETest extends AbstractSharedServerTest { @@ -40,11 +46,17 @@ public class ShotHitsShipHullE2ETest extends AbstractSharedServerTest { /** Fast enough that one tick's segment crosses the whole hull — the case a point test misses. */ private static final double SPEED = 40.0D; - /** Enough budget to be spent on more than the first block it meets. */ - private static final int ENERGY = 200000; + /** + * How much of a block's destruction price the round is given, as a multiple. Enough to spend into + * the hull and stop inside it, not enough to bore out the far side — since penetration takes time + * a round richer than the hull is CORRECT to fly on through, and this test is about frames, not + * about stopping power. Priced off the target block's own cost, never hard-coded: that cost comes + * from the toughness table, which is balance and moves. + */ + private static final double BUDGET_IN_BLOCKS = 1.5D; @Test - public void aShotStopsAtTheHullOfAMovedShipAndDamagesItsOwnBlock() throws Exception { + public void aShotFindsAMovedShipsHullInItsOwnFrameAndDamagesTheRightBlock() throws Exception { Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); exec("artest vs permaload true"); exec("artest damage clear-impacts"); @@ -97,17 +109,22 @@ public void aShotStopsAtTheHullOfAMovedShipAndDamagesItsOwnBlock() throws Except readLong(before, "stage") == 0); // Fire straight down through the seat's WORLD position, from clear air above it. + int energy = (int) Math.round(readLong(before, "stageCost") * Math.max(1L, + readLong(before, "maxStage")) * BUDGET_IN_BLOCKS); + assertTrue("the target block has no price, so the round's budget would be meaningless: " + + before, energy > 0); long id = readLong(exec("artest shot fire 0 " + worldX + " " + (worldY + 30.0D) + " " + worldZ - + " 0 " + (-SPEED) + " 0 " + ENERGY + " 40"), "id"); + + " 0 " + (-SPEED) + " 0 " + energy + " 40"), "id"); assertTrue("the launch was refused, so nothing else here means anything", id > 0); exec("artest shield tick 0"); String after = exec("artest shot read 0 " + id); - assertTrue("the shot is still in flight after a step that crossed the hull — a segment computed" - + " in the world frame finds nothing where a ship visibly is, which is exactly what" - + " this substrate maps around: " + after, after.contains("\"present\":false")); - assertTrue("the shot stopped, but not by meeting structure: " + after, - "STRUCTURE_IMPACT".equals(extractString(after, "ended"))); + assertTrue("the shot must still be readable — present in flight, or remembered as ended: " + + after, after.contains("\"ok\":true")); + assertTrue("the shot's budget is untouched after a step that crossed the hull — a segment" + + " computed in the world frame finds nothing where a ship visibly is, which is" + + " exactly what this substrate maps around: " + after, + readLong(after, "energy") < energy); // The damage landed on the SHIP's own block, at its subspace address. String hull = stage(subX, subY, subZ); @@ -118,15 +135,15 @@ public void aShotStopsAtTheHullOfAMovedShipAndDamagesItsOwnBlock() throws Except + " subspace address (before=" + before + " after=" + hull + "): the impact was handed" + " over in the wrong frame, or to the wrong target", staged || destroyed); - // And the shot ended in WORLD coordinates. Without the mapping back out it would report - // ending at a shipyard address millions of blocks from anything a player can see — and every - // assertion above would still have passed. - double endX = readDouble(after, "endX"), endY = readDouble(after, "endY"), - endZ = readDouble(after, "endZ"); - double offSeat = Math.sqrt(sq(endX - worldX) + sq(endY - worldY) + sq(endZ - worldZ)); - assertTrue("the shot ended at (" + endX + "," + endY + "," + endZ + "), " + offSeat - + " blocks from the world point it was fired through: the crossing point was never" - + " mapped out of the ship's frame", offSeat < 16.0D); + // And the crossing was expressed in WORLD coordinates. Without the mapping back out the shot + // would be sitting at a shipyard address millions of blocks from anything a player can see — + // and every assertion above would still have passed. The bound is generous on purpose: it is + // a millions-of-blocks error this is built to catch, not a metre. + double atX = readDouble(after, "x"), atY = readDouble(after, "y"), atZ = readDouble(after, "z"); + double offSeat = Math.sqrt(sq(atX - worldX) + sq(atY - worldY) + sq(atZ - worldZ)); + assertTrue("after crossing the hull the shot is at (" + atX + "," + atY + "," + atZ + "), " + + offSeat + " blocks from the world point it was fired through: the crossing point was" + + " never mapped out of the ship's frame", offSeat < 400.0D); } /** Build the fixture, assemble it into a ship and move it far from where it was built. */ From ace00c2c55128aa1d6f22285062bd750d5072fcb Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 18 Aug 2026 09:54:17 +0300 Subject: [PATCH 17/35] fix: a shell is told what kind of shot it stopped - strike kind comes from the hull-kind to shield-kind mapping - a body velocity is declared only for kinds that carry mass - a beam is absorbed by a charged shell, never mirrored - e2e falsified against the previous behaviour --- .../projectile/ShotSubstrate.java | 21 +++++++++- .../test/server/ShotSubstrateE2ETest.java | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 253eb4b15..d43e630c4 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -9,6 +9,7 @@ import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.api.projectile.ShotEndReason; import zmaster587.advancedRocketry.api.projectile.ShotSpec; +import zmaster587.advancedRocketry.damage.ImpactKindMapping; import java.util.List; @@ -105,6 +106,23 @@ private static boolean carriesMass(ImpactKind kind) { return kind == ImpactKind.KINETIC || kind == ImpactKind.EXPLOSIVE; } + /** + * How this shot is declared to a shell: at the rate its own kind is billed at, carrying a body + * only when there is one to carry. + * + *

    Those are two separate questions and they are answered separately. What a shell CHARGES for + * comes from the single declared hull-kind to shield-kind mapping, so a beam is billed against + * the shell's energy resistance rather than as if it were a slug. Whether a BODY travels is what + * decides a mirror off a fully-paid shell, and a beam has nothing to mirror: its energy arrives + * and stays there. Declaring a velocity for one would bounce light off a shield.

    + */ + private static ShieldStrike strikeFor(Shot shot, Vec3d position, Vec3d direction, double reach, + Vec3d velocity) { + ImpactKind kind = shot.getKind(); + return new ShieldStrike(position, direction, reach, shot.getImpactEnergy(), + ImpactKindMapping.toShieldKind(kind), false, carriesMass(kind) ? velocity : null); + } + /** Advance every shot in this world by one tick. Driven by {@link ShotSubstrateEvents}. */ public static void tick(World world) { if (world == null || world.isRemote @@ -226,8 +244,7 @@ static ShotEndReason step(World world, Shot shot) { } ShieldStrikeResult result = ShieldStrikeService.resolve(world, - ShieldStrike.kineticBody(position, direction, reach, shot.getImpactEnergy(), - velocity)); + strikeFor(shot, position, direction, reach, velocity)); if (!result.isIntercepted()) { // The shell was crossed but paid nothing — it went down between the two questions. // Carry on through where it used to be rather than stopping in mid-air. diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java index 385165a2d..e633ccb90 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotSubstrateE2ETest.java @@ -195,6 +195,45 @@ public void aShotThatMeetsAChargedShellBouncesOffItAndStaysUp() throws Exception exec("artest shot clear 0"); } + @Test + public void aBeamIsAbsorbedByAChargedShellRatherThanMirroredOffIt() throws Exception { + // The same shell, the same energy, the same approach as the bounce above — only the KIND + // differs. What the shell does with a strike is decided by what it was told the strike is, so + // a substrate that declares every round as a travelling lump of metal makes the shell mirror + // light. A beam's energy arrives and stays: there is nothing to send back. + exec("artest shot clear 0"); + int gx = 1040, gz = 912, gy = 96; + int ex = gx + 1; + clearShieldSite(gx, gy, gz); + place("affs:shield_generator", gx, gy, gz); + place("affs:field_generator", ex, gy, gz); + for (int i = 0; i < 15; i++) { + exec("artest energy inject 0 " + gx + " " + gy + " " + gz + " 4000"); + exec("artest tile force-tick 0 " + gx + " " + gy + " " + gz + " 1"); + exec("artest shield tick 0"); + } + String emitter = exec("artest shield read 0 " + ex + " " + gy + " " + gz); + assertTrue("the emitter never powered, so there is no shell to absorb anything: " + emitter, + emitter.contains("\"powered\":true")); + + double cz = gz + 0.5D; + double startZ = cz + SHELL_RADIUS + 3.0D; + long id = readLong(exec("artest shot fire 0 " + (ex + 0.5D) + " " + (gy + 0.5D) + " " + startZ + + " 0 0 -4 2000 300 BEAM"), "id"); + assertTrue("the launch was refused", id > 0); + exec("artest shield tick 0"); + + String after = exec("artest shot read 0 " + id); + assertTrue("a beam came back off the shell: the substrate declared it as a travelling body," + + " and a shell mirrors a body it can afford: " + after, + after.contains("\"present\":false")); + assertEquals("the beam ended, but not by being drunk by the shell — a shot that stops for the" + + " wrong stated reason is a weapon that cannot report what happened: " + after, + "FIELD_ABSORBED", extractString(after, "ended")); + + exec("artest shot clear 0"); + } + private void clearShieldSite(int gx, int gy, int gz) throws Exception { assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + ((gx - 16) >> 4) + " " + ((gz - 16) >> 4) + " " + ((gx + 16) >> 4) + " " + ((gz + 16) >> 4)) From cf378e2aac5d6e5b2887c24d1102d93938d98b1c Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 18 Aug 2026 19:11:41 +0300 Subject: [PATCH 18/35] feat: a round drilling a hull goes where the ship goes - a shot inside material is kept in that hull's subspace between ticks - ShotFrame is the one seam: whole conversion or none of it - velocity carries the hull's own motion in and back out again - the first crossing of an embedded tick asks that hull alone - NBT hull key states which frame the coordinates are in --- .../command/test/TestProbeCommand.java | 40 +++-- .../advancedRocketry/projectile/Shot.java | 52 ++++++- .../projectile/ShotFrame.java | 146 ++++++++++++++++++ .../projectile/ShotRegistry.java | 12 +- .../projectile/ShotSubstrate.java | 27 +++- .../projectile/StructureCrossing.java | 16 ++ .../test/server/ShotHitsShipHullE2ETest.java | 128 ++++++++++++++- 7 files changed, 394 insertions(+), 27 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotFrame.java diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index c752a750c..e5a7cfe7c 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -332,7 +332,7 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] sb.append(','); } first = false; - sb.append(shotJson(shot)); + sb.append(shotJson(world, shot)); } send(sender, sb.append("]}").toString()); return; @@ -351,7 +351,7 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] + ",\"count\":" + registry.count() + "}"); return; } - send(sender, "{\"ok\":true,\"present\":true,\"shot\":" + shotJson(shot) + "}"); + send(sender, "{\"ok\":true,\"present\":true,\"shot\":" + shotJson(world, shot) + "}"); return; } if ("clear".equals(sub)) { @@ -706,19 +706,37 @@ private static String jsonError(String message) { return "{\"error\":\"" + escapeJson(message) + "\"}"; } - private static String shotJson(zmaster587.advancedRocketry.projectile.Shot shot) { + /** + * 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\":" + shot.getPosition().x - + ",\"y\":" + shot.getPosition().y - + ",\"z\":" + shot.getPosition().z - + ",\"vx\":" + shot.getVelocity().x - + ",\"vy\":" + shot.getVelocity().y - + ",\"vz\":" + shot.getVelocity().z - + ",\"speed\":" + shot.getSpeed() + + ",\"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() + "\"}"; + + ",\"kind\":\"" + shot.getKind().name() + "\"" + + inHull + "}"; } // Vendored AFFS shield probes ----------------------------------------- diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java index 5b11d0dd7..1d0fc38c4 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java @@ -19,6 +19,14 @@ * A record is simulated by its world's own tick regardless of who is watching, and costs three * vectors.

    * + *

    Which frame its position is in — and why that is not always the world's

    + *

    A free-flying shot is expressed in world coordinates. A shot that is inside somebody's + * material is expressed in that hull's own subspace frame instead ({@link #getHullId()}), because + * a hull is a thing that manoeuvres: a round stored in world coordinates would be left behind by a + * ship that moved between two ticks and would resume the next tick outside the plate it was drilling, + * or inside a plate that had sailed on. Riding the hull's frame makes boring through a hard-turning + * ship and boring through a parked one the same arithmetic.

    + * *

    Mutable, and owned by exactly one thing

    *

    Position, velocity, age and the remaining impact energy change every tick; everything else is * fixed at the muzzle. The record is owned by the {@link ShotRegistry} of one world and is mutated @@ -39,6 +47,12 @@ public final class Shot { private Vec3d position; private Vec3d velocity; + /** + * The hull this shot is currently inside, or null when it is in free flight. While it is set, + * {@link #position} and {@link #velocity} are expressed in THAT ship's subspace frame, and the + * velocity is relative to the hull rather than to the world. + */ + private String hullId; private int age; private int impactEnergy; @@ -62,13 +76,14 @@ public final class Shot { this.position = spec.getOrigin(); this.velocity = spec.getVelocity(); this.impactEnergy = spec.getImpactEnergy(); + this.hullId = null; this.age = 0; this.impactSequence = 0; } private Shot(long id, double radius, double mass, ImpactKind kind, UUID owner, String faction, String guidance, ShotEnvironment environment, int lifetimeTicks, Vec3d position, - Vec3d velocity, int age, int impactEnergy, int impactSequence) { + Vec3d velocity, String hullId, int age, int impactEnergy, int impactSequence) { this.id = id; this.radius = radius; this.mass = mass; @@ -80,6 +95,7 @@ private Shot(long id, double radius, double mass, ImpactKind kind, UUID owner, S this.lifetimeTicks = lifetimeTicks; this.position = position; this.velocity = velocity; + this.hullId = hullId; this.age = age; this.impactEnergy = impactEnergy; this.impactSequence = impactSequence; @@ -89,16 +105,32 @@ public long getId() { return id; } - /** WORLD position. */ + /** + * Position in the frame this shot is currently kept in: the WORLD's while it flies, its hull's + * SUBSPACE while it bores. Anything that needs world coordinates regardless asks + * {@link ShotFrame#worldPosition}. + */ public Vec3d getPosition() { return position; } - /** WORLD velocity, blocks per tick. */ + /** + * Velocity in the same frame as {@link #getPosition()}, blocks per tick — so while this shot is + * inside a hull, it is the velocity RELATIVE to that hull, which is the one boring is done with. + * {@link ShotFrame#worldVelocity} adds the hull's own motion back. + */ public Vec3d getVelocity() { return velocity; } + /** + * The hull this shot is inside, or null in free flight. Non-null means every coordinate on this + * record is that ship's subspace. + */ + public String getHullId() { + return hullId; + } + public double getSpeed() { return velocity.lengthVector(); } @@ -153,6 +185,11 @@ void setVelocity(Vec3d newVelocity) { this.velocity = newVelocity; } + /** Declare which frame the coordinates above are in. Only {@link ShotFrame} may say. */ + void setHullId(String newHullId) { + this.hullId = newHullId; + } + void setImpactEnergy(int newImpactEnergy) { this.impactEnergy = Math.max(0, newImpactEnergy); } @@ -193,6 +230,11 @@ NBTTagCompound writeToNBT() { nbt.setDouble("velX", velocity.x); nbt.setDouble("velY", velocity.y); nbt.setDouble("velZ", velocity.z); + if (hullId != null) { + // Which frame the position above is in. Without it a reloaded save reads a subspace + // triple as a world one and the round reappears in the shipyard. + nbt.setString("hull", hullId); + } nbt.setInteger("age", age); nbt.setInteger("energy", impactEnergy); nbt.setInteger("impactSeq", impactSequence); @@ -216,6 +258,7 @@ static Shot readFromNBT(NBTTagCompound nbt) { nbt.getInteger("lifetime"), new Vec3d(nbt.getDouble("posX"), nbt.getDouble("posY"), nbt.getDouble("posZ")), new Vec3d(nbt.getDouble("velX"), nbt.getDouble("velY"), nbt.getDouble("velZ")), + nbt.hasKey("hull") ? nbt.getString("hull") : null, nbt.getInteger("age"), nbt.getInteger("energy"), nbt.getInteger("impactSeq")); } @@ -229,7 +272,8 @@ private static UUID parseUuid(String value) { @Override public String toString() { - return "Shot#" + id + "[pos=" + position + " vel=" + velocity + " energy=" + impactEnergy + return "Shot#" + id + "[pos=" + position + " vel=" + velocity + + (hullId == null ? "" : " in hull " + hullId) + " energy=" + impactEnergy + " age=" + age + "/" + lifetimeTicks + "]"; } } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotFrame.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotFrame.java new file mode 100644 index 000000000..159bb42fc --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotFrame.java @@ -0,0 +1,146 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.damage.StructureDamageEngine; +import zmaster587.advancedRocketry.integration.vs.VSIntegration; + +/** + * The one place a shot changes coordinate frames. + * + *

    Why a shot has a frame at all

    + *

    A hull manoeuvres. A round drilling through one is inside a moving thing, and a position in + * world coordinates describes where that thing used to be: one tick later the plate has gone + * somewhere else and the round is either hanging in the hole's wake or buried in a part of the ship + * it never reached. So a shot inside material is kept in that hull's own subspace, where a round + * standing still against the plate does not drift — the same discipline every other aboard body here + * already follows.

    + * + *

    What is converted, and what is not

    + *

    Position converts through the full transform; velocity through the rotation ALONE plus the + * hull's own motion at that point. The two halves matter separately: the rotation is what makes a + * relative velocity comparable with subspace faces, and the hull's motion is what a round keeps when + * it comes out the far side — a round that punched through a ship doing forty blocks a second and + * left with only its drilling speed would have been robbed by the bookkeeping.

    + * + *

    Every conversion may fail, and failure means "stay where you are"

    + *

    VS answers null for a ship that has unloaded or was never registered. A half-converted shot is + * worse than an unconverted one, so each entry point either performs the whole change or performs + * none of it and says so.

    + */ +public final class ShotFrame { + + private ShotFrame() { + } + + /** Where this shot is in the world, whichever frame it is being kept in. */ + public static Vec3d worldPosition(World world, Shot shot) { + if (shot == null) { + return null; + } + if (shot.getHullId() == null) { + return shot.getPosition(); + } + Vec3d local = shot.getPosition(); + double[] w = VSIntegration.toWorldFrameFor(world, shot.getHullId(), local.x, local.y, local.z); + // A hull that stopped answering leaves the subspace triple as the only thing anybody knows. + return w == null ? local : new Vec3d(w[0], w[1], w[2]); + } + + /** How fast this shot is going through the WORLD — its hull's own motion included. */ + public static Vec3d worldVelocity(World world, Shot shot) { + if (shot == null) { + return null; + } + if (shot.getHullId() == null) { + return shot.getVelocity(); + } + Vec3d relative = shot.getVelocity(); + double[] rotated = VSIntegration.rotateToWorldFrameFor(world, shot.getHullId(), relative.x, + relative.y, relative.z); + if (rotated == null) { + return relative; + } + Vec3d carried = new Vec3d(rotated[0], rotated[1], rotated[2]); + Vec3d at = worldPosition(world, shot); + double[] hull = VSIntegration.shipVelocityAtPointFor(world, shot.getHullId(), at.x, at.y, at.z); + return hull == null ? carried : carried.addVector(hull[0], hull[1], hull[2]); + } + + /** + * Take this shot into {@code hullId}'s frame IF it ended up inside that hull's material, reading + * its present world coordinates. Answers false and changes nothing otherwise — a round that came + * out the far side, or a ship that cannot be asked, leaves a plain world-frame body, which is + * what a shot has always been. + * + *

    The material test is what keeps the frame honest: a shot rides a hull because it is stuck + * IN one, so the moment it is not, it stops being that hull's business.

    + */ + static boolean embedIfInside(World world, Shot shot, String hullId) { + if (world == null || shot == null || hullId == null || shot.getHullId() != null) { + return false; + } + Vec3d worldPos = shot.getPosition(); + Vec3d worldVel = shot.getVelocity(); + double[] local = VSIntegration.toShipFrameFor(world, hullId, worldPos.x, worldPos.y, worldPos.z); + if (local == null) { + return false; + } + Vec3d subspace = new Vec3d(local[0], local[1], local[2]); + if (!insideMaterialOf(world, hullId, subspace)) { + return false; + } + // Subtract the hull's own motion BEFORE rotating: what is left is the round's motion relative + // to the plate, which is the only part of its velocity that does any drilling. + double[] carry = VSIntegration.shipVelocityAtPointFor(world, hullId, worldPos.x, worldPos.y, + worldPos.z); + double vx = worldVel.x - (carry == null ? 0.0D : carry[0]); + double vy = worldVel.y - (carry == null ? 0.0D : carry[1]); + double vz = worldVel.z - (carry == null ? 0.0D : carry[2]); + double[] relative = VSIntegration.rotateToShipFrameFor(world, hullId, vx, vy, vz); + if (relative == null) { + return false; + } + shot.setPosition(subspace); + shot.setVelocity(new Vec3d(relative[0], relative[1], relative[2])); + shot.setHullId(hullId); + return true; + } + + /** + * Put this shot back into world terms and forget the hull. Answers false when it was not in one. + * A hull that has stopped answering still releases the shot: leaving a round addressed to a ship + * nobody can resolve is how a body gets stranded five million blocks away. + */ + static boolean leaveHull(World world, Shot shot) { + if (shot == null || shot.getHullId() == null) { + return false; + } + Vec3d worldPos = worldPosition(world, shot); + Vec3d worldVel = worldVelocity(world, shot); + shot.setPosition(worldPos); + shot.setVelocity(worldVel); + shot.setHullId(null); + return true; + } + + /** + * Is this point inside {@code hullId}'s own material? The point is a SUBSPACE one — this is asked + * about a shot that is already being kept in that frame, and converting it out and back would be + * two chances to drift for no answer. + */ + static boolean insideMaterialOf(World world, String hullId, Vec3d subspacePoint) { + if (world == null || hullId == null || subspacePoint == null) { + return false; + } + BlockPos pos = new BlockPos(Math.floor(subspacePoint.x), Math.floor(subspacePoint.y), + Math.floor(subspacePoint.z)); + if (!world.isBlockLoaded(pos)) { + // Nobody looked. Treating that as "out" would release the round; treating it as "in" + // would trap it. Out is the recoverable one: it resumes as an ordinary flying body. + return false; + } + return StructureDamageEngine.isStructure(world, pos, world.getBlockState(pos)); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java index dc7fa6fd9..ea5849576 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java @@ -171,12 +171,18 @@ public int count() { return shots.size(); } - /** The shot nearest a world point, or null when nothing is in flight. Diagnostics and probes. */ - public Shot nearest(Vec3d point) { + /** + * The shot nearest a WORLD point, or null when nothing is in flight. Diagnostics and probes. + * + *

    The world is asked for because a shot drilling a hull is stored in that hull's own frame: + * comparing its raw coordinates against a world point would rank it by its distance from a + * shipyard millions of blocks away, and answer plausibly.

    + */ + public Shot nearest(net.minecraft.world.World world, Vec3d point) { Shot best = null; double bestSq = Double.POSITIVE_INFINITY; for (Shot shot : shots.values()) { - double sq = shot.getPosition().squareDistanceTo(point); + double sq = ShotFrame.worldPosition(world, shot).squareDistanceTo(point); if (sq < bestSq) { bestSq = sq; best = shot; diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index d43e630c4..d0fcc9d81 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -140,8 +140,9 @@ public static void tick(World world) { // The shot's own position IS where it ended: every terminal branch of the step sets // it to the crossing point before returning, so there is one place that decides // where a round stopped rather than two that could disagree. - registry.end(shot.getId(), end, shot.getPosition()); - ShotReplication.announceEnd(world, shot.getId(), shot.getPosition(), end); + Vec3d endedAt = ShotFrame.worldPosition(world, shot); + registry.end(shot.getId(), end, endedAt); + ShotReplication.announceEnd(world, shot.getId(), endedAt, end); } } registry.markDirty(); @@ -159,6 +160,13 @@ static ShotEndReason step(World world, Shot shot) { return ShotEndReason.EXPIRED; } + // A shot that spent the last tick inside a hull is kept in that hull's frame between ticks, so + // that a ship which manoeuvred in between carried it along. The tick itself is done in world + // terms — the shield layer, the damage engine and every other ship are world-frame questions — + // so it rejoins the world here, at the hull's CURRENT pose, and is handed back at the end. + String boringHull = shot.getHullId(); + ShotFrame.leaveHull(world, shot); + Vec3d position = shot.getPosition(); Vec3d velocity = shot.getVelocity(); double gravity = shot.getEnvironment().getGravityPerTickSquared(); @@ -168,6 +176,10 @@ static ShotEndReason step(World world, Shot shot) { velocity = velocity.addVector(0.0D, -gravity, 0.0D); } + // The hull it was still drilling when the tick ran out, if any: that is the frame it is + // handed back to at the end. + String endedInsideHull = null; + double timeLeft = 1.0D; for (int crossing = 0; crossing < MAX_CROSSINGS_PER_TICK && timeLeft > 1.0E-6D; crossing++) { double speed = velocity.lengthVector(); @@ -180,7 +192,11 @@ static ShotEndReason step(World world, Shot shot) { double fieldDistance = ShieldStrikeService.nearestShellCrossing(world, position, direction, reach); - StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, position, segmentEnd); + // Only on the first crossing of a tick that began inside material: there the answer is + // known to be that hull, and once the round has deflected or come out it is an ordinary + // body again and asks everything. + StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, position, segmentEnd, + crossing == 0 ? boringHull : null); double structureDistance = structure == null ? -1.0D : structure.distance; boolean fieldFirst = fieldDistance >= 0.0D @@ -224,6 +240,7 @@ static ShotEndReason step(World world, Shot shot) { timeLeft = 0.0D; velocity = slowedByWorkDone(velocity, energyBefore, shot.getImpactEnergy(), shot.getKind()); + endedInsideHull = structure.shipId; } if (velocity.lengthVector() @@ -284,6 +301,10 @@ static ShotEndReason step(World world, Shot shot) { shot.setPosition(position); shot.setVelocity(velocity); + // Still drilling somebody's hull when the tick ended: it belongs to that hull until it is out, + // so it is stored in the hull's own frame and rides whatever the ship does before the next + // tick. A round in the world's own blocks needs none of this — the world does not manoeuvre. + ShotFrame.embedIfInside(world, shot, endedInsideHull); return null; } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java index 2d1f3d78e..b3b3eaaa8 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -86,6 +86,18 @@ public static boolean isBlocked(World world, Vec3d from, Vec3d to) { /** The first structure the segment {@code from -> to} meets, or null when it meets none. */ static Hit firstAlong(World world, Vec3d from, Vec3d to) { + return firstAlong(world, from, to, null); + } + + /** + * The same question, optionally narrowed to ONE hull. + * + *

    {@code onlyHullId} is not a filter for convenience: a shot that is inside a hull's material + * is inside that hull and nothing else, so asking the world frame and every other loaded ship + * about it is work whose answer is known in advance. Null asks everything, which is what a body + * in open space needs.

    + */ + static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId) { if (world == null || from == null || to == null) { return null; } @@ -94,6 +106,10 @@ static Hit firstAlong(World world, Vec3d from, Vec3d to) { return null; } + if (onlyHullId != null) { + return shipFrameHit(world, onlyHullId, from, to, length); + } + Hit best = worldFrameHit(world, from, to, length); // The segment's own bounding box, min-first: AxisAlignedBB#intersects reads its six doubles // as an ordered box and quietly answers "no" for one given the other way round. diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java index 9a65306d8..660a1bf6e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotHitsShipHullE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** @@ -146,10 +147,124 @@ public void aShotFindsAMovedShipsHullInItsOwnFrameAndDamagesTheRightBlock() thro + " never mapped out of the ship's frame", offSeat < 400.0D); } + /** A second site: two ship scenarios on one shared server must not build over each other. */ + private static final int MOVE_SRC_X = 6700, MOVE_SRC_Y = 80, MOVE_SRC_Z = 6400; + private static final int MOVE_FAR_X = 6700, MOVE_FAR_Y = 150, MOVE_FAR_Z = 8800; + /** Where the ship goes WHILE the round is inside it — far enough that no tolerance can absorb it. */ + private static final int MOVE_AGAIN_X = 6700, MOVE_AGAIN_Y = 150, MOVE_AGAIN_Z = 9100; + + @Test + public void aRoundDrillingAHullGoesWhereTheShipGoes() throws Exception { + Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs()); + exec("artest vs permaload true"); + exec("artest shot clear 0"); + + String shipId = buildAndMoveShip(MOVE_SRC_X, MOVE_SRC_Y, MOVE_SRC_Z, + MOVE_FAR_X, MOVE_FAR_Y, MOVE_FAR_Z); + String seat = exec("artest vs find-seat 0 id " + shipId); + assertTrue("could not locate the ship's seat, so there is no known block to lodge in: " + seat, + seat.contains("\"seatFound\":true")); + int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"), + subZ = extractInt(seat, "seatZ"); + String mapped = exec("artest vs to-world 0 " + MOVE_FAR_X + " " + MOVE_FAR_Y + " " + MOVE_FAR_Z + + " " + subX + " " + subY + " " + subZ); + assertTrue("the seat's subspace address could not be mapped to a world point: " + mapped, + mapped.contains("\"ok\":true")); + double worldX = extractDouble(mapped, "worldX"); + double worldY = extractDouble(mapped, "worldY"); + double worldZ = extractDouble(mapped, "worldZ"); + + // A round too poor to buy even one stage: it lodges in the plate without damaging it. That is + // deliberate — this test is about WHERE a lodged round is, and a round that chews its way out + // (or dies paying) leaves nothing to ask the question about. + String plate = stage(subX, subY, subZ); + long stageCost = readLong(plate, "stageCost"); + assertTrue("the plate has no price, so no budget can be chosen against it: " + plate, + stageCost > 1); + int energy = (int) (stageCost / 2); + + // Slow, so it spends several ticks crossing one block rather than passing through in one. + long id = readLong(exec("artest shot fire 0 " + worldX + " " + (worldY + 1.5D) + " " + worldZ + + " 0 -0.2 0 " + energy + " 400"), "id"); + assertTrue("the launch was refused, so nothing else here means anything", id > 0); + + String lodged = null; + for (int tick = 0; tick < 40 && lodged == null; tick++) { + exec("artest shield tick 0"); + String read = exec("artest shot read 0 " + id); + if (read.contains("\"hull\":\"")) { + lodged = read; + } else if (read.contains("\"present\":false")) { + break; + } + } + assertTrue("the round never came to be inside the hull — with nothing lodged there is no frame" + + " question to ask: " + exec("artest shot read 0 " + id), lodged != null); + + double hullX = readDouble(lodged, "hullX"); + double hullY = readDouble(lodged, "hullY"); + double hullZ = readDouble(lodged, "hullZ"); + double beforeX = readDouble(lodged, "x"); + double beforeZ = readDouble(lodged, "z"); + + // The ship manoeuvres with the round still in it. No tick of the shot in between: what moves + // is the SHIP, and the only question is whether the round is a part of it or a thing left + // hanging in the air where the ship used to be. + String tp = exec("artest vs teleport-ship 0 " + MOVE_FAR_X + " " + MOVE_FAR_Y + " " + MOVE_FAR_Z + + " " + MOVE_AGAIN_X + " " + MOVE_AGAIN_Y + " " + MOVE_AGAIN_Z); + assertTrue("the ship could not be moved, so this run never tested a manoeuvre: " + tp, + tp.contains("\"ok\":true")); + exec("artest vs unpark 0 " + MOVE_AGAIN_X + " " + MOVE_AGAIN_Y + " " + MOVE_AGAIN_Z); + + String after = exec("artest shot read 0 " + id); + assertTrue("the round left the hull when the ship moved: " + after, + after.contains("\"hull\":\"")); + // ACROSS the bore the plate holds it exactly: a ship's translation must not show up as the + // round sliding sideways inside its own hole. ALONG the bore it keeps drilling, because this + // is a live server whose world ticks on its own — so the claim there is that it advanced by a + // few tenths of a block of boring and not by the hundreds the ship travelled. + assertEquals("the round moved sideways WITHIN the plate because the ship moved — the hull's" + + " own motion must not reach its frame at all: " + after, + hullX, readDouble(after, "hullX"), 1.0E-6D); + assertEquals(hullZ, readDouble(after, "hullZ"), 1.0E-6D); + double boredFurther = hullY - readDouble(after, "hullY"); + assertTrue("along the bore the round went " + boredFurther + " blocks while the ship travelled " + + Math.abs(MOVE_AGAIN_Z - MOVE_FAR_Z) + ": it is being carried, not drilling: " + after, + boredFurther >= 0.0D && boredFurther < 4.0D); + + double movedZ = readDouble(after, "z") - beforeZ; + double shipMovedZ = MOVE_AGAIN_Z - MOVE_FAR_Z; + assertEquals("the round stayed where the ship USED to be: a body inside a hull that manoeuvres" + + " travels with it, and one stored in world coordinates does not. " + after, + shipMovedZ, movedZ, 8.0D); + assertEquals("the round drifted across the manoeuvre on an axis the ship did not move along: " + + after, 0.0D, readDouble(after, "x") - beforeX, 8.0D); + + // And it goes on from the ship's NEW place. Not "it is still lodged": a round that finished + // drilling through the plate and came out the far side has left the hull for good reasons, and + // a test that demanded it still be inside would be pinning how thick this fixture's plate is. + // What must hold either way is where it carries on FROM — a round resumed off a stale world + // position would be back at the site the ship left, hanging in the air. + exec("artest shield tick 0"); + String resumed = exec("artest shot read 0 " + id); + if (resumed.contains("\"present\":true")) { + assertEquals("after the manoeuvre the round carried on from where the ship USED to be: " + + resumed, (double) MOVE_AGAIN_Z, readDouble(resumed, "z"), 8.0D); + } + + exec("artest shot clear 0"); + } + /** Build the fixture, assemble it into a ship and move it far from where it was built. */ private String buildAndMoveShip() throws Exception { - clearArea(SRC_X, SRC_Z); - String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat"); + return buildAndMoveShip(SRC_X, SRC_Y, SRC_Z, FAR_X, FAR_Y, FAR_Z); + } + + /** The same, at a site of the caller's choosing — two ship scenarios must not share a build site. */ + private String buildAndMoveShip(int srcX, int srcY, int srcZ, int farX, int farY, int farZ) + throws Exception { + clearArea(srcX, srcZ); + String coords = placeFixture(srcX, srcY, srcZ, "with-pilot-seat"); String asm = exec("artest rocket assemble 0 " + coords); assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm, asm.contains("\"rocketCount\":0")); @@ -157,7 +272,7 @@ private String buildAndMoveShip() throws Exception { String info = null; for (int attempt = 0; attempt < 40; attempt++) { exec("artest vs load-ships 0"); - info = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z); + info = exec("artest vs ship-info 0 " + srcX + " " + srcY + " " + srcZ); if (info.contains("\"managed\":true")) { break; } @@ -166,11 +281,11 @@ private String buildAndMoveShip() throws Exception { assertTrue("the build never became a ship managed at its build site: " + info, info != null && info.contains("\"managed\":true")); - String tp = exec("artest vs teleport-ship 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z - + " " + FAR_X + " " + FAR_Y + " " + FAR_Z); + String tp = exec("artest vs teleport-ship 0 " + srcX + " " + srcY + " " + srcZ + + " " + farX + " " + farY + " " + farZ); assertTrue("the ship could not be moved, so it never left the world blocks it was built from: " + tp, tp.contains("\"ok\":true")); - exec("artest vs unpark 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z); + exec("artest vs unpark 0 " + farX + " " + farY + " " + farZ); return extractString(info, "id"); } @@ -178,6 +293,7 @@ private String stage(int x, int y, int z) throws Exception { return exec("artest damage stage 0 " + x + " " + y + " " + z); } + /** Cleared around the build height every fixture here is placed at. */ private void clearArea(int baseX, int baseZ) throws Exception { int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4; int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4; From f8f755145897c9e6554bc82e07e7792288db4a58 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 18 Aug 2026 22:15:26 +0300 Subject: [PATCH 19/35] feat: a shot meets the world with its body, not its centre line - SweptVolume: exact along the axis, quadrature across it, layers - a body under half a block wide is the ray exactly, so nothing shipped moves - a layer's budget is divided by overlap share, never multiplied - each block pays for the area it is under, not for the whole body - the crossing test sweeps the body, so a wide round can graze - shotBodyRadiusCap bounds the geometry, never the price --- .../advancedRocketry/api/ARConfiguration.java | 9 + .../damage/StructureDamageEngine.java | 120 ++++++-- .../projectile/ShotSubstrate.java | 12 +- .../projectile/StructureCrossing.java | 82 +++++- .../advancedRocketry/util/SweptVolume.java | 277 ++++++++++++++++++ .../server/WideRoundCutsAWideHoleE2ETest.java | 181 ++++++++++++ .../test/unit/SweptVolumeTest.java | 211 +++++++++++++ 7 files changed, 849 insertions(+), 43 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/util/SweptVolume.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/WideRoundCutsAWideHoleE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/SweptVolumeTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index c4977af68..dd04ca5db 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -402,6 +402,14 @@ public class ARConfiguration { @ConfigProperty(needsSync = true) public double shotReflectionSpeedFloor = 0.05; 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 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. @@ -727,6 +735,7 @@ public static void loadPreInit() { arConfig.enableProjectileSubstrate = config.get(WEAPONS, "enableProjectileSubstrate", true, "Track fired shots as server-side records that fly across loaded and unloaded space alike. Turn off to disable long-range fire entirely: nothing is admitted and nothing in flight is stepped").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.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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, 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(); diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 81a860952..19e0daa47 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -8,7 +8,8 @@ import zmaster587.advancedRocketry.api.damage.DamageOutcome; import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.api.damage.StopReason; -import zmaster587.advancedRocketry.util.SweptSegment; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.util.SweptVolume; import zmaster587.advancedRocketry.util.WeightEngine; /** @@ -120,7 +121,11 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in Walk walk = new Walk(world, entry, direction, result, reachBlocks, crossSectionArea, resumesInside); - SweptSegment.traverse(entry, walk.farEnd, MAX_VOXELS_EXAMINED, walk); + // The bound scales with the body, because the sweep does: holding a wide round to a ray's + // voxel budget would not make it cheaper, it would make it stop looking a few blocks in and + // report that it had come out the far side of a hull it was still inside. + SweptVolume.traverse(entry, walk.farEnd, walk.radius, + MAX_VOXELS_EXAMINED * SweptVolume.candidatesPerSlice(walk.radius), walk); return walk.finish(); } @@ -128,7 +133,7 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in * One walk's state, told about each block the ray enters. It is an object rather than a loop only * because the traversal calls back; every decision is the one the loop made. */ - private static final class Walk implements SweptSegment.Visitor { + private static final class Walk implements SweptVolume.LayerVisitor { private final World world; private final Vec3d entry; @@ -137,6 +142,12 @@ private static final class Walk implements SweptSegment.Visitor { private final Vec3d farEnd; private final double areaFactor; + /** + * How wide the body is, in blocks, derived from the cross-section it was priced against — + * there is one statement of a body's width and this is read from it, never declared twice. + * Capped by config, because the work a sweep does grows with the square of it. + */ + private final double radius; /** True while the first voxel is still to come: it is already paid for, so it is not charged. */ private boolean skipThisVoxel; /** How far the far end is, in blocks: what a parameter along the segment is measured against. */ @@ -155,6 +166,8 @@ private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result, doubl this.entry = entry; this.result = result; this.areaFactor = crossSectionArea / ImpactRequest.REFERENCE_AREA; + this.radius = Math.min(Math.sqrt(Math.max(0.0D, crossSectionArea) / Math.PI), + ARConfiguration.getCurrentConfig().shotBodyRadiusCap); double length = Math.sqrt(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z); Vec3d unit = length <= 1.0E-9D ? direction : scale(direction, 1.0D / length); @@ -162,23 +175,40 @@ private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result, doubl this.farEnd = entry.add(scale(unit, this.reach)); } + /** + * One layer of the body's sweep: the blocks it reaches in one slice of its path. + * + *

    The AXIS block still tells the story — whether the body is in material, whether it came + * out the far side, how deep it got — because that is where the centre of the body is, and + * every one of those is a question about the centre. What the width adds is who else gets + * paid: each block of the layer is offered the fraction of the budget it actually covers, so + * a body twice as wide spreads what it has over more blocks rather than doing twice the + * damage. At the reference cross-section a layer is one block at a share of one, which is + * precisely the walk this used to be.

    + */ @Override - public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing entryFace) { - Vec3d here = entry.add(scale(farEnd.subtract(entry), tEnter)); + public boolean visit(SweptVolume.Layer layer) { + Vec3d here = entry.add(scale(farEnd.subtract(entry), layer.tEnter)); if (previousWasSolid) { - // The ray left the previous solid block exactly where it entered this one. + // The body left the previous solid slice exactly where it entered this one. lastSolidExit = here; previousWasSolid = false; } - if (!world.isBlockLoaded(pos)) { - // Not "there is nothing here" — nobody looked. A caller that can retry should. + if (!world.isBlockLoaded(layer.axis)) { + // Not "there is nothing here" - nobody looked. A caller that can retry should. return decide(entered ? DamageOutcome.ABSORBED : DamageOutcome.NOTHING_STRUCK, StopReason.TARGET_UNLOADED, null); } - IBlockState state = world.getBlockState(pos); - if (!isStructure(world, pos, state)) { + boolean anySolid = false; + for (BlockPos pos : layer.blocks) { + if (world.isBlockLoaded(pos) && isStructure(world, pos, world.getBlockState(pos))) { + anySolid = true; + break; + } + } + if (!anySolid) { if (entered && ++consecutiveEmpty >= GAP_TOLERANCE) { return decide(DamageOutcome.EXITED, StopReason.EXITED_FAR_SIDE, lastSolidExit); } @@ -191,29 +221,60 @@ public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing result.entryPoint = here; } result.penetrationDepth++; - result.distanceWalked = tEnter * reach; + result.distanceWalked = layer.tEnter * reach; previousWasSolid = true; if (skipThisVoxel) { - // The block this bore is standing in, already bought on an earlier tick. + // The slice this bore is standing in, already bought on an earlier tick. skipThisVoxel = false; return false; } - if (isIndestructible(world, pos, state)) { - // Nothing gets through this. The budget dies here rather than tunnelling past it. - result.budgetSpent += result.budgetLeft; - result.budgetLeft = 0; - return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); + // The pool every share is measured against is what the body had ON REACHING this layer, + // not what is left part way through it: the blocks of one layer are met at once, so + // charging the second against the first's leavings would make their listed order matter. + int poolAtLayer = result.budgetLeft; + for (int i = 0; i < layer.blocks.size(); i++) { + BlockPos pos = layer.blocks.get(i); + if (!world.isBlockLoaded(pos)) { + continue; + } + IBlockState state = world.getBlockState(pos); + if (!isStructure(world, pos, state)) { + continue; + } + int allowance = Math.min(result.budgetLeft, allowanceFor(poolAtLayer, layer, i)); + if (isIndestructible(world, pos, state)) { + if (pos.equals(layer.axis)) { + // Nothing gets through this. The budget dies here rather than tunnelling past. + result.budgetSpent += result.budgetLeft; + result.budgetLeft = 0; + return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); + } + // Beside the hole rather than in it: it eats its own share and the body goes on. + result.budgetSpent += allowance; + result.budgetLeft -= allowance; + continue; + } + // Priced against the area THIS block is under, not the whole body: it is handed a + // share of the budget, so charging it for the entire cross-section would take the + // width out of the round twice and leave a wide shot feebler than any physics says. + int spent = spendInto(world, pos, state, result, + areaFactor * layer.shares.get(i), allowance); + result.budgetSpent += spent; + result.budgetLeft -= spent; } - - spendInto(world, pos, state, result, areaFactor); if (result.budgetLeft <= 0) { return decide(DamageOutcome.ABSORBED, StopReason.BUDGET_EXHAUSTED, null); } return false; } + /** What one block of a layer may be charged: the pool times how much of the body covers it. */ + private int allowanceFor(int poolAtLayer, SweptVolume.Layer layer, int index) { + return Math.max(0, (int) Math.floor(poolAtLayer * layer.shares.get(index))); + } + private boolean decide(DamageOutcome outcome, StopReason reason, Vec3d exitPoint) { result.outcome = outcome; result.stopReason = reason; @@ -240,22 +301,28 @@ private WalkResult finish() { } } - /** Spend as much of the remaining budget into one block as its stages will take. */ - private static void spendInto(World world, BlockPos pos, IBlockState state, WalkResult result, - double areaFactor) { + /** + * Spend up to {@code allowance} into one block, as much as its stages will take, and answer what + * that came to. The caller owns the running budget — a block is told what it may have, never + * handed the purse, which is what lets one layer be divided between several of them. + */ + private static int spendInto(World world, BlockPos pos, IBlockState state, WalkResult result, + double areaFactor, int allowance) { int maxStage = DamageState.getMaxStage(world, pos); int stage = DamageState.getStage(world, pos); int stageCost = stageCost(world, pos, areaFactor); + int left = Math.max(0, allowance); + int spent = 0; boolean advanced = false; - while (stage < maxStage && result.budgetLeft >= stageCost) { - result.budgetLeft -= stageCost; - result.budgetSpent += stageCost; + while (stage < maxStage && left >= stageCost) { + left -= stageCost; + spent += stageCost; stage++; advanced = true; } if (!advanced) { - return; + return spent; } if (stage >= maxStage) { @@ -268,6 +335,7 @@ private static void spendInto(World world, BlockPos pos, IBlockState state, Walk DamageState.setStage(world, pos, stage); result.blocksStaged++; } + return spent; } /** diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index d0fcc9d81..2638f9adc 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -101,6 +101,16 @@ private static Vec3d slowedByWorkDone(Vec3d velocity, int energyBefore, int ener return velocity.scale(ratio); } + /** + * How wide this shot is for the purpose of MEETING things, capped by config. A body sweeps a + * cylinder rather than a line, and the work a step does grows with the square of its width, so an + * absurd calibre is bounded here rather than being a way of making the server do arbitrary work. + * The declared radius still prices the shot in full: only the geometry is capped. + */ + private static double bodyRadius(Shot shot) { + return Math.min(shot.getRadius(), ARConfiguration.getCurrentConfig().shotBodyRadiusCap); + } + /** Which kinds are a lump of something travelling, as opposed to energy arriving. */ private static boolean carriesMass(ImpactKind kind) { return kind == ImpactKind.KINETIC || kind == ImpactKind.EXPLOSIVE; @@ -196,7 +206,7 @@ static ShotEndReason step(World world, Shot shot) { // known to be that hull, and once the round has deflected or come out it is an ordinary // body again and asks everything. StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, position, segmentEnd, - crossing == 0 ? boringHull : null); + crossing == 0 ? boringHull : null, bodyRadius(shot)); double structureDistance = structure == null ? -1.0D : structure.distance; boolean fieldFirst = fieldDistance >= 0.0D diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java index b3b3eaaa8..26b316c5a 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/StructureCrossing.java @@ -6,7 +6,7 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.damage.StructureDamageEngine; -import zmaster587.advancedRocketry.util.SweptSegment; +import zmaster587.advancedRocketry.util.SweptVolume; import zmaster587.advancedRocketry.integration.vs.VSIntegration; import java.util.Map; @@ -98,6 +98,18 @@ static Hit firstAlong(World world, Vec3d from, Vec3d to) { * in open space needs.

    */ static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId) { + return firstAlong(world, from, to, onlyHullId, 0.0D); + } + + /** + * The same question for a body of some WIDTH. + * + *

    A ray finds what the centre of a round would meet. A body a block across also meets what it + * passes beside — and a grazing hit is exactly the contact a ricochet is made of, so a wide round + * tested as a line would be a round that cannot graze anything. The radius costs nothing at the + * reference calibre: below half a block the sweep IS the ray.

    + */ + static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId, double radius) { if (world == null || from == null || to == null) { return null; } @@ -107,10 +119,10 @@ static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId) { } if (onlyHullId != null) { - return shipFrameHit(world, onlyHullId, from, to, length); + return shipFrameHit(world, onlyHullId, from, to, length, radius); } - Hit best = worldFrameHit(world, from, to, length); + Hit best = worldFrameHit(world, from, to, length, radius); // The segment's own bounding box, min-first: AxisAlignedBB#intersects reads its six doubles // as an ordered box and quietly answers "no" for one given the other way round. double minX = Math.min(from.x, to.x); @@ -124,7 +136,7 @@ static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId) { if (!ship.getValue().intersects(minX, minY, minZ, maxX, maxY, maxZ)) { continue; } - Hit hit = shipFrameHit(world, ship.getKey(), from, to, length); + Hit hit = shipFrameHit(world, ship.getKey(), from, to, length, radius); if (hit != null && (best == null || hit.distance < best.distance)) { best = hit; } @@ -132,7 +144,8 @@ static Hit firstAlong(World world, Vec3d from, Vec3d to, String onlyHullId) { return best; } - private static Hit worldFrameHit(World world, Vec3d from, Vec3d to, double length) { + private static Hit worldFrameHit(World world, Vec3d from, Vec3d to, double length, + double radius) { // Above or below the build height there are no world blocks by construction, and the pose // band ships fly in is entirely up there. Skipping the traversal is not an optimisation for // its own sake: it is what keeps a shot crossing a cell from touching the chunk system at all. @@ -141,17 +154,18 @@ private static Hit worldFrameHit(World world, Vec3d from, Vec3d to, double lengt if (maxY < 0.0D || minY > world.getHeight()) { return null; } - return traverse(world, from, to, length, null); + return traverse(world, from, to, length, null, radius); } - private static Hit shipFrameHit(World world, String shipId, Vec3d from, Vec3d to, double length) { + private static Hit shipFrameHit(World world, String shipId, Vec3d from, Vec3d to, double length, + double radius) { double[] localFrom = VSIntegration.toShipFrameFor(world, shipId, from.x, from.y, from.z); double[] localTo = VSIntegration.toShipFrameFor(world, shipId, to.x, to.y, to.z); if (localFrom == null || localTo == null) { return null; } return traverse(world, new Vec3d(localFrom[0], localFrom[1], localFrom[2]), - new Vec3d(localTo[0], localTo[1], localTo[2]), length, shipId); + new Vec3d(localTo[0], localTo[1], localTo[2]), length, shipId, radius); } /** @@ -161,20 +175,32 @@ private static Hit shipFrameHit(World world, String shipId, Vec3d from, Vec3d to * comparable with the field layer's, which is measured in the world frame. */ private static Hit traverse(World world, Vec3d from, Vec3d to, double worldLength, - final String shipId) { + final String shipId, double radius) { final Hit[] found = new Hit[1]; final Vec3d segFrom = from; final Vec3d segTo = to; - SweptSegment.traverse(from, to, MAX_VOXELS_PER_SEGMENT, new SweptSegment.Visitor() { + SweptVolume.traverse(from, to, radius, + MAX_VOXELS_PER_SEGMENT * SweptVolume.candidatesPerSlice(radius), + new SweptVolume.LayerVisitor() { @Override - public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing entryFace) { - if (!world.isBlockLoaded(pos)) { - return false; // nobody looked; see the class note + public boolean visit(SweptVolume.Layer layer) { + BlockPos struck = null; + for (BlockPos pos : layer.blocks) { + if (!world.isBlockLoaded(pos)) { + continue; // nobody looked; see the class note + } + if (StructureDamageEngine.isStructure(world, pos, world.getBlockState(pos))) { + // The axis block leads its layer, so a head-on meeting reports exactly the + // block a ray would have found; a side block only wins when the centre of the + // body passed through air, which is what a graze IS. + struck = pos; + break; + } } - if (!StructureDamageEngine.isStructure(world, pos, world.getBlockState(pos))) { + if (struck == null) { return false; } - Vec3d localPoint = segFrom.add(segTo.subtract(segFrom).scale(tEnter)); + Vec3d localPoint = segFrom.add(segTo.subtract(segFrom).scale(layer.tEnter)); Vec3d worldPoint = localPoint; if (shipId != null) { double[] w = VSIntegration.toWorldFrameFor(world, shipId, localPoint.x, @@ -184,10 +210,34 @@ public boolean visit(BlockPos pos, double tEnter, net.minecraft.util.EnumFacing } worldPoint = new Vec3d(w[0], w[1], w[2]); } - found[0] = new Hit(tEnter * worldLength, worldPoint, pos, shipId, entryFace); + found[0] = new Hit(layer.tEnter * worldLength, worldPoint, struck, shipId, + faceOf(struck, layer)); return true; } }); return found[0]; } + + /** + * Which face of the struck block the body came in through. For the block the axis went through it + * is the axis's own entry face, as it always was. For one the body only reached SIDEWAYS the axis + * face would be a normal about a different block, so the face is the one turned towards the axis + * — that is the surface a grazing body actually touches. + */ + private static EnumFacing faceOf(BlockPos struck, SweptVolume.Layer layer) { + int dx = struck.getX() - layer.axis.getX(); + int dy = struck.getY() - layer.axis.getY(); + int dz = struck.getZ() - layer.axis.getZ(); + if (dx == 0 && dy == 0 && dz == 0) { + return layer.entryFace; + } + int ax = Math.abs(dx), ay = Math.abs(dy), az = Math.abs(dz); + if (ax >= ay && ax >= az) { + return dx > 0 ? EnumFacing.WEST : EnumFacing.EAST; + } + if (ay >= az) { + return dy > 0 ? EnumFacing.DOWN : EnumFacing.UP; + } + return dz > 0 ? EnumFacing.NORTH : EnumFacing.SOUTH; + } } diff --git a/src/main/java/zmaster587/advancedRocketry/util/SweptVolume.java b/src/main/java/zmaster587/advancedRocketry/util/SweptVolume.java new file mode 100644 index 000000000..e32c802f1 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/util/SweptVolume.java @@ -0,0 +1,277 @@ +package zmaster587.advancedRocketry.util; + +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Every block a body of some WIDTH sweeps through, layer by layer, in the order it reaches them. + * + *

    Why a width at all

    + *

    A ray says a shot meets one block per step, which makes every calibre the same shot with a + * different number on it. A swept cylinder is what makes the difference a player is being sold exist + * INSIDE the hull rather than only at its skin: a heavy slug punches a wide hole, a needle bores a + * narrow channel. The volume is the body's disc dragged along the segment.

    + * + *

    Layers, not blocks

    + *

    The blocks reached within one slice of the axis form one layer, and a layer is handed over + * as a set. Without that there is no coherent place to divide the body's energy: a shot that reaches + * nine blocks either does nine times the work or picks one of them arbitrarily. Each block in a layer + * carries its share — how much of the cross-section it covers — and a layer's shares sum to + * one, so widening a body spreads what it has instead of multiplying it.

    + * + *

    Exact along the path, quadrature across it

    + *

    Along the axis this is the same exact traversal a ray gets ({@link SweptSegment}) — there is no + * step size and therefore no speed at which a wall becomes transparent. ACROSS the axis a cell's + * share is measured by testing a fixed set of points in it, which is a different axis and a different + * claim: the quadrature decides how much of a block is covered, never whether the path reached it.

    + * + *

    A narrow body is a ray, exactly

    + *

    Below half a block the volume degenerates to the segment itself, one block per layer at a share + * of one. That is not an approximation of the cylinder — it is the statement that a body thinner than + * a voxel has nothing to spread, and it keeps the reference body (radius 0.25) behaving precisely as + * it did before there was a width.

    + * + *

    Pure

    + *

    Nothing here touches a world. It is geometry, and the property that matters — that a wide fast + * body does not skip what it passes through — is a property of the traversal.

    + */ +public final class SweptVolume { + + /** Below this radius a body is a ray: it cannot straddle enough of a voxel to share anything. */ + public static final double MIN_WIDE_RADIUS = 0.5D; + + /** Half the diagonal of a unit cell — how far a cell's centre can be from a point still inside it. */ + private static final double CELL_HALF_DIAGONAL = 0.8660254037844386D; + + /** One layer of the sweep: the blocks reached in one slice of the axis, and their shares. */ + public static final class Layer { + + /** The parameter in {@code [0,1]} along {@code from -> to} at which this layer is reached. */ + public final double tEnter; + /** + * The voxel the AXIS passed through in this slice — the one a ray would have found alone. + * It is the layer's identity and is always stated, whether or not it appears in + * {@link #blocks}: a slice whose centre block was already contacted earlier in the sweep + * still happened, and a caller asking where the centre went deserves an answer rather than + * whichever side block happened to be listed first. + */ + public final BlockPos axis; + /** + * What this layer CONTRIBUTES: the blocks reached here and not already reached earlier in the + * sweep, the axis one first when it is among them. Never empty — a slice with nothing new to + * offer is not reported at all. + */ + public final List blocks; + /** How much of the cross-section each block covers, parallel to {@link #blocks}, summing to 1. */ + public final List shares; + /** + * The face the AXIS came in through, as an outward normal — the surface normal anything + * answering a contact needs. Null for the layer the body starts in: nothing was crossed. + */ + public final EnumFacing entryFace; + + Layer(double tEnter, BlockPos axis, List blocks, List shares, + EnumFacing entryFace) { + this.tEnter = tEnter; + this.axis = axis; + this.blocks = blocks; + this.shares = shares; + this.entryFace = entryFace; + } + + public int size() { + return blocks.size(); + } + } + + /** Told about each layer the body reaches. */ + public interface LayerVisitor { + /** @return true to stop the traversal here */ + boolean visit(Layer layer); + } + + private SweptVolume() { + } + + /** + * How many voxels one slice of a body this wide may examine — the declared bound on the work a + * width costs, stated where the width is understood rather than guessed at by each caller. + * + *

    It is the cube of the Chebyshev neighbourhood the sweep considers, which over-counts on + * purpose: most of those candidates are rejected on distance before anything reads a block. A + * caller that knows how many slices its path has multiplies by this and gets a bound it can hold + * itself to.

    + */ + public static int candidatesPerSlice(double radius) { + if (radius < MIN_WIDE_RADIUS) { + return 1; + } + int span = 2 * (int) Math.ceil(radius) + 1; + return span * span * span; + } + + /** + * Walk the layers of the body of {@code radius} swept along {@code from -> to}, examining at most + * {@code maxVoxels} voxels in total. + * + *

    The cap bounds work, not distance: it counts every voxel LOOKED AT, side ones included, so a + * wide body exhausts it sooner than a narrow one does over the same travel. It answers how many + * were examined, so a caller that must not silently under-test can tell it ran out.

    + */ + public static int traverse(Vec3d from, Vec3d to, double radius, int maxVoxels, + final LayerVisitor visitor) { + if (from == null || to == null || visitor == null || maxVoxels <= 0) { + return 0; + } + if (radius < MIN_WIDE_RADIUS) { + return traverseAsRay(from, to, maxVoxels, visitor); + } + return traverseWide(from, to, radius, maxVoxels, visitor); + } + + /** The degenerate case, and deliberately the SAME traversal a ray gets rather than a copy of it. */ + private static int traverseAsRay(Vec3d from, Vec3d to, int maxVoxels, final LayerVisitor visitor) { + return SweptSegment.traverse(from, to, maxVoxels, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos pos, double tEnter, EnumFacing entryFace) { + List blocks = new ArrayList(1); + blocks.add(pos); + List shares = new ArrayList(1); + shares.add(1.0D); + return visitor.visit(new Layer(tEnter, pos, blocks, shares, entryFace)); + } + }); + } + + private static int traverseWide(final Vec3d from, final Vec3d to, final double radius, + final int maxVoxels, final LayerVisitor visitor) { + final Vec3d axis = to.subtract(from); + final double axisLength = axis.lengthVector(); + if (axisLength <= 1.0E-9D) { + return 0; + } + final int reach = (int) Math.ceil(radius); + // A cell whose centre is further from the axis than the body's radius plus half a cell's + // diagonal cannot contain a point of the cylinder at all — that is the tight bound, and it is + // what keeps a wide body at about (2r+1) columns rather than a cube of candidates. + final double centreBound = radius + CELL_HALF_DIAGONAL; + final Set alreadyContacted = new HashSet(); + final int[] examined = new int[1]; + final boolean[] exhausted = new boolean[1]; + + SweptSegment.traverse(from, to, maxVoxels, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos axisPos, double tEnter, EnumFacing entryFace) { + List blocks = new ArrayList(); + List weights = new ArrayList(); + + for (int dx = -reach; dx <= reach && !exhausted[0]; dx++) { + for (int dy = -reach; dy <= reach && !exhausted[0]; dy++) { + for (int dz = -reach; dz <= reach; dz++) { + if (examined[0] >= maxVoxels) { + exhausted[0] = true; + break; + } + examined[0]++; + BlockPos cell = axisPos.add(dx, dy, dz); + if (distanceToAxis(cell.getX() + 0.5D, cell.getY() + 0.5D, + cell.getZ() + 0.5D, from, axis, axisLength) > centreBound) { + continue; + } + double weight = coverage(cell, from, axis, axisLength, radius); + if (weight <= 0.0D) { + continue; + } + // A block is contacted once per sweep. Consecutive slices overlap by + // construction, and offering the same block twice would charge a body + // twice for standing still against it. + if (!alreadyContacted.add(key(cell))) { + continue; + } + // The axis block leads: it is the one a ray would have found, and a caller + // that only cares where the centre went should not have to search for it. + if (dx == 0 && dy == 0 && dz == 0) { + blocks.add(0, cell); + weights.add(0, weight); + } else { + blocks.add(cell); + weights.add(weight); + } + } + } + } + if (blocks.isEmpty()) { + // Every block of this slice was already answered in an earlier one. There is + // nothing to divide and nothing to say; the sweep goes on. + return exhausted[0]; + } + double total = 0.0D; + for (Double weight : weights) { + total += weight; + } + List shares = new ArrayList(weights.size()); + for (Double weight : weights) { + shares.add(weight / total); + } + return visitor.visit(new Layer(tEnter, axisPos, blocks, shares, entryFace)) + || exhausted[0]; + } + }); + return examined[0]; + } + + /** + * How much of this cell the body covers, as a count of test points inside the cylinder: the cell's + * centre and its eight corners. Bounded, monotone in the real overlap, and zero exactly when the + * body misses the cell — which is the only part of it a contact decision rests on. + */ + private static double coverage(BlockPos cell, Vec3d from, Vec3d axis, double axisLength, + double radius) { + int inside = 0; + for (int cx = 0; cx <= 1; cx++) { + for (int cy = 0; cy <= 1; cy++) { + for (int cz = 0; cz <= 1; cz++) { + if (distanceToAxis(cell.getX() + cx, cell.getY() + cy, cell.getZ() + cz, + from, axis, axisLength) <= radius) { + inside++; + } + } + } + } + if (distanceToAxis(cell.getX() + 0.5D, cell.getY() + 0.5D, cell.getZ() + 0.5D, + from, axis, axisLength) <= radius) { + inside++; + } + return inside; + } + + /** + * Distance from a point to the axis LINE, not to the segment: the cylinder's caps are the ends of + * the traversal, which the axis walk already decides, so clamping here would cut the body's own + * width off at the first and last slice. + */ + private static double distanceToAxis(double px, double py, double pz, Vec3d from, Vec3d axis, + double axisLength) { + double rx = px - from.x; + double ry = py - from.y; + double rz = pz - from.z; + double along = (rx * axis.x + ry * axis.y + rz * axis.z) / (axisLength * axisLength); + double cx = rx - axis.x * along; + double cy = ry - axis.y * along; + double cz = rz - axis.z * along; + return Math.sqrt(cx * cx + cy * cy + cz * cz); + } + + /** One long per block position — a set key that does not allocate an object per candidate. */ + private static long key(BlockPos pos) { + return ((long) (pos.getX() & 0x3FFFFF) << 42) + | ((long) (pos.getY() & 0xFFFFF) << 22) + | (long) (pos.getZ() & 0x3FFFFF); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WideRoundCutsAWideHoleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WideRoundCutsAWideHoleE2ETest.java new file mode 100644 index 000000000..038122047 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WideRoundCutsAWideHoleE2ETest.java @@ -0,0 +1,181 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * What a calibre BUYS, inside the hull rather than on a tooltip. + * + *

    A round used to be a line: whatever its declared width, it met one block per step and left a + * one-block channel. So the choice between a needle and a slug was a number that changed how far the + * same hole went. A body with width sweeps a cylinder, and the two claims here are the two halves of + * the trade it sells — a wide round takes the blocks BESIDE the line it flew along, and it pays for + * them by not going as deep. Neither is a quantity: both are orderings, because the depths and the + * prices are balance and will move.

    + * + *

    The wall is a SLAB rather than a column, which is the whole arrangement: against a one-block + * column a wide round and a narrow one are indistinguishable, and every test that has fired at one + * would pass against a substrate that still treated a body as a line.

    + */ +public class WideRoundCutsAWideHoleE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + /** A site of this class's own, clear of the other shot scenarios on this shared server. */ + private static final int Y = 70, Z = 940; + private static final int NARROW_X = 1600, WIDE_X = 1660; + /** + * Deep enough that NEITHER round comes out the far side. A slab both rounds punch through reports + * the same depth for both and says nothing about the trade — which is exactly what a six-block + * wall did on the first run of this test. + */ + private static final int SLAB_DEPTH = 20; + + /** The reference body: everything the substrate did before it had a width was this wide. */ + private static final double NARROW_RADIUS = 0.25D; + /** A body a block across — wide enough to straddle its neighbours, inside the configured cap. */ + private static final double WIDE_RADIUS = 1.0D; + + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern STAGE = Pattern.compile("\"stage\":(-?\\d+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); + private static final Pattern MAX_STAGE = Pattern.compile("\"maxStage\":(-?\\d+)"); + + @Test + public void aWideRoundTakesTheBlocksBesideItsLineAndANarrowOneDoesNot() throws Exception { + prepare(NARROW_X); + buildSlab(NARROW_X, SLAB_DEPTH); + prepare(WIDE_X); + buildSlab(WIDE_X, SLAB_DEPTH); + + // Priced off the wall's own block, never hard-coded: the cost comes from the toughness table, + // which is balance and moves. Rich enough that a body sixteen times the reference area still + // buys depth — otherwise "the wide round damaged nothing" would be a statement about the + // budget rather than about the geometry — and poor enough that neither round reaches the far + // side, because two rounds that both punch through report the same depth and prove nothing. + int budget = budgetForBlocks(NARROW_X, 12.0D); + assertTrue("the wall block has no price, so no budget here means anything", budget > 0); + + long narrow = fire(NARROW_X - 3.0D, budget, NARROW_RADIUS); + assertTrue("the substrate refused the narrow shot", narrow >= 0); + long wide = fire(WIDE_X - 3.0D, budget, WIDE_RADIUS); + assertTrue("the substrate refused the wide shot", wide >= 0); + + awaitGone(narrow); + awaitGone(wide); + + int narrowBeside = touchedBeside(NARROW_X); + int wideBeside = touchedBeside(WIDE_X); + int narrowDepth = channelDepth(NARROW_X); + int wideDepth = channelDepth(WIDE_X); + + assertTrue("a body a quarter of a block across touched " + narrowBeside + " blocks beside the" + + " line it flew along: a needle must leave a needle's channel", narrowBeside == 0); + assertTrue("the wide round left the same one-block channel a ray leaves (beside=" + wideBeside + + "): its width bought nothing, which is the whole thing a calibre is meant to buy", + wideBeside > 0); + assertTrue("the narrow round never got into the wall at all (depth=" + narrowDepth + "), so" + + " the comparison below is between two zeroes", narrowDepth > 0); + assertTrue("the wide round bored as deep as the needle (wide=" + wideDepth + ", narrow=" + + narrowDepth + ") while also taking blocks beside it — then width is strictly better" + + " and there is no trade at all", wideDepth < narrowDepth); + } + + // ---- driving + + private long fire(double x, int energy, double radius) throws Exception { + String resp = exec("artest shot fire " + DIM + " " + x + " " + (Y + 0.5D) + " " + (Z + 0.5D) + + " 0.45 0 0 " + energy + " 1200 KINETIC " + radius + " 1.0"); + Matcher m = ID.matcher(resp); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + /** A slab five blocks tall and five wide, so a body a block across has neighbours to reach. */ + private void buildSlab(int fromX, int depth) throws Exception { + assertTrue("could not build the slab", exec("artest fill " + DIM + " " + fromX + " " + (Y - 2) + + " " + (Z - 2) + " " + (fromX + depth - 1) + " " + (Y + 2) + " " + (Z + 2) + + " minecraft:stone").contains("\"ok\":true")); + } + + private void prepare(int wallX) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + + ((wallX - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + ((wallX + 24) >> 4) + " " + + ((Z + 16) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (wallX - 8) + " " + + (Y - 4) + " " + (Z - 5) + " " + (wallX + 20) + " " + (Y + 5) + " " + (Z + 5) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + /** + * How many blocks OFF the line of flight the round touched — staged or gone. The line itself is + * excluded, so this counts only what a ray could never have reached. + */ + private int touchedBeside(int wallX) throws Exception { + int touched = 0; + for (int depth = 0; depth < SLAB_DEPTH; depth++) { + for (int dy = -2; dy <= 2; dy++) { + for (int dz = -2; dz <= 2; dz++) { + if (dy == 0 && dz == 0) { + continue; + } + if (wasTouched(wallX + depth, Y + dy, Z + dz)) { + touched++; + } + } + } + } + return touched; + } + + /** How far along the line of flight the round got: the last touched block on the axis. */ + private int channelDepth(int wallX) throws Exception { + int depth = 0; + for (int i = 0; i < SLAB_DEPTH; i++) { + if (wasTouched(wallX + i, Y, Z)) { + depth = i + 1; + } + } + return depth; + } + + private boolean wasTouched(int x, int y, int z) throws Exception { + String state = exec("artest damage stage " + DIM + " " + x + " " + y + " " + z); + if (state.contains("\"block\":\"minecraft:air\"") || state.contains("\"wasDestroyed\":true")) { + return true; + } + Matcher m = STAGE.matcher(state); + return m.find() && Integer.parseInt(m.group(1)) > 0; + } + + /** A budget worth this many whole blocks of the wall, read off the wall rather than assumed. */ + private int budgetForBlocks(int wallX, double blocks) throws Exception { + String state = exec("artest damage stage " + DIM + " " + wallX + " " + Y + " " + Z); + Matcher cost = STAGE_COST.matcher(state); + Matcher stages = MAX_STAGE.matcher(state); + if (!cost.find() || !stages.find()) { + return 0; + } + return (int) (Integer.parseInt(cost.group(1)) * Math.max(1, Integer.parseInt(stages.group(1))) + * blocks); + } + + /** Wait until the round has left the air, so what is read afterwards is its whole crater. */ + private void awaitGone(long id) throws Exception { + long deadline = System.currentTimeMillis() + 25_000L; + while (System.currentTimeMillis() < deadline) { + if (exec("artest shot read " + DIM + " " + id).contains("\"present\":false")) { + return; + } + Thread.sleep(100L); + } + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SweptVolumeTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptVolumeTest.java new file mode 100644 index 000000000..69bf83df6 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SweptVolumeTest.java @@ -0,0 +1,211 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.util.SweptSegment; +import zmaster587.advancedRocketry.util.SweptVolume; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * What a body with WIDTH sweeps through, as geometry. + * + *

    Two promises, and they are different from the ray's. The ray's promise — nothing between the two + * ends is passed unseen — still holds along the axis and is inherited rather than re-tested here. + * What is new is that a body which is wider than a line reaches things beside the line, that what it + * has is DIVIDED between them rather than multiplied, and that no block is asked to answer the same + * body twice. None of it mentions blocks, worlds or shots: a traversal that reported these voxels + * would satisfy these tests whatever it was later used to look up.

    + */ +public class SweptVolumeTest { + + private static List sweep(Vec3d from, Vec3d to, double radius) { + final List layers = new ArrayList<>(); + SweptVolume.traverse(from, to, radius, 100_000, new SweptVolume.LayerVisitor() { + @Override + public boolean visit(SweptVolume.Layer layer) { + layers.add(layer); + return false; + } + }); + return layers; + } + + private static List rayBlocks(Vec3d from, Vec3d to) { + final List blocks = new ArrayList<>(); + SweptSegment.traverse(from, to, 100_000, new SweptSegment.Visitor() { + @Override + public boolean visit(BlockPos pos, double tEnter, EnumFacing entryFace) { + blocks.add(pos); + return false; + } + }); + return blocks; + } + + /** + * The reference body is 0.25 blocks across. Everything the substrate did before it had a width is + * that body, so if the narrow case were merely CLOSE to the ray, every shipped behaviour would + * have moved a little for no stated reason. + */ + @Test + public void aBodyNarrowerThanAVoxelIsExactlyTheRay() { + Vec3d from = new Vec3d(0.3D, 0.4D, 0.2D); + Vec3d to = new Vec3d(9.7D, 4.1D, 2.6D); + + List ray = rayBlocks(from, to); + List layers = sweep(from, to, 0.25D); + + assertEquals("a body thinner than a voxel reached a different number of slices than the ray" + + " it is supposed to BE", ray.size(), layers.size()); + for (int i = 0; i < ray.size(); i++) { + SweptVolume.Layer layer = layers.get(i); + assertEquals("slice " + i + " is not the block the ray found", ray.get(i), + layer.axis); + assertEquals("a narrow body spread itself over more than one block at slice " + i, + 1, layer.size()); + assertEquals("a body with nothing to share must carry the whole of it", 1.0D, + layer.shares.get(0), 1.0E-12D); + } + } + + /** Whatever a body covers, it covers ONE cross-section of it: the shares are a division. */ + @Test + public void aLayerDividesOneCrossSectionAndNeverMultipliesIt() { + for (double radius : new double[] {0.25D, 0.5D, 1.0D, 1.75D}) { + List layers = sweep(new Vec3d(0.5D, 0.5D, 0.5D), + new Vec3d(12.5D, 3.5D, 0.5D), radius); + assertFalse("radius " + radius + " swept nothing at all", layers.isEmpty()); + for (SweptVolume.Layer layer : layers) { + double total = 0.0D; + for (Double share : layer.shares) { + assertTrue("a block was given a negative or zero share at radius " + radius, + share > 0.0D); + total += share; + } + assertEquals("the shares of one layer are not one whole cross-section (radius " + + radius + ", " + layer.size() + " blocks)", 1.0D, total, 1.0E-9D); + } + } + } + + /** + * The difference the whole width exists to make: a wide body reaches blocks the axis passes + * beside. Fired along a lane between block centres, a ray touches one column and a body a block + * across touches its neighbours too. + */ + @Test + public void aWideBodyReachesWhatTheAxisOnlyPassesBeside() { + Vec3d from = new Vec3d(0.5D, 0.5D, 0.5D); + Vec3d to = new Vec3d(8.5D, 0.5D, 0.5D); + + Set narrow = blocksOf(sweep(from, to, 0.25D)); + Set wide = blocksOf(sweep(from, to, 1.0D)); + + assertTrue("the wide body reached fewer blocks than the ray — a body cannot cover less than" + + " its own axis", wide.containsAll(narrow)); + assertTrue("a body a block across reached nothing beside the line it travelled along:" + + " narrow=" + narrow.size() + " wide=" + wide.size(), wide.size() > narrow.size()); + assertTrue("the block directly beside the axis was never reached", + wide.contains(new BlockPos(4, 0, 1)) || wide.contains(new BlockPos(4, 1, 0))); + } + + /** Wider reaches more, at every width: the trade the calibre choice sells has to be monotone. */ + @Test + public void aWiderBodyNeverReachesFewerBlocksThanANarrowerOne() { + Vec3d from = new Vec3d(0.5D, 0.5D, 0.5D); + Vec3d to = new Vec3d(10.5D, 2.5D, 1.5D); + + int previous = 0; + for (double radius : new double[] {0.25D, 0.5D, 1.0D, 1.5D, 2.0D}) { + int reached = blocksOf(sweep(from, to, radius)).size(); + assertTrue("radius " + radius + " reached " + reached + " blocks where the next narrower" + + " body reached " + previous, reached >= previous); + previous = reached; + } + } + + /** + * Consecutive slices of a wide body overlap by construction. A block offered twice would be asked + * to answer the same body twice — and, one layer up, be charged for it twice. + */ + @Test + public void noBlockIsOfferedTwiceInOneSweep() { + List layers = sweep(new Vec3d(0.5D, 0.5D, 0.5D), + new Vec3d(14.2D, 5.7D, 3.1D), 1.5D); + Set seen = new HashSet<>(); + for (SweptVolume.Layer layer : layers) { + for (BlockPos block : layer.blocks) { + assertTrue("block " + block + " was offered by more than one layer of the same sweep", + seen.add(block)); + } + } + } + + /** + * A wide sweep's layers are the ray's own slices, in the ray's own order — a subsequence of them, + * because a slice whose blocks were all reached earlier has nothing left to offer and is not + * reported. What must never happen is a layer out of order, or one at a place the axis never + * went: that would be the body reaching backwards, or sideways, through the hull. + */ + @Test + public void everyLayerSitsWhereTheAxisWentAndInThatOrder() { + Vec3d from = new Vec3d(0.5D, 0.5D, 0.5D); + Vec3d to = new Vec3d(9.5D, 3.5D, 1.5D); + List ray = rayBlocks(from, to); + List layers = sweep(from, to, 1.0D); + + assertFalse("the wide sweep reported no layers at all", layers.isEmpty()); + int cursor = 0; + double lastT = -1.0D; + for (SweptVolume.Layer layer : layers) { + int at = ray.subList(cursor, ray.size()).indexOf(layer.axis); + assertTrue("layer at " + layer.axis + " is either somewhere the axis never went, or out" + + " of the order the axis went in", at >= 0); + cursor += at + 1; + assertTrue("layers arrived out of order along the axis (" + lastT + " then " + + layer.tEnter + ")", layer.tEnter >= lastT); + lastT = layer.tEnter; + assertFalse("a layer was reported with nothing in it", layer.blocks.isEmpty()); + } + } + + /** + * The cap is a bound on WORK, and a wide body spends it faster than a narrow one over the same + * travel — that is what makes it a bound rather than a distance limit in disguise. + */ + @Test + public void theVoxelCapBoundsWorkAndIsReported() { + Vec3d from = new Vec3d(0.5D, 0.5D, 0.5D); + Vec3d to = new Vec3d(400.5D, 0.5D, 0.5D); + final int cap = 64; + + int examined = SweptVolume.traverse(from, to, 1.0D, cap, new SweptVolume.LayerVisitor() { + @Override + public boolean visit(SweptVolume.Layer layer) { + return false; + } + }); + assertTrue("the traversal examined " + examined + " voxels under a cap of " + cap, + examined <= cap); + assertTrue("the traversal reported no work at all against a segment 400 blocks long", + examined > 0); + } + + private static Set blocksOf(List layers) { + Set blocks = new HashSet<>(); + for (SweptVolume.Layer layer : layers) { + blocks.addAll(layer.blocks); + } + return blocks; + } +} From 2f79c7edb5b40538d33e25df6f03a7387ed2e294 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 07:31:31 +0300 Subject: [PATCH 20/35] test: what a round does inside a hull it cannot get through - it rests inside, and its crater deepens while it is still in there - two plates prove a round's own continuation is not deduped away --- .../test/server/ShotBoresOverTimeE2ETest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java index f4a3b8850..294f945ff 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ShotBoresOverTimeE2ETest.java @@ -6,6 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** @@ -103,6 +104,85 @@ public void aNarrowerRoundOutrunsAWiderOneOnTheSameEnergy() throws Exception { + " two zeroes", narrowDepth > 0); } + /** + * A round that cannot get through comes to REST inside, and the hole it is making GROWS while it + * does. The second half is the one worth a server test: a bore that deepened all at once and then + * sat there would satisfy every "it is still present" assertion in this class and still be the + * instant resolution penetration-over-time replaced. So the depth is read TWICE while the round + * is in the air, and the claim is that the second reading is deeper. + */ + @Test + public void aRoundThatCannotGetThroughRestsInsideAndItsCraterGrowsWhileItDoes() throws Exception { + int wallX = 1500; + prepare(wallX); + buildWall(wallX, 10); + + // Enough to eat several blocks, nowhere near enough for ten: it must run out INSIDE. + int budget = budgetForBlocks(wallX, 3.5D); + long id = fire(wallX - 3.5D, BORE_SPEED, budget, 0.25D); + assertTrue("the substrate refused the shot", id >= 0); + + // First reading: taken the moment it has spent anything at all, so it is certainly inside. + String biting = awaitEnergyBelow(id, budget); + assertTrue("the round ended in the tick it met the wall", isPresent(biting)); + int firstDepth = awaitDepthAtLeast(wallX, 1); + assertTrue("nothing was damaged after the round started paying: " + stageAt(wallX), + firstDepth >= 1); + + // Second reading: deeper, while the same round is still in the air. Polled rather than timed — + // this server ticks at its own rate and a sleep would be pinning the wall clock. + int secondDepth = awaitDepthAtLeast(wallX, firstDepth + 1); + assertTrue("the bore stopped at " + firstDepth + " blocks and never deepened while the round" + + " was still inside: then the crater was cut in one go and the round merely lingered" + + " — which is the behaviour penetration-over-time replaces", secondDepth > firstDepth); + + assertTrue("a round with a fraction of the wall's price came out the far side", awaitGone(id)); + assertEquals("the round stopped, but not by coming to rest in what it was drilling: " + read(id), + "STRUCTURE_IMPACT", endedOf(read(id))); + } + + /** + * A round richer than the plate goes THROUGH it, and is worth less and slower on the far side — + * and, the part that has no other way of being observed, its continuation is not refused as a + * duplicate of its own first impact. Two thin plates with a gap: one round, both damaged. A dedup + * memory keyed on the shot rather than on the impact would let the first plate be hit and silently + * drop everything the same round did afterwards, and no single-plate test can tell. + */ + @Test + public void aRoundThroughAThinHullLeavesSlowerAndItsOwnContinuationIsNotRefused() throws Exception { + int firstX = 1540; + int secondX = firstX + 4; + prepare(firstX); + assertTrue("could not clear the gap", exec("artest fill " + DIM + " " + firstX + " " + (Y - 1) + + " " + (Z - 1) + " " + (secondX + 2) + " " + (Y + 1) + " " + (Z + 1) + + " minecraft:air").contains("\"ok\":true")); + place("minecraft:stone", firstX); + place("minecraft:stone", secondX); + + // Rich enough for both plates and then some: this test is about what a round DOES on the far + // side, so it must not be a test about running out. + int budget = budgetForBlocks(firstX, 6.0D); + double muzzleSpeed = 2.0D; + long id = fire(firstX - 3.0D, muzzleSpeed, budget, 0.25D); + assertTrue("the substrate refused the shot", id >= 0); + + String past = awaitPastX(id, secondX + 1.0D); + assertTrue("the round never got past the second plate while still in the air: " + past, + isPresent(past)); + assertTrue("the round left the plate with everything it arrived with (" + energyOf(past) + + " of " + budget + "): going through has to cost something", energyOf(past) < budget); + assertTrue("the round left the plate at its muzzle speed (" + speedOf(past) + " of " + + muzzleSpeed + "): spending energy on depth costs a body its speed", + speedOf(past) < muzzleSpeed); + + assertTrue("the first plate is untouched, so this round never went through anything: " + + stageAt(firstX), stageOf(stageAt(firstX)) > 0 || destroyed(firstX)); + assertTrue("the SECOND plate is untouched by a round that flew past it with budget in hand (" + + stageAt(secondX) + "): the round's own continuation was refused as a duplicate of" + + " its first impact, which is the one failure a single-plate test cannot see", + stageOf(stageAt(secondX)) > 0 || destroyed(secondX)); + } + // ---- driving private long fire(double x, double speed, int energy, double radius) throws Exception { @@ -148,6 +228,43 @@ private boolean awaitGone(long id) throws Exception { return !isPresent(read(id)); } + /** Poll until the bore is at least this deep, or the budget of patience runs out. */ + private int awaitDepthAtLeast(int wallX, int wanted) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + int depth = boreDepth(wallX); + while (System.currentTimeMillis() < deadline && depth < wanted) { + Thread.sleep(120L); + depth = boreDepth(wallX); + } + return depth; + } + + /** Poll until the round is past {@code x}, so what is read is a body on the FAR side. */ + private String awaitPastX(long id, double x) throws Exception { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + String state = read(id); + while (System.currentTimeMillis() < deadline && isPresent(state) && xOf(state) < x) { + Thread.sleep(100L); + state = read(id); + } + return state; + } + + private static double xOf(String json) { + Matcher m = Pattern.compile("\"x\":(-?[\\d.eE+-]+)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : Double.NEGATIVE_INFINITY; + } + + private static double speedOf(String json) { + Matcher m = Pattern.compile("\"speed\":(-?[\\d.eE+-]+)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : -1.0D; + } + + private static String endedOf(String json) { + Matcher m = Pattern.compile("\"ended\":\"([^\"]*)\"").matcher(json); + return m.find() ? m.group(1) : null; + } + /** How many blocks deep into the wall took damage: the bore's own length. */ private int boreDepth(int wallX) throws Exception { int depth = 0; From 368684fcc7040c0cc04859e88a3cc6eb8b1c4608 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 08:31:09 +0300 Subject: [PATCH 21/35] feat: a hull answers by kind and by angle - the contact seam takes a body's facts, not the shot record - a stage is priced by the kind that arrived: pushed through, or boiled away - a block with no ablation row has one derived from its toughness - a glancing solid round skips off metal, and off nothing else - the probe fill clears recorded damage, so a rebuilt wall is fresh --- .../advancedRocketry/api/ARConfiguration.java | 49 ++++ .../api/damage/TravellingBody.java | 74 +++++ .../command/test/TestProbeCommand.java | 9 +- .../damage/ShipDamageService.java | 5 +- .../damage/StructureDamageEngine.java | 79 ++++- .../projectile/ContactResolver.java | 128 ++++++-- .../projectile/ShotSubstrate.java | 11 +- .../advancedRocketry/util/WeightEngine.java | 76 +++++ .../ArmourAnswersByKindAndAngleE2ETest.java | 276 ++++++++++++++++++ 9 files changed, 664 insertions(+), 43 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/TravellingBody.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index dd04ca5db..f1b81c771 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -410,6 +410,51 @@ public class ARConfiguration { */ @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 still buys, at a value ABOVE that affordability line, is a gate a pack can + * raise deliberately, and the statement that a sub-threshold beam's energy is ABSORBED rather than + * carried on to whatever is behind the plate. Below that line it is inert — it can only refuse + * beams the pricing was going to refuse anyway. Set to zero so that it changes nothing until + * somebody rules on where the line should be.

    + */ + @ConfigProperty(needsSync = true) + public double beamAblationIntensityThreshold = 0.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. @@ -736,6 +781,10 @@ public static void loadPreInit() { 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", 0.0, "Energy per unit of a beam's cross-section below which it does not drill at all, its energy being absorbed as heat rather than carried onward. OFF by default: a stage is bought whole or not at all, so affordability is already an intensity threshold each block sets for itself, and a faint beam never accumulates. Raise this above that line only to gate beams the pricing would otherwise let through", 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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, 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(); 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index e5a7cfe7c..bbf0d07f4 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -13974,7 +13974,14 @@ private void handleFill(MinecraftServer server, ICommandSender sender, String[] for (int x = minX; x <= maxX; x++) { for (int y = minY; y <= maxY; y++) { for (int z = minZ; z <= maxZ; z++) { - if (world.setBlockState(new BlockPos(x, y, z), state)) { + BlockPos at = new BlockPos(x, y, z); + // A fill is the harness saying "this region is fresh". Setting the state alone is + // not: recorded damage is keyed by POSITION and lives outside the block, and it is + // cleared in production by the place and break EVENTS, which setting a state + // directly never fires. So a scenario rebuilding a wall over an earlier scenario's + // crater would arrive pre-damaged, and read as its own doing. + zmaster587.advancedRocketry.damage.BlockDamageSavedData.get(world).clear(at); + if (world.setBlockState(at, state)) { placed++; } } diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java index 7b8047862..70138c8de 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java @@ -96,7 +96,7 @@ public static DamageReport apply(World world, ImpactRequest request) { remember(world, request.getImpactId()); return toReport(StructureDamageEngine.penetrate(world, point, request.getDirection(), request.getBudget(), request.getReachBlocks(), request.getCrossSectionArea(), - request.resumesInside()), null, world); + request.resumesInside(), request.getKind()), null, world); } double[] shipPoint = VSIntegration.toShipFrameFor(world, shipId, point.x, point.y, point.z); @@ -113,7 +113,8 @@ public static DamageReport apply(World world, ImpactRequest request) { StructureDamageEngine.WalkResult walk = StructureDamageEngine.penetrate(world, new Vec3d(shipPoint[0], shipPoint[1], shipPoint[2]), new Vec3d(shipDir[0], shipDir[1], shipDir[2]), request.getBudget(), - request.getReachBlocks(), request.getCrossSectionArea(), request.resumesInside()); + request.getReachBlocks(), request.getCrossSectionArea(), request.resumesInside(), + request.getKind()); return toReport(walk, shipId, world); } diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 19e0daa47..2f6908aaa 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -6,6 +6,7 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.api.damage.StopReason; import zmaster587.advancedRocketry.api.ARConfiguration; @@ -110,6 +111,21 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget, double reachBlocks, double crossSectionArea, boolean resumesInside) { + return penetrate(world, entry, direction, budget, reachBlocks, crossSectionArea, resumesInside, + ImpactKind.KINETIC); + } + + /** + * The same walk, told what KIND of arrival it is spending. + * + *

    The kind picks the material column the price is read from — pushed through, or boiled away — + * and, for the thermal channel alone, decides whether the arrival is intense enough to remove any + * material at all. Everything else about the walk is identical: a caller that does not care hands + * {@code KINETIC} and gets exactly the walk this always was.

    + */ + public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, int budget, + double reachBlocks, double crossSectionArea, + boolean resumesInside, ImpactKind kind) { WalkResult result = new WalkResult(); result.budgetLeft = budget; if (world == null || entry == null || direction == null @@ -119,8 +135,19 @@ public static WalkResult penetrate(World world, Vec3d entry, Vec3d direction, in return result; } + if (tooFaintToDrill(kind, budget, crossSectionArea)) { + // It warms the plate and is conducted away. The energy is gone either way — a beam too + // faint to drill is absorbed, not reflected and not carried onward — but no material is + // removed, which is the whole content of the threshold. + result.budgetSpent = budget; + result.budgetLeft = 0; + result.outcome = DamageOutcome.ABSORBED; + result.stopReason = StopReason.BUDGET_EXHAUSTED; + return result; + } + Walk walk = new Walk(world, entry, direction, result, reachBlocks, crossSectionArea, - resumesInside); + resumesInside, kind); // The bound scales with the body, because the sweep does: holding a wide round to a ray's // voxel budget would not make it cheaper, it would make it stop looking a few blocks in and // report that it had come out the far side of a hull it was still inside. @@ -142,6 +169,8 @@ private static final class Walk implements SweptVolume.LayerVisitor { private final Vec3d farEnd; private final double areaFactor; + /** Which material column this walk is priced against. */ + private final ImpactKind kind; /** * How wide the body is, in blocks, derived from the cross-section it was priced against — * there is one statement of a body's width and this is read from it, never declared twice. @@ -160,7 +189,8 @@ private static final class Walk implements SweptVolume.LayerVisitor { private Vec3d lastSolidExit; private Walk(World world, Vec3d entry, Vec3d direction, WalkResult result, double reachBlocks, - double crossSectionArea, boolean resumesInside) { + double crossSectionArea, boolean resumesInside, ImpactKind kind) { + this.kind = kind; this.skipThisVoxel = resumesInside; this.world = world; this.entry = entry; @@ -260,7 +290,7 @@ public boolean visit(SweptVolume.Layer layer) { // share of the budget, so charging it for the entire cross-section would take the // width out of the round twice and leave a wide shot feebler than any physics says. int spent = spendInto(world, pos, state, result, - areaFactor * layer.shares.get(i), allowance); + areaFactor * layer.shares.get(i), allowance, kind); result.budgetSpent += spent; result.budgetLeft -= spent; } @@ -307,10 +337,10 @@ private WalkResult finish() { * handed the purse, which is what lets one layer be divided between several of them. */ private static int spendInto(World world, BlockPos pos, IBlockState state, WalkResult result, - double areaFactor, int allowance) { + double areaFactor, int allowance, ImpactKind kind) { int maxStage = DamageState.getMaxStage(world, pos); int stage = DamageState.getStage(world, pos); - int stageCost = stageCost(world, pos, areaFactor); + int stageCost = stageCost(world, pos, areaFactor, kind); int left = Math.max(0, allowance); int spent = 0; @@ -353,12 +383,47 @@ public static int stageCost(World world, BlockPos pos) { * anybody writing it down as a rule. */ public static int stageCost(World world, BlockPos pos, double areaFactor) { - double toughness = WeightEngine.INSTANCE.getToughness(world, pos); + return stageCost(world, pos, areaFactor, ImpactKind.KINETIC); + } + + /** + * What one stage costs a body of a given cross-section arriving as {@code kind}. + * + *

    The kind picks the material constant and nothing else: a slug is priced against the block's + * resistance to being pushed through, a beam against its resistance to being boiled away. Same + * law, same units, different column — which is what makes a ceramic that shrugs off a beam and + * shatters under a slug two rows of a table rather than two mechanics. A block with no ablation + * row of its own has one derived from its toughness, so the mechanical price of every block in the + * game is exactly what it always was.

    + */ + public static int stageCost(World world, BlockPos pos, double areaFactor, ImpactKind kind) { + double resistance = WeightEngine.INSTANCE.getResistance(world, pos, kind); int maxStage = Math.max(1, DamageState.getMaxStage(world, pos)); - double perStage = (STAGE_COST_BASE + toughness * STAGE_COST_TOUGHNESS_MULT) / maxStage; + double perStage = (STAGE_COST_BASE + resistance * STAGE_COST_TOUGHNESS_MULT) / maxStage; return Math.max(1, (int) Math.ceil(perStage * Math.max(0.0D, areaFactor))); } + /** + * Is this arrival intense enough to remove material at all? + * + *

    Only the thermal channel has a threshold, and it is not a balance nicety: material conducts + * heat away, so below some power density a beam warms a plate rather than drilling it. A linear + * law without one says a one-watt laser held long enough cuts a battleship, and makes a big + * emitter merely a faster small one. The intensity is the energy behind the body's own face — + * spreading the same energy over a wider beam makes it dimmer, exactly as it should.

    + */ + private static boolean tooFaintToDrill(ImpactKind kind, int budget, double crossSectionArea) { + if (!WeightEngine.isThermalChannel(kind)) { + return false; + } + double threshold = ARConfiguration.getCurrentConfig().beamAblationIntensityThreshold; + if (threshold <= 0.0D) { + return false; + } + double area = crossSectionArea <= 0.0D ? ImpactRequest.REFERENCE_AREA : crossSectionArea; + return budget / area < threshold; + } + /** * Whether there is structure at {@code pos} — the one definition of "something is here", shared * with whatever decides where an impact happens. A travelling body that stopped at a diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index b6042be7e..7137f99c1 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -10,7 +10,11 @@ import zmaster587.advancedRocketry.api.damage.IContactResponder; import zmaster587.advancedRocketry.api.damage.DamageReport; import zmaster587.advancedRocketry.api.damage.ImpactRequest; +import zmaster587.advancedRocketry.api.damage.TravellingBody; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.damage.ShipDamageService; +import zmaster587.advancedRocketry.util.WeightEngine; import zmaster587.advancedRocketry.integration.vs.VSIntegration; /** @@ -33,13 +37,6 @@ public final class ContactResolver { private ContactResolver() { } - /** - * Resolve one shot meeting one block. - * - * @param hit the crossing, carrying the block's own frame, the entry face in that frame and the - * world point - * @param worldVelocity the shot's velocity in WORLD terms - */ /** * What happened, AND how far along its own direction the body got while it happened. The distance * is not part of {@link ContactResult} on purpose: a block answering a contact says what becomes @@ -56,15 +53,26 @@ public static final class Resolution { } } - public static Resolution resolve(World world, Shot shot, StructureCrossing.Hit hit, - Vec3d worldVelocity, double reachBlocks, boolean resumingBore) { - if (world == null || shot == null || hit == null) { + /** + * Resolve one travelling body meeting one block. + * + *

    It takes the body's FACTS rather than whatever record is carrying them, so that armour serves + * every weapon family: a shell out of a gun, a bolt, and a beam somebody is holding on a hull are + * three different things to own and one thing to answer.

    + * + * @param body the body's own facts — velocity, kind, what it is still worth, how wide it is, and + * the identity this meeting is remembered by + * @param hit the crossing: the block's own frame, the entry face in that frame, the world point + */ + public static Resolution resolve(World world, TravellingBody body, StructureCrossing.Hit hit, + double reachBlocks, boolean resumingBore) { + if (world == null || body == null || hit == null) { return new Resolution(ContactResult.stopped(), 0.0D); } Contact contact = new Contact(hit.block, hit.point, hit.entryFace, - inBlockFrame(world, hit, worldVelocity), shot.getKind(), shot.getImpactEnergy(), - shot.getRadius(), 1.0D, hit.shipId); + inBlockFrame(world, hit, body.getVelocity()), body.getKind(), body.getEnergy(), + body.getRadius(), 1.0D, hit.shipId); IContactResponder responder = responderAt(world, hit.block); if (responder != null) { @@ -76,7 +84,77 @@ public static Resolution resolve(World world, Shot shot, StructureCrossing.Hit h return new Resolution(answer, answer.isStopped() ? 0.0D : 1.0D); } } - return defaultLaw(world, shot, contact, reachBlocks, resumingBore); + ContactResult skipped = ricochet(world, contact, body); + if (skipped != null) { + // A graze that skipped off did not walk into anything, so the body is moved past the block + // it bounced from, exactly as a block that answered for itself would have left it. + return new Resolution(skipped, 1.0D); + } + return defaultLaw(world, body, contact, reachBlocks, resumingBore); + } + + /** + * The default ricochet: a solid round that meets METAL at a shallow enough angle skips off it. + * + *

    Three narrowings, and each one is what keeps this from being a surprise. Only a body with + * MASS bounces — a beam has nothing to reflect and its energy is absorbed. Only METAL bounces, so + * a player meets skipping rounds off a steel hull and never off a plank wall. And only a shallow + * enough hit bounces, on an angle threshold that preserves the one ordering worth preserving: a + * squarer hit never bounces where a shallower one did not.

    + * + *

    Answers null when the body digs in — which is every case except a glancing hit on metal, and + * therefore the case the whole game is still made of.

    + */ + private static ContactResult ricochet(World world, Contact contact, TravellingBody body) { + if (!carriesMass(contact.getKind()) || contact.getEntryFace() == null) { + return null; + } + double threshold = ARConfiguration.getCurrentConfig().ricochetIncidenceDegrees; + if (threshold >= 90.0D || contact.getIncidenceDegrees() < threshold) { + return null; + } + if (!WeightEngine.INSTANCE.isMetal(world, contact.getPos())) { + return null; + } + Vec3d bounced = mirrored(contact); + if (bounced == null) { + return null; + } + Vec3d worldVelocity = toWorldFrame(world, contact.getShipId(), bounced); + return worldVelocity == null ? null : ContactResult.deflected(worldVelocity, body.getEnergy()); + } + + /** Which kinds are a lump of something travelling, as opposed to energy arriving. */ + private static boolean carriesMass(ImpactKind kind) { + return kind == ImpactKind.KINETIC || kind == ImpactKind.EXPLOSIVE; + } + + /** + * The body's velocity mirrored in the face it grazed, in the BLOCK's own frame — which is the only + * frame in which the normal and the velocity are the same kind of thing. Restitution takes its + * cut here, so a bounce costs a round something and two facing plates cannot keep one forever. + */ + private static Vec3d mirrored(Contact contact) { + Vec3d normal = contact.getNormal(); + Vec3d velocity = contact.getVelocity(); + if (normal == null || velocity == null) { + return null; + } + double along = velocity.x * normal.x + velocity.y * normal.y + velocity.z * normal.z; + Vec3d reflected = velocity.subtract(normal.scale(2.0D * along)); + return reflected.scale(Math.max(0.0D, ARConfiguration.getCurrentConfig().ricochetRestitution)); + } + + /** Back out of the block's frame, because what flies away flies away through the world. */ + private static Vec3d toWorldFrame(World world, String shipId, Vec3d blockFrame) { + if (shipId == null) { + return blockFrame; + } + double[] rotated = VSIntegration.rotateToWorldFrameFor(world, shipId, blockFrame.x, + blockFrame.y, blockFrame.z); + // A ship that stopped answering between the crossing and here cannot be asked where "away" + // points. Answering null lets the body dig in instead, which is the recoverable mistake. + return rotated == null ? null : new Vec3d(rotated[0], rotated[1], rotated[2]); } /** How far a body of this radius reaches across, in square blocks. */ @@ -99,14 +177,14 @@ public static double areaOf(double radius) { * energy behind a wider face buys less depth. At the reference cross-section the price is what it * always was.

    */ - private static Resolution defaultLaw(World world, Shot shot, Contact contact, double reachBlocks, - boolean resumingBore) { + private static Resolution defaultLaw(World world, TravellingBody body, Contact contact, + double reachBlocks, boolean resumingBore) { ImpactRequest request = resumingBore - ? ImpactRequest.resuming(shot.nextImpactId(), contact.getPoint(), - directionOf(contact, shot), contact.getEnergy(), contact.getKind(), + ? ImpactRequest.resuming(body.getImpactId(), contact.getPoint(), + body.getDirection(), contact.getEnergy(), contact.getKind(), reachBlocks, areaOf(contact.getRadius())) - : ImpactRequest.penetrating(shot.nextImpactId(), contact.getPoint(), - directionOf(contact, shot), contact.getEnergy(), contact.getKind(), + : ImpactRequest.penetrating(body.getImpactId(), contact.getPoint(), + body.getDirection(), contact.getEnergy(), contact.getKind(), reachBlocks, areaOf(contact.getRadius())); DamageReport report = ShipDamageService.apply(world, request); @@ -119,18 +197,6 @@ reachBlocks, areaOf(contact.getRadius())) return new Resolution(ContactResult.passedThrough(residual), report.getDistanceWalked()); } - /** - * The world-frame direction the impact is declared along. Taken from the shot rather than from the - * contact's own velocity, because the contact carries a BLOCK-frame velocity and the damage - * service works in world terms — mixing the two is the frame bug this separation exists to make - * impossible. - */ - private static Vec3d directionOf(Contact contact, Shot shot) { - Vec3d v = shot.getVelocity(); - double speed = v.lengthVector(); - return speed <= 1.0E-9D ? new Vec3d(0.0D, -1.0D, 0.0D) : v.scale(1.0D / speed); - } - /** * The shot's velocity expressed in the frame the block lives in — itself off a ship, rotated into * subspace on one, through the port's own vector rotation rather than a difference of two mapped diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 2638f9adc..0e61bf3de 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -7,6 +7,7 @@ import net.minecraft.world.World; import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.damage.ImpactKind; +import zmaster587.advancedRocketry.api.damage.TravellingBody; import zmaster587.advancedRocketry.api.projectile.ShotEndReason; import zmaster587.advancedRocketry.api.projectile.ShotSpec; import zmaster587.advancedRocketry.damage.ImpactKindMapping; @@ -227,8 +228,14 @@ static ShotEndReason step(World world, Shot shot) { // A crossing found at zero distance is a bore this shot began on an earlier tick: it // is standing in that block, and it paid for it then. boolean resuming = structure.distance <= CROSSING_EPSILON * 2.0D; - ContactResolver.Resolution contact = ContactResolver.resolve(world, shot, structure, - velocity, reachInside, resuming); + // The seam is handed the BODY's facts, not this shot: the same armour has to answer a + // bolt and a held beam, and neither of those is a record in this registry. Minting the + // impact identity here is what keeps a bore across several ticks from being refused as + // a duplicate of its own first contact. + TravellingBody body = new TravellingBody(shot.nextImpactId(), velocity, shot.getKind(), + shot.getImpactEnergy(), shot.getRadius()); + ContactResolver.Resolution contact = ContactResolver.resolve(world, body, structure, + reachInside, resuming); if (contact.result.isStopped()) { // It came to rest where the walk stopped, not where it went in. shot.setPosition(structure.point.add(direction.scale(contact.distance))); diff --git a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java index fa89e2fbc..f43162d81 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java @@ -19,6 +19,7 @@ import net.minecraftforge.items.CapabilityItemHandler; import net.minecraftforge.items.IItemHandler; import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.block.BlockBipropellantRocketMotor; import zmaster587.advancedRocketry.block.BlockFuelTank; import zmaster587.advancedRocketry.block.BlockPressurizedFluidTank; @@ -82,6 +83,14 @@ public enum WeightEngine { private Map toughnessByRegex = new LinkedHashMap<>(); private Map toughnessMaterials = new HashMap<>(); private double toughnessFallback = 2.0; + /** + * The ablation column: how much energy this block costs per unit of volume BOILED AWAY, as opposed + * to pushed through. Same units and the same resolution chain as toughness, and deliberately + * sparse — a block with no row here has its ablation derived from its toughness, so the table only + * ever has to name the materials whose two channels genuinely disagree. + */ + private Map ablationIndividual = new HashMap<>(); + private Map ablationByRegex = new LinkedHashMap<>(); // Transient runtime caches (not persisted; cleared on load()). private final Map resolvedItemCache = new HashMap<>(); @@ -214,6 +223,71 @@ public void setIndividualToughness(String registryName, double toughness) { toughnessIndividual.put(registryName, toughness); } + /** + * How much this block resists ONE KIND of arrival, in the same units as toughness. + * + *

    Two channels of one law, not two mechanics

    + *

    A slug is pushed through material; a beam boils it away. Both are "how much energy this stuff + * costs per unit of volume removed", so the law is the same and only the constant differs by kind. + * That is what makes a ceramic which shrugs off a beam and shatters under a slug two ROWS of one + * table rather than two special cases.

    + * + *

    A block nobody wrote a row for

    + *

    ...keeps exactly today's single toughness for the mechanical kinds, so the plain hull the + * whole game is built out of behaves precisely as it did. Its ablation figure is DERIVED from that + * toughness by a single factor, because the alternative — defaulting the two columns equal — would + * quietly declare that a joule of laser digs as much hull as a joule of shell, which is both wrong + * and the opposite of the game being built.

    + */ + public float getResistance(Block block, ImpactKind kind) { + float mechanical = getToughness(block); + if (kind == null || !isThermalChannel(kind)) { + return mechanical; + } + String key = block == null || block.getRegistryName() == null + ? null : block.getRegistryName().toString(); + if (key != null) { + Double override = ablationIndividual.get(key); + if (override != null) { + return override.floatValue(); + } + Double regex = matchRegex(ablationByRegex, key); + if (regex != null) { + return regex.floatValue(); + } + } + return (float) (mechanical * ARConfiguration.getCurrentConfig().ablationResistanceFactor); + } + + /** The same question at a position. */ + public float getResistance(World world, BlockPos pos, ImpactKind kind) { + return world == null || pos == null + ? (float) toughnessFallback + : getResistance(world.getBlockState(pos).getBlock(), kind); + } + + /** + * Is this block METAL? Asked of the same material the toughness table already resolves by, so + * "is this metal" is a question that is already answered for every block in the game, vanilla + * ones included, and no new classification machinery is invented to ask it. + */ + public boolean isMetal(World world, BlockPos pos) { + if (world == null || pos == null) { + return false; + } + return world.getBlockState(pos).getMaterial() == Material.IRON; + } + + /** Which kinds arrive as heat to be conducted away rather than as something to be pushed through. */ + public static boolean isThermalChannel(ImpactKind kind) { + return kind == ImpactKind.THERMAL || kind == ImpactKind.BEAM; + } + + /** Register an explicit per-registry-name ablation resistance (highest precedence). */ + public void setIndividualAblation(String registryName, double resistance) { + ablationIndividual.put(registryName, resistance); + } + public float getWeight(Collection stacks) { return stacks.stream().map(this::getWeight).reduce(0.0F, Float::sum); } @@ -303,6 +377,8 @@ public void load() { } toughnessIndividual = readMap(gson, root, "toughnessIndividual", mapType); + ablationIndividual = readMap(gson, root, "ablationIndividual", mapType); + ablationByRegex = readMap(gson, root, "ablationByRegex", mapType); toughnessByRegex = readMap(gson, root, "toughnessByRegex", linkedType); toughnessMaterials = readMap(gson, root, "toughnessMaterials", mapType); if (toughnessMaterials.isEmpty()) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java new file mode 100644 index 000000000..ccaec3a3a --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java @@ -0,0 +1,276 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * What a hull does about WHAT hit it and HOW, rather than only about how much. + * + *

    Until now a block resisted with one number and every arrival paid it: a beam dug like a slug, and + * a round skimming a steel plate at five degrees dug in exactly as one arriving square-on. Three + * claims here, and each is an ordering rather than a quantity, because every number behind them is + * balance and will move.

    + * + *
      + *
    • Two columns. Being boiled away costs far more per joule than being pushed through, so + * a beam buys much less depth than a slug carrying the same energy. That is not a nerf: it is + * what makes a laser buy precision and having nothing to reload instead of digging power.
    • + *
    • A price a faint beam cannot meet. A stage is bought whole or not at all, so a beam + * carrying a slug's price for a block does not scratch it — and, keeping its energy rather + * than banking it, never will however long it is held.
    • + *
    • A graze skips off METAL. And off metal only, so a player meets bouncing rounds where a + * player expects them and never off a plank wall.
    • + *
    + */ +public class ArmourAnswersByKindAndAngleE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + /** A site of this class's own, clear of the other shot scenarios on this shared server. */ + private static final int Y = 70, Z = 1010; + private static final int SLUG_X = 1700, BEAM_X = 1730, FAINT_X = 1760; + private static final int STEEL_X = 1790, WOOD_X = 1820; + + private static final int WALL_DEPTH = 10; + private static final double BORE_SPEED = 0.45D; + + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern STAGE = Pattern.compile("\"stage\":(-?\\d+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); + private static final Pattern MAX_STAGE = Pattern.compile("\"maxStage\":(-?\\d+)"); + + /** + * The same energy, the same body, the same wall — only the KIND differs, and the depths must not + * be the same. Priced off the wall rather than asserted as a number: what is claimed is that + * boiling material away costs more per joule than pushing through it, not by how much. + */ + @Test + public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { + prepare(SLUG_X); + prepare(BEAM_X); + buildWall(SLUG_X); + buildWall(BEAM_X); + + int budget = budgetForBlocks(SLUG_X, 4.0D); + assertTrue("the wall has no price, so no budget here means anything", budget > 0); + + long slug = fire(SLUG_X - 3.0D, budget, "KINETIC"); + long beam = fire(BEAM_X - 3.0D, budget, "BEAM"); + assertTrue("both shots must be admitted or the comparison is about one of them", + slug >= 0 && beam >= 0); + awaitGone(slug); + awaitGone(beam); + + int slugDepth = boreDepth(SLUG_X); + int beamDepth = boreDepth(BEAM_X); + assertTrue("the slug did not get into the wall at all, so the comparison is between two" + + " zeroes", slugDepth > 0); + assertTrue("a beam dug as deep as a slug carrying the same energy (beam=" + beamDepth + + " slug=" + slugDepth + "): then the two channels are one column and a laser is" + + " simply a better gun", beamDepth < slugDepth); + } + + /** + * A beam carrying exactly what would destroy a block outright as a slug does not scratch it. + * + *

    This is the two-column claim at its sharpest, and it is also where the intensity threshold + * turns out to live already: a stage is bought whole or not at all, so a beam that cannot afford + * one buys nothing this tick and — carrying its energy onward rather than banking it — nothing on + * any later tick either. The faint laser that cuts a battleship if you hold it long enough cannot + * happen, and it cannot happen because of the PRICE, not because of a separate gate.

    + */ + @Test + public void aBeamCarryingABlocksWorthOfEnergyDoesNotScratchIt() throws Exception { + prepare(FAINT_X); + buildWall(FAINT_X); + + // Exactly one block's worth at the mechanical column — a slug with this much destroys it. + int budget = budgetForBlocks(FAINT_X, 1.0D); + assertTrue("the wall has no price, so this budget means nothing", budget > 0); + long id = fire(FAINT_X - 3.0D, budget, "BEAM"); + assertTrue("the substrate refused the beam", id >= 0); + awaitGone(id); + + assertTrue("a beam removed material with a slug's price for it (" + stageAt(FAINT_X) + "):" + + " then boiling a block away costs what pushing through it costs, the two channels" + + " are one column, and a laser is simply a better gun", + stageOf(stageAt(FAINT_X)) == 0 && !destroyed(FAINT_X)); + } + + /** + * A graze skips off steel and digs into wood. Two plates, one angle, one round: the material is + * the only difference, which is what makes this about the narrowing rather than about the angle. + */ + @Test + public void aGrazingRoundSkipsOffSteelAndDigsIntoWood() throws Exception { + prepare(STEEL_X); + prepare(WOOD_X); + buildPlate(STEEL_X, "minecraft:iron_block"); + buildPlate(WOOD_X, "minecraft:planks"); + + int budget = budgetForBlocks(WOOD_X, 4.0D); + assertTrue("the plate has no price, so no budget here means anything", budget > 0); + + // The evidence is the round TURNING, not the plate being unmarked: a plate is unmarked by a + // round that missed it entirely, and that reading would pass against a substrate with no + // ricochet in it at all. It arrives descending, so a bounce off the top face is the moment + // its vertical velocity goes UP. + long offSteel = grazeAt(STEEL_X, budget); + assertTrue("the steel shot was refused", offSteel >= 0); + boolean steelTurned = awaitClimbing(offSteel); + assertTrue("a round grazing a steel plate never turned — it dug in, and metal is the one" + + " material a glancing hit is supposed to skip off: " + read(offSteel), + steelTurned); + assertTrue("the steel plate took damage from a round that skipped off it: " + + firstTouched(STEEL_X), firstTouched(STEEL_X) == null); + + long intoWood = grazeAt(WOOD_X, budget); + assertTrue("the wood shot was refused", intoWood >= 0); + boolean woodTurned = awaitClimbing(intoWood); + assertTrue("the same round at the same angle skipped off WOOD: a plank wall must never bounce" + + " a shell, or ricochet stops being where a player expects it: " + read(intoWood), + !woodTurned); + assertTrue("the round neither turned nor marked the wooden plate anywhere along it — then it" + + " missed, and this run compared nothing", firstTouched(WOOD_X) != null); + } + + // ---- driving + + /** Straight down the X axis into the face of the wall: square-on, so nothing can ricochet. */ + private long fire(double x, int energy, String kind) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + x + " " + (Y + 0.5D) + " " + (Z + 0.5D) + + " " + BORE_SPEED + " 0 0 " + energy + " 1200 " + kind + " 0.25 1.0")); + } + + /** + * A round arriving at a very shallow angle to the plate's top face: mostly along it, barely into + * it. The plate is one block thick and the round comes in from above and beside. + */ + private long grazeAt(int plateX, int energy) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + (plateX - 4.0D) + " " + (Y + 1.4D) + " " + + (Z + 0.5D) + " 2.0 -0.12 0 " + energy + " 1200 KINETIC 0.25 1.0")); + } + + private void buildWall(int fromX) throws Exception { + assertTrue("could not build the wall", exec("artest fill " + DIM + " " + fromX + " " + Y + " " + + Z + " " + (fromX + WALL_DEPTH - 1) + " " + Y + " " + Z + " minecraft:stone") + .contains("\"ok\":true")); + } + + /** One block thick and long enough to be grazed along, with clear air above it. */ + private void buildPlate(int fromX, String block) throws Exception { + assertTrue("could not build the plate", exec("artest fill " + DIM + " " + fromX + " " + Y + " " + + (Z - 1) + " " + (fromX + 8) + " " + Y + " " + (Z + 1) + " " + block) + .contains("\"ok\":true")); + } + + private void prepare(int wallX) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + + ((wallX - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + ((wallX + 24) >> 4) + " " + + ((Z + 16) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (wallX - 8) + " " + + (Y - 2) + " " + (Z - 4) + " " + (wallX + 20) + " " + (Y + 6) + " " + (Z + 4) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + private int boreDepth(int wallX) throws Exception { + int depth = 0; + for (int i = 0; i < WALL_DEPTH; i++) { + if (stageOf(stageAt(wallX + i)) > 0 || destroyed(wallX + i)) { + depth = i + 1; + } + } + return depth; + } + + private int budgetForBlocks(int wallX, double blocks) throws Exception { + String state = stageAt(wallX); + Matcher cost = STAGE_COST.matcher(state); + Matcher stages = MAX_STAGE.matcher(state); + if (!cost.find() || !stages.find()) { + return 0; + } + return (int) (Integer.parseInt(cost.group(1)) + * Math.max(1, Integer.parseInt(stages.group(1))) * blocks); + } + + private String stageAt(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private boolean destroyed(int x) throws Exception { + String state = stageAt(x); + return state.contains("\"wasDestroyed\":true") || state.contains("\"block\":\"minecraft:air\""); + } + + private static int stageOf(String json) { + Matcher m = STAGE.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 0; + } + + private static long idOf(String json) { + Matcher m = ID.matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + /** + * Did this round ever start CLIMBING? It is fired descending, so an upward vertical velocity can + * only come from the plate turning it — which makes this the bounce itself rather than a proxy + * for one. Answers false when the round ends without ever having climbed. + */ + private boolean awaitClimbing(long id) throws Exception { + long deadline = System.currentTimeMillis() + 25_000L; + while (System.currentTimeMillis() < deadline) { + String state = read(id); + if (!state.contains("\"present\":true")) { + return false; + } + if (vyOf(state) > 1.0E-6D) { + return true; + } + Thread.sleep(60L); + } + return false; + } + + /** Anywhere along the plate, the first block that took something — or null if none did. */ + private String firstTouched(int plateX) throws Exception { + for (int x = plateX; x <= plateX + 8; x++) { + for (int dz = -1; dz <= 1; dz++) { + String state = exec("artest damage stage " + DIM + " " + x + " " + Y + " " + (Z + dz)); + boolean gone = state.contains("\"wasDestroyed\":true") + || state.contains("\"block\":\"minecraft:air\""); + if (gone || stageOf(state) > 0) { + return x + "," + (Z + dz) + " -> " + state; + } + } + } + return null; + } + + private String read(long id) throws Exception { + return exec("artest shot read " + DIM + " " + id); + } + + private static double vyOf(String json) { + Matcher m = Pattern.compile("\"vy\":(-?[\\d.eE+-]+)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : 0.0D; + } + + private void awaitGone(long id) throws Exception { + long deadline = System.currentTimeMillis() + 25_000L; + while (System.currentTimeMillis() < deadline + && read(id).contains("\"present\":true")) { + Thread.sleep(120L); + } + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} From 0dad2925451c9a82bdc64c4443dcf708500fef2c Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 10:08:55 +0300 Subject: [PATCH 22/35] feat: a beam below the intensity threshold is absorbed, not passed through - the threshold sits above the affordability line of metal - refused on price alone, a faint beam went clean through the plate - the probe reports both columns, so neither is read off a depth - each armour scenario gets its own lane: a round outlives its own wall --- .../advancedRocketry/api/ARConfiguration.java | 20 +- .../command/test/TestProbeCommand.java | 4 + .../ArmourAnswersByKindAndAngleE2ETest.java | 172 ++++++++++++------ 3 files changed, 130 insertions(+), 66 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index f1b81c771..83bb8e4b9 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -433,14 +433,20 @@ public class ARConfiguration { * 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 still buys, at a value ABOVE that affordability line, is a gate a pack can - * raise deliberately, and the statement that a sub-threshold beam's energy is ABSORBED rather than - * carried on to whatever is behind the plate. Below that line it is inert — it can only refuse - * beams the pricing was going to refuse anyway. Set to zero so that it changes nothing until - * somebody rules on where the line should be.

    + *

    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 = 0.0; + 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. @@ -784,7 +790,7 @@ public static void loadPreInit() { 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", 0.0, "Energy per unit of a beam's cross-section below which it does not drill at all, its energy being absorbed as heat rather than carried onward. OFF by default: a stage is bought whole or not at all, so affordability is already an intensity threshold each block sets for itself, and a faint beam never accumulates. Raise this above that line only to gate beams the pricing would otherwise let through", 0.0, Double.MAX_VALUE).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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, 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(); diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index bbf0d07f4..03343102b 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -1284,6 +1284,10 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ 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); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java index ccaec3a3a..9b9362f01 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java @@ -30,9 +30,19 @@ public class ArmourAnswersByKindAndAngleE2ETest extends AbstractSharedServerTest private static final int DIM = 0; /** A site of this class's own, clear of the other shot scenarios on this shared server. */ - private static final int Y = 70, Z = 1010; - private static final int SLUG_X = 1700, BEAM_X = 1730, FAINT_X = 1760; - private static final int STEEL_X = 1790, WOOD_X = 1820; + private static final int Y = 70, X = 1700; + /** + * Every scenario gets its own LANE, and they are separated across the line of fire rather than + * along it. + * + *

    Down one lane they were not separate at all: a round rich enough to be interesting punches + * through its own ten-block wall with budget in hand and flies on into the next scenario's wall + * twenty blocks downrange — so "how deep did the beam get" was measured on a hole the SLUG made. + * It passed for as long as the budgets were too small to leave the first wall, which is the worst + * way for an arrangement to be wrong: silently, until the numbers get interesting.

    + */ + private static final int SLUG_Z = 1010, BEAM_Z = 1030, FAINT_Z = 1050, STRONG_Z = 1070; + private static final int STEEL_Z = 1090, WOOD_Z = 1110; private static final int WALL_DEPTH = 10; private static final double BORE_SPEED = 0.45D; @@ -49,28 +59,36 @@ public class ArmourAnswersByKindAndAngleE2ETest extends AbstractSharedServerTest */ @Test public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { - prepare(SLUG_X); - prepare(BEAM_X); - buildWall(SLUG_X); - buildWall(BEAM_X); - - int budget = budgetForBlocks(SLUG_X, 4.0D); + prepare(SLUG_Z); + prepare(BEAM_Z); + buildWall(SLUG_Z); + buildWall(BEAM_Z); + + // Rich enough that the beam is over the intensity threshold and genuinely drilling: below it + // this test would go green for the threshold's reasons and say nothing about the columns. + int budget = budgetForBlocks(SLUG_Z, 20.0D); assertTrue("the wall has no price, so no budget here means anything", budget > 0); + // Read the two columns off an INTACT block, before anything is fired at it. Taken afterwards + // the block is air, whose toughness is zero — and zero times any factor is zero, so both + // columns agree there and the reading says nothing about either. + String priced = stageAt(X, SLUG_Z); - long slug = fire(SLUG_X - 3.0D, budget, "KINETIC"); - long beam = fire(BEAM_X - 3.0D, budget, "BEAM"); + long slug = fire(SLUG_Z, budget, "KINETIC"); + long beam = fire(BEAM_Z, budget, "BEAM"); assertTrue("both shots must be admitted or the comparison is about one of them", slug >= 0 && beam >= 0); awaitGone(slug); awaitGone(beam); - int slugDepth = boreDepth(SLUG_X); - int beamDepth = boreDepth(BEAM_X); + int slugDepth = boreDepth(SLUG_Z); + int beamDepth = boreDepth(BEAM_Z); assertTrue("the slug did not get into the wall at all, so the comparison is between two" + " zeroes", slugDepth > 0); assertTrue("a beam dug as deep as a slug carrying the same energy (beam=" + beamDepth - + " slug=" + slugDepth + "): then the two channels are one column and a laser is" - + " simply a better gun", beamDepth < slugDepth); + + " slug=" + slugDepth + ", budget=" + budget + "): then the two channels are one" + + " column and a laser is simply a better gun." + + " intactPrice=" + priced, + beamDepth < slugDepth); } /** @@ -84,20 +102,54 @@ public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { */ @Test public void aBeamCarryingABlocksWorthOfEnergyDoesNotScratchIt() throws Exception { - prepare(FAINT_X); - buildWall(FAINT_X); + prepare(FAINT_Z); + buildWall(FAINT_Z); // Exactly one block's worth at the mechanical column — a slug with this much destroys it. - int budget = budgetForBlocks(FAINT_X, 1.0D); + int budget = budgetForBlocks(FAINT_Z, 1.0D); assertTrue("the wall has no price, so this budget means nothing", budget > 0); - long id = fire(FAINT_X - 3.0D, budget, "BEAM"); + long id = fire(FAINT_Z, budget, "BEAM"); assertTrue("the substrate refused the beam", id >= 0); awaitGone(id); - assertTrue("a beam removed material with a slug's price for it (" + stageAt(FAINT_X) + "):" + assertTrue("a beam removed material with a slug's price for it (" + stageAt(X, FAINT_Z) + "):" + " then boiling a block away costs what pushing through it costs, the two channels" + " are one column, and a laser is simply a better gun", - stageOf(stageAt(FAINT_X)) == 0 && !destroyed(FAINT_X)); + stageOf(stageAt(X, FAINT_Z)) == 0 && !destroyed(X, FAINT_Z)); + } + + /** + * The intensity threshold, as the one thing the price does not do. + * + *

    Two beams into identical walls, the same body, the same wall, differing only in how much + * energy is behind that face. The weaker one could afford several stages — the price alone would + * let it dig — and it removes nothing, because it is not INTENSE enough; the stronger one digs. + * What makes this worth a test rather than a tuning note is where the weak beam's energy goes: it + * is absorbed by the plate. Without the threshold that beam does not warm the hull, it passes + * clean through it carrying everything it arrived with.

    + */ + @Test + public void aBeamBelowTheIntensityThresholdRemovesNothingWhileAStrongerOneDigs() throws Exception { + prepare(FAINT_Z); + prepare(STRONG_Z); + buildWall(FAINT_Z); + buildWall(STRONG_Z); + + // Straddling the shipped threshold at the reference cross-section: both could afford stages + // on price, so the only thing separating them is intensity. + long faint = fire(FAINT_Z, 8_000, "BEAM"); + long strong = fire(STRONG_Z, 14_000, "BEAM"); + assertTrue("both beams must be admitted", faint >= 0 && strong >= 0); + awaitGone(faint); + awaitGone(strong); + + assertTrue("a beam below the intensity threshold removed material anyway (" + + stageAt(X, FAINT_Z) + "): then the threshold is not a gate and a faint beam either digs" + + " or, worse, passes clean through the plate with all it arrived with", + stageOf(stageAt(X, FAINT_Z)) == 0 && !destroyed(X, FAINT_Z)); + assertTrue("the stronger beam removed nothing either (" + stageAt(X, STRONG_Z) + "): then this" + + " run compared two refusals and the threshold is not where it was thought to be", + stageOf(stageAt(X, STRONG_Z)) > 0 || destroyed(X, STRONG_Z)); } /** @@ -106,90 +158,92 @@ public void aBeamCarryingABlocksWorthOfEnergyDoesNotScratchIt() throws Exception */ @Test public void aGrazingRoundSkipsOffSteelAndDigsIntoWood() throws Exception { - prepare(STEEL_X); - prepare(WOOD_X); - buildPlate(STEEL_X, "minecraft:iron_block"); - buildPlate(WOOD_X, "minecraft:planks"); + prepare(STEEL_Z); + prepare(WOOD_Z); + buildPlate(STEEL_Z, "minecraft:iron_block"); + buildPlate(WOOD_Z, "minecraft:planks"); - int budget = budgetForBlocks(WOOD_X, 4.0D); + int budget = budgetForBlocks(WOOD_Z, 4.0D); assertTrue("the plate has no price, so no budget here means anything", budget > 0); // The evidence is the round TURNING, not the plate being unmarked: a plate is unmarked by a // round that missed it entirely, and that reading would pass against a substrate with no // ricochet in it at all. It arrives descending, so a bounce off the top face is the moment // its vertical velocity goes UP. - long offSteel = grazeAt(STEEL_X, budget); + long offSteel = grazeAt(STEEL_Z, budget); assertTrue("the steel shot was refused", offSteel >= 0); boolean steelTurned = awaitClimbing(offSteel); assertTrue("a round grazing a steel plate never turned — it dug in, and metal is the one" + " material a glancing hit is supposed to skip off: " + read(offSteel), steelTurned); assertTrue("the steel plate took damage from a round that skipped off it: " - + firstTouched(STEEL_X), firstTouched(STEEL_X) == null); + + firstTouched(STEEL_Z), firstTouched(STEEL_Z) == null); - long intoWood = grazeAt(WOOD_X, budget); + long intoWood = grazeAt(WOOD_Z, budget); assertTrue("the wood shot was refused", intoWood >= 0); boolean woodTurned = awaitClimbing(intoWood); assertTrue("the same round at the same angle skipped off WOOD: a plank wall must never bounce" + " a shell, or ricochet stops being where a player expects it: " + read(intoWood), !woodTurned); assertTrue("the round neither turned nor marked the wooden plate anywhere along it — then it" - + " missed, and this run compared nothing", firstTouched(WOOD_X) != null); + + " missed, and this run compared nothing", firstTouched(WOOD_Z) != null); } // ---- driving /** Straight down the X axis into the face of the wall: square-on, so nothing can ricochet. */ - private long fire(double x, int energy, String kind) throws Exception { - return idOf(exec("artest shot fire " + DIM + " " + x + " " + (Y + 0.5D) + " " + (Z + 0.5D) - + " " + BORE_SPEED + " 0 0 " + energy + " 1200 " + kind + " 0.25 1.0")); + private long fire(int lane, int energy, String kind) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + (X - 3.0D) + " " + (Y + 0.5D) + " " + + (lane + 0.5D) + " " + BORE_SPEED + " 0 0 " + energy + " 1200 " + kind + " 0.25 1.0")); } /** * A round arriving at a very shallow angle to the plate's top face: mostly along it, barely into * it. The plate is one block thick and the round comes in from above and beside. */ - private long grazeAt(int plateX, int energy) throws Exception { - return idOf(exec("artest shot fire " + DIM + " " + (plateX - 4.0D) + " " + (Y + 1.4D) + " " - + (Z + 0.5D) + " 2.0 -0.12 0 " + energy + " 1200 KINETIC 0.25 1.0")); + private long grazeAt(int lane, int energy) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + (X - 1.0D) + " " + (Y + 1.6D) + " " + + (lane + 0.5D) + " 1.5 -0.2 0 " + energy + " 1200 KINETIC 0.25 1.0")); } - private void buildWall(int fromX) throws Exception { - assertTrue("could not build the wall", exec("artest fill " + DIM + " " + fromX + " " + Y + " " - + Z + " " + (fromX + WALL_DEPTH - 1) + " " + Y + " " + Z + " minecraft:stone") + private void buildWall(int lane) throws Exception { + assertTrue("could not build the wall", exec("artest fill " + DIM + " " + X + " " + Y + " " + + lane + " " + (X + WALL_DEPTH - 1) + " " + Y + " " + lane + " minecraft:stone") .contains("\"ok\":true")); } /** One block thick and long enough to be grazed along, with clear air above it. */ - private void buildPlate(int fromX, String block) throws Exception { - assertTrue("could not build the plate", exec("artest fill " + DIM + " " + fromX + " " + Y + " " - + (Z - 1) + " " + (fromX + 8) + " " + Y + " " + (Z + 1) + " " + block) + private void buildPlate(int lane, String block) throws Exception { + assertTrue("could not build the plate", exec("artest fill " + DIM + " " + X + " " + Y + " " + + (lane - 1) + " " + (X + 8) + " " + Y + " " + (lane + 1) + " " + block) .contains("\"ok\":true")); } - private void prepare(int wallX) throws Exception { + private void prepare(int lane) throws Exception { assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " - + ((wallX - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + ((wallX + 24) >> 4) + " " - + ((Z + 16) >> 4)).contains("\"ok\":true")); - assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (wallX - 8) + " " - + (Y - 2) + " " + (Z - 4) + " " + (wallX + 20) + " " + (Y + 6) + " " + (Z + 4) + + ((X - 16) >> 4) + " " + ((lane - 16) >> 4) + " " + ((X + 60) >> 4) + " " + + ((lane + 16) >> 4)).contains("\"ok\":true")); + // Cleared far past the wall along the line of fire: a round that punches through must fly out + // into empty air rather than into the next thing this class built. + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (X - 8) + " " + + (Y - 2) + " " + (lane - 3) + " " + (X + 60) + " " + (Y + 6) + " " + (lane + 3) + " minecraft:air").contains("\"ok\":true")); } // ---- reading - private int boreDepth(int wallX) throws Exception { + private int boreDepth(int lane) throws Exception { int depth = 0; for (int i = 0; i < WALL_DEPTH; i++) { - if (stageOf(stageAt(wallX + i)) > 0 || destroyed(wallX + i)) { + if (stageOf(stageAt(X + i, lane)) > 0 || destroyed(X + i, lane)) { depth = i + 1; } } return depth; } - private int budgetForBlocks(int wallX, double blocks) throws Exception { - String state = stageAt(wallX); + private int budgetForBlocks(int lane, double blocks) throws Exception { + String state = stageAt(X, lane); Matcher cost = STAGE_COST.matcher(state); Matcher stages = MAX_STAGE.matcher(state); if (!cost.find() || !stages.find()) { @@ -199,12 +253,12 @@ private int budgetForBlocks(int wallX, double blocks) throws Exception { * Math.max(1, Integer.parseInt(stages.group(1))) * blocks); } - private String stageAt(int x) throws Exception { - return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + private String stageAt(int x, int lane) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + lane); } - private boolean destroyed(int x) throws Exception { - String state = stageAt(x); + private boolean destroyed(int x, int lane) throws Exception { + String state = stageAt(x, lane); return state.contains("\"wasDestroyed\":true") || state.contains("\"block\":\"minecraft:air\""); } @@ -239,14 +293,14 @@ private boolean awaitClimbing(long id) throws Exception { } /** Anywhere along the plate, the first block that took something — or null if none did. */ - private String firstTouched(int plateX) throws Exception { - for (int x = plateX; x <= plateX + 8; x++) { + private String firstTouched(int lane) throws Exception { + for (int x = X; x <= X + 8; x++) { for (int dz = -1; dz <= 1; dz++) { - String state = exec("artest damage stage " + DIM + " " + x + " " + Y + " " + (Z + dz)); + String state = exec("artest damage stage " + DIM + " " + x + " " + Y + " " + (lane + dz)); boolean gone = state.contains("\"wasDestroyed\":true") || state.contains("\"block\":\"minecraft:air\""); if (gone || stageOf(state) > 0) { - return x + "," + (Z + dz) + " -> " + state; + return x + "," + (lane + dz) + " -> " + state; } } } From 9e2c8fab09daef23e11425f88076e02cddda8d92 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 14:34:12 +0300 Subject: [PATCH 23/35] feat: armour that answers, on any face, priced by the volume it fills - mirror and reactive plating cling to the face they were applied to - a mirror reflects a fraction and dies by what its film absorbs - a reactive charge eats a portion and spends only itself - a block is priced by how much of its voxel it fills, not by owning one - occupancy comes from the collision list: stairs are boxes, not a cube - a round goes further through panes than through solid glass --- .../advancedRocketry/AdvancedRocketry.java | 32 +++ .../api/AdvancedRocketryBlocks.java | 12 + .../api/damage/IContactResponder.java | 12 +- .../block/BlockMirrorPlating.java | 109 +++++++++ .../advancedRocketry/block/BlockPlating.java | 107 +++++++++ .../block/BlockReactivePlating.java | 81 +++++++ .../damage/StructureDamageEngine.java | 68 +++++- .../projectile/ContactResolver.java | 20 +- .../blockstates/mirrorPlatingAluminium.json | 18 ++ .../blockstates/mirrorPlatingGold.json | 18 ++ .../blockstates/mirrorPlatingSilver.json | 18 ++ .../blockstates/reactiveBlock.json | 18 ++ .../blockstates/reactivePlate.json | 18 ++ .../assets/advancedrocketry/lang/en_US.lang | 5 + .../block/plating_mirror_aluminium.json | 21 ++ .../models/block/plating_mirror_gold.json | 21 ++ .../models/block/plating_mirror_silver.json | 21 ++ .../models/block/plating_reactive.json | 21 ++ .../ArmourAnswersByKindAndAngleE2ETest.java | 73 +++++- ...rmourBlocksAnswerForThemselvesE2ETest.java | 226 ++++++++++++++++++ .../FirstShotIntoAFreshWallE2ETest.java | 146 +++++++++++ 21 files changed, 1052 insertions(+), 13 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java create mode 100644 src/main/java/zmaster587/advancedRocketry/block/BlockPlating.java create mode 100644 src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json create mode 100644 src/main/resources/assets/advancedrocketry/models/block/plating_mirror_aluminium.json create mode 100644 src/main/resources/assets/advancedrocketry/models/block/plating_mirror_gold.json create mode 100644 src/main/resources/assets/advancedrocketry/models/block/plating_mirror_silver.json create mode 100644 src/main/resources/assets/advancedrocketry/models/block/plating_reactive.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/FirstShotIntoAFreshWallE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 94c2f99a6..2ea49e679 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); @@ -663,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); @@ -878,6 +905,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); diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java index e66deef42..becee99e9 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; diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java index 439bc1acd..7dc78d221 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java @@ -1,5 +1,7 @@ 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. * @@ -20,6 +22,14 @@ public interface IContactResponder { /** * Answer for one body meeting this block. Never null: return * {@link ContactResult#passedThrough(int)} to decline having an opinion. + * + *

    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(Contact contact); + ContactResult onContact(World world, Contact contact); } 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..f53b486c7 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java @@ -0,0 +1,109 @@ +package zmaster587.advancedRocketry.block; + +import net.minecraft.block.material.Material; +import net.minecraft.init.Blocks; +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. A slug does not care that it is shiny. + return ContactResult.passedThrough(contact.getEnergy()); + } + + 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) { + world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + } + } + +} 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..87f830c5b --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java @@ -0,0 +1,81 @@ +package zmaster587.advancedRocketry.block; + +import net.minecraft.block.material.Material; +import net.minecraft.init.Blocks; +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) { + world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + } + } + +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 2f6908aaa..fa8b137ab 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -2,6 +2,7 @@ import net.minecraft.block.state.IBlockState; import net.minecraft.init.Blocks; +import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; @@ -11,6 +12,9 @@ import zmaster587.advancedRocketry.api.damage.StopReason; import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.util.SweptVolume; + +import java.util.ArrayList; +import java.util.List; import zmaster587.advancedRocketry.util.WeightEngine; /** @@ -54,6 +58,16 @@ public final class StructureDamageEngine { */ private static final int GAP_TOLERANCE = 6; + /** + * The least of its voxel a block is ever treated as filling. A pane is not a wall and should not + * cost like one, but nothing is free either: a body still has to break the thing off its mounting, + * and a floor here is what stops a torch or a tripwire from being a hole in a hull. + */ + private static final double MIN_OCCUPANCY = 0.1D; + + /** One whole voxel at the origin — the box a block is asked to report its collision shape within. */ + private static final AxisAlignedBB FULL_VOXEL = new AxisAlignedBB(0.0D, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D); + /** * Hard bound on how many blocks one walk may examine, derived from {@link #MAX_PATH_BLOCKS} and * never reached before it. A segment of length L crosses at most about 1.74 L voxels (the sum of @@ -400,7 +414,59 @@ public static int stageCost(World world, BlockPos pos, double areaFactor, Impact double resistance = WeightEngine.INSTANCE.getResistance(world, pos, kind); int maxStage = Math.max(1, DamageState.getMaxStage(world, pos)); double perStage = (STAGE_COST_BASE + resistance * STAGE_COST_TOUGHNESS_MULT) / maxStage; - return Math.max(1, (int) Math.ceil(perStage * Math.max(0.0D, areaFactor))); + return Math.max(1, (int) Math.ceil(perStage * Math.max(0.0D, areaFactor) + * occupancyOf(world, pos))); + } + + /** + * How much of its voxel this block actually fills, in {@code [MIN_OCCUPANCY, 1]}. + * + *

    Why a price has to know this at all

    + *

    The law is an energy per unit of VOLUME removed — that is the whole of why the mechanical and + * ablation columns are the same law with different constants. A voxel is one cubic metre only when + * something fills it, and until this was asked a glass pane cost a solid block of glass to shoot + * through, a carpet cost a block of wool, and a coating filling an eighth of its voxel was eight + * times dearer to bore than the physics says. Every hull the tests fire at is built of full cubes, + * which is precisely the shape that hides it.

    + * + *

    Why the collision LIST and not the bounding box

    + *

    A bounding box is one box, so a shape made of several can only be summarised by it — and + * vanilla proves the point twice over: {@code BlockStairs} does not override {@code getBoundingBox} + * at all and reports a full cube, while {@code BlockFence} reports the envelope of its post and + * arms, which is mostly air. Both over-state, one enormously. The collision list is where a block + * states its real shape, box by box, so that is what is summed. Overlapping boxes would double + * count, which is why the sum is clamped: over-counting can only ever produce "a full cube", the + * answer we started from.

    + */ + private static double occupancyOf(World world, BlockPos pos) { + if (world == null || pos == null) { + return 1.0D; + } + IBlockState state = world.getBlockState(pos); + try { + List boxes = new ArrayList(); + state.addCollisionBoxToList(world, pos, FULL_VOXEL.offset(pos), boxes, null, true); + double volume = 0.0D; + for (AxisAlignedBB box : boxes) { + volume += (box.maxX - box.minX) * (box.maxY - box.minY) * (box.maxZ - box.minZ); + } + if (volume > 0.0D) { + return Math.max(MIN_OCCUPANCY, Math.min(1.0D, volume)); + } + // No collision at all — a torch, a plant, a tripwire. It is still SOMETHING, so it falls + // back to the shape it draws itself with rather than to nothing. + AxisAlignedBB drawn = state.getBoundingBox(world, pos); + if (drawn == null) { + return MIN_OCCUPANCY; + } + double drawnVolume = (drawn.maxX - drawn.minX) * (drawn.maxY - drawn.minY) + * (drawn.maxZ - drawn.minZ); + return Math.max(MIN_OCCUPANCY, Math.min(1.0D, drawnVolume)); + } catch (RuntimeException blockDidNotLikeBeingAsked) { + // A block may compute its shape from neighbours it expects to be loaded. It costs a full + // cube rather than throwing, which is the answer that changes nothing. + return 1.0D; + } } /** diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index 7137f99c1..320d59ed5 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -76,7 +76,7 @@ public static Resolution resolve(World world, TravellingBody body, StructureCros IContactResponder responder = responderAt(world, hit.block); if (responder != null) { - ContactResult answer = responder.onContact(contact); + ContactResult answer = responder.onContact(world, contact); if (answer != null) { // A block that answered for itself did not walk anything, so the body is advanced past // the block it was answered by — otherwise the next test finds the same block, asks @@ -134,7 +134,23 @@ private static boolean carriesMass(ImpactKind kind) { * frame in which the normal and the velocity are the same kind of thing. Restitution takes its * cut here, so a bounce costs a round something and two facing plates cannot keep one forever. */ + /** + * The body's velocity mirrored in the face it met, in WORLD terms — what a block answering with a + * deflection has to hand back. Exposed because a block that computed this itself would be doing + * the frame conversion a second time, and the second time is where a subspace normal meets a + * world velocity and nobody notices. Answers null when the ship cannot be asked, which the caller + * should read as "let it through" rather than as a zero velocity. + */ + public static Vec3d mirroredWorldVelocity(World world, Contact contact, double restitution) { + Vec3d local = mirrored(contact, restitution); + return local == null ? null : toWorldFrame(world, contact.getShipId(), local); + } + private static Vec3d mirrored(Contact contact) { + return mirrored(contact, ARConfiguration.getCurrentConfig().ricochetRestitution); + } + + private static Vec3d mirrored(Contact contact, double restitution) { Vec3d normal = contact.getNormal(); Vec3d velocity = contact.getVelocity(); if (normal == null || velocity == null) { @@ -142,7 +158,7 @@ private static Vec3d mirrored(Contact contact) { } double along = velocity.x * normal.x + velocity.y * normal.y + velocity.z * normal.z; Vec3d reflected = velocity.subtract(normal.scale(2.0D * along)); - return reflected.scale(Math.max(0.0D, ARConfiguration.getCurrentConfig().ricochetRestitution)); + return reflected.scale(Math.max(0.0D, restitution)); } /** Back out of the block's frame, because what flies away flies away through the world. */ diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json new file mode 100644 index 000000000..18657519a --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "advancedrocketry:plating_mirror_aluminium" + }, + "variants": { + "facing": { + "down": {}, + "up": {"x": 180}, + "north": {"x": 90}, + "south": {"x": 90, "y": 180}, + "west": {"x": 90, "y": 270}, + "east": {"x": 90, "y": 90} + }, + "inventory": [{}] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json new file mode 100644 index 000000000..6b6ac3a83 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "advancedrocketry:plating_mirror_gold" + }, + "variants": { + "facing": { + "down": {}, + "up": {"x": 180}, + "north": {"x": 90}, + "south": {"x": 90, "y": 180}, + "west": {"x": 90, "y": 270}, + "east": {"x": 90, "y": 90} + }, + "inventory": [{}] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json new file mode 100644 index 000000000..8a349ca22 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "advancedrocketry:plating_mirror_silver" + }, + "variants": { + "facing": { + "down": {}, + "up": {"x": 180}, + "north": {"x": 90}, + "south": {"x": 90, "y": 180}, + "west": {"x": 90, "y": 270}, + "east": {"x": 90, "y": 90} + }, + "inventory": [{}] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json b/src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json new file mode 100644 index 000000000..154c11e6e --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "advancedrocketry:plating_reactive" + }, + "variants": { + "facing": { + "down": {}, + "up": {"x": 180}, + "north": {"x": 90}, + "south": {"x": 90, "y": 180}, + "west": {"x": 90, "y": 270}, + "east": {"x": 90, "y": 90} + }, + "inventory": [{}] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json b/src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json new file mode 100644 index 000000000..154c11e6e --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "advancedrocketry:plating_reactive" + }, + "variants": { + "facing": { + "down": {}, + "up": {"x": 180}, + "north": {"x": 90}, + "south": {"x": 90, "y": 180}, + "west": {"x": 90, "y": 270}, + "east": {"x": 90, "y": 90} + }, + "inventory": [{}] + } +} diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 836dd9ab9..b2a246200 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -1720,3 +1720,8 @@ msg.navcomputer.eta=Flight time: msg.navcomputer.flightcost=Energy for the flight: msg.navcomputer.hullexposed=Hull outside the window (blocks): msg.navcomputer.ready=Ready to jump +tile.mirrorPlatingAluminium.name=Aluminium Mirror Plating +tile.mirrorPlatingSilver.name=Silver Mirror Plating +tile.mirrorPlatingGold.name=Gold Mirror Plating +tile.reactivePlate.name=Reactive Plate +tile.reactiveBlock.name=Reactive Armour Block diff --git a/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_aluminium.json b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_aluminium.json new file mode 100644 index 000000000..5a2a5d520 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_aluminium.json @@ -0,0 +1,21 @@ +{ + "parent": "block/block", + "textures": { + "all": "advancedrocketry:blocks/structuretower", + "particle": "advancedrocketry:blocks/structuretower" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 2, 16], + "faces": { + "down": {"texture": "#all", "cullface": "down"}, + "up": {"texture": "#all"}, + "north": {"texture": "#all"}, + "south": {"texture": "#all"}, + "west": {"texture": "#all"}, + "east": {"texture": "#all"} + } + } + ] +} diff --git a/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_gold.json b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_gold.json new file mode 100644 index 000000000..5a2a5d520 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_gold.json @@ -0,0 +1,21 @@ +{ + "parent": "block/block", + "textures": { + "all": "advancedrocketry:blocks/structuretower", + "particle": "advancedrocketry:blocks/structuretower" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 2, 16], + "faces": { + "down": {"texture": "#all", "cullface": "down"}, + "up": {"texture": "#all"}, + "north": {"texture": "#all"}, + "south": {"texture": "#all"}, + "west": {"texture": "#all"}, + "east": {"texture": "#all"} + } + } + ] +} diff --git a/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_silver.json b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_silver.json new file mode 100644 index 000000000..5a2a5d520 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/models/block/plating_mirror_silver.json @@ -0,0 +1,21 @@ +{ + "parent": "block/block", + "textures": { + "all": "advancedrocketry:blocks/structuretower", + "particle": "advancedrocketry:blocks/structuretower" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 2, 16], + "faces": { + "down": {"texture": "#all", "cullface": "down"}, + "up": {"texture": "#all"}, + "north": {"texture": "#all"}, + "south": {"texture": "#all"}, + "west": {"texture": "#all"}, + "east": {"texture": "#all"} + } + } + ] +} diff --git a/src/main/resources/assets/advancedrocketry/models/block/plating_reactive.json b/src/main/resources/assets/advancedrocketry/models/block/plating_reactive.json new file mode 100644 index 000000000..dfadfca81 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/models/block/plating_reactive.json @@ -0,0 +1,21 @@ +{ + "parent": "block/block", + "textures": { + "all": "advancedrocketry:blocks/blastbrick", + "particle": "advancedrocketry:blocks/blastbrick" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 2, 16], + "faces": { + "down": {"texture": "#all", "cullface": "down"}, + "up": {"texture": "#all"}, + "north": {"texture": "#all"}, + "south": {"texture": "#all"}, + "west": {"texture": "#all"}, + "east": {"texture": "#all"} + } + } + ] +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java index 9b9362f01..f6d234083 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java @@ -43,6 +43,7 @@ public class ArmourAnswersByKindAndAngleE2ETest extends AbstractSharedServerTest */ private static final int SLUG_Z = 1010, BEAM_Z = 1030, FAINT_Z = 1050, STRONG_Z = 1070; private static final int STEEL_Z = 1090, WOOD_Z = 1110; + private static final int SOLID_Z = 1130, THIN_Z = 1150; private static final int WALL_DEPTH = 10; private static final double BORE_SPEED = 0.45D; @@ -68,10 +69,15 @@ public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { // this test would go green for the threshold's reasons and say nothing about the columns. int budget = budgetForBlocks(SLUG_Z, 20.0D); assertTrue("the wall has no price, so no budget here means anything", budget > 0); - // Read the two columns off an INTACT block, before anything is fired at it. Taken afterwards - // the block is air, whose toughness is zero — and zero times any factor is zero, so both - // columns agree there and the reading says nothing about either. - String priced = stageAt(X, SLUG_Z); + + // A round is fired and discarded before the ones this test is about, and it is not + // superstition: the FIRST shot fired into a freshly built arrangement passes through it with + // its budget untouched, while every shot after it behaves. That is a live defect in the + // substrate, not in the columns — the same point struck by a hand-fired impact is bored four + // blocks deep, so the damage side is sound — and this test's subject is which COLUMN a kind is + // priced from. A test that went red for somebody else's bug would say nothing about its own + // claim, which is the most expensive kind of red to read. + awaitGone(fire(SLUG_Z, 10, "KINETIC")); long slug = fire(SLUG_Z, budget, "KINETIC"); long beam = fire(BEAM_Z, budget, "BEAM"); @@ -83,12 +89,11 @@ public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { int slugDepth = boreDepth(SLUG_Z); int beamDepth = boreDepth(BEAM_Z); assertTrue("the slug did not get into the wall at all, so the comparison is between two" - + " zeroes", slugDepth > 0); + + " zeroes. budget=" + budget + " wall=" + stageAt(X, SLUG_Z) + + " slug=" + exec("artest shot read " + DIM + " " + slug), slugDepth > 0); assertTrue("a beam dug as deep as a slug carrying the same energy (beam=" + beamDepth + " slug=" + slugDepth + ", budget=" + budget + "): then the two channels are one" - + " column and a laser is simply a better gun." - + " intactPrice=" + priced, - beamDepth < slugDepth); + + " column and a laser is simply a better gun", beamDepth < slugDepth); } /** @@ -152,6 +157,41 @@ public void aBeamBelowTheIntensityThresholdRemovesNothingWhileAStrongerOneDigs() stageOf(stageAt(X, STRONG_Z)) > 0 || destroyed(X, STRONG_Z)); } + /** + * A block is priced by how much of its voxel it actually FILLS. + * + *

    The law is an energy per unit of volume removed, and a voxel is a cubic metre only when + * something fills it. Panes and solid glass are the same material — the same row of the same + * table — so the only thing separating these two walls is that one of them is mostly air. A round + * that got no further through panes than through solid glass would be a round paying for material + * that is not there, which is what every hull built of full cubes hides.

    + */ + @Test + public void aRoundGoesFurtherThroughWhatIsMostlyAir() throws Exception { + prepare(SOLID_Z); + prepare(THIN_Z); + buildWallOf(SOLID_Z, "minecraft:glass"); + buildWallOf(THIN_Z, "minecraft:glass_pane"); + + int budget = budgetForBlocks(SOLID_Z, 3.0D); + assertTrue("the glass has no price, so no budget here means anything", budget > 0); + + long throughSolid = fire(SOLID_Z, budget, "KINETIC"); + long throughPanes = fire(THIN_Z, budget, "KINETIC"); + assertTrue("both rounds must be admitted", throughSolid >= 0 && throughPanes >= 0); + awaitGone(throughSolid); + awaitGone(throughPanes); + + int solidDepth = boreDepth(SOLID_Z); + int paneDepth = boreDepth(THIN_Z); + assertTrue("the round did not get into the solid wall at all, so this compares two zeroes", + solidDepth > 0); + assertTrue("a wall of panes cost the same to bore as a wall of solid glass (panes=" + paneDepth + + " solid=" + solidDepth + "): then a block is priced as a full cubic metre of material" + + " however little of its voxel it fills, and the law stops being about volume", + paneDepth > solidDepth); + } + /** * A graze skips off steel and digs into wood. Two plates, one angle, one round: the material is * the only difference, which is what makes this about the narrowing rather than about the angle. @@ -207,8 +247,12 @@ private long grazeAt(int lane, int energy) throws Exception { } private void buildWall(int lane) throws Exception { + buildWallOf(lane, "minecraft:stone"); + } + + private void buildWallOf(int lane, String block) throws Exception { assertTrue("could not build the wall", exec("artest fill " + DIM + " " + X + " " + Y + " " - + lane + " " + (X + WALL_DEPTH - 1) + " " + Y + " " + lane + " minecraft:stone") + + lane + " " + (X + WALL_DEPTH - 1) + " " + Y + " " + lane + " " + block) .contains("\"ok\":true")); } @@ -220,6 +264,17 @@ private void buildPlate(int lane, String block) throws Exception { } private void prepare(int lane) throws Exception { + // Scenario isolation, and this class went without it for four scenarios: a round that punches + // through its own wall keeps flying for the rest of its lifetime, and the next scenario builds + // its arrangement while somebody else's round is still in the air. + exec("artest shot clear " + DIM); + // HELD, not merely warmed. There is no player on this server, so a warmed chunk unloads again + // on its own — and a swept segment SKIPS a voxel whose chunk is not loaded rather than calling + // it solid or empty, because nobody looked. A round then flies through a wall with its budget + // untouched, which is what this class spent an afternoon reading as a damage bug. + for (int cx = (X - 16) >> 4; cx <= (X + 32) >> 4; cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (lane >> 4)); + } assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + ((X - 16) >> 4) + " " + ((lane - 16) >> 4) + " " + ((X + 60) >> 4) + " " + ((lane + 16) >> 4)).contains("\"ok\":true")); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java new file mode 100644 index 000000000..c8bef2065 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java @@ -0,0 +1,226 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Armour that ANSWERS — the half of the contact seam that had a contract, a set of result states and + * no block in the game which implemented it. + * + *

    Every claim here is about a block deciding its own fate, which is the thing toughness alone can + * never express: a plate cannot send a body somewhere else, cannot spend a charge of its own, and + * cannot tell a beam from a slug. Each scenario gets its own LANE across the line of fire, because a + * round rich enough to be interesting outlives its own target and would otherwise arrive in the next + * one's arrangement.

    + */ +public class ArmourBlocksAnswerForThemselvesE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 70, X = 2100; + private static final int MIRROR_Z = 1200, MIRROR_SLUG_Z = 1220, BETTER_Z = 1240; + private static final int REACTIVE_Z = 1260, REACTIVE_TWICE_Z = 1280, RAILGUN_Z = 1300; + + private static final double SPEED = 0.45D; + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern VX = Pattern.compile("\"vx\":(-?[\\d.eE+-]+)"); + + /** + * A mirror returns a beam and lets a slug through, and the difference is the kind in the contact + * and nothing else — the same block, the same face, the same energy. + */ + @Test + public void aMirrorReturnsABeamAndLetsASlugStraightThrough() throws Exception { + prepare(MIRROR_Z); + prepare(MIRROR_SLUG_Z); + place("advancedrocketry:mirrorPlatingAluminium", MIRROR_Z); + place("advancedrocketry:mirrorPlatingAluminium", MIRROR_SLUG_Z); + + // Well inside what an aluminium film can shed, so the plate survives to reflect. + long beam = fire(MIRROR_Z, 3_000, "BEAM"); + assertTrue("the beam was refused", beam >= 0); + assertTrue("a beam fired at a mirror was never sent back — the plate answered as if it were" + + " ordinary hull: " + read(beam), awaitTurnedBack(beam)); + + long slug = fire(MIRROR_SLUG_Z, 3_000, "KINETIC"); + assertTrue("the slug was refused", slug >= 0); + assertTrue("a solid round bounced off glass and foil: a mirror has no opinion about a slug," + + " and the kind in the contact is the only thing that separates the two cases: " + + read(slug), !awaitTurnedBack(slug)); + assertTrue("the mirror is still standing after a slug went through it", stillThere(MIRROR_SLUG_Z)); + } + + /** + * A mirror dies by what it ABSORBS. Two tiers, the same beam: the worse one lets more of it into + * its film and is gone; the better one lets less in and survives. No count, no stages — an optic + * either is one or is not. + */ + @Test + public void aBetterMirrorSurvivesWhatKillsAWorseOne() throws Exception { + prepare(BETTER_Z); + place("advancedrocketry:mirrorPlatingAluminium", BETTER_Z); + + // Chosen against the film rather than against a number in a test: enough that a tenth of it + // exceeds what the film sheds, and a thirtieth of it does not. + int killsAluminium = 60_000; + + // CONTROL, and the test is worthless without it: the same energy as a SLUG must leave the + // plate standing. Ordinary damage does not know one mirror from another, so if it were doing + // the work below, this is where it would show — and the first cut of this test passed with the + // whole responder switched off, which is exactly what this catches. + long slug = fire(BETTER_Z, killsAluminium, "KINETIC"); + assertTrue("the control round was refused", slug >= 0); + awaitGone(slug); + assertTrue("a slug carrying what the beams below carry destroyed the plating: then what kills" + + " a mirror here is ordinary damage, and nothing in this test is about mirrors", + stillThere(BETTER_Z)); + long first = fire(BETTER_Z, killsAluminium, "BEAM"); + assertTrue("the beam was refused", first >= 0); + awaitGone(first); + assertTrue("an aluminium mirror survived a beam that put more into its film than the film can" + + " shed: then nothing burns out and a mirror is unconditional armour", + !stillThere(BETTER_Z)); + + place("advancedrocketry:mirrorPlatingGold", BETTER_Z); + long second = fire(BETTER_Z, killsAluminium, "BEAM"); + assertTrue("the second beam was refused", second >= 0); + awaitGone(second); + assertTrue("a gold mirror died to the same beam that killed an aluminium one: then the tiers" + + " are not the reflectances and the ladder means nothing", stillThere(BETTER_Z)); + } + + /** + * A reactive plate stops one shot and is gone; the second through the same spot is not stopped. + * That is a property of the thing rather than a counter somebody keeps. + */ + @Test + public void aReactivePlateStopsOneShotAndIsThenNotThere() throws Exception { + prepare(REACTIVE_Z); + place("advancedrocketry:reactivePlate", REACTIVE_Z); + // Behind it, an ordinary block: what a spent charge stops protecting. + placeAt(X + 2, REACTIVE_Z, "minecraft:stone"); + + long first = fire(REACTIVE_Z, 4_000, "KINETIC"); + assertTrue("the first round was refused", first >= 0); + awaitGone(first); + assertTrue("the charge is still standing after eating a round: a reactive plate spends ITSELF" + + " or it is just a tough block", !stillThere(REACTIVE_Z)); + assertTrue("the block BEHIND the charge was hit through it: the charge did not stop the round" + + " it spent itself on", clean(X + 2, REACTIVE_Z)); + + long second = fire(REACTIVE_Z, 4_000, "KINETIC"); + assertTrue("the second round was refused", second >= 0); + awaitGone(second); + assertTrue("the second round through the same spot was stopped as well — then the charge was" + + " never spent and reactive armour is free", !clean(X + 2, REACTIVE_Z)); + } + + /** Twice the plating eats more of the same impact — the ordering the volume rule exists for. */ + @Test + public void twiceTheReactiveVolumeEatsMoreOfTheSameImpact() throws Exception { + prepare(REACTIVE_TWICE_Z); + prepare(RAILGUN_Z); + place("advancedrocketry:reactivePlate", REACTIVE_TWICE_Z); + placeAt(X + 2, REACTIVE_TWICE_Z, "minecraft:stone"); + place("advancedrocketry:reactiveBlock", RAILGUN_Z); + placeAt(X + 2, RAILGUN_Z, "minecraft:stone"); + + // More than one plate can swallow, less than a full block can. + int between = 15_000; + long throughPlate = fire(REACTIVE_TWICE_Z, between, "KINETIC"); + long intoBlock = fire(RAILGUN_Z, between, "KINETIC"); + assertTrue("both rounds must be admitted", throughPlate >= 0 && intoBlock >= 0); + awaitGone(throughPlate); + awaitGone(intoBlock); + + assertTrue("a round bigger than one plate can swallow was stopped by it anyway: then capacity" + + " does not bound what a charge eats", !clean(X + 2, REACTIVE_TWICE_Z)); + assertTrue("the full block let through what it should have swallowed whole: then twice the" + + " plating is not twice the protection and layering buys nothing", + clean(X + 2, RAILGUN_Z)); + } + + // ---- driving + + private long fire(int lane, int energy, String kind) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + (X - 3.0D) + " " + (Y + 0.5D) + " " + + (lane + 0.5D) + " " + SPEED + " 0 0 " + energy + " 1200 " + kind + " 0.25 1.0")); + } + + private void place(String block, int lane) throws Exception { + placeAt(X, lane, block); + } + + private void placeAt(int x, int lane, String block) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + Y + " " + lane + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + lane + ": " + resp, + resp.contains("\"placed\":true")); + } + + private void prepare(int lane) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + ((X - 16) >> 4) + + " " + ((lane - 16) >> 4) + " " + ((X + 40) >> 4) + " " + ((lane + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the lane", exec("artest fill " + DIM + " " + (X - 8) + " " + + (Y - 2) + " " + (lane - 3) + " " + (X + 40) + " " + (Y + 4) + " " + (lane + 3) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + /** Did the round ever turn around? It is fired along +X, so a negative vx is the answer itself. */ + private boolean awaitTurnedBack(long id) throws Exception { + long deadline = System.currentTimeMillis() + 20_000L; + while (System.currentTimeMillis() < deadline) { + String state = read(id); + if (!state.contains("\"present\":true")) { + return false; + } + Matcher m = VX.matcher(state); + if (m.find() && Double.parseDouble(m.group(1)) < 0.0D) { + return true; + } + Thread.sleep(60L); + } + return false; + } + + private void awaitGone(long id) throws Exception { + long deadline = System.currentTimeMillis() + 20_000L; + while (System.currentTimeMillis() < deadline && read(id).contains("\"present\":true")) { + Thread.sleep(100L); + } + } + + /** Is the armour block still where it was placed? */ + private boolean stillThere(int lane) throws Exception { + return !exec("artest damage stage " + DIM + " " + X + " " + Y + " " + lane) + .contains("\"block\":\"minecraft:air\""); + } + + /** Is the block behind the armour untouched — never staged and never destroyed? */ + private boolean clean(int x, int lane) throws Exception { + String state = exec("artest damage stage " + DIM + " " + x + " " + Y + " " + lane); + if (state.contains("\"block\":\"minecraft:air\"") || state.contains("\"wasDestroyed\":true")) { + return false; + } + Matcher m = Pattern.compile("\"stage\":(-?\\d+)").matcher(state); + return m.find() && Integer.parseInt(m.group(1)) == 0; + } + + private String read(long id) throws Exception { + return exec("artest shot read " + DIM + " " + id); + } + + private static long idOf(String json) { + Matcher m = ID.matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/FirstShotIntoAFreshWallE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/FirstShotIntoAFreshWallE2ETest.java new file mode 100644 index 000000000..ffdd04e7a --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/FirstShotIntoAFreshWallE2ETest.java @@ -0,0 +1,146 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * One round, one fresh wall, and it marks it. + * + *

    Written as a reproduction, kept as a pin

    + *

    This class was written to fail. Another scenario had a round fly through a stone wall with its + * budget untouched, and the trigger looked like "the FIRST round fired into a freshly prepared + * arrangement". Three versions of this test were built to reproduce that, each closer to the sequence + * it was seen in — a single lane; two lanes prepared and built; and a preceding scenario firing down a + * third lane and left in the air. All three passed. So the characterisation was wrong, and it is + * recorded here rather than quietly dropped.

    + * + *

    What it leaves behind is worth keeping anyway: the plain property that a round fired once into a + * wall it flies down the middle of comes out having marked it. Nothing else pins that on its own — the + * shot suites all fire more than once, and the armour column test deliberately discards a round first. + * If this ever goes red, the thing it was written to catch has finally come out into the open.

    + * + *

    What IS established about the defect, and where

    + *

    The damage side is sound (a hand-fired impact at the same point bores four blocks deep), it does + * not follow the impact kind (swapping lanes swaps which round sails through), it is not the lane and + * not chunk loading. It reproduces only inside `ArmourAnswersByKindAndAngleE2ETest`'s own sequence, + * which is why that class discards a round before the pair it measures — the reason is written there, + * and the defect is carried in the bug ledger with everything ruled out so far.

    + */ +public class FirstShotIntoAFreshWallE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + /** A lane of this class's own, so nothing else has fired down it. */ + private static final int X = 2400, Y = 70, Z = 1400; + /** + * A SECOND arrangement, prepared and built after the first and never fired into. It is part of the + * reproduction rather than scenery: with only one lane prepared the round bores normally, and the + * minimal version of this test went green. Whatever the first shot fails to see, it takes a second + * arrangement built after the first to bring it out. + */ + private static final int Z2 = 1420; + /** The preceding scenario's lane — fired down and abandoned, never measured. */ + private static final int Z3 = 1440; + private static final int WALL_DEPTH = 10; + + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern STAGE = Pattern.compile("\"stage\":(-?\\d+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); + private static final Pattern MAX_STAGE = Pattern.compile("\"maxStage\":(-?\\d+)"); + + @Test + public void theFirstRoundFiredIntoAFreshWallDamagesIt() throws Exception { + // The exact sequence the defect was found in: clear what is in the air, build the target, fire + // ONE round. Nothing here is unusual — it is what any scenario does first. + // A PRECEDING scenario, because two standalone versions of this test went green without one: + // rounds fired down another lane and left to fly, exactly as the scenario before the failing + // one does. Whatever the round below fails to see, it takes an earlier scenario to bring out. + exec("artest fill " + DIM + " " + (X - 8) + " " + (Y - 2) + " " + (Z3 - 3) + " " + + (X + 40) + " " + (Y + 4) + " " + (Z3 + 3) + " minecraft:air"); + exec("artest fill " + DIM + " " + X + " " + Y + " " + Z3 + " " + (X + WALL_DEPTH - 1) + + " " + Y + " " + Z3 + " minecraft:glass_pane"); + exec("artest shot fire " + DIM + " " + (X - 3.0D) + " " + (Y + 0.5D) + " " + (Z3 + 0.5D) + + " 0.45 0 0 3000 1200 KINETIC 0.25 1.0"); + Thread.sleep(1500L); + + exec("artest shot clear " + DIM); + for (int cx = (X - 16) >> 4; cx <= (X + 32) >> 4; cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + assertTrue("could not clear the lane", exec("artest fill " + DIM + " " + (X - 8) + " " + + (Y - 2) + " " + (Z - 3) + " " + (X + 40) + " " + (Y + 4) + " " + (Z + 3) + + " minecraft:air").contains("\"ok\":true")); + assertTrue("could not clear the second lane", exec("artest fill " + DIM + " " + (X - 8) + " " + + (Y - 2) + " " + (Z2 - 3) + " " + (X + 40) + " " + (Y + 4) + " " + (Z2 + 3) + + " minecraft:air").contains("\"ok\":true")); + assertTrue("could not build the wall", exec("artest fill " + DIM + " " + X + " " + Y + " " + Z + + " " + (X + WALL_DEPTH - 1) + " " + Y + " " + Z + " minecraft:stone") + .contains("\"ok\":true")); + assertTrue("could not build the second wall", exec("artest fill " + DIM + " " + X + " " + Y + + " " + Z2 + " " + (X + WALL_DEPTH - 1) + " " + Y + " " + Z2 + " minecraft:stone") + .contains("\"ok\":true")); + + // Priced off the wall itself: rich enough that failing to mark it cannot be a budget story. + String priced = stageAt(X); + Matcher cost = STAGE_COST.matcher(priced); + Matcher stages = MAX_STAGE.matcher(priced); + assertTrue("the wall has no price, so nothing below means anything", + cost.find() && stages.find()); + int budget = Integer.parseInt(cost.group(1)) + * Math.max(1, Integer.parseInt(stages.group(1))) * 20; + + long id = idOf(exec("artest shot fire " + DIM + " " + (X - 3.0D) + " " + (Y + 0.5D) + " " + + (Z + 0.5D) + " 0.45 0 0 " + budget + " 1200 KINETIC 0.25 1.0")); + assertTrue("the substrate refused the shot", id >= 0); + + // Wait until it is either gone or well past the far side of the wall. + long deadline = System.currentTimeMillis() + 25_000L; + String state = read(id); + while (System.currentTimeMillis() < deadline && state.contains("\"present\":true") + && xOf(state) < X + WALL_DEPTH + 4) { + Thread.sleep(100L); + state = read(id); + } + + int depth = 0; + for (int i = 0; i < WALL_DEPTH; i++) { + if (stageOf(stageAt(X + i)) > 0 || stageAt(X + i).contains("\"block\":\"minecraft:air\"")) { + depth = i + 1; + } + } + assertTrue("a round fired once into a wall it flew down the middle of came out the far side" + + " having marked nothing. Its budget is untouched, so it is not a question of price:" + + " the wall was never seen. budget=" + budget + " wall=" + stageAt(X) + + " round=" + state, depth > 0); + } + + private String stageAt(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private String read(long id) throws Exception { + return exec("artest shot read " + DIM + " " + id); + } + + private static int stageOf(String json) { + Matcher m = STAGE.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 0; + } + + private static double xOf(String json) { + Matcher m = Pattern.compile("\"x\":(-?[\\d.eE+-]+)").matcher(json); + return m.find() ? Double.parseDouble(m.group(1)) : Double.NEGATIVE_INFINITY; + } + + private static long idOf(String json) { + Matcher m = ID.matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} From a39b9c877ca5ed48dcd7355e7e03f430d8950bd1 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 19:23:02 +0300 Subject: [PATCH 24/35] fix: an impact identity the world hands out, not one a shot assembles - mint impact ids from ShotRegistry, one monotonic counter per world - drop the shot id/sequence packing that collided after 256 impacts - key the dedup memory by dimension: ids are minted per world - move probe-declared identities out of production's id space - add ShotCrossingTrace and the probes that tell refusal from miss - remove the workaround the armour e2e carried for this --- .../command/test/TestProbeCommand.java | 100 ++++++++- .../damage/ShipDamageService.java | 37 +++- .../projectile/ContactResolver.java | 10 + .../advancedRocketry/projectile/Shot.java | 23 +-- .../projectile/ShotCrossingTrace.java | 192 ++++++++++++++++++ .../projectile/ShotRegistry.java | 47 +++++ .../projectile/ShotSubstrate.java | 22 +- .../ArmourAnswersByKindAndAngleE2ETest.java | 9 - .../test/unit/ImpactIdentityTest.java | 92 +++++++++ 9 files changed, 487 insertions(+), 45 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ShotCrossingTrace.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/ImpactIdentityTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 03343102b..b2db18b20 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -274,7 +274,16 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args * 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 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 @@ -284,7 +293,7 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args */ private void handleShot(MinecraftServer server, ICommandSender sender, String[] args) { if (args.length == 0) { - send(sender, "{\"error\":\"usage: /artest shot fire|list|read|clear ...\"}"); + send(sender, "{\"error\":\"usage: /artest shot fire|list|read|clear|trace|traceclear|crossing ...\"}"); return; } String sub = args[0].toLowerCase(java.util.Locale.ROOT); @@ -360,6 +369,60 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[] 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) + "\"}"); } @@ -1270,6 +1333,25 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ send(sender, "{\"ok\":true,\"cleared\":" + before + "}"); 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); @@ -1402,7 +1484,15 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ // keep KINETIC; the reply echoes what was used so a typo is visible } } - long impactId = args.length >= 11 ? (long) parseDoubleOr(args[10], 0) : world.getTotalWorldTime(); + // 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, @@ -1412,6 +1502,10 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ 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 diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java index 70138c8de..5c973e5ef 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java @@ -53,17 +53,28 @@ public final class ShipDamageService { /** * Recently applied impact identities → the tick they were applied on. Written only from here. - * It outlives a scenario: on a server shared by several tests, an id used by one is still - * refused for the next, so a test that reuses ids must call {@link #clearRecentImpacts()} between - * them rather than assume a fresh service. + * + *

    Keyed by DIMENSION as well as identity. Identities are minted per world, so two worlds hand + * out the same numbers as a matter of course; a memory shared between them would refuse a round + * in one world for an impact declared in another, and the refusal is silent — the budget comes + * back whole and the round flies on through the hull it was aimed at.

    + * + *

    It outlives a scenario: on a server shared by several tests, an id used by one is + * still refused for the next, so a test that reuses ids must call {@link #clearRecentImpacts()} + * between them rather than assume a fresh service.

    */ - private static final Map RECENT_IMPACTS = new LinkedHashMap() { + private static final Map RECENT_IMPACTS = new LinkedHashMap() { @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { return size() > IMPACT_MEMORY_MAX; } }; + /** One world's identity space, kept apart from every other world's. */ + private static String memoryKey(World world, long impactId) { + return world.provider.getDimension() + ":" + impactId; + } + private ShipDamageService() { } @@ -137,6 +148,15 @@ public static void clearRecentImpacts() { RECENT_IMPACTS.clear(); } + /** + * The tick an identity was remembered on, or {@code null} when it is not remembered at all. + * Diagnostics: a refusal reports only that the id was seen, and "seen when, and how long ago" + * is what separates a genuine retry from one caller's ids colliding with another's. + */ + public static Long rememberedTickOf(World world, long impactId) { + return world == null ? null : RECENT_IMPACTS.get(memoryKey(world, impactId)); + } + /** How many identities are currently remembered (diagnostics and tests). */ public static int rememberedImpactCount() { return RECENT_IMPACTS.size(); @@ -219,18 +239,19 @@ private static Vec3d toWorld(World world, String shipId, Vec3d local) { } private static boolean isDuplicate(World world, long impactId) { - Long appliedAt = RECENT_IMPACTS.get(impactId); + String key = memoryKey(world, impactId); + Long appliedAt = RECENT_IMPACTS.get(key); if (appliedAt == null) { return false; } if (world.getTotalWorldTime() - appliedAt > IMPACT_MEMORY_TICKS) { - RECENT_IMPACTS.remove(impactId); + RECENT_IMPACTS.remove(key); return false; } return true; } private static void remember(World world, long impactId) { - RECENT_IMPACTS.put(impactId, world.getTotalWorldTime()); + RECENT_IMPACTS.put(memoryKey(world, impactId), world.getTotalWorldTime()); } } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index 320d59ed5..1be4a9ff1 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -204,6 +204,16 @@ reachBlocks, areaOf(contact.getRadius())) reachBlocks, areaOf(contact.getRadius())); DamageReport report = ShipDamageService.apply(world, request); + if (ShotCrossingTrace.enabled()) { + ShotCrossingTrace.impact(body.getImpactId(), contact.getPos(), contact.getEnergy(), + reachBlocks, resumingBore, report.getOutcome().name(), + report.getStopReason() == null ? null : report.getStopReason().name(), + report.getBudgetSpent(), report.getBudgetLeft(), report.getDistanceWalked(), + report.getBlocksStaged(), report.getBlocksDestroyed(), + ShipDamageService.rememberedTickOf(world, body.getImpactId()), + world.getTotalWorldTime()); + } + int residual = report.getBudgetLeft(); if (residual <= 0) { return new Resolution(ContactResult.stopped(), report.getDistanceWalked()); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java index 1d0fc38c4..b88b13090 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/Shot.java @@ -56,13 +56,6 @@ public final class Shot { private int age; private int impactEnergy; - /** - * How many impacts this shot has already declared. It is part of the impact identity so that a - * shot which strikes twice — a shell it was let through, then the hull behind it — is not refused - * the second time by the damage service's duplicate memory. - */ - private int impactSequence; - Shot(long id, ShotSpec spec) { this.id = id; this.radius = spec.getRadius(); @@ -78,12 +71,11 @@ public final class Shot { this.impactEnergy = spec.getImpactEnergy(); this.hullId = null; this.age = 0; - this.impactSequence = 0; } private Shot(long id, double radius, double mass, ImpactKind kind, UUID owner, String faction, String guidance, ShotEnvironment environment, int lifetimeTicks, Vec3d position, - Vec3d velocity, String hullId, int age, int impactEnergy, int impactSequence) { + Vec3d velocity, String hullId, int age, int impactEnergy) { this.id = id; this.radius = radius; this.mass = mass; @@ -98,7 +90,6 @@ private Shot(long id, double radius, double mass, ImpactKind kind, UUID owner, S this.hullId = hullId; this.age = age; this.impactEnergy = impactEnergy; - this.impactSequence = impactSequence; } public long getId() { @@ -198,15 +189,6 @@ void incrementAge() { this.age++; } - /** - * An identity for the next impact this shot declares, distinct from every other impact by any - * shot in this world. The dimension is not mixed in: the damage service is asked about one world - * at a time and two worlds cannot share a shot. - */ - long nextImpactId() { - return (id << 8) ^ (impactSequence++); - } - NBTTagCompound writeToNBT() { NBTTagCompound nbt = new NBTTagCompound(); nbt.setLong("id", id); @@ -237,7 +219,6 @@ NBTTagCompound writeToNBT() { } nbt.setInteger("age", age); nbt.setInteger("energy", impactEnergy); - nbt.setInteger("impactSeq", impactSequence); return nbt; } @@ -259,7 +240,7 @@ static Shot readFromNBT(NBTTagCompound nbt) { new Vec3d(nbt.getDouble("posX"), nbt.getDouble("posY"), nbt.getDouble("posZ")), new Vec3d(nbt.getDouble("velX"), nbt.getDouble("velY"), nbt.getDouble("velZ")), nbt.hasKey("hull") ? nbt.getString("hull") : null, - nbt.getInteger("age"), nbt.getInteger("energy"), nbt.getInteger("impactSeq")); + nbt.getInteger("age"), nbt.getInteger("energy")); } private static UUID parseUuid(String value) { diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotCrossingTrace.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotCrossingTrace.java new file mode 100644 index 000000000..e40740eb1 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotCrossingTrace.java @@ -0,0 +1,192 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.command.test.TestProbeCommandRegistration; + +import java.util.ArrayList; +import java.util.List; + +/** + * What a shot's step DECIDED, tick by tick — a recorder, not a mechanism. + * + *

    Why this exists

    + *

    A round that crosses a wall spending nothing and a round whose impact was refused look exactly + * the same from outside: the wall is unmarked and the budget is whole either way. The difference is + * a single comparison inside the step — which layer answered a distance, and which one won — and + * nothing above that comparison can see it. So the comparison says so itself, and the question stops + * being a matter of inference.

    + * + *

    Off unless the harness is running

    + *

    Every entry point is gated on {@link TestProbeCommandRegistration#isTestMode()}, the same flag + * the {@code /artest} surface is registered under. In an ordinary game this class records nothing and + * costs one boolean read per crossing.

    + * + *

    It LEAKS across scenarios, and that is stated rather than hidden

    + *

    The ring is a single-writer diagnostic with no owner: it is written by the world tick, it + * outlives any one test, and on a shared server it carries entries from whatever ran before. It + * decides nothing — no production branch reads it — but a reader who forgets that will attribute + * somebody else's round to their own. {@link #reset()} is how a scenario claims a clean instrument, + * and every read reports {@code dropped} so a truncated history cannot pass for a quiet one.

    + */ +public final class ShotCrossingTrace { + + /** + * How many crossing decisions are kept. A shot lives up to its lifetime in ticks and spends at + * least one decision per tick, so this holds a full 1200-tick flight of a single round with room + * for the scenario around it — which matters because the interesting decision is usually the + * FIRST one, and a ring too small to hold a whole flight throws exactly that one away. + */ + private static final int CAPACITY = 2048; + + private static final List ENTRIES = new ArrayList(); + + private static long queries; + private static long structureFound; + private static long fieldWon; + private static long nothingFound; + private static long dropped; + private static long impacts; + + private ShotCrossingTrace() { + } + + /** + * Whether anything is recorded at all — read once, at class initialisation. + * + *

    The flag is a launch property and cannot change while the game runs, and this is asked on + * the shot hot path: once per crossing, per shot, per tick. Asking {@code System.getProperty} + * that often would be a synchronised lookup inside the tick loop of every round in flight, paid + * by every ordinary game to answer a question whose answer was fixed at startup. Constant, not + * mutable state — there is nothing here to reset.

    + */ + private static final boolean ENABLED = TestProbeCommandRegistration.isTestMode(); + + /** Whether anything is being recorded at all — read by the caller so it can skip the formatting. */ + public static boolean enabled() { + return ENABLED; + } + + /** + * One crossing decision of one shot. + * + *

    {@code fieldDistance} and {@code structureDistance} are as the step itself saw them, in + * blocks along this segment, with {@code -1} meaning "that layer answered nothing" — the same + * value the layers use, kept rather than collapsed into a verdict, because a verdict is what is + * in dispute. The segment is carried in full for the same reason: a decision reported without the + * question it answered cannot be re-asked.

    + */ + public static synchronized void crossing(long shotId, int age, String hullAsked, Vec3d from, + Vec3d to, double radius, double fieldDistance, + double structureDistance, String struckBlock) { + queries++; + if (structureDistance >= 0.0D) { + structureFound++; + } + if (fieldDistance >= 0.0D && (structureDistance < 0.0D || fieldDistance <= structureDistance)) { + fieldWon++; + } + if (fieldDistance < 0.0D && structureDistance < 0.0D) { + nothingFound++; + } + append("{\"id\":" + shotId + ",\"age\":" + age + + ",\"hullAsked\":" + quoted(hullAsked) + + ",\"fromX\":" + round(from.x) + ",\"fromY\":" + round(from.y) + + ",\"fromZ\":" + round(from.z) + + ",\"toX\":" + round(to.x) + ",\"toY\":" + round(to.y) + ",\"toZ\":" + round(to.z) + + ",\"radius\":" + round(radius) + + ",\"field\":" + round(fieldDistance) + + ",\"structure\":" + round(structureDistance) + + ",\"block\":" + quoted(struckBlock) + "}"); + } + + /** + * What the damage service answered one declared impact, recorded beside the crossing that caused + * it. The crossing and the impact are two different questions with one symptom — an unmarked wall + * — so recording only the first leaves the second to be inferred, which is how this defect stayed + * open. + */ + public static synchronized void impact(long impactId, net.minecraft.util.math.BlockPos at, + int budget, double reachBlocks, boolean resuming, + String outcome, String stopReason, int spent, int left, + double walked, int staged, int destroyed, + Long rememberedAt, long now) { + impacts++; + append("{\"impactId\":" + impactId + + ",\"x\":" + at.getX() + ",\"y\":" + at.getY() + ",\"z\":" + at.getZ() + + ",\"budget\":" + budget + ",\"reach\":" + round(reachBlocks) + + ",\"resuming\":" + resuming + + ",\"outcome\":" + quoted(outcome) + ",\"stop\":" + quoted(stopReason) + + ",\"spent\":" + spent + ",\"left\":" + left + ",\"walked\":" + round(walked) + + ",\"staged\":" + staged + ",\"destroyed\":" + destroyed + + ",\"rememberedAt\":" + (rememberedAt == null ? "null" : rememberedAt.toString()) + + ",\"now\":" + now + "}"); + } + + private static void append(String entry) { + if (ENTRIES.size() >= CAPACITY) { + // The OLDEST goes, and it is counted. A ring that silently forgot its first decisions + // would answer "the wall was never seen" for a flight whose opening it no longer holds. + ENTRIES.remove(0); + dropped++; + } + ENTRIES.add(entry); + } + + /** Forget everything, counters included: a scenario asking for an instrument of its own. */ + public static synchronized void reset() { + ENTRIES.clear(); + queries = 0L; + structureFound = 0L; + fieldWon = 0L; + nothingFound = 0L; + dropped = 0L; + impacts = 0L; + } + + /** + * The recording, optionally narrowed to one shot and capped in length. + * + *

    The cap keeps the EARLIEST matching entries rather than the latest: a round that flew + * through something did so in its first few ticks and then travelled for a thousand more, so the + * tail of such a flight is the one stretch guaranteed to say nothing. {@code matched} is reported + * beside them so a truncated answer announces itself.

    + * + *

    Every field is emitted in every state, zeros and an empty array included — an instrument + * that reports "nothing here" by changing its own shape breaks the reader who came to ask exactly + * that.

    + */ + public static synchronized String summaryJson(long onlyShotId, int limit) { + String needle = onlyShotId < 0L ? null : "{\"id\":" + onlyShotId + ","; + int matched = 0; + StringBuilder listed = new StringBuilder(); + for (String entry : ENTRIES) { + if (needle != null && !entry.startsWith(needle)) { + continue; + } + matched++; + if (matched <= limit) { + if (listed.length() > 0) { + listed.append(','); + } + listed.append(entry); + } + } + return "{\"enabled\":" + enabled() + ",\"queries\":" + queries + + ",\"structureFound\":" + structureFound + + ",\"fieldWon\":" + fieldWon + + ",\"nothingFound\":" + nothingFound + + ",\"dropped\":" + dropped + + ",\"impacts\":" + impacts + + ",\"held\":" + ENTRIES.size() + + ",\"matched\":" + matched + + ",\"entries\":[" + listed + "]}"; + } + + private static String quoted(String value) { + return value == null ? "null" : "\"" + value + "\""; + } + + private static double round(double value) { + return Math.round(value * 1000.0D) / 1000.0D; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java index ea5849576..61d797c3d 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java @@ -53,6 +53,22 @@ protected boolean removeEldestEntry(Map.Entry eldest) { private long nextId = 1L; + /** + * The next identity an impact declared in this world will be remembered by. + * + *

    Minted HERE rather than by the shot that declares the impact, and that is the whole point: + * an identity built out of a shot's own id and a counter has to reserve a field for each, and a + * counter that outgrows its field walks into the neighbouring one. A round that bored for a few + * hundred ticks then minted the identities a LATER round was going to mint, and the later round's + * impacts were refused as duplicates of impacts it never made — it crossed a stone wall spending + * nothing, because a refusal hands the budget back whole.

    + * + *

    One counter per world cannot do that: it is handed out once and never again. It is + * deliberately NOT reset by {@link #clear()} — the dedup memory that reads these identities + * outlives any one scenario, so re-minting from one would be re-creating the collision by hand.

    + */ + private long nextImpactId = 1L; + public ShotRegistry() { super(DATA_NAME); } @@ -87,6 +103,35 @@ public long add(ShotSpec spec, int maxShots) { return id; } + /** + * An identity for one declared impact. Persisted with the registry, so a restart does not start + * handing out identities the dedup memory may still be holding. + * + *

    It counts up and never cycles, and cycling is what it is avoiding

    + *

    What uniqueness is actually needed FOR is the dedup memory's window: an identity is + * remembered for a bounded number of ticks and then forgotten, so what this must not do is repeat + * inside that window. Reusing a number the service has already let go of is harmless. The scheme + * this replaced failed at exactly that scale — it repeated within seconds of one round's flight — + * which is why the answer here is a counter that never comes back round rather than a wider one + * that comes back round later.

    + * + *

    Exhaustion is not a practical bound: at the substrate's own ceiling — every slot of + * {@code maxShotsPerWorld} occupied, every round boring its maximum crossings, every tick — the + * default configuration spends a {@code long} in the order of ten million years. A configuration + * that raises the shot cap to its own maximum would need a couple of years of continuously + * saturated fire, which is a server that has already fallen over for other reasons.

    + * + *

    If it ever did overflow it would land in the negatives, where hand-declared probe + * identities live (see the {@code /artest damage impact} verb). That is stated so it is a known + * neighbour rather than a surprise; it is not guarded against, because reaching it means the + * count above was wrong by a factor nothing here can produce.

    + */ + public long nextImpactId() { + long id = nextImpactId++; + markDirty(); + return id; + } + public Shot get(long id) { return shots.get(id); } @@ -195,6 +240,7 @@ public Shot nearest(net.minecraft.world.World world, Vec3d point) { public void readFromNBT(NBTTagCompound nbt) { shots.clear(); nextId = Math.max(1L, nbt.getLong("nextId")); + nextImpactId = Math.max(1L, nbt.getLong("nextImpactId")); NBTTagList list = nbt.getTagList("shots", 10); for (int i = 0; i < list.tagCount(); i++) { Shot shot = Shot.readFromNBT(list.getCompoundTagAt(i)); @@ -210,6 +256,7 @@ public void readFromNBT(NBTTagCompound nbt) { @Override public NBTTagCompound writeToNBT(NBTTagCompound nbt) { nbt.setLong("nextId", nextId); + nbt.setLong("nextImpactId", nextImpactId); NBTTagList list = new NBTTagList(); for (Shot shot : shots.values()) { list.appendTag(shot.writeToNBT()); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 0e61bf3de..585343462 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -210,6 +210,18 @@ static ShotEndReason step(World world, Shot shot) { crossing == 0 ? boringHull : null, bodyRadius(shot)); double structureDistance = structure == null ? -1.0D : structure.distance; + if (ShotCrossingTrace.enabled()) { + // The two distances as this step saw them, before anything is decided from them. A + // round that crossed a wall unmarked and one whose impact was refused are the same + // picture from outside; they differ here, and only here. + ShotCrossingTrace.crossing(shot.getId(), shot.getAge(), + crossing == 0 ? boringHull : null, position, segmentEnd, bodyRadius(shot), + fieldDistance, structureDistance, + structure == null ? null : structure.block.getX() + "," + structure.block.getY() + + "," + structure.block.getZ() + " " + + world.getBlockState(structure.block).getBlock().getRegistryName()); + } + boolean fieldFirst = fieldDistance >= 0.0D && (structureDistance < 0.0D || fieldDistance <= structureDistance); boolean structureFirst = structureDistance >= 0.0D && !fieldFirst; @@ -229,10 +241,12 @@ static ShotEndReason step(World world, Shot shot) { // is standing in that block, and it paid for it then. boolean resuming = structure.distance <= CROSSING_EPSILON * 2.0D; // The seam is handed the BODY's facts, not this shot: the same armour has to answer a - // bolt and a held beam, and neither of those is a record in this registry. Minting the - // impact identity here is what keeps a bore across several ticks from being refused as - // a duplicate of its own first contact. - TravellingBody body = new TravellingBody(shot.nextImpactId(), velocity, shot.getKind(), + // bolt and a held beam, and neither of those is a record in this registry. A fresh + // identity per contact is what keeps a bore across several ticks from being refused as + // a duplicate of its own first one — and it comes from the WORLD's counter rather than + // from this shot, so that no amount of boring can walk it into another round's. + TravellingBody body = new TravellingBody(ShotRegistry.get(world).nextImpactId(), + velocity, shot.getKind(), shot.getImpactEnergy(), shot.getRadius()); ContactResolver.Resolution contact = ContactResolver.resolve(world, body, structure, reachInside, resuming); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java index f6d234083..e6064241e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourAnswersByKindAndAngleE2ETest.java @@ -70,15 +70,6 @@ public void aBeamBuysFarLessDepthThanASlugOfTheSameEnergy() throws Exception { int budget = budgetForBlocks(SLUG_Z, 20.0D); assertTrue("the wall has no price, so no budget here means anything", budget > 0); - // A round is fired and discarded before the ones this test is about, and it is not - // superstition: the FIRST shot fired into a freshly built arrangement passes through it with - // its budget untouched, while every shot after it behaves. That is a live defect in the - // substrate, not in the columns — the same point struck by a hand-fired impact is bored four - // blocks deep, so the damage side is sound — and this test's subject is which COLUMN a kind is - // priced from. A test that went red for somebody else's bug would say nothing about its own - // claim, which is the most expensive kind of red to read. - awaitGone(fire(SLUG_Z, 10, "KINETIC")); - long slug = fire(SLUG_Z, budget, "KINETIC"); long beam = fire(BEAM_Z, budget, "BEAM"); assertTrue("both shots must be admitted or the comparison is about one of them", diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactIdentityTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactIdentityTest.java new file mode 100644 index 000000000..0f3448c76 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ImpactIdentityTest.java @@ -0,0 +1,92 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.nbt.NBTTagCompound; +import org.junit.Test; + +import zmaster587.advancedRocketry.projectile.ShotRegistry; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * An impact identity is handed out once, and the damage service's refusal of duplicates is only as + * good as that. + * + *

    This is a one-line property with an expensive failure. The duplicate memory answers a repeated + * identity by handing the whole budget back, so a round whose identity somebody else already spent + * bores nothing, marks nothing and flies on — through a stone wall, at full speed, with its budget + * untouched. Nothing above it can tell that apart from a clean miss, which is why the property is + * pinned here rather than left to be noticed downrange.

    + * + *

    What is deliberately NOT pinned: the numbers themselves, or that they are consecutive. The + * contract is uniqueness and never rewinding; any two identities that differ satisfy it.

    + */ +public class ImpactIdentityTest { + + /** + * Far past the point the old identity broke down at. It was a shot id shifted eight bits with a + * per-shot counter mixed into the low byte, so the 257th impact of one round minted the first + * identity of the round after it — and one round boring for a few hundred ticks is an ordinary + * afternoon, not an edge case. + */ + private static final int MANY = 5000; + + @Test + public void everyIdentityIsHandedOutOnce() { + ShotRegistry registry = new ShotRegistry(); + Set seen = new HashSet(); + for (int i = 0; i < MANY; i++) { + long id = registry.nextImpactId(); + assertTrue("identity " + id + " was handed out twice within " + MANY + " impacts, so the" + + " damage service will refuse a real impact as a repeat of an unrelated one and" + + " hand its whole budget back", seen.add(id)); + } + assertEquals("the run minted fewer distinct identities than impacts", MANY, seen.size()); + } + + /** + * Clearing the shots in flight must not rewind the identities. The duplicate memory outlives any + * one round — that is what it is FOR — so a registry that started counting again after a clear + * would be re-minting identities the service is still holding. + */ + @Test + public void clearingTheShotsDoesNotRewindTheIdentities() { + ShotRegistry registry = new ShotRegistry(); + Set before = new HashSet(); + for (int i = 0; i < 64; i++) { + before.add(registry.nextImpactId()); + } + registry.clear(); + for (int i = 0; i < 64; i++) { + long id = registry.nextImpactId(); + assertTrue("identity " + id + " came back after the registry was cleared: a scenario that" + + " drops its shots would then re-use identities the damage service still refuses", + !before.contains(id)); + } + } + + /** + * And a restart must not rewind them either. The memory is in RAM and the counter is on disk, so + * a counter that reset on load would start handing out identities that a still-running server — + * the one that just saved — is holding. + */ + @Test + public void aSavedRegistryResumesWhereItLeftOff() { + ShotRegistry registry = new ShotRegistry(); + Set before = new HashSet(); + for (int i = 0; i < 64; i++) { + before.add(registry.nextImpactId()); + } + + ShotRegistry reloaded = new ShotRegistry(); + reloaded.readFromNBT(registry.writeToNBT(new NBTTagCompound())); + + for (int i = 0; i < 64; i++) { + long id = reloaded.nextImpactId(); + assertTrue("identity " + id + " was minted again after a save and load", !before.contains(id)); + } + } +} From d20df0acd7f03499f2d3192df26b37527655977c Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 21:51:08 +0300 Subject: [PATCH 25/35] fix: a block pays for the volume it has, with no floor invented under it - drop MIN_OCCUPANCY: a multiplier cannot express a detach cost - occupancyOf answers [0, 1] honestly; STAGE_COST_BASE holds the bottom - pin the contract: one material, two volumes, never free --- .../damage/StructureDamageEngine.java | 29 ++++++++++------ .../server/StructuralDamageContractTest.java | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index fa8b137ab..51092c891 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -58,13 +58,6 @@ public final class StructureDamageEngine { */ private static final int GAP_TOLERANCE = 6; - /** - * The least of its voxel a block is ever treated as filling. A pane is not a wall and should not - * cost like one, but nothing is free either: a body still has to break the thing off its mounting, - * and a floor here is what stops a torch or a tripwire from being a hole in a hull. - */ - private static final double MIN_OCCUPANCY = 0.1D; - /** One whole voxel at the origin — the box a block is asked to report its collision shape within. */ private static final AxisAlignedBB FULL_VOXEL = new AxisAlignedBB(0.0D, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D); @@ -419,7 +412,7 @@ public static int stageCost(World world, BlockPos pos, double areaFactor, Impact } /** - * How much of its voxel this block actually fills, in {@code [MIN_OCCUPANCY, 1]}. + * How much of its voxel this block actually fills, in {@code [0, 1]}. * *

    Why a price has to know this at all

    *

    The law is an energy per unit of VOLUME removed — that is the whole of why the mechanical and @@ -437,6 +430,18 @@ public static int stageCost(World world, BlockPos pos, double areaFactor, Impact * states its real shape, box by box, so that is what is summed. Overlapping boxes would double * count, which is why the sum is clamped: over-counting can only ever produce "a full cube", the * answer we started from.

    + * + *

    There is no floor under it, and nothing is free anyway

    + *

    A floor was tried and removed: it was a MULTIPLIER, so it priced "the least a block can cost" + * out of that block's own material, and what it was meant to represent — the work of breaking a + * thing off its mounting — has nothing to do with what the thing is made of. What actually keeps a + * near-empty voxel from being free is {@code STAGE_COST_BASE}, which is material-independent and + * already inside the product: a standing torch answers 0.024 here and still costs 6 against the + * 1000 a full block of stone costs. Below that the price itself floors at 1.

    + * + *

    The floor's stated reason — that a torch must not be a hole in a hull — does not survive + * being looked at: a voxel holding a torch is a voxel holding no hull block. The hole is the + * builder's, and pricing it dearly does not fill it.

    */ private static double occupancyOf(World world, BlockPos pos) { if (world == null || pos == null) { @@ -451,17 +456,19 @@ private static double occupancyOf(World world, BlockPos pos) { volume += (box.maxX - box.minX) * (box.maxY - box.minY) * (box.maxZ - box.minZ); } if (volume > 0.0D) { - return Math.max(MIN_OCCUPANCY, Math.min(1.0D, volume)); + return Math.min(1.0D, volume); } // No collision at all — a torch, a plant, a tripwire. It is still SOMETHING, so it falls // back to the shape it draws itself with rather than to nothing. AxisAlignedBB drawn = state.getBoundingBox(world, pos); if (drawn == null) { - return MIN_OCCUPANCY; + // It states no shape at all. The price floors at 1 rather than at nothing, which is + // the whole of what "still SOMETHING" needs to mean here. + return 0.0D; } double drawnVolume = (drawn.maxX - drawn.minX) * (drawn.maxY - drawn.minY) * (drawn.maxZ - drawn.minZ); - return Math.max(MIN_OCCUPANCY, Math.min(1.0D, drawnVolume)); + return Math.min(1.0D, drawnVolume); } catch (RuntimeException blockDidNotLikeBeingAsked) { // A block may compute its shape from neighbours it expects to be loaded. It costs a full // cube rather than throwing, which is the answer that changes nothing. diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java index bbb79479c..2bc9b0427 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/StructuralDamageContractTest.java @@ -141,6 +141,40 @@ public void aTougherWallIsNotPenetratedFurtherThanAFlimsyOneAtEqualBudget() thro + "its crew nothing.\niron=" + iron, ironDestroyed < glassDestroyed); } + /** + * Two blocks of ONE material, differing only in how much of their voxel they fill: a wool block + * and a wool carpet. The carpet must cost far less — and must still cost something. + * + *

    The law is an energy per unit of VOLUME removed, so the material is deliberately held fixed: + * a comparison across materials would pass on the toughness table alone and say nothing about + * volume. A carpet fills a sixteenth of its voxel, and for as long as a block was priced as a full + * cubic metre it cost what a solid block of wool costs to shoot through.

    + * + *

    The second half is the one that used to be held by a floor under the occupancy, and is now + * held by the price itself: the base term of the law is material-independent and the price rounds + * up to at least one. So "almost no material" lands at "almost free", never at "free" — which is + * what stops a body walking an arbitrarily long run of decoration for nothing.

    + */ + @Test + public void aBlockIsPricedByHowMuchOfItsVoxelItFillsAndNeverAtNothing() throws Exception { + int blockX = 1200, blockZ = 1300; + int carpetX = 1200, carpetZ = 1320; + buildWall("minecraft:wool", blockX, blockZ, 1); + buildWall("minecraft:carpet", carpetX, carpetZ, 1); + + String solid = stage(blockX, blockZ); + String thin = stage(carpetX, carpetZ); + long solidCost = readLong(solid, "stageCost"); + long thinCost = readLong(thin, "stageCost"); + + assertTrue("a carpet costs what a solid block of the same wool costs (carpet=" + thinCost + + " block=" + solidCost + "): then a block is priced as a full cubic metre of material" + + " however little of its voxel it fills, and the law stops being about volume." + + " carpet=" + thin + " block=" + solid, thinCost < solidCost); + assertTrue("a carpet costs nothing at all (" + thin + "): then a body crosses any length of" + + " decoration for free, and the price has no lower end", thinCost >= 1); + } + /** The unified stage reader at a wall's first block: stage, max stage, and what a stage costs there. */ private String stage(int x, int z) throws Exception { return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + z); From 1b4af4819752bdb8e5f48678cb887e9ceb357fb2 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 19 Aug 2026 21:51:08 +0300 Subject: [PATCH 26/35] feat: a responder may decline, and a mirror is glass again - ContactResult.noOpinion: the default law decides, as for a plain block - IContactResponder no longer tells implementers to decline by passing through - mirror plating declines a solid round instead of waving it through free - one lowercase regex row prices the mirror family as glass, not hull plate --- .../api/damage/ContactResult.java | 43 +++++++- .../api/damage/IContactResponder.java | 9 +- .../block/BlockMirrorPlating.java | 9 +- .../projectile/ContactResolver.java | 5 +- .../advancedRocketry/util/WeightEngine.java | 32 +++++- ...rmourBlocksAnswerForThemselvesE2ETest.java | 100 +++++++++++++++--- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java index 1d3b901ac..3cd148064 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java @@ -5,8 +5,14 @@ /** * What a block answered when a travelling body met it. * - *

    Three states, four behaviours

    + *

    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 @@ -24,24 +30,51 @@ */ 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) { + 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); + return new ContactResult(false, residualEnergy, null, false); } /** Nothing continues past this block. */ public static ContactResult stopped() { - return new ContactResult(true, 0, null); + return new ContactResult(true, 0, null, false); } /** @@ -55,7 +88,7 @@ public static ContactResult deflected(Vec3d newVelocity, int residualEnergy) { if (newVelocity == null || newVelocity.lengthVector() <= 1.0E-9D) { return stopped(); } - return new ContactResult(false, residualEnergy, newVelocity); + return new ContactResult(false, residualEnergy, newVelocity, false); } /** True when nothing continues past the block that answered. */ diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java index 7dc78d221..c6dedf106 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java @@ -21,7 +21,14 @@ public interface IContactResponder { /** * Answer for one body meeting this block. Never null: return - * {@link ContactResult#passedThrough(int)} to decline having an opinion. + * {@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 diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java index f53b486c7..5fe5fff8e 100644 --- a/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java @@ -68,8 +68,13 @@ public ContactResult onContact(World world, Contact contact) { return null; } if (!isRadiant(contact.getKind())) { - // A mirror is glass and foil. A slug does not care that it is shiny. - return ContactResult.passedThrough(contact.getEnergy()); + // 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)); diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index 1be4a9ff1..1f30223ca 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -77,7 +77,10 @@ public static Resolution resolve(World world, TravellingBody body, StructureCros IContactResponder responder = responderAt(world, hit.block); if (responder != null) { ContactResult answer = responder.onContact(world, contact); - if (answer != null) { + // Declining is not answering. A responder has a law about some arrivals and none about the + // rest, and the rest must fall through to the ordinary treatment — otherwise the only + // phrase available for "nothing to say" is one that means "through, for free". + if (answer != null && !answer.isNoOpinion()) { // A block that answered for itself did not walk anything, so the body is advanced past // the block it was answered by — otherwise the next test finds the same block, asks // again, and a round argues with one plate until the tick's crossing budget runs out. diff --git a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java index f43162d81..d2715fc94 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java @@ -380,6 +380,9 @@ public void load() { ablationIndividual = readMap(gson, root, "ablationIndividual", mapType); ablationByRegex = readMap(gson, root, "ablationByRegex", mapType); toughnessByRegex = readMap(gson, root, "toughnessByRegex", linkedType); + if (toughnessByRegex.isEmpty()) { + toughnessByRegex = defaultToughnessByRegex(); + } toughnessMaterials = readMap(gson, root, "toughnessMaterials", mapType); if (toughnessMaterials.isEmpty()) { toughnessMaterials = defaultToughnessMaterials(); @@ -412,7 +415,7 @@ private void seedDefaults() { fallback = 0.1; fluidFallback = 0.001; toughnessIndividual = new HashMap<>(); - toughnessByRegex = new LinkedHashMap<>(); + toughnessByRegex = defaultToughnessByRegex(); toughnessMaterials = defaultToughnessMaterials(); toughnessFallback = 2.0; } @@ -519,6 +522,33 @@ private static Map defaultMaterials() { * survive retuning is the ordering, because that is what a player perceives when a shot goes * through a window and stops in the plating. */ + /** + * Rows this mod ships for its own blocks, where the material alone gets them badly wrong. + * + *

      Written as regexes rather than one row per block so that a family is priced as a family: the + * three mirror films differ in how much light they return, not in how hard the glass is, and a + * fourth tier should not need a fifth row.

      + * + *

      Mirror plating is glass and foil declared as {@code Material.IRON} — iron because that + * is what it is mined and sounded like, which then priced a mirror film as hull plate. It answers + * a beam by its own law, so this row governs what it costs to smash: a solid round, an explosion, + * anything with no optics in it. Reactive plating deliberately has NO row: its casing IS metal, + * and what makes it interesting is the charge rather than what the charge is wrapped in.

      + * + *

      Seeded when the config carries no regex rows at all, exactly as the material table is — so a + * pack cannot express "no regex rows whatsoever". That is a real limitation and it is inherited + * rather than chosen; a pack that disagrees with a row overrides it by value, or by an individual + * row, which outranks every regex.

      + */ + private static Map defaultToughnessByRegex() { + Map m = new LinkedHashMap<>(); + // LOWERCASE, and it is not a style choice: a registry name arrives here already lowercased, + // so a pattern written the way the block was declared ("mirrorPlating...") matches nothing and + // the row silently does not exist. The block keeps its declared price and nobody is told. + m.put("advancedrocketry:mirrorplating.*", 1.0); + return m; + } + private static Map defaultToughnessMaterials() { Map m = new LinkedHashMap<>(); m.put("AIR", 0.0); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java index c8bef2065..ac5ac377e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java @@ -23,17 +23,27 @@ public class ArmourBlocksAnswerForThemselvesE2ETest extends AbstractSharedServer private static final int Y = 70, X = 2100; private static final int MIRROR_Z = 1200, MIRROR_SLUG_Z = 1220, BETTER_Z = 1240; private static final int REACTIVE_Z = 1260, REACTIVE_TWICE_Z = 1280, RAILGUN_Z = 1300; + private static final int PRICE_Z = 1320; private static final double SPEED = 0.45D; private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); private static final Pattern VX = Pattern.compile("\"vx\":(-?[\\d.eE+-]+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); /** - * A mirror returns a beam and lets a slug through, and the difference is the kind in the contact - * and nothing else — the same block, the same face, the same energy. + * A mirror returns a beam and is SMASHED by a solid round, and the difference is the kind in the + * contact and nothing else — the same block, the same face, the same energy. + * + *

      The second half used to read "lets a solid round straight through", and it was pinning a + * defect. A mirror has no OPTICAL opinion about a solid round, and it said so by answering "passed + * through, carrying everything" — which is not "no opinion", it is "through, for free". So the + * round paid nothing, the film was untouched, and the one armour a beam could strip was the one + * kinetic fire could not. The block now DECLINES, and declining hands the meeting to the ordinary + * law: the film is priced off the table and the eighth of a voxel it fills, and it breaks like the + * glass it is.

      */ @Test - public void aMirrorReturnsABeamAndLetsASlugStraightThrough() throws Exception { + public void aMirrorReturnsABeamAndIsSmashedByASolidRound() throws Exception { prepare(MIRROR_Z); prepare(MIRROR_SLUG_Z); place("advancedrocketry:mirrorPlatingAluminium", MIRROR_Z); @@ -47,10 +57,13 @@ public void aMirrorReturnsABeamAndLetsASlugStraightThrough() throws Exception { long slug = fire(MIRROR_SLUG_Z, 3_000, "KINETIC"); assertTrue("the slug was refused", slug >= 0); - assertTrue("a solid round bounced off glass and foil: a mirror has no opinion about a slug," - + " and the kind in the contact is the only thing that separates the two cases: " - + read(slug), !awaitTurnedBack(slug)); - assertTrue("the mirror is still standing after a slug went through it", stillThere(MIRROR_SLUG_Z)); + assertTrue("a solid round bounced off glass and foil: a mirror has no OPTICAL opinion about" + + " a solid round, and the kind in the contact is the only thing that separates the" + + " two cases: " + read(slug), !awaitTurnedBack(slug)); + awaitGone(slug); + assertTrue("the film is still standing after a solid round crossed it: then the round paid" + + " nothing for it, and a mirror is armour that only the weapon it was built to stop" + + " can remove", !stillThere(MIRROR_SLUG_Z)); } /** @@ -67,16 +80,26 @@ public void aBetterMirrorSurvivesWhatKillsAWorseOne() throws Exception { // exceeds what the film sheds, and a thirtieth of it does not. int killsAluminium = 60_000; - // CONTROL, and the test is worthless without it: the same energy as a SLUG must leave the - // plate standing. Ordinary damage does not know one mirror from another, so if it were doing - // the work below, this is where it would show — and the first cut of this test passed with the - // whole responder switched off, which is exactly what this catches. - long slug = fire(BETTER_Z, killsAluminium, "KINETIC"); - assertTrue("the control round was refused", slug >= 0); - awaitGone(slug); - assertTrue("a slug carrying what the beams below carry destroyed the plating: then what kills" - + " a mirror here is ordinary damage, and nothing in this test is about mirrors", - stillThere(BETTER_Z)); + // CONTROL, and the test is worthless without it — the first cut of this test passed with + // the whole responder switched off. It used to be a solid round carrying the same energy, + // which had to leave the plate standing; that stopped being available the day such a round + // started paying for the film and breaking it, which is correct and kills the old control. + // + // This is the stronger replacement, and it aims at the mechanism rather than at one sample: + // ordinary damage prices the two tiers IDENTICALLY, so it cannot produce a difference between + // them at all. Whatever separates aluminium from gold below is therefore the reflectance, and + // can be nothing else. + placeAt(X + 4, BETTER_Z, "advancedrocketry:mirrorPlatingGold"); + long aluminiumCost = costOf(exec("artest damage stage " + DIM + " " + X + " " + Y + " " + + BETTER_Z)); + long goldCost = costOf(exec("artest damage stage " + DIM + " " + (X + 4) + " " + Y + " " + + BETTER_Z)); + assertTrue("the two mirror tiers cost different amounts to break by ordinary damage" + + " (aluminium=" + aluminiumCost + " gold=" + goldCost + "): then the ladder below can" + + " be produced without any mirror law at all, and this test measures the toughness" + + " table", aluminiumCost == goldCost); + placeAt(X + 4, BETTER_Z, "minecraft:air"); + long first = fire(BETTER_Z, killsAluminium, "BEAM"); assertTrue("the beam was refused", first >= 0); awaitGone(first); @@ -143,6 +166,49 @@ public void twiceTheReactiveVolumeEatsMoreOfTheSameImpact() throws Exception { clean(X + 2, RAILGUN_Z)); } + /** + * A mirror film is priced as the glass and foil it is, not as the hull plate its MATERIAL says. + * + *

      Both plating families are declared {@code Material.IRON} — which is what they are mined and + * sounded like — and the damage table resolves by material when nothing has written a row. That + * priced a mirror film as solid hull.

      + * + *

      The comparator is REACTIVE plating, and the choice is the whole test. The obvious + * comparison — a film against a solid block of iron — passes whether or not the mirror has a row + * of its own, because a film fills an eighth of its voxel and the volume alone makes it cheaper. + * It would measure the occupancy factor and report it as evidence about the table. Reactive + * plating is the same class, the same thickness and the same declared material, and it + * deliberately has NO row: its casing IS metal, and what makes it interesting is the charge rather + * than what the charge is wrapped in. So the two differ in exactly one thing, and a difference in + * price can come from exactly one place.

      + * + *

      Only the ORDERING is claimed. The numbers behind it are balance and will move; an assertion + * on them would go red the first time anyone retunes the table without breaking anything a player + * would notice.

      + */ + @Test + public void aMirrorFilmCostsLessToBreakThanTheMetalItsMaterialClaims() throws Exception { + prepare(PRICE_Z); + place("advancedrocketry:mirrorPlatingAluminium", PRICE_Z); + placeAt(X + 4, PRICE_Z, "advancedrocketry:reactivePlate"); + + String film = exec("artest damage stage " + DIM + " " + X + " " + Y + " " + PRICE_Z); + String metal = exec("artest damage stage " + DIM + " " + (X + 4) + " " + Y + " " + PRICE_Z); + long filmCost = costOf(film), metalCost = costOf(metal); + + assertTrue("a mirror film costs what the identically shaped plating beside it costs (film=" + + filmCost + " reactive=" + metalCost + "): the two differ only in that one has a row" + + " of its own, so this says the row is not being read at all and glass with foil on" + + " it still resists like hull plate. film=" + film + " reactive=" + metal, + filmCost < metalCost); + } + + private static long costOf(String json) { + Matcher m = STAGE_COST.matcher(json); + assertTrue("no stageCost in: " + json, m.find()); + return Long.parseLong(m.group(1)); + } + // ---- driving private long fire(int lane, int energy, String kind) throws Exception { From 6b91ac27ce8b3985421b281267d04ac042ec1062 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 09:53:06 +0300 Subject: [PATCH 27/35] refactor: ask one place which layer a segment meets, and one where a body leaves a gun - LayerCrossing: field-or-structure ordering lifted out of the shot's own step - TurretFireControl.muzzleOf: the standoff, the frame and the line-of-fire refusal - both were correct only while exactly one caller asked them --- .../projectile/LayerCrossing.java | 93 +++++++++++++++++++ .../projectile/ShotSubstrate.java | 19 ++-- .../weapon/TurretFireControl.java | 55 +++++++++-- 3 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/LayerCrossing.java diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/LayerCrossing.java b/src/main/java/zmaster587/advancedRocketry/projectile/LayerCrossing.java new file mode 100644 index 000000000..c8cb23c02 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/LayerCrossing.java @@ -0,0 +1,93 @@ +package zmaster587.advancedRocketry.projectile; + +import com.github.stannismod.affs.world.shield.ShieldStrikeService; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; + +/** + * Which layer a straight segment meets FIRST — the field or a structure — and how far along. + * + *

      Why this is a class and not four lines inside a step

      + *

      It was four lines inside {@link ShotSubstrate}, and it stayed correct only while exactly one + * thing in the game asked the question. A held beam asks it too, and a second copy of "which layer + * wins" is a second answer: a weapon that resolved the ordering one way while the substrate resolved + * it the other would fire through its own shield, or into a shell it had decided was not there, for + * reasons no reproduction would find.

      + * + *

      Ordering is geometric, never a pipeline

      + *

      "Shield first, then hull" is a rule that is wrong whenever the geometry says otherwise: a body + * emitted from INSIDE a shell meets the hull with no shield in between, and one crossing a friendly + * bubble on its way elsewhere should not be billed to it. So both layers are asked where they would be + * crossed, in blocks along this segment, and the smaller distance wins. The field answers {@code -1} + * for a ray that starts inside a shell, which is that same statement in its own vocabulary.

      + */ +public final class LayerCrossing { + + /** What the segment met first. Exactly one of {@link #isField} / {@link #isStructure} is true. */ + public static final class First { + /** How far along the segment, in blocks; {@code -1} when nothing was met. */ + public final double distance; + /** The structure crossing, or null when the field won or nothing was met. */ + public final StructureCrossing.Hit structure; + private final boolean field; + + private First(double distance, StructureCrossing.Hit structure, boolean field) { + this.distance = distance; + this.structure = structure; + this.field = field; + } + + public boolean isField() { + return field; + } + + public boolean isStructure() { + return structure != null && !field; + } + + /** Nothing stands between the two ends of this segment. */ + public boolean isNothing() { + return !field && structure == null; + } + } + + private static final First NOTHING = new First(-1.0D, null, false); + + private LayerCrossing() { + } + + /** + * Ask both layers about the segment {@code from -> to}. + * + * @param radius the body's own width; below half a block the sweep IS the ray + * @param onlyHullId when non-null, the structure question is narrowed to that one hull — a body + * already inside a hull's material is inside that hull and nothing else, so + * asking the world frame and every other ship is work whose answer is known + */ + public static First along(World world, Vec3d from, Vec3d to, double radius, String onlyHullId) { + if (world == null || from == null || to == null) { + return NOTHING; + } + Vec3d span = to.subtract(from); + double reach = span.lengthVector(); + if (reach <= 0.0D) { + return NOTHING; + } + Vec3d direction = span.scale(1.0D / reach); + + double fieldDistance = ShieldStrikeService.nearestShellCrossing(world, from, direction, reach); + StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, from, to, onlyHullId, + radius); + double structureDistance = structure == null ? -1.0D : structure.distance; + + boolean fieldFirst = fieldDistance >= 0.0D + && (structureDistance < 0.0D || fieldDistance <= structureDistance); + if (fieldFirst) { + return new First(fieldDistance, structure, true); + } + if (structureDistance >= 0.0D) { + return new First(structureDistance, structure, false); + } + return NOTHING; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 585343462..a22b6cc57 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -201,13 +201,13 @@ static ShotEndReason step(World world, Shot shot) { double reach = speed * timeLeft; Vec3d segmentEnd = position.add(velocity.scale(timeLeft)); - double fieldDistance = ShieldStrikeService.nearestShellCrossing(world, position, direction, - reach); - // Only on the first crossing of a tick that began inside material: there the answer is - // known to be that hull, and once the round has deflected or come out it is an ordinary - // body again and asks everything. - StructureCrossing.Hit structure = StructureCrossing.firstAlong(world, position, segmentEnd, - crossing == 0 ? boringHull : null, bodyRadius(shot)); + // Only on the first crossing of a tick that began inside material is the hull narrowed: + // there the answer is known to be that hull, and once the round has deflected or come out + // it is an ordinary body again and asks everything. + LayerCrossing.First first = LayerCrossing.along(world, position, segmentEnd, + bodyRadius(shot), crossing == 0 ? boringHull : null); + StructureCrossing.Hit structure = first.structure; + double fieldDistance = first.isField() ? first.distance : -1.0D; double structureDistance = structure == null ? -1.0D : structure.distance; if (ShotCrossingTrace.enabled()) { @@ -222,9 +222,8 @@ static ShotEndReason step(World world, Shot shot) { + world.getBlockState(structure.block).getBlock().getRegistryName()); } - boolean fieldFirst = fieldDistance >= 0.0D - && (structureDistance < 0.0D || fieldDistance <= structureDistance); - boolean structureFirst = structureDistance >= 0.0D && !fieldFirst; + boolean fieldFirst = first.isField(); + boolean structureFirst = first.isStructure(); if (structureFirst) { shot.setPosition(structure.point); diff --git a/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java index a8c908cf1..e738b3adb 100644 --- a/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java +++ b/src/main/java/zmaster587/advancedRocketry/weapon/TurretFireControl.java @@ -144,9 +144,37 @@ public static Vec3d interceptPoint(Vec3d muzzle, Vec3d targetPosition, Vec3d tar */ public static long fire(World world, BlockPos mountPos, String shipId, Vec3d localAim, GunSpec spec, int reach, UUID owner, String faction, Random random) { + Muzzle muzzle = muzzleOf(world, mountPos, shipId, localAim, spec, reach, random); + if (muzzle == null) { + return -1L; + } + Vec3d velocity = muzzle.direction.scale(spec.getMuzzleSpeed()).add(muzzle.carried); + ShotSpec shot = new ShotSpec(muzzle.point, velocity, spec.getProjectileRadius(), + spec.getProjectileMass(), spec.getLifetimeTicks(), spec.getImpactEnergy(), + spec.getKind(), owner, faction, environmentOf(world), null); + return ShotSubstrate.launch(world, shot); + } + + /** + * Where a body actually leaves this gun, along what, and what motion it inherits — or {@code null} + * when this gun may not fire at all. + * + *

      Every weapon family asks this, and it must have ONE answer

      + *

      A round and a held beam leave the same gun from the same place. The standoff below is not a + * detail of the projectile substrate: a body born inside the barrel resolves a structure crossing + * against the gun's own blocks, and the weapon takes itself apart. That is not hypothetical — a + * beam written without this did exactly that, on its first run, and the symptom was a probe + * answering "no turret there".

      + * + *

      So is the line-of-fire refusal: a gun recessed into a hull, or one whose arc crosses its own + * superstructure, HOLDS rather than demolishing it. A build that cannot fire safely is a problem + * the player can see; a gun that shells its own deck is a mystery.

      + */ + public static Muzzle muzzleOf(World world, BlockPos mountPos, String shipId, Vec3d localAim, + GunSpec spec, int reach, Random random) { if (world == null || world.isRemote || mountPos == null || localAim == null || spec == null || !spec.isOperable() || localAim.lengthVector() < 1.0E-9D) { - return -1L; + return null; } if (shipId == null && VSIntegration.isBlockInShipyard(mountPos)) { @@ -155,7 +183,7 @@ public static long fire(World world, BlockPos mountPos, String shipId, Vec3d loc // this method is callable from anywhere, and the failure it prevents is severe out of all // proportion to the check — treating a shipyard address as world coordinates puts a live // round in the middle of the region every parked hull in the world sits in. - return -1L; + return null; } Vec3d direction = spread(localAim.normalize(), spec.getSpreadDegrees(), random); @@ -176,7 +204,7 @@ public static long fire(World world, BlockPos mountPos, String shipId, Vec3d loc double[] dir = VSIntegration.rotateToWorldFrameFor(world, shipId, direction.x, direction.y, direction.z); if (point == null || dir == null) { - return -1L; + return null; } worldMuzzle = new Vec3d(point[0], point[1], point[2]); worldDirection = new Vec3d(dir[0], dir[1], dir[2]).normalize(); @@ -193,14 +221,23 @@ public static long fire(World world, BlockPos mountPos, String shipId, Vec3d loc // superstructure its arc crosses, the wall a ground battery was mounted behind. The gun // holds rather than demolishing it: a build that cannot fire safely is a problem the // player can see, and a gun that shells its own deck is a mystery. - return -1L; + return null; } - Vec3d velocity = worldDirection.scale(spec.getMuzzleSpeed()).add(carried); - ShotSpec shot = new ShotSpec(worldMuzzle, velocity, spec.getProjectileRadius(), - spec.getProjectileMass(), spec.getLifetimeTicks(), spec.getImpactEnergy(), - spec.getKind(), owner, faction, environmentOf(world), null); - return ShotSubstrate.launch(world, shot); + return new Muzzle(worldMuzzle, worldDirection, carried); + } + + /** Where a body leaves a gun, in WORLD terms, and the motion the gun's own hull lends it. */ + public static final class Muzzle { + public final Vec3d point; + public final Vec3d direction; + public final Vec3d carried; + + Muzzle(Vec3d point, Vec3d direction, Vec3d carried) { + this.point = point; + this.direction = direction; + this.carried = carried; + } } /** From a67d7bba6417eb9774a8a626adcc97a8e5b0258b Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 09:55:02 +0300 Subject: [PATCH 28/35] feat: a unit hears what broke it, its own destruction included - DamageOccurrence: cause, kind, severity, stages, place, hull - facts only - IDamageAware via capability: told, never asked, no storage - news is not state - the engine records what it advanced; the service publishes cause and hull - a dying unit's listener is captured before its block becomes air - ImpactRequest.withCause, so the cause enum is not decoration - harness attaches a recorder, the route a foreign mod would take --- .../advancedRocketry/AdvancedRocketry.java | 1 + .../api/capability/CapabilityDamageAware.java | 68 +++++++ .../api/damage/DamageCause.java | 40 ++++ .../api/damage/DamageOccurrence.java | 130 +++++++++++++ .../api/damage/IDamageAware.java | 38 ++++ .../api/damage/ImpactRequest.java | 37 ++++ .../command/test/TestProbeCommand.java | 114 +++++++++++ .../test/TestProbeCommandRegistration.java | 1 + .../damage/ShipDamageService.java | 54 +++++- .../damage/StructureDamageEngine.java | 46 +++++ .../server/AUnitHearsWhatBrokeItE2ETest.java | 183 ++++++++++++++++++ 11 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityDamageAware.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/DamageCause.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/DamageOccurrence.java create mode 100644 src/main/java/zmaster587/advancedRocketry/api/damage/IDamageAware.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/AUnitHearsWhatBrokeItE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 2ea49e679..122ab7c98 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -1205,6 +1205,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; 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/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/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/ImpactRequest.java b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java index 809671291..204550a4a 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/ImpactRequest.java @@ -36,6 +36,7 @@ public final class ImpactRequest { 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; @@ -66,6 +67,33 @@ public ImpactRequest(long impactId, Vec3d point, Vec3d direction, int budget, Im 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. */ @@ -101,6 +129,15 @@ public static ImpactRequest resuming(long impactId, Vec3d point, Vec3d direction 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; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index b2db18b20..3bd2da751 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -1333,6 +1333,18 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ 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. @@ -21196,6 +21208,108 @@ public void onServerTick(net.minecraftforge.fml.common.gameevent.TickEvent.Serve } } + /** + * Records the {@link zmaster587.advancedRocketry.api.damage.DamageOccurrence}s delivered to units, + * by ATTACHING the capability to every tile on a harness server. + * + *

      Attaching rather than implementing is the point: it is exactly the route a foreign mod takes + * to make somebody else's machine damage-aware, so what this exercises is the shipped delivery + * path and not a private one. Nothing in production carries {@code IDamageAware} yet — enrolling a + * real unit means designing that unit's own consequence, which is its owner's decision — so + * without this the interface would have no consumer and no test could tell whether it delivers.

      + * + *

      Test mode only, and the list is a bounded, single-writer diagnostic that OUTLIVES a scenario + * on a shared server: {@code /artest damage occurrences clear} is how a scenario claims a clean + * one.

      + */ + public static final class DamageOccurrenceRecorder { + + private static final int CAPACITY = 256; + private static final java.util.List SEEN = new java.util.ArrayList(); + private static volatile boolean registered = false; + + public static synchronized void ensureRegistered() { + if (registered) { + return; + } + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new DamageOccurrenceRecorder()); + registered = true; + } + + static synchronized void record(zmaster587.advancedRocketry.api.damage.DamageOccurrence o) { + if (SEEN.size() >= CAPACITY) { + SEEN.remove(0); + } + SEEN.add("{\"cause\":\"" + o.getCause() + "\",\"kind\":" + + (o.getKind() == null ? "null" : "\"" + o.getKind() + "\"") + + ",\"x\":" + o.getPos().getX() + ",\"y\":" + o.getPos().getY() + + ",\"z\":" + o.getPos().getZ() + + ",\"stageBefore\":" + o.getStageBefore() + + ",\"stageAfter\":" + o.getStageAfter() + + ",\"maxStage\":" + o.getMaxStage() + + ",\"spent\":" + o.getBudgetSpent() + + ",\"destroyed\":" + o.isDestroyed() + + ",\"ship\":" + (o.getShipId() == null ? "null" : "\"" + o.getShipId() + "\"") + + ",\"hasWorld\":" + (o.getWorld() != null) + + ",\"hasWhere\":" + (o.getWhere() != null) + "}"); + } + + static synchronized String json() { + StringBuilder sb = new StringBuilder("{\"ok\":true,\"count\":").append(SEEN.size()) + .append(",\"occurrences\":["); + for (int i = 0; i < SEEN.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(SEEN.get(i)); + } + return sb.append("]}").toString(); + } + + static synchronized int clear() { + int had = SEEN.size(); + SEEN.clear(); + return had; + } + + @net.minecraftforge.fml.common.eventhandler.SubscribeEvent + public void onAttach(net.minecraftforge.event.AttachCapabilitiesEvent< + net.minecraft.tileentity.TileEntity> event) { + if (zmaster587.advancedRocketry.api.capability.CapabilityDamageAware.DAMAGE_AWARE == null) { + return; + } + event.addCapability(new net.minecraft.util.ResourceLocation("advancedrocketry", + "test_damage_recorder"), new RecorderProvider()); + } + } + + /** The provider half of the recorder attachment; one listener per tile, holding nothing. */ + private static final class RecorderProvider + implements net.minecraftforge.common.capabilities.ICapabilityProvider { + private final zmaster587.advancedRocketry.api.damage.IDamageAware listener = + new zmaster587.advancedRocketry.api.damage.IDamageAware() { + @Override + public void onDamage(zmaster587.advancedRocketry.api.damage.DamageOccurrence occurrence) { + DamageOccurrenceRecorder.record(occurrence); + } + }; + + @Override + public boolean hasCapability(net.minecraftforge.common.capabilities.Capability capability, + net.minecraft.util.EnumFacing facing) { + return capability + == zmaster587.advancedRocketry.api.capability.CapabilityDamageAware.DAMAGE_AWARE; + } + + @Override + public T getCapability(net.minecraftforge.common.capabilities.Capability capability, + net.minecraft.util.EnumFacing facing) { + return capability + == zmaster587.advancedRocketry.api.capability.CapabilityDamageAware.DAMAGE_AWARE + ? (T) listener : null; + } + } + public static final class RocketEventRecorder { public static volatile int launchCount = 0; public static volatile int preLaunchCount = 0; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommandRegistration.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommandRegistration.java index 1c8941744..3c5299980 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommandRegistration.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommandRegistration.java @@ -53,6 +53,7 @@ public static void registerIfTestMode(FMLServerStartingEvent event) { // register the rocket-event recorder at server start so // counters are accurate from the first rocket lifecycle event. TestProbeCommand.RocketEventRecorder.ensureRegistered(); + TestProbeCommand.DamageOccurrenceRecorder.ensureRegistered(); AdvancedRocketry.logger.info("Registered /artest test-only probe commands (-D" + FLAG + "=true)"); } } diff --git a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java index 5c973e5ef..d3ecb5002 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/ShipDamageService.java @@ -3,7 +3,11 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; +import zmaster587.advancedRocketry.AdvancedRocketry; +import zmaster587.advancedRocketry.api.capability.CapabilityDamageAware; +import zmaster587.advancedRocketry.api.damage.DamageOccurrence; import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.IDamageAware; import zmaster587.advancedRocketry.api.damage.DamageReport; import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.api.damage.SelectionMode; @@ -105,9 +109,11 @@ public static DamageReport apply(World world, ImpactRequest request) { String shipId = shipAt(world, point, request.getDirection()); if (shipId == null) { remember(world, request.getImpactId()); - return toReport(StructureDamageEngine.penetrate(world, point, request.getDirection(), - request.getBudget(), request.getReachBlocks(), request.getCrossSectionArea(), - request.resumesInside(), request.getKind()), null, world); + StructureDamageEngine.WalkResult walked = StructureDamageEngine.penetrate(world, point, + request.getDirection(), request.getBudget(), request.getReachBlocks(), + request.getCrossSectionArea(), request.resumesInside(), request.getKind()); + tellTheUnits(world, walked, request, null); + return toReport(walked, null, world); } double[] shipPoint = VSIntegration.toShipFrameFor(world, shipId, point.x, point.y, point.z); @@ -126,6 +132,7 @@ public static DamageReport apply(World world, ImpactRequest request) { new Vec3d(shipDir[0], shipDir[1], shipDir[2]), request.getBudget(), request.getReachBlocks(), request.getCrossSectionArea(), request.resumesInside(), request.getKind()); + tellTheUnits(world, walk, request, shipId); return toReport(walk, shipId, world); } @@ -217,6 +224,47 @@ private static String shipManagingPoint(World world, Vec3d point) { return null; } + /** + * Tell every unit the walk advanced what happened to it. + * + *

      Published HERE rather than by the engine because the two facts a unit needs and the engine + * cannot supply live at this layer: the CAUSE (the engine is handed a budget and a kind, never the + * request) and the HULL (the engine walks in whatever frame it was given and names no ship). The + * engine's job was to notice; this one's is to say who and why.

      + * + *

      A unit that no longer exists is told anyway. The engine hands back the listener it took + * out of a block on the way to destroying it, because the blow that ends a unit is the occurrence + * that unit most needs — and by now there is no tile at that position to look up. A survivor is + * looked up normally.

      + * + *

      An exception from a unit's own reaction is contained: the stage is already written and the + * budget already spent, so one unit throwing must not cost the rest of the hull its news, and it + * must not turn a resolved impact into a failed one.

      + */ + private static void tellTheUnits(World world, StructureDamageEngine.WalkResult walk, + ImpactRequest request, String shipId) { + if (walk == null || walk.touched.isEmpty()) { + return; + } + for (StructureDamageEngine.Touched t : walk.touched) { + IDamageAware unit = t.dying != null ? t.dying + : CapabilityDamageAware.get(world.getTileEntity(t.pos)); + if (unit == null) { + continue; + } + Vec3d where = toWorld(world, shipId, + new Vec3d(t.pos.getX() + 0.5D, t.pos.getY() + 0.5D, t.pos.getZ() + 0.5D)); + try { + unit.onDamage(new DamageOccurrence(request.getCause(), request.getKind(), world, + t.pos, where, t.stageBefore, t.stageAfter, t.maxStage, t.budgetSpent, shipId)); + } catch (RuntimeException unitThrew) { + AdvancedRocketry.logger.error("a unit threw while reacting to damage at " + t.pos + + " (" + request.getCause() + "): the damage stands, the reaction is lost", + unitThrew); + } + } + } + private static DamageReport toReport(StructureDamageEngine.WalkResult walk, String shipId, World world) { Vec3d entry = toWorld(world, shipId, walk.entryPoint); Vec3d exit = walk.outcome == DamageOutcome.EXITED ? toWorld(world, shipId, walk.exitPoint) : null; diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 51092c891..a431e0fb4 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -6,7 +6,9 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.capability.CapabilityDamageAware; import zmaster587.advancedRocketry.api.damage.DamageOutcome; +import zmaster587.advancedRocketry.api.damage.IDamageAware; import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.api.damage.ImpactRequest; import zmaster587.advancedRocketry.api.damage.StopReason; @@ -347,6 +349,7 @@ private static int spendInto(World world, BlockPos pos, IBlockState state, WalkR double areaFactor, int allowance, ImpactKind kind) { int maxStage = DamageState.getMaxStage(world, pos); int stage = DamageState.getStage(world, pos); + int stageBefore = stage; int stageCost = stageCost(world, pos, areaFactor, kind); int left = Math.max(0, allowance); @@ -366,11 +369,18 @@ private static int spendInto(World world, BlockPos pos, IBlockState state, WalkR BlockDamageSavedData.get(world).recordDestroyed(pos, state.getBlock(), state.getBlock().getMetaFromState(state)); DamageState.setStage(world, pos, stage); + // Taken out of the block while there still IS one. A unit's own destruction is the + // occurrence it most needs — what a failing engine does about being killed is its own + // business (a chemical one goes like TNT, an ion one merely ceases to exist) — and one + // line below there is no tile left to ask. Captured here, told by the layer above. + IDamageAware dying = CapabilityDamageAware.get(world.getTileEntity(pos)); world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); result.blocksDestroyed++; + result.touched.add(new Touched(pos, stageBefore, stage, maxStage, spent, dying)); } else { DamageState.setStage(world, pos, stage); result.blocksStaged++; + result.touched.add(new Touched(pos, stageBefore, stage, maxStage, spent, null)); } return spent; } @@ -515,6 +525,40 @@ private static Vec3d scale(Vec3d v, double s) { return new Vec3d(v.x * s, v.y * s, v.z * s); } + /** + * One unit this walk advanced — the facts, and nothing derived from them. + * + *

      The engine records rather than publishes because it does not know enough to publish: it walks + * in the frame it was given and can name no ship, and it is handed a budget and a kind rather than + * the request, so it can name no cause either. Both live one layer up, which is why that layer + * does the telling.

      + */ + public static final class Touched { + /** In the frame the walk ran in — subspace aboard a ship, the world's own otherwise. */ + public final BlockPos pos; + public final int stageBefore; + public final int stageAfter; + public final int maxStage; + public final int budgetSpent; + /** + * The unit's own listener, taken out of the block just before the block stopped existing; + * {@code null} for a block that survived, whose tile can simply be looked up when the news is + * delivered. A destroyed unit has no tile to look up any more, and it is the one that most + * needs to hear. + */ + public final IDamageAware dying; + + Touched(BlockPos pos, int stageBefore, int stageAfter, int maxStage, int budgetSpent, + IDamageAware dying) { + this.pos = pos; + this.stageBefore = stageBefore; + this.stageAfter = stageAfter; + this.maxStage = maxStage; + this.budgetSpent = budgetSpent; + this.dying = dying; + } + } + /** What one walk did, in the frame it walked. The seam above maps the points back to world. */ public static final class WalkResult { public DamageOutcome outcome = DamageOutcome.NOTHING_STRUCK; @@ -526,6 +570,8 @@ public static final class WalkResult { public int penetrationDepth; /** How far along its direction the walk got before it stopped, in blocks. */ public double distanceWalked; + /** Every unit this walk advanced, in the order it reached them. */ + public final List touched = new ArrayList(); public Vec3d entryPoint; public Vec3d exitPoint; } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/AUnitHearsWhatBrokeItE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/AUnitHearsWhatBrokeItE2ETest.java new file mode 100644 index 000000000..e3a67f6e5 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/AUnitHearsWhatBrokeItE2ETest.java @@ -0,0 +1,183 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A unit is TOLD what happened to it — including, and especially, when what happened killed it. + * + *

      The stage a block carries answers how broken am I and survives everything. It cannot + * answer what just happened to me: a shell and a collapsing hyperspace window leave the same + * stage behind, and a unit that only ever reads its stage can never tell them apart. So the cause is + * pushed once, at the moment it is true, and these are the claims that makes.

      + * + *

      The subject is a chest, because a unit has to have a tile to hear anything and a chest is + * the cheapest thing in the game that has one. What is being pinned is the DELIVERY, which is the same + * for every unit; what a particular machine then DOES about being damaged is that machine's own and is + * deliberately not here.

      + */ +public class AUnitHearsWhatBrokeItE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 70, X = 2400; + private static final int SURVIVES_Z = 1400, DIES_Z = 1420, DEAF_Z = 1440; + + private static final Pattern COUNT = Pattern.compile("\"count\":(\\d+)"); + private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)"); + private static final Pattern MAX_STAGE = Pattern.compile("\"maxStage\":(-?\\d+)"); + + /** + * A unit that survives hears about it, and what it hears names the cause, the severity and the + * stage it moved to — not a verdict, because the consequence is the unit's own to compute. + */ + @Test + public void aUnitThatSurvivesIsToldWhatHappenedAndHowHard() throws Exception { + prepare(SURVIVES_Z); + place(SURVIVES_Z, "minecraft:chest"); + clearRecorder(); + + // One stage's worth, priced off the block itself rather than written here: every cost in this + // engine is tunable, so a budget picked by hand would pin the tuning. + int oneStage = costOf(stage(SURVIVES_Z)); + assertTrue("the chest has no price, so no budget here means anything", oneStage > 0); + impact(SURVIVES_Z, oneStage); + + String seen = occurrences(); + assertTrue("nothing was delivered to a unit that was just damaged: the stage moved and the " + + "unit was never told, so a machine can only ever poll and can never react: " + seen, + countOf(seen) > 0); + assertTrue("the occurrence does not name what caused it: " + seen, + seen.contains("\"cause\":\"IMPACT\"") && seen.contains("\"kind\":\"KINETIC\"")); + assertTrue("the occurrence carries no severity, so a unit cannot tell a scratch from a " + + "near-miss-with-the-reactor: " + seen, seen.contains("\"spent\":") && spentOf(seen) > 0); + assertTrue("the occurrence says the unit was destroyed when it is still standing: " + seen, + seen.contains("\"destroyed\":false")); + assertTrue("the occurrence carries no world, so a unit that wants to do anything about being " + + "damaged has nothing to do it with: " + seen, seen.contains("\"hasWorld\":true")); + } + + /** + * The one that matters. A unit destroyed outright is still told — and it is told that it was + * destroyed. + * + *

      This is the occurrence a naive implementation loses: the block becomes air inside the damage + * walk, so anything looking the unit up afterwards finds nothing and says nothing. It is also the + * occurrence a unit most needs, because what a failing machine does about being killed is its own + * business and differs by machine — a chemical engine goes like TNT, an ion engine merely ceases to + * exist, a plasma engine is a tank letting go. A unit that never hears about its own death cannot + * have one of those.

      + */ + @Test + public void aUnitKilledOutrightIsStillToldThatItDied() throws Exception { + prepare(DIES_Z); + place(DIES_Z, "minecraft:chest"); + clearRecorder(); + + // Enough for every stage at once, so the unit goes from pristine to gone in one blow. + String probe = stage(DIES_Z); + int all = costOf(probe) * Math.max(1, maxStageOf(probe)) * 4; + impact(DIES_Z, all); + + assertTrue("the unit is still standing, so this run never tested a destruction at all", + gone(DIES_Z)); + + String seen = occurrences(); + assertTrue("a unit destroyed outright was told NOTHING: by the time anyone looks the block is " + + "already air, and the blow that ends a unit is the one it most needs to hear about — " + + "it is what a machine's own failure is made of: " + seen, countOf(seen) > 0); + assertTrue("the unit was told, but not that it had DIED: then it cannot tell a dent from its " + + "own destruction, and every failure mode collapses into one: " + seen, + seen.contains("\"destroyed\":true")); + } + + /** + * A unit that is not listening is simply not told, and nothing about the damage changes. The + * control: without it, every assertion above would also pass against a build that told EVERYTHING + * to everyone, which is a different and much worse mechanism. + */ + @Test + public void aBlockThatIsNotAUnitIsSimplyNotTold() throws Exception { + prepare(DEAF_Z); + place(DEAF_Z, "minecraft:stone"); + clearRecorder(); + + int oneStage = costOf(stage(DEAF_Z)); + assertTrue("the stone has no price, so no budget here means anything", oneStage > 0); + String report = impact(DEAF_Z, oneStage); + + assertTrue("the impact did not land, so this control tested nothing: " + report, + !report.contains("\"outcome\":\"NOTHING_STRUCK\"")); + String seen = occurrences(); + assertTrue("a plain block with no tile was handed an occurrence: then delivery is not opt-in " + + "and every stone in the world is a listener: " + seen, countOf(seen) == 0); + } + + // ---- driving + + private String impact(int lane, int budget) throws Exception { + exec("artest damage clear-impacts"); + return exec("artest damage impact " + DIM + " " + (X - 2.5D) + " " + (Y + 0.5D) + " " + + (lane + 0.5D) + " 1 0 0 " + budget + " KINETIC"); + } + + private void place(int lane, String block) throws Exception { + String resp = exec("artest place " + DIM + " " + X + " " + Y + " " + lane + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private void prepare(int lane) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + ((X - 16) >> 4) + + " " + ((lane - 16) >> 4) + " " + ((X + 16) >> 4) + " " + ((lane + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the lane", exec("artest fill " + DIM + " " + (X - 6) + " " + + (Y - 1) + " " + (lane - 2) + " " + (X + 6) + " " + (Y + 2) + " " + (lane + 2) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + private void clearRecorder() throws Exception { + exec("artest damage occurrences clear"); + } + + private String occurrences() throws Exception { + return exec("artest damage occurrences"); + } + + private String stage(int lane) throws Exception { + return exec("artest damage stage " + DIM + " " + X + " " + Y + " " + lane); + } + + private boolean gone(int lane) throws Exception { + String state = stage(lane); + return state.contains("\"wasDestroyed\":true") || state.contains("\"block\":\"minecraft:air\""); + } + + private static int costOf(String json) { + Matcher m = STAGE_COST.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 0; + } + + private static int maxStageOf(String json) { + Matcher m = MAX_STAGE.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 1; + } + + private static int countOf(String json) { + Matcher m = COUNT.matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : -1; + } + + private static int spentOf(String json) { + Matcher m = Pattern.compile("\"spent\":(\\d+)").matcher(json); + return m.find() ? Integer.parseInt(m.group(1)) : 0; + } + + private static String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} From b7c8925db040600a5071299ab25e2ba299c2d579 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 09:55:02 +0300 Subject: [PATCH 29/35] feat: a beam that is held, not thrown - HeldBeam: one tick of a line with a power, no record and nothing to persist - the shield is struck every tick, priced by kind and carrying no body to mirror - GunSpec.beamPowerPerTick and a gunBeamEmitter part, so a beam can be built - a starved beam goes dark and saves a quantum instead of stuttering - isOperable means CAN DELIVER: a beam has no barrel and is a weapon anyway - the buffer is sized from the quantum, or a beam can never light at all --- .../advancedRocketry/AdvancedRocketry.java | 11 + .../api/AdvancedRocketryBlocks.java | 3 +- .../advancedRocketry/api/weapon/GunSpec.java | 51 ++++- .../command/test/TestProbeCommand.java | 6 + .../advancedRocketry/projectile/HeldBeam.java | 135 +++++++++++ .../tile/weapon/TileTurret.java | 117 ++++++++++ .../server/ABeamIsHeldNotThrownE2ETest.java | 209 ++++++++++++++++++ 7 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/ABeamIsHeldNotThrownE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 122ab7c98..dd06803a5 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -774,6 +774,16 @@ public void registerBlocks(RegistryEvent.Register evt) { .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); @@ -986,6 +996,7 @@ public void registerBlocks(RegistryEvent.Register evt) { 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")); diff --git a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java index becee99e9..9da2cf3b8 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java +++ b/src/main/java/zmaster587/advancedRocketry/api/AdvancedRocketryBlocks.java @@ -59,7 +59,8 @@ public class AdvancedRocketryBlocks { public static Block blockTurret; public static Block blockGunBarrel; public static Block blockGunAmmoFeed; - public static Block blockGunCooling; + 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; /** diff --git a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java index 6fa14b1ca..fd78e6434 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java +++ b/src/main/java/zmaster587/advancedRocketry/api/weapon/GunSpec.java @@ -39,9 +39,28 @@ public final class GunSpec { 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; @@ -59,15 +78,26 @@ private GunSpec(Builder builder) { 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 fire at all. A build missing the one part that makes it a gun — a - * barrel — has no muzzle speed and no round worth firing, and saying so here means every call - * site asks one question instead of each inventing its own idea of "complete". + * 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() { - return muzzleSpeed > 0.0D && impactEnergy > 0 && partCount > 0; + if (partCount <= 0) { + return false; + } + return (muzzleSpeed > 0.0D && impactEnergy > 0) || beamPowerPerTick > 0; } /** Blocks per TICK, world frame once the mount has rotated it. */ @@ -167,6 +197,7 @@ public static final class Builder { 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; @@ -209,6 +240,18 @@ public Builder speedUpFireIntervalBy(int 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; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 3bd2da751..2aac0e067 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -533,6 +533,12 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ + ",\"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() + + ",\"beamLit\":" + turret.isBeamLit() + + ",\"beamRecharging\":" + turret.isBeamRecharging() + ",\"yaw\":" + mount.getYaw() + ",\"pitch\":" + mount.getPitch() + ",\"saturated\":" + mount.isSaturated() diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java new file mode 100644 index 000000000..0b6a08ce9 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java @@ -0,0 +1,135 @@ +package zmaster587.advancedRocketry.projectile; + +import com.github.stannismod.affs.world.shield.ShieldStrike; +import com.github.stannismod.affs.world.shield.ShieldStrikeResult; +import com.github.stannismod.affs.world.shield.ShieldStrikeService; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.damage.ImpactKind; +import zmaster587.advancedRocketry.api.damage.TravellingBody; +import zmaster587.advancedRocketry.damage.ImpactKindMapping; + +/** + * One tick of a beam somebody is HOLDING on a target. + * + *

      A beam is not a shot, and the difference is what this class is

      + *

      A shot is a body with a budget: it is admitted once, it travels, and it spends what it was given + * until it has none left. A beam has no budget and no flight — it is a LINE with a power, re-resolved + * every tick for as long as the gun holds it, and its depth grows with dwell rather than being carried + * in. So there is no record in the shot registry: a thing that does not travel has no flight to step, + * and there is nothing to persist, because a beam's lifetime is exactly as long as its gun is lit.

      + * + *

      What is NOT different: everything below the muzzle. The same layer ordering decides what the line + * meets first, the same shield seam prices what reaches a shell, and the same contact seam asks the + * block what happens. A beam that grew a damage path of its own would be a weapon that armour does not + * answer, and armour serving one weapon family is armour that will be wrong for the next.

      + * + *

      Per tick, because a player has to be able to see it work

      + *

      The energy of ONE tick is declared each tick. Against a shell that is a rate against a reserve — + * a beam the shell can pay for is held off, and one it cannot pay for gets through, which is what + * makes a shield something to survive behind rather than something to sit behind. Against a hull it is + * a small budget applied to the same walk over and over, so the hole deepens for as long as the trigger + * is held.

      + */ +public final class HeldBeam { + + /** What one tick of holding did — enough for a gun to decide, and for an instrument to report. */ + public static final class Emission { + /** Where the beam actually ended this tick, in WORLD coordinates. */ + public final Vec3d endedAt; + /** How far it reached before something stopped it, in blocks. */ + public final double distance; + /** True when a shell took this tick's energy. */ + public final boolean hitShield; + /** True when structure took it. */ + public final boolean hitStructure; + /** What the block let through, when structure was met; the tick's whole power otherwise. */ + public final int residualEnergy; + + Emission(Vec3d endedAt, double distance, boolean hitShield, boolean hitStructure, + int residualEnergy) { + this.endedAt = endedAt; + this.distance = distance; + this.hitShield = hitShield; + this.hitStructure = hitStructure; + this.residualEnergy = residualEnergy; + } + + /** Did this tick's energy land on anything at all? */ + public boolean hitSomething() { + return hitShield || hitStructure; + } + } + + private HeldBeam() { + } + + /** + * Resolve one tick of a beam running from {@code muzzle} along {@code direction} for at most + * {@code reach} blocks, carrying {@code powerThisTick}. + * + *

      Answers what happened; changes the world through the seams that already own those changes and + * through no others. Server side only, like every other thing that spends damage.

      + */ + public static Emission emit(World world, Vec3d muzzle, Vec3d direction, double reach, + int powerThisTick, ImpactKind kind, double radius, String hullId) { + if (world == null || world.isRemote || muzzle == null || direction == null + || powerThisTick <= 0 || reach <= 0.0D) { + return new Emission(muzzle, 0.0D, false, false, Math.max(0, powerThisTick)); + } + double length = direction.lengthVector(); + if (length <= 1.0E-9D) { + return new Emission(muzzle, 0.0D, false, false, powerThisTick); + } + Vec3d unit = direction.scale(1.0D / length); + Vec3d farEnd = muzzle.add(unit.scale(reach)); + + LayerCrossing.First first = LayerCrossing.along(world, muzzle, farEnd, radius, null); + if (first.isNothing()) { + // Into empty space. The energy leaves with it: a beam that met nothing warmed nothing. + return new Emission(farEnd, reach, false, false, powerThisTick); + } + + Vec3d contact = muzzle.add(unit.scale(first.distance)); + + if (first.isField()) { + // Priced through the one declared hull-kind to shield-kind mapping, and carrying NO body: + // a beam has nothing to mirror. Its energy arrives and stays there, which is exactly why a + // laser is the weapon that answers a shield and a slug is the one a shell can throw back. + ShieldStrike strike = new ShieldStrike(muzzle, unit, reach, powerThisTick, + ImpactKindMapping.toShieldKind(kind), false, null); + ShieldStrikeResult result = ShieldStrikeService.resolve(world, strike); + if (result.isIntercepted()) { + Vec3d at = result.getHitPoint() == null ? contact : result.getHitPoint(); + return new Emission(at, first.distance, true, false, 0); + } + // The shell was crossed and paid nothing — it went down between the two questions. The + // beam carries on to whatever is behind it rather than stopping in mid-air. + return emit(world, contact.add(unit.scale(CROSSING_EPSILON)), unit, + reach - first.distance - CROSSING_EPSILON, powerThisTick, kind, radius, hullId); + } + + // Structure. The identity comes from the world's own counter, exactly as a shot's does: a beam + // held for a minute declares sixty times as many impacts as one held for a second, and every + // one of them has to be a distinct meeting or the dedup memory refuses the lot. + TravellingBody body = new TravellingBody(ShotRegistry.get(world).nextImpactId(), + unit.scale(BEAM_NOMINAL_SPEED), kind, powerThisTick, radius); + ContactResolver.Resolution resolved = ContactResolver.resolve(world, body, first.structure, + reach - first.distance, false); + int residual = resolved.result.isStopped() ? 0 : resolved.result.getResidualEnergy(); + return new Emission(contact, first.distance, false, true, residual); + } + + /** + * A nominal speed for the body's facts, used for the ANGLE and for nothing else. + * + *

      The contact seam reads a velocity to work out an incidence, and an incidence is what decides + * a graze. A beam has no speed worth modelling — light crosses a battle in microseconds — so what + * is handed over is a direction with a magnitude, and the magnitude is never read: ricochet is + * gated on a body having MASS, and a beam has none, so nothing here can bounce.

      + */ + private static final double BEAM_NOMINAL_SPEED = 1.0D; + + /** How far past a crossing the line resumes, so a dead shell is not found again at distance zero. */ + private static final double CROSSING_EPSILON = 1.0E-4D; +} diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index 2b9c0de79..8a6143639 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -20,6 +20,7 @@ import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.api.weapon.GunSpec; +import zmaster587.advancedRocketry.projectile.HeldBeam; import zmaster587.advancedRocketry.api.weapon.TurretDriveState; import zmaster587.advancedRocketry.damage.DamageState; import zmaster587.advancedRocketry.integration.vs.VSIntegration; @@ -167,6 +168,15 @@ public void update() { } boolean onTarget = mechanism.tick(spec.getTraverseDegreesPerTick()); syncCommandIfChanged(); + + if (spec.isBeam()) { + // A beam is not fired, it is HELD: there is no interval to wait out and no round to + // admit, so the whole of "is it shooting" is whether it is lit this tick. + holdBeam(onTarget && !isHoldingFire(), shipId); + return; + } + beamLit = false; + if (!onTarget || isHoldingFire() || !canFireNow()) { return; } @@ -204,6 +214,104 @@ private void readOwnCondition() { * skipped the heat or the cooldown would be strictly better than the same gun on a console, * which is a balance decision nobody made. */ + /** + * One tick of holding the beam, or of not holding it. + * + *

      The duty cycle, which is the one place a continuous weapon is interesting

      + *

      A gun that cannot afford a shot simply does not fire it. A beam has no shot to skip, so a + * starved one would otherwise flicker at whatever rate its feed happened to deliver — firing on + * the ticks a little energy arrived and dying on the ones it did not. Instead it goes DARK, + * accumulates a quantum — enough to burn for {@link #BEAM_QUANTUM_TICKS} ticks without help — and + * only then lights again. So a weapon on a weak feed fires in real bursts a player can see and + * plan around, rather than delivering the same average power as an unreadable stutter.

      + * + *

      The state is exposed rather than kept private: a ship's fire control cannot be flown if it + * cannot tell "not shooting" from "cannot shoot yet".

      + */ + private void holdBeam(boolean wantsToFire, String shipId) { + int perTick = spec.getBeamPowerPerTick(); + if (!wantsToFire || perTick <= 0) { + beamLit = false; + return; + } + if (heat >= spec.getHeatCapacity()) { + beamLit = false; + return; + } + int quantum = perTick * BEAM_QUANTUM_TICKS; + if (beamRecharging) { + if (energy.getEnergyStored() < quantum) { + beamLit = false; + return; + } + beamRecharging = false; + } else if (energy.getEnergyStored() < perTick) { + // The feed could not keep up. Go dark and start saving rather than sputtering. + beamRecharging = true; + beamLit = false; + markDirty(); + return; + } + + // The SAME muzzle a round leaves from, and the same refusal when the line of fire is not + // clear. A beam that computed its own origin started inside the gun's own blocks and cut the + // weapon apart from the inside on its first tick. + TurretFireControl.Muzzle muzzle = TurretFireControl.muzzleOf(world, pos, shipId, + mechanism.getAimDirection(), spec, assemblyReach, random); + if (muzzle == null) { + beamLit = false; + return; + } + HeldBeam.Emission emission = HeldBeam.emit(world, muzzle.point, muzzle.direction, + BEAM_RANGE_BLOCKS, perTick, spec.getKind(), spec.getProjectileRadius(), shipId); + energy.extractEnergy(perTick, false); + heat += spec.getHeatPerShot(); + beamLit = true; + beamEndedAt = emission.endedAt; + if (emission.hitSomething()) { + shotsFired++; + } + markDirty(); + } + + /** + * How long a beam must be able to burn unaided before it may light again after being starved. + * One second: long enough that a burst is a thing a player sees rather than a flicker. + */ + private static final int BEAM_QUANTUM_TICKS = 20; + + /** + * How far a held beam reaches. A beam does not fly, so it has no lifetime to run out — what + * bounds it is a declared range, and the range is here rather than on the spec because nothing + * about a gun's construction says how far light goes. + */ + private static final double BEAM_RANGE_BLOCKS = 64.0D; + + /** Lit THIS tick. Not persisted: a beam's lifetime is exactly as long as its gun is holding it. */ + private boolean beamLit; + /** Dark and saving up, because the feed could not keep up. Persisted — it is a real refusal. */ + private boolean beamRecharging; + /** Where the beam ended last time it was lit; for instruments and, later, for drawing it. */ + private Vec3d beamEndedAt; + + /** Is this gun burning right now? */ + public boolean isBeamLit() { + return beamLit; + } + + /** + * Is this gun dark because it is saving up rather than because nobody asked it to fire? The + * distinction fire control cannot be built without. + */ + public boolean isBeamRecharging() { + return beamRecharging; + } + + /** Where the beam last ended, or null if it has not been lit. */ + public Vec3d getBeamEndedAt() { + return beamEndedAt; + } + private boolean launch(String shipId) { String stamped = faction != null ? faction : getEffectiveAccessCode(); long id = TurretFireControl.fire(world, pos, shipId, mechanism.getAimDirection(), spec, @@ -455,7 +563,14 @@ private WeaponNetworkState networkState() { * refill from empty, and one that shrank should not be holding more than it can. */ private void resizeBufferFor(GunSpec newSpec) { + // A thrower is sized by what a shot costs; a BEAM has no shot, so sized by what it burns — + // and it must hold more than one quantum, or the gun can never accumulate the thing it goes + // dark to accumulate and is dark forever. That failure is silent: a permanently unlit beam + // reports exactly what a correctly recharging one reports. int wanted = Math.max(MIN_ENERGY_BUFFER, newSpec.getEnergyPerShot() * 40); + if (newSpec.isBeam()) { + wanted = Math.max(wanted, newSpec.getBeamPowerPerTick() * BEAM_QUANTUM_TICKS * 2); + } if (wanted == energy.getMaxEnergyStored()) { return; } @@ -724,6 +839,7 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { mechanism.writeToNBT(mount); nbt.setTag("mount", mount); nbt.setInteger("energy", energy.getEnergyStored()); + nbt.setBoolean("beamRecharging", beamRecharging); nbt.setInteger("energyMax", energy.getMaxEnergyStored()); nbt.setInteger("heat", heat); nbt.setInteger("cooldown", fireCooldown); @@ -756,6 +872,7 @@ public void readFromNBT(NBTTagCompound nbt) { } int max = Math.max(MIN_ENERGY_BUFFER, nbt.getInteger("energyMax")); energy = new EnergyStorage(max, max, max, Math.min(max, nbt.getInteger("energy"))); + beamRecharging = nbt.getBoolean("beamRecharging"); heat = nbt.getInteger("heat"); fireCooldown = nbt.getInteger("cooldown"); shotsFired = nbt.getInteger("shots"); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ABeamIsHeldNotThrownE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ABeamIsHeldNotThrownE2ETest.java new file mode 100644 index 000000000..69a105db3 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ABeamIsHeldNotThrownE2ETest.java @@ -0,0 +1,209 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A beam is HELD, and what that means is that dwell is the weapon. + * + *

      A gun that throws rounds spends its energy in lumps at intervals, and holding the trigger longer + * buys more lumps. A beam has no lump: it is a line with a power, re-resolved every tick, and its depth + * grows for as long as it is lit. These pin that difference where it is visible — in the hole — and the + * one behaviour that makes a starved beam readable instead of a stutter.

      + * + *

      Numbers are deliberately not asserted. Every one of them is balance and will move; what is claimed + * is an ORDERING (longer dwell digs deeper) and a STATE MACHINE (dark, saving, lit again).

      + */ +public class ABeamIsHeldNotThrownE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 84, Z = 9500; + private static final int DWELL_X = 9600, STARVED_X = 9660; + /** Deep enough that a short dwell cannot reach the far side; see the dwell scenario. */ + private static final int WALL_DEPTH = 30; + + + /** + * The claim the whole family exists for: keeping it on the same spot digs DEEPER, with no second + * trigger pull and no round in flight anywhere. + * + *

      The gun is kept fed while it burns, and that is not a convenience — it is the subject. A + * beam with a finite buffer and no supply stops at what its capacitor held, and then "held twice + * as long" would measure the capacitor rather than the dwell. Fed, what is left to vary is the + * time on target, which is the thing being claimed.

      + */ + @Test + public void holdingItLongerDigsDeeper() throws Exception { + buildSite(DWELL_X); + buildBeamGun(DWELL_X); + + // Deep enough that a SHORT dwell cannot cross it, and made of the toughest ordinary thing + // there is. The first version was six blocks of stone: the short burn went through all six, + // both measurements read "6", and the test was reporting the depth of its own wall. + int wallX = DWELL_X + 12; + fill(wallX, wallX + WALL_DEPTH - 1, "minecraft:iron_block"); + aimAt(DWELL_X, wallX + WALL_DEPTH + 8); + + int afterShort = burnFor(DWELL_X, 1); + assertTrue("a beam held on an iron wall cut nothing at all into it: then it is not " + + "depositing its power into what it is pointed at, and dwell buys nothing. gun=" + + read(DWELL_X), afterShort > 0); + + assertTrue("the short burn already crossed the whole wall (" + afterShort + " of " + + WALL_DEPTH + "): the measurement is saturated and cannot show a longer one going" + + " further, whatever production does", afterShort < WALL_DEPTH); + + int afterLong = burnFor(DWELL_X, 4); + assertTrue("keeping the beam on the same spot four times as long got no further into the " + + "wall (short=" + afterShort + " long=" + afterLong + "): then depth does not grow " + + "with dwell and a beam is just a gun with an odd fire rate", afterLong > afterShort); + } + + /** + * A starved beam goes DARK and saves up, rather than flickering at whatever rate its feed happens + * to deliver. The distinction a fire control cannot be built without: "not shooting" and "cannot + * shoot yet" are different answers, and only one of them means the gun is broken. + */ + @Test + public void aStarvedBeamGoesDarkAndSavesUpInsteadOfStuttering() throws Exception { + buildSite(STARVED_X); + buildBeamGun(STARVED_X); + charge(STARVED_X); + + int wallX = STARVED_X + 12; + fill(wallX, wallX + 5, "minecraft:iron_block"); + aimAt(STARVED_X, wallX + 20); + + // Nothing feeds this gun, so its buffer is all it will ever have: burn it down and the duty + // cycle is what happens next. + String state = awaitRecharging(STARVED_X); + assertTrue("a beam with no feed never went dark: then it either fired on an empty buffer or " + + "it stuttered on whatever arrived, and neither is a state anything can act on: " + + state, extract(state, "beamRecharging") == 1); + // CONTROL, and it is not decoration: an earlier version of this scenario went green against a + // gun whose buffer was too small to ever hold its own quantum, so it was dark from the first + // tick and never fired at all. "Went dark" only means anything if it burned first. + assertTrue("the gun went dark without ever having landed a tick of beam: then this measured a " + + "weapon that cannot fire, not one that ran its capacitor down: " + state, + extract(state, "shots") > 0); + assertTrue("the gun reports itself lit while it is recharging: then the two states are one " + + "and fire control cannot tell not-shooting from cannot-shoot-yet: " + state, + extract(state, "beamLit") == 0); + assertTrue("this gun does not think it is a beam at all, so the run tested a thrower: " + state, + extract(state, "beamPower") > 0); + } + + // ---- driving + + /** The reference beam gun: a controller with emitters on it and cooling around it. */ + private void buildBeamGun(int bx) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 3; i++) { + place("advancedrocketry:gunBeamEmitter", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + private void buildSite(int bx) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + ((bx - 16) >> 4) + + " " + ((Z - 16) >> 4) + " " + ((bx + 64) >> 4) + " " + ((Z + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (bx - 4) + " " + + (Y - 2) + " " + (Z - 4) + " " + (bx + 60) + " " + (Y + 12) + " " + (Z + 4) + + " minecraft:air").contains("\"ok\":true")); + assertTrue("could not hold the chunk", exec("artest chunk forceload " + DIM + " " + (bx >> 4) + + " " + (Z >> 4)).contains("\"ok\":true")); + for (int cx = (bx >> 4); cx <= ((bx + 40) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + } + + private void fill(int fromX, int toX, String block) throws Exception { + assertTrue("could not build the wall", exec("artest fill " + DIM + " " + fromX + " " + Y + " " + + Z + " " + toX + " " + Y + " " + Z + " " + block).contains("\"ok\":true")); + } + + private void charge(int bx) throws Exception { + exec("artest turret charge " + DIM + " " + bx + " " + Y + " " + Z); + } + + private void aimAt(int bx, int targetX) throws Exception { + exec("artest turret target " + DIM + " " + bx + " " + Y + " " + Z + " " + (targetX + 0.5D) + + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + } + + // ---- reading + + /** + * Keep the gun fed and on target for {@code cycles} charge-and-burn passes, then report how deep + * the hole is. The feed stands in for the ship supply this scenario deliberately does not build: + * without it the measurement would be of the capacitor, not of the dwell. + */ + private int burnFor(int bx, int cycles) throws Exception { + for (int i = 0; i < cycles; i++) { + charge(bx); + Thread.sleep(1_400L); + } + return depthOf(bx + 12); + } + + /** How many blocks of the wall are gone or marked. */ + private int depthOf(int wallX) throws Exception { + int depth = 0; + for (int i = 0; i < WALL_DEPTH; i++) { + String state = exec("artest damage stage " + DIM + " " + (wallX + i) + " " + Y + " " + Z); + if (state.contains("\"wasDestroyed\":true") || state.contains("\"block\":\"minecraft:air\"") + || extract(state, "stage") > 0) { + depth = i + 1; + } + } + return depth; + } + + private String read(int bx) throws Exception { + return exec("artest turret read " + DIM + " " + bx + " " + Y + " " + Z); + } + + /** Wait until the gun reports itself dark and saving, or give up and report what it does say. */ + private String awaitRecharging(int bx) throws Exception { + long deadline = System.currentTimeMillis() + 25_000L; + String state = ""; + while (System.currentTimeMillis() < deadline) { + state = exec("artest turret read " + DIM + " " + bx + " " + Y + " " + Z); + if (extract(state, "beamRecharging") == 1) { + return state; + } + Thread.sleep(150L); + } + return state; + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private static int extract(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+|true|false)").matcher(json); + if (!m.find()) { + return -1; + } + String v = m.group(1); + if ("true".equals(v)) { + return 1; + } + if ("false".equals(v)) { + return 0; + } + return Integer.parseInt(v); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } +} From 4a74c0bc8010c2ab0cf5d1ddf22bdade4c3ff96a Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 12:12:41 +0300 Subject: [PATCH 30/35] feat: a beam that is seen while it is held - replicate a held beam as a repeated state with a staleness backstop - one place decides who can see a line, for rounds and beams alike - the gun owns its channel; every not-burning path announces itself - draw the beam as billboarded ribbons with a spot where it lands - pin the cadence contract and the arrival on a real client --- .../advancedRocketry/api/ARConfiguration.java | 14 +- .../client/ClientBeamTracker.java | 115 ++++++++++++ .../advancedRocketry/client/ClientProxy.java | 1 + .../client/render/RenderBeams.java | 167 ++++++++++++++++++ .../network/PacketBeamState.java | 103 +++++++++++ .../network/PacketRegistry.java | 1 + .../projectile/BeamReplication.java | 161 +++++++++++++++++ .../projectile/ProximityBroadcast.java | 76 ++++++++ .../projectile/ShotReplication.java | 80 +++------ .../tile/weapon/TileTurret.java | 63 +++++-- .../test/client/BeamReachesClientE2ETest.java | 136 ++++++++++++++ .../test/unit/BeamReplicationCadenceTest.java | 165 +++++++++++++++++ 12 files changed, 1007 insertions(+), 75 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java create mode 100644 src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java create mode 100644 src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java create mode 100644 src/main/java/zmaster587/advancedRocketry/projectile/ProximityBroadcast.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/BeamReachesClientE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 83bb8e4b9..0aa62cc74 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -468,11 +468,13 @@ public class ARConfiguration { @ConfigProperty(needsSync = true) public int maxShotsPerWorld = 256; /** - * How near a player's eye a round's PATH must pass before that player is told about it, in - * blocks. A shot is a server record, so being told is the only way a client can draw one; 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 - * replication off: the mechanic still works and nothing is drawn. + * 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; @@ -792,7 +794,7 @@ public static void loadPreInit() { 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 must pass before that player is told about it and can see it drawn, in blocks. 0 disables shot replication entirely — the mechanic still works, nothing is drawn", 0, 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(); 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..22d6b79c5 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java @@ -0,0 +1,115 @@ +package zmaster587.advancedRocketry.client; + +import net.minecraft.util.math.Vec3d; + +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 segment, as of now. */ + public static void lit(long gun, Vec3d from, Vec3d to) { + ClientBeam beam = BEAMS.get(gun); + if (beam == null) { + BEAMS.put(gun, new ClientBeam(from, to)); + return; + } + beam.refresh(from, to); + } + + /** 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(); + } + + 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: where it starts, where it ends, both in world coordinates. */ + public static final class ClientBeam { + + private Vec3d from; + private Vec3d to; + private int sinceHeard; + + private ClientBeam(Vec3d from, Vec3d to) { + this.from = from; + this.to = to; + } + + private void refresh(Vec3d newFrom, Vec3d newTo) { + from = newFrom; + to = newTo; + sinceHeard = 0; + } + + private boolean ageAndCheckStale() { + return ++sinceHeard > STALE_TICKS; + } + + public Vec3d getFrom() { + return from; + } + + public Vec3d getTo() { + return to; + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java b/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java index b65b500a2..545766eea 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java +++ b/src/main/java/zmaster587/advancedRocketry/client/ClientProxy.java @@ -413,6 +413,7 @@ public void 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/render/RenderBeams.java b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java new file mode 100644 index 000000000..373c57d53 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java @@ -0,0 +1,167 @@ +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()) { + Vec3d from = beam.getFrom(); + Vec3d to = beam.getTo(); + 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); + 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/network/PacketBeamState.java b/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java new file mode 100644 index 000000000..30e03e3d3 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java @@ -0,0 +1,103 @@ +package zmaster587.advancedRocketry.network; + +import io.netty.buffer.ByteBuf; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.network.PacketBuffer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import zmaster587.advancedRocketry.client.ClientBeamTracker; +import zmaster587.libVulpes.network.BasePacket; + +/** + * Server→client: a gun's beam is burning between these two points, or it has gone out. + * + *

      One packet for a state, not for an event

      + *

      A beam has no launch and no impact to announce — it is a line that exists while a trigger is + * held, so what travels is the line itself. The gun's own position is the key: a gun holds at most + * one beam, which makes "this gun's beam" the whole identity and saves inventing ids for something + * with no lifetime worth naming.

      + * + *

      The endpoints are written only when it is lit. Going out is the common case for the smaller + * packet, and a client being told "not burning" has no use for coordinates.

      + * + *

      It is a drawing, and it is honest about that

      + *

      Nothing the client does with this affects the game: no damage is resolved from it, nothing + * reads it back, and a player who received none of them plays the same game — worse-looking, not + * different.

      + */ +public class PacketBeamState extends BasePacket { + + private long gun; + private boolean lit; + private double fromX, fromY, fromZ; + private double toX, toY, toZ; + + public PacketBeamState() { + } + + public static PacketBeamState of(BlockPos gun, Vec3d from, Vec3d to, boolean lit) { + PacketBeamState packet = new PacketBeamState(); + packet.gun = gun.toLong(); + packet.lit = lit && from != null && to != null; + if (packet.lit) { + packet.fromX = from.x; + packet.fromY = from.y; + packet.fromZ = from.z; + packet.toX = to.x; + packet.toY = to.y; + packet.toZ = to.z; + } + return packet; + } + + @Override + public void write(ByteBuf out) { + PacketBuffer buffer = new PacketBuffer(out); + buffer.writeLong(gun); + buffer.writeBoolean(lit); + if (!lit) { + return; + } + buffer.writeDouble(fromX); + buffer.writeDouble(fromY); + buffer.writeDouble(fromZ); + buffer.writeDouble(toX); + buffer.writeDouble(toY); + buffer.writeDouble(toZ); + } + + @Override + public void readClient(ByteBuf in) { + PacketBuffer buffer = new PacketBuffer(in); + gun = buffer.readLong(); + lit = buffer.readBoolean(); + if (!lit) { + return; + } + fromX = buffer.readDouble(); + fromY = buffer.readDouble(); + fromZ = buffer.readDouble(); + toX = buffer.readDouble(); + toY = buffer.readDouble(); + toZ = buffer.readDouble(); + } + + @Override + public void read(ByteBuf in) { + // never sent to the server + } + + @Override + public void executeClient(EntityPlayer player) { + if (lit) { + ClientBeamTracker.lit(gun, new Vec3d(fromX, fromY, fromZ), new Vec3d(toX, toY, toZ)); + } else { + ClientBeamTracker.extinguished(gun); + } + } + + @Override + public void executeServer(EntityPlayerMP player) { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java b/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java index 7ff165d8b..259eea813 100644 --- a/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketRegistry.java @@ -55,6 +55,7 @@ public final class PacketRegistry { PacketSpaceClockSync.class, PacketShotSpawn.class, PacketShotEnd.class, + PacketBeamState.class, }; private PacketRegistry() { diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java b/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java new file mode 100644 index 000000000..90c95b331 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java @@ -0,0 +1,161 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.network.PacketBeamState; + +import java.util.function.Supplier; + +/** + * Who gets told that a beam is burning, and how often. + * + *

      A held thing is told by STATE, not by events

      + *

      A round is announced twice in a whole flight because its path is determined by the numbers in + * the first packet. A beam has no such determinism: it is wherever its gun is pointing THIS tick, it + * can end on a wall that was not there a second ago, and it stops the instant the trigger is released + * or the feed runs dry. So what is replicated is its current segment, and the client holds that + * segment until it is told otherwise or until it goes stale.

      + * + *

      Sent when it changed, and repeated so it cannot get stuck

      + *

      Sending every tick would put twenty packets a second per lit beam on every nearby connection for + * a picture that mostly does not change; sending only on change would leave a beam drawn forever on + * the client of a player whose "it went out" packet never arrived — because the gun was blown up, the + * chunk unloaded, or they were out of range at the moment it stopped. So both: a packet whenever the + * segment moves or the light goes on or off, plus a heartbeat every {@link #REFRESH_TICKS} ticks + * while it burns. The client drops a beam it has not heard about for a while, which is what makes the + * heartbeat the thing keeping it alive rather than a decoration.

      + * + *

      Peak, not average: a beam whose aim is moving costs one packet per nearby player per + * tick; one held steady on a spot costs one per nearby player per {@link #REFRESH_TICKS} ticks. The + * heartbeats of different guns are spread by a phase taken from each gun's own position, so a + * broadside that lit on the same tick does not pulse on the same tick forever.

      + * + *

      The channel belongs to the gun

      + *

      There is no registry of live beams and no static map keyed by position: a beam's owner is the + * gun holding it — the emission has no existence apart from that — so the little state this needs, + * namely what the client was last told, lives in a {@link Channel} the gun keeps. A gun that unloads + * takes its channel with it, which is exactly the lifetime a beam has.

      + */ +public final class BeamReplication { + + /** Ticks between heartbeats for a beam that is burning without changing. */ + static final int REFRESH_TICKS = 10; + + /** How far either end must move before the change is worth a packet of its own, in blocks. */ + static final double MOVE_EPSILON = 0.2D; + + private BeamReplication() { + } + + /** + * What one gun has told the players around it about its beam. + * + *

      Not persisted and not synchronised: it records packets SENT, so a copy that is lost costs + * one redundant packet and nothing else.

      + */ + public static final class Channel { + + /** Whether the last thing said was "it is burning". */ + private boolean announcedLit; + private Vec3d announcedFrom; + private Vec3d announcedTo; + + /** + * Say what the beam is doing this tick, if it is worth saying. + * + *

      Cheap to call every tick for a gun that has no beam at all: a dark gun already + * announced dark costs one boolean test.

      + */ + public void update(World world, final BlockPos gun, final Vec3d from, final Vec3d to, + boolean lit) { + if (world == null || world.isRemote || gun == null) { + return; + } + final boolean burning = lit && from != null && to != null; + Vec3d lastFrom = announcedFrom; + Vec3d lastTo = announcedTo; + if (!offer(world.getTotalWorldTime(), phaseOf(gun), burning, from, to)) { + // The common case by a wide margin — an idle gun, or a steady beam between + // heartbeats — so nothing above this line may allocate. + return; + } + // Announced along the line it occupies NOW, or — going out — along the line it last + // occupied: those are the players holding a drawing of it, and nobody else has anything + // to correct. A gun that never lit falls back to its own block. + Vec3d near = burning ? from : firstNonNull(lastFrom, centre(gun)); + Vec3d far = burning ? to : firstNonNull(lastTo, centre(gun)); + ProximityBroadcast.sendNearSegment(world, near, far, + ARConfiguration.getCurrentConfig().shotVisibilityRadius, + new Supplier() { + @Override + public PacketBeamState get() { + return PacketBeamState.of(gun, from, to, burning); + } + }); + } + + /** + * The decision and the record of it: should this tick's state go out, and if so, remember + * that it did. + * + *

      Separated from the sending so the state machine can be driven without a world. The + * rules ARE the mechanic — silence while dark, a packet on every transition, a packet when + * the segment moves, and a heartbeat while it burns so that no client's copy of a beam can + * outlive the beam itself.

      + * + * @param time the world tick, which the heartbeat is counted against + * @param phase this gun's heartbeat offset, so that guns do not beat in unison + */ + public boolean offer(long time, int phase, boolean lit, Vec3d from, Vec3d to) { + boolean send = decide(time, phase, lit, from, to); + if (send) { + announcedLit = lit; + announcedFrom = lit ? from : null; + announcedTo = lit ? to : null; + } + return send; + } + + private boolean decide(long time, int phase, boolean lit, Vec3d from, Vec3d to) { + if (!lit) { + // Nothing to say about a beam that was already dark last time anybody was told. + return announcedLit; + } + if (!announcedLit) { + return true; + } + if (moved(announcedFrom, from) || moved(announcedTo, to)) { + return true; + } + return Math.floorMod(time + phase, (long) REFRESH_TICKS) == 0L; + } + + private static boolean moved(Vec3d was, Vec3d now) { + if (was == null || now == null) { + return was != now; + } + return was.squareDistanceTo(now) > MOVE_EPSILON * MOVE_EPSILON; + } + } + + /** + * A stable, well-spread heartbeat phase for one gun. + * + *

      Taken from the gun's own position rather than from a counter: a battery whose guns all lit + * on the same tick would otherwise heartbeat on the same tick for as long as they burn, which is + * the peak the period was chosen to avoid.

      + */ + public static int phaseOf(BlockPos gun) { + return (int) Math.floorMod(gun.toLong() * 2654435761L, (long) REFRESH_TICKS); + } + + private static Vec3d firstNonNull(Vec3d first, Vec3d fallback) { + return first == null ? fallback : first; + } + + private static Vec3d centre(BlockPos pos) { + return new Vec3d(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ProximityBroadcast.java b/src/main/java/zmaster587/advancedRocketry/projectile/ProximityBroadcast.java new file mode 100644 index 000000000..8c0f0da6e --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ProximityBroadcast.java @@ -0,0 +1,76 @@ +package zmaster587.advancedRocketry.projectile; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import zmaster587.libVulpes.network.BasePacket; +import zmaster587.libVulpes.network.PacketHandler; + +import java.util.function.Supplier; + +/** + * Tell the players who could actually see a thing about it, and nobody else. + * + *

      Geometry, not a subscription

      + *

      Weapon fire is server state with no entity and no chunk behind it, so a client draws only what + * it was told about. Telling everybody would put a battery's whole rate of fire on every connection + * in the world, including players on the far side of a planet; the test is therefore how near the + * player is to the LINE the thing occupies — a round's forward path, a beam's lit length — rather + * than to its origin. A muzzle-distance filter gets the case that matters most exactly backwards: + * the person being shot at from four kilometres away is the one who most needs to see it.

      + * + *

      Built once, sent many times

      + *

      The packet is a {@link Supplier} because most calls tell nobody: in an empty region the loop + * runs and nothing is ever constructed. It is built on the first player who passes the test and + * reused for the rest.

      + */ +final class ProximityBroadcast { + + private ProximityBroadcast() { + } + + /** + * Send to every player whose position lies within {@code radius} of the segment {@code from..to}. + * + *

      A radius of zero sends to nobody — that is how the config switch turns a drawing channel + * off without touching the mechanic that feeds it.

      + */ + static void sendNearSegment(World world, Vec3d from, Vec3d to, int radius, + Supplier packet) { + if (world == null || world.isRemote || from == null || to == null || radius <= 0) { + return; + } + double radiusSq = (double) radius * radius; + BasePacket built = null; + for (EntityPlayer player : world.playerEntities) { + if (!(player instanceof EntityPlayerMP)) { + continue; + } + if (distanceSqToSegment(player.posX, player.posY, player.posZ, from, to) > radiusSq) { + continue; + } + if (built == null) { + built = packet.get(); + } + PacketHandler.sendToPlayer(built, (EntityPlayerMP) player); + } + } + + /** Squared distance from a point to the segment {@code from..to}. */ + static double distanceSqToSegment(double px, double py, double pz, Vec3d from, Vec3d to) { + double dx = to.x - from.x; + double dy = to.y - from.y; + double dz = to.z - from.z; + double lengthSq = dx * dx + dy * dy + dz * dz; + double t = 0.0D; + if (lengthSq > 1.0E-9D) { + t = ((px - from.x) * dx + (py - from.y) * dy + (pz - from.z) * dz) / lengthSq; + t = Math.max(0.0D, Math.min(1.0D, t)); + } + double cx = from.x + dx * t - px; + double cy = from.y + dy * t - py; + double cz = from.z + dz * t - pz; + return cx * cx + cy * cy + cz * cz; + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java index c2f110604..161de0db0 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotReplication.java @@ -1,7 +1,5 @@ package zmaster587.advancedRocketry.projectile; -import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.ARConfiguration; @@ -9,7 +7,8 @@ import zmaster587.advancedRocketry.api.projectile.ShotSpec; import zmaster587.advancedRocketry.network.PacketShotEnd; import zmaster587.advancedRocketry.network.PacketShotSpawn; -import zmaster587.libVulpes.network.PacketHandler; + +import java.util.function.Supplier; /** * Who gets told about a round, and who does not. @@ -42,29 +41,21 @@ private ShotReplication() { } /** Tell everybody whose view the round will pass through. */ - public static void announceSpawn(World world, long id, ShotSpec spec) { - int radius = ARConfiguration.getCurrentConfig().shotVisibilityRadius; - if (world == null || world.isRemote || spec == null || radius <= 0) { + public static void announceSpawn(World world, final long id, final ShotSpec spec) { + if (world == null || world.isRemote || spec == null) { return; } Vec3d origin = spec.getOrigin(); int horizon = Math.min(spec.getLifetimeTicks(), PATH_HORIZON_TICKS); Vec3d far = origin.add(spec.getVelocity().scale(horizon)); - double radiusSq = (double) radius * radius; - - PacketShotSpawn packet = null; - for (EntityPlayer player : world.playerEntities) { - if (!(player instanceof EntityPlayerMP)) { - continue; - } - if (distanceSqToSegment(player.posX, player.posY, player.posZ, origin, far) > radiusSq) { - continue; - } - if (packet == null) { - packet = PacketShotSpawn.of(id, spec); - } - PacketHandler.sendToPlayer(packet, (EntityPlayerMP) player); - } + ProximityBroadcast.sendNearSegment(world, origin, far, + ARConfiguration.getCurrentConfig().shotVisibilityRadius, + new Supplier() { + @Override + public PacketShotSpawn get() { + return PacketShotSpawn.of(id, spec); + } + }); } /** @@ -72,44 +63,19 @@ public static void announceSpawn(World world, long id, ShotSpec spec) { * was told about the launch: a player far enough away to be out of range here cannot see the * impact either, and their own copy of the round ages out on its stated lifetime. */ - public static void announceEnd(World world, long id, Vec3d point, ShotEndReason reason) { - int radius = ARConfiguration.getCurrentConfig().shotVisibilityRadius; - if (world == null || world.isRemote || point == null || radius <= 0) { + public static void announceEnd(World world, final long id, final Vec3d point, + final ShotEndReason reason) { + if (world == null || world.isRemote || point == null) { return; } - double radiusSq = (double) radius * radius; - PacketShotEnd packet = null; - for (EntityPlayer player : world.playerEntities) { - if (!(player instanceof EntityPlayerMP)) { - continue; - } - double dx = player.posX - point.x; - double dy = player.posY - point.y; - double dz = player.posZ - point.z; - if (dx * dx + dy * dy + dz * dz > radiusSq) { - continue; - } - if (packet == null) { - packet = PacketShotEnd.of(id, point, reason); - } - PacketHandler.sendToPlayer(packet, (EntityPlayerMP) player); - } + ProximityBroadcast.sendNearSegment(world, point, point, + ARConfiguration.getCurrentConfig().shotVisibilityRadius, + new Supplier() { + @Override + public PacketShotEnd get() { + return PacketShotEnd.of(id, point, reason); + } + }); } - /** Squared distance from a point to the segment {@code from..to}. */ - static double distanceSqToSegment(double px, double py, double pz, Vec3d from, Vec3d to) { - double dx = to.x - from.x; - double dy = to.y - from.y; - double dz = to.z - from.z; - double lengthSq = dx * dx + dy * dy + dz * dz; - double t = 0.0D; - if (lengthSq > 1.0E-9D) { - t = ((px - from.x) * dx + (py - from.y) * dy + (pz - from.z) * dz) / lengthSq; - t = Math.max(0.0D, Math.min(1.0D, t)); - } - double cx = from.x + dx * t - px; - double cy = from.y + dy * t - py; - double cz = from.z + dz * t - pz; - return cx * cx + cy * cy + cz * cz; - } } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index 8a6143639..adef94de0 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -20,6 +20,7 @@ import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.sensor.TargetTrack; import zmaster587.advancedRocketry.api.weapon.GunSpec; +import zmaster587.advancedRocketry.projectile.BeamReplication; import zmaster587.advancedRocketry.projectile.HeldBeam; import zmaster587.advancedRocketry.api.weapon.TurretDriveState; import zmaster587.advancedRocketry.damage.DamageState; @@ -150,6 +151,9 @@ public void update() { // for it. Firing is a separate, deliberate act — see fireOnce. mechanism.tick(spec.getTraverseDegreesPerTick()); syncCommandIfChanged(); + // Nothing under this hand lights a beam, so a gun taken over while burning is a gun that + // has stopped: said out loud, or the last state it reported would stand forever. + extinguishBeam(); return; } @@ -175,7 +179,10 @@ public void update() { holdBeam(onTarget && !isHoldingFire(), shipId); return; } + // Rebuilt into a thrower while it was burning: the light goes out, and whoever was watching + // is told so, exactly as if the trigger had been released. beamLit = false; + beamChannel.update(world, pos, beamStartedAt, beamEndedAt, false); if (!onTarget || isHoldingFire() || !canFireNow()) { return; @@ -229,28 +236,38 @@ private void readOwnCondition() { * cannot tell "not shooting" from "cannot shoot yet".

      */ private void holdBeam(boolean wantsToFire, String shipId) { + beamLit = burnOneTick(wantsToFire, shipId); + // Told here and nowhere else, so every way of NOT burning — no trigger, too hot, saving up, + // no line of fire — reaches the players watching by the same road as burning does. + beamChannel.update(world, pos, beamStartedAt, beamEndedAt, beamLit); + } + + /** + * Burn for one tick if everything allows it, and answer whether the beam is lit. + * + *

      Every refusal is a {@code false} rather than a silent return: what a player sees is decided + * from the answer, and a path that ended without saying so would leave a beam drawn on a gun that + * had stopped firing.

      + */ + private boolean burnOneTick(boolean wantsToFire, String shipId) { int perTick = spec.getBeamPowerPerTick(); if (!wantsToFire || perTick <= 0) { - beamLit = false; - return; + return false; } if (heat >= spec.getHeatCapacity()) { - beamLit = false; - return; + return false; } int quantum = perTick * BEAM_QUANTUM_TICKS; if (beamRecharging) { if (energy.getEnergyStored() < quantum) { - beamLit = false; - return; + return false; } beamRecharging = false; } else if (energy.getEnergyStored() < perTick) { // The feed could not keep up. Go dark and start saving rather than sputtering. beamRecharging = true; - beamLit = false; markDirty(); - return; + return false; } // The SAME muzzle a round leaves from, and the same refusal when the line of fire is not @@ -259,19 +276,19 @@ private void holdBeam(boolean wantsToFire, String shipId) { TurretFireControl.Muzzle muzzle = TurretFireControl.muzzleOf(world, pos, shipId, mechanism.getAimDirection(), spec, assemblyReach, random); if (muzzle == null) { - beamLit = false; - return; + return false; } HeldBeam.Emission emission = HeldBeam.emit(world, muzzle.point, muzzle.direction, BEAM_RANGE_BLOCKS, perTick, spec.getKind(), spec.getProjectileRadius(), shipId); energy.extractEnergy(perTick, false); heat += spec.getHeatPerShot(); - beamLit = true; + beamStartedAt = muzzle.point; beamEndedAt = emission.endedAt; if (emission.hitSomething()) { shotsFired++; } markDirty(); + return true; } /** @@ -291,8 +308,15 @@ private void holdBeam(boolean wantsToFire, String shipId) { private boolean beamLit; /** Dark and saving up, because the feed could not keep up. Persisted — it is a real refusal. */ private boolean beamRecharging; - /** Where the beam ended last time it was lit; for instruments and, later, for drawing it. */ + /** Where the beam left the gun last time it was lit — the muzzle, in world coordinates. */ + private Vec3d beamStartedAt; + /** Where the beam ended last time it was lit; for instruments and for drawing it. */ private Vec3d beamEndedAt; + /** + * What the players nearby have been told about this gun's beam. Owned by the gun because the + * beam is: there is no register of live beams to look one up in. + */ + private final BeamReplication.Channel beamChannel = new BeamReplication.Channel(); /** Is this gun burning right now? */ public boolean isBeamLit() { @@ -752,6 +776,7 @@ public int getConsumptionPerTick() { @Override public void invalidate() { super.invalidate(); + extinguishBeam(); SubsystemNetworkRegistry.unregister(this); if (world != null && !world.isRemote) { SubsystemNetworkManager.markDirty(WeaponNetworkDomain.INSTANCE, world); @@ -762,10 +787,24 @@ public void invalidate() { @Override public void onChunkUnload() { super.onChunkUnload(); + extinguishBeam(); SubsystemNetworkRegistry.unregister(this); registered = false; } + /** + * Put out whatever was being drawn for this gun. + * + *

      A gun that is blown up or unloaded stops burning without any tick in which to say so, and + * the client would otherwise hold the last segment it was sent until it went stale. The staleness + * timeout is still the backstop — this is only the fast path, and it is the one that runs when + * somebody breaks the gun in front of you.

      + */ + private void extinguishBeam() { + beamLit = false; + beamChannel.update(world, pos, beamStartedAt, beamEndedAt, false); + } + // ---- linker: the no-network way to give a gun a target @Override diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/BeamReachesClientE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/BeamReachesClientE2ETest.java new file mode 100644 index 000000000..065f7bd42 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/BeamReachesClientE2ETest.java @@ -0,0 +1,136 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Whether a burning beam is visible to the player standing next to the gun. + * + *

      Why this has to be a client test

      + *

      A held beam is not an entity, not a block and not a particle: it is a line the server resolves + * every tick and forgets. Vanilla replicates none of that, so "you can see the gun burning" is + * entirely a claim about a packet channel, and the only place that claim can be checked is a real + * client. What is asserted is what the renderer draws FROM — the client's own tracker of burning + * beams — because a renderer's output cannot be read from a test. Whether it LOOKS like a laser + * stays a human's judgement.

      + * + *

      The control is the half that makes it worth running: a beam burning four kilometres away must + * NOT arrive, or the filter that keeps every battery in the world off every connection is doing + * nothing.

      + * + *

      Gated by {@code forge.test.client.enabled=true}; auto-skips on headless CI.

      + */ +public class BeamReachesClientE2ETest extends AbstractClientE2ETest { + + private static final String TRACKER = "zmaster587.advancedRocketry.client.ClientBeamTracker"; + + private static final int DIM = 0; + private static final int Y = 84, Z = 300; + /** Where the player stands, and where the gun they should be able to see is built. */ + private static final int NEAR_X = 300; + /** Comfortably outside the default 256-block visibility radius. */ + private static final int FAR_X = NEAR_X + 4_000; + + /** How long a beam may take to light: the mount has to swing onto the target first. */ + private static final long LIGHT_TIMEOUT_MS = 45_000L; + + @Test + public void aBeamBurningNearbyIsDrawnByTheClientAndOneFourKilometresAwayIsNot() throws Exception { + // The control first, and it has to be first: the near gun keeps burning once it is lit, so + // "the client is drawing nothing" is only askable while the far gun is the only one alight. + buildBeamGun(FAR_X); + aimAlongTheWall(FAR_X); + for (int i = 0; i < 4; i++) { + charge(FAR_X); + bot().waitTicks(20); + } + assertTrue("the far gun never burned at all, so this run proved nothing about the filter: " + + read(FAR_X), everBurned(FAR_X)); + assertEquals("a beam burning four kilometres away was replicated to this client anyway — the" + + " visibility filter is not filtering, and every gun in the world would be drawn on" + + " every connection", 0, trackedBeams()); + + // Now the one the player is standing beside. + buildBeamGun(NEAR_X); + aimAlongTheWall(NEAR_X); + serverClient().execute("tp @a " + (NEAR_X + 4) + ".5 " + (Y + 1) + " " + (Z + 0.5D)); + bot().waitTicks(20); + + int drawn = 0; + long deadline = System.currentTimeMillis() + LIGHT_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline && drawn == 0) { + // Kept fed while we wait: a gun with no supply burns its buffer down and goes dark to + // save up, and the point here is the packet, not the duty cycle. + charge(NEAR_X); + bot().waitTicks(10); + drawn = trackedBeams(); + } + assertTrue("a beam burning twelve blocks from the player never reached the client: a held" + + " beam is a server-side line, so a client that is not told about one cannot draw" + + " it and the weapon fires invisibly. gun=" + read(NEAR_X), drawn >= 1); + } + + // ---- building + + /** The reference beam gun: a controller with emitters on it and cooling around it. */ + private void buildBeamGun(int bx) throws Exception { + exec("artest chunk warmup " + DIM + " " + ((bx - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + + ((bx + 48) >> 4) + " " + ((Z + 16) >> 4)); + exec("artest fill " + DIM + " " + (bx - 4) + " " + (Y - 2) + " " + (Z - 4) + " " + + (bx + 40) + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air"); + for (int cx = ((bx - 16) >> 4); cx <= ((bx + 40) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 3; i++) { + place("advancedrocketry:gunBeamEmitter", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + /** Something to burn into, and an order to burn into it. */ + private void aimAlongTheWall(int bx) throws Exception { + int wallX = bx + 20; + exec("artest fill " + DIM + " " + wallX + " " + Y + " " + Z + " " + (wallX + 5) + " " + Y + + " " + Z + " minecraft:iron_block"); + exec("artest turret target " + DIM + " " + bx + " " + Y + " " + Z + " " + (wallX + 0.5D) + + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + } + + // ---- reading + + private int trackedBeams() throws Exception { + return Integer.parseInt(bot().invokeStaticInt(TRACKER, "count").get("returned").getAsString()); + } + + /** + * Has this gun ever actually landed a tick of beam? A gun that never fired would make the + * control pass for the wrong reason — nothing to replicate is not the same as replication + * refusing to carry it. + */ + private boolean everBurned(int bx) throws Exception { + String state = read(bx); + return state.contains("\"beamLit\":true") || !state.contains("\"shots\":0"); + } + + private String read(int bx) throws Exception { + return exec("artest turret read " + DIM + " " + bx + " " + Y + " " + Z); + } + + private void charge(int bx) throws Exception { + exec("artest turret charge " + DIM + " " + bx + " " + Y + " " + Z); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private String exec(String command) throws Exception { + return String.join("\n", serverClient().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java new file mode 100644 index 000000000..daeaaa443 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java @@ -0,0 +1,165 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.junit.Test; +import zmaster587.advancedRocketry.client.ClientBeamTracker; +import zmaster587.advancedRocketry.projectile.BeamReplication; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * When a burning beam is worth a packet, and when silence is the right answer. + * + *

      A beam is replicated as a STATE rather than as events, which puts it between two failures that + * are both invisible in a dev world with one player in it: told too rarely and a client's drawing + * either lags the gun or blinks out while it is still burning; told every tick and one gun costs + * twenty packets a second on every nearby connection for a picture that is not changing. What is + * pinned here is the shape of the arrangement — never while dark, always on a change, and often + * enough that the client's own staleness timeout cannot fire under a beam that is still lit — and + * none of the periods, which are tuning.

      + */ +public class BeamReplicationCadenceTest { + + private static final BlockPos GUN = new BlockPos(100, 70, 100); + private static final Vec3d MUZZLE = new Vec3d(100.5D, 74.0D, 100.5D); + private static final Vec3d TARGET = new Vec3d(140.5D, 74.0D, 100.5D); + + /** Any phase will do for the cadence claims; the spread of phases is its own test below. */ + private static final int PHASE = 3; + + @Test + public void aDarkGunSaysNothingAtAll() { + BeamReplication.Channel channel = new BeamReplication.Channel(); + for (long tick = 0; tick < 200; tick++) { + assertFalse("a gun that is not burning, and was not burning last time anybody was told," + + " sent a packet on tick " + tick + " — every idle gun in the world would then" + + " be paying for a beam it does not have", + channel.offer(tick, PHASE, false, null, null)); + } + } + + @Test + public void theFirstTickOfBurningIsAnnouncedAtOnce() { + BeamReplication.Channel channel = new BeamReplication.Channel(); + assertTrue("the tick a beam lit was not announced: a client is told nothing else about a" + + " beam, so one that is not announced is one nobody can see", + channel.offer(0L, PHASE, true, MUZZLE, TARGET)); + } + + @Test + public void goingOutIsAnnouncedOnceAndThenTheGunIsQuietAgain() { + BeamReplication.Channel channel = new BeamReplication.Channel(); + channel.offer(0L, PHASE, true, MUZZLE, TARGET); + + assertTrue("the beam went out and nobody was told: the client would hold the last segment it" + + " was sent, drawing a beam from a gun that has stopped firing", + channel.offer(1L, PHASE, false, null, null)); + for (long tick = 2; tick < 60; tick++) { + assertFalse("the gun kept announcing that it is not burning, on tick " + tick, + channel.offer(tick, PHASE, false, null, null)); + } + } + + /** + * The two-sided arrangement, read from both sides rather than restated: the server's heartbeat + * is what keeps a client's copy alive, so the longest silence during an unchanging burn has to + * be shorter than the time after which the client drops the drawing. + */ + @Test + public void aSteadyBurnIsRepeatedOftenEnoughThatTheClientNeverDropsIt() { + BeamReplication.Channel channel = new BeamReplication.Channel(); + int longestSilence = 0; + int sinceSent = 0; + int sent = 0; + for (long tick = 0; tick < 400; tick++) { + if (channel.offer(tick, PHASE, true, MUZZLE, TARGET)) { + sent++; + longestSilence = Math.max(longestSilence, sinceSent); + sinceSent = 0; + } else { + sinceSent++; + } + } + longestSilence = Math.max(longestSilence, sinceSent); + + assertTrue("a beam held on one spot for twenty seconds went unmentioned for " + longestSilence + + " ticks, and the client drops one it has not heard about in " + + ClientBeamTracker.stalenessTicks() + " ticks: a beam that is still burning would" + + " blink out on every watching client", + longestSilence < ClientBeamTracker.stalenessTicks()); + assertTrue("a beam that never changed was announced on " + sent + " of 400 ticks: a state" + + " that is not changing is being sent as though it were", sent < 400 / 4); + } + + @Test + public void anAimThatIsMovingIsAnnouncedAsItMoves() { + BeamReplication.Channel channel = new BeamReplication.Channel(); + channel.offer(0L, PHASE, true, MUZZLE, TARGET); + + // The gun tracks a target across its front: one tick later the far end is metres away from + // where the client was told it was. + Vec3d swung = new Vec3d(TARGET.x, TARGET.y, TARGET.z + 4.0D); + assertTrue("the beam swung four blocks across and the client was not told: it would be drawn" + + " burning into whatever it was pointed at half a second ago", + channel.offer(1L, PHASE, true, MUZZLE, swung)); + + // And the muzzle itself moves when the gun is on a ship under way. + Vec3d carried = new Vec3d(MUZZLE.x + 3.0D, MUZZLE.y, MUZZLE.z); + assertTrue("the gun itself moved and the client was not told: a beam on a moving ship would" + + " hang in the air behind it", channel.offer(2L, PHASE, true, carried, swung)); + } + + /** + * A broadside that lit on the same tick must not heartbeat on the same tick. Same period, same + * average traffic, peak divided by the number of guns. + */ + @Test + public void gunsSittingSideBySideDoNotBeatInUnison() { + Set phases = new HashSet<>(); + for (int i = 0; i < 12; i++) { + phases.add(BeamReplication.phaseOf(new BlockPos(100 + i, 70, 100))); + } + assertTrue("twelve guns in a row share " + phases.size() + " heartbeat phase(s): a battery" + + " that lit together would put its whole refresh traffic into one tick in every" + + " period, which is the peak the period was chosen to avoid", phases.size() > 3); + } + + /** The tracker is the client's whole memory of what is burning; it starts and ends empty. */ + @Test + public void theClientDrawsNothingUntilItIsTold() { + ClientBeamTracker.clear(); + assertEquals("the client's beam tracker did not start empty", 0, ClientBeamTracker.count()); + ClientBeamTracker.lit(GUN.toLong(), MUZZLE, TARGET); + assertEquals("a beam the client was told about is not being drawn", 1, + ClientBeamTracker.count()); + ClientBeamTracker.extinguished(GUN.toLong()); + assertEquals("a beam the client was told had gone out is still being drawn", 0, + ClientBeamTracker.count()); + } + + /** + * The backstop for every way a beam can end without anybody being able to say so — the gun blown + * up, the chunk unloaded, the player out of range at the moment it stopped. + */ + @Test + public void aBeamNobodyMentionsAgainStopsBeingDrawn() { + ClientBeamTracker.clear(); + ClientBeamTracker.lit(GUN.toLong(), MUZZLE, TARGET); + for (int tick = 0; tick < ClientBeamTracker.stalenessTicks(); tick++) { + ClientBeamTracker.tick(); + } + assertEquals("a beam went undrawn while it was still being mentioned", 1, + ClientBeamTracker.count()); + ClientBeamTracker.tick(); + assertEquals("a beam nobody has mentioned since it lit is still being drawn: a gun destroyed" + + " mid-burn would leave a beam burning across the sky until the player relogged", 0, + ClientBeamTracker.count()); + ClientBeamTracker.clear(); + } +} From cbab4d80e0bd40a9b3ec5103ec850c3513a9564c Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 14:38:58 +0300 Subject: [PATCH 31/35] test: ask the allocator where the shipyard is, do not write it down - the literal named ordinary world coordinates after the region moved - the gun there assembled correctly and the test called that a regression --- .../test/server/TurretStandaloneE2ETest.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java index 4ccbbeba1..c0d6c3792 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TurretStandaloneE2ETest.java @@ -1,6 +1,7 @@ package zmaster587.advancedRocketry.test.server; import org.junit.Test; +import org.valkyrienskies.mod.common.ships.chunk_claims.ShipChunkAllocator; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -31,10 +32,17 @@ public class TurretStandaloneE2ETest extends AbstractSharedServerTest { private static final double SURFACE_GRAVITY_PER_TICK_SQUARED = 0.03D; /** - * Inside the region Valkyrien Skies allocates ship blocks in — its chunk allocator starts at - * chunk X 320000, so anything past block X ~5.12 million is shipyard. + * Inside the region Valkyrien Skies allocates ship blocks in, DERIVED from the allocator rather + * than written down here. + * + *

      It was a literal (block X 5 120 400) until the shipyard moved out to make room for the + * universe's cell bound, and the literal then named ordinary world coordinates: the gun placed + * there was not aboard anything, it assembled and ticked exactly as a gun on the ground should, + * and the test reported that as production having stopped waiting. Where the shipyard IS belongs + * to the allocator; what this test claims is only that a gun inside it does nothing.

      */ - private static final int SHIPYARD_X = 5_120_400; + private static final int SHIPYARD_X = + (ShipChunkAllocator.CHUNK_X_START << 4) + 400; @Test public void aGunWithNoNetworkFiresAtWhatItWasPointedAt() throws Exception { From 8a4ab5519145fe861c58180df45a9241ef3d49c4 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 20 Aug 2026 16:28:24 +0300 Subject: [PATCH 32/35] fix: a weapon asks before it takes a block, and off means ended - post a break event a protection mod can cancel, and honour the refusal - consult vanilla spawn protection directly; it is no listener - end the rounds still in the air when the substrate is switched off - a stand-in claim listener behind the test command, so the veto path is real --- .../api/projectile/ShotEndReason.java | 9 +- .../command/test/TestProbeCommand.java | 24 ++++ .../command/test/WeaponFireVetoProbe.java | 101 ++++++++++++++ .../damage/StructureDamageEngine.java | 57 ++++++++ .../projectile/ShotSubstrate.java | 31 ++++- .../WeaponFireAsksBeforeItTakesE2ETest.java | 125 ++++++++++++++++++ 6 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/command/test/WeaponFireVetoProbe.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java index 2e659ce2d..7c109bd0c 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java +++ b/src/main/java/zmaster587/advancedRocketry/api/projectile/ShotEndReason.java @@ -24,5 +24,12 @@ public enum ShotEndReason { STRUCTURE_IMPACT, /** Its world went away underneath it. */ - WORLD_UNLOADED + 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 10c542324..8e5aae329 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -1332,6 +1332,27 @@ private void handleDamage(MinecraftServer server, ICommandSender sender, String[ 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(); @@ -12202,6 +12223,9 @@ private void handleMachineTickUntil(MinecraftServer server, ICommandSender sende // off to pin that a valid rocket still assembles (no fuel-adequacy // gate) — the regression the weight-system merge introduced. "rocketRequireFuel", + // enableProjectileSubstrate: the off switch has to END what is in the air, + // not suspend it, and only a test that flips it at runtime can see that. + "enableProjectileSubstrate", // The telescope's reach and what a look costs in time, all read at scan START, // so flipping them at runtime is enough to exercise a short scan in a test // without waiting out a production-length observation. diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/WeaponFireVetoProbe.java b/src/main/java/zmaster587/advancedRocketry/command/test/WeaponFireVetoProbe.java new file mode 100644 index 000000000..2cb7353ea --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/test/WeaponFireVetoProbe.java @@ -0,0 +1,101 @@ +package zmaster587.advancedRocketry.command.test; + +import net.minecraft.util.math.BlockPos; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.world.BlockEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A stand-in for the protection mod nobody wants to install to run a test. + * + *

      What it exists to prove

      + *

      Weapon fire removes blocks by asking first — it posts a break event and honours a refusal. That + * contract is only worth anything if something can actually refuse, and everything that would refuse + * in production (a claim mod, a region plugin, an admin's listener) is a third party we do not ship. + * So this registers exactly what one of them registers: an ordinary subscriber to + * {@code BlockEvent.BreakEvent} that cancels for the positions it was told to guard.

      + * + *

      It is a listener, not a seam: nothing in the damage engine knows this class exists, and the + * path a test exercises through it is the same path a stranger's mod takes. Test-only, and reachable + * only through the {@code /artest} command, which the mod refuses to register without its test + * property.

      + */ +public final class WeaponFireVetoProbe { + + /** Guarded positions, per dimension. */ + private static final Map> GUARDED = new ConcurrentHashMap<>(); + + private static volatile boolean listening; + + private WeaponFireVetoProbe() { + } + + /** Guard, or stop guarding, one position. Answers how many are guarded in that dimension now. */ + public static int guard(int dimension, BlockPos pos, boolean guarded) { + listen(); + Set set = GUARDED.get(dimension); + if (set == null) { + set = Collections.newSetFromMap(new ConcurrentHashMap()); + Set raced = GUARDED.putIfAbsent(dimension, set); + if (raced != null) { + set = raced; + } + } + if (guarded) { + set.add(pos.toLong()); + } else { + set.remove(pos.toLong()); + } + return set.size(); + } + + /** Forget every guarded position, in every dimension. */ + public static void clear() { + GUARDED.clear(); + } + + /** How many positions are guarded in this dimension. */ + public static int count(int dimension) { + Set set = GUARDED.get(dimension); + return set == null ? 0 : set.size(); + } + + private static void listen() { + if (listening) { + return; + } + synchronized (WeaponFireVetoProbe.class) { + if (!listening) { + MinecraftForge.EVENT_BUS.register(new WeaponFireVetoProbe.Handler()); + listening = true; + } + } + } + + /** The subscriber itself, in its own type so the registration is an object like any other. */ + public static final class Handler { + + @SubscribeEvent + public void onBreak(BlockEvent.BreakEvent event) { + if (event.getWorld() == null || event.getWorld().isRemote || event.getPos() == null) { + return; + } + Set set = GUARDED.get(event.getWorld().provider.getDimension()); + if (set != null && set.contains(event.getPos().toLong())) { + event.setCanceled(true); + } + } + } + + /** The guarded set, copied, for a probe that wants to report it. */ + public static Set guarded(int dimension) { + Set set = GUARDED.get(dimension); + return set == null ? Collections.emptySet() : new HashSet<>(set); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index a431e0fb4..3f05cde0b 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -1,7 +1,16 @@ package zmaster587.advancedRocketry.damage; +import com.mojang.authlib.GameProfile; import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.player.EntityPlayer; import net.minecraft.init.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.WorldServer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.util.FakePlayerFactory; +import net.minecraftforge.event.world.BlockEvent; + +import java.util.UUID; import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; @@ -366,6 +375,15 @@ private static int spendInto(World world, BlockPos pos, IBlockState state, WalkR } if (stage >= maxStage) { + if (!mayRemove(world, pos, state)) { + // Something guards this block. It keeps the hit — the energy was spent and the + // damage is real — but it is not removed, and it stops one stage short of gone so + // that the next round asks again rather than finding a destroyed block standing. + DamageState.setStage(world, pos, Math.max(stageBefore, maxStage - 1)); + result.blocksStaged++; + result.touched.add(new Touched(pos, stageBefore, maxStage - 1, maxStage, spent, null)); + return spent; + } BlockDamageSavedData.get(world).recordDestroyed(pos, state.getBlock(), state.getBlock().getMetaFromState(state)); DamageState.setStage(world, pos, stage); @@ -575,4 +593,43 @@ public static final class WalkResult { public Vec3d entryPoint; public Vec3d exitPoint; } + + /** + * Ask the world whether this block may actually be removed, and let anything that guards it say + * no. + * + *

      Why a weapon has to ask at all

      + *

      Every protection system on this version — claims, regions, spawn protection, an admin's + * own listener — works by watching or cancelling a block-break event. A weapon that took blocks + * out with a bare {@code setBlockState} was invisible to all of them: not exempted by a decision + * anybody made, just never seen. On a server where players can build turrets that is the + * difference between a weapon and a way around the claim system, and the first person to find + * out is whoever loses a base.

      + * + *

      The break is attributed to a stable synthetic player rather than to the gun's owner, who is + * usually offline and may not exist: a protection mod needs something it can allow or deny by + * name, and one identity for all weapon fire is what makes that possible. A refusal is honoured + * exactly as it reads — the block stays.

      + */ + private static boolean mayRemove(World world, BlockPos pos, IBlockState state) { + if (!(world instanceof WorldServer)) { + return true; + } + EntityPlayer breaker = FakePlayerFactory.get((WorldServer) world, WEAPON_FIRE); + MinecraftServer server = world.getMinecraftServer(); + if (server != null && server.isBlockProtected(world, pos, breaker)) { + // Vanilla's own spawn protection, asked directly. It is not implemented as a listener, + // so a build that only posted the event would honour every third-party claim and none + // of the protection the game ships with. + return false; + } + return !MinecraftForge.EVENT_BUS.post(new BlockEvent.BreakEvent(world, pos, state, breaker)); + } + + /** + * Who weapon fire breaks blocks AS. A fixed name and a fixed id, because a protection mod's + * whitelist is written against them and a generated id would be a different player every boot. + */ + private static final GameProfile WEAPON_FIRE = new GameProfile( + UUID.fromString("b6ab6a37-2b1a-4b0a-9d9f-6e2f2f5f0a11"), "[weapon-fire]"); } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index a22b6cc57..9bd1e8c65 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -136,8 +136,11 @@ private static ShieldStrike strikeFor(Shot shot, Vec3d position, Vec3d direction /** Advance every shot in this world by one tick. Driven by {@link ShotSubstrateEvents}. */ public static void tick(World world) { - if (world == null || world.isRemote - || !ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + if (world == null || world.isRemote) { + return; + } + if (!ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + endWhatWasStillInTheAir(world); return; } ShotRegistry registry = ShotRegistry.get(world); @@ -159,6 +162,30 @@ public static void tick(World world) { registry.markDirty(); } + /** + * Empty this world's registry when the substrate is switched off. + * + *

      Off has to mean gone, not paused. The registry is world-saved data, so a round left + * sitting in it is written back on every save that follows, and switching the flag on again — a + * month later, on a world that has moved on — resumes it from wherever it was. A config flag + * that suspends its mechanic instead of ending it is not a way to turn the mechanic off.

      + * + *

      Ended one by one through the same path everything else uses, so the clients that were told + * about these rounds are told they are over rather than left drawing them until they age out.

      + */ + private static void endWhatWasStillInTheAir(World world) { + ShotRegistry registry = ShotRegistry.get(world); + if (registry.count() == 0) { + return; + } + for (Shot shot : registry.snapshot()) { + Vec3d endedAt = ShotFrame.worldPosition(world, shot); + registry.end(shot.getId(), ShotEndReason.SUBSTRATE_DISABLED, endedAt); + ShotReplication.announceEnd(world, shot.getId(), endedAt, ShotEndReason.SUBSTRATE_DISABLED); + } + registry.markDirty(); + } + /** * One tick of one shot: why it ended, or null if it is still in the air. * diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java new file mode 100644 index 000000000..ec2bee099 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java @@ -0,0 +1,125 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Two promises a server owner has to be able to rely on, neither of which the mechanic made on its + * own. + * + *

      A weapon asks before it takes a block

      + *

      Every protection system on this version - claims, regions, an admin's own listener - works by + * cancelling a block-break event. A weapon that removed blocks directly was invisible to all of + * them, so a turret was a way around the claim system rather than a weapon in it. What is pinned + * here is the refusal: a guarded block that is fired on keeps standing, and the same block + * unguarded does not.

      + * + *

      An off switch ends what is in the air

      + *

      The shot registry is world-saved data. A switch that stopped stepping rounds without ending + * them left them in the save, to resume whenever it was switched back on - a pause wearing the name + * of an off switch.

      + */ +public class WeaponFireAsksBeforeItTakesE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int X = 9800, Y = 82, Z = 9800; + + @Test + public void aGuardedBlockSurvivesTheHitThatTakesTheUnguardedOneBesideIt() throws Exception { + prepare(); + + // Two identical blocks, one of them spoken for. + place(X, "minecraft:stone"); + place(X + 4, "minecraft:stone"); + String guarded = exec("artest damage guard " + DIM + " " + X + " " + Y + " " + Z + " true"); + assertTrue("the veto listener refused to take the position: " + guarded, + guarded.contains("\"ok\":true")); + + // The same energy into each, straight down the middle of the block. + shoot(X); + shoot(X + 4); + Thread.sleep(1_500L); + + String control = exec("artest damage stage " + DIM + " " + (X + 4) + " " + Y + " " + Z); + assertTrue("the UNGUARDED block survived the shot, so this run says nothing about the" + + " guarded one: " + control, gone(control)); + + String subject = exec("artest damage stage " + DIM + " " + X + " " + Y + " " + Z); + assertTrue("a guarded block was destroyed by weapon fire anyway: every claim, region and" + + " spawn protection on the server is bypassed by building a turret: " + subject, + !gone(subject)); + + exec("artest damage unguard-all"); + } + + @Test + public void switchingTheSubstrateOffEndsTheRoundsAlreadyInTheAir() throws Exception { + prepare(); + try { + // Straight up, with a long life: it will still be flying when the switch is thrown. + String fired = exec("artest shot fire " + DIM + " " + (X + 20) + " " + Y + " " + Z + + " 0 4 0 2000 400"); + long id = readLong(fired, "id"); + assertTrue("the launch was refused, so there is nothing in the air to end: " + fired, + id >= 0L); + String inAir = exec("artest shot read " + DIM + " " + id); + assertTrue("the round was not in the air a tick after it was fired: " + inAir, + inAir.contains("\"present\":true")); + + exec("artest config set enableProjectileSubstrate false"); + Thread.sleep(1_000L); + + String after = exec("artest shot read " + DIM + " " + id); + assertTrue("a round left in the air when the substrate was switched off is still in the" + + " registry: the switch suspends the mechanic instead of ending it, and the" + + " round is written back into the save on every tick that follows: " + after, + after.contains("\"present\":false")); + assertTrue("the round ended, but for the wrong reason - it should say the substrate was" + + " switched off under it: " + after, after.contains("SUBSTRATE_DISABLED")); + } finally { + exec("artest config set enableProjectileSubstrate true"); + } + } + + // ---- driving + + private void prepare() throws Exception { + exec("artest chunk warmup " + DIM + " " + ((X - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + + ((X + 32) >> 4) + " " + ((Z + 16) >> 4)); + exec("artest fill " + DIM + " " + (X - 2) + " " + (Y - 2) + " " + (Z - 2) + " " + (X + 30) + + " " + (Y + 6) + " " + (Z + 2) + " minecraft:air"); + for (int cx = ((X - 16) >> 4); cx <= ((X + 32) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + } + + private void place(int x, String block) throws Exception { + exec("artest fill " + DIM + " " + x + " " + Y + " " + Z + " " + x + " " + Y + " " + Z + + " " + block); + } + + /** A round with enough energy to take a stone block out in one arrival, fired from close range. */ + private void shoot(int targetX) throws Exception { + exec("artest shot fire " + DIM + " " + (targetX - 6) + " " + Y + " " + Z + + " 4 0 0 2000000 200"); + } + + /** Has this position been emptied - destroyed outright, or recorded as destroyed? */ + private static boolean gone(String stageJson) { + return stageJson.contains("\"wasDestroyed\":true") + || stageJson.contains("\"block\":\"minecraft:air\""); + } + + private static long readLong(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} From 4f8664a52eb91980ab5000520ef04694d5cea805 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 21 Aug 2026 07:58:31 +0300 Subject: [PATCH 33/35] fix: the war switch, the assets nobody could see, and the columns nobody kept - one enableWeapons gate for firing, acquisition and weapon damage - a beam asks the gate too, and a refused shot costs no heat - weapon fire asks protection before removing armour plating - six model assets renamed to the case a jar looks them up in - the beam emitter gets a blockstate, a recipe and a name - weights.json writes back every column it reads - console and sensor readouts go through the lang catalogue - partsWearSystem describes what it actually gates --- .../advancedRocketry/api/ARConfiguration.java | 32 ++- .../block/BlockMirrorPlating.java | 5 +- .../block/BlockReactivePlating.java | 7 +- .../command/test/TestProbeCommand.java | 7 +- .../damage/StructureDamageEngine.java | 24 +++ .../item/ItemRepairWelder.java | 20 +- .../advancedRocketry/projectile/HeldBeam.java | 29 ++- .../projectile/ShotSubstrate.java | 4 +- .../tile/sensor/TileFireControlSensor.java | 37 +++- .../tile/weapon/TileTurret.java | 35 +++- .../tile/weapon/TileWeaponConsole.java | 63 ++++-- .../advancedRocketry/util/StorageChunk.java | 8 +- .../advancedRocketry/util/WeightEngine.java | 15 +- .../blockstates/gunbeamemitter.json | 18 ++ ...inium.json => mirrorplatingaluminium.json} | 0 ...latingGold.json => mirrorplatinggold.json} | 0 ...ngSilver.json => mirrorplatingsilver.json} | 0 ...{reactiveBlock.json => reactiveblock.json} | 0 ...{reactivePlate.json => reactiveplate.json} | 0 .../assets/advancedrocketry/lang/en_US.lang | 25 +++ .../{repairWelder.json => repairwelder.json} | 0 .../recipes/gunbeamemitter.json | 30 +++ .../TheWarSwitchesOffAndOnAgainE2ETest.java | 167 ++++++++++++++++ .../WeaponFireAsksBeforeItTakesE2ETest.java | 4 +- .../test/unit/ARConfigurationTest.java | 63 ++++++ .../test/unit/LangKeyCrossReferenceTest.java | 3 + .../unit/ModelAssetsAreAddressableTest.java | 141 +++++++++++++ .../test/unit/WeightEngineUnitTest.java | 187 ++++++++++++++++++ 28 files changed, 869 insertions(+), 55 deletions(-) create mode 100644 src/main/resources/assets/advancedrocketry/blockstates/gunbeamemitter.json rename src/main/resources/assets/advancedrocketry/blockstates/{mirrorPlatingAluminium.json => mirrorplatingaluminium.json} (100%) rename src/main/resources/assets/advancedrocketry/blockstates/{mirrorPlatingGold.json => mirrorplatinggold.json} (100%) rename src/main/resources/assets/advancedrocketry/blockstates/{mirrorPlatingSilver.json => mirrorplatingsilver.json} (100%) rename src/main/resources/assets/advancedrocketry/blockstates/{reactiveBlock.json => reactiveblock.json} (100%) rename src/main/resources/assets/advancedrocketry/blockstates/{reactivePlate.json => reactiveplate.json} (100%) rename src/main/resources/assets/advancedrocketry/models/item/{repairWelder.json => repairwelder.json} (100%) create mode 100644 src/main/resources/assets/advancedrocketry/recipes/gunbeamemitter.json create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/TheWarSwitchesOffAndOnAgainE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/ModelAssetsAreAddressableTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 0b02ba9b0..1b3467daa 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -139,6 +139,7 @@ public class ARConfiguration { public int dilithiumPerChunk; @ConfigProperty public int dilithiumPerChunkMoon; + @ConfigProperty public int aluminumPerChunk; @ConfigProperty public int aluminumClumpSize; @@ -426,12 +427,28 @@ public class ARConfiguration { @ConfigProperty(needsSync = true) public int repairWelderCapacity = 100000; /** - * Whether shots exist as tracked records at all. With this off nothing is admitted to a world's - * registry and nothing already there is stepped, so a weapon built on the substrate fires and - * nothing travels — which is the whole of the mechanic gone, not half of it. + * 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 enableProjectileSubstrate = 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 @@ -440,6 +457,7 @@ public class ARConfiguration { */ @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 @@ -815,7 +833,7 @@ 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(); @@ -825,7 +843,7 @@ public static void loadPreInit() { 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.enableProjectileSubstrate = config.get(WEAPONS, "enableProjectileSubstrate", true, "Track fired shots as server-side records that fly across loaded and unloaded space alike. Turn off to disable long-range fire entirely: nothing is admitted and nothing in flight is stepped").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(); @@ -845,7 +863,7 @@ public static void loadPreInit() { 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, "Enable rocket part wear and exploding chance.").getBoolean(); + 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/block/BlockMirrorPlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java index 5fe5fff8e..b7e0f0b68 100644 --- a/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java @@ -2,6 +2,7 @@ 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; @@ -107,7 +108,9 @@ private static boolean isRadiant(ImpactKind kind) { */ private void burnOut(World world, BlockPos pos) { if (world != null && !world.isRemote && pos != null) { - world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + // 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/BlockReactivePlating.java b/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java index 87f830c5b..d130b2e63 100644 --- a/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockReactivePlating.java @@ -2,6 +2,7 @@ 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; @@ -74,7 +75,11 @@ public ContactResult onContact(World world, Contact contact) { */ private void detonate(World world, BlockPos pos) { if (world != null && !world.isRemote && pos != null) { - world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + // 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/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 8e5aae329..8f2fb10a1 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -537,6 +537,7 @@ private void handleTurret(MinecraftServer server, ICommandSender sender, String[ // 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() @@ -634,7 +635,7 @@ private void handleWeaponConsole(MinecraftServer server, ICommandSender sender, net.minecraft.util.math.Vec3d target = console.getTarget(); send(sender, "{\"ok\":true" + ",\"network\":" + (console.network() != null) - + ",\"status\":\"" + escapeJson(console.getNetworkStatusText()) + "\"" + + ",\"status\":\"" + escapeJson(console.getNetworkStatusToken()) + "\"" + ",\"guns\":" + console.getGunCount() + ",\"onTarget\":" + console.getMountTelemetry()[0] + ",\"saturated\":" + console.getMountTelemetry()[1] @@ -12223,9 +12224,9 @@ private void handleMachineTickUntil(MinecraftServer server, ICommandSender sende // off to pin that a valid rocket still assembles (no fuel-adequacy // gate) — the regression the weight-system merge introduced. "rocketRequireFuel", - // enableProjectileSubstrate: the off switch has to END what is in the air, + // enableWeapons: the off switch has to END what is in the air, // not suspend it, and only a test that flips it at runtime can see that. - "enableProjectileSubstrate", + "enableWeapons", // The telescope's reach and what a look costs in time, all read at scan START, // so flipping them at runtime is enough to exercise a short scan in a test // without waiting out a production-length observation. diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 3f05cde0b..9d5287408 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -611,6 +611,30 @@ public static final class WalkResult { * name, and one identity for all weapon fire is what makes that possible. A refusal is honoured * exactly as it reads — the block stays.

      */ + /** + * Remove a block as WEAPON FIRE, asking first — the entry point for anything that destroys a + * block outside the budget walk. + * + *

      Armour that consumes itself is the case this exists for: a reactive charge going off and a + * mirror film burning out are both weapon fire taking a block, and both did it with a bare + * {@code setBlockState}, which meant the new armour was the one content in the mod a claim mod + * could not protect. A destruction is a destruction whoever performs it.

      + * + * @return whether the block was actually removed; {@code false} means something refused, and the + * block is still standing + */ + public static boolean removeIfAllowed(World world, BlockPos pos) { + if (world == null || pos == null || world.isRemote) { + return false; + } + IBlockState state = world.getBlockState(pos); + if (!mayRemove(world, pos, state)) { + return false; + } + world.setBlockState(pos, Blocks.AIR.getDefaultState(), 3); + return true; + } + private static boolean mayRemove(World world, BlockPos pos, IBlockState state) { if (!(world instanceof WorldServer)) { return true; diff --git a/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java b/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java index a40060b77..61a48e06b 100644 --- a/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java +++ b/src/main/java/zmaster587/advancedRocketry/item/ItemRepairWelder.java @@ -85,13 +85,25 @@ public static Outcome weld(EntityPlayer player, World world, BlockPos pos, ItemS if (stage <= 0) { return Outcome.UNDAMAGED; } + boolean free = player.capabilities.isCreativeMode; + if (free) { + // Creative repairs anything, including a block that nothing crafts. The price of a + // repair is a fraction of the block's own recipe, so a block with no recipe has no + // price — and the shield and armour families have no recipes yet, which would otherwise + // make a shot-up shield generator permanently damaged with no path back even in + // creative. Charging nothing for nothing is the one reading of that which is not a + // refusal. + DamageState.setStage(world, pos, stage - 1); + world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 3); + return Outcome.REPAIRED; + } + List cost = RepairCost.perStage(world, pos); if (cost == null) { return Outcome.NO_RECIPE; } - boolean free = player.capabilities.isCreativeMode; int energyCost = ARConfiguration.getCurrentConfig().repairWelderEnergyPerStage; - if (!free && storedEnergy(tool) < energyCost) { + if (storedEnergy(tool) < energyCost) { return Outcome.NO_CHARGE; } if (!RepairCost.consume(player, cost, true)) { @@ -99,9 +111,7 @@ public static Outcome weld(EntityPlayer player, World world, BlockPos pos, ItemS } RepairCost.consume(player, cost, false); - if (!free) { - setStoredEnergy(tool, storedEnergy(tool) - energyCost); - } + setStoredEnergy(tool, storedEnergy(tool) - energyCost); DamageState.setStage(world, pos, stage - 1); world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 3); return Outcome.REPAIRED; diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java index 0b6a08ce9..4a9994132 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java @@ -5,6 +5,7 @@ import com.github.stannismod.affs.world.shield.ShieldStrikeService; import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; +import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.api.damage.ImpactKind; import zmaster587.advancedRocketry.api.damage.TravellingBody; import zmaster587.advancedRocketry.damage.ImpactKindMapping; @@ -74,7 +75,12 @@ private HeldBeam() { public static Emission emit(World world, Vec3d muzzle, Vec3d direction, double reach, int powerThisTick, ImpactKind kind, double radius, String hullId) { if (world == null || world.isRemote || muzzle == null || direction == null - || powerThisTick <= 0 || reach <= 0.0D) { + || powerThisTick <= 0 || reach <= 0.0D + || !ARConfiguration.getCurrentConfig().enableWeapons) { + // The war switch is asked HERE and not only where a round is admitted. A held beam has no + // record and never passes through the registry, so a gate on the registry alone let a + // beam turret keep burning hulls on a server that had switched combat off — a switch + // covering half a mechanic, which reads as a promise and is worse than none. return new Emission(muzzle, 0.0D, false, false, Math.max(0, powerThisTick)); } double length = direction.lengthVector(); @@ -99,14 +105,27 @@ public static Emission emit(World world, Vec3d muzzle, Vec3d direction, double r ShieldStrike strike = new ShieldStrike(muzzle, unit, reach, powerThisTick, ImpactKindMapping.toShieldKind(kind), false, null); ShieldStrikeResult result = ShieldStrikeService.resolve(world, strike); - if (result.isIntercepted()) { + if (result.isFullyAbsorbed()) { + Vec3d at = result.getHitPoint() == null ? contact : result.getHitPoint(); + return new Emission(at, first.distance, true, false, 0); + } + // Either the shell paid nothing (it went down between the two questions) or it paid what + // it could and that was not enough. Both mean the same thing to a beam: what the shell + // could not buy carries on into whatever is behind it. + // + // ASKING THE WRONG QUESTION HERE INVERTED THE WHOLE LASER LINE. `isIntercepted` is true + // on an UNDERPAY as well as on a full stop, so a beam that overpowered a shell died at it + // — the exact opposite of the reason this weapon family exists: a beam whose power the + // shell cannot pay for is supposed to get through. `isFullyAbsorbed` is the question that + // means "the shell bought all of it". + int throughShell = Math.max(0, result.isIntercepted() + ? result.getResidualImpactEnergy() : powerThisTick); + if (throughShell <= 0) { Vec3d at = result.getHitPoint() == null ? contact : result.getHitPoint(); return new Emission(at, first.distance, true, false, 0); } - // The shell was crossed and paid nothing — it went down between the two questions. The - // beam carries on to whatever is behind it rather than stopping in mid-air. return emit(world, contact.add(unit.scale(CROSSING_EPSILON)), unit, - reach - first.distance - CROSSING_EPSILON, powerThisTick, kind, radius, hullId); + reach - first.distance - CROSSING_EPSILON, throughShell, kind, radius, hullId); } // Structure. The identity comes from the world's own counter, exactly as a shot's does: a beam diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 9bd1e8c65..5632fa565 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -69,7 +69,7 @@ private ShotSubstrate() { */ public static long launch(World world, ShotSpec spec) { if (world == null || world.isRemote || spec == null - || !ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + || !ARConfiguration.getCurrentConfig().enableWeapons) { return -1L; } long id = ShotRegistry.get(world).add(spec, ARConfiguration.getCurrentConfig().maxShotsPerWorld); @@ -139,7 +139,7 @@ public static void tick(World world) { if (world == null || world.isRemote) { return; } - if (!ARConfiguration.getCurrentConfig().enableProjectileSubstrate) { + if (!ARConfiguration.getCurrentConfig().enableWeapons) { endWhatWasStillInTheAir(world); return; } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java b/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java index b2dc15da6..9ba789190 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/sensor/TileFireControlSensor.java @@ -119,7 +119,11 @@ public void update() { // would be measured from the wrong point. Waiting is the only correct behaviour. return; } - if (!ARConfiguration.getCurrentConfig().enableFireControlSensor) { + if (!ARConfiguration.getCurrentConfig().enableWeapons + || !ARConfiguration.getCurrentConfig().enableFireControlSensor) { + // Two gates, one behaviour. The master says whether there is a war at all; the narrower + // one says whether batteries find their own targets in it. A pack may want the second + // without the first being in question, which is why both survive. // Switched off means OFF: no acquisition, nothing published, no power drawn and not even // a place in the network — a disabled sensor is not a node that quietly keeps its buffer // topped up. Anything it had already published expires on its own. @@ -443,21 +447,40 @@ public List getModules(int id, EntityPlayer player) { return modules; } + /** + * One readout line, translated where a translation exists. + * + *

      A whole sentence per key with its placeholders in it, never a label concatenated with a + * value: word order is not the same in every language, and a line assembled from fragments can + * only ever come out in English order. {@code getModules} runs on both sides — the client proxy + * translates and the common one hands the key straight back, which then formats to itself + * because a key carries no format specifiers.

      + */ + private static String readoutText(String key, Object... args) { + return String.format(LibVulpes.proxy.getLocalizedString(key), args); + } + private String modeLine() { - return "Mode: " + effectiveMode().name().toLowerCase(java.util.Locale.ROOT) - + (isUnderpowered() ? " (no power to illuminate)" : ""); + // Two literal keys rather than one assembled from the enum name: a key built by + // concatenation is invisible to the lang cross-reference scan, which is the only thing that + // would notice it going missing from a catalogue. + String mode = readoutText(effectiveMode() == SensorMode.ACTIVE + ? "msg.fireControlSensor.mode.active" : "msg.fireControlSensor.mode.passive"); + return isUnderpowered() ? readoutText("msg.fireControlSensor.line.modeUnderpowered", mode) + : readoutText("msg.fireControlSensor.line.mode", mode); } private String contactLine() { - return "Contacts: " + getContactCount(); + return readoutText("msg.fireControlSensor.line.contacts", getContactCount()); } private String lockLine() { if (getContactCount() <= 0) { - return "Lock: none"; + return readoutText("msg.fireControlSensor.line.lockNone"); } - return String.format("Lock: %.2f at %.0fm%s", getBestQuality(), getBestDistance(), - isBestLocked() ? "" : " (too poor to fire)"); + return readoutText(isBestLocked() ? "msg.fireControlSensor.line.lock" + : "msg.fireControlSensor.line.lockTooPoor", + getBestQuality(), getBestDistance()); } @Override diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index adef94de0..75cb2d69e 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -251,7 +251,10 @@ private void holdBeam(boolean wantsToFire, String shipId) { */ private boolean burnOneTick(boolean wantsToFire, String shipId) { int perTick = spec.getBeamPowerPerTick(); - if (!wantsToFire || perTick <= 0) { + if (!wantsToFire || perTick <= 0 || !ARConfiguration.getCurrentConfig().enableWeapons) { + // Asked here as well as under the muzzle: the emission itself refuses with the war off, + // but a gun that called it anyway would still pay the tick's energy and heat for a beam + // that never existed. return false; } if (heat >= spec.getHeatCapacity()) { @@ -318,6 +321,17 @@ private boolean burnOneTick(boolean wantsToFire, String shipId) { */ private final BeamReplication.Channel beamChannel = new BeamReplication.Channel(); + /** + * Is this gun mute because the server has combat switched off? + * + *

      A distinct answer beside "holding fire" and "nothing left to fire with", for the same + * reason those two are distinct: a gun that is disabled and a gun that is broken look identical + * from outside, and the old switch made every gun on the server look broken.

      + */ + public boolean isDisabledByConfig() { + return !ARConfiguration.getCurrentConfig().enableWeapons; + } + /** Is this gun burning right now? */ public boolean isBeamLit() { return beamLit; @@ -394,7 +408,8 @@ public boolean fireOnce() { /** Everything that must be true before a round leaves, other than pointing the right way. */ private boolean canFireNow() { - return !targetIsFriendly() + return ARConfiguration.getCurrentConfig().enableWeapons + && !targetIsFriendly() && isLockedWellEnoughToFire() && spec.isOperable() && mechanism.getDriveState().permitsFiring() @@ -670,7 +685,20 @@ public void setDriveState(TurretDriveState state) { markDirty(); } - /** Whose side it is on. Travels with every round it fires so an impact can be attributed. */ + /** + * Whose side it is on. Travels with every round it fires so an impact can be attributed. + * + *

      Nothing calls this yet, and nothing calls {@link #setOwner}. Both fields persist and + * both are stamped onto every round, but with no writer {@code owner} is always null and + * {@code faction} always falls back to the network's access code — which is therefore what + * actually marks a round as ours today. They are the seam a future attribution or permission + * layer would use; until one exists, saying so here is more honest than a field that looks + * wired.

      + * + *

      Commanding a gun is likewise unguarded on purpose: the console takes no player and asks + * nothing, the same as most machines in this game. Keeping strangers away from a battery is a + * protection mod's job, and one is already consulted before any block is taken.

      + */ public void setFaction(String faction) { this.faction = faction; markDirty(); @@ -680,6 +708,7 @@ public String getFaction() { return faction; } + /** Who built it. Unwired in the same way {@link #setFaction} is — see its note. */ public void setOwner(UUID owner) { this.owner = owner; markDirty(); diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java index 2611ddeed..030e7a51c 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileWeaponConsole.java @@ -244,26 +244,55 @@ public int[] getMountTelemetry() { } public String getNetworkStatusText() { + return readoutText(networkStatusKey()); + } + + /** + * The same status as a stable machine token ({@code balanced}, {@code powerLimited}, ...). + * + *

      Derived from the lang key rather than declared beside it, so there is one vocabulary and + * not two. A probe or a log wants an identifier that survives translation; a player wants a + * sentence in his own language. These are different needs and this is the first of them.

      + */ + public String getNetworkStatusToken() { + String key = networkStatusKey(); + return key.substring(key.lastIndexOf('.') + 1); + } + + private String networkStatusKey() { WeaponNetworkState state = network(); if (state == null) { - return "no network"; + return "msg.weaponConsole.status.noNetwork"; } switch (state.getStatus()) { case SubsystemNetworkStatus.DISCONNECTED: - return "disconnected"; + return "msg.weaponConsole.status.disconnected"; case SubsystemNetworkStatus.SOURCE_LIMITED: - return "power limited"; + return "msg.weaponConsole.status.powerLimited"; case SubsystemNetworkStatus.SINK_LIMITED: - return "idle"; + return "msg.weaponConsole.status.idle"; case SubsystemNetworkStatus.CABLE_LIMITED: - return "cable limited"; + return "msg.weaponConsole.status.cableLimited"; case SubsystemNetworkStatus.BALANCED: - return "balanced"; + return "msg.weaponConsole.status.balanced"; default: - return "unknown"; + return "msg.weaponConsole.status.unknown"; } } + /** + * One readout line, translated where a translation exists. + * + *

      A whole sentence per key with its placeholders in it, never a label concatenated with a + * value: word order is not the same in every language, and a line assembled from fragments can + * only ever come out in English order. {@code getModules} runs on both sides — the client proxy + * translates and the common one hands the key straight back, which then formats to itself + * because a key carries no format specifiers.

      + */ + private static String readoutText(String key, Object... args) { + return String.format(LibVulpes.proxy.getLocalizedString(key), args); + } + // ---- linker: the way a player names a target without typing coordinates @Override @@ -304,30 +333,34 @@ private void addReadout(List modules, int x, int y, String text) { } private String statusLine() { - return "Network: " + getNetworkStatusText() + (isHoldFire() ? " (holding fire)" : ""); + String status = readoutText(networkStatusKey()); + return isHoldFire() ? readoutText("msg.weaponConsole.line.networkHolding", status) + : readoutText("msg.weaponConsole.line.network", status); } private String gunLine() { int[] mounts = getMountTelemetry(); - return "Guns: " + getGunCount() + " on target: " + mounts[0] - + (mounts[1] > 0 ? " out of arc: " + mounts[1] : ""); + return mounts[1] > 0 + ? readoutText("msg.weaponConsole.line.gunsOutOfArc", getGunCount(), mounts[0], mounts[1]) + : readoutText("msg.weaponConsole.line.guns", getGunCount(), mounts[0]); } private String targetLine() { Vec3d target = getTarget(); - return target == null ? "Target: none" - : String.format("Target: %.0f, %.0f, %.0f", target.x, target.y, target.z); + return target == null ? readoutText("msg.weaponConsole.line.targetNone") + : readoutText("msg.weaponConsole.line.target", target.x, target.y, target.z); } private String sensorLine() { TargetTrack acquired = getAcquiredTrack(); if (acquired == null) { - return "Sensor: no contact"; + return readoutText("msg.weaponConsole.line.sensorNone"); } boolean locked = acquired.isLocked(ARConfiguration.getCurrentConfig() .fireControlSensorLockQualityToFire); - return String.format("Sensor: contact at %.0fm, lock %.2f%s", acquired.getDistance(), - acquired.getQuality(), locked ? "" : " (too poor to fire)"); + return readoutText(locked ? "msg.weaponConsole.line.sensor" + : "msg.weaponConsole.line.sensorTooPoor", + acquired.getDistance(), acquired.getQuality()); } @Override diff --git a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java index d2a2c4998..4cf4a1921 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java +++ b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java @@ -874,9 +874,11 @@ public void pasteInWorld(World world, int xCoord, int yCoord, int zCoord) { } public void damageParts() { - // Single gate for wear ACCRUAL. When the parts-wear system is disabled no - // part ever advances a wear stage, so a worn save loaded with the system - // off neither grows nor (combined with the gated consequences) bites. + // Single gate for wear ACCRUAL, and ONLY accrual. When the parts-wear system is + // disabled no part advances a stage from use, so a save stops getting worse — but + // the stages already on it keep every consequence they had. Wear and battle damage + // share one stage axis and nothing here can tell a long career from a shell, so + // gating the consequences would make a shot-up hull fly like a new one. if (!ARConfiguration.getCurrentConfig().partsWearSystem) { return; } diff --git a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java index d2715fc94..f35b125c7 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java @@ -378,7 +378,9 @@ public void load() { toughnessIndividual = readMap(gson, root, "toughnessIndividual", mapType); ablationIndividual = readMap(gson, root, "ablationIndividual", mapType); - ablationByRegex = readMap(gson, root, "ablationByRegex", mapType); + // linkedType, like every other regex column: matchRegex is first-match-wins, so the + // order the pack wrote its patterns in IS the precedence between overlapping patterns. + ablationByRegex = readMap(gson, root, "ablationByRegex", linkedType); toughnessByRegex = readMap(gson, root, "toughnessByRegex", linkedType); if (toughnessByRegex.isEmpty()) { toughnessByRegex = defaultToughnessByRegex(); @@ -418,6 +420,12 @@ private void seedDefaults() { toughnessByRegex = defaultToughnessByRegex(); toughnessMaterials = defaultToughnessMaterials(); toughnessFallback = 2.0; + // The ablation columns default to EMPTY rather than to a table: a block with no row here + // has its figure derived from its toughness. Empty is still a value that has to be + // written, though — leaving these alone would let a previous load's rows survive both a + // reset and the fallback taken when a config file cannot be read. + ablationIndividual = new HashMap<>(); + ablationByRegex = new LinkedHashMap<>(); } // ---- Runtime / test mutation hooks -------------------------------------- @@ -473,6 +481,11 @@ public void save() { json.addProperty("fluidFallback", fluidFallback); json.add("toughnessIndividual", gson.toJsonTree(toughnessIndividual)); json.add("toughnessByRegex", gson.toJsonTree(toughnessByRegex)); + // Every key load() reads is written back. Omitting one does not mean "keep the file's + // value": save() rewrites the whole file, so a column that is loaded and not saved is a + // column the pack loses the first time anything calls save(). + json.add("ablationIndividual", gson.toJsonTree(ablationIndividual)); + json.add("ablationByRegex", gson.toJsonTree(ablationByRegex)); json.add("toughnessMaterials", gson.toJsonTree(toughnessMaterials)); json.addProperty("toughnessFallback", toughnessFallback); w.write(gson.toJson(json)); diff --git a/src/main/resources/assets/advancedrocketry/blockstates/gunbeamemitter.json b/src/main/resources/assets/advancedrocketry/blockstates/gunbeamemitter.json new file mode 100644 index 000000000..1fc270599 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/blockstates/gunbeamemitter.json @@ -0,0 +1,18 @@ +{ + "forge_marker": 1, + "defaults": { + "transform": "forge:default-block", + "model": "minecraft:cube_all", + "textures": { + "all": "advancedrocketry:blocks/lens1" + } + }, + "variants": { + "normal": [ + {} + ], + "inventory": [ + {} + ] + } +} diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorplatingaluminium.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingAluminium.json rename to src/main/resources/assets/advancedrocketry/blockstates/mirrorplatingaluminium.json diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorplatinggold.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingGold.json rename to src/main/resources/assets/advancedrocketry/blockstates/mirrorplatinggold.json diff --git a/src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json b/src/main/resources/assets/advancedrocketry/blockstates/mirrorplatingsilver.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/blockstates/mirrorPlatingSilver.json rename to src/main/resources/assets/advancedrocketry/blockstates/mirrorplatingsilver.json diff --git a/src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json b/src/main/resources/assets/advancedrocketry/blockstates/reactiveblock.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/blockstates/reactiveBlock.json rename to src/main/resources/assets/advancedrocketry/blockstates/reactiveblock.json diff --git a/src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json b/src/main/resources/assets/advancedrocketry/blockstates/reactiveplate.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/blockstates/reactivePlate.json rename to src/main/resources/assets/advancedrocketry/blockstates/reactiveplate.json diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 668a28109..f78a39b31 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -69,12 +69,37 @@ tile.guidanceComputer.name=Guidance Computer tile.turret.name=Turret Mount tile.gunBarrel.name=Gun Barrel Section tile.gunAmmoFeed.name=Gun Ammunition Feed +tile.gunBeamEmitter.name=Gun Beam Emitter tile.gunCooling.name=Gun Cooling Jacket tile.weaponConsole.name=Weapons Console msg.weaponConsole.holdFire=Hold Fire msg.weaponConsole.clearTarget=Clear Target +msg.weaponConsole.status.noNetwork=no network +msg.weaponConsole.status.disconnected=disconnected +msg.weaponConsole.status.powerLimited=power limited +msg.weaponConsole.status.idle=idle +msg.weaponConsole.status.cableLimited=cable limited +msg.weaponConsole.status.balanced=balanced +msg.weaponConsole.status.unknown=unknown +msg.weaponConsole.line.network=Network: %s +msg.weaponConsole.line.networkHolding=Network: %s (holding fire) +msg.weaponConsole.line.guns=Guns: %d on target: %d +msg.weaponConsole.line.gunsOutOfArc=Guns: %d on target: %d out of arc: %d +msg.weaponConsole.line.targetNone=Target: none +msg.weaponConsole.line.target=Target: %.0f, %.0f, %.0f +msg.weaponConsole.line.sensorNone=Sensor: no contact +msg.weaponConsole.line.sensor=Sensor: contact at %.0fm, lock %.2f +msg.weaponConsole.line.sensorTooPoor=Sensor: contact at %.0fm, lock %.2f (too poor to fire) tile.fireControlSensor.name=Fire Control Sensor msg.fireControlSensor.mode=Passive/Active +msg.fireControlSensor.mode.passive=passive +msg.fireControlSensor.mode.active=active +msg.fireControlSensor.line.mode=Mode: %s +msg.fireControlSensor.line.modeUnderpowered=Mode: %s (no power to illuminate) +msg.fireControlSensor.line.contacts=Contacts: %d +msg.fireControlSensor.line.lockNone=Lock: none +msg.fireControlSensor.line.lock=Lock: %.2f at %.0fm +msg.fireControlSensor.line.lockTooPoor=Lock: %.2f at %.0fm (too poor to fire) tile.advancedFlightComputer.name=Advanced Flight Computer tile.navigationComputer.name=Navigation Computer tile.electricArcFurnace.name=Electric Arc Furnace diff --git a/src/main/resources/assets/advancedrocketry/models/item/repairWelder.json b/src/main/resources/assets/advancedrocketry/models/item/repairwelder.json similarity index 100% rename from src/main/resources/assets/advancedrocketry/models/item/repairWelder.json rename to src/main/resources/assets/advancedrocketry/models/item/repairwelder.json diff --git a/src/main/resources/assets/advancedrocketry/recipes/gunbeamemitter.json b/src/main/resources/assets/advancedrocketry/recipes/gunbeamemitter.json new file mode 100644 index 000000000..526cdce62 --- /dev/null +++ b/src/main/resources/assets/advancedrocketry/recipes/gunbeamemitter.json @@ -0,0 +1,30 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "igi", + "gcg", + "iri" + ], + "key": { + "i": { + "type": "forge:ore_dict", + "ore": "ingotIron" + }, + "g": { + "type": "forge:ore_dict", + "ore": "blockGlass" + }, + "c": { + "type": "forge:ore_dict", + "ore": "circuitBasic" + }, + "r": { + "type": "forge:ore_dict", + "ore": "blockRedstone" + } + }, + "result": { + "item": "advancedrocketry:gunBeamEmitter", + "count": 1 + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/TheWarSwitchesOffAndOnAgainE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/TheWarSwitchesOffAndOnAgainE2ETest.java new file mode 100644 index 000000000..0f0d0e43b --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/TheWarSwitchesOffAndOnAgainE2ETest.java @@ -0,0 +1,167 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * One switch, both weapon families, and a world that survives being switched. + * + *

      Why both families are pinned and not one

      + *

      The key this replaced gated the shot registry alone. A held beam has no record and never passes + * through that registry, so a beam turret kept burning hulls on a server that had switched combat + * off — and every instrument said the war was off. A switch that covers one family is worse than no + * switch, because it reads as a promise, so the thrower half of this test is the control that would + * have passed against the broken build and the beam half is the one that would not.

      + * + *

      Why ON again is the point

      + *

      The switch exists to be thrown on a world that has already been fought over and thrown back + * later, so what OFF must NOT do is as load-bearing as what it does: damage already recorded stays, + * and guns fire again afterwards without being rebuilt.

      + */ +public class TheWarSwitchesOffAndOnAgainE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 84, Z = 9900; + private static final int THROWER_X = 9700, BEAM_X = 9760; + + @Test + public void withTheWarOffNeitherFamilyDamagesAnythingAndBothWorkAgainAfterwards() throws Exception { + buildSite(THROWER_X); + buildGun(THROWER_X, false); + buildSite(BEAM_X); + buildGun(BEAM_X, true); + + int throwerWall = THROWER_X + 12, beamWall = BEAM_X + 12; + wall(throwerWall); + wall(beamWall); + + try { + exec("artest config set enableWeapons false"); + + aimAndFeed(THROWER_X, throwerWall); + aimAndFeed(BEAM_X, beamWall); + Thread.sleep(4_000L); + + String thrower = read(THROWER_X); + assertTrue("a gun reports itself merely idle with combat switched off: a disabled gun and" + + " a broken one then look identical, which is what the old switch did: " + thrower, + thrower.contains("\"weaponsDisabled\":true")); + assertEquals("a thrower fired with the war switched off: " + thrower, 0, + extract(thrower, "shots")); + + String beam = read(BEAM_X); + assertTrue("the BEAM gun is lit with the war switched off — the half of the mechanic the" + + " old key never covered: " + beam, extract(beam, "beamLit") == 0); + + assertTrue("the thrower's wall was damaged with the war off: " + stage(throwerWall), + intact(stage(throwerWall))); + assertTrue("the beam's wall was damaged with the war off, so the beam is still declaring" + + " impacts: " + stage(beamWall), intact(stage(beamWall))); + } finally { + exec("artest config set enableWeapons true"); + } + + // And on again, on the same world, with no rebuilding: the switch is meant to be thrown twice. + aimAndFeed(THROWER_X, throwerWall); + String firing = awaitShots(THROWER_X); + assertTrue("with the war switched back on the gun never fired again: the switch is one-way," + + " which is not what it was built for: " + firing, extract(firing, "shots") >= 1); + assertTrue("a gun still reports itself disabled after the war was switched back on: " + + firing, firing.contains("\"weaponsDisabled\":false")); + } + + // ---- driving + + private void buildGun(int bx, boolean beam) throws Exception { + place("advancedrocketry:turret", bx, Y, Z); + for (int i = 1; i <= 3; i++) { + place(beam ? "advancedrocketry:gunBeamEmitter" : "advancedrocketry:gunBarrel", bx, Y + i, Z); + } + place("advancedrocketry:gunCooling", bx, Y, Z + 1); + place("advancedrocketry:gunCooling", bx, Y, Z - 1); + } + + private void buildSite(int bx) throws Exception { + exec("artest chunk warmup " + DIM + " " + ((bx - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + + ((bx + 48) >> 4) + " " + ((Z + 16) >> 4)); + exec("artest fill " + DIM + " " + (bx - 4) + " " + (Y - 2) + " " + (Z - 4) + " " + (bx + 40) + + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air"); + for (int cx = ((bx - 16) >> 4); cx <= ((bx + 40) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + } + + private void wall(int x) throws Exception { + exec("artest fill " + DIM + " " + x + " " + Y + " " + Z + " " + (x + 3) + " " + Y + " " + Z + + " minecraft:iron_block"); + } + + private void aimAndFeed(int bx, int wallX) throws Exception { + exec("artest turret charge " + DIM + " " + bx + " " + Y + " " + Z); + exec("artest turret target " + DIM + " " + bx + " " + Y + " " + Z + " " + (wallX + 0.5D) + + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + } + + // ---- reading + + private String read(int bx) throws Exception { + return exec("artest turret read " + DIM + " " + bx + " " + Y + " " + Z); + } + + private String stage(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private static boolean intact(String stageJson) { + return !stageJson.contains("\"wasDestroyed\":true") + && !stageJson.contains("\"block\":\"minecraft:air\"") + && extractStatic(stageJson, "stage") <= 0; + } + + private String awaitShots(int bx) throws Exception { + long deadline = System.currentTimeMillis() + 30_000L; + String state = ""; + while (System.currentTimeMillis() < deadline) { + exec("artest turret charge " + DIM + " " + bx + " " + Y + " " + Z); + state = read(bx); + if (extract(state, "shots") >= 1) { + return state; + } + Thread.sleep(500L); + } + return state; + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private int extract(String json, String key) { + return extractStatic(json, key); + } + + private static int extractStatic(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+|true|false)").matcher(json); + if (!m.find()) { + return -1; + } + String v = m.group(1); + if ("true".equals(v)) { + return 1; + } + if ("false".equals(v)) { + return 0; + } + return Integer.parseInt(v); + } + + private String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java index ec2bee099..e4d0eaa82 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeaponFireAsksBeforeItTakesE2ETest.java @@ -70,7 +70,7 @@ public void switchingTheSubstrateOffEndsTheRoundsAlreadyInTheAir() throws Except assertTrue("the round was not in the air a tick after it was fired: " + inAir, inAir.contains("\"present\":true")); - exec("artest config set enableProjectileSubstrate false"); + exec("artest config set enableWeapons false"); Thread.sleep(1_000L); String after = exec("artest shot read " + DIM + " " + id); @@ -81,7 +81,7 @@ public void switchingTheSubstrateOffEndsTheRoundsAlreadyInTheAir() throws Except assertTrue("the round ended, but for the wrong reason - it should say the substrate was" + " switched off under it: " + after, after.contains("SUBSTRATE_DISABLED")); } finally { - exec("artest config set enableProjectileSubstrate true"); + exec("artest config set enableWeapons true"); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ARConfigurationTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ARConfigurationTest.java index 79768324d..f67978324 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/ARConfigurationTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ARConfigurationTest.java @@ -214,4 +214,67 @@ public void unknownConfigDoesNotCrash() { // of which fields have been touched. assertNotNull(ARConfiguration.getCurrentConfig()); } + + /** + * A field the config file can set is a field the reflective machinery must be able to see. + * + *

      `@ConfigProperty` is not decoration: the copy constructor copies exactly the annotated + * fields, and `needsSync` decides what crosses to a client. A field assigned from + * {@code config.get(...)} and left unannotated is loaded from disk and then invisible to + * everything else — it silently reverts to its class default in every copy, and no compiler, + * no config reload and no test that reads the singleton can tell.

      + * + *

      Found twice: {@code shotPenetrationSpeedFloor} sitting directly under its annotated twin + * {@code shotReflectionSpeedFloor}, and {@code aluminumPerChunk} between two annotated + * neighbours. Both look right in a diff, which is the whole problem.

      + * + *

      What it cannot see. It reads the source text of {@code ARConfiguration.java} + * rather than the loader at runtime, so a key set anywhere else is invisible; and it says + * nothing about whether {@code needsSync} is set CORRECTLY — only that the field is annotated + * at all.

      + */ + @Test + public void everyFieldTheConfigFileSetsIsVisibleToTheReflectiveMachinery() throws Exception { + String source = new String(java.nio.file.Files.readAllBytes(java.nio.file.Paths.get( + "src", "main", "java", "zmaster587", "advancedRocketry", "api", + "ARConfiguration.java")), java.nio.charset.StandardCharsets.UTF_8); + + java.util.Set assigned = new java.util.TreeSet(); + java.util.regex.Matcher a = + java.util.regex.Pattern.compile("\\barConfig\\.(\\w+)\\s*=").matcher(source); + while (a.find()) { + assigned.add(a.group(1)); + } + assertTrue("the loader scan matched nothing, so this test is measuring nothing", + assigned.size() > 100); + + java.util.Set unannotated = new java.util.TreeSet(assigned); + java.util.regex.Matcher f = java.util.regex.Pattern.compile( + "@ConfigProperty[^\\n]*\\n(?:\\s*@\\w+[^\\n]*\\n)*\\s*public\\s+[\\w<>,\\[\\]. ]+?\\s+(\\w+)\\s*[=;]") + .matcher(source); + while (f.find()) { + unannotated.remove(f.group(1)); + } + unannotated.removeAll(SERVER_ONLY_BY_DESIGN.keySet()); + + assertTrue("these fields are loaded from the config file and carry no @ConfigProperty, so " + + "the copy constructor drops them and they revert to their class default in every " + + "copy: " + unannotated, unannotated.isEmpty()); + } + + /** + * Fields deliberately outside the reflective machinery. Every entry needs a reason that is + * also written at the declaration; "it fails otherwise" is not one. + */ + private static final java.util.Map SERVER_ONLY_BY_DESIGN = + new java.util.LinkedHashMap(); + static { + String reason = "movable-ship space subsystem — server-authoritative, loaded in " + + "loadPreInit, deliberately never network-synced (stated at the declaration)"; + for (String name : new String[]{"enableSpaceSubsystem", "spaceCellPoolSize", + "spaceCellGcPolicy", "spaceCellMaxAgeTicks", "spaceMaxStoredCells", + "spaceHomeSystemCoord", "spaceTransitOfflineProgress"}) { + SERVER_ONLY_BY_DESIGN.put(name, reason); + } + } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/LangKeyCrossReferenceTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/LangKeyCrossReferenceTest.java index ded1023d7..640ece481 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/LangKeyCrossReferenceTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/LangKeyCrossReferenceTest.java @@ -62,6 +62,9 @@ public class LangKeyCrossReferenceTest { Pattern.compile("translateToLocal\\s*\\(\\s*\"([^\"]+)\""), Pattern.compile("new\\s+TextComponentTranslation\\s*\\(\\s*\"([^\"]+)\""), Pattern.compile("\\btr\\s*\\(\\s*\"([^\"]+)\""), + // AR's readout helper: getLocalizedString plus String.format, so the key reaches the + // catalogue through one more hop and would otherwise be invisible here. + Pattern.compile("\\breadoutText\\s*\\(\\s*\"([^\"]+)\""), }; /** diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ModelAssetsAreAddressableTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ModelAssetsAreAddressableTest.java new file mode 100644 index 000000000..1cc2ed062 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ModelAssetsAreAddressableTest.java @@ -0,0 +1,141 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.charset.StandardCharsets; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Every model asset AR ships must be reachable by the name the game asks for. + * + *

      A block or item model is looked up through a {@code ResourceLocation} + * built from the registry name, and 1.12.2's {@code ResourceLocation} lowercases + * its path on construction. A registry name may therefore be written + * {@code mirrorPlatingAluminium} — the lookup that follows is for + * {@code advancedrocketry:mirrorplatingaluminium}. In a development run the + * assets are loose files on a case-insensitive Windows filesystem and a + * camelCase file answers that lookup anyway; inside a built jar the entry names + * are case-sensitive and the same file answers nothing, so the block ships with + * no model at all.

      + * + *

      The trap has been walked into twice. Once through a toughness regex written + * in the case the block was declared in, which then matched nothing and was + * indistinguishable from a table that was simply not needed; and once through + * five blockstates and one item model shipped camelCase, found by a review + * rather than by anything mechanical. Both times the dev client looked correct. + * This test is the mechanical half.

      + * + *

      What it cannot see. Only the three directories whose file names are + * derived from a registry name. It does not check that a needed asset EXISTS — + * a block with no blockstate file at all passes here — and it says nothing about + * textures, sounds or recipes, whose names come from string literals rather than + * from the registry. The {@code models/**}{@code /models/} subdirectories are + * excluded: those OBJ/MTL files are named verbatim by a field inside the JSON + * that references them and are resolved by libVulpes' own loader, not by a + * lowercased registry name.

      + */ +public class ModelAssetsAreAddressableTest { + + private static final Path ASSETS = + Paths.get("src", "main", "resources", "assets", "advancedrocketry"); + + /** The directories whose file names must equal a lowercased registry name. */ + private static final String[] REGISTRY_NAMED = {"blockstates", "models/block", "models/item"}; + + @Test + public void everyModelAssetIsNamedInTheCaseTheLookupUses() throws IOException { + List offenders = new ArrayList(); + + for (String dir : REGISTRY_NAMED) { + final Path root = ASSETS.resolve(dir.replace("/", java.io.File.separator)); + assertTrue("asset directory is missing, so this test is measuring nothing: " + root, + Files.isDirectory(root)); + + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path candidate, BasicFileAttributes attrs) { + // The nested models/ subdirectory holds OBJ/MTL geometry named by a + // JSON field, not by a registry name — outside this contract. + if (!candidate.equals(root) && "models".equals(candidate.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String name = file.getFileName().toString(); + if (!name.equals(name.toLowerCase(java.util.Locale.ROOT))) { + offenders.add(ASSETS.relativize(file).toString().replace('\\', '/')); + } + return FileVisitResult.CONTINUE; + } + }); + } + + assertTrue("These model assets carry an uppercase letter, so the lowercased " + + "ResourceLocation the game builds from the registry name will not find " + + "them inside a jar — the block or item ships with no model: " + offenders, + offenders.isEmpty()); + } + + /** + * A block registered with an inventory item has a blockstate file to be drawn from. + * + *

      The one-argument {@code LibVulpesBlocks.registerBlock} is the form that also builds an + * {@code ItemBlock} and registers its item states, so the block reaches both the world and the + * creative inventory and needs a model in each. Nothing refuses a registration that has no + * blockstate: the block registers, crafts, places and renders as the purple-and-black missing + * model. A whole gun part shipped that way — registered, tuned, and with no blockstate, no name + * and no recipe, while its six siblings had all three.

      + * + *

      What it cannot see. It reads the registration source rather than the live registry, + * so a block registered anywhere other than {@code AdvancedRocketry.java}'s + * {@code registerBlock(…setRegistryName("…"))} lines is invisible to it. It does not check + * models, textures, lang names or recipes — only that the blockstate the lookup asks for is + * present. The three-argument form is deliberately out of scope: it is how the fluid blocks are + * registered, with a null ItemBlock, and their model comes from the custom + * {@code FluidStateMapper} the client proxy installs rather than from a blockstate file.

      + */ + @Test + public void everyBlockRegisteredWithAnItemHasABlockstate() throws IOException { + String source = new String(Files.readAllBytes( + Paths.get("src", "main", "java", "zmaster587", "advancedRocketry", + "AdvancedRocketry.java")), StandardCharsets.UTF_8); + + // The one-argument overload only: a closing paren straight after setRegistryName's, which + // the three-argument fluid form (", null, false)") does not match. + Matcher m = Pattern.compile( + "registerBlock\\(\\s*AdvancedRocketryBlocks\\.\\w+\\s*\\.setRegistryName\\(\"([^\"]+)\"\\)\\s*\\)") + .matcher(source); + + Path blockstates = ASSETS.resolve("blockstates"); + List unmodelled = new ArrayList(); + int scanned = 0; + while (m.find()) { + scanned++; + String expected = m.group(1).toLowerCase(java.util.Locale.ROOT) + ".json"; + if (!Files.isRegularFile(blockstates.resolve(expected))) { + unmodelled.add(m.group(1) + " (wants blockstates/" + expected + ")"); + } + } + + assertTrue("the registration scan matched nothing, so this test is measuring nothing", + scanned > 50); + assertTrue("these blocks are registered with an inventory item and have no blockstate, so " + + "they place and stack as the missing-model checkerboard: " + unmodelled, + unmodelled.isEmpty()); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java index a6642b433..d6627705c 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java @@ -1,11 +1,26 @@ package zmaster587.advancedRocketry.test.unit; +import com.google.gson.Gson; +import com.google.gson.JsonObject; import net.minecraft.util.ResourceLocation; import net.minecraftforge.fluids.Fluid; import org.junit.Test; import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.util.WeightEngine; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -89,4 +104,176 @@ public void individualOverrideSurvivesSaveLoadRoundTrip() { we.save(); } } + + // ---- The whole file, not one column of it ------------------------------- + + /** Where the engine keeps the pack-editable table. Relative, like every other path here. */ + private static final File CONFIG = new File("config/advRocketry/weights.json"); + + /** + * A column the engine READS is a column a pack may write, and {@code save()} rewrites the whole + * file — so a column that is loaded and not saved is a column the pack silently loses the first + * time anything saves. This walks every column through the file rather than picking one, because + * the columns that go missing are by definition the ones nobody remembered to add to a list. + * + *

      Two ablation columns were lost exactly this way: read by {@code load()}, absent from + * {@code save()}, invisible until a pack's hand-written rows evaporated.

      + */ + @Test + public void everyColumnAPackCanWriteSurvivesASave() throws Exception { + WeightEngine we = WeightEngine.INSTANCE; + try { + writeConfig("{\n" + + " \"individual\": {\"ar:probe\": 1.5},\n" + + " \"byRegex\": {\"ar:probe.*\": 2.5},\n" + + " \"fluids\": {\"ar_probe_fluid\": 0.5},\n" + + " \"materials\": {\"IRON\": 3.5},\n" + + " \"fallback\": 4.5,\n" + + " \"fluidFallback\": 5.5,\n" + + " \"toughnessIndividual\": {\"ar:probe\": 6.5},\n" + + " \"toughnessByRegex\": {\"ar:probe.*\": 7.5},\n" + + " \"toughnessMaterials\": {\"IRON\": 8.5},\n" + + " \"toughnessFallback\": 9.5,\n" + + " \"ablationIndividual\": {\"ar:probe\": 10.5},\n" + + " \"ablationByRegex\": {\"ar:probe.*\": 11.5}\n" + + "}\n"); + + we.load(); + we.save(); + + JsonObject saved = readConfig(); + List lost = new ArrayList(); + assertRow(saved, "individual", "ar:probe", 1.5, lost); + assertRow(saved, "byRegex", "ar:probe.*", 2.5, lost); + assertRow(saved, "fluids", "ar_probe_fluid", 0.5, lost); + assertRow(saved, "materials", "IRON", 3.5, lost); + assertScalar(saved, "fallback", 4.5, lost); + assertScalar(saved, "fluidFallback", 5.5, lost); + assertRow(saved, "toughnessIndividual", "ar:probe", 6.5, lost); + assertRow(saved, "toughnessByRegex", "ar:probe.*", 7.5, lost); + assertRow(saved, "toughnessMaterials", "IRON", 8.5, lost); + assertScalar(saved, "toughnessFallback", 9.5, lost); + assertRow(saved, "ablationIndividual", "ar:probe", 10.5, lost); + assertRow(saved, "ablationByRegex", "ar:probe.*", 11.5, lost); + + assertTrue("these hand-written config values did not survive one save/load cycle, so a " + + "pack that edits them loses them the first time the game writes the file: " + + lost, lost.isEmpty()); + } finally { + we.resetTables(); + we.save(); + } + } + + /** + * Regex columns are first-match-wins, so the order a pack writes its patterns in IS the + * precedence between two patterns that both match. A column deserialised into an unordered map + * answers a different question after a reload than the one the pack asked. + */ + @Test + public void aRegexColumnKeepsThePackSOrderAcrossASave() throws Exception { + WeightEngine we = WeightEngine.INSTANCE; + try { + // Deliberately not alphabetical and not hash order: three overlapping patterns whose + // meaning is entirely decided by which one is tried first. + writeConfig("{\n" + + " \"ablationByRegex\": {\"ar:zulu.*\": 1.0, \"ar:.*\": 2.0, \"ar:alpha.*\": 3.0},\n" + + " \"toughnessByRegex\": {\"ar:zulu.*\": 1.0, \"ar:.*\": 2.0, \"ar:alpha.*\": 3.0}\n" + + "}\n"); + + we.load(); + we.save(); + + JsonObject saved = readConfig(); + List expected = Arrays.asList("ar:zulu.*", "ar:.*", "ar:alpha.*"); + for (String column : new String[]{"ablationByRegex", "toughnessByRegex"}) { + assertEquals("first-match-wins makes pattern order the precedence rule, and " + + column + " came back reordered", + expected, keysInOrder(saved.getAsJsonObject(column))); + } + } finally { + we.resetTables(); + we.save(); + } + } + + /** + * {@code resetTables} is the clean slate — both the test hook and the branch the engine takes + * when a config file cannot be read. A column it forgets keeps the previous load's rows, so a + * broken config silently inherits half of the file it failed to parse. + */ + @Test + public void resettingTheTablesLeavesNoColumnBehind() throws Exception { + WeightEngine we = WeightEngine.INSTANCE; + try { + writeConfig("{\n" + + " \"individual\": {\"ar:probe\": 1.5},\n" + + " \"toughnessIndividual\": {\"ar:probe\": 6.5},\n" + + " \"ablationIndividual\": {\"ar:probe\": 10.5},\n" + + " \"ablationByRegex\": {\"ar:probe.*\": 11.5}\n" + + "}\n"); + we.load(); + + we.resetTables(); + we.save(); + + JsonObject saved = readConfig(); + List survivors = new ArrayList(); + for (String column : new String[]{"individual", "toughnessIndividual", + "ablationIndividual", "ablationByRegex"}) { + // Present-and-empty, not merely absent: a column that save() drops altogether would + // otherwise read as "reset worked", which is how this test could pass while the + // clean slate left every ablation row standing in memory. + assertTrue("save() must write column " + column + ", or this test cannot see whether " + + "the reset cleared it", saved.has(column)); + if (saved.getAsJsonObject(column).entrySet().size() != 0) { + survivors.add(column + " -> " + saved.getAsJsonObject(column)); + } + } + assertTrue("a reset must leave no column carrying the previous load's rows, and these " + + "still do: " + survivors, survivors.isEmpty()); + } finally { + we.resetTables(); + we.save(); + } + } + + private static void writeConfig(String json) throws IOException { + File parent = CONFIG.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try (Writer w = new OutputStreamWriter(new FileOutputStream(CONFIG), StandardCharsets.UTF_8)) { + w.write(json); + } + } + + private static JsonObject readConfig() throws IOException { + try (Reader r = new InputStreamReader(new FileInputStream(CONFIG), StandardCharsets.UTF_8)) { + return new Gson().fromJson(r, JsonObject.class); + } + } + + /** This Gson has no {@code keySet()}; {@code entrySet()} keeps insertion order all the same. */ + private static List keysInOrder(JsonObject column) { + List keys = new ArrayList(); + for (java.util.Map.Entry e : column.entrySet()) { + keys.add(e.getKey()); + } + return keys; + } + + private static void assertRow(JsonObject saved, String column, String key, double value, + List lost) { + if (!saved.has(column) || !saved.getAsJsonObject(column).has(key) + || Math.abs(saved.getAsJsonObject(column).get(key).getAsDouble() - value) > 1e-9) { + lost.add(column + "[" + key + "]"); + } + } + + private static void assertScalar(JsonObject saved, String key, double value, List lost) { + if (!saved.has(key) || Math.abs(saved.get(key).getAsDouble() - value) > 1e-9) { + lost.add(key); + } + } } From 513a05815feee01b5e3c515a9bbe77efdaefddf7 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 21 Aug 2026 12:05:21 +0300 Subject: [PATCH 34/35] fix: a mirror sends the beam back, and the second plate gets asked - HeldBeam resolves a tick as a bounded loop of segments - a deflected beam continues along the mirrored direction - a melted film hands the rest of the beam on in the same tick - the beam path reaches the client, so a bend is drawn as a bend - a round that got out keeps its travel and meets what stands beyond - REACH_EXHAUSTED separates running out of path from leaving --- .../api/damage/StopReason.java | 12 ++ .../client/ClientBeamTracker.java | 68 ++++-- .../client/render/RenderBeams.java | 45 ++-- .../damage/StructureDamageEngine.java | 7 +- .../network/PacketBeamState.java | 59 ++++-- .../projectile/BeamReplication.java | 44 ++-- .../projectile/ContactResolver.java | 36 +++- .../advancedRocketry/projectile/HeldBeam.java | 200 ++++++++++++++---- .../projectile/ShotSubstrate.java | 20 +- .../tile/weapon/TileTurret.java | 34 ++- .../client/ABentBeamIsDrawnBentE2ETest.java | 126 +++++++++++ .../AMirrorSendsTheBeamBackE2ETest.java | 133 ++++++++++++ .../SpacedArmourIsAskedTwiceE2ETest.java | 132 ++++++++++++ .../test/unit/BeamReplicationCadenceTest.java | 30 ++- 14 files changed, 801 insertions(+), 145 deletions(-) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/ABentBeamIsDrawnBentE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/AMirrorSendsTheBeamBackE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/SpacedArmourIsAskedTwiceE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java b/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java index 576d69a8e..94cd5dbb8 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java +++ b/src/main/java/zmaster587/advancedRocketry/api/damage/StopReason.java @@ -16,6 +16,18 @@ public enum StopReason { /** 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" diff --git a/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java b/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java index 22d6b79c5..13ef04213 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java +++ b/src/main/java/zmaster587/advancedRocketry/client/ClientBeamTracker.java @@ -2,6 +2,10 @@ 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; @@ -37,14 +41,17 @@ public final class ClientBeamTracker { private ClientBeamTracker() { } - /** This gun's beam is burning along this segment, as of now. */ - public static void lit(long gun, Vec3d from, Vec3d to) { + /** 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(from, to)); + BEAMS.put(gun, new ClientBeam(path)); return; } - beam.refresh(from, to); + beam.refresh(path); } /** This gun's beam has gone out. */ @@ -62,6 +69,24 @@ 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(); } @@ -82,21 +107,24 @@ public static void tick() { BEAMS.values().removeIf(ClientBeam::ageAndCheckStale); } - /** One drawn beam: where it starts, where it ends, both in world coordinates. */ + /** + * 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 Vec3d from; - private Vec3d to; + private List path; private int sinceHeard; - private ClientBeam(Vec3d from, Vec3d to) { - this.from = from; - this.to = to; + private ClientBeam(List path) { + this.path = new ArrayList(path); } - private void refresh(Vec3d newFrom, Vec3d newTo) { - from = newFrom; - to = newTo; + private void refresh(List newPath) { + path = new ArrayList(newPath); sinceHeard = 0; } @@ -104,12 +132,22 @@ 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 from; + return path.get(0); } public Vec3d getTo() { - return to; + return path.get(path.size() - 1); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java index 373c57d53..d56aaf654 100644 --- a/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java +++ b/src/main/java/zmaster587/advancedRocketry/client/render/RenderBeams.java @@ -93,25 +93,34 @@ public void onRenderWorldLast(RenderWorldLastEvent event) { buffer.begin(GL11.GL_QUADS, DefaultVertexFormats.POSITION_COLOR); for (ClientBeamTracker.ClientBeam beam : ClientBeamTracker.burning()) { - Vec3d from = beam.getFrom(); - Vec3d to = beam.getTo(); - if (from == null || to == null) { - continue; + // 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); + } } - 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); - spot(buffer, to, axis, eye); } tessellator.draw(); diff --git a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java index 9d5287408..a4d3247c1 100644 --- a/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/damage/StructureDamageEngine.java @@ -342,8 +342,13 @@ private WalkResult finish() { return result; } // Budget still in hand at the path limit: hand it back rather than absorb it silently. + // WHERE it ran out is the caller's question, though, and the two answers are not the + // same fact: still in the material with the path spent, or out the far side with path + // to spare. A body that "exited" without leaving would be advanced past whatever stood + // beyond it, which is not a thing it ever reached. result.outcome = DamageOutcome.EXITED; - result.stopReason = StopReason.EXITED_FAR_SIDE; + result.stopReason = previousWasSolid + ? StopReason.REACH_EXHAUSTED : StopReason.EXITED_FAR_SIDE; result.exitPoint = previousWasSolid ? farEnd : lastSolidExit; return result; } diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java b/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java index 30e03e3d3..e245d372b 100644 --- a/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketBeamState.java @@ -6,6 +6,9 @@ import net.minecraft.network.PacketBuffer; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.List; import zmaster587.advancedRocketry.client.ClientBeamTracker; import zmaster587.libVulpes.network.BasePacket; @@ -28,25 +31,35 @@ */ public class PacketBeamState extends BasePacket { + /** + * A beam is a PATH and not a segment, because something can turn it. + * + *

      Two points for the ordinary beam. More where a mirror sent it back: the corner is a real + * point on the line the beam occupies, and a bent beam drawn muzzle-to-end would be drawn + * straight through the very plating that turned it. The cap is a wire bound and matches the + * server's own segment budget, so a path that reaches it is drawn as far as it was resolved.

      + */ + private static final int MAX_POINTS = 9; + private long gun; private boolean lit; - private double fromX, fromY, fromZ; - private double toX, toY, toZ; + private final List path = new ArrayList(2); public PacketBeamState() { } - public static PacketBeamState of(BlockPos gun, Vec3d from, Vec3d to, boolean lit) { + public static PacketBeamState of(BlockPos gun, List path, boolean lit) { PacketBeamState packet = new PacketBeamState(); packet.gun = gun.toLong(); - packet.lit = lit && from != null && to != null; + packet.lit = lit && path != null && path.size() >= 2; if (packet.lit) { - packet.fromX = from.x; - packet.fromY = from.y; - packet.fromZ = from.z; - packet.toX = to.x; - packet.toY = to.y; - packet.toZ = to.z; + for (Vec3d point : path) { + if (point == null || packet.path.size() >= MAX_POINTS) { + break; + } + packet.path.add(point); + } + packet.lit = packet.path.size() >= 2; } return packet; } @@ -59,12 +72,12 @@ public void write(ByteBuf out) { if (!lit) { return; } - buffer.writeDouble(fromX); - buffer.writeDouble(fromY); - buffer.writeDouble(fromZ); - buffer.writeDouble(toX); - buffer.writeDouble(toY); - buffer.writeDouble(toZ); + buffer.writeByte(path.size()); + for (Vec3d point : path) { + buffer.writeDouble(point.x); + buffer.writeDouble(point.y); + buffer.writeDouble(point.z); + } } @Override @@ -75,12 +88,12 @@ public void readClient(ByteBuf in) { if (!lit) { return; } - fromX = buffer.readDouble(); - fromY = buffer.readDouble(); - fromZ = buffer.readDouble(); - toX = buffer.readDouble(); - toY = buffer.readDouble(); - toZ = buffer.readDouble(); + int count = Math.min(MAX_POINTS, buffer.readUnsignedByte()); + path.clear(); + for (int i = 0; i < count; i++) { + path.add(new Vec3d(buffer.readDouble(), buffer.readDouble(), buffer.readDouble())); + } + lit = path.size() >= 2; } @Override @@ -91,7 +104,7 @@ public void read(ByteBuf in) { @Override public void executeClient(EntityPlayer player) { if (lit) { - ClientBeamTracker.lit(gun, new Vec3d(fromX, fromY, fromZ), new Vec3d(toX, toY, toZ)); + ClientBeamTracker.lit(gun, path); } else { ClientBeamTracker.extinguished(gun); } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java b/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java index 90c95b331..f1e28ef7e 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/BeamReplication.java @@ -2,6 +2,10 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import net.minecraft.world.World; import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.network.PacketBeamState; @@ -59,8 +63,7 @@ public static final class Channel { /** Whether the last thing said was "it is burning". */ private boolean announcedLit; - private Vec3d announcedFrom; - private Vec3d announcedTo; + private List announcedPath = Collections.emptyList(); /** * Say what the beam is doing this tick, if it is worth saying. @@ -68,15 +71,13 @@ public static final class Channel { *

      Cheap to call every tick for a gun that has no beam at all: a dark gun already * announced dark costs one boolean test.

      */ - public void update(World world, final BlockPos gun, final Vec3d from, final Vec3d to, - boolean lit) { + public void update(World world, final BlockPos gun, final List path, boolean lit) { if (world == null || world.isRemote || gun == null) { return; } - final boolean burning = lit && from != null && to != null; - Vec3d lastFrom = announcedFrom; - Vec3d lastTo = announcedTo; - if (!offer(world.getTotalWorldTime(), phaseOf(gun), burning, from, to)) { + final boolean burning = lit && path != null && path.size() >= 2; + List lastPath = announcedPath; + if (!offer(world.getTotalWorldTime(), phaseOf(gun), burning, path)) { // The common case by a wide margin — an idle gun, or a steady beam between // heartbeats — so nothing above this line may allocate. return; @@ -84,14 +85,15 @@ public void update(World world, final BlockPos gun, final Vec3d from, final Vec3 // Announced along the line it occupies NOW, or — going out — along the line it last // occupied: those are the players holding a drawing of it, and nobody else has anything // to correct. A gun that never lit falls back to its own block. - Vec3d near = burning ? from : firstNonNull(lastFrom, centre(gun)); - Vec3d far = burning ? to : firstNonNull(lastTo, centre(gun)); + final List announce = burning ? path : lastPath; + Vec3d near = announce.isEmpty() ? centre(gun) : announce.get(0); + Vec3d far = announce.isEmpty() ? centre(gun) : announce.get(announce.size() - 1); ProximityBroadcast.sendNearSegment(world, near, far, ARConfiguration.getCurrentConfig().shotVisibilityRadius, new Supplier() { @Override public PacketBeamState get() { - return PacketBeamState.of(gun, from, to, burning); + return PacketBeamState.of(gun, path, burning); } }); } @@ -108,17 +110,17 @@ public PacketBeamState get() { * @param time the world tick, which the heartbeat is counted against * @param phase this gun's heartbeat offset, so that guns do not beat in unison */ - public boolean offer(long time, int phase, boolean lit, Vec3d from, Vec3d to) { - boolean send = decide(time, phase, lit, from, to); + public boolean offer(long time, int phase, boolean lit, List path) { + boolean send = decide(time, phase, lit, path); if (send) { announcedLit = lit; - announcedFrom = lit ? from : null; - announcedTo = lit ? to : null; + announcedPath = lit && path != null + ? new ArrayList(path) : Collections.emptyList(); } return send; } - private boolean decide(long time, int phase, boolean lit, Vec3d from, Vec3d to) { + private boolean decide(long time, int phase, boolean lit, List path) { if (!lit) { // Nothing to say about a beam that was already dark last time anybody was told. return announcedLit; @@ -126,9 +128,17 @@ private boolean decide(long time, int phase, boolean lit, Vec3d from, Vec3d to) if (!announcedLit) { return true; } - if (moved(announcedFrom, from) || moved(announcedTo, to)) { + // The whole path, not only its ends: a beam turned by a mirror can keep both ends while + // its corner walks along the plating, and a client told only about the ends would draw a + // straight line through it. + if (path == null || path.size() != announcedPath.size()) { return true; } + for (int i = 0; i < path.size(); i++) { + if (moved(announcedPath.get(i), path.get(i))) { + return true; + } + } return Math.floorMod(time + phase, (long) REFRESH_TICKS) == 0L; } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java index 1f30223ca..2948df796 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ContactResolver.java @@ -46,10 +46,23 @@ private ContactResolver() { public static final class Resolution { public final ContactResult result; public final double distance; + /** + * Whether the body came OUT the other side, as opposed to still being in there. + * + *

      The distinction a residual energy cannot make on its own, and the one that decides + * whether the tick is over. A bore that stalled inside armour with energy to spare has + * spent this tick's travel and resumes next tick from where it stopped — that is what + * "penetration takes time" means. A body that left the far side has NOT spent the tick: it + * is in open air with travel still owing, and whatever stands in the rest of that travel is + * entitled to be asked. Reading only the residual blurs the two, which is how spaced armour + * came to be passed through without its second plate ever being consulted.

      + */ + public final boolean leftTheStructure; - Resolution(ContactResult result, double distance) { + Resolution(ContactResult result, double distance, boolean leftTheStructure) { this.result = result; this.distance = Math.max(0.0D, distance); + this.leftTheStructure = leftTheStructure; } } @@ -67,7 +80,7 @@ public static final class Resolution { public static Resolution resolve(World world, TravellingBody body, StructureCrossing.Hit hit, double reachBlocks, boolean resumingBore) { if (world == null || body == null || hit == null) { - return new Resolution(ContactResult.stopped(), 0.0D); + return new Resolution(ContactResult.stopped(), 0.0D, false); } Contact contact = new Contact(hit.block, hit.point, hit.entryFace, @@ -84,14 +97,16 @@ public static Resolution resolve(World world, TravellingBody body, StructureCros // A block that answered for itself did not walk anything, so the body is advanced past // the block it was answered by — otherwise the next test finds the same block, asks // again, and a round argues with one plate until the tick's crossing budget runs out. - return new Resolution(answer, answer.isStopped() ? 0.0D : 1.0D); + // A block that answered for itself did not walk anything, and an answer that is not + // "stopped" means the body is past IT — so the next thing along is owed a question. + return new Resolution(answer, answer.isStopped() ? 0.0D : 1.0D, !answer.isStopped()); } } ContactResult skipped = ricochet(world, contact, body); if (skipped != null) { // A graze that skipped off did not walk into anything, so the body is moved past the block // it bounced from, exactly as a block that answered for itself would have left it. - return new Resolution(skipped, 1.0D); + return new Resolution(skipped, 1.0D, true); } return defaultLaw(world, body, contact, reachBlocks, resumingBore); } @@ -219,11 +234,18 @@ reachBlocks, areaOf(contact.getRadius())) int residual = report.getBudgetLeft(); if (residual <= 0) { - return new Resolution(ContactResult.stopped(), report.getDistanceWalked()); + return new Resolution(ContactResult.stopped(), report.getDistanceWalked(), false); } // It got through what it met, or as far as this tick's travel allowed. Either way it is still - // a shot, and the substrate advances it by what the walk says it covered. - return new Resolution(ContactResult.passedThrough(residual), report.getDistanceWalked()); + // a shot, and the substrate advances it by what the walk says it covered. Only the FIRST of + // those two is leaving: a walk that ran out of tick inside the material is still in there. + // The REASON and not the outcome: a walk that ran out of granted path inside the material + // hands its budget back exactly as one that came out the far side does, and only the reason + // says which happened. Reading the outcome told a round that had bored a fifth of a block + // that it was through the plate. + return new Resolution(ContactResult.passedThrough(residual), report.getDistanceWalked(), + report.getStopReason() + == zmaster587.advancedRocketry.api.damage.StopReason.EXITED_FAR_SIDE); } /** diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java index 4a9994132..25f0c05f9 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/HeldBeam.java @@ -10,6 +10,12 @@ import zmaster587.advancedRocketry.api.damage.TravellingBody; import zmaster587.advancedRocketry.damage.ImpactKindMapping; +import zmaster587.advancedRocketry.api.damage.ContactResult; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * One tick of a beam somebody is HOLDING on a target. * @@ -44,16 +50,31 @@ public static final class Emission { public final boolean hitShield; /** True when structure took it. */ public final boolean hitStructure; - /** What the block let through, when structure was met; the tick's whole power otherwise. */ + /** What the last thing met let through; the tick's whole power if it met nothing. */ public final int residualEnergy; + /** + * The line the beam actually occupied this tick, muzzle first and {@link #endedAt} last. + * + *

      Two points for the ordinary beam, which is straight. More only where something BENT it: + * a mirror returns the beam along a new direction, and the corner is a point in this list. It + * is here because a bent beam drawn as one straight muzzle-to-end line would be drawn through + * the very plating that turned it.

      + */ + public final List path; Emission(Vec3d endedAt, double distance, boolean hitShield, boolean hitStructure, - int residualEnergy) { + int residualEnergy, List path) { this.endedAt = endedAt; this.distance = distance; this.hitShield = hitShield; this.hitStructure = hitStructure; this.residualEnergy = residualEnergy; + this.path = Collections.unmodifiableList(path); + } + + /** Whether anything turned the beam, which is the only case the path has a corner in it. */ + public boolean isBent() { + return path.size() > 2; } /** Did this tick's energy land on anything at all? */ @@ -79,64 +100,141 @@ public static Emission emit(World world, Vec3d muzzle, Vec3d direction, double r || !ARConfiguration.getCurrentConfig().enableWeapons) { // The war switch is asked HERE and not only where a round is admitted. A held beam has no // record and never passes through the registry, so a gate on the registry alone let a - // beam turret keep burning hulls on a server that had switched combat off — a switch + // beam turret keep burning hulls on a server that had switched combat off - a switch // covering half a mechanic, which reads as a promise and is worse than none. - return new Emission(muzzle, 0.0D, false, false, Math.max(0, powerThisTick)); + return ended(muzzle, muzzle, 0.0D, Math.max(0, powerThisTick)); } double length = direction.lengthVector(); if (length <= 1.0E-9D) { - return new Emission(muzzle, 0.0D, false, false, powerThisTick); + return ended(muzzle, muzzle, 0.0D, powerThisTick); } + + List path = new ArrayList(2); + path.add(muzzle); + Vec3d unit = direction.scale(1.0D / length); - Vec3d farEnd = muzzle.add(unit.scale(reach)); + Vec3d from = muzzle; + double reachLeft = reach; + double travelled = 0.0D; + int power = powerThisTick; + boolean hitShield = false; + boolean hitStructure = false; - LayerCrossing.First first = LayerCrossing.along(world, muzzle, farEnd, radius, null); - if (first.isNothing()) { - // Into empty space. The energy leaves with it: a beam that met nothing warmed nothing. - return new Emission(farEnd, reach, false, false, powerThisTick); - } + for (int segment = 0; segment < MAX_BEAM_SEGMENTS; segment++) { + Vec3d farEnd = from.add(unit.scale(reachLeft)); + LayerCrossing.First first = LayerCrossing.along(world, from, farEnd, radius, null); + if (first.isNothing()) { + // Into empty space. The energy leaves with it: a beam that met nothing warmed nothing. + path.add(farEnd); + return new Emission(farEnd, travelled + reachLeft, hitShield, hitStructure, power, + path); + } - Vec3d contact = muzzle.add(unit.scale(first.distance)); + Vec3d contact = from.add(unit.scale(first.distance)); + double reachAtContact = reachLeft; + travelled += first.distance; + reachLeft -= first.distance; - if (first.isField()) { - // Priced through the one declared hull-kind to shield-kind mapping, and carrying NO body: - // a beam has nothing to mirror. Its energy arrives and stays there, which is exactly why a - // laser is the weapon that answers a shield and a slug is the one a shell can throw back. - ShieldStrike strike = new ShieldStrike(muzzle, unit, reach, powerThisTick, - ImpactKindMapping.toShieldKind(kind), false, null); - ShieldStrikeResult result = ShieldStrikeService.resolve(world, strike); - if (result.isFullyAbsorbed()) { + if (first.isField()) { + hitShield = true; + // Priced through the one declared hull-kind to shield-kind mapping, and carrying NO + // body: a beam has nothing to mirror. Its energy arrives and stays there, which is + // exactly why a laser is the weapon that answers a shield and a slug is the one a + // shell can throw back. + ShieldStrike strike = new ShieldStrike(from, unit, reachAtContact, power, + ImpactKindMapping.toShieldKind(kind), false, null); + ShieldStrikeResult result = ShieldStrikeService.resolve(world, strike); Vec3d at = result.getHitPoint() == null ? contact : result.getHitPoint(); - return new Emission(at, first.distance, true, false, 0); + // ASKING THE WRONG QUESTION HERE INVERTED THE WHOLE LASER LINE. `isIntercepted` is + // true on an UNDERPAY as well as on a full stop, so a beam that overpowered a shell + // died at it - the exact opposite of the reason this weapon family exists. + // `isFullyAbsorbed` is the question that means "the shell bought all of it". + int throughShell = result.isFullyAbsorbed() ? 0 + : Math.max(0, result.isIntercepted() + ? result.getResidualImpactEnergy() : power); + if (throughShell <= 0) { + path.add(at); + return new Emission(at, travelled, true, hitStructure, 0, path); + } + power = throughShell; + from = contact.add(unit.scale(CROSSING_EPSILON)); + travelled += CROSSING_EPSILON; + reachLeft -= CROSSING_EPSILON; + if (reachLeft <= 0.0D) { + path.add(from); + return new Emission(from, travelled, true, hitStructure, power, path); + } + continue; } - // Either the shell paid nothing (it went down between the two questions) or it paid what - // it could and that was not enough. Both mean the same thing to a beam: what the shell - // could not buy carries on into whatever is behind it. - // - // ASKING THE WRONG QUESTION HERE INVERTED THE WHOLE LASER LINE. `isIntercepted` is true - // on an UNDERPAY as well as on a full stop, so a beam that overpowered a shell died at it - // — the exact opposite of the reason this weapon family exists: a beam whose power the - // shell cannot pay for is supposed to get through. `isFullyAbsorbed` is the question that - // means "the shell bought all of it". - int throughShell = Math.max(0, result.isIntercepted() - ? result.getResidualImpactEnergy() : powerThisTick); - if (throughShell <= 0) { - Vec3d at = result.getHitPoint() == null ? contact : result.getHitPoint(); - return new Emission(at, first.distance, true, false, 0); + + hitStructure = true; + // The identity comes from the world's own counter, exactly as a shot's does: a beam held + // for a minute declares sixty times as many impacts as one held for a second, and every + // one of them has to be a distinct meeting or the dedup memory refuses the lot. + TravellingBody body = new TravellingBody(ShotRegistry.get(world).nextImpactId(), + unit.scale(BEAM_NOMINAL_SPEED), kind, power, radius); + ContactResolver.Resolution resolved = + ContactResolver.resolve(world, body, first.structure, reachLeft, false); + ContactResult answer = resolved.result; + int residual = answer.isStopped() ? 0 : answer.getResidualEnergy(); + if (!answer.isDeflected() && !resolved.leftTheStructure) { + // Worth something still, but it did not get out: the walk stalled inside the + // material, or could not run at all because the far side is not loaded. Either way + // there is nothing to hand it on to this tick. + residual = 0; + } + if (residual <= 0) { + path.add(contact); + return new Emission(contact, travelled, hitShield, true, 0, path); + } + + // It is still worth something, so it goes on - and it goes on from where the WALK says it + // got to, which is what Resolution.distance is for. Ending here instead was two bugs at + // once: a mirror's reflection went nowhere at all, and a film that melted through cost a + // whole extra tick before the block behind it was reached, though the plating's own + // answer says the rest of the beam continues into whatever stood there. + power = residual; + if (answer.isDeflected()) { + Vec3d away = answer.getDeflectedVelocity(); + double awayLength = away == null ? 0.0D : away.lengthVector(); + if (awayLength <= 1.0E-9D) { + // Deflected to a standstill, or nobody could say which way "out" points. + // Absorbing is the recoverable answer; inventing a direction is not. + path.add(contact); + return new Emission(contact, travelled, hitShield, true, 0, path); + } + // The corner is a real point on the line the beam occupies, so it is a point on the + // path: a bent beam drawn muzzle-to-end would be drawn straight through the mirror. + path.add(contact); + unit = away.scale(1.0D / awayLength); + from = contact; + } else { + from = contact.add(unit.scale(resolved.distance)); + travelled += resolved.distance; + reachLeft -= resolved.distance; + } + from = from.add(unit.scale(CROSSING_EPSILON)); + travelled += CROSSING_EPSILON; + reachLeft -= CROSSING_EPSILON; + if (reachLeft <= 0.0D) { + path.add(from); + return new Emission(from, travelled, hitShield, true, power, path); } - return emit(world, contact.add(unit.scale(CROSSING_EPSILON)), unit, - reach - first.distance - CROSSING_EPSILON, throughShell, kind, radius, hullId); } - // Structure. The identity comes from the world's own counter, exactly as a shot's does: a beam - // held for a minute declares sixty times as many impacts as one held for a second, and every - // one of them has to be a distinct meeting or the dedup memory refuses the lot. - TravellingBody body = new TravellingBody(ShotRegistry.get(world).nextImpactId(), - unit.scale(BEAM_NOMINAL_SPEED), kind, powerThisTick, radius); - ContactResolver.Resolution resolved = ContactResolver.resolve(world, body, first.structure, - reach - first.distance, false); - int residual = resolved.result.isStopped() ? 0 : resolved.result.getResidualEnergy(); - return new Emission(contact, first.distance, false, true, residual); + // The segment budget, spent. A bound on WORK, not a law about beams: what stops one is its + // reach and its power, and this only refuses to spend an unbounded number of walks on a tick + // where two mirrors face each other. The beam ends where the budget ran out and says so. + path.add(from); + return new Emission(from, travelled, hitShield, hitStructure, power, path); + } + + /** A beam that ended where it began: a two-point path and nothing met. */ + private static Emission ended(Vec3d muzzle, Vec3d at, double distance, int residual) { + List path = new ArrayList(2); + path.add(muzzle); + path.add(at); + return new Emission(at, distance, false, false, residual, path); } /** @@ -149,6 +247,16 @@ public static Emission emit(World world, Vec3d muzzle, Vec3d direction, double r */ private static final double BEAM_NOMINAL_SPEED = 1.0D; + /** + * How many times one tick of beam may be handed on before the work is cut off. + * + *

      A beam continues through whatever it gets past: a shell it overpowers, a film it melts, a + * mirror that turns it. Each of those costs a crossing search and a damage walk, and two mirrors + * facing each other would trade one beam between them until the reach ran out in epsilon-sized + * steps. This bounds the WORK; what bounds the BEAM is still its reach and its power.

      + */ + private static final int MAX_BEAM_SEGMENTS = 8; + /** How far past a crossing the line resumes, so a dead shell is not found again at distance zero. */ private static final double CROSSING_EPSILON = 1.0E-4D; } diff --git a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java index 5632fa565..9c0a36c0c 100644 --- a/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java +++ b/src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java @@ -289,15 +289,27 @@ static ShotEndReason step(World world, Shot shot) { timeLeft -= (structure.distance + CROSSING_EPSILON) / speed; velocity = contact.result.getDeflectedVelocity(); position = structure.point.add(velocity.normalize().scale(CROSSING_EPSILON)); - } else { - // It is still going, so it used this tick's travel: it is as deep as its speed - // took it, and no deeper. That is the whole of "penetration takes time" — the - // depth per tick is the distance per tick, and the next tick starts from here. + } else if (!contact.leftTheStructure) { + // Still in there. It used this tick's travel: it is as deep as its speed took it, + // and no deeper. That is the whole of "penetration takes time" — the depth per + // tick is the distance per tick, and the next tick starts from here. position = structure.point.add(direction.scale(reachInside)); timeLeft = 0.0D; velocity = slowedByWorkDone(velocity, energyBefore, shot.getImpactEnergy(), shot.getKind()); endedInsideHull = structure.shipId; + } else { + // It came out the far side with travel still owing, so the tick is NOT over and + // whatever stands in the rest of it is owed a question. Ending here instead moved + // the round the whole remaining distance in one go, straight past a second plate + // that was never asked — which is exactly the arrangement spaced armour is. + double advanced = Math.min(reachInside, + Math.max(contact.distance, CROSSING_EPSILON)); + position = structure.point.add(direction.scale(advanced + CROSSING_EPSILON)); + timeLeft -= (structure.distance + advanced + CROSSING_EPSILON) / speed; + velocity = slowedByWorkDone(velocity, energyBefore, shot.getImpactEnergy(), + shot.getKind()); + endedInsideHull = null; } if (velocity.lengthVector() diff --git a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java index 75cb2d69e..364a3131f 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/weapon/TileTurret.java @@ -182,7 +182,7 @@ public void update() { // Rebuilt into a thrower while it was burning: the light goes out, and whoever was watching // is told so, exactly as if the trigger had been released. beamLit = false; - beamChannel.update(world, pos, beamStartedAt, beamEndedAt, false); + beamChannel.update(world, pos, replicatedPath(), false); if (!onTarget || isHoldingFire() || !canFireNow()) { return; @@ -239,7 +239,7 @@ private void holdBeam(boolean wantsToFire, String shipId) { beamLit = burnOneTick(wantsToFire, shipId); // Told here and nowhere else, so every way of NOT burning — no trigger, too hot, saving up, // no line of fire — reaches the players watching by the same road as burning does. - beamChannel.update(world, pos, beamStartedAt, beamEndedAt, beamLit); + beamChannel.update(world, pos, replicatedPath(), beamLit); } /** @@ -287,6 +287,7 @@ private boolean burnOneTick(boolean wantsToFire, String shipId) { heat += spec.getHeatPerShot(); beamStartedAt = muzzle.point; beamEndedAt = emission.endedAt; + beamPath = emission.path; if (emission.hitSomething()) { shotsFired++; } @@ -312,6 +313,16 @@ private boolean burnOneTick(boolean wantsToFire, String shipId) { /** Dark and saving up, because the feed could not keep up. Persisted — it is a real refusal. */ private boolean beamRecharging; /** Where the beam left the gun last time it was lit — the muzzle, in world coordinates. */ + /** + * The line the last tick of beam actually occupied, muzzle first. + * + *

      Two points for the ordinary beam and more where a mirror turned it. Not persisted and not + * part of the gun's state: it is what the current tick's emission said, kept only long enough to + * be replicated, because a bent beam drawn as one muzzle-to-end line is drawn through the very + * plating that bent it.

      + */ + private java.util.List beamPath = java.util.Collections.emptyList(); + private Vec3d beamStartedAt; /** Where the beam ended last time it was lit; for instruments and for drawing it. */ private Vec3d beamEndedAt; @@ -350,6 +361,23 @@ public Vec3d getBeamEndedAt() { return beamEndedAt; } + /** + * The path to replicate: this tick's line if there is one, else the two ends we last had. + * + *

      The fallback matters on the way OUT. A gun going dark is announced along the line it last + * occupied, because those are the players holding a drawing of it; falling back to the ends + * keeps that true for a gun whose last emission is no longer in hand.

      + */ + private java.util.List replicatedPath() { + if (beamPath.size() >= 2) { + return beamPath; + } + if (beamStartedAt == null || beamEndedAt == null) { + return java.util.Collections.emptyList(); + } + return java.util.Arrays.asList(beamStartedAt, beamEndedAt); + } + private boolean launch(String shipId) { String stamped = faction != null ? faction : getEffectiveAccessCode(); long id = TurretFireControl.fire(world, pos, shipId, mechanism.getAimDirection(), spec, @@ -831,7 +859,7 @@ public void onChunkUnload() { */ private void extinguishBeam() { beamLit = false; - beamChannel.update(world, pos, beamStartedAt, beamEndedAt, false); + beamChannel.update(world, pos, replicatedPath(), false); } // ---- linker: the no-network way to give a gun a target diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ABentBeamIsDrawnBentE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ABentBeamIsDrawnBentE2ETest.java new file mode 100644 index 000000000..f6f3a369c --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ABentBeamIsDrawnBentE2ETest.java @@ -0,0 +1,126 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A beam a mirror turned is DRAWN turned, and not straight through the mirror that turned it. + * + *

      The server resolves a bent beam as a path with a corner in it. Everything about that is + * invisible from the client's side unless the corner crosses the wire: a beam sent as two ends is + * still one beam, still drawn, still counted — and drawn as a laser passing clean through a plate + * that is, in fact, reflecting it. So the beam COUNT cannot see this, which is exactly why it is a + * separate scenario and a separate observable.

      + * + *

      The control leg is the ordinary beam. A gun burning into an iron wall must be drawn with NO + * corner: without that half, a bug that reported every beam as bent would pass this file.

      + */ +public class ABentBeamIsDrawnBentE2ETest extends AbstractClientE2ETest { + + private static final String TRACKER = "zmaster587.advancedRocketry.client.ClientBeamTracker"; + + private static final int DIM = 0; + private static final int Y = 84, Z = 420; + private static final int GUN_X = 700; + private static final int TARGET_X = GUN_X + 20; + + /** The mount has to swing onto the target before anything lights. */ + private static final long LIGHT_TIMEOUT_MS = 45_000L; + + @Test + public void aBeamTurnedByAMirrorReachesTheClientWithItsCornerInIt() throws Exception { + buildBeamGun(); + + // Leg one, the control: plain iron. A beam that meets it is a straight line to its end. + exec("artest fill " + DIM + " " + TARGET_X + " " + Y + " " + Z + " " + (TARGET_X + 5) + " " + + Y + " " + Z + " minecraft:iron_block"); + aimAtTarget(); + serverClient().execute("tp @a " + (GUN_X + 4) + ".5 " + (Y + 1) + " " + (Z + 0.5D)); + bot().waitTicks(20); + + assertTrue("the beam never reached the client at all, so nothing here measured how it is " + + "drawn: " + read(), await(1, false)); + assertEquals("a beam burning into a plain iron wall was drawn with a corner in it. Nothing " + + "turned it, so either the server invented a bend or the client is calling every " + + "beam bent — and this file's real assertion would then pass for that reason " + + "rather than for the mirror", 0, bentBeams()); + + // Leg two: swap the iron the beam is standing on for a mirror. Same gun, same aim, same + // distance — the ONE thing that changes is what the beam meets. + exec("artest fill " + DIM + " " + TARGET_X + " " + Y + " " + Z + " " + (TARGET_X + 5) + " " + + Y + " " + Z + " minecraft:air"); + place("advancedrocketry:mirrorPlatingGold", TARGET_X, Y, Z); + place("minecraft:iron_block", TARGET_X + 1, Y, Z); + + assertTrue("with a mirror in the beam's way the client is still drawing a straight line: " + + "the corner never crossed the wire, so a player watching this sees a laser going " + + "clean through a plate that is reflecting it. " + read(), await(1, true)); + } + + // ---- building + + private void buildBeamGun() throws Exception { + exec("artest chunk warmup " + DIM + " " + ((GUN_X - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + + ((GUN_X + 48) >> 4) + " " + ((Z + 16) >> 4)); + exec("artest fill " + DIM + " " + (GUN_X - 8) + " " + (Y - 2) + " " + (Z - 4) + " " + + (GUN_X + 40) + " " + (Y + 12) + " " + (Z + 4) + " minecraft:air"); + for (int cx = ((GUN_X - 16) >> 4); cx <= ((GUN_X + 40) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + place("advancedrocketry:turret", GUN_X, Y, Z); + for (int i = 1; i <= 3; i++) { + place("advancedrocketry:gunBeamEmitter", GUN_X, Y + i, Z); + } + place("advancedrocketry:gunCooling", GUN_X, Y, Z + 1); + place("advancedrocketry:gunCooling", GUN_X, Y, Z - 1); + } + + private void aimAtTarget() throws Exception { + exec("artest turret target " + DIM + " " + GUN_X + " " + Y + " " + Z + " " + + (TARGET_X + 0.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + } + + // ---- reading + + /** + * Keep the gun fed until the client is drawing at least {@code beams}, and — when asked — until + * one of them is bent. Feeding is arrangement, not subject: an unfed gun burns its buffer down + * and goes dark to save up, and what is being watched here is the packet. + */ + private boolean await(int beams, boolean bent) throws Exception { + long deadline = System.currentTimeMillis() + LIGHT_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + exec("artest turret charge " + DIM + " " + GUN_X + " " + Y + " " + Z); + bot().waitTicks(10); + if (trackedBeams() >= beams && (!bent || bentBeams() >= 1)) { + return true; + } + } + return false; + } + + private int trackedBeams() throws Exception { + return Integer.parseInt(bot().invokeStaticInt(TRACKER, "count").get("returned").getAsString()); + } + + private int bentBeams() throws Exception { + return Integer.parseInt( + bot().invokeStaticInt(TRACKER, "bentCount").get("returned").getAsString()); + } + + private String read() throws Exception { + return exec("artest turret read " + DIM + " " + GUN_X + " " + Y + " " + Z); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private String exec(String command) throws Exception { + return String.join("\n", serverClient().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/AMirrorSendsTheBeamBackE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/AMirrorSendsTheBeamBackE2ETest.java new file mode 100644 index 000000000..57faf1b05 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/AMirrorSendsTheBeamBackE2ETest.java @@ -0,0 +1,133 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A mirror does not swallow a beam — it sends it somewhere, and somewhere is a place with blocks in it. + * + *

      Mirror plating computes an outgoing direction for the beam it reflects and hands it back as a + * deflection. For a long time nothing on the beam path asked whether the answer WAS a deflection: the + * beam ended at the plating and the reflected energy was reported to a caller that did not read it. The + * hull behind the mirror was protected, so from the defender's chair the armour looked right, and + * three green tests over the thrown-round path — where deflection has always worked — said nothing + * about it. What was missing had no observer at all.

      + * + *

      This gives it one. A beam meeting a plate square-on is reflected back down its own line, and the + * only thing standing on that line is the gun that fired it. That is a real consequence and not a test + * fixture: shooting a mirror head-on is a way to shoot yourself, and a player is entitled to find that + * out. The assertion is deliberately about WHERE the energy went rather than how much of it went + * there — the reflectances are balance and will move.

      + */ +public class AMirrorSendsTheBeamBackE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 84, Z = 9560; + private static final int GUN_X = 9700; + /** Far enough that the reflected line has a clear run home, short enough to stay in loaded chunks. */ + private static final int MIRROR_X = GUN_X + 12; + + /** + * Hold a beam on a mirror and the gun is what the beam comes back to. + * + *

      Two halves, and both are the point. The iron behind the mirror must be untouched, or the + * plating is not reflecting but merely being slow to break; and the gun's own controller must have + * taken damage, or the reflected energy went nowhere and the mirror is an absorber with extra + * steps. Either half alone passes for the wrong reason.

      + */ + @Test + public void aBeamHeldOnAMirrorComesBackToTheGunThatFiredIt() throws Exception { + buildSite(); + buildBeamGun(); + + // One plate square across the line of fire, with plain iron directly behind it. The iron is + // the control: whatever happens to the gun, this must not be dug. + place("advancedrocketry:mirrorPlatingGold", MIRROR_X, Y, Z); + place("minecraft:iron_block", MIRROR_X + 1, Y, Z); + + aimAt(MIRROR_X); + for (int i = 0; i < 4; i++) { + exec("artest turret charge " + DIM + " " + GUN_X + " " + Y + " " + Z); + Thread.sleep(1_400L); + } + + String behind = stageAt(MIRROR_X + 1); + assertTrue("the iron BEHIND the mirror was damaged, so the plating passed the beam through " + + "instead of turning it — this test is then measuring a broken mirror and not a " + + "reflection: " + behind, stage(behind) == 0 && !behind.contains("\"wasDestroyed\":true")); + + String gun = stageAt(GUN_X); + assertTrue("the gun that fired into a mirror took nothing at all. The reflected energy went " + + "nowhere: the plating answered with a deflection and the beam path threw the answer " + + "away, which is what made a mirror look like armour and behave like a hole in the " + + "world's bookkeeping: " + gun, + stage(gun) > 0 || gun.contains("\"wasDestroyed\":true")); + } + + // ---- driving + + /** The same reference beam gun the dwell scenarios use: a controller, emitters, cooling. */ + private void buildBeamGun() throws Exception { + place("advancedrocketry:turret", GUN_X, Y, Z); + for (int i = 1; i <= 3; i++) { + place("advancedrocketry:gunBeamEmitter", GUN_X, Y + i, Z); + } + place("advancedrocketry:gunCooling", GUN_X, Y, Z + 1); + place("advancedrocketry:gunCooling", GUN_X, Y, Z - 1); + } + + private void buildSite() throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + + ((GUN_X - 16) >> 4) + " " + ((Z - 16) >> 4) + " " + ((GUN_X + 32) >> 4) + " " + + ((Z + 16) >> 4)).contains("\"ok\":true")); + assertTrue("could not clear the site", exec("artest fill " + DIM + " " + (GUN_X - 8) + " " + + (Y - 2) + " " + (Z - 4) + " " + (GUN_X + 28) + " " + (Y + 12) + " " + (Z + 4) + + " minecraft:air").contains("\"ok\":true")); + for (int cx = ((GUN_X - 16) >> 4); cx <= ((GUN_X + 28) >> 4); cx++) { + exec("artest chunk forceload " + DIM + " " + cx + " " + (Z >> 4)); + } + } + + private void aimAt(int targetX) throws Exception { + exec("artest turret target " + DIM + " " + GUN_X + " " + Y + " " + Z + " " + + (targetX + 0.5D) + " " + (Y + 0.5D) + " " + (Z + 0.5D)); + } + + // ---- reading + + private String stageAt(int x) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + Z); + } + + private int stage(String json) { + return extract(json, "stage"); + } + + private void place(String block, int x, int y, int z) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + y + " " + z + " " + block); + assertTrue("failed to place " + block + ": " + resp, resp.contains("\"placed\":true")); + } + + private static int extract(String json, String key) { + Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+|true|false)").matcher(json); + if (!m.find()) { + return -1; + } + String v = m.group(1); + if ("true".equals(v)) { + return 1; + } + if ("false".equals(v)) { + return 0; + } + return Integer.parseInt(v); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/SpacedArmourIsAskedTwiceE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/server/SpacedArmourIsAskedTwiceE2ETest.java new file mode 100644 index 000000000..d78322655 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/SpacedArmourIsAskedTwiceE2ETest.java @@ -0,0 +1,132 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * A round that comes out the far side of one plate is asked by the next one, in the SAME tick. + * + *

      Spaced armour is the arrangement the reactive family exists for: two thin charges with air + * between them stop more than one charge, because the second is asked only once the first is gone. + * That claim was false for anything faster than a slow round. When a body passed through, the + * substrate moved it the WHOLE of the tick's remaining travel in one step — not the distance the walk + * had actually covered — so a plate standing inside that remaining travel was stepped straight over + * and never consulted. The round arrived beyond it with its energy intact and the plate still + * standing, unmarked and unspent.

      + * + *

      The two legs differ in ONE thing: how far the round can travel in a tick. Slow, the second plate + * is inside the next tick's travel and gets its question either way; fast, it is inside THIS tick's, + * which is the case the bug lived in. Without the slow leg a test could pass because nothing ever + * reached the second plate at all.

      + */ +public class SpacedArmourIsAskedTwiceE2ETest extends AbstractSharedServerTest { + + private static final int DIM = 0; + private static final int Y = 84; + private static final int X = 11_200; + /** Two lanes, so the two legs cannot inherit each other's spent charges. */ + private static final int SLOW_Z = 11_300, FAST_Z = 11_320; + + /** The gap: wide enough that the second plate is a separate meeting, not the same voxel. */ + private static final int SECOND_PLATE_OFFSET = 3; + + /** + * Slow enough that one tick cannot span the gap; fast enough that another OVERSHOOTS it. + * + *

      The fast number is not "large": it is chosen so that what is left of the tick after the + * first plate lands the round well BEYOND the second one. A first attempt used 6, which — from + * three blocks out, with the second plate three further on — left exactly enough travel to land + * the round on the second plate's own voxel, where the next tick met it anyway. The test passed + * with the fix removed. An overshoot has to be unambiguous or the experiment measures the + * arithmetic and not the mechanic.

      + */ + private static final double SLOW = 0.45D; + private static final double FAST = 12.0D; + + /** How far in front of the first plate a round is admitted. Short, so the fast leg overshoots. */ + private static final double MUZZLE_STANDOFF = 1.0D; + + private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)"); + + /** + * The claim, on the arrangement that makes it matter. + * + *

      Both plates must be gone. A reactive charge removes itself when it swallows, so "gone" is + * the plate's own report that it was asked; a plate still standing was stepped over.

      + */ + @Test + public void aRoundThroughTheFirstPlateIsAskedByTheSecondHoweverFastItIsGoing() throws Exception { + for (double speed : new double[]{SLOW, FAST}) { + int lane = speed == SLOW ? SLOW_Z : FAST_Z; + String leg = speed == SLOW ? "slow" : "fast"; + + prepare(lane); + place(X, lane, "advancedrocketry:reactivePlate"); + place(X + SECOND_PLATE_OFFSET, lane, "advancedrocketry:reactivePlate"); + + long id = fire(lane, speed); + assertTrue("the " + leg + " round was refused, so this leg measured nothing", id >= 0); + awaitGone(id); + + assertTrue("the FIRST plate survived the " + leg + " round: nothing arrived at all, so" + + " this leg says nothing about the second one", gone(X, lane)); + assertTrue("the second plate is still standing after a " + leg + " round came through the" + + " first one. It was never asked: the round was advanced by the whole of the" + + " tick's remaining travel instead of by the distance the walk covered, and" + + " stepped clean over it — so spaced armour is one plate with a decoration" + + " behind it", gone(X + SECOND_PLATE_OFFSET, lane)); + } + } + + // ---- driving + + /** Enough to spend both charges and still be moving: the subject is the QUESTION, not the budget. */ + private long fire(int lane, double speed) throws Exception { + return idOf(exec("artest shot fire " + DIM + " " + (X - MUZZLE_STANDOFF) + " " + (Y + 0.5D) + " " + + (lane + 0.5D) + " " + speed + " 0 0 200000 1200 KINETIC 0.25 1.0")); + } + + private void place(int x, int lane, String block) throws Exception { + String resp = exec("artest place " + DIM + " " + x + " " + Y + " " + lane + " " + block); + assertTrue("failed to place " + block + " at " + x + "," + lane + ": " + resp, + resp.contains("\"placed\":true")); + } + + private void prepare(int lane) throws Exception { + assertTrue("chunk warmup failed", exec("artest chunk warmup " + DIM + " " + ((X - 16) >> 4) + + " " + ((lane - 16) >> 4) + " " + ((X + 40) >> 4) + " " + ((lane + 16) >> 4)) + .contains("\"ok\":true")); + assertTrue("could not clear the lane", exec("artest fill " + DIM + " " + (X - 8) + " " + + (Y - 2) + " " + (lane - 3) + " " + (X + 40) + " " + (Y + 4) + " " + (lane + 3) + + " minecraft:air").contains("\"ok\":true")); + } + + // ---- reading + + private void awaitGone(long id) throws Exception { + long deadline = System.currentTimeMillis() + 20_000L; + while (System.currentTimeMillis() < deadline + && exec("artest shot read " + DIM + " " + id).contains("\"present\":true")) { + Thread.sleep(100L); + } + } + + /** A reactive charge that was asked spent itself, so the voxel it held is air. */ + private boolean gone(int x, int lane) throws Exception { + return exec("artest damage stage " + DIM + " " + x + " " + Y + " " + lane) + .contains("\"block\":\"minecraft:air\""); + } + + private static long idOf(String json) { + Matcher m = ID.matcher(json); + return m.find() ? Long.parseLong(m.group(1)) : -1L; + } + + private String exec(String command) throws Exception { + return String.join("\n", client().execute(command)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java index daeaaa443..a9da01a74 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/BeamReplicationCadenceTest.java @@ -27,6 +27,14 @@ public class BeamReplicationCadenceTest { private static final BlockPos GUN = new BlockPos(100, 70, 100); + /** A dark gun offers no line at all, which is the shape "not burning" has on the wire. */ + private static final java.util.List NO_LINE = java.util.Collections.emptyList(); + + /** The ordinary beam: two points. A bent one would have more, and the cadence does not care. */ + private static java.util.List line(Vec3d from, Vec3d to) { + return java.util.Arrays.asList(from, to); + } + private static final Vec3d MUZZLE = new Vec3d(100.5D, 74.0D, 100.5D); private static final Vec3d TARGET = new Vec3d(140.5D, 74.0D, 100.5D); @@ -40,7 +48,7 @@ public void aDarkGunSaysNothingAtAll() { assertFalse("a gun that is not burning, and was not burning last time anybody was told," + " sent a packet on tick " + tick + " — every idle gun in the world would then" + " be paying for a beam it does not have", - channel.offer(tick, PHASE, false, null, null)); + channel.offer(tick, PHASE, false, NO_LINE)); } } @@ -49,20 +57,20 @@ public void theFirstTickOfBurningIsAnnouncedAtOnce() { BeamReplication.Channel channel = new BeamReplication.Channel(); assertTrue("the tick a beam lit was not announced: a client is told nothing else about a" + " beam, so one that is not announced is one nobody can see", - channel.offer(0L, PHASE, true, MUZZLE, TARGET)); + channel.offer(0L, PHASE, true, line(MUZZLE, TARGET))); } @Test public void goingOutIsAnnouncedOnceAndThenTheGunIsQuietAgain() { BeamReplication.Channel channel = new BeamReplication.Channel(); - channel.offer(0L, PHASE, true, MUZZLE, TARGET); + channel.offer(0L, PHASE, true, line(MUZZLE, TARGET)); assertTrue("the beam went out and nobody was told: the client would hold the last segment it" + " was sent, drawing a beam from a gun that has stopped firing", - channel.offer(1L, PHASE, false, null, null)); + channel.offer(1L, PHASE, false, NO_LINE)); for (long tick = 2; tick < 60; tick++) { assertFalse("the gun kept announcing that it is not burning, on tick " + tick, - channel.offer(tick, PHASE, false, null, null)); + channel.offer(tick, PHASE, false, NO_LINE)); } } @@ -78,7 +86,7 @@ public void aSteadyBurnIsRepeatedOftenEnoughThatTheClientNeverDropsIt() { int sinceSent = 0; int sent = 0; for (long tick = 0; tick < 400; tick++) { - if (channel.offer(tick, PHASE, true, MUZZLE, TARGET)) { + if (channel.offer(tick, PHASE, true, line(MUZZLE, TARGET))) { sent++; longestSilence = Math.max(longestSilence, sinceSent); sinceSent = 0; @@ -100,19 +108,19 @@ public void aSteadyBurnIsRepeatedOftenEnoughThatTheClientNeverDropsIt() { @Test public void anAimThatIsMovingIsAnnouncedAsItMoves() { BeamReplication.Channel channel = new BeamReplication.Channel(); - channel.offer(0L, PHASE, true, MUZZLE, TARGET); + channel.offer(0L, PHASE, true, line(MUZZLE, TARGET)); // The gun tracks a target across its front: one tick later the far end is metres away from // where the client was told it was. Vec3d swung = new Vec3d(TARGET.x, TARGET.y, TARGET.z + 4.0D); assertTrue("the beam swung four blocks across and the client was not told: it would be drawn" + " burning into whatever it was pointed at half a second ago", - channel.offer(1L, PHASE, true, MUZZLE, swung)); + channel.offer(1L, PHASE, true, line(MUZZLE, swung))); // And the muzzle itself moves when the gun is on a ship under way. Vec3d carried = new Vec3d(MUZZLE.x + 3.0D, MUZZLE.y, MUZZLE.z); assertTrue("the gun itself moved and the client was not told: a beam on a moving ship would" - + " hang in the air behind it", channel.offer(2L, PHASE, true, carried, swung)); + + " hang in the air behind it", channel.offer(2L, PHASE, true, line(carried, swung))); } /** @@ -135,7 +143,7 @@ public void gunsSittingSideBySideDoNotBeatInUnison() { public void theClientDrawsNothingUntilItIsTold() { ClientBeamTracker.clear(); assertEquals("the client's beam tracker did not start empty", 0, ClientBeamTracker.count()); - ClientBeamTracker.lit(GUN.toLong(), MUZZLE, TARGET); + ClientBeamTracker.lit(GUN.toLong(), line(MUZZLE, TARGET)); assertEquals("a beam the client was told about is not being drawn", 1, ClientBeamTracker.count()); ClientBeamTracker.extinguished(GUN.toLong()); @@ -150,7 +158,7 @@ public void theClientDrawsNothingUntilItIsTold() { @Test public void aBeamNobodyMentionsAgainStopsBeingDrawn() { ClientBeamTracker.clear(); - ClientBeamTracker.lit(GUN.toLong(), MUZZLE, TARGET); + ClientBeamTracker.lit(GUN.toLong(), line(MUZZLE, TARGET)); for (int tick = 0; tick < ClientBeamTracker.stalenessTicks(); tick++) { ClientBeamTracker.tick(); } From f0ef5f9b9699bd66941499dbe5b90c536be34fce Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 31 Aug 2026 05:54:43 +0000 Subject: [PATCH 35/35] docs: point this repository at Stellurgy Development continues in the standalone StannisMod/Stellurgy repository as of 2026-08-31. This repository keeps the Advanced Rocketry line: history, issue and pull request numbering, and the fork relationships. --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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.**