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

Commit 1b4af48

Browse files
committed
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
1 parent d20df0a commit 1b4af48

6 files changed

Lines changed: 171 additions & 27 deletions

File tree

src/main/java/zmaster587/advancedRocketry/api/damage/ContactResult.java

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
/**
66
* What a block answered when a travelling body met it.
77
*
8-
* <h3>Three states, four behaviours</h3>
8+
* <h3>Three behaviours, and a way to have none</h3>
99
* <ul>
10+
* <li>{@link #noOpinion} — <b>this block has nothing to say about THIS body</b>, and the default law
11+
* applies exactly as if it answered nothing at all. It is not a behaviour, it is a declining to
12+
* have one, and it exists because the alternative was to decline by saying
13+
* {@code passedThrough(everything)} — which is a real answer meaning "through, for free". A
14+
* mirror shipped saying that about slugs and became an armour plate that kinetic fire could
15+
* neither pay for nor break.</li>
1016
* <li>{@link #passedThrough} — the body carries on, worth less. The default: it is what an ordinary
1117
* block does, and what "weakened penetration" means.</li>
1218
* <li>{@link #stopped} — nothing continues past this block. <b>Reactive armour is this</b>, plus the
@@ -24,24 +30,51 @@
2430
*/
2531
public final class ContactResult {
2632

33+
/**
34+
* The one instance of "nothing to say". A singleton because it carries no facts: two declinings
35+
* are the same declining, and giving it a residual energy would invite somebody to read one.
36+
*/
37+
private static final ContactResult NO_OPINION = new ContactResult(false, 0, null, true);
38+
2739
private final boolean stopped;
2840
private final int residualEnergy;
2941
private final Vec3d deflectedVelocity;
42+
private final boolean noOpinion;
3043

31-
private ContactResult(boolean stopped, int residualEnergy, Vec3d deflectedVelocity) {
44+
private ContactResult(boolean stopped, int residualEnergy, Vec3d deflectedVelocity,
45+
boolean noOpinion) {
3246
this.stopped = stopped;
3347
this.residualEnergy = Math.max(0, residualEnergy);
3448
this.deflectedVelocity = deflectedVelocity;
49+
this.noOpinion = noOpinion;
50+
}
51+
52+
/**
53+
* This block declines to answer for this body: the default law applies, exactly as it does for the
54+
* two thousand blocks that implement nothing at all.
55+
*
56+
* <p>A responder answers for the arrivals it has a mechanism for and declines for the rest —
57+
* a mirror has a law about light and none about a solid round, and the round should then be priced
58+
* and resisted like any other piece of glass. Whoever declines here is asking for the ordinary
59+
* treatment, not asking to be skipped.</p>
60+
*/
61+
public static ContactResult noOpinion() {
62+
return NO_OPINION;
63+
}
64+
65+
/** True when this block declined to answer and the default law should decide instead. */
66+
public boolean isNoOpinion() {
67+
return noOpinion;
3568
}
3669

3770
/** The body carries on along its own course with {@code residualEnergy} left. */
3871
public static ContactResult passedThrough(int residualEnergy) {
39-
return new ContactResult(false, residualEnergy, null);
72+
return new ContactResult(false, residualEnergy, null, false);
4073
}
4174

4275
/** Nothing continues past this block. */
4376
public static ContactResult stopped() {
44-
return new ContactResult(true, 0, null);
77+
return new ContactResult(true, 0, null, false);
4578
}
4679

4780
/**
@@ -55,7 +88,7 @@ public static ContactResult deflected(Vec3d newVelocity, int residualEnergy) {
5588
if (newVelocity == null || newVelocity.lengthVector() <= 1.0E-9D) {
5689
return stopped();
5790
}
58-
return new ContactResult(false, residualEnergy, newVelocity);
91+
return new ContactResult(false, residualEnergy, newVelocity, false);
5992
}
6093

6194
/** True when nothing continues past the block that answered. */

src/main/java/zmaster587/advancedRocketry/api/damage/IContactResponder.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ public interface IContactResponder {
2121

2222
/**
2323
* Answer for one body meeting this block. Never null: return
24-
* {@link ContactResult#passedThrough(int)} to decline having an opinion.
24+
* {@link ContactResult#noOpinion()} to decline having one, and the default law decides instead —
25+
* price, stages, ricochet, the lot — exactly as it does for a block that implements nothing.
26+
*
27+
* <p><b>Do not decline with {@link ContactResult#passedThrough(int)}.</b> That is an answer, and
28+
* what it says is "through, carrying this much", so declining with the arriving energy says
29+
* "through, for free". This javadoc told implementers to do exactly that until 2026-08-19, and
30+
* mirror plating followed it: a solid round crossed the film spending nothing and left it standing,
31+
* so the one armour a beam could strip was the one kinetic fire could not.</p>
2532
*
2633
* <p>The world is passed rather than carried on the {@link Contact} on purpose. A contact states
2734
* the FACTS of a meeting — that is what lets a held beam, which is not a shot in any registry, use

src/main/java/zmaster587/advancedRocketry/block/BlockMirrorPlating.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ public ContactResult onContact(World world, Contact contact) {
6868
return null;
6969
}
7070
if (!isRadiant(contact.getKind())) {
71-
// A mirror is glass and foil. A slug does not care that it is shiny.
72-
return ContactResult.passedThrough(contact.getEnergy());
71+
// A mirror is glass and foil, and a solid round does not care that it is shiny — but it
72+
// does have to get through it. Declining hands the meeting to the default law, which
73+
// prices the film off the table and the eighth of a voxel it fills and breaks it like any
74+
// other pane. Answering "passed through" here instead would let a round cross for nothing
75+
// and leave the plating standing, which made it armour that only its own counter could
76+
// remove.
77+
return ContactResult.noOpinion();
7378
}
7479

7580
int absorbed = (int) Math.ceil(contact.getEnergy() * (1.0D - reflectance));

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ public static Resolution resolve(World world, TravellingBody body, StructureCros
7777
IContactResponder responder = responderAt(world, hit.block);
7878
if (responder != null) {
7979
ContactResult answer = responder.onContact(world, contact);
80-
if (answer != null) {
80+
// Declining is not answering. A responder has a law about some arrivals and none about the
81+
// rest, and the rest must fall through to the ordinary treatment — otherwise the only
82+
// phrase available for "nothing to say" is one that means "through, for free".
83+
if (answer != null && !answer.isNoOpinion()) {
8184
// A block that answered for itself did not walk anything, so the body is advanced past
8285
// the block it was answered by — otherwise the next test finds the same block, asks
8386
// again, and a round argues with one plate until the tick's crossing budget runs out.

src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,9 @@ public void load() {
380380
ablationIndividual = readMap(gson, root, "ablationIndividual", mapType);
381381
ablationByRegex = readMap(gson, root, "ablationByRegex", mapType);
382382
toughnessByRegex = readMap(gson, root, "toughnessByRegex", linkedType);
383+
if (toughnessByRegex.isEmpty()) {
384+
toughnessByRegex = defaultToughnessByRegex();
385+
}
383386
toughnessMaterials = readMap(gson, root, "toughnessMaterials", mapType);
384387
if (toughnessMaterials.isEmpty()) {
385388
toughnessMaterials = defaultToughnessMaterials();
@@ -412,7 +415,7 @@ private void seedDefaults() {
412415
fallback = 0.1;
413416
fluidFallback = 0.001;
414417
toughnessIndividual = new HashMap<>();
415-
toughnessByRegex = new LinkedHashMap<>();
418+
toughnessByRegex = defaultToughnessByRegex();
416419
toughnessMaterials = defaultToughnessMaterials();
417420
toughnessFallback = 2.0;
418421
}
@@ -519,6 +522,33 @@ private static Map<String, Double> defaultMaterials() {
519522
* survive retuning is the ordering, because that is what a player perceives when a shot goes
520523
* through a window and stops in the plating.
521524
*/
525+
/**
526+
* Rows this mod ships for its own blocks, where the material alone gets them badly wrong.
527+
*
528+
* <p>Written as regexes rather than one row per block so that a family is priced as a family: the
529+
* three mirror films differ in how much light they return, not in how hard the glass is, and a
530+
* fourth tier should not need a fifth row.</p>
531+
*
532+
* <p><b>Mirror plating is glass and foil declared as {@code Material.IRON}</b> — iron because that
533+
* is what it is mined and sounded like, which then priced a mirror film as hull plate. It answers
534+
* a beam by its own law, so this row governs what it costs to smash: a solid round, an explosion,
535+
* anything with no optics in it. Reactive plating deliberately has NO row: its casing IS metal,
536+
* and what makes it interesting is the charge rather than what the charge is wrapped in.</p>
537+
*
538+
* <p>Seeded when the config carries no regex rows at all, exactly as the material table is — so a
539+
* pack cannot express "no regex rows whatsoever". That is a real limitation and it is inherited
540+
* rather than chosen; a pack that disagrees with a row overrides it by value, or by an individual
541+
* row, which outranks every regex.</p>
542+
*/
543+
private static Map<String, Double> defaultToughnessByRegex() {
544+
Map<String, Double> m = new LinkedHashMap<>();
545+
// LOWERCASE, and it is not a style choice: a registry name arrives here already lowercased,
546+
// so a pattern written the way the block was declared ("mirrorPlating...") matches nothing and
547+
// the row silently does not exist. The block keeps its declared price and nobody is told.
548+
m.put("advancedrocketry:mirrorplating.*", 1.0);
549+
return m;
550+
}
551+
522552
private static Map<String, Double> defaultToughnessMaterials() {
523553
Map<String, Double> m = new LinkedHashMap<>();
524554
m.put("AIR", 0.0);

src/test/java/zmaster587/advancedRocketry/test/server/ArmourBlocksAnswerForThemselvesE2ETest.java

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,27 @@ public class ArmourBlocksAnswerForThemselvesE2ETest extends AbstractSharedServer
2323
private static final int Y = 70, X = 2100;
2424
private static final int MIRROR_Z = 1200, MIRROR_SLUG_Z = 1220, BETTER_Z = 1240;
2525
private static final int REACTIVE_Z = 1260, REACTIVE_TWICE_Z = 1280, RAILGUN_Z = 1300;
26+
private static final int PRICE_Z = 1320;
2627

2728
private static final double SPEED = 0.45D;
2829
private static final Pattern ID = Pattern.compile("\"id\":(-?\\d+)");
2930
private static final Pattern VX = Pattern.compile("\"vx\":(-?[\\d.eE+-]+)");
31+
private static final Pattern STAGE_COST = Pattern.compile("\"stageCost\":(-?\\d+)");
3032

3133
/**
32-
* A mirror returns a beam and lets a slug through, and the difference is the kind in the contact
33-
* and nothing else — the same block, the same face, the same energy.
34+
* A mirror returns a beam and is SMASHED by a solid round, and the difference is the kind in the
35+
* contact and nothing else — the same block, the same face, the same energy.
36+
*
37+
* <p>The second half used to read "lets a solid round straight through", and it was pinning a
38+
* defect. A mirror has no OPTICAL opinion about a solid round, and it said so by answering "passed
39+
* through, carrying everything" — which is not "no opinion", it is "through, for free". So the
40+
* round paid nothing, the film was untouched, and the one armour a beam could strip was the one
41+
* kinetic fire could not. The block now DECLINES, and declining hands the meeting to the ordinary
42+
* law: the film is priced off the table and the eighth of a voxel it fills, and it breaks like the
43+
* glass it is.</p>
3444
*/
3545
@Test
36-
public void aMirrorReturnsABeamAndLetsASlugStraightThrough() throws Exception {
46+
public void aMirrorReturnsABeamAndIsSmashedByASolidRound() throws Exception {
3747
prepare(MIRROR_Z);
3848
prepare(MIRROR_SLUG_Z);
3949
place("advancedrocketry:mirrorPlatingAluminium", MIRROR_Z);
@@ -47,10 +57,13 @@ public void aMirrorReturnsABeamAndLetsASlugStraightThrough() throws Exception {
4757

4858
long slug = fire(MIRROR_SLUG_Z, 3_000, "KINETIC");
4959
assertTrue("the slug was refused", slug >= 0);
50-
assertTrue("a solid round bounced off glass and foil: a mirror has no opinion about a slug,"
51-
+ " and the kind in the contact is the only thing that separates the two cases: "
52-
+ read(slug), !awaitTurnedBack(slug));
53-
assertTrue("the mirror is still standing after a slug went through it", stillThere(MIRROR_SLUG_Z));
60+
assertTrue("a solid round bounced off glass and foil: a mirror has no OPTICAL opinion about"
61+
+ " a solid round, and the kind in the contact is the only thing that separates the"
62+
+ " two cases: " + read(slug), !awaitTurnedBack(slug));
63+
awaitGone(slug);
64+
assertTrue("the film is still standing after a solid round crossed it: then the round paid"
65+
+ " nothing for it, and a mirror is armour that only the weapon it was built to stop"
66+
+ " can remove", !stillThere(MIRROR_SLUG_Z));
5467
}
5568

5669
/**
@@ -67,16 +80,26 @@ public void aBetterMirrorSurvivesWhatKillsAWorseOne() throws Exception {
6780
// exceeds what the film sheds, and a thirtieth of it does not.
6881
int killsAluminium = 60_000;
6982

70-
// CONTROL, and the test is worthless without it: the same energy as a SLUG must leave the
71-
// plate standing. Ordinary damage does not know one mirror from another, so if it were doing
72-
// the work below, this is where it would show — and the first cut of this test passed with the
73-
// whole responder switched off, which is exactly what this catches.
74-
long slug = fire(BETTER_Z, killsAluminium, "KINETIC");
75-
assertTrue("the control round was refused", slug >= 0);
76-
awaitGone(slug);
77-
assertTrue("a slug carrying what the beams below carry destroyed the plating: then what kills"
78-
+ " a mirror here is ordinary damage, and nothing in this test is about mirrors",
79-
stillThere(BETTER_Z));
83+
// CONTROL, and the test is worthless without it — the first cut of this test passed with
84+
// the whole responder switched off. It used to be a solid round carrying the same energy,
85+
// which had to leave the plate standing; that stopped being available the day such a round
86+
// started paying for the film and breaking it, which is correct and kills the old control.
87+
//
88+
// This is the stronger replacement, and it aims at the mechanism rather than at one sample:
89+
// ordinary damage prices the two tiers IDENTICALLY, so it cannot produce a difference between
90+
// them at all. Whatever separates aluminium from gold below is therefore the reflectance, and
91+
// can be nothing else.
92+
placeAt(X + 4, BETTER_Z, "advancedrocketry:mirrorPlatingGold");
93+
long aluminiumCost = costOf(exec("artest damage stage " + DIM + " " + X + " " + Y + " "
94+
+ BETTER_Z));
95+
long goldCost = costOf(exec("artest damage stage " + DIM + " " + (X + 4) + " " + Y + " "
96+
+ BETTER_Z));
97+
assertTrue("the two mirror tiers cost different amounts to break by ordinary damage"
98+
+ " (aluminium=" + aluminiumCost + " gold=" + goldCost + "): then the ladder below can"
99+
+ " be produced without any mirror law at all, and this test measures the toughness"
100+
+ " table", aluminiumCost == goldCost);
101+
placeAt(X + 4, BETTER_Z, "minecraft:air");
102+
80103
long first = fire(BETTER_Z, killsAluminium, "BEAM");
81104
assertTrue("the beam was refused", first >= 0);
82105
awaitGone(first);
@@ -143,6 +166,49 @@ public void twiceTheReactiveVolumeEatsMoreOfTheSameImpact() throws Exception {
143166
clean(X + 2, RAILGUN_Z));
144167
}
145168

169+
/**
170+
* A mirror film is priced as the glass and foil it is, not as the hull plate its MATERIAL says.
171+
*
172+
* <p>Both plating families are declared {@code Material.IRON} — which is what they are mined and
173+
* sounded like — and the damage table resolves by material when nothing has written a row. That
174+
* priced a mirror film as solid hull.</p>
175+
*
176+
* <p><b>The comparator is REACTIVE plating, and the choice is the whole test.</b> The obvious
177+
* comparison — a film against a solid block of iron — passes whether or not the mirror has a row
178+
* of its own, because a film fills an eighth of its voxel and the volume alone makes it cheaper.
179+
* It would measure the occupancy factor and report it as evidence about the table. Reactive
180+
* plating is the same class, the same thickness and the same declared material, and it
181+
* deliberately has NO row: its casing IS metal, and what makes it interesting is the charge rather
182+
* than what the charge is wrapped in. So the two differ in exactly one thing, and a difference in
183+
* price can come from exactly one place.</p>
184+
*
185+
* <p>Only the ORDERING is claimed. The numbers behind it are balance and will move; an assertion
186+
* on them would go red the first time anyone retunes the table without breaking anything a player
187+
* would notice.</p>
188+
*/
189+
@Test
190+
public void aMirrorFilmCostsLessToBreakThanTheMetalItsMaterialClaims() throws Exception {
191+
prepare(PRICE_Z);
192+
place("advancedrocketry:mirrorPlatingAluminium", PRICE_Z);
193+
placeAt(X + 4, PRICE_Z, "advancedrocketry:reactivePlate");
194+
195+
String film = exec("artest damage stage " + DIM + " " + X + " " + Y + " " + PRICE_Z);
196+
String metal = exec("artest damage stage " + DIM + " " + (X + 4) + " " + Y + " " + PRICE_Z);
197+
long filmCost = costOf(film), metalCost = costOf(metal);
198+
199+
assertTrue("a mirror film costs what the identically shaped plating beside it costs (film="
200+
+ filmCost + " reactive=" + metalCost + "): the two differ only in that one has a row"
201+
+ " of its own, so this says the row is not being read at all and glass with foil on"
202+
+ " it still resists like hull plate. film=" + film + " reactive=" + metal,
203+
filmCost < metalCost);
204+
}
205+
206+
private static long costOf(String json) {
207+
Matcher m = STAGE_COST.matcher(json);
208+
assertTrue("no stageCost in: " + json, m.find());
209+
return Long.parseLong(m.group(1));
210+
}
211+
146212
// ---- driving
147213

148214
private long fire(int lane, int energy, String kind) throws Exception {

0 commit comments

Comments
 (0)