Skip to content
This repository was archived by the owner on Aug 31, 2026. It is now read-only.

Commit 55f874b

Browse files
committed
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
1 parent 4dfe1bc commit 55f874b

5 files changed

Lines changed: 327 additions & 11 deletions

File tree

src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,14 @@ private void handleShot(MinecraftServer server, ICommandSender sender, String[]
325325
zmaster587.advancedRocketry.projectile.Shot shot =
326326
registry.get(Long.parseLong(args[2]));
327327
if (shot == null) {
328-
zmaster587.advancedRocketry.api.projectile.ShotEndReason ended =
329-
registry.endReasonOf(Long.parseLong(args[2]));
328+
zmaster587.advancedRocketry.projectile.ShotRegistry.Ending ended =
329+
registry.endingOf(Long.parseLong(args[2]));
330330
send(sender, "{\"ok\":true,\"present\":false,\"ended\":\""
331-
+ (ended == null ? "" : ended.name()) + "\",\"count\":" + registry.count() + "}");
331+
+ (ended == null ? "" : ended.getReason().name()) + "\""
332+
+ (ended == null ? "" : ",\"endX\":" + ended.getPoint().x
333+
+ ",\"endY\":" + ended.getPoint().y
334+
+ ",\"endZ\":" + ended.getPoint().z)
335+
+ ",\"count\":" + registry.count() + "}");
332336
return;
333337
}
334338
send(sender, "{\"ok\":true,\"present\":true,\"shot\":" + shotJson(shot) + "}");

src/main/java/zmaster587/advancedRocketry/projectile/ShotRegistry.java

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ public class ShotRegistry extends WorldSavedData {
4141
private final Map<Long, Shot> shots = new LinkedHashMap<>();
4242

4343
/**
44-
* Why recently ended shots ended. Not world state and not saved — the oldest is dropped once the
44+
* How recently ended shots ended. Not world state and not saved — the oldest is dropped once the
4545
* map is full, so a caller that waits too long is told nothing rather than told a guess.
4646
*/
47-
private final Map<Long, ShotEndReason> endings = new LinkedHashMap<Long, ShotEndReason>() {
47+
private final Map<Long, Ending> endings = new LinkedHashMap<Long, Ending>() {
4848
@Override
49-
protected boolean removeEldestEntry(Map.Entry<Long, ShotEndReason> eldest) {
49+
protected boolean removeEldestEntry(Map.Entry<Long, Ending> eldest) {
5050
return size() > ENDINGS_REMEMBERED;
5151
}
5252
};
@@ -102,19 +102,44 @@ public void remove(long id) {
102102
* gone: a weapon asks about its round after the fact, and "it is not in the registry" cannot tell
103103
* a hit from a round that timed out half a kilometre short.
104104
*/
105-
void end(long id, ShotEndReason reason) {
105+
void end(long id, ShotEndReason reason, Vec3d where) {
106106
remove(id);
107-
endings.put(id, reason);
107+
endings.put(id, new Ending(reason, where));
108108
}
109109

110110
/**
111-
* Why the shot with this id ended, or null if it is still up or was forgotten. Deliberately NOT
111+
* How the shot with this id ended, or null if it is still up or was forgotten. Deliberately NOT
112112
* persisted: it is an answer to a question asked seconds later, not world state.
113113
*/
114-
public ShotEndReason endReasonOf(long id) {
114+
public Ending endingOf(long id) {
115115
return endings.get(id);
116116
}
117117

118+
/**
119+
* How a shot ended: the reason, and the WORLD point it ended at. The point is world-frame even
120+
* when the thing it hit was a ship's block, which lives millions of blocks away in a shipyard
121+
* subspace — a weapon showing an impact where its round actually stopped needs the place the
122+
* player can see, not the address the block is filed under.
123+
*/
124+
public static final class Ending {
125+
private final ShotEndReason reason;
126+
private final Vec3d point;
127+
128+
private Ending(ShotEndReason reason, Vec3d point) {
129+
this.reason = reason;
130+
this.point = point;
131+
}
132+
133+
public ShotEndReason getReason() {
134+
return reason;
135+
}
136+
137+
/** WORLD point, never null: a shot that ended is always somewhere. */
138+
public Vec3d getPoint() {
139+
return point;
140+
}
141+
}
142+
118143
/**
119144
* Drop everything in flight. Not a game action — nothing in the mod calls it. It exists because a
120145
* shared test server hands one scenario's rounds to the next, and a suite that has to reason

src/main/java/zmaster587/advancedRocketry/projectile/ShotSubstrate.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ public static void tick(World world) {
8888
for (Shot shot : shots) {
8989
ShotEndReason end = step(world, shot);
9090
if (end != null) {
91-
registry.end(shot.getId(), end);
91+
// The shot's own position IS where it ended: every terminal branch of the step sets
92+
// it to the crossing point before returning, so there is one place that decides
93+
// where a round stopped rather than two that could disagree.
94+
registry.end(shot.getId(), end, shot.getPosition());
9295
}
9396
}
9497
registry.markDirty();
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package zmaster587.advancedRocketry.test.server;
2+
3+
import org.junit.Assume;
4+
import org.junit.Test;
5+
6+
import java.util.regex.Matcher;
7+
import java.util.regex.Pattern;
8+
9+
import static org.junit.Assert.assertTrue;
10+
11+
/**
12+
* A shot that meets a SHIP — the case the whole substrate exists for, and the one its world-block
13+
* tests cannot reach.
14+
*
15+
* <p>A ship's blocks are not where the ship appears to be: they sit at fixed addresses in a shipyard
16+
* subspace millions of blocks away while the hull flies around. So a swept segment computed in world
17+
* coordinates crosses <b>nothing</b> — the world frame is empty air where the hull visibly is. The
18+
* substrate therefore maps both ends of the segment into each candidate ship's frame, traverses
19+
* there, and maps the crossing point back out. Three conversions, none of which announces itself when
20+
* it is wrong: a frame error here is not an exception, it is a round that flies through a hull.</p>
21+
*
22+
* <h3>What makes this evidence rather than a coincidence</h3>
23+
* <p>Two controls, both asserted before any conclusion is drawn. The world frame at the target must
24+
* genuinely hold <b>air</b>, so a hit cannot have come from the world-frame traversal; and the
25+
* subject block must be undamaged at its subspace address beforehand, so "damaged afterwards" is
26+
* about this shot. The end point is then checked against the ship's WORLD position — with the
27+
* mapping-back-out leg deleted, a shot would report ending five million blocks away in a shipyard
28+
* nobody can see, and every other assertion here would still pass.</p>
29+
*/
30+
public class ShotHitsShipHullE2ETest extends AbstractSharedServerTest {
31+
32+
private static final Pattern BUILDER_POS =
33+
Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]");
34+
35+
/** A build site of this class's own, clear of the other ship scenarios on this shared server. */
36+
private static final int SRC_X = 6400, SRC_Y = 80, SRC_Z = 6400;
37+
/** Where the ship is moved to: in the air, INSIDE build height, so "the world is air there" is a
38+
* measurement rather than a consequence of being above the world's ceiling. */
39+
private static final int FAR_X = 6400, FAR_Y = 150, FAR_Z = 8800;
40+
41+
/** Fast enough that one tick's segment crosses the whole hull — the case a point test misses. */
42+
private static final double SPEED = 40.0D;
43+
/** Enough budget to be spent on more than the first block it meets. */
44+
private static final int ENERGY = 200000;
45+
46+
@Test
47+
public void aShotStopsAtTheHullOfAMovedShipAndDamagesItsOwnBlock() throws Exception {
48+
Assume.assumeTrue("needs Valkyrien Skies on the server classpath", serverHasVs());
49+
exec("artest vs permaload true");
50+
exec("artest damage clear-impacts");
51+
exec("artest shot clear 0");
52+
53+
String shipId = buildAndMoveShip();
54+
55+
// A block of this ship whose subspace address we know: its pilot seat.
56+
String seat = exec("artest vs find-seat 0 id " + shipId);
57+
assertTrue("could not locate the ship's seat, so there is no known block to aim at: " + seat,
58+
seat.contains("\"seatFound\":true"));
59+
int subX = extractInt(seat, "seatX"), subY = extractInt(seat, "seatY"),
60+
subZ = extractInt(seat, "seatZ");
61+
62+
String mapped = exec("artest vs to-world 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z
63+
+ " " + subX + " " + subY + " " + subZ);
64+
assertTrue("the seat's subspace address could not be mapped to a world point: " + mapped,
65+
mapped.contains("\"ok\":true"));
66+
double worldX = extractDouble(mapped, "worldX");
67+
double worldY = extractDouble(mapped, "worldY");
68+
double worldZ = extractDouble(mapped, "worldZ");
69+
70+
// ARRANGEMENT CONTROL — the mapped point is actually on the ship as the world sees it. If it
71+
// is not, everything below measures a broken fixture rather than the substrate.
72+
String moved = exec("artest vs ship-info 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z);
73+
assertTrue("the moved ship is not managed at its new position: " + moved,
74+
moved.contains("\"managed\":true"));
75+
double shipX = extractDouble(moved, "posX"), shipY = extractDouble(moved, "posY"),
76+
shipZ = extractDouble(moved, "posZ");
77+
double offHull = Math.sqrt(sq(worldX - shipX) + sq(worldY - shipY) + sq(worldZ - shipZ));
78+
assertTrue("the seat's mapped world point (" + worldX + "," + worldY + "," + worldZ + ") is "
79+
+ offHull + " blocks from the ship's own world position: the fixture, not the"
80+
+ " substrate, is what this run would be measuring. mapped=" + mapped, offHull < 64.0D);
81+
82+
// CONTROL 1 — the WORLD frame is air along the line of fire. A hit therefore cannot have come
83+
// from the world-frame traversal, which is the only other way this substrate finds anything.
84+
for (int drop = -4; drop <= 4; drop++) {
85+
String worldBlock = exec("artest damage stage 0 " + (int) Math.floor(worldX) + " "
86+
+ ((int) Math.floor(worldY) + drop) + " " + (int) Math.floor(worldZ));
87+
assertTrue("the world frame holds a block at the target, " + drop + " blocks off the seat: "
88+
+ worldBlock + ". A shot stopping here would prove nothing about ship frames",
89+
worldBlock.contains("\"block\":\"minecraft:air\""));
90+
}
91+
92+
// CONTROL 2 — the subject is undamaged at its SUBSPACE address, where the ship's blocks are.
93+
String before = stage(subX, subY, subZ);
94+
assertTrue("the seat's subspace address holds no block, so nothing below is about the ship: "
95+
+ before, !before.contains("\"block\":\"minecraft:air\""));
96+
assertTrue("the subject block is already damaged before the shot: " + before,
97+
readLong(before, "stage") == 0);
98+
99+
// Fire straight down through the seat's WORLD position, from clear air above it.
100+
long id = readLong(exec("artest shot fire 0 " + worldX + " " + (worldY + 30.0D) + " " + worldZ
101+
+ " 0 " + (-SPEED) + " 0 " + ENERGY + " 40"), "id");
102+
assertTrue("the launch was refused, so nothing else here means anything", id > 0);
103+
exec("artest shield tick 0");
104+
105+
String after = exec("artest shot read 0 " + id);
106+
assertTrue("the shot is still in flight after a step that crossed the hull — a segment computed"
107+
+ " in the world frame finds nothing where a ship visibly is, which is exactly what"
108+
+ " this substrate maps around: " + after, after.contains("\"present\":false"));
109+
assertTrue("the shot stopped, but not by meeting structure: " + after,
110+
"STRUCTURE_IMPACT".equals(extractString(after, "ended")));
111+
112+
// The damage landed on the SHIP's own block, at its subspace address.
113+
String hull = stage(subX, subY, subZ);
114+
boolean staged = readLong(hull, "stage") > 0;
115+
boolean destroyed = hull.contains("\"wasDestroyed\":true")
116+
|| hull.contains("\"block\":\"minecraft:air\"");
117+
assertTrue("the shot reported hitting structure but the ship's own block is untouched at its"
118+
+ " subspace address (before=" + before + " after=" + hull + "): the impact was handed"
119+
+ " over in the wrong frame, or to the wrong target", staged || destroyed);
120+
121+
// And the shot ended in WORLD coordinates. Without the mapping back out it would report
122+
// ending at a shipyard address millions of blocks from anything a player can see — and every
123+
// assertion above would still have passed.
124+
double endX = readDouble(after, "endX"), endY = readDouble(after, "endY"),
125+
endZ = readDouble(after, "endZ");
126+
double offSeat = Math.sqrt(sq(endX - worldX) + sq(endY - worldY) + sq(endZ - worldZ));
127+
assertTrue("the shot ended at (" + endX + "," + endY + "," + endZ + "), " + offSeat
128+
+ " blocks from the world point it was fired through: the crossing point was never"
129+
+ " mapped out of the ship's frame", offSeat < 16.0D);
130+
}
131+
132+
/** Build the fixture, assemble it into a ship and move it far from where it was built. */
133+
private String buildAndMoveShip() throws Exception {
134+
clearArea(SRC_X, SRC_Z);
135+
String coords = placeFixture(SRC_X, SRC_Y, SRC_Z, "with-pilot-seat");
136+
String asm = exec("artest rocket assemble 0 " + coords);
137+
assertTrue("with VS an AFC-bearing build must become a ship, not a rocket: " + asm,
138+
asm.contains("\"rocketCount\":0"));
139+
140+
String info = null;
141+
for (int attempt = 0; attempt < 40; attempt++) {
142+
exec("artest vs load-ships 0");
143+
info = exec("artest vs ship-info 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z);
144+
if (info.contains("\"managed\":true")) {
145+
break;
146+
}
147+
Thread.sleep(250);
148+
}
149+
assertTrue("the build never became a ship managed at its build site: " + info,
150+
info != null && info.contains("\"managed\":true"));
151+
152+
String tp = exec("artest vs teleport-ship 0 " + SRC_X + " " + SRC_Y + " " + SRC_Z
153+
+ " " + FAR_X + " " + FAR_Y + " " + FAR_Z);
154+
assertTrue("the ship could not be moved, so it never left the world blocks it was built from: "
155+
+ tp, tp.contains("\"ok\":true"));
156+
exec("artest vs unpark 0 " + FAR_X + " " + FAR_Y + " " + FAR_Z);
157+
return extractString(info, "id");
158+
}
159+
160+
private String stage(int x, int y, int z) throws Exception {
161+
return exec("artest damage stage 0 " + x + " " + y + " " + z);
162+
}
163+
164+
private void clearArea(int baseX, int baseZ) throws Exception {
165+
int cx1 = (baseX - 4) >> 4, cz1 = (baseZ - 4) >> 4;
166+
int cx2 = (baseX + 20) >> 4, cz2 = (baseZ + 20) >> 4;
167+
assertTrue("chunk warmup failed", exec("artest chunk warmup 0 " + cx1 + " " + cz1 + " "
168+
+ cx2 + " " + cz2).contains("\"ok\":true"));
169+
assertTrue("pre-clear failed", exec("artest fill 0 " + (baseX - 4) + " " + (SRC_Y - 2) + " "
170+
+ (baseZ - 4) + " " + (baseX + 20) + " " + (SRC_Y + 12) + " " + (baseZ + 20)
171+
+ " minecraft:air").contains("\"ok\":true"));
172+
}
173+
174+
private String placeFixture(int baseX, int baseY, int baseZ, String variant) throws Exception {
175+
String fixture = exec("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " "
176+
+ variant);
177+
assertTrue("fixture (" + variant + ") failed: " + fixture, fixture.contains("\"ok\":true"));
178+
Matcher bp = BUILDER_POS.matcher(fixture);
179+
assertTrue("fixture (" + variant + ") missing builderPos: " + fixture, bp.find());
180+
return bp.group(1) + " " + bp.group(2) + " " + bp.group(3);
181+
}
182+
183+
private boolean serverHasVs() throws Exception {
184+
return exec("artest vs available").contains("\"available\":true");
185+
}
186+
187+
private static double sq(double v) {
188+
return v * v;
189+
}
190+
191+
private String exec(String cmd) throws Exception {
192+
return String.join("\n", client().execute(cmd));
193+
}
194+
195+
private static long readLong(String json, String key) {
196+
Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json);
197+
assertTrue("no " + key + " field in: " + json, m.find());
198+
return Long.parseLong(m.group(1));
199+
}
200+
201+
private static double readDouble(String json, String key) {
202+
Matcher m = Pattern.compile("\"" + key + "\":(-?[0-9][0-9.eE+-]*)").matcher(json);
203+
assertTrue("no " + key + " field in: " + json, m.find());
204+
return Double.parseDouble(m.group(1));
205+
}
206+
207+
private static int extractInt(String json, String key) {
208+
Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+)").matcher(json);
209+
return m.find() ? Integer.parseInt(m.group(1)) : Integer.MIN_VALUE;
210+
}
211+
212+
private static double extractDouble(String json, String key) {
213+
Matcher m = Pattern.compile("\"" + key + "\":(-?\\d+(?:\\.\\d+)?)").matcher(json);
214+
return m.find() ? Double.parseDouble(m.group(1)) : 0.0;
215+
}
216+
217+
private static String extractString(String json, String key) {
218+
Matcher m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(json);
219+
return m.find() ? m.group(1) : null;
220+
}
221+
}

0 commit comments

Comments
 (0)