reverseeon-1.1.6-2.jar
Curse Maven Snippet
What's new
# Reverse Eon 1.1.6-2
## [1.1.6-2] — 2026-08-08
A second revision on top of 1.1.6. Carries the fixes a player reported against 1.1.6 itself, plus
the first new mechanic since it: the suitcase will now carry an animal to the island.
### The changelog is one file per version
`CHANGELOG.md` had grown to 1870 lines across 11 releases. It is now an **index**, with each release
in `changelogs/<version>.md` — readable on its own, and copy-pasteable to CurseForge without hunting
for where one version stops and the next starts.
- ⚠️ **The split is verified by round trip, not by eye.** `tools/split_changelog.py` slices the
original on its own `## [version]` headings, reassembles the slices, and compares the result to the
original **byte for byte before writing anything**. A dropped section fails nothing — the file just
gets shorter, and nobody rereads a changelog they already wrote. Confirmed after the fact too:
reassembling the 11 files reproduces all 1870 lines, differing only in two blank lines between
sections.
- ⚠️ **The index lists counts, not auto-summaries.** The first version used each release's first
`###` heading as a one-liner, which produced "Fixes" and "Bug Fixes" for half the releases — and for
1.1.6-2, which has 41 sections, presented one arbitrary section as though it were the release. An
index that summarises wrongly is worse than one that only points.
- **`tools/check_changelog.py`** keeps the two in sync: every link resolves, every file is linked, each
file holds exactly one version heading matching its filename, and the version in `build.gradle` has
a file to be written about. ⚠️ None of this can fail a build — a changelog cannot break a compile,
so nothing else in the project would ever notice. 8 break tests, all caught.
- ⓘ Fixed while in there: `context.md` claimed the newest entry was `[1.2.0]`, **a version that has
never existed**. It no longer restates the version at all — the checker compares against
`build.gradle` instead, which cannot go stale.
### The theme still did not play: the soundtrack jar was never rebuilt
Third round on this, and this time the answer came from looking at the artifacts instead of the source.
- **The shipped `reverseeon_soundtrack-1.0.0.jar` is from before the fix.** Its `sounds.json` has 19
events and **no `music.mini_boss`** — the `.ogg` is inside the jar, but nothing names it. The game
loads the jar, not the source tree, so every source-level check was green while the thing actually
running was a day old.
- The main mod jar **was** current — verified by reading the class bytes for `restartGrace`,
`flurryShot`, `FlurryCd` and `DATA_SECOND_PHASE`. So the code was never the problem this round.
- The `.ogg` itself is a valid Ogg Vorbis file (`OggS` magic, 2.6 MB), which rules out the other
common cause: an mp3 renamed to `.ogg`, which Minecraft cannot decode.
**`check_sounds.py` now checks the built jar, not just the source.** This is the check that would have
saved two rounds: it opens the newest `reverseeon_soundtrack-*.jar`, compares its `sounds.json`
against the source, and confirms every `.ogg` an entry names is actually packed. A jar built before
the last edit is indistinguishable from a code bug from inside the game — one `Missing sound` line,
and the feature just does not happen.
- It also checks the jar's **filename against `mod_version`**, and notes any older jars still sitting
in `build/libs`. ⚠️ Two jars with the same modid in one `mods` folder is a crash or a silent
coin-toss over which wins — and the loser is usually the new one, which looks exactly like "my
change did nothing".
### The boss theme still did not play — and the reason was mine
Adding the `sounds.json` entry was necessary but not sufficient. `MiniBossMusicHandler` re-checked
`SoundManager.isActive(current)` on the very next tick after `play()`.
- ⚠️ **The track is streamed** (`"stream": true`), so `isActive` reports false for a while after
`play()` while the stream spins up. That read as "the engine dropped it", so the handler built a
fresh `BossSound` — **every tick, forever**. The track never got far enough to be audible.
- `AuraMusicHandler` has carried a 100-tick `restartGrace` for exactly this reason since it was
written. I copied its recover-if-dropped branch and left the guard behind. Same grace now applies
here.
- ⚠️ The checker rule for it needs a **non-zero** assignment: `stop()` sets `restartGrace = 0`, so a
plain "is it assigned" search stays satisfied after the one line that arms it is deleted.
### It throws instead of punching — no melee at all, and a 20-second flurry
- **`MeleeAttackGoal` is gone from both phases.** It used to be kept for point-blank and removed on
the phase change, which gave the boss two different answers to the same situation and made the
escalation read as "it stopped punching" rather than "it changed weapons".
- **The flurry replaces it**: six flasks back to back, one every 0.2s, on a **20-second cooldown**.
- ⚠️ **No minimum range, deliberately.** The ordinary throw refuses inside 3 blocks, so the flurry is
the boss's only answer when a player is stood on top of it — putting a distance check on it would
leave the boss doing nothing at exactly the range this attack exists for.
- ⚠️ **Flat and fast, not the lobbed arc the ordinary throw uses.** A high arc thrown at something two
blocks away sails over its head.
- ⚠️ **Only safe because `ForgetMeNotFlask` skips its own thrower.** On a vanilla `ThrownPotion` this
would poison the boss six times in a row — the flurry could not have existed before the custom
projectile did. The checker asserts the *constructed projectile*, not the class name anywhere in the
method: `ForgetMeNotFlask.flaskOf(...)` builds the ItemStack and keeps the name alive even after the
entity is swapped for a vanilla one.
- ⚠️ **A flurry in progress is checked before every other cooldown**, and abandoned if the target is
lost. Otherwise a bomb or a spell wins a tick mid-burst and the remaining shots dribble out one at a
time between other attacks.
- **The obsolete checker rules were replaced, not deleted.** "Phase two calls `removeGoal`" became
wrong the moment melee stopped existing — the checker was demanding code that removes something
that is not there. It now asserts the stronger property: `MeleeAttackGoal` is never registered.
- 11 break tests, all caught.
### Soundtrack 1.0.1 — and the mini-boss theme would not actually have played
Bumped the soundtrack mod to **1.0.1**, and while in there found that last release's work was silently
dead.
- ⚠️ **`music.mini_boss` had no `sounds.json` entry.** The `.ogg` was sitting at
`music/bosstheme/forget_me_not.ogg`, the Java was correct, the handler was correct — and the boss
would have fought in complete silence, with one `Missing sound for event` line in the log. Entry
added, pointing at the file that is actually there.
- ⚠️ **The version was declared twice, and one copy was dead.** `build.gradle` hard-coded
`version = '1.0.0'` while `gradle.properties` also declared `mod_version=1.0.0` that nothing read.
Bumping the obvious-looking one would have shipped a jar still stamped 1.0.0, with only the filename
and the mod list quietly disagreeing with the release. `build.gradle` now reads `mod_version`.
- **New `tools/check_sounds.py`** ties the three together: every id `ModSounds` registers has an
entry, every entry resolves to a real `.ogg`, every music entry sets `"stream": true`, every name
uses the `reverseeon` namespace, and the version is declared once. 8 break tests, all caught —
including a faithful reproduction of the missing entry.
- ⚠️ It also has to know about ids the mod builds at **runtime**: `AuraMusicHandler` composes
`music.aura.<key>` from the `AuraColor` enum, so those exist nowhere as a literal. The first run
reported eleven live aura tracks as orphans — the checker was measuring the wrong thing, not
finding a bug.
- ⚠️ "Registered id with no entry" is an **error** (breakage: silence where sound was intended);
"entry nobody plays" is a **note** (waste: a file riding along in the jar). Failing the build for
waste makes a checker something to silence rather than read.
- ⚠️ When the soundtrack folder is not connected it says so loudly instead of skipping quietly. A
checker that goes green after checking nothing is how "the audio is fine" gets believed.
- ⓘ Reported, not touched: `sound/textsound.mp3` (**Minecraft cannot decode mp3**),
`sound/uisuitcasecomputer.wav`, and the `ambient.rain.normal` entry — all unreferenced, all riding
along in a jar whose whole reason for existing is size.
### The mini-boss theme plays when the second phase starts
`reverseeon:music.mini_boss` is registered and a new `MiniBossMusicHandler` plays it, looping, while a
Forget Me Not is within 48 blocks and in its second phase. See **`SOUNDTRACK_mini_boss.md`** for the
`.ogg` path and `sounds.json` entry to add on the soundtrack side — the audio still lives in the
separate `reverseeon_soundtrack` mod.
- ⚠️ **Driven by entity data, not by a packet.** `secondPhase` is now synched, so the client already
knows. That gets three awkward cases right for free that a one-shot "phase started" packet would
each have needed code for — a player who logs in mid-fight, a player who walks into range, and a
chunk reload — and each of those would have failed silently for exactly one player. It also costs
nothing in the packet-id ordering this project has to be careful with.
- ⚠️ **Polls, never latches.** The track is started and stopped from the current state every tick. A
`started = true` latch leaves the music playing forever when the boss dies out of render distance,
and silent after a respawn — both unrecoverable without relogging.
- ⚠️ **`isAlive()` as well as the phase flag.** The synched value stays true right up until the entity
is removed, and the death animation alone is long enough for the track to feel like it is refusing
to stop.
- **The aura track stands down while it plays**, the same way it already does for the Storm. Two of
this mod's own tracks at once is worse than either being absent.
- With the soundtrack mod absent: one `Missing sound` log line, no crash, fight unchanged.
- 12 break tests, all caught. ⚠️ One initially passed while broken — deleting the entire
`else { stop(mc); }` branch still satisfied `'stop(mc)' in tick`, because the method's own
null-player guard calls `stop` too. The check now reads the **not-wanted path** specifically.
- ⚠️ **Two of this file's own checks had to be repaired** after the phase flag moved behind a synched
setter: one asserted the literal `secondPhase = true`, and one used `\b` *after* `isSecondPhase()` —
a word boundary means nothing next to a closing paren, so that alternation never matched at all.
Both were rejecting correct code.
### Forget Me Not's second phase is a different fight
At half health the boss now opens a **second health bar of 500**, speeds up, stops swinging its fists
entirely and starts kiting. The flasks become the attack.
- **500 HP, refilled.** ⚠️ `setBaseValue` before `setHealth`, in that order — filling first clamps to
the old 60 and leaves the boss at 12% of its new bar, which reads as the escalation having
backfired.
- **Melee is removed, not out-prioritised.** "ปาขวดยาดาเมจทันทีแทนการโจมตีปกติ" means the swing has to
stop. ⚠️ Goals at different priorities both run, so leaving `MeleeAttackGoal` in and hoping the
retreat goal outranks it would still let the boss punch whenever the player caught up — and the
phase would read as "same fight, bigger numbers". The goal is held in a field and removed.
- **Radial volley** — eight flasks lobbed in a full circle around the boss, landing on a ring. ⚠️ Aimed
at the ground around *itself*, not at anyone: that is what makes it an area to leave rather than a
projectile to dodge, and it is why the boss backs off first — the ring is drawn where it stands, so
whoever is chasing it is the one inside.
- **Instant damage becomes the common payload** (60%), with the four debuffs still in the mix. A phase
that only deals damage stops being this boss.
- **Two Masked Follower Amazons** replace phase one's three Thralls, and the bomb comes three times as
often. ⚠️ `MOB_SUMMONED` matters far more now: the Amazon's own `finalizeSpawn` escorts 2–4 Thralls
for *every* reason except that one, so two Amazons on any other reason arrive with up to eight
Thralls in tow — every summon, compounding, for the rest of the fight.
- **Two new native spells.** **Repel** shoves everything out of a 7-block ring; **Blight** is a
10-block debuff field. ⚠️ Repel uses vanilla `knockback`, not a hand-written delta — that is what
respects Knockback Resistance and sets `hurtMarked`, without which the server and client disagree
and the player rubber-bands back. It deals no damage on purpose.
- ⚠️ **The retreat runs on a duty cycle, and that is the balance of the phase.** A boss that backs away
continuously at near-sprint speed can never be caught, which removes melee players from the fight
rather than challenging them. It retreats for 1.5s, then must stand for 2.25s. The gap is the melee
window; the retreat is what keeps the ranged fight at range.
- ⚠️ **The phase's effects are re-applied on load, not just the flag.** Attributes and the goal list
are rebuilt from scratch for a fresh entity, so a boss saved mid-phase-two would come back at 60 max
health, phase-one speed, and swinging its fists again — while still reporting itself as phase two.
- 24 break tests, all caught. ⚠️ **Four initially passed while broken**, and one was the substring trap
again: renaming the class to `KeepDistanceGoalX` satisfied `'KeepDistanceGoal' in src`. Also
`const_int` could not parse `500.0D` at all and returned `None` — so the check *reported the right
constant for the wrong reason*, which is worse than not checking it.
### Shift-click a network entry to send it straight to your inventory
The tablet's grid had three gestures — take a stack onto the cursor, take half, put the carried stack
away — and no way to move something down without going via the cursor. Shift + click now withdraws a
stack directly into the player's inventory.
- **Both mouse buttons.** Every container in the game quick-moves on shift+click regardless of which
button it was; a player who shift-right-clicks and gets "half a stack onto the cursor" instead reads
it as a bug, not as a different gesture.
- ⚠️ **Carrying still wins.** Shift+click while holding something is not a quick-move in any vanilla
screen either, and `PUT_CARRIED` is the only way to deposit onto one specific pile — losing it to a
held shift key would be a real regression.
- ⚠️ **`Inventory.add` mutates the stack**, shrinking it as it places, and what is left is what did not
fit. Letting that reference fall out of scope **deletes items** — silently, and only for players
whose inventory is full, which is exactly when a missing stack is hardest to notice. The leftover
goes back into the network, and if that deposit also comes back non-empty (someone else filled it in
the meantime) it goes on the floor rather than nowhere.
- ⚠️ **`Action` is serialised by ordinal**, so its order is a wire format exactly like the packet-id
order in `ModNetwork`. The new constant is appended and `check_tablet_menu.py` now freezes the
existing four as a prefix: inserting one above them re-labels every message already in flight, so a
`PUT_CARRIED` would arrive as a `TAKE_STACK` — which looks like the network eating the stack the
player was holding.
- Eight new break tests, all caught, including "forget to put the leftover back" and "let shift beat
carrying".
### The tablet's INFO tab stopped drawing three things in the same place
The attribute list, the BONDS picker and the character portrait were all writing into the same pixels.
Each was correct on its own; nothing in the source looked like a collision, because **all three were
measuring from a different origin** and the overlap only existed once they were composited.
- The list sized itself to the panel's bottom edge (`top + H - 26`) while the bonds block was placed at
a fixed offset from that same edge and drawn **on top of it** — so any subject with more than nine
attribute rows had its list running straight through five rows of bond names.
- The portrait was blitted over whatever text was behind it, so every long attribute name was cut
mid-word by a picture.
Fixed by giving the tab four named accessors — `infoListTop/Bottom`, `portraitLeft/Right/Top/Bottom` —
and one rule: **the list owns everything between them, minus the portrait's column while it is level
with it.** Nothing else draws inside that.
- ⚠️ **The clip is per row, and only while the row is level with the portrait.** Clipping the whole
list to the narrow column throws away 40px on every row below it, where there is nothing to avoid —
and the long attribute names are exactly the lines that need the width.
- ⚠️ **A scissor, not a truncated string.** These lines carry `§` colour codes, and cutting one
mid-code changes the colour of everything after it.
- **The bonds block moved down 18px**, which reclaimed 29px of dead space above the footer *and* gave
the list four more rows — the cramped list and the overlap had the same cause.
- **`check_tablet_info.py` now computes all three regions** from the screen's own accessors and tests
them against each other, which is the check this tab never had. Break-tested 11 ways, including a
faithful reconstruction of the original bug.
- ⚠️ **Two of those 11 initially passed while broken**, and both for the same reason: the check
searched the whole method for the words `enableScissor` and `portraitLeft`, which also appear in the
portrait-drawing block a few lines above. Deleting the row clipping entirely left both tokens in
place. It now reads the clip out of the row loop's actual scissor argument.
- ⚠️ **A third check was rejecting correct code.** The "portrait is yourself only" rule asserted the
literal shape `if (PlayerInfoClientData.self …) { … render }`, so hoisting the guard into a boolean
failed it while the guard was still right. A checker that rejects a correct refactor teaches people
to delete the checker; it now follows the guard through a single-assignment local.
### The Suitcase Tablet has painted trim
A new 33-piece ornament sheet, `terminal_ornament.png`, and the tablet now wears it: moulded bezel
corners with bolts, a louvred vent along the title bar, a knurled seam, a mounting rail under the tab
strip with a real bed behind the selected tab, crop-marked item wells replacing the checkerboard fill,
reticles at the grid's corners, a capped scrollbar thumb with chevrons, a status lamp, a
network-capacity meter, and a nine-sliced card behind the pre-install message.
- ⚠️ **Its own sheet, not another `NpcOrnament` palette.** Those are aged brass with an enamel inlay;
re-palettising them would have put brass trim on a green tube — the right *pieces*, in a material
from a different object. This sheet carries two materials that both belong to the tablet: unlit case
hardware for the bezel, and phosphor for anything the tube is drawing.
- ⚠️ **The palette is parsed out of `SuitcaseTabletScreen.java`**, not copied into the art script. A
copy would leave the painted trim a shade off everywhere the next time the ramp is tuned — the worst
kind of art bug, because each piece looks fine alone and only the assembled screen looks wrong.
`check_terminal_sheet.py` also re-measures every colour in the shipped PNG against that ramp, so a
hand-edited pixel is caught even though the parse is still in place.
- ⚠️ **All four chevrons shipped pointing backwards** in the first draft — the tip was placed at the
far end of the travel instead of the near one, so "scroll down" drew an up arrow. Nothing catches
that: the region table matches, the sizes match, the contrast is fine. The art script now asserts
each chevron's tip position from the pixels.
- ⚠️ **The scrollbar chevrons ran through the STOW button.** Placing them just outside the track is
the obvious choice and it overlapped a row that ends four pixels above the grid — invisible in the
source, visible the moment the sheet was composited over the real geometry. They now sit inside a
shortened track.
- ⚠️ **The lit hover well had to be drawn *before* the item, not after.** The natural order — well,
item, then the lit well on hover — blits an opaque 20×20 frame over the icon and erases it, in the
one slot the player is looking at.
- **Two checkers were repaired by this work.** `check_npc_sheets.py` paired *every* `*_ornament.png`
with a panel and so rejected the terminal sheet; it now derives the set it owns from the palette list
the art script actually draws, which keeps it exactly as strict for the NPC sheets. And
`tools/mock_tablet_ui.py` was hard-coding a scanline alpha of 40 against the screen's real `0x14`,
at 3× upscale — a mock that exaggerates the very effect you are judging is worse than no mock.
It now blits the real sheet and reads the real alpha.
### Forget Me Not's spawn egg is painted, in the Masked Follower family
Same silhouette, same two-pixel dark rim, same mottled interior and row-11 band as the Masked Follower
eggs — repainted in the boss's indigo and periwinkle, with a small forget-me-not bloom (four petals, a
yellow centre) on the front. It replaces the vanilla two-tone template it shipped with.
- ⚠️ **The structure is read from `masked_follower_amazon_spawn_egg.png` at generation time**, not
retyped into `tools/gen_forget_me_not_egg.py`. A hand-copied 16×16 grid drifts the moment anyone
re-tunes the template, and the drift is invisible in the worst way: two eggs that are *nearly* the
same shape read as a mistake rather than a family. Colour roles are remapped by **luminance rank**,
so the mapping survives a template re-tune too.
- ⚠️ **The palette is deliberately dark through the middle.** The first pass spread it evenly across
the full range and produced an egg visibly brighter than the two it sits beside — right shape, wrong
family, and the bloom drowned in the mottle. Rank 3 is the *dominant* colour (52 of 134 pixels) and
rank 4 is only the mottle, so the body is what has to stay dark; the light end is spent on the
flower. Mean luminance now lands at 50.6, between the Amazon's 43.0 and the Thrall's 56.0.
- ⚠️ **The model and the no-tint list have to change together**, and they are three files apart. The
vanilla template *needs* the `ForgeSpawnEggItem` colours; painted art needs them off, or the tint
multiplies into the drawing and muddies every pixel. `check_forget_me_not.py` now checks the pair
rather than either half — break-tested in both directions.
### The Dragon Hand removed
The item and everything that only existed to serve it: `TheDragonHandItem`, the `DragonHandEvents`
curse handler, the quest and its advancement, the Tome entry, the lang keys, and the texture, item
model, GeckoLib geometry and animation files. The **Masked Follower Thrall now goes bare-headed** —
the Amazon's dragon head is what marks rank in the pack.
- ⚠️ **Existing worlds:** a player holding one will see a missing item. Nothing recreates it, and the
quest that required it is gone, so the quest total drops from 31 to 30 and the fragment total from
270 to 260. The Tome index drops from 49 items to 48.
- ⚠️ **The Thrall's `CustomHeadLayer` was kept.** It is what draws *any* head item — a mob given a
helmet by a datapack, spawn-egg NBT or `/item` would otherwise be bare with nothing to explain why.
- **Fixed while nearby:** `ParchmentUi.well`'s scissor comment and `check_quests.py`'s third
item-source category both cited The Dragon Hand as their worked example. The scissor and the
category are still right; the explanations were about to describe a thing that no longer exists.
### Forget Me Not — debuff spells of this mod's own, and a flask that spares the minions
- **Three native debuff spells**, in `ForgetMeNotSpells`. **Veil** blinds, sickens and slows-to-mine
everything in 6 blocks *and strips one beneficial effect* — the signature. **Rot** is single-target
wither, weakness and hunger with a bite of magic damage. **Unravel** slows, darkens and drags the
target back toward the boss.
- ⚠️ **Native rather than Iron's Spellbooks ids.** The Storm monsters cast through a reflective
bridge that silently does nothing when an id is absent — the right trade for flavour on an
ordinary mob, the wrong one for a mini-boss whose entire identity is debuffing. A pack without
Iron's, or one where a single id was renamed, would have left it with nothing to do and no error
anywhere to say so. The Blood Step cast stays config-driven on top of these.
- ⚠️ **`stripOneBuff` collects first and removes after.** Calling `removeEffect` while iterating
`getActiveEffects()` throws `ConcurrentModificationException` from inside the entity tick — which
on a server kills the whole tick loop, not just the spell.
- ⚠️ It takes **one** buff, not all of them. Stripping everything turns every potion the player
drank into wasted inventory and makes the fight feel arbitrary rather than disruptive.
- **The thrown potions no longer touch Masked Followers.** A new `ForgetMeNotFlask` projectile
resolves its own splash instead of using vanilla's.
- ⚠️ **Why a new projectile rather than an exemption.** `ThrownPotion.applySplash` is *private*, hits
every living thing in the box, and offers exactly one opt-out: `isAffectedByPotions()`, which is a
property of the **victim**. Using it would also have made the followers immune to a *player's*
harming splash — a much larger change than was asked for, and one the player would feel. Resolving
the cloud here means the followers are skipped because *this flask* skips them, not because they
became potion-proof. It also makes "up to 5 targets" a real cap instead of an emergent one.
- **Second phase at half health:** throws twice as fast, fans far more often, and opens with a free
Veil. ⚠️ Guarded by a **saved** flag — recomputing it from health re-triggers the phase on every
single sword swing, and without the NBT it re-triggers on every reload.
- **Panic blink** when something lands a melee hit, on an 8-second cooldown. ⚠️ Gated on a *melee*
attacker: reacting to any damage would make it unkillable at range, and would fire constantly during
its own poison ticks where there is no attacker to escape from. Uses `randomTeleport`, which is what
refuses to land inside a wall or over a void.
- ⚠️ **Every cooldown now ticks outside the target check.** Ticking them only while a target is in
sight leaves a boss that lost aggro fully charged, firing bomb and blink the instant it sees anyone.
### Two projectiles were invisible
- **Neither `explosive_potion_projectile` nor the new flask had a renderer registered.** A missing
entity renderer throws at startup in a dev environment but only *logs* in a production client — so
the explosive potion has been flying invisibly this whole time, and nothing in game said why. Both
are now `ThrownItemRenderer`.
- ⚠️ To carry an item at all, `ExplosivePotionProjectile` had to stop extending `ThrowableProjectile`
(with an **empty** `defineSynchedData` override) and extend `ThrowableItemProjectile`.
- ⚠️ **And it was spawning at the world origin.** It used `super(type, level)` and then `setOwner`,
which never sets a position; `ExplosivePotionItem` only calls `shootFromRotation`, which sets
velocity, not location. The three-argument `super(type, shooter, level)` is what places it at the
thrower's eye. Invisible *and* somewhere else is why this survived.
### Forget Me Not — a potion-throwing mini boss
A nimble mini boss (60 HP, movement 0.28) that fights by making the encounter inconvenient rather than
by out-damaging you. It throws splash flasks roughly every 1.5s carrying **darkness, poison, wither,
nausea or instant damage**; 35% of throws are a **fan of five**. It calls **three Masked Follower
Thralls every 45s** and lobs one **explosive flask every 60s**, and casts Blood Step to reposition.
Hostile to villagers and to the island's own NPCs. Drops **Black Substance ×1–6 (75%)** and an
**Explosive Potion (50%)**.
- **Immune to every debuff — by category, not by a list of names.** `canBeAffected` refuses
`MobEffectCategory.HARMFUL`. ⚠️ Enumerating poison/wither/slowness would have compiled, looked
right, and silently missed every debuff added by another mod — which in a pack with Iron's
Spellbooks is most of them. Beneficial effects still apply, so `/effect` on it is not a dead command.
- ⚠️ **`canBeAffected` is not enough on its own, and the gap is exactly the instant-damage flask.**
Vanilla's splash code branches on `MobEffect.isInstantenous()`: ordinary effects go through
`addEffect`, which honours `canBeAffected`, but instant ones go through `applyInstantenousEffect`,
which calls `hurt` directly and never asks. The one payload that is pure damage is therefore the one
that would have landed on the caster — and not rarely, since the splash radius is 4 blocks and it
throws from 3. `hurt` now ignores any damage whose source entity is the boss itself.
- ⚠️ **The two long cooldowns start half-elapsed and are saved to NBT.** At zero they fire on the first
tick the boss sees you, so the fight opens with a bomb and three minions before the player has done
anything. Without the NBT they reset on every chunk unload, so walking 100 blocks away and back
re-triggers that opening every time.
- ⚠️ **Summoned thralls use `MOB_SUMMONED`.** Any other spawn reason makes each thrall bring an Amazon
escort of its own, which turns a 45-second summon into a compounding mob.
- ⚠️ **Renderer uses `ModelLayers.PLAYER` with `slim = false`**, not the `PLAYER_SLIM` the island NPCs
use. The uploaded skin was drawn for the 4px arm; the slim model shifts those UVs by a pixel and
leaves a seam down both arms that reads as bad art rather than the wrong model.
- **`tools/check_forget_me_not.py`**, verified by breaking it 29 different ways — every one caught.
It parses the loot table as JSON rather than grepping it, and reads each rule out of the specific
method it belongs to, because the javadoc for `canBeAffected` mentions `HARMFUL` and a file-wide
search would keep passing after the code was gone.
- **Fixed while nearby:** the Storm spawn eggs were listed **twice** in the creative tab.
### Storm monsters now wear their own skin, and the spawn eggs are findable
- **Full 64×64 skins painted for Reverse Eon**, replacing the vanilla ones: bone / rotted flesh with
bites cut **fully transparent** so the holes go through, a lit rim where the Storm is still chewing,
and a necrotic ring around each one. The animated decay layer stays on top — a static texture cannot
pulse.
- ⚠️ **This reverses the earlier decision, and the cost is real.** The overlay-only design kept the
vanilla skin so a resource pack retexturing skeletons carried through. That was right while the decay
was the only thing that was ours; it is wrong now the ask is a creature of this mod's own. A pack
that retextures skeletons no longer affects these two — correct, because they are not skeleton
variants any more, but a trade rather than a free upgrade.
- ⚠️ **The checker rule inverted with the design.** It used to *fail* if `getTextureLocation` was
overridden. Leaving that in place would have had the checker enforcing the old design against the new
code — and a checker that is confidently wrong is worse than none, because the fix people reach for
is deleting the rule. Its success message was also still describing the overlay design, which would
have made a passing run say something untrue.
- **Spawn eggs added to the creative tab.** They were registered, modelled and translated but never
listed — an item that exists and cannot be found in creative reads as missing, and `/give` was the
only way to get one.
- **Dragon Hand worn pose** updated from the uploaded animation: rotation `[0,5,0]` applied to the item
model's `head` display, which is what `CustomHeadLayer` actually reads. ⚠️ The scale was already
correct — `display.scale` is exactly `2.25 ×` the animation's, on all three axes. ⚠️ The **position
was deliberately not touched**: the animation's `[0.5, 24.5, -4]` is a bone position in model space
and `display.translation` is an offset from a different origin. No linear relation fit, and it cannot
be checked without rendering — a guess would misplace the hat with nothing to explain it.
- ⓘ **Geometry is still the vanilla humanoid.** The bites are in the texture, not the mesh. Custom
geometry would also mean re-adding the armour and held-item layers by hand, which the vanilla
renderer currently provides for free.
### Storm Syndrome Skeleton & Zombie
Two monsters the Storm is eating. They spawn **only while the Storm is blowing, in the Overworld**.
- **×3 health and damage.** ⚠️ Speed and follow-range are deliberately untouched: a skeleton that moves
three times as fast cannot be outrun or kited, which is not "stronger" so much as unfightable.
- ⚠️ **The skeleton's arrow is multiplied too.** `ATTACK_DAMAGE` is the *melee* number — a bow
skeleton's damage lives on the arrow. Without that, ×3 would apply to the half that punch and not at
all to the half that shoot, which are the ones you fight at range.
- 50% carry a bow, 35% of zombies carry a stone sword. ⚠️ Vanilla always gives a skeleton a bow, and
the slot must be cleared *inside* `populateDefaultEquipmentSlots` — `reassessWeaponGoal()` runs after
it and reads the current weapon, so clearing later leaves a bow goal with no bow: the mob backs away
to shoot, forever, and never attacks.
- **Both extend the vanilla `Skeleton`/`Zombie`** rather than `Monster`. That inherits the
weapon-dependent goal swap, sunlight burning, door breaking, and every `instanceof` check in the game
and in other mods. A hand-rolled monster looks identical until someone notices Smite does not apply.
- ⚠️ **The zombie does not reinforce and does not drown-convert.** A ×3-health zombie that summons help
every time it is hit has no difficulty ceiling; and a Storm Zombie that becomes an ordinary Drowned
means a player can "cure" the hard mob by pushing it into water.
- **Spell every 10s** through a new `StormSpellBridge` — ⚠️ `CastSource.MOB`, not `SPELLBOOK`. The
player path charges mana, and a skeleton has no mana bar, so SPELLBOOK would refuse every cast and
the spell would look simply broken. ⚠️ The goal only starts its cooldown **when the cast actually
happened**, so a pack without Iron's Spellbooks gets an ordinary strong skeleton rather than one that
stops every ten seconds to cast nothing.
- ⚠️ **The spell ids are config, not constants** — an admission, not a feature. Iron's is an optional
dependency reached only by reflection, so the ids cannot be verified at build time; a hard-coded
wrong id disables the spell with nothing to point at. `stormMonsters.skeletonSpell` /
`.zombieSpell` can be corrected without a rebuild.
- **The decay is an overlay layer, not a repainted skin.** ⚠️ Shipping a modified `skeleton.png` would
redistribute Mojang's artwork *and* hard-code one skin, so a resource pack that retextures skeletons
would leave these two looking default. Compositing at render time keeps the base skin whatever the
pack says and only the decay is ours — the same technique as the charged creeper. The overlay PNGs
are generated procedurally (value noise, biased to decay from the top).
- ⚠️ **The first overlay attempt was wrong and looked fine as a file.** Noise spread evenly over the
64×64 sheet puts most of its pixels in UV space the humanoid model never samples — busy PNG, nearly
blank mob. It is now generated only inside the boxes the model actually reads.
- ⚠️ **The layer respects invisibility.** Render layers are not skipped for invisible entities the way
the base model is, so without the check a potion leaves a floating, fully-lit shell — worse than not
hiding at all, because it is a perfect outline.
- ⚠️ **Spawning layers the storm test on top of vanilla's rules, never instead of them.** A bare storm
check would let these spawn in a fully-lit base, which is the thing players build a base to prevent.
Deliberate placement (spawn egg, `/summon`) is always allowed, or the mobs could not be tested.
- ⚠️ **Ninth too-loose check, and it was the important one.** The rule for "must require the storm"
searched the whole file for `isStormActive` — which the *helper method* contains, so deleting the
actual `if (!stormActive(...)) return false;` passed the break test while the monsters spawned in
clear weather. It now matches the call in `checkSpawn`. 12 break-tests, all caught.
### Tablet crafting is switched OFF by default
The system is not stable enough to ship on, so it is **disabled rather than deleted** —
`craftingPanel = false` in the common config, one line away from coming back.
- **Off, not removed.** Deleting it would throw away the storage-network integration, the JEI transfer
handler and several rounds of fixes, and it would all have to be rebuilt — including the mistakes.
The code stays compiled and covered by the same 21 checkers.
- ⚠️ **The ticker deliberately keeps running, and that is the important part.** A queued smelting job
had its ingredients **taken from the network when it was queued**. Gating `CookQueue.tick` along
with everything else would freeze those jobs forever and quietly eat the ore of every player who
used the feature before it was switched off — a data-loss bug introduced *by the feature flag*,
which is close to the worst way to ship one. **Disabled means "accept no new work", not "abandon
what was already paid for".** Existing queues drain and deliver as normal.
`check_network_craft.py` now fails if a flag check appears in the ticker.
- ⚠️ **Linking a station is refused with `CONSUME`, not `PASS`.** Falling through would let a furnace
be linked as a storage **box** instead — putting the coal it is burning into the withdrawable pool
and letting the terminal deposit cobblestone into its fuel slot. Switching a feature off must not
quietly enable a worse one.
- The CRAFT tab is **not drawn at all** rather than drawn and refusing — a visible control that never
works gets filed as a bug and clicked repeatedly. JEI's transfer button says why it declined.
- Gates are **server-side** (`CraftRequestPacket`, `CraftPatternPacket`, station linking); the
client-side check only keeps the UI honest. A hidden button is not a closed door.
### The furnace queue stopping dead, with nothing to explain it
This is the reported "เตาหลอม / คิวเผามีปัญหา", and the cause is a rule the game gives the player no
way to discover.
- ⚠️ **Fuel comes out of linked _boxes_ — and a linked furnace is a _station_, not a box.** So the
obvious thing to do (put coal in the furnace, link the furnace, queue a smelt) leaves the coal
outside the network entirely. `CookQueue.tick` then hits "no fuel" and `return`s, which is the
correct behaviour — the ingredients are already paid for and must not be thrown away — and a total
black hole: the panel reads **"cooking 8" forever**, nothing is logged, and there is nothing
anywhere that names the rule.
- The queue now reports the stall: the panel shows **`NO FUEL 8` in red** instead of a `cooking 8`
that never moves. The flag is transient (recomputed within a tick of loading, so persisting it
would only create a way for it to be wrong) and is cleared the moment burning resumes.
- ⚠️ Note this is a *reporting* fix, not a behaviour change. Making the tablet reach into a linked
furnace's own fuel slot would undo the reason stations and boxes were split in the first place —
a furnace linked as storage puts its burning coal into the withdrawable pool and lets the terminal
deposit cobblestone into the fuel slot.
### Craft panel — four real bugs found by re-reading the code
I built this system without ever compiling or running it, so I went back through the whole craft path
looking for what actually breaks. All four produce the same visible symptom — **"No recipe matches
that pattern" against a recipe the player is looking at** — from four different causes.
- ⚠️ **The pattern was lost every time the tablet was reopened.** The client's pattern is `static` (it
has to be — it must survive a resize and JEI firing mid-rebuild), but the server's copy lives on
`SuitcaseTerminalMenu`, which is **built fresh on every open**. So: set a pattern, craft, close,
reopen — the panel still shows the recipe because the client kept it, and CRAFT fails because the
server's menu is brand new and holds nine empty stacks. Nothing logs. The panel is telling the truth
about what the *client* holds; it is only wrong about who is being asked. Fixed by re-seeding once
per screen, from `render` rather than `init()` — the process list arrives in a packet *after* the
screen opens, so at init time there is no id to send the pattern under.
- ⚠️ **Clicks landed on cells that are never drawn.** The pattern is always 9 cells but smelting uses
1 and smithing 3. Clicking the unused area stored a ghost the panel does not render: the grid looks
empty, CRAFT lights up because `patternEmpty()` counted all nine, and the server answers about
something invisible. Dead cells now refuse the click.
- ⚠️ **Switching process left the old cells populated.** Same invisible-but-counted problem from the
other direction — a 3×3 pattern then switched to smelting keeps items in cells 1–8. `pruneToShape`
now enforces one invariant, in one place: **a cell that is not drawn is empty.** Applied on
selection change *and* on every sync, because a datapack reload or an unlinked station can change
the selected process's shape underneath the player.
- ⚠️ **Three lines of text drew on top of each other.** With five processes linked the list ran to
+70, the "pattern (ghost)" caption sat at +68 and "cooking N" at +72 — stacked in an 18px band, on
the one screen state where all three are visible, i.e. for exactly the players using the feature
most. The panel's offsets are now **derived from each other** rather than written as literals, and
`check_tablet_menu.py` computes the bands and compares them.
- ⓘ Worth stating plainly: the break-test for "set `CP_ROWS` back to 5" **does not fail any more**,
and that is the fix working rather than a hole. Every offset below the list is derived from it, so
raising the row count shifts the whole stack down together — verified across 3–7 rows. Recreating
the collision now requires overwriting a derived offset directly, which tests 2 and 3 do catch.
### One layout, shared by every tab
- Every tab computed its own content origin and they disagreed: NPC and ZONE started at `top+46`,
INFO at `top+44`, SYSTEM at `top+48`, with side margins of 9, 10 and 17. Switching tabs made the
whole panel twitch a few pixels — the kind of thing you feel rather than see, and impossible to fix
reliably while five methods each owned a copy of the number. There is now one `contentTop()` /
`contentLeft()` / `contentRight()` / `rowTop(i)` and a shared `tabHeader(...)`.
- ⚠️ **The sharper half was the row formula.** `npcSummonRect` computed `top + 62 + i * 26` and
`renderNpcTab` drew the row at `top + 62 + i * 26` **independently** — two copies of one layout
formula, one edit from buttons that no longer sit on their rows. And the button stays clickable at
its old coordinates, so it reads as *the row below responding to a click on the row above*. Both
now go through `rowTop(i)`; `check_tablet_menu.py` fails if either side grows its own copy.
- **Rows and buttons land at byte-identical coordinates** — verified arithmetically, not by eye:
`contentTop() + ROW_INSET + i*ROW_H` reproduces `62, 88, 114, 140, 166`, and every rect's new
`contentRight() - n` resolves to its old `left + W - m`. The only things that moved are the three
misaligned headers and the two odd margins, which was the entire point.
### The NPC and ZONE tabs finally say something
Both were a name and a button per row, and neither told you whether the button would do anything.
- **NPC tab** shows each islander's distance, or `AWAY` when they are outside the client's cached
radius. Previously you pressed SUMMON with no way to know they were already standing behind you —
and it costs a cooldown.
- **ZONE tab** shows each islander's zone size, or `FREE`, and **dims CLEAR when there is no zone to
clear**. Before this, clearing a free-roaming islander looked broken rather than redundant, and the
only way to confirm a zone had saved was to walk out and look at it. ⚠️ A button that does nothing
should not look like a button that does something.
- ⚠️ **Both read from the same source as the in-world outlines** — `ZoneClientCache`, already synced,
and `SuitcaseZonePreviewRenderer.cachedNpcs`, made package-visible rather than copied. Two entity
scans would mean two radii and two cache periods, and then the tab and the world view disagree
about who is present, which is worse than either being wrong on its own.
- ⚠️ **Eighth too-loose check, same shape.** The rule for the dimmed CLEAR searched for
`zone == null ? P_OFF` — which the *state label two lines above* also contains, so reverting CLEAR
to a flat colour passed. It now matches the `drawButton(... zoneClearRect(i) ...)` call itself.
### Smelting costs fuel and takes time
A linked furnace used to smelt instantly and for free, which made it strictly better than a real
furnace at no cost — you would never build one again. It now behaves like a furnace.
- Cooking recipes go on a **queue on the Computer Suitcase**, drained by its block-entity ticker.
Crafting, smithing and stonecutting are instant in vanilla and stay instant here. ⚠️ The queue lives
on the computer, not on the tablet item: the player walks away, changes dimension, logs out.
- ⚠️ **Ingredients are taken when the job is accepted, not when it finishes.** The duplication risk
moved with the feature — a queue produces long after the click, so charging at completion lets a
player queue 64 batches off one ore and walk away with 64 ingots. `NetworkCrafter.consumeOnly`
reuses the existing `plan`/`commit` rather than writing a second extraction loop, because a second
loop is exactly where the per-plan reservation would go missing.
- ⚠️ **Fuel is charged by burn time, not per item.** One coal is 1600 ticks — eight smelts — and it
must be here too; charging a whole coal per batch makes the tablet eight times more expensive than
the block it stands in for, which players read as a bug. The leftover burn time is **persisted**,
or saving often would quietly make fuel worth less.
- ⚠️ **The weakest usable fuel is burned first.** `getBurnTime` reports blaze rods and lava buckets as
fuel, and a first-hit scan burns whatever sits in slot 0 while a chest of coal goes untouched. That
reads as theft, not as a cost. Lava buckets return the empty bucket.
- ⚠️ **Campfires burn nothing, deliberately.** `campfire_cooking` has no fuel slot in vanilla;
charging for it would invent a cost the base game does not have. It still takes time. The fuel test
keys on `AbstractCookingRecipe`, not a list of the four vanilla ids, so a modded furnace-alike does
not get free smelting through a name check.
- The ticker is registered **unconditionally** and returns immediately on an empty queue — a ticker is
chosen when the block entity is created and cannot be added later, so "only tick when busy" would
never start. ⚠️ Server-side only by an explicit guard: `createTickerHelper` does not do that for you.
- ⚠️ Jobs store the recipe's **id**, never the `Recipe` object (a datapack can change it under a saved
world) but **do** store the input stack, because `assemble` takes a container and a modded cooking
recipe is entitled to read it. Loading uses `getList(..., 10)` — the wrong tag type returns an empty
list *without erroring*, so a typo there would drop every queued job silently.
### The craft panel scrolls, and shows the oven
- The process list scrolls (arrows appear only when there is something off-screen, so their presence
is itself the information). ⚠️ It previously drew `i < 5` and stopped: a pack with six recipe types
simply lost one, with nothing on screen suggesting it existed.
- Scrolling over the panel moves the list and **stops there** — without swallowing the event, the item
grid behind the panel scrolls at the same time and neither is the thing under the cursor. The panel
check runs before the tab checks, because the panel floats outside the window on every tab.
- The queue count is shown, and re-synced on the terminal's existing once-a-second refresh rather than
a new timer. ⚠️ A count synced only on open sits frozen at whatever it was when the tablet was
opened, which reads as the queue being stuck.
### Three more too-loose checks, found by break-testing
Same class each time, and worth recording because it keeps recurring:
- `enqueue` matched **`ctx.get().enqueueWork(...)`** — the first line of every packet handler in the
mod — so the check reported "queued before paying" against code that pays first. Now `cse.enqueue(`.
- `burnLeft` survived as a *declaration* after `burnLeft += burn` was replaced with `burnLeft = ticks`,
so a version where one coal smelts exactly one item passed. Now matches `+=` and `-=` separately.
- `bestBurn` survived the same way after the `burn < bestBurn` comparison was replaced with a
first-hit condition. Now matches the comparison.
⚠️ **The rule, restated: a check must name the construct it is about, not a token that appears inside
it.** That is now seven occurrences in this project; every one was found by deliberately breaking the
code, and none by reading the checker.
### Craft-station overrides, and two promises the code had not kept
- **`data/<namespace>/craft_stations/*.json` now exists and works.** A pack can name a block, list the
recipe types it grants, and set the grid size — reloaded by `/reload` like any other datapack file.
Order of precedence is **datapack → built-in vanilla table → namespace guess**, so a pack can retune
even a furnace. The built-in table stays in *code* rather than shipping as data, so a broken or
missing datapack cannot take vanilla crafting offline. `_example.json` ships as a worked example
that restates the built-in crafting-table mapping and therefore changes nothing.
- ⚠️ **This was a correction, not just a feature.** `CraftStations`' javadoc already told readers that
file could fix a mapping — before any loader existed. A comment describing a feature nobody wrote is
worse than silence: the next person writes the json, sees no effect, and has to read the class to
discover the documentation lied.
- ⚠️ **An empty `recipe_types` is an answer, not an omission.** It means "this block is not a station",
which is the only way to switch off a namespace guess that produced nonsense. It works only because
the loader *stores* the empty list; skipping it would fall through to the guess and make the
override silently do nothing — precisely the failure the file was added to end.
- ⚠️ **`RecipeRunner.resetQuarantine()` was written at the start and never called.** Same class of
dangling promise. It now runs on every datapack reload: quarantine is learned by *trying* a modded
recipe, and a reload can replace the very recipe that failed — without this, a pack owner who fixes
their recipe still sees the process greyed out until the game restarts, and concludes the fix did
not work.
- Pack-supplied `width`/`height` are **clamped to 1–3**. A typo of `"width": 30` would otherwise index
past the 9-cell pattern the first time someone opened that process.
### The craft panel shows what you're making, and what you're missing
- The result of the current pattern is drawn in a preview slot, and any pattern cell the network
cannot supply gets a **red bed** (not a red overlay — an overlay tints the item icon too, which
makes a familiar item look like an unfamiliar one). A count of missing ingredients sits under the
grid.
- ⚠️ **"no recipe" and "missing materials" are labelled differently.** They look identical on a blank
result slot and the fixes are opposite: change the pattern, versus go and mine something.
- **The grid shape now comes from the server** (`CraftSyncPacket` carries width/height) instead of
`id.getPath().equals("crafting")`. That guess breaks the moment a datapack retunes a process or a
modded type takes one input: the panel draws nine live cells, the player fills them, and nothing
ever matches — with nothing to suggest the extra cells were the problem.
- ⚠️ **The preview builds its process from the packet, not `CraftStations.byId`.** On a dedicated
server the client never runs `CraftStationLoader`, so its override maps are empty and `byId` returns
the built-in shape — disagreeing with the grid actually on screen whenever a datapack retuned that
process, and silently previewing nothing.
- Preview is cached; `getRecipeFor` scans a whole recipe type's list and `render` runs 60× a second —
the `TerminalView` lesson again. ⚠️ The cache key includes the **process**, because two processes
accept the same pattern and give different results (iron: smelting vs blasting), so a pattern-only
key keeps showing the previous tab's answer.
- ⚠️ **A break-test slipped through and the checker was tightened, not the test dropped.** The rule
read "is `previewValid` mentioned in `craftPreview`" — satisfied by the `previewValid = true;`
assignment, so deleting the early return passed. It now matches the guard itself. That is the
**sixth** time in this project a substring check has been too loose in exactly this way; the rule is
that a check must name the construct it is about, not a token that appears inside it.
### JEI: the + button fills the tablet's pattern
- Registered as a **universal** transfer handler, not one per recipe type — the per-type API needs the
types listed at registration, and the whole point of the craft panel is that it also runs types this
mod has never heard of. Requires **JEI 15.11.1+** (`IUniversalRecipeTransferHandler` is `@since`
that), so `build.gradle` pins a known build and `mods.toml` uses `[15.11.1,)` instead of the `[0,)`
the other optional deps get.
- ⚠️ **The layout is read from the recipe, not from JEI's slot views** — and JEI's own docs are what
flagged this. It centres smaller recipes inside the 3×3 display and bottom-aligns some (slabs), so
its slot order is *not* grid order. Transferring a 2×2 recipe that way lands it as four-in-a-row,
matching nothing: the player gets "No recipe matches that pattern" one click after JEI told them the
recipe was transferable. `Recipe.getIngredients()` plus `ShapedRecipe.getWidth()` is exact, and
vanilla's shaped matcher scans every offset so top-left always matches.
- ⚠️ **`doTransfer == false` writes nothing.** JEI calls `transferRecipe` every frame the cursor is
over the button, just to decide whether to draw it enabled. Mutating state there means merely
hovering the button overwrites the pattern the player was building.
- ⚠️ **The representative item is one the network actually holds.** `getItems()[0]` is the obvious
choice and wrong in the ordinary case: for `#logs` the first entry is oak, so transferring a planks
recipe with a network full of spruce writes a pattern that cannot be paid for — "Not enough
materials", while staring at a full chest.
- Recipes larger than 3×3, or with more than 9 inputs, are **refused with a reason** rather than
truncated. A silently truncated pattern is a different recipe.
- ⚠️ **The load-safety property is one import wide.** JEI is `compileOnly` and optional, and that only
works because `@JeiPlugin` classes are loaded *by JEI* — with JEI absent the JVM never resolves
them. The moment any class outside `compat.jei` names a `mezz.jei.*` type, even in a method never
called, every player without JEI crashes on startup. It compiles, and it works fine for the
developer, who has JEI. `tools/check_jei.py` fails the build if `mezz.jei` appears anywhere else —
break-test 1 adds one import to the screen and it fires. Note this is a **different** mechanism from
`compat.CuriosTablet`'s explicit `isLoaded` guard; the two are not interchangeable.
### The craft panel
A vertical **CRAFT** tab on the right edge of the tablet; clicking it slides a panel out to the left,
listing the processes your linked stations unlock, with a 3×3 pattern grid and a CRAFT button
(shift-click for ×64).
- ⚠️ **The panel is drawn outside the window, and that nearly ate items.**
`AbstractContainerScreen` treats any click outside the container rectangle as *"throw the carried
stack on the floor"* — so every attempt to put an item into the pattern grid while holding one would
have dropped it behind the player. It does not log, and it reads exactly like the tablet eating
items. `hasClickedOutside` is overridden for the panel's rect, `handleCraftClick` runs **before** the
fall-through to `super.mouseClicked`, and clicks on the panel's blank background are swallowed
rather than allowed through.
- **The pattern is copied from the cursor, never consumed.** It is a template; taking the item would
make the player pay for the recipe twice — once to describe it, again when the network buys the
ingredients. Right-click clears a cell.
- **Every pattern change is sent to the server immediately**, not batched until CRAFT. The server
crafts from its own copy, so a client-only pattern produces "no recipe matches that pattern" while
the player is looking at a perfectly good recipe — the most confusing failure this panel could have.
- Processes are **told to the client**, not derived by it: the client cannot know a linked crafting
table was mined an hour ago, and quarantine state only exists on the server (it is learned by
*trying*). Re-synced on link/unlink as well as on open, or linking a furnace would appear to do
nothing until the tablet was reopened. A quarantined process is shown **greyed rather than hidden** —
hidden is indistinguishable from never having linked it.
- ⚠️ **A checker crashed instead of failing, and the break-tests "passed" on the crash.**
`check_tablet_menu.py` called a `body_of` it did not have, so it exited non-zero for every input —
which is indistinguishable from "the check works" if you only look at the exit code. Four break
tests were meaningless. The harness now greps for `Traceback` and reports "⚠️ script broke" as a
distinct outcome from "✅ caught".
- ⚠️ And once fixed it produced a **false positive on itself**: it looked for the *first*
`super.mouseClicked`, which is the `if (!isInstalled) return super...` early-out that
`handleCraftClick` legitimately sits after (no panel is drawn when the tablet is uninstalled). The
call that matters is the fall-through at the end — `rfind`, not `find`.
- ⚠️ **`reverseeon.craft.*` lang keys are checked exactly, not waved through.** They are built as
`"reverseeon.craft." + id.getPath()`, a tail that appears as a literal nowhere, so
`check_dead_code`'s "prefix AND suffix" rule reported all seven as orphans. The tempting fix is to
add the prefix to its exempt list — but that file's own comment records a real dead key that hid
behind exactly such a prefix. So the prefix is exempt *there* and verified *precisely* in
`check_network_craft.py`, which derives the expected set from `CraftStations`' own table and
requires the lang file to match both ways. A missing key is not cosmetic: `Component.translatable`
renders the raw key, so the tab would read `reverseeon.craft.smelting`.
### Network crafting — the server half
Crafting from the tablet, pulling ingredients out of the storage network. The UI panel and the JEI
button are not in yet; what is in is everything underneath them, because that is where the bugs live.
- **Linked *stations* are a new, separate list from linked boxes.** ⚠️ They had to be. A crafting
table, smithing table and stonecutter have **no block entity at all**, so they could never satisfy
the storage rule (`has a Container or ITEM_HANDLER`) — "link your crafting table" would have done
nothing, silently. And a furnace satisfies it *too* well: linked as storage, the coal mid-burn
becomes withdrawable stock and the terminal will happily deposit cobblestone into the fuel slot. So
`useOn` now asks `CraftStations.isStation` **before** the block-entity test, and station wins over
container.
- **Every vanilla process, plus other mods' machines.** Vanilla is a written-out table (crafting,
smelting, blasting, smoking, campfire, stonecutting, smithing). ⚠️ Forge has no "this block runs
this recipe type" registry — that link lives only inside each machine's own code — so modded blocks
fall back to matching **namespaces**. That is a guess, and deliberately the generous one; the
alternative is the feature not working for the packs it was asked for.
- ⚠️ **The guess is safe because `RecipeRunner` sandboxes it.** `Recipe<C extends Container>` types
`matches` to *that recipe's own container class*, and generics are erased — handing a modded recipe
a plain container compiles fine and throws `ClassCastException` from inside another mod's code, on
the server thread, mid-click. There is no way to ask a recipe what it wants; the type argument is
gone at runtime. So every non-vanilla call is wrapped, a type that throws is **quarantined and never
retried**, and it is logged **once** — without the once, one bad recipe writes a stack trace every
frame the mouse moves, and the crash is contained but the server still dies, of logging.
- ⚠️ **Vanilla crafting needed the same care, and would have failed silently.** `CraftingRecipe`
narrows to `CraftingContainer` and calls `getWidth()` immediately, so a `SimpleContainer` throws on
the first 3×3 recipe anyone tries. Because the quarantine works, the symptom would not have been a
crash — it would have been `minecraft:crafting` disabling itself the first time a player clicked,
with one WARN in the log. `RecipeRunner.newInputs` returns a `TransientCraftingContainer` for shaped
processes.
- **`NetworkCrafter` runs plan → verify → commit, and removes nothing until the recipe is confirmed.**
⚠️ The naive order is a duplication bug: pull-then-assemble leaves the player short when assembly
fails, assemble-then-pull hands out a free item when the pull comes up short, and neither throws.
The planner is read-only by construction (`getStackInSlot`, never `extractItem` — extracting and
putting back can fail, and that failure destroys the item), and a lost race between plan and commit
rolls back, dropping into the world rather than voiding.
- ⚠️ **Reservation is per-plan, not per-ingredient.** A recipe needing 2 sticks would otherwise find
the same single stick twice, validate, then pull one — a 2-input recipe crafted from 1 input, free,
forever. `tools/check_network_craft.py` **simulates the planner** rather than reading it, asserting
`before == after + consumed` across six cases, and includes a control that deletes the reservation
to prove the test can fail. A structural check passes a planner with the reservation removed.
- **The craft grid is a *pattern*, not slots.** No items live in it, so closing the tablet — or the
hotkey closing it 200 blocks from the island — cannot strand anything, which vanilla's crafting grid
does to everyone eventually. `CraftPatternPacket` letting the client name any item is fine
*precisely because* the pattern is a ghost: a pattern of nine netherite blocks is a picture of an
exploit that no recipe accepts. ⚠️ That property is load-bearing — the moment anything treats the
pattern as a source of items, it becomes an item generator.
- `CraftRequestPacket` carries only a process id and a batch count. Not the recipe, not the
ingredients, not the result, not the computer position — each of those is a duplication bug wearing
a different hat. The process is checked against what the player's **own linked stations** actually
unlock, which is what makes "ต้อง link ก่อน" a rule rather than a UI suggestion, and batches are
clamped to 64 because each one is a full pass over every linked chest.
- ⚠️ **`check_tablet_access.py`'s packet-order rule was the wrong shape and got fixed, not loosened.**
It asserted `OpenTabletPacket` was *last*, which broke the moment two more packets were legitimately
appended. The invariant is not "X is last", it is "the order that already shipped never changes" —
it now holds a frozen prefix, so appending passes and inserting *or removing* anywhere fails.
### The tablet goes on your belt, and `C` opens it
- The Suitcase Tablet can be worn in the Curios **belt** slot, and a new keybind — **`C`** by default,
rebindable under Controls → Reverse Eon — opens the terminal without taking it out. Right-clicking a
held tablet still works exactly as before.
- ⚠️ **The dangerous part of this was not the slot, it was the six places that looked for the tablet.**
`stillValid`, `SuitcaseNetworkOps.mayAccess`, `TabletSummonNpcPacket`, `TabletZonePacket` (twice),
the screen's `tablet()`, the zone preview renderer — and a seventh, `/reverseeon install skip`, that
only turned up when the new checker swept the tree. Each had its own
`for (InteractionHand hand : ...)` loop. Teaching **some** of them about belts is the failure mode
that hurts, because it does not crash and it does not look like a belt problem:
| left hand-only | what the player sees |
|---|---|
| `stillValid` | terminal opens, closes one tick later → "the hotkey is broken" |
| `mayAccess` | terminal opens and stays open, every item click silently refuses → "storage is broken" |
Neither mentions Curios anywhere in the log. So there is now **one** answer to "where is the
tablet" — `item/SuitcaseTabletAccess` — and `tools/check_tablet_access.py` fails the build if any
other file scans for one. Break-test 12 adds a fresh file with its own loop; the checker catches it.
- The 80% Tome gate and the open moved into that class too, so right-click and the hotkey cannot
drift. ⚠️ Two copies of a gate is two places for it to rot, and the one that rots is the one used
less — the hotkey — so nobody would notice. `check_tablet_menu.py` follows the code to its new home
rather than being deleted for failing.
- ⚠️ **Walk-zone marking deliberately still requires the tablet in your hand.** The two corners are
picked by right-clicking blocks, which runs `useOn` on the *held* stack — arming a belt-worn tablet
would write zone mode onto a stack you cannot click with, and the marks would silently never land.
It now says so instead. That is what `SuitcaseTabletAccess.inHand` exists for.
- ⚠️ `OpenTabletPacket` **carries nothing**. The obvious design sends the slot or the bound computer
the client found, and it is the same hole `mayAccess` exists to close: anything in the packet is
chosen by the client, so a crafted one could name someone else's Computer Suitcase. With no fields,
there is nothing to lie about — the server acts only on what it can see for itself.
- ⚠️ Curios is `mandatory=false`, and the new lookup runs from `stillValid` — **every tick the terminal
is open**. `compat/CuriosTablet` keeps the `CuriosApi` call in its own class behind an
`isLoaded("curios")` check, and in a *separate method* from that check, because the JVM resolves a
whole method at once and a guard sharing the method never gets to run. Without Curios installed the
in-line version would have thrown `NoClassDefFoundError` in a loop.
- The `belt` tag ships `"replace": false` and the slot itself is **not** redefined — `belt` is one of
Curios' standard slot types, and re-declaring it would overwrite another mod's icon or slot count.
### The terminal stopped stuttering past ~300 item types
- ⚠️ **It was not the drawing.** Only 36 cells are ever drawn, however large the network. The cost was
`displayList()`, which filtered and sorted the whole network from scratch — and was called **twice
per frame**: once by the grid and again through `renderScrollbar → maxScroll → totalRows`.
- ⚠️ **The comparator was the expensive part, and it was invisible.**
`Comparator.comparing(i -> reps.get(i).getHoverName().getString().toLowerCase(...))` builds a
`Component`, resolves a translation and allocates a lowercased copy — **per comparison, on both
operands**, and the `thenComparing` tie-break did it again. Roughly **4 name resolutions per
comparison**, and a sort of n items is about n·log₂n comparisons.
| types | name resolutions per frame, before | after |
|---|---|---|
| 40 | 1,783 | 0 |
| 300 | 20,349 | 0 |
| 400 | 28,460 | 0 |
| 800 | 63,320 | 0 |
At 400 types and 60fps that was ~1.7 million a second. It is invisible at 40 types because 40 is
1,783 a frame, which is why this only showed up once a network got big.
- New `client/TerminalView`: the display name, registry id and type name are resolved **once, when a
contents snapshot arrives**, and the sort compares those Strings. The order is cached and rebuilt
only when the contents, the search text, the sort mode or the sort direction changes — scrolling
and hovering rebuild nothing. The order is an `int[]` rather than a `List<Integer>`, which had been
boxing 400 throwaway objects per rebuild.
- `tools/check_terminal_perf.py` — new, and it guards **correctness**, not speed. ⚠️ A cache that
misses an input does not go slow, it goes *wrong*: type in the search box and the grid keeps showing
the previous filter, which reads as the search being broken. It asserts all four inputs appear in
the guard, and that the search is compared with `equals` rather than `==`.
- ⚠️ Its first version checked the whole `rebuildIfStale` **body**, which also *assigns* every key
field — so four break-tests that each deleted one comparison from the `if` all passed. It reads the
condition only now. That is the fifth time this session a substring check has been too loose; the
pattern is that a check must be scoped to the exact construct it is about.
### Bonds are picked from the tablet now, and you can see yourself
- The INFO tab carries a **BONDS picker** — all ten, click to toggle. Vertin's screen still works; the
tablet is simply no longer the one place you cannot do it from.
- ⚠️ **The names are read from `PassiveSelectScreen`, not copied.** Ten strings in two screens is the
parallel list this project keeps getting caught by, and this instance would have been quieter than
most: both screens keep working, they just disagree about what a bond is called, forever. The array
went from `private` to package-visible for exactly this.
- **The toggle sends `PassiveTogglePacket`** — the same message Vertin's screen sends. The tablet has
no opinion about which bonds are unlocked or how many slots you have; the server answers with a
sync, so the tablet cannot show a bond as active that the server refused.
- Your character is drawn beside your stats. ⚠️ **Only ever yours** — the info packet carries numbers,
not a skin or a pose, and the subject may be behind a wall. Drawing your own body next to someone
else's numbers would be a lie about whose stats are on screen.
- `tools/check_tablet_info.py` — new. ⚠️ Its first version was satisfied by a file-wide search for
`PassiveData.ALL.length`, so hard-coding `10` in the **draw** loop passed while the click loop still
had it — which would leave an eleventh bond clickable but invisible. Both loops are checked
separately now. Seven break-tests fire.
### The tablet has an INFO tab — and it can read other players
- New **INFO** tab: every attribute that has actually been touched, with **what touched it**, plus
active effects and the bond passives selected. `HP 20 (+10 Sonetto bond, +4 chestplate)` rather than
a bare number.
- **It covers other mods for free.** Anything that changes a stat through the attribute system lands
in the same map with a name attached — a skill-tree node, an Aether accessory, a Curio, armour,
this mod's own bonds. Walking `AttributeInstance.getModifiers()` catches mods this one has never
heard of, with no per-mod code.
- ⚠️ **The tab says "from attributes only", and that wording is load-bearing.** A mod that adds flat
damage inside its own damage handler writes nothing down anywhere, so there is nothing to read —
two of this mod's own artifacts work that way. Presenting the list as a complete accounting would
be a lie a player has no way to check.
- ⚠️ **Why it needs a packet when the client already has the numbers.** Vanilla syncs a player's
attributes to their own client, but `ClientboundUpdateAttributesPacket` carries only **UUID, amount
and operation** per modifier — never the name. A purely client-side version could say "+10% from
some UUID" and never say *which passive*, which is the entire question.
- **Shift + right-click another player with the tablet** to read them. ⚠️ Range, dimension and
invisibility are checked in `PlayerInfoPacket.mayRead` — **one copy, called by both paths**. For a
moment the rules lived on the request packet only, so the gesture skipped every one of them while a
comment claimed otherwise; without them the tablet is a remote scanner that names anyone on the
server and reads their gear through a wall.
- This is the **third** item on shift + right-click. They never race because the binding is
disambiguated by *what is held*, not by what is clicked — and `check_interact.py` now renders all
three in one (held × clicked) table. ⚠️ Two more break-tests slipped past it first: counting
`return PASS` occurrences instead of checking the specific branch, and matching `mayRead` as a
substring of `mayReadDISABLED`. Four break-tests fire now.
### The Suitcase Tablet is a real container now
- ⚠️ **The terminal had none of the basics, and could not have had them.** It was a bare `Screen`
that drew item icons itself and fired a packet on each click. A bare screen has no cursor stack, so
there was nothing to pick an item up *onto* — which is why there was no drag, no shift-click to
stow, no Q to drop, no double-click to gather, no click-drag to split. It is a real
`AbstractContainerMenu` + `AbstractContainerScreen`, and the player's 36 squares are real `Slot`s,
so all of that now comes from vanilla and behaves exactly as it does in a chest.
- **Shift + left-click stows to the network**, via `quickMoveStack`. ⚠️ That method's contract is
unforgiving: return `EMPTY` to stop, otherwise a copy of what the slot held *before*. Vanilla loops
until it gets `EMPTY`, so a version that reports progress it did not make hangs the server thread —
every early exit returns `EMPTY`, including "the network is full".
- **The network grid is deliberately still not made of slots.** A `Slot` addresses one index of one
container; the grid is an *aggregate* that merges equal stacks across every linked chest, so one
entry can be 4,000 items living in nine boxes and its index moves whenever anything changes
anywhere. Slots over that would be lying. Its clicks go through the cursor instead — take a stack,
take half, or put the carried stack away — so once an item is on the cursor everything after that
is ordinary vanilla.
- ⚠️ **The tablet is opened by the server now.** A menu only exists if the server made one; the old
client-side `setScreen` produced a window with no menu behind it. The Tome's 80% gate moved with
it — it used to be a client-side check that stopped the window appearing and stopped nothing else,
since the packets that window would have sent were accepted regardless.
- ⚠️ **Slots go inactive on the NPC and ZONE tabs.** Slots cannot be removed after the menu is built,
and a slot you cannot see is still a slot you can click; merely not drawing them would leave an
invisible, working inventory over those panels. They are hidden by an `isActive()` override rather
than by relocation — **`Slot#x`/`Slot#y` are `final` in 1.20.1**, which the first attempt got wrong
and the compiler caught.
- `tools/check_tablet_menu.py` — new, and it re-derives the geometry from the source's own
expressions rather than keeping a copy. ⚠️ The first version *did* keep a copy, and a deliberate
one-pixel shift in `placeSlots()` did not fail it — a checker holding its own copy of the thing it
checks checks nothing. Two more holes came out of the same break-testing: substring searches were
finding calls inside comments, and `quickMoveStack` matched a method renamed
`quickMoveStackDISABLED`. All eight break-tests fire now.
### Glasfeder cast for free, forever
- ⚠️ **No mana check, no mana spent, no cooldown.** The staff called Iron's Spellbooks'
`AbstractSpell#onCast` directly, which in IS's own source is "the primary spell effect handling" and
nothing more. The three things that make a spell cost something are all somewhere else: the check
is in `canBeCastedBy`, the mana is subtracted in `castSpell`, and the cooldown is applied at the end
of the same method. Calling `onCast` skipped all three, so the staff fired the effect and charged
nothing. It now goes through **`attemptInitiateCast`**, which is what IS's own right-click handler
uses — so an empty mana bar or a spell still cooling down now refuses, with IS's own message.
- ⚠️ **It fails closed.** If the API cannot be resolved the staff casts nothing. The old code did the
opposite and cast anyway, which quietly turns a compatibility problem into a balance exploit.
- ⚠️ **The method is found by name and its arguments built from its real parameter types**, read at
runtime. `attemptInitiateCast`'s signature is not the same across IS versions — newer ones carry a
trailing `castingEquipmentSlot`, and the caster parameter has been both `Player` and `LivingEntity`.
Hard-coding one signature is how the original bug happened.
- **The five Euphoria artifacts and the Masked Follower still call `onCast`, and that is correct.**
They pass `CastSource.NONE` / `MOB`, which IS does not debit mana for by design — an artifact
proc on its own cooldown, and a mob with no mana bar. `tools/check_spellcast.py` enforces the
distinction: `onCast` is fine for a free source and forbidden for one the player pays for. Proven to
fire on five deliberate breakages.
- ⚠️ The item's 10-tick cooldown is **click anti-spam only** and is now labelled as such. It was
previously the only cooldown the staff had, which is how this went unnoticed.
### Mr. Apple comes through the portal with you
- ⚠️ **He used to be left behind, permanently.** `FollowSummonerGoal` teleports the buddy when it
falls more than 22 blocks behind, but it refuses to run when the two are in different levels —
correctly, since distance between dimensions means nothing. So the one case it could not cover was
the only one with no way back: step through the Dimension Portal and Mr. Apple stands on the island
forever. He now follows through any dimension change.
- ⚠️ **A dimension change replaces the entity.** `changeDimension` builds a new one in the destination
and copies the old one's NBT across, and NBT does not carry a UUID — so the buddy that arrives has a
**different UUID** from the one recorded at summon time. Left unwritten, every check keyed on that
id stops matching: dismiss refuses ("that isn't your buddy") and the next summon fails to clean up
the old one, so you accumulate a herd of Apples. The id is rewritten as part of the move.
### Sonetto's 400 bond is a sworn blade now
- Was: aura cooldown −5 minutes. Now: **+10% spell damage**, and **you cannot harm another player** —
melee, arrows and spells alike. The damage figure is `[affinity] sonettoSpellDamage`; the oath is
not tunable.
- ⚠️ Cancelled at `LivingAttackEvent`, not `LivingHurtEvent`. Zeroing the damage would still leave the
victim flinching, taking knockback and aggroing — "I hit them for nothing" rather than "I cannot
hit them".
- ⚠️ Keyed on `getEntity()`, not `getDirectEntity()`. The direct entity is the *arrow or the spell
projectile*; the player who fired it is `getEntity()`. Checking the wrong one would block only the
melee swing and leave every bow, thrown potion and ranged spell — most of what a spellcaster does —
free to kill a teammate.
- The bonus uses its **own** modifier UUID, separate from the 800 burst that touches the same
attribute. Sharing one would mean the standing bonus vanished the first time the burst expired.
### NPCs come back to the zone you drew
- An islander that ends up more than 12 blocks outside its Suitcase Tablet walk zone is returned to
it. `NpcWanderGoal` already *steered* toward the zone, but steering is not arriving: an NPC knocked
off a ledge or left across water has no path home and will keep choosing destinations it cannot
reach for as long as the world is loaded.
- It aims at the **nearest** point of the zone, not the centre — slip off the north wall and you come
back to the north wall, not the middle of the courtyard — and searches downward for standable
ground rather than trusting the clamped Y, which may be underground.
- **Never while dead or dying**, as asked: a death is the one time an NPC belongs exactly where it
fell, drops and all. Following a player also wins over the zone, since that request is more recent.
### The suitcase carries animals now
- **Shift + right-click an animal with the Timekeeper Suitcase** to send it to the island dock. It is
the one thing in the mod that already moves a living body between worlds, so putting a sheep in it
needed no new fiction. Arrivals land at the dock rather than your personal warp pad — a pad can be
a one-block ledge, or a room you fit in and a horse does not. Switch: `[suitcase] ferryAnimals`.
- Refused, each with its own line: someone else's tamed pet, anything that is not an animal, anything
hostile, and **anything with a rider** — `changeDimension` moves the vehicle and leaves the rider
behind in the old world, which for a player on a horse means being dropped where the horse used to
be. Leashes are cut and a mob riding a boat steps off first.
- ⚠️ **Horses are not `TamableAnimal`.** They keep their own `isTamed`/`getOwnerUUID` pair under
different spelling, so the obvious single `instanceof` check reports every tamed horse, donkey,
mule, camel and llama as unowned — and hands other people's mounts to whoever clicks one.
### Following an islander — the toggle got stuck
- ⚠️ **Shift + right-click to follow needed two clicks to resume, and said the wrong thing on the
first.** The toggle compared the stored UUID and nothing else, but the movement goal only runs
while the target is in the same level — so walking through the Dimension Portal, or logging out,
stopped the NPC while the id stayed set. Coming back and clicking to resume matched that stale id
and turned following *off*, answering "I'll stay here" about something it had already stopped
doing. It now toggles on what the NPC is **actually doing**: a stored id only counts as following
if that player is reachable right now.
- The same `toggleFollow` was copy-pasted **byte-for-byte into all five islanders**. Five copies of a
state machine is five places for it to be wrong, and it was wrong in all five. The logic is now a
default method on `IslandFollower`; the entities supply only the field it lives in.
- `tools/check_interact.py` — new, and the important one. ⚠️ Minecraft calls
`Item#interactLivingEntity` **before** the entity's `mobInteract`, so the suitcase decides whether
the follow toggle runs at all. Returning `FAIL` on an islander — the obvious thing to write, since
ferrying one should not happen — would silently break following whenever the suitcase was in hand,
which is exactly when you are on the island. It returns `PASS`, and the checker asserts it, plus
that no islander re-implements the toggle. Proven to fire on five deliberate breakages.
### Reported by a player after 1.1.6 — onboarding could not be switched off
- ⚠️ **`giveTomeOnJoin = false` did not stop the join gifts, and from outside it looked like the config
was simply broken.** It was doing its job — it just only ever covered *half* the onboarding. The
free **Timekeeper Suitcase** and its `[The Storm]` chat line had **no setting at all**, because
until recently that gift was hardcore-only and there was nothing to switch off. Opening it up to
every game mode turned a gift almost nobody saw into one everybody sees, in new and existing worlds
alike, with no way to stop it. The Suitcase now has the same pair of gates the Tome has:
`[onboarding] giveSuitcaseOnJoin` in the config, and the `reverseeonGiveSuitcaseOnJoin` gamerule.
**Two gifts on the same event need the same number of switches.**
- ⚠️ **Seven advancements announced themselves in chat on join.** Nothing was earned in that moment:
`onProgressTick` walks the player's whole inventory every two seconds and grants `items/<name>` for
anything not yet recorded, so on a world played before the tracker existed every mod item already
in the pack gets "discovered" on the same tick — and all seventeen `items/*` files had
`announce_to_chat: true`. They are now toast-only. The seven `recipes/*` advancements, which are
not achievements at all and exist only to unlock a recipe, are silent and hidden the way vanilla
marks its own.
- `tools/check_advancements.py` — new. Enforces the rule by folder, and treats a **missing**
`announce_to_chat` / `show_toast` as the failure it is: Minecraft defaults both to *true*, so an
omitted flag is as loud as one that asks for it. Also checks every parent resolves, every
`requirements` entry names a real criterion, and every `rewards.recipes` id has a recipe file.
Proven to fire on five deliberate breakages.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include