diff --git a/CLAUDE.md b/CLAUDE.md index a4f12c2fb..51ab48759 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,9 +107,14 @@ for the edit→build loop in this repo. Generated output lives in `/common/src/main/generated/` and is committed; **regenerate, never hand-edit**. -> On MC 26.2, datagen additionally needs item data components bound at the top of `buildRecipes()` -> (`BuiltInRegistries.DATA_COMPONENT_INITIALIZERS`). **That is not needed on 26.1.2** and the API does -> not exist here — components are bound at construction. Restore it if you forward-port. +> ⚠ Datagen needs item data components bound at the top of `buildRecipes()`, because components are +> data-driven and datagen never performs the server reload that binds them: +> ```java +> BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(registryLookup).forEach(pending -> pending.apply()); +> ``` +> Without it, anything reading components at datagen time (e.g. `Item.getDefaultMaxStackSize()`) throws +> `NullPointerException: Components not bound yet`. Same on 26.1.2 and 26.2 — see `docs/TESTING.md` +> for the unit-test equivalent. ## Scaffolding diff --git a/archaeology-tweaks/POTENTIAL_FEATURES.md b/archaeology-tweaks/POTENTIAL_FEATURES.md new file mode 100644 index 000000000..34409cf7d --- /dev/null +++ b/archaeology-tweaks/POTENTIAL_FEATURES.md @@ -0,0 +1,53 @@ +# Potential Features — Archaeology Tweaks + +Brainstormed, thematically appropriate feature ideas. Nothing here is committed or implemented; these are +starting points for future planning. + +## More suspicious blocks + +* **Suspicious Snow / Suspicious Powder Snow** — frozen digs in icy biomes; melts (losing its loot) if the + biome warms or fire is nearby, making excavation time-sensitive. +* **Suspicious Moss** — jungle and lush-cave digs; brushing occasionally yields living things (seeds, + saplings, glow berries) alongside artifacts. +* **Suspicious Mycelium / Suspicious Podzol** — mushroom-island and old-growth forest digs. +* **Suspicious Netherrack / Suspicious End Stone** — extend archaeology to the other two dimensions, with + dimension-appropriate loot tables (ancient debris scraps, chorus relics). +* **Suspicious Ash / Basalt Sand** (custom block) — volcanic dig sites near basalt deltas. + +## Brush improvements + +* **Copper, Gold, Diamond, and Netherite Brushes** — a tier ladder mirroring tools: higher tiers brush + faster and/or have more durability. Gold could trade durability for a luck bonus. +* **"Keen Eye" enchantment** — suspicious blocks within a few chunks shimmer faintly while a brush with + this enchantment is held, solving the "they're invisible in terrain" discoverability problem. +* **"Careful Hands" enchantment** — a chance to pull a *second* roll from the loot table on completion. +* **Brush handle customization** — combine a brush with dye or a banner pattern purely for looks. + +## Dig sites & world generation + +* **Buried dig sites** — small worldgen features that cluster 5–15 suspicious blocks around a skeleton, + fossil, or ruined foundation, so players can find a "site" rather than lone blocks. +* **Fossil excavations** — vanilla fossils generate partially encased in suspicious blocks; fully brushing + the site yields bonus bone blocks and rare sherds. +* **Torn map fragments** — a loot item that, when several are combined, points to a nearby dig site + (treasure-map style). + +## Loot & progression + +* **Depth-tiered loot** — optional rule where suspicious blocks below a configurable Y level roll a rarer + loot table; deeper digs, better finds. +* **New pottery sherds** — a set of sherd designs matching the new materials (a mushroom sherd from + mycelium, a soul-flame sherd from soul sand, etc.), usable on vanilla decorated pots. +* **Field Journal item** — records every unique artifact you've brushed up; doubles as a collection + checklist with advancement hooks. +* **Advancements** — "First Dig," "Gentle Touch" (complete a dig without breaking the block), "Museum + Quality" (collect every sherd), "Interdimensional Archaeology." + +## Integration & configurability + +* **Datapack-defined suspicious variants** — a data-driven registry so datapacks can declare *any* + full block as brushable with a chosen loot table, rather than being limited to the mod's built-ins. +* **Archaeologist wandering trades** — wandering traders occasionally sell brushes and single suspicious + blocks, giving survival players a renewable (if pricey) way to seed their own dig sites. +* **Config: brushing speed & loot rolls** — global multipliers so pack makers can make archaeology + faster/slower or more/less generous. diff --git a/archaeology-tweaks/README.md b/archaeology-tweaks/README.md new file mode 100644 index 000000000..5254d47a0 --- /dev/null +++ b/archaeology-tweaks/README.md @@ -0,0 +1,52 @@ +# Archaeology Tweaks (Fabric/NeoForge) + +![Version: 4.0.0-alpha.0](https://img.shields.io/badge/version-4.0.0--alpha.0-blueviolet?style=flat-square) ![Modloader: Fabric](https://img.shields.io/badge/modloader-Fabric-1976d2?style=flat-square) ![Modloader: NeoForge](https://img.shields.io/badge/modloader-NeoForge-1976d2?style=flat-square) ![Client: required](https://img.shields.io/badge/client-required-4caf50?style=flat-square) ![Server: required](https://img.shields.io/badge/server-required-4caf50?style=flat-square) + +_Small, vanilla-friendly tweaks to Minecraft's archaeology system._ + +## Introduction + +Vanilla archaeology only lets you brush two blocks: suspicious sand and suspicious gravel. Archaeology Tweaks +extends that idea to a range of additional materials, so digs can be seeded across far more of the world without +feeling out of place. Every block behaves like its vanilla counterpart — brush it with a brush to slowly reveal +whatever loot has been assigned to it. + +### Minecraft Versions + +* 26.1.2: supported +* Earlier versions: see the git history for builds targeting older releases + +### Current Features + +* Adds "suspicious" brushable variants of common terrain blocks: + * Suspicious Clay + * Suspicious Dirt + * Suspicious Mud + * Suspicious Packed Mud + * Suspicious Red Sand + * Suspicious Rooted Dirt + * Suspicious Soul Sand + * Suspicious Soul Soil +* Each block is brushed exactly like vanilla suspicious sand/gravel, revealing its loot piece by piece +* Includes gravity-affected variants that behave like the block they are based on (e.g. sand and red sand fall) +* Loot is driven by loot tables, making it easy for datapacks to customize what each block can contain + +## Notes for Documentation + +This mod's blocks are intended to be placed by world generation, structures, or datapacks rather than crafted. +When writing in-game or datapack documentation, the key player-facing behavior is simply: _find a suspicious +block, equip a brush, and hold right-click to excavate it._ + +## Issues & Suggestions + +Please use the [GitHub issue tracker](https://github.com/chimericdream/archaeology-tweaks/issues) to report any +bugs you find. + +## Credits + +Obviously this mod would not be possible if not for the people at Mojang making an awesome game. Thanks also go to +the developers of the Fabric and NeoForge mod loaders and the Architectury API. + +## License + +This mod is released under the MIT license. [The full text of the license can be found here.](./LICENSE) diff --git a/archaeology-tweaks/TEST_PLAN.md b/archaeology-tweaks/TEST_PLAN.md new file mode 100644 index 000000000..27153d465 --- /dev/null +++ b/archaeology-tweaks/TEST_PLAN.md @@ -0,0 +1,106 @@ +# Test Plan — Archaeology Tweaks + +Archaeology Tweaks adds eight "suspicious" brushable terrain variants (clay, dirt, mud, packed mud, +red sand, rooted dirt, soul sand, soul soil), each behaving like vanilla suspicious sand/gravel: +brush to reveal loot-table-driven loot piece by piece. Key classes: per-material block classes, +`ATBrushableBlockEntity`, `BrushableFloatingBlock`/`BrushableFloatingNonFullBlock` (gravity +variants), the `BrushItemMixin`/`AbstractBlockMixin` hooks, and a client-side renderer for the +partially-brushed states. + +## Test conventions + +Follow the Hopper X-Treme pattern: GameTest classes in +`fabric/src/main/java/com/chimericdream/archtweaks/fabric/test/` (match the mod's actual package, +`com.chimericdream.archtweaks`... verify against `ArchaeologyTweaksMod`'s package before creating), +registered under the `fabric-gametest` entrypoint in `fabric.mod.json`, `.snbt` structures under +`common/src/main/resources/data/archtweaks/gametest/structure/`. Brushing is player-driven, so most +GameTests will use a mock player + programmatic brush usage, or directly drive the block entity's +brushing methods where the mixin allows. + +## Manual test plan + +Setup: creative world; `/give` each suspicious block and a brush. Fabric full pass, NeoForge smoke +pass. + +1. **Brushing happy path (per block)** — for each of the 8 blocks: place, hold right-click with a + brush, verify (a) progressive "dusting" animation and texture stages render, (b) loot item pops + out after the vanilla-length brush time, (c) block converts to its base terrain block (clay → + clay, soul sand → soul sand, etc.) when fully brushed. +2. **Brush interruption** — stop brushing mid-way: block should regress to unbrushed after a moment + (vanilla behavior), with no dropped loot. +3. **Gravity variants** — suspicious red sand (and any other gravity blocks): break the supporting + block; it should fall like sand, and — mirroring vanilla — the falling version should degrade to + plain red sand (or whatever behavior is intended; pin it). Non-full gravity blocks + (`BrushableFloatingNonFullBlock`) need the same check. +4. **Soul sand specifics** — suspicious soul sand: confirm whether it slows entities like real soul + sand (block behavior parity) and works under water. +5. **Loot customization** — override one block's loot table with a datapack; verify the brushed loot + changes accordingly (this is an advertised feature). +6. **Non-brush interactions** — mining with a shovel drops nothing special (or per loot table); + pistons: vanilla suspicious blocks are destroyed when pushed — verify parity. +7. **Brush durability & enchantments** — brushing consumes durability; Unbreaking/Mending behave. +8. **Client rendering** — `ATBrushableBlockEntityRenderer`: the partially-revealed item renders + inside the block at the correct dusting stages; check on Fabric and NeoForge (renderer + registration is per-loader). + +## Recommended automated tests + +### GameTests — brushing behavior + +Structure `archtweaks:brushing/single_block` (a 3×3 platform with one suspicious block). + +* **`brushingRevealsLootAndConverts_`** (parameterized across all 8 blocks, like + Hopper X-Treme's per-tier tests): set the block, seed its `ATBrushableBlockEntity` with a known + item (the BE should expose the vanilla `item` field — set directly rather than relying on the loot + roll), then simulate brushing. Options for driving the brush, in order of preference: + 1. call the block entity's brush-progress method directly across ticks (deterministic); + 2. mock player + `player.gameMode().useItemOn(...)` repeatedly with a brush. + Assert: an item entity of the seeded item appears (`assertItemEntityCountIs`), and the block + becomes the base terrain block. +* **`brushingInterruptedResets`** — advance brushing partway, stop, wait ~40 ticks, assert dusted + state property returns to 0 and no loot dropped. +* **`lootTableRolledOncePerBlock`** — brush the same block fully; assert exactly one loot drop (no + duplication when the conversion and the drop race — a classic bug source). + +### GameTests — gravity variants + +* **`suspiciousRedSandFalls`** — block on a support over a 3-deep hole; remove support; after + 10 ticks assert the landing position holds the expected block and the original position is air. + Also assert what lands: suspicious block vs plain red sand (pin intended behavior). +* **`fallingOntoTorchBreaksToItem`** — vanilla falling-block edge: assert the drop matches the loot + table. +* **`nonFullFloatingVariantSurvives`** — whatever `BrushableFloatingNonFullBlock` exists for (verify + in code), place unsupported and assert intended float/fall behavior. + +### GameTests — loot data + +* **`allBlocksHaveBrushingLootTables`** — iterate `ModBlocks`; assert each block's brushing loot + table ID resolves in the server's reloadable registries (catches typos and missing datagen + output). +* **`datapackOverrideRespected`** — ship a test-only datapack (in the fabric test source set's + resources) overriding one block's table to a single known item; brush and assert that item drops. + +### Unit tests + +* None of substance — the logic is engine-coupled. Keep the effort in GameTests. + +## ChimericLib helper opportunities + +* **Brushing simulation helper** — "advance brushable BE at pos by N brush strokes" + + `assertDustedLevel(pos, n)`. Only this mod needs it today, but a "tag-first brushable" ChimericLib + feature is already floated in its POTENTIAL_FEATURES; the helper belongs beside it. +* **Loot-table resolution assertion** — `assertLootTableExists(server, id)` and + `rollLootTable(server, id, seed, n)` for datapack-driven mods (shared with Athenaeum, Miniblock + Merchants). +* **Mock-player item-use loop** — "use item X on pos Y for N ticks" wraps the fiddly + `useItemOn`/`releaseUsing` dance; reused by Artificial Heart (axe/shears), Villager Tweaks + (bundle on villager), and Minekea (wrench). +* **Falling-block assertions** — `assertBlockFellTo(helper, from, to, expectedBlock)`; shared with + Houdini Block's sand tests. + +## Open questions + +* Intended behavior when a suspicious gravity block falls: stay suspicious (keep loot) or degrade to + the plain block (vanilla suspicious sand degrades)? Tests must pin one. +* Do pistons/explosions destroy the loot (vanilla: yes)? Worth an explicit decision + regression + test either way. diff --git a/archaeology-tweaks/common/src/main/java/com/chimericdream/archaeologytweaks/block/entity/ATBrushableBlockEntity.java b/archaeology-tweaks/common/src/main/java/com/chimericdream/archaeologytweaks/block/entity/ATBrushableBlockEntity.java index 381121d97..0d0752470 100644 --- a/archaeology-tweaks/common/src/main/java/com/chimericdream/archaeologytweaks/block/entity/ATBrushableBlockEntity.java +++ b/archaeology-tweaks/common/src/main/java/com/chimericdream/archaeologytweaks/block/entity/ATBrushableBlockEntity.java @@ -156,7 +156,6 @@ public void scheduledTick(ServerLevel world) { world.setBlock(this.getBlockPos(), this.getBlockState().setValue(BlockStateProperties.DUSTED, j), 3); } - int k = 4; this.nextDustTime = world.getGameTime() + 4L; } diff --git a/chimeric-lib/POTENTIAL_FEATURES.md b/chimeric-lib/POTENTIAL_FEATURES.md new file mode 100644 index 000000000..d536716f8 --- /dev/null +++ b/chimeric-lib/POTENTIAL_FEATURES.md @@ -0,0 +1,124 @@ +# Potential Features — ChimericLib + +Brainstormed, thematically appropriate feature ideas. Nothing here is committed or implemented; these are +starting points for future planning. + +ChimericLib is **developer-facing plumbing**, so these suggestions are library features: shared systems +that multiple mods in the suite would otherwise each reinvent. Several are extracted from patterns that +already exist in individual mods. + +## Extract & generalize existing suite patterns + +* **Block migration framework** — a general "deprecated block converts itself on placement/load" system + (the pattern Hopper X-Treme uses for its filtered hoppers), so any mod can rename or merge blocks across + versions without DataFixerUpper: old ID → new block + component/NBT mapping, contents preserved. +* **Shared Wrench API** — Minekea and Hopper X-Treme both ship wrench items. A common wrench capability + (`WrenchableBlock` interface + a default wrench item) would let one wrench work across the whole suite + and keep interaction behavior consistent. +* **Item Filter API** — Hopper X-Treme's include/exclude filter logic as a reusable component, so Shulker + Stuff's Vacuum/Void enchantments (and future mods) can offer the same filtering UX with the same UI + widgets. +* **Villager profession helper** — Miniblock Merchants' "convert villager with item, register profession, + build trade tables" flow as a declarative builder. + +## Registration & data generation + +* **Block family generator** — declare a base block once and auto-generate stairs, slabs, walls, vertical + variants, plus their models, blockstates, recipes, loot tables, and tags (Minekea's bread and butter, + useful to every content mod). +* **Data-driven creative tab builder** — ordering, icons, and per-mod tab conventions in one helper. +* **Tag-first behavior registry** — register behavior against block/item tags rather than concrete + registries, so datapacks can opt blocks into suite mechanics (e.g. "any block in this tag is brushable"). + +## Cross-cutting infrastructure + +* **Config sync layer** — a small channel that syncs server-authoritative YACL config values to clients + on join, with a consistent "server value overrides local" indicator in config screens. Nearly every mod + in the suite with server-side config wants this. +* **Networking wrapper** — a thin, versioned packet abstraction over Fabric/NeoForge networking so common + code never touches loader-specific channels directly. +* **Component/NBT compatibility helpers** — utilities for reading old NBT and new data components through + one API, easing multi-version maintenance. + +## Developer & testing tools + +* **GameTest harness helpers** — shared fixtures for spinning up test worlds with suite blocks placed, + hopper-line assertions, inventory diffing, and "place → break → assert no updates" style checks. + The per-mod `TEST_PLAN.md` files (July 2026) identified the concrete helpers below; each name lists + its known consumers so the highest-leverage ones can be built first. + * **Container fill/assert helpers** — `fillContainer(helper, pos, stacks...)` and + `assertContainerExactly(helper, pos, stacks...)`, replacing the slot-by-slot `if` blocks in + Hopper X-Treme's `SixSlotTransferTest`. *(hopper-xtreme, minekea crates/barrels/jars, + shulker-stuff, jdcrafte)* + * **Menu/screen-handler test harness** — open a screen handler server-side with a mock player, + manipulate/shift-click slots, assert slot validation and output. Probably the single + highest-leverage helper in the suite. *(banner-tweaks loom, shulker-stuff dye station, + hopper-xtreme filter screens, minekea crates, chimeric-lib's own screens)* + * **Config override fixture** — `withConfig(handler, mutator, testBody)` that snapshots a YACL + config, mutates it for the test, and guarantees restoration. *(banner-tweaks, + beacon-conduit-tweaks, villager-tweaks, shulker-stuff, hopper-xtreme, miniblock-merchants)* + * **Loot-table test kit** — `assertLootTableExists/Contains(server, id, predicate)` structural + assertions plus `rollLootTable(server, id, seed, n)` seeded sampling. *(athenaeum, + miniblock-merchants, archaeology-tweaks, shulker-stuff, pannotia-companion)* + * **Registry loop-test scaffolding** — "for every entry of registry R in namespace M, assert + predicate P" with per-entry failure reporting: every block has an item / loot table / recipe / + creative tab. *(minekea ~230 blocks, miniblock-merchants ~1000 trades, cobblicious, + artificial-heart, sponj)* + * **Mock-player interaction wrappers** — "use item I on block face F", "use item I on entity E", + "shift-right-click", and "hold-use for N ticks" (brushing). *(artificial-heart axe/shears, + archaeology-tweaks brush, villager-tweaks bundle, miniblock-merchants conversion items, + minekea wrench/painter, hopper-xtreme wrench)* + * **Villager fixture builder** — `spawnVillager(helper, pos).profession(X).level(3).offers(...)` + with age/employment control; plus gossip/reputation assertion helpers. *(villager-tweaks, + miniblock-merchants)* + * **Block-update detector fixture** — observer + lamp watching a position, with + `assertNeighborUpdated` / `assertNoNeighborUpdate`, self-validated by negative controls. + *(houdini-block — its entire feature, minekea beam toggling, hopper-xtreme redstone mixin)* + * **Redstone start-gate fixture** — the "destroy redstone block → run → re-place to freeze" + pattern from Hopper X-Treme's timing tests, as `RedstoneGate.open/close(helper, pos)`. + *(hopper-xtreme, any timed machine)* + * **Inventory diffing** — snapshot player + container inventories, run an action, assert the + exact diff; includes `assertNothingDropped(helper, box)`. *(shulker-stuff vacuum/void/refill, + hopper-xtreme deprecation "no dupes" check, minekea)* + * **Entity-absence watcher** — `assertNoEntitySpawns(helper, type, ticks)` for spawn-suppression + promises. *(artificial-heart "no creaking", villager-tweaks zombie conversion controls)* + * **Fluid-region helpers** — fill/count/assert-absent fluid in a box. + *(sponj absorption radius/capacity, minekea jars)* + * **Tool-use conversion assertion** — "use tool on block ⇒ block became B with properties P + preserved, tool damaged by N". *(artificial-heart, hopper-xtreme deprecation-adjacent, + minekea block painter)* + * **BE NBT round-trip helper** — `assertSurvivesReload(blockEntity)` write/read cycle. + *(every block-entity mod: hopper-xtreme, shulker-stuff, minekea, archaeology-tweaks)* + * **Networking round-trip helper** — encode/decode assertions for custom payloads to pin wire + formats. *(banner-tweaks layer-limit sync, minekea network package)* + * **Reload-idempotence harness** — "reload datapacks twice, assert registries/loot stable". + *(athenaeum, chimeric-lib LootTableModifier, miniblock-merchants)* + * **Test-datapack fixture conventions** — a documented way to ship datapack fixtures in the + fabric test source set and assert on their load results. *(athenaeum, archaeology-tweaks + loot overrides, pannotia-companion)* + * **Mock-player-at-distance status-effect assertion** — spawn player N blocks away, assert + effect present/absent after M ticks. *(beacon-conduit-tweaks)* + * **Chunk-scan / dimension helpers** — generate a far-away or other-dimension chunk and run + per-column layer assertions. *(flat-bedrock, future world-gen features)* + * **Time-of-day and difficulty fixtures** — safely force/restore day-night or difficulty within + a test batch. *(artificial-heart eyeblossoms, villager-tweaks zombie conversion)* + * **Furnace fixture** — preload fuel/input, fast-forward burn state, assert slots. + *(sponj wet lava sponj fuel, future fuel items)* + * **Crop test helpers** — bonemeal/random-tick a crop to stage X, assert drops per stage. + *(minekea warped wart, jdcrafte's planned crops)* + * **JUnit wiring blueprint** — the canonical Gradle `test` source set + `fabric-loader-junit` + setup for Architectury projects, pioneered here and copied by other mods. + *(enchantment-numbers-fix RomanNumeralUtil, all pure-helper unit tests)* +* **Registry dump command** — `/chimericlib dump ` writing a report to disk, for + debugging cross-mod registration issues. +* **Debug overlay hooks** — an opt-in F3-style overlay section where suite mods can publish debug lines + (hopper cooldowns, filter state, seat entity positions). + +## Small shared conveniences + +* **SimpleSeatEntity polish** — configurable seat height offsets, dismount position safety checks, and + support for multi-seat blocks (benches/couches for Minekea). +* **Sound & particle helpers** — one-liners for "play this sound/particles at block, both sides handled." +* **Patchouli book datagen** — generate book structure from registered content so in-game docs stay in + sync with registries automatically (the repo already has a Patchouli update script; this would move the + guarantee into datagen). diff --git a/chimeric-lib/README.md b/chimeric-lib/README.md new file mode 100644 index 000000000..43bfa37c1 --- /dev/null +++ b/chimeric-lib/README.md @@ -0,0 +1,47 @@ +# ChimericLib (Fabric/NeoForge) + +![Version: 6.0.0-alpha.0](https://img.shields.io/badge/version-6.0.0--alpha.0-blueviolet?style=flat-square) ![Modloader: Fabric](https://img.shields.io/badge/modloader-Fabric-1976d2?style=flat-square) ![Modloader: NeoForge](https://img.shields.io/badge/modloader-NeoForge-1976d2?style=flat-square) ![Client: required](https://img.shields.io/badge/client-required-4caf50?style=flat-square) ![Server: required](https://img.shields.io/badge/server-required-4caf50?style=flat-square) + +_Shared library with common code for chimericdream's mods._ + +## Introduction + +ChimericLib is the core library that the rest of the chimericdream mod suite is built on. It is **not a content +mod** — it adds no blocks, items, or gameplay of its own. Instead it bundles the shared plumbing every mod would +otherwise have to reimplement: registration helpers, data generation, inventory and screen utilities, tag +definitions, and a handful of common blocks/entities. + +If you have installed one of the other mods in this suite, you need ChimericLib as a dependency. On its own it does +nothing visible in-game, so **in-game documentation is not applicable** to this mod. + +### Minecraft Versions + +* 26.2: supported + +### What's Inside + +For developers, ChimericLib provides shared code across the Architectury `common` layer, including: + +* **Registration** — `ModRegistryHelper` and related helpers for registering blocks, items, and other content + consistently across Fabric and NeoForge. +* **Blocks** — a `RegisterableBlock` abstraction plus `BlockConfig` and block/item data generators for + automated model, blockstate, and loot table generation. +* **Inventories** — `ImplementedInventory` and `InventoryUtils` for block entities that hold items. +* **Screens** — reusable single- and double-wide inventory screens and screen handlers. +* **Tags** — common block and item tag definitions shared between mods. +* **Utilities** — helpers for colors, fluids, text, textures, math/direction, tools, and configuration. +* **Entities** — a `SimpleSeatEntity` for sittable blocks (e.g. chairs and stools). + +## Issues & Suggestions + +Please use the [GitHub issue tracker](https://github.com/chimericdream/chimericlib-mc/issues) to report any bugs +you find. + +## Credits + +Thanks go to the developers of the Fabric and NeoForge mod loaders and the Architectury API, which this library +builds upon. + +## License + +This library is released under the MIT license. [The full text of the license can be found here.](./LICENSE) diff --git a/chimeric-lib/TEST_PLAN.md b/chimeric-lib/TEST_PLAN.md new file mode 100644 index 000000000..5c4e5f90c --- /dev/null +++ b/chimeric-lib/TEST_PLAN.md @@ -0,0 +1,176 @@ +# Test Plan — ChimericLib + +ChimericLib is developer-facing plumbing: registration (`ModRegistryHelper`, +`RegisterableBlock`/`BlockConfig`), datagen (`BlockDataGenerator`, `ItemDataGenerator` + Fabric +implementations), inventories (`ImplementedInventory`, `InventoryUtils`), screens +(`SimpleInventoryScreen(Handler)`, `DoubleWideInventoryScreen(Handler)`, `ScreenHelpers`), loot +(`LootTableModifier`), entities (`SimpleSeatEntity`), a `ShearsItemMixin`, tags, and utility classes +(`ColorHelpers`, `FluidHelpers`, `TextHelpers`, `TextureUtils`, `DirectionUtils`, `Tool`, +`ModConfigurable`). It has no player-facing content, so its test strategy differs from every other +mod: **unit tests for the pure helpers, GameTests against test-only fixture content for the +world-coupled pieces, and consumer-mod tests as the integration layer.** + +A key decision baked into this plan: chimeric-lib is also the planned home of the suite's **GameTest +harness helpers** (see `POTENTIAL_FEATURES.md`, "Developer & testing tools" — now expanded with the +specific helpers the other mods' test plans call for). The helpers themselves need tests here, using +throwaway fixture blocks registered only in the test environment (or in the `playgrounds` sandbox +project during prototyping). + +## Test conventions + +* **Unit tests** — the `test` source set lives in the **fabric** subproject (JUnit, wired in the + root `build.gradle` under `project.name == 'fabric'`: `fabric-loader-junit` + `useJUnitPlatform()`). + It went to fabric rather than common because the loader-junit bootstrap that makes `Identifier`, + `ItemStack`, etc. resolve off-thread is a fabric artifact. Registry-touching tests extend the + shared `BootstrapMinecraft` helper (see below) — the canonical wiring the other mods copy. +* **Shared test helpers (`testFixtures`)** — cross-mod unit-test helpers live in chimeric-lib + **`common`'s `testFixtures` source set** (`common/src/testFixtures/java/com/chimericdream/lib/testkit/`) + and publish as a `testFixtures` variant on `components.java` (a `-test-fixtures.jar` described in + Gradle Module Metadata). chimeric-lib's own fabric tests consume it via + `testImplementation(testFixtures(project(":chimeric-lib:common")))`; a downstream mod imports the + published variant with + `testImplementation(testFixtures("com.chimericdream.lib:chimericlib-common-:"))` — no + copying. `BootstrapMinecraft` is the first helper; the GameTest harness helpers land here too. +* **GameTests** — live in the fabric subproject's dedicated **`gametest` source set** + (`fabric/src/gametest/...`), created by `fabricApi.configureTests { createSourceSet = true }` in the + root `build.gradle`. This source set's classpath is extended from `main` (so it sees Minecraft, + fabric-api's gametest module, and chimeric-lib common) but its output goes only to the `runGameTest` + run config — **never** into the shipped/published jar. That is the isolation boundary: fixture + content and test classes both live here and cannot reach production. Test classes are in + `com.chimericdream.lib.fabric.test`, registered under the `fabric-gametest` entrypoint of a separate + test mod (`chimericlib_test`, `fabric/src/gametest/resources/fabric.mod.json`); its `main` entrypoint + registers the fixture content. Fixture content lives in `com.chimericdream.lib.fabric.test.fixture` + (a test block via `ModRegistryHelper`/`registerWithItem`, an `ImplementedInventory` container BE, a + `SimpleSeatEntity` `EntityType`, `MenuType`s for the screen handlers, and a `LootTableModifier` + subclass). Tests use the default `fabric-gametest-api-v1:empty` structure (8×8×8 air) and `setBlock` + at relative positions, so **no `.snbt` structure files are needed**. Run with + `./gradlew :chimeric-lib:fabric:runGameTest`. +* **Shared GameTest harness helpers (published)** — the reusable, mod-agnostic helpers + (`GameTestContainers`, `GameTestEntities`, `GameTestMenus`) live in **common's `testFixtures`** + source set (`common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/`), published in the + same `testFixtures` variant as `BootstrapMinecraft`. chimeric-lib's own `gametest` source set + consumes them via `gametestImplementation(testFixtures(project(":chimeric-lib:common")))`; a + downstream mod consumes the published variant the same way from its own `gametest` source set. They + operate only on vanilla `GameTestHelper`/`Container`/`AbstractContainerMenu`, so they compile in + common and never drag test code into any production jar. + +## Manual test plan + +The library has almost no direct manual surface; manual verification happens through consumer mods. +Keep this short list: + +1. **Suite boot** — full modpack (all active mods) boots on Fabric and NeoForge with no + registration errors; this is the library's real smoke test. +2. **Seat behavior** — via Minekea chairs/stools: sit, dismount, break-while-seated (details in + `minekea/TEST_PLAN.md`; root fixes land here). +3. **Shears mixin** — whatever `ShearsItemMixin` enables (verify against consumers — Artificial + Heart's shears conversion path): confirm vanilla shears behavior is otherwise unchanged + (sheep, vines, tripwire). +4. **Datagen run** — run each consumer mod's datagen and diff generated resources for unexpected + changes after touching the generators. + +## Recommended automated tests + +### Unit tests — pure utilities (highest value, cheapest) + +**Status: implemented.** All pure-utility suites below are live in the fabric `test` source set +(`fabric/src/test/java/com/chimericdream/chimericlib/test/`), 30 tests across 7 classes, run with +`./gradlew :chimeric-lib:fabric:test`. Registry-touching tests extend the shared `BootstrapMinecraft` +helper (published from common's `testFixtures` — see "Test conventions"), which runs the vanilla +bootstrap and bakes data components so headless `ItemStack` construction works on MC 26.2 (plain +`Bootstrap.bootStrap()` leaves them unbound — see that class's javadoc). This is the canonical JUnit +wiring the other mods copy (enchantment-numbers-fix next). + +* **`DirectionUtils`** — ✅ done. Full table tests: `getHitFace(axis, clickedFace)` across all 3 + axes × 6 faces (Artificial Heart depends on this exact matrix). No rotation/opposite helpers + exist on the class today. +* **`ColorHelpers`** — ✅ done. `RGB` round-trip and boundary values (0, 255, alpha handling), + `getTint` in/out-of-range, `getName`/`getColors`, `getDye`/`getWool` resolution + rejection, + `mixColors` null/identity/blend paths. +* **`TextHelpers`** — ✅ done. Component building/formatting: literal/translatable output, aqua+italic + style application; asserts flattened strings. +* **`ItemHelpers`** — ✅ done (not originally listed): `getIdentifier` for a populated stack and + `ItemStack.EMPTY` → `minecraft:air`. +* **`RomanNumeral`-adjacent note** — lives in enchantment-numbers-fix, not here; if it migrates to + the lib, its unit suite (see that mod's plan) moves with it. +* **`TextureUtils` / `Tool` / `ModConfigurable`** — ✅ done for `TextureUtils` (path prefix/suffix + building, namespace preservation, registered-block id lookup) and `Tool` (full `getItemTag` / + `getMineableTag` enum mapping incl. SHEARS/NONE nulls). `ModConfigurable` is a single-method + interface with no logic — nothing to unit-test. +* **`InventoryUtils`** — ✅ done for the current surface (`convertListToInventory`: size, per-index + contents, empty case). The stack merging/insertion math described here (component-sensitive + stacks, max-stack-size edges, Shulker Stuff's builder) is aspirational — that logic does not yet + live in this class; expand the suite when it lands. + +### GameTests — world-coupled pieces (against fixture content) + +**Status: implemented.** Nine GameTests across five classes, all green via +`./gradlew :chimeric-lib:fabric:runGameTest` (see "Test conventions" for the isolated `gametest` +source set + published-`testFixtures` structure). + +* **`RegisterableBlockGameTest`** — ✅ done. Registers a plain block+item and a block+item+block-entity + through `ModRegistryHelper`, then asserts each resolves under the expected id (`BuiltInRegistries` + lookups by identity, `hasBlockEntity()` for the trio). +* **`ImplementedInventoryGameTest`** — ✅ done. Fixture container BE: fill via code + slot assertions, + a `saveWithFullMetadata`→`loadStatic` NBT round-trip preserving contents, and a **vanilla hopper** + inserting into it (proves the `Container` contract behaves for real game systems). +* **`ScreenHandlerGameTest`** — ✅ done. Opens `SimpleInventoryScreenHandler` and + `DoubleWideInventoryScreenHandler` server-side with a mock player over a fixture container: asserts + slot count/layout and shift-click (`quickMoveStack`) routing in both directions. +* **`SimpleSeatEntityGameTest`** — ✅ done (lifecycle). Mock player sits, dismounts, and the seat + auto-despawns past its 20-tick grace window, leaving **no leaked entities**. Two behaviours the + earlier draft listed are **deliberately not asserted** because they don't exist yet (known gaps, + POTENTIAL_FEATURES "SimpleSeatEntity polish"): freeing the rider when the seat block is broken + mid-sit, and keeping the dismount position out of walls. Asserting them would only encode the gap; + add the tests when the safety logic lands. +* **`LootTableModifierGameTest`** — ✅ done. Subclass injects a guaranteed marker pool into exactly one + targeted table id; asserts non-targeted ids are untouched, and rolls the modified table to prove the + marker actually drops. (Reload idempotence is a property of the fabric loot event firing once per + load, not of `LootTableModifier` itself, so it's out of scope for this unit-of-code test.) + +### Datagen verification tests + +* **Golden-file style** — run `BlockDataGenerator`/`ItemDataGenerator` for a fixture block in a + unit test and compare emitted JSON against checked-in expected files (blockstate, model, loot + table). Catches silent format drift on Minecraft updates before it breaks 200 Minekea blocks. + +### Tests for the GameTest harness helpers themselves + +Each helper the suite plans (see `POTENTIAL_FEATURES.md` for the consolidated list — container +fill/assert, redstone gate, update detector, mock-player interaction wrappers, villager fixture +builder, config override fixture, loot-table kit, inventory diffing, entity-absence watcher, +menu harness, registry loop scaffolding) gets a self-test GameTest here proving it does what it +claims — e.g. the update-detector fixture is validated against a vanilla stone place (fires) and +nothing (doesn't fire) before Houdini Block trusts it. Helper self-tests double as usage +documentation for the other mods. + +## ChimericLib helper opportunities + +Not applicable in the usual sense — this mod is the *destination* for the helpers collected from +every other TEST_PLAN.md in the repo. The consolidated, prioritized list lives in +`POTENTIAL_FEATURES.md` under "Developer & testing tools"; treat the per-mod "ChimericLib helper +opportunities" sections as its requirements backlog, and this plan's "helper self-tests" section as +the acceptance criteria. + +## Resolved decisions + +* **JUnit `test` source set wiring** (was an open question) — the `test` source set lives in the + **fabric** subproject with `fabric-loader-junit` + `useJUnitPlatform()` (root `build.gradle`). + Shared helpers live in **common's `testFixtures`** source set and publish as a `testFixtures` + variant, imported downstream via `testImplementation(testFixtures("…:chimericlib-common-:"))`. + One loom wart to copy: the custom `testFixtures` source set doesn't inherit loom's Minecraft + classpath, so `common/build.gradle` does `sourceSets.testFixtures.compileClasspath += sourceSets.main.compileClasspath`. +* **GameTest fixture-content location** (was an open question) — the fully-isolated approach won: the + fabric **`gametest` source set** (from `configureTests { createSourceSet = true }`) is the isolation + boundary. Fixture content and test classes live there and are built only for `runGameTest`, never + the shipped jar — no separate `chimericlib-testmod` subproject, no gametest code behind the + production `main` entrypoint. The reusable *helpers* are published from common's `testFixtures` + variant (see conventions). Hopper X-Treme predates this and puts its gametests in `main`; its + TEST_PLAN carries a note to refactor to this pattern. + +## Open questions + +* None outstanding for chimeric-lib's own tests. Remaining work is the broader **GameTest harness + helper backlog** (POTENTIAL_FEATURES "Developer & testing tools" — redstone gate, update detector, + villager fixture builder, etc.) and their self-tests; the three helpers shipped so far + (`GameTestContainers`/`GameTestEntities`/`GameTestMenus`) are the first slice. diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockConfig.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockConfig.java index 046b4bd5c..0c9bf37c2 100644 --- a/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockConfig.java +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockConfig.java @@ -188,7 +188,16 @@ public BlockConfig texture(String name, Identifier texture) { } public @Nullable Identifier getTexture() { - return textures.getOrDefault("default", TextureMapping.getBlockTexture(this.getIngredient()).sprite()); + Identifier texture = textures.get("default"); + + // Not getOrDefault: it evaluates the fallback even when a default texture is set, and + // getIngredient() throws when the config has no ingredient — which is exactly the case where + // an explicit texture is most likely to be the only thing configured. + if (texture != null) { + return texture; + } + + return TextureMapping.getBlockTexture(this.getIngredient()).sprite(); } public @Nullable Identifier getTexture(String name) { diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockUtils.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockUtils.java new file mode 100644 index 000000000..fb360d222 --- /dev/null +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/blocks/BlockUtils.java @@ -0,0 +1,86 @@ +package com.chimericdream.lib.blocks; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; + +/** + * Small position/adjacency helpers shared across mods. Nothing here is specific to any one mod. + */ +public class BlockUtils { + public static List getAdjacentBlockPositions(BlockPos pos, boolean down) { + List around = new ArrayList<>(); + + around.add(pos.north()); + around.add(pos.east()); + around.add(pos.south()); + around.add(pos.west()); + around.add(pos.above()); + + if (down) { + around.add(pos.below()); + } + + return around; + } + + /** + * Collects up to {@code maxCount} connected blocks whose type is in {@code blockTypes}, searching + * outward from {@code start} breadth-first so the ones nearest {@code start} (by step count) are + * the ones kept. + * + *

{@code maxCount} is intended as a deliberate gameplay/performance ceiling, not an incidental + * limit: the search stops as soon as the cap is reached, so callers never walk (or count) an + * entire large structure. For example, sponj scales both a sponge's absorption reach and its + * budget of blocks to clear with how many connected sponjes this returns, so an unbounded count + * would let a large sponj wall schedule tens of thousands of block operations in a single tick; + * capping the count caps that work. + */ + public static List getConnectedBlocksByType(Level world, BlockPos start, List blockTypes, int maxCount) { + List connected = new ArrayList<>(); + + if (maxCount <= 0) { + return connected; + } + + Set visited = new HashSet<>(); + Deque frontier = new ArrayDeque<>(); + + visited.add(start); + frontier.add(start); + + if (blockTypes.contains(world.getBlockState(start).getBlock())) { + connected.add(start); + } + + while (!frontier.isEmpty() && connected.size() < maxCount) { + BlockPos pos = frontier.poll(); + + for (BlockPos neighbor : getAdjacentBlockPositions(pos, true)) { + if (!visited.add(neighbor)) { + continue; + } + + if (!blockTypes.contains(world.getBlockState(neighbor).getBlock())) { + continue; + } + + connected.add(neighbor); + + if (connected.size() >= maxCount) { + break; + } + + frontier.add(neighbor); + } + } + + return connected; + } +} diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/colors/ColorHelpers.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/colors/ColorHelpers.java index 1ec384c11..a4e52a7f2 100644 --- a/chimeric-lib/common/src/main/java/com/chimericdream/lib/colors/ColorHelpers.java +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/colors/ColorHelpers.java @@ -25,26 +25,28 @@ public String toHex() { } public int toInt() { - return ARGB.color(0, r, g, b); + return getColor(); } } - public static int[] WHITE = {0xf9fffe, 0xe4e4e4}; - public static int[] LIGHT_GRAY = {0x9d9d97, 0xa0a7a7}; - public static int[] GRAY = {0x474f52, 0x414141}; - public static int[] BLACK = {0x1d1d21, 0x181414}; - public static int[] BROWN = {0x835432, 0x56331c}; - public static int[] RED = {0xb02e26, 0x9e2b27}; - public static int[] ORANGE = {0xf9801d, 0xea7e35}; - public static int[] YELLOW = {0xfed83d, 0xc2b51c}; - public static int[] LIME = {0x80c71f, 0x39ba2e}; - public static int[] GREEN = {0x5e7c16, 0x364b18}; - public static int[] CYAN = {0x169c9c, 0x267191}; - public static int[] LIGHT_BLUE = {0x3ab3da, 0x6387d2}; - public static int[] BLUE = {0x3c44aa, 0x253193}; - public static int[] PURPLE = {0x8932b8, 0x7e34bf}; - public static int[] MAGENTA = {0xc74ebd, 0xbe49c9}; - public static int[] PINK = {0xf38baa, 0xd98199}; + // Tint variants (base color + darker edge) per dye color. Kept private and handed out only as + // defensive copies via getTint/getTints so callers cannot mutate the shared palette. + private static final int[] WHITE = {0xf9fffe, 0xe4e4e4}; + private static final int[] LIGHT_GRAY = {0x9d9d97, 0xa0a7a7}; + private static final int[] GRAY = {0x474f52, 0x414141}; + private static final int[] BLACK = {0x1d1d21, 0x181414}; + private static final int[] BROWN = {0x835432, 0x56331c}; + private static final int[] RED = {0xb02e26, 0x9e2b27}; + private static final int[] ORANGE = {0xf9801d, 0xea7e35}; + private static final int[] YELLOW = {0xfed83d, 0xc2b51c}; + private static final int[] LIME = {0x80c71f, 0x39ba2e}; + private static final int[] GREEN = {0x5e7c16, 0x364b18}; + private static final int[] CYAN = {0x169c9c, 0x267191}; + private static final int[] LIGHT_BLUE = {0x3ab3da, 0x6387d2}; + private static final int[] BLUE = {0x3c44aa, 0x253193}; + private static final int[] PURPLE = {0x8932b8, 0x7e34bf}; + private static final int[] MAGENTA = {0xc74ebd, 0xbe49c9}; + private static final int[] PINK = {0xf38baa, 0xd98199}; public static String[] getColors() { return List.of( @@ -68,7 +70,7 @@ public static String[] getColors() { } public static int getTint(int tintIndex, int[] variants) { - if (tintIndex >= variants.length) { + if (tintIndex < 0 || tintIndex >= variants.length) { return variants[0]; } @@ -149,6 +151,32 @@ public static Block getWool(String color) { }; } + /** + * Returns the tint variants for a dye color as a fresh array, so mutating the result never + * corrupts the shared palette. Pair with {@link #getTint(int, int[])} to pick a variant. + */ + public static int[] getTints(String color) { + return switch (color) { + case "white" -> WHITE.clone(); + case "light_gray" -> LIGHT_GRAY.clone(); + case "gray" -> GRAY.clone(); + case "black" -> BLACK.clone(); + case "brown" -> BROWN.clone(); + case "red" -> RED.clone(); + case "orange" -> ORANGE.clone(); + case "yellow" -> YELLOW.clone(); + case "lime" -> LIME.clone(); + case "green" -> GREEN.clone(); + case "cyan" -> CYAN.clone(); + case "light_blue" -> LIGHT_BLUE.clone(); + case "blue" -> BLUE.clone(); + case "purple" -> PURPLE.clone(); + case "magenta" -> MAGENTA.clone(); + case "pink" -> PINK.clone(); + default -> throw new RuntimeException(String.format("Invalid color %s", color)); + }; + } + public static ColorHelpers.RGB mixColors(@Nullable ColorHelpers.RGB rgb, List colors) { int l = rgb == null ? 0 : Math.max(rgb.r, Math.max(rgb.g, rgb.b)); int i = rgb == null ? 0 : rgb.r; diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ContainerOpenersCounters.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ContainerOpenersCounters.java new file mode 100644 index 000000000..4a363919a --- /dev/null +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ContainerOpenersCounters.java @@ -0,0 +1,101 @@ +package com.chimericdream.lib.inventories; + +import java.util.function.Function; +import net.minecraft.core.BlockPos; +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.ContainerOpenersCounter; +import net.minecraft.world.level.block.state.BlockState; +import org.jspecify.annotations.Nullable; + +/** + * Factory for the {@link ContainerOpenersCounter} boilerplate that container block entities all + * hand-roll identically: an anonymous subclass whose {@code openerCountChanged} fires the vanilla + * comparator block event and whose {@code isOwnContainer} confirms the player is looking at + * this container. + * + *

The menu class is a required parameter, and ownership is confirmed by identity + * ({@code inventoryAccessor.apply(menu) == owner}) rather than by class alone — this is what stops + * the copy-paste bug where a block entity checked for the wrong menu type (e.g. the dye station + * testing {@code ChestMenu}) and therefore never recognised a legitimate viewer. + */ +public final class ContainerOpenersCounters { + /** Side effect to run when the container opens or closes (e.g. play a sound, set an OPEN state). */ + @FunctionalInterface + public interface OpenCloseHandler { + void accept(Level level, BlockPos pos, BlockState state); + } + + /** Reacts to a change in viewer count (e.g. fire the comparator block event, poke neighbours). */ + @FunctionalInterface + public interface ViewerCountHandler { + void accept(Level level, BlockPos pos, BlockState state, int oldViewerCount, int newViewerCount); + } + + private ContainerOpenersCounters() { + } + + /** + * Equivalent to {@link #create(Container, Class, Function, OpenCloseHandler, OpenCloseHandler, + * ViewerCountHandler)} with the plain vanilla viewer-count reaction (fire the comparator block + * event). Use the six-arg overload when the block needs extra behavior on viewer-count changes + * (e.g. a trapped-chest-style neighbour update). + */ + public static ContainerOpenersCounter create( + Container owner, + Class menuClass, + Function inventoryAccessor, + @Nullable OpenCloseHandler onOpen, + @Nullable OpenCloseHandler onClose + ) { + return create(owner, menuClass, inventoryAccessor, onOpen, onClose, + (world, pos, state, oldCount, newCount) -> world.blockEvent(pos, state.getBlock(), 1, newCount)); + } + + /** + * @param owner the container these openers are counted for; ownership is confirmed by + * reference identity against the open menu's inventory + * @param menuClass the menu class this container opens (required) + * @param inventoryAccessor reads the backing container out of an instance of {@code menuClass} + * @param onOpen side effect when the viewer count goes from 0 to >0, or {@code null} + * @param onClose side effect when the viewer count returns to 0, or {@code null} + * @param onViewerCountChanged reaction to any viewer-count change (must fire the comparator event) + */ + public static ContainerOpenersCounter create( + Container owner, + Class menuClass, + Function inventoryAccessor, + @Nullable OpenCloseHandler onOpen, + @Nullable OpenCloseHandler onClose, + ViewerCountHandler onViewerCountChanged + ) { + return new ContainerOpenersCounter() { + @Override + protected void onOpen(Level world, BlockPos pos, BlockState state) { + if (onOpen != null) { + onOpen.accept(world, pos, state); + } + } + + @Override + protected void onClose(Level world, BlockPos pos, BlockState state) { + if (onClose != null) { + onClose.accept(world, pos, state); + } + } + + @Override + protected void openerCountChanged(Level world, BlockPos pos, BlockState state, int oldViewerCount, int newViewerCount) { + onViewerCountChanged.accept(world, pos, state, oldViewerCount, newViewerCount); + } + + @Override + public boolean isOwnContainer(Player player) { + AbstractContainerMenu menu = player.containerMenu; + return menuClass.isInstance(menu) && inventoryAccessor.apply(menu) == owner; + } + }; + } +} diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ImplementedInventory.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ImplementedInventory.java index f32229277..9c99da3ae 100644 --- a/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ImplementedInventory.java +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/inventories/ImplementedInventory.java @@ -107,7 +107,10 @@ default boolean isMatchingPartialStack(ItemStack incomingStack, ItemStack existi return false; } - if (!ItemStack.matches(incomingStack, existingStack)) { + // Use isSameItemSameComponents, NOT ItemStack.matches: matches also compares counts, so two + // otherwise-identical partial stacks would only be considered mergeable when their counts + // happened to be equal. Merging must depend only on item + components. + if (!ItemStack.isSameItemSameComponents(incomingStack, existingStack)) { return false; } @@ -169,10 +172,19 @@ default void setItem(int slot, ItemStack stack) { /** * Clears the inventory. + *

+ * Fills every slot with {@link ItemStack#EMPTY} rather than calling {@code getItems().clear()}: + * the common backing list is {@code NonNullList.withSize(...)}, whose fixed-size backing makes a + * structural {@code clear()} either throw or collapse the list to size 0 (breaking the fixed-slot + * invariant every other method here relies on). Emptying in place keeps the slot count stable. */ @Override default void clearContent() { - getItems().clear(); + NonNullList items = getItems(); + for (int i = 0; i < items.size(); i++) { + items.set(i, ItemStack.EMPTY); + } + setChanged(); } /** diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/item/AbstractWrenchItem.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/item/AbstractWrenchItem.java new file mode 100644 index 000000000..6949f3a85 --- /dev/null +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/item/AbstractWrenchItem.java @@ -0,0 +1,122 @@ +package com.chimericdream.lib.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BedBlock; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.SlabType; +import org.jetbrains.annotations.NotNull; + +/** + * Shared wrench behavior: right-click a block to cycle its orientation (facing / axis) or flip a + * slab, server-side only, playing a click when something changed. Mods supply their own + * {@link Item.Properties} (registry key, creative tab, tooltip prefix) via a thin subclass; the + * interaction logic lives here so it isn't copy-pasted per mod. + */ +public abstract class AbstractWrenchItem extends Item { + protected AbstractWrenchItem(Properties properties) { + super(properties); + } + + private boolean tryPlacing(BlockPos pos, BlockState state, Level world) { + if (state.canSurvive(world, pos)) { + world.setBlockAndUpdate(pos, state); + world.blockEntityChanged(pos); + + return true; + } + + return false; + } + + private boolean tryFacing(BlockState state, BlockPos pos, Level world) { + if (state.getOptionalValue(BlockStateProperties.FACING).isPresent()) { + if (tryPlacing(pos, state.cycle(BlockStateProperties.FACING), world)) { + return true; + } + } + + if (state.getOptionalValue(BlockStateProperties.HORIZONTAL_FACING).isPresent()) { + if (tryPlacing(pos, state.cycle(BlockStateProperties.HORIZONTAL_FACING), world)) { + return true; + } + } + + if (state.getOptionalValue(BlockStateProperties.FACING_HOPPER).isPresent()) { + return tryPlacing(pos, state.cycle(BlockStateProperties.FACING_HOPPER), world); + } + + return false; + } + + private boolean tryAxes(BlockState state, BlockPos pos, Level world) { + if (state.getOptionalValue(BlockStateProperties.AXIS).isPresent()) { + if (tryPlacing(pos, state.cycle(BlockStateProperties.AXIS), world)) { + return true; + } + } + + if (state.getOptionalValue(BlockStateProperties.HORIZONTAL_AXIS).isPresent()) { + return tryPlacing(pos, state.cycle(BlockStateProperties.HORIZONTAL_AXIS), world); + } + + return false; + } + + private boolean trySlab(BlockState state, BlockPos pos, Level world) { + if (state.getBlock() instanceof SlabBlock) { + BlockState newState = state; + + if (state.getValue(BlockStateProperties.SLAB_TYPE).equals(SlabType.DOUBLE)) { + return false; + } + + if (state.getValue(BlockStateProperties.SLAB_TYPE).equals(SlabType.BOTTOM)) { + newState = state.setValue(BlockStateProperties.SLAB_TYPE, SlabType.TOP); + } else if (state.getValue(BlockStateProperties.SLAB_TYPE).equals(SlabType.TOP)) { + newState = state.setValue(BlockStateProperties.SLAB_TYPE, SlabType.BOTTOM); + } + + world.setBlockAndUpdate(pos, newState); + world.blockEntityChanged(pos); + + return true; + } + + return false; + } + + @Override + public @NotNull InteractionResult useOn(UseOnContext context) { + Level world = context.getLevel(); + BlockPos pos = context.getClickedPos(); + BlockState state = world.getBlockState(pos); + + if ( + context.getPlayer() == null + || world.isClientSide() + || state.getBlock() instanceof ChestBlock + || state.getBlock() instanceof BedBlock + ) { + return InteractionResult.PASS; + } + + if (tryFacing(state, pos, world) || tryAxes(state, pos, world) || trySlab(state, pos, world)) { + if (!world.isClientSide()) { + world.playSound(null, pos, SoundEvents.SPYGLASS_USE, SoundSource.AMBIENT, 2.0F, 1.5F); + } + + return InteractionResult.SUCCESS; + } + + return InteractionResult.PASS; + } +} diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/DoubleWideInventoryScreenHandler.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/DoubleWideInventoryScreenHandler.java index 6dfca467e..2e23f4dcd 100644 --- a/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/DoubleWideInventoryScreenHandler.java +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/DoubleWideInventoryScreenHandler.java @@ -3,94 +3,20 @@ import net.minecraft.world.Container; import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.player.Inventory; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.MenuType; -import net.minecraft.world.inventory.Slot; -import net.minecraft.world.item.ItemStack; -public class DoubleWideInventoryScreenHandler extends AbstractContainerMenu { - private final Container inventory; +/** + * A double-width (18-column) fixed-grid container menu. All behavior lives in + * {@link InventoryScreenHandler}; this class only pins the column count. + */ +public class DoubleWideInventoryScreenHandler extends InventoryScreenHandler { + private static final int COLUMNS = 18; public DoubleWideInventoryScreenHandler(MenuType type, int syncId, Inventory playerInventory, int rowCount) { this(type, syncId, playerInventory, new SimpleContainer(ScreenHelpers.getDoubleWideInventorySize(rowCount)), rowCount); } public DoubleWideInventoryScreenHandler(MenuType type, int syncId, Inventory playerInventory, Container inventory, int rowCount) { - super(type, syncId); - - checkContainerSize(inventory, ScreenHelpers.getDoubleWideInventorySize(rowCount)); - - this.inventory = inventory; - - inventory.startOpen(playerInventory.player); - - int i = (rowCount - 4) * 18, j, k; - for (j = 0; j < rowCount; ++j) { - for (k = 0; k < 18; ++k) { - this.addSlot(new Slot( - inventory, - k + j * 18, - 8 + k * ScreenHelpers.ROW_HEIGHT, - ScreenHelpers.ROW_HEIGHT + j * ScreenHelpers.ROW_HEIGHT - )); - } - } - - for (j = 0; j < 3; ++j) { - for (k = 0; k < 9; ++k) { - this.addSlot(new Slot( - playerInventory, - k + j * 9 + 9, - 89 + k * ScreenHelpers.ROW_HEIGHT, - 104 + j * ScreenHelpers.ROW_HEIGHT + i - )); - } - } - - for (j = 0; j < 9; ++j) { - this.addSlot(new Slot( - playerInventory, - j, - 89 + j * ScreenHelpers.ROW_HEIGHT, - 162 + i - )); - } - } - - public Container getInventory() { - return inventory; - } - - @Override - public boolean stillValid(Player player) { - return this.inventory.stillValid(player); - } - - @Override - public ItemStack quickMoveStack(Player player, int invSlot) { - ItemStack newStack = ItemStack.EMPTY; - - Slot slot = this.slots.get(invSlot); - - if (slot != null && slot.hasItem()) { - ItemStack originalStack = slot.getItem(); - newStack = originalStack.copy(); - if (invSlot < this.inventory.getContainerSize()) { - if (!this.moveItemStackTo(originalStack, this.inventory.getContainerSize(), this.slots.size(), true)) { - return ItemStack.EMPTY; - } - } else if (!this.moveItemStackTo(originalStack, 0, this.inventory.getContainerSize(), false)) { - return ItemStack.EMPTY; - } - - if (originalStack.isEmpty()) { - slot.setByPlayer(ItemStack.EMPTY); - } else { - slot.setChanged(); - } - } - - return newStack; + super(type, syncId, playerInventory, inventory, rowCount, COLUMNS); } } diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/InventoryScreenHandler.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/InventoryScreenHandler.java new file mode 100644 index 000000000..169716137 --- /dev/null +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/InventoryScreenHandler.java @@ -0,0 +1,124 @@ +package com.chimericdream.lib.screen; + +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.NonNull; + +/** + * Shared base for the fixed-grid container menus. The layout is identical for every width — a + * {@code columns}-wide storage grid on top and the standard 3x9 player inventory + hotbar below — + * so the only per-menu variation is the column count (and the player-inventory x-offset that + * derives from it). {@link SimpleInventoryScreenHandler} (9 columns) and + * {@link DoubleWideInventoryScreenHandler} (18 columns) are thin subclasses that pass their width. + */ +public abstract class InventoryScreenHandler extends AbstractContainerMenu { + // Fixed padding (px) between the storage grid and the player inventory, and between the player + // inventory and the hotbar — mirrors vanilla ChestMenu's spacing. + private static final int PLAYER_INVENTORY_GAP = 14; + private static final int HOTBAR_GAP = 4; + + private final Container inventory; + + protected InventoryScreenHandler(MenuType type, int syncId, Inventory playerInventory, Container inventory, int rowCount, int columns) { + super(type, syncId); + + checkContainerSize(inventory, rowCount * columns); + + this.inventory = inventory; + + inventory.startOpen(playerInventory.player); + + // The player inventory is always 9 wide; center it under a grid wider than 9. + int playerX = 8 + (columns - 9) * ScreenHelpers.ROW_HEIGHT / 2; + + // The player inventory + hotbar hang below the storage grid, whose bottom edge sits at + // ROW_HEIGHT * (rowCount + 1). Everything derives from ROW_HEIGHT so the layout scales with + // the grid height. + int playerInventoryY = ScreenHelpers.ROW_HEIGHT * (rowCount + 1) + PLAYER_INVENTORY_GAP; + int hotbarY = playerInventoryY + (3 * ScreenHelpers.ROW_HEIGHT) + HOTBAR_GAP; + int j, k; + + for (j = 0; j < rowCount; ++j) { + for (k = 0; k < columns; ++k) { + this.addSlot(new Slot( + inventory, + k + j * columns, + 8 + k * ScreenHelpers.ROW_HEIGHT, + ScreenHelpers.ROW_HEIGHT + j * ScreenHelpers.ROW_HEIGHT + )); + } + } + + for (j = 0; j < 3; ++j) { + for (k = 0; k < 9; ++k) { + this.addSlot(new Slot( + playerInventory, + k + j * 9 + 9, + playerX + k * ScreenHelpers.ROW_HEIGHT, + playerInventoryY + j * ScreenHelpers.ROW_HEIGHT + )); + } + } + + for (j = 0; j < 9; ++j) { + this.addSlot(new Slot( + playerInventory, + j, + playerX + j * ScreenHelpers.ROW_HEIGHT, + hotbarY + )); + } + } + + public Container getInventory() { + return inventory; + } + + @Override + public boolean stillValid(@NonNull Player player) { + return this.inventory.stillValid(player); + } + + /** + * Balances the {@code startOpen} the constructor issues. Vanilla's {@code ChestMenu} does the + * same; without it a container backed by a {@code ContainerOpenersCounter} never sees its viewer + * leave (so lids stay open and open/close side effects never fire). + */ + @Override + public void removed(@NonNull Player player) { + super.removed(player); + this.inventory.stopOpen(player); + } + + @Override + public @NonNull ItemStack quickMoveStack(@NonNull Player player, int invSlot) { + ItemStack newStack = ItemStack.EMPTY; + + Slot slot = this.slots.get(invSlot); + + if (slot != null && slot.hasItem()) { + ItemStack originalStack = slot.getItem(); + newStack = originalStack.copy(); + if (invSlot < this.inventory.getContainerSize()) { + if (!this.moveItemStackTo(originalStack, this.inventory.getContainerSize(), this.slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (!this.moveItemStackTo(originalStack, 0, this.inventory.getContainerSize(), false)) { + return ItemStack.EMPTY; + } + + if (originalStack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + } + + return newStack; + } +} diff --git a/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/SimpleInventoryScreenHandler.java b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/SimpleInventoryScreenHandler.java index 025f87721..d92f76c14 100644 --- a/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/SimpleInventoryScreenHandler.java +++ b/chimeric-lib/common/src/main/java/com/chimericdream/lib/screen/SimpleInventoryScreenHandler.java @@ -3,94 +3,20 @@ import net.minecraft.world.Container; import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.player.Inventory; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.MenuType; -import net.minecraft.world.inventory.Slot; -import net.minecraft.world.item.ItemStack; -public class SimpleInventoryScreenHandler extends AbstractContainerMenu { - private final Container inventory; +/** + * A single-width (9-column) fixed-grid container menu. All behavior lives in + * {@link InventoryScreenHandler}; this class only pins the column count. + */ +public class SimpleInventoryScreenHandler extends InventoryScreenHandler { + private static final int COLUMNS = 9; public SimpleInventoryScreenHandler(MenuType type, int syncId, Inventory playerInventory, int rowCount) { this(type, syncId, playerInventory, new SimpleContainer(ScreenHelpers.getInventorySize(rowCount)), rowCount); } public SimpleInventoryScreenHandler(MenuType type, int syncId, Inventory playerInventory, Container inventory, int rowCount) { - super(type, syncId); - - checkContainerSize(inventory, ScreenHelpers.getInventorySize(rowCount)); - - this.inventory = inventory; - - inventory.startOpen(playerInventory.player); - - int i = (rowCount - 4) * 18, j, k; - for (j = 0; j < rowCount; ++j) { - for (k = 0; k < 9; ++k) { - this.addSlot(new Slot( - inventory, - k + j * 9, - 8 + k * ScreenHelpers.ROW_HEIGHT, - ScreenHelpers.ROW_HEIGHT + j * ScreenHelpers.ROW_HEIGHT - )); - } - } - - for (j = 0; j < 3; ++j) { - for (k = 0; k < 9; ++k) { - this.addSlot(new Slot( - playerInventory, - k + j * 9 + 9, - 8 + k * ScreenHelpers.ROW_HEIGHT, - 104 + j * ScreenHelpers.ROW_HEIGHT + i - )); - } - } - - for (j = 0; j < 9; ++j) { - this.addSlot(new Slot( - playerInventory, - j, - 8 + j * ScreenHelpers.ROW_HEIGHT, - 162 + i - )); - } - } - - public Container getInventory() { - return inventory; - } - - @Override - public boolean stillValid(Player player) { - return this.inventory.stillValid(player); - } - - @Override - public ItemStack quickMoveStack(Player player, int invSlot) { - ItemStack newStack = ItemStack.EMPTY; - - Slot slot = this.slots.get(invSlot); - - if (slot != null && slot.hasItem()) { - ItemStack originalStack = slot.getItem(); - newStack = originalStack.copy(); - if (invSlot < this.inventory.getContainerSize()) { - if (!this.moveItemStackTo(originalStack, this.inventory.getContainerSize(), this.slots.size(), true)) { - return ItemStack.EMPTY; - } - } else if (!this.moveItemStackTo(originalStack, 0, this.inventory.getContainerSize(), false)) { - return ItemStack.EMPTY; - } - - if (originalStack.isEmpty()) { - slot.setByPlayer(ItemStack.EMPTY); - } else { - slot.setChanged(); - } - } - - return newStack; + super(type, syncId, playerInventory, inventory, rowCount, COLUMNS); } } diff --git a/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/BootstrapMinecraft.java b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/BootstrapMinecraft.java new file mode 100644 index 000000000..cfebba2a8 --- /dev/null +++ b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/BootstrapMinecraft.java @@ -0,0 +1,57 @@ +package com.chimericdream.lib.testkit; + +import net.minecraft.SharedConstants; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.data.registries.VanillaRegistries; +import net.minecraft.server.Bootstrap; +import org.junit.jupiter.api.BeforeAll; + +/** + * Base class for unit tests that touch Minecraft registries, items, blocks, or tags. + * + *

This lives in chimeric-lib's {@code testFixtures} source set so every consumer mod can reuse it + * instead of copying the bootstrap logic. In this repo's own build, chimeric-lib's fabric tests pull + * it in via {@code testImplementation(testFixtures(project(":chimeric-lib:common")))}; a downstream + * mod adds it to its fabric {@code test} source set with: + * + *

{@code
+ * testImplementation(testFixtures("com.chimericdream.lib:chimericlib-common:-"))
+ * }
+ * + *

Tests run outside a live game, so the static registries ({@code Items}, {@code Blocks}, + * {@code BuiltInRegistries}, ...) are unpopulated until Minecraft's bootstrap runs. The + * {@code fabric-loader-junit} dependency (wired into the fabric subproject's {@code test} source + * set) puts the Fabric loader and the named Minecraft jar on the test classpath; this base then + * triggers the vanilla bootstrap so registry lookups resolve. + * + *

An item's data components are data-driven: {@link Bootstrap#bootStrap()} + * populates the registries but leaves each {@code Holder.Reference}'s components unbound, + * so constructing an {@link net.minecraft.world.item.ItemStack} fails with "Components not bound + * yet". We bake the components from the full vanilla lookup once, mirroring what a loading + * server/data pack does, so headless {@code ItemStack} construction works. + * + *

The bootstrap calls are idempotent and the component bake is guarded by {@link #baked}, so it + * is safe for every subclass to run this {@code @BeforeAll}. Pure-logic tests (string/bit math, + * {@code Direction} tables, {@code Component} building) do not need this and should not + * extend it. + */ +public abstract class BootstrapMinecraft { + private static boolean baked = false; + + @BeforeAll + static synchronized void bootstrapMinecraft() { + if (baked) { + return; + } + + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + + HolderLookup.Provider provider = VanillaRegistries.createLookup(); + BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(provider) + .forEach(pending -> pending.apply()); + + baked = true; + } +} diff --git a/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestContainers.java b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestContainers.java new file mode 100644 index 000000000..f3564d54a --- /dev/null +++ b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestContainers.java @@ -0,0 +1,63 @@ +package com.chimericdream.lib.testkit.gametest; + +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.Container; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * Reusable {@link Container} assertions for GameTests. Every mod in this repo that ships a container + * block entity (Hopper X-Treme, Minekea crates/shelves, Shulker Stuff) exercises the same "fill a + * slot, assert a slot" shape, so it lives here in the published {@code testFixtures} variant rather + * than being copied per mod. Failures are reported through {@link GameTestHelper#fail(String)} so they + * surface as normal GameTest failures. + * + *

Consume it from a downstream mod's {@code gametest} source set with + * {@code gametestImplementation(testFixtures("com.chimericdream.lib:chimericlib-common-:"))}. + */ +public final class GameTestContainers { + private GameTestContainers() { + } + + /** Fails the test unless {@code container} holds exactly {@code count} of {@code expected} in {@code slot}. */ + public static void assertSlot(GameTestHelper context, Container container, int slot, Item expected, int count) { + ItemStack stack = container.getItem(slot); + + if (!stack.is(expected) || stack.getCount() != count) { + context.fail("Expected " + count + "x " + expected + " in slot " + slot + ", found " + describe(stack)); + } + } + + /** Fails the test unless {@code slot} is empty. */ + public static void assertSlotEmpty(GameTestHelper context, Container container, int slot) { + ItemStack stack = container.getItem(slot); + + if (!stack.isEmpty()) { + context.fail("Expected slot " + slot + " to be empty, found " + describe(stack)); + } + } + + /** Fails the test unless every slot is empty. */ + public static void assertEmpty(GameTestHelper context, Container container) { + if (!container.isEmpty()) { + context.fail("Expected an empty container, but " + countNonEmpty(container) + " slot(s) held items"); + } + } + + /** Counts the non-empty slots in {@code container}. */ + public static int countNonEmpty(Container container) { + int count = 0; + + for (int slot = 0; slot < container.getContainerSize(); slot++) { + if (!container.getItem(slot).isEmpty()) { + count++; + } + } + + return count; + } + + private static String describe(ItemStack stack) { + return stack.isEmpty() ? "" : stack.getCount() + "x " + stack.getItem(); + } +} diff --git a/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestEntities.java b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestEntities.java new file mode 100644 index 000000000..c582593e5 --- /dev/null +++ b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestEntities.java @@ -0,0 +1,46 @@ +package com.chimericdream.lib.testkit.gametest; + +import java.util.List; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; + +/** + * Reusable entity-presence assertions for GameTests, built on + * {@link GameTestHelper#getEntities(EntityType)} (which is scoped to the test's own region). The most + * common need is a "no leaked entities" watcher for things that are supposed to clean themselves up — + * e.g. {@code SimpleSeatEntity}, which auto-despawns once its rider leaves. Published in the shared + * {@code testFixtures} variant so downstream mods reuse it instead of copying. + */ +public final class GameTestEntities { + private GameTestEntities() { + } + + /** Returns every entity of {@code type} currently inside the test region. */ + public static List of(GameTestHelper context, EntityType type) { + return context.getEntities(type); + } + + /** Counts the entities of {@code type} in the test region. */ + public static int count(GameTestHelper context, EntityType type) { + return context.getEntities(type).size(); + } + + /** Fails the test unless zero entities of {@code type} remain in the test region. */ + public static void assertNone(GameTestHelper context, EntityType type) { + int count = count(context, type); + + if (count != 0) { + context.fail("Expected no " + type + " entities to remain, but found " + count); + } + } + + /** Fails the test unless exactly {@code expected} entities of {@code type} are present. */ + public static void assertCount(GameTestHelper context, EntityType type, int expected) { + int count = count(context, type); + + if (count != expected) { + context.fail("Expected " + expected + " " + type + " entities, but found " + count); + } + } +} diff --git a/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestMenus.java b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestMenus.java new file mode 100644 index 000000000..d7ec6fa6d --- /dev/null +++ b/chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/gametest/GameTestMenus.java @@ -0,0 +1,34 @@ +package com.chimericdream.lib.testkit.gametest; + +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; + +/** + * Reusable {@link AbstractContainerMenu} helpers for server-side screen-handler GameTests: assert a + * handler's slot layout and simulate shift-click ("quick move") routing without a client. Chimeric + * mods share a handful of container screen shapes (single- and double-wide), so this lives in the + * published {@code testFixtures} variant alongside {@link GameTestContainers}. + */ +public final class GameTestMenus { + private GameTestMenus() { + } + + /** Fails the test unless {@code menu} exposes exactly {@code expected} slots. */ + public static void assertSlotCount(GameTestHelper context, AbstractContainerMenu menu, int expected) { + int actual = menu.slots.size(); + + if (actual != expected) { + context.fail("Expected the menu to have " + expected + " slots, but it had " + actual); + } + } + + /** + * Simulates a shift-click (quick move) on {@code slot} and returns the leftover stack the vanilla + * contract reports ({@link ItemStack#EMPTY} when everything moved). + */ + public static ItemStack shiftClick(AbstractContainerMenu menu, Player player, int slot) { + return menu.quickMoveStack(player, slot); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ImplementedInventoryGameTest.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ImplementedInventoryGameTest.java new file mode 100644 index 000000000..34500ac10 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ImplementedInventoryGameTest.java @@ -0,0 +1,62 @@ +package com.chimericdream.lib.fabric.test; + +import com.chimericdream.lib.fabric.test.fixture.TestContainerBlockEntity; +import com.chimericdream.lib.fabric.test.fixture.TestFixtures; +import com.chimericdream.lib.testkit.gametest.GameTestContainers; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.HopperBlockEntity; + +/** + * Exercises the library's {@code ImplementedInventory} contract through a fixture container block + * entity: an NBT save/load round-trip preserves its contents, and a vanilla hopper can insert into it + * (proving the {@code Container} contract behaves for real game systems). + */ +@SuppressWarnings("unused") +public class ImplementedInventoryGameTest { + private static final BlockPos CONTAINER = new BlockPos(2, 2, 2); + + @GameTest + public void nbtRoundTripPreservesContents(GameTestHelper context) { + context.setBlock(CONTAINER, TestFixtures.TEST_CONTAINER_BLOCK.get()); + TestContainerBlockEntity be = context.getBlockEntity(CONTAINER, TestContainerBlockEntity.class); + + be.setItem(0, new ItemStack(Items.DIAMOND, 5)); + be.setItem(TestContainerBlockEntity.SIZE - 1, new ItemStack(Items.EMERALD, 2)); + GameTestContainers.assertSlot(context, be, 0, Items.DIAMOND, 5); + GameTestContainers.assertSlot(context, be, TestContainerBlockEntity.SIZE - 1, Items.EMERALD, 2); + + HolderLookup.Provider provider = context.getLevel().registryAccess(); + CompoundTag tag = be.saveWithFullMetadata(provider); + BlockEntity reloaded = BlockEntity.loadStatic(context.absolutePos(CONTAINER), be.getBlockState(), tag, provider); + + context.assertTrue(reloaded instanceof TestContainerBlockEntity, "the reloaded block entity should keep its type"); + TestContainerBlockEntity restored = (TestContainerBlockEntity) reloaded; + GameTestContainers.assertSlot(context, restored, 0, Items.DIAMOND, 5); + GameTestContainers.assertSlot(context, restored, TestContainerBlockEntity.SIZE - 1, Items.EMERALD, 2); + + context.succeed(); + } + + @GameTest(maxTicks = 120) + public void vanillaHopperFeedsTheContainer(GameTestHelper context) { + BlockPos hopperPos = CONTAINER.above(); + context.setBlock(CONTAINER, TestFixtures.TEST_CONTAINER_BLOCK.get()); + context.setBlock(hopperPos, Blocks.HOPPER); // default facing is DOWN, into the container + + HopperBlockEntity hopper = context.getBlockEntity(hopperPos, HopperBlockEntity.class); + hopper.setItem(0, new ItemStack(Items.DIAMOND, 3)); + + context.succeedWhen(() -> { + TestContainerBlockEntity be = context.getBlockEntity(CONTAINER, TestContainerBlockEntity.class); + GameTestContainers.assertSlot(context, be, 0, Items.DIAMOND, 3); + }); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/LootTableModifierGameTest.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/LootTableModifierGameTest.java new file mode 100644 index 000000000..e3e1a7ada --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/LootTableModifierGameTest.java @@ -0,0 +1,47 @@ +package com.chimericdream.lib.fabric.test; + +import com.chimericdream.lib.fabric.test.fixture.TestLootModifier; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import java.util.List; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.HolderLookup; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.LootPool; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; + +/** + * Validates {@code LootTableModifier}: it injects its pool only into the targeted table id, leaves + * every other table untouched, and the injected guaranteed pool actually yields the marker item when + * the modified table is rolled. + */ +@SuppressWarnings("unused") +public class LootTableModifierGameTest { + @GameTest + public void injectsMarkerIntoTargetedTableOnly(GameTestHelper context) { + HolderLookup.Provider provider = context.getLevel().registryAccess(); + TestLootModifier modifier = new TestLootModifier(); + + List targeted = modifier.generatePoolBuilders(TestLootModifier.TARGET, provider); + context.assertTrue(targeted.size() == 1, "expected exactly one injected pool for the targeted table, got " + targeted.size()); + + List untargeted = modifier.generatePoolBuilders(Identifier.withDefaultNamespace("blocks/dirt"), provider); + context.assertTrue(untargeted.isEmpty(), "non-targeted tables must be left untouched"); + + // Build the modified table and roll it: the guaranteed pool must always yield the marker. + LootTable.Builder builder = LootTable.lootTable(); + modifier.modifyLootTables(TestLootModifier.TARGET, builder, provider); + LootTable table = builder.build(); + + LootParams params = new LootParams.Builder(context.getLevel()).create(LootContextParamSets.EMPTY); + ObjectArrayList drops = table.getRandomItems(params); + + boolean hasMarker = drops.stream().anyMatch(stack -> stack.is(TestLootModifier.MARKER)); + context.assertTrue(hasMarker, "the rolled, modified table should contain the injected marker; got " + drops); + + context.succeed(); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/RegisterableBlockGameTest.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/RegisterableBlockGameTest.java new file mode 100644 index 000000000..188803df7 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/RegisterableBlockGameTest.java @@ -0,0 +1,50 @@ +package com.chimericdream.lib.fabric.test; + +import com.chimericdream.lib.fabric.test.fixture.TestFixtures; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; + +/** + * Proves {@code ModRegistryHelper} + {@code registerWithItem}/{@code registerBlockEntity} register + * everything under the expected ids: a plain block resolves alongside its {@link BlockItem}, and a + * block that owns a block entity resolves the whole block/item/block-entity trio. + */ +@SuppressWarnings("unused") +public class RegisterableBlockGameTest { + @GameTest + public void registerWithItemRegistersBlockAndItem(GameTestHelper context) { + Identifier id = TestFixtures.REGISTRY_HELPER.makeId("test_block"); + + Block block = BuiltInRegistries.BLOCK.getValue(id); + context.assertTrue(block == TestFixtures.TEST_BLOCK.get(), "block should resolve at " + id); + + Item item = BuiltInRegistries.ITEM.getValue(id); + context.assertTrue(item instanceof BlockItem blockItem && blockItem.getBlock() == block, + "a BlockItem pointing at the block should resolve at " + id); + + context.succeed(); + } + + @GameTest + public void registerBlockEntityResolvesTheWholeTrio(GameTestHelper context) { + Identifier id = TestFixtures.REGISTRY_HELPER.makeId("test_container"); + + Block block = BuiltInRegistries.BLOCK.getValue(id); + context.assertTrue(block == TestFixtures.TEST_CONTAINER_BLOCK.get(), "container block should resolve at " + id); + + Item item = BuiltInRegistries.ITEM.getValue(id); + context.assertTrue(item instanceof BlockItem, "a BlockItem should resolve at " + id); + + BlockEntityType beType = BuiltInRegistries.BLOCK_ENTITY_TYPE.getValue(id); + context.assertTrue(beType == TestFixtures.TEST_CONTAINER_BE.get(), "the block entity type should resolve at " + id); + context.assertTrue(block.defaultBlockState().hasBlockEntity(), "the block should carry a block entity"); + + context.succeed(); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ScreenHandlerGameTest.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ScreenHandlerGameTest.java new file mode 100644 index 000000000..973672d12 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/ScreenHandlerGameTest.java @@ -0,0 +1,134 @@ +package com.chimericdream.lib.fabric.test; + +import com.chimericdream.lib.fabric.test.fixture.TestFixtures; +import com.chimericdream.lib.screen.DoubleWideInventoryScreenHandler; +import com.chimericdream.lib.screen.SimpleInventoryScreenHandler; +import com.chimericdream.lib.testkit.gametest.GameTestContainers; +import com.chimericdream.lib.testkit.gametest.GameTestMenus; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.ContainerUser; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Opens the library's {@code SimpleInventoryScreenHandler} and {@code DoubleWideInventoryScreenHandler} + * server-side over a fixture container with a mock player, and verifies slot layout and shift-click + * ("quick move") routing in both directions. + */ +@SuppressWarnings("unused") +public class ScreenHandlerGameTest { + @GameTest + public void simpleHandlerLayoutAndQuickMoveBothWays(GameTestHelper context) { + ServerPlayer player = context.makeMockServerPlayerInLevel(); + Container container = new SimpleContainer(TestFixtures.SIMPLE_ROWS * 9); + + SimpleInventoryScreenHandler menu = new SimpleInventoryScreenHandler( + TestFixtures.SIMPLE_MENU.get(), 1, player.getInventory(), container, TestFixtures.SIMPLE_ROWS + ); + + int expectedSlots = TestFixtures.SIMPLE_ROWS * 9 + 27 + 9; + GameTestMenus.assertSlotCount(context, menu, expectedSlots); + + // container -> player: shift-clicking a container slot empties it into the player inventory. + container.setItem(0, new ItemStack(Items.DIAMOND, 10)); + menu.quickMoveStack(player, 0); + GameTestContainers.assertSlotEmpty(context, container, 0); + + // player -> container: shift-clicking a player slot routes back into the now-empty container. + int firstPlayerSlot = container.getContainerSize(); + menu.getSlot(firstPlayerSlot).set(new ItemStack(Items.EMERALD, 5)); + menu.quickMoveStack(player, firstPlayerSlot); + GameTestContainers.assertSlot(context, container, 0, Items.EMERALD, 5); + + context.succeed(); + } + + @GameTest + public void doubleWideHandlerLayoutAndQuickMove(GameTestHelper context) { + ServerPlayer player = context.makeMockServerPlayerInLevel(); + Container container = new SimpleContainer(TestFixtures.DOUBLE_ROWS * 18); + + DoubleWideInventoryScreenHandler menu = new DoubleWideInventoryScreenHandler( + TestFixtures.DOUBLE_MENU.get(), 1, player.getInventory(), container, TestFixtures.DOUBLE_ROWS + ); + + int expectedSlots = TestFixtures.DOUBLE_ROWS * 18 + 27 + 9; + GameTestMenus.assertSlotCount(context, menu, expectedSlots); + + container.setItem(0, new ItemStack(Items.DIAMOND, 4)); + menu.quickMoveStack(player, 0); + GameTestContainers.assertSlotEmpty(context, container, 0); + + context.succeed(); + } + + /** + * Both handlers call {@code startOpen} in their constructor but used to never call + * {@code stopOpen}, so a container backed by a {@code ContainerOpenersCounter} kept counting a + * viewer that had already closed the screen. + */ + @GameTest + public void simpleHandlerBalancesOpenAndClose(GameTestHelper context) { + ServerPlayer player = context.makeMockServerPlayerInLevel(); + OpenCountingContainer container = new OpenCountingContainer(TestFixtures.SIMPLE_ROWS * 9); + + SimpleInventoryScreenHandler menu = new SimpleInventoryScreenHandler( + TestFixtures.SIMPLE_MENU.get(), 1, player.getInventory(), container, TestFixtures.SIMPLE_ROWS + ); + + container.assertOpenCount(context, 1, "opening the menu"); + menu.removed(player); + container.assertOpenCount(context, 0, "closing the menu"); + + context.succeed(); + } + + @GameTest + public void doubleWideHandlerBalancesOpenAndClose(GameTestHelper context) { + ServerPlayer player = context.makeMockServerPlayerInLevel(); + OpenCountingContainer container = new OpenCountingContainer(TestFixtures.DOUBLE_ROWS * 18); + + DoubleWideInventoryScreenHandler menu = new DoubleWideInventoryScreenHandler( + TestFixtures.DOUBLE_MENU.get(), 1, player.getInventory(), container, TestFixtures.DOUBLE_ROWS + ); + + container.assertOpenCount(context, 1, "opening the menu"); + menu.removed(player); + container.assertOpenCount(context, 0, "closing the menu"); + + context.succeed(); + } + + /** + * {@code SimpleContainer}'s own {@code startOpen}/{@code stopOpen} are no-ops, so this stands in + * for the real block entities (which delegate to a {@code ContainerOpenersCounter}) and just + * tallies the calls. + */ + private static class OpenCountingContainer extends SimpleContainer { + private int openCount = 0; + + OpenCountingContainer(int size) { + super(size); + } + + @Override + public void startOpen(ContainerUser user) { + openCount++; + } + + @Override + public void stopOpen(ContainerUser user) { + openCount--; + } + + void assertOpenCount(GameTestHelper context, int expected, String what) { + if (openCount != expected) { + context.fail("Expected an open count of " + expected + " after " + what + ", got " + openCount); + } + } + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/SimpleSeatEntityGameTest.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/SimpleSeatEntityGameTest.java new file mode 100644 index 000000000..7948c28a8 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/SimpleSeatEntityGameTest.java @@ -0,0 +1,41 @@ +package com.chimericdream.lib.fabric.test; + +import com.chimericdream.lib.entities.SimpleSeatEntity; +import com.chimericdream.lib.fabric.test.fixture.TestFixtures; +import com.chimericdream.lib.testkit.gametest.GameTestEntities; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerPlayer; + +/** + * Validates the {@code SimpleSeatEntity} lifecycle: it carries a rider, and once the rider dismounts + * and the entity has lived past its 20-tick grace window, {@link SimpleSeatEntity#tick()} kills it — + * so a sit/dismount cycle leaves no leaked seat entities. + * + *

Deliberately not asserted (known gaps, tracked in chimeric-lib POTENTIAL_FEATURES under + * "SimpleSeatEntity polish"): freeing the rider when the seat block is broken mid-sit, and keeping the + * dismount position out of walls. Those behaviours don't exist yet, so asserting them would only encode + * the gap rather than test the code. + */ +@SuppressWarnings("unused") +public class SimpleSeatEntityGameTest { + @GameTest(maxTicks = 80) + public void sitDismountAutoDespawnLeavesNoLeak(GameTestHelper context) { + BlockPos seatPos = new BlockPos(3, 2, 3); + SimpleSeatEntity seat = context.spawn(TestFixtures.SEAT_ENTITY.get(), seatPos); + ServerPlayer rider = context.makeMockServerPlayerInLevel(); + + rider.startRiding(seat); + context.assertTrue(!seat.getPassengers().isEmpty(), "the rider should be sitting on the seat"); + GameTestEntities.assertCount(context, TestFixtures.SEAT_ENTITY.get(), 1); + + // Dismount after a few ticks; the seat should self-destruct once it is both rider-less and past + // its 20-tick grace window. + context.runAtTickTime(5L, rider::stopRiding); + context.runAtTickTime(60L, () -> { + GameTestEntities.assertNone(context, TestFixtures.SEAT_ENTITY.get()); + context.succeed(); + }); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/ChimericLibTestEntrypoint.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/ChimericLibTestEntrypoint.java new file mode 100644 index 000000000..d5ce3f777 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/ChimericLibTestEntrypoint.java @@ -0,0 +1,15 @@ +package com.chimericdream.lib.fabric.test.fixture; + +import net.fabricmc.api.ModInitializer; + +/** + * {@code main} entrypoint of the {@code chimericlib_test} test mod. Only present in the {@code gametest} + * source set, so it runs solely inside {@code runGameTest} — this is what keeps the fixture content out + * of the shipped jar. + */ +public class ChimericLibTestEntrypoint implements ModInitializer { + @Override + public void onInitialize() { + TestFixtures.register(); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlock.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlock.java new file mode 100644 index 000000000..f377692f8 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlock.java @@ -0,0 +1,24 @@ +package com.chimericdream.lib.fabric.test.fixture; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.jspecify.annotations.Nullable; + +/** + * A throwaway {@link EntityBlock} whose block entity is a {@link TestContainerBlockEntity}. Registered + * only from the test mod's entrypoint; never shipped. Exists so GameTests can place a real container + * block in the world (and, e.g., let a vanilla hopper feed it). + */ +public class TestContainerBlock extends Block implements EntityBlock { + public TestContainerBlock(Properties properties) { + super(properties); + } + + @Override + public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new TestContainerBlockEntity(pos, state); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlockEntity.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlockEntity.java new file mode 100644 index 000000000..f312eee43 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestContainerBlockEntity.java @@ -0,0 +1,43 @@ +package com.chimericdream.lib.fabric.test.fixture; + +import com.chimericdream.lib.inventories.ImplementedInventory; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** + * A throwaway container block entity backed by {@link ImplementedInventory}, used only by the + * GameTests to prove the library's {@code Container} contract and NBT persistence work end to end. + * Registered only from the test mod's entrypoint, so it never ships in the production jar. + */ +public class TestContainerBlockEntity extends BlockEntity implements ImplementedInventory { + public static final int SIZE = 27; + + private final NonNullList items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + + public TestContainerBlockEntity(BlockPos pos, BlockState state) { + super(TestFixtures.TEST_CONTAINER_BE.get(), pos, state); + } + + @Override + public NonNullList getItems() { + return items; + } + + @Override + protected void saveAdditional(ValueOutput view) { + super.saveAdditional(view); + ContainerHelper.saveAllItems(view, items); + } + + @Override + protected void loadAdditional(ValueInput view) { + super.loadAdditional(view); + ContainerHelper.loadAllItems(view, items); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestFixtures.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestFixtures.java new file mode 100644 index 000000000..906f6b27e --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestFixtures.java @@ -0,0 +1,92 @@ +package com.chimericdream.lib.fabric.test.fixture; + +import com.chimericdream.lib.entities.SimpleSeatEntity; +import com.chimericdream.lib.registries.ModRegistryHelper; +import com.chimericdream.lib.screen.DoubleWideInventoryScreenHandler; +import com.chimericdream.lib.screen.SimpleInventoryScreenHandler; +import dev.architectury.registry.registries.RegistrySupplier; +import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Registers the throwaway fixture content the GameTests need — all through the library's own + * {@link ModRegistryHelper}, so registering them is part of what + * {@code RegisterableBlockGameTest} verifies. Registration is driven from the test mod's + * {@code main} entrypoint ({@link ChimericLibTestEntrypoint}), which only loads in the {@code gametest} + * run, so none of this reaches the production jar. + */ +public final class TestFixtures { + public static final String MOD_ID = "chimericlib_test"; + + private static final Logger LOGGER = LogManager.getLogger(MOD_ID); + + public static final ModRegistryHelper REGISTRY_HELPER = new ModRegistryHelper(MOD_ID, LOGGER); + + public static final int SIMPLE_ROWS = 3; + public static final int DOUBLE_ROWS = 3; + + /** A plain block+item, to prove {@code registerWithItem} resolves both. */ + public static final RegistrySupplier TEST_BLOCK = REGISTRY_HELPER.registerWithItem( + "test_block", + () -> new Block(BlockBehaviour.Properties.of().setId(REGISTRY_HELPER.makeBlockRegistryKey("test_block"))) + ); + + /** A block+item that also owns a block entity, to prove the whole trio resolves. */ + public static final RegistrySupplier TEST_CONTAINER_BLOCK = REGISTRY_HELPER.registerWithItem( + "test_container", + () -> new TestContainerBlock(BlockBehaviour.Properties.of().setId(REGISTRY_HELPER.makeBlockRegistryKey("test_container"))) + ); + + // Built via fabric-api rather than `new BlockEntityType<>(...)`: on 26.1.2 that constructor is + // private, and the mods that call it directly each widen it in their own access widener. This + // source set is fabric-only and never ships, so using the fabric-api builder keeps chimeric-lib + // free of an access widener it would otherwise need purely for a test fixture. + public static final RegistrySupplier> TEST_CONTAINER_BE = REGISTRY_HELPER.registerBlockEntity( + "test_container", + () -> FabricBlockEntityTypeBuilder.create(TestContainerBlockEntity::new, TEST_CONTAINER_BLOCK.get()).build() + ); + + public static final RegistrySupplier> SEAT_ENTITY = REGISTRY_HELPER.registerEntityType( + "test_seat_entity", + () -> EntityType.Builder.of(SimpleSeatEntity::new, MobCategory.MISC) + .sized(0.5f, 0.5f) + .build(ResourceKey.create(Registries.ENTITY_TYPE, REGISTRY_HELPER.makeId("test_seat_entity"))) + ); + + // The MenuSupplier passes a null type (as consumer handlers like Minekea's CrateScreenHandler do) + // to sidestep a self-reference in this field's initializer; the tests construct these handlers + // directly with the real registered type via SIMPLE_MENU.get()/DOUBLE_MENU.get(). + public static final RegistrySupplier> SIMPLE_MENU = REGISTRY_HELPER.registerScreenHandler( + "simple_menu", + () -> new MenuType<>( + (syncId, inv) -> new SimpleInventoryScreenHandler(null, syncId, inv, SIMPLE_ROWS), + FeatureFlagSet.of() + ) + ); + + public static final RegistrySupplier> DOUBLE_MENU = REGISTRY_HELPER.registerScreenHandler( + "double_menu", + () -> new MenuType<>( + (syncId, inv) -> new DoubleWideInventoryScreenHandler(null, syncId, inv, DOUBLE_ROWS), + FeatureFlagSet.of() + ) + ); + + private TestFixtures() { + } + + /** Flushes every deferred registration above into the game registries. */ + public static void register() { + REGISTRY_HELPER.init(); + } +} diff --git a/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestLootModifier.java b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestLootModifier.java new file mode 100644 index 000000000..785fc85a6 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/java/com/chimericdream/lib/fabric/test/fixture/TestLootModifier.java @@ -0,0 +1,27 @@ +package com.chimericdream.lib.fabric.test.fixture; + +import com.chimericdream.lib.loot.LootTableModifier; +import java.util.List; +import net.minecraft.core.HolderLookup; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.storage.loot.LootPool; + +/** + * A test {@link LootTableModifier} that injects a single guaranteed marker item ({@link Items#DIAMOND}) + * into exactly one targeted vanilla loot table id, and leaves every other table untouched. Used to + * validate the modifier's routing and pool generation. + */ +public class TestLootModifier extends LootTableModifier { + public static final Identifier TARGET = Identifier.withDefaultNamespace("blocks/stone"); + public static final Item MARKER = Items.DIAMOND; + + @Override + protected void checkVanillaLootTables(Identifier id, List poolBuilders, HolderLookup.Provider wrapperLookup) { + if (id.equals(TARGET)) { + // chance == 1 -> a single, guaranteed marker entry, so a roll always yields it. + poolBuilders.add(makeWeightedItem(MARKER, 1)); + } + } +} diff --git a/chimeric-lib/fabric/src/gametest/resources/fabric.mod.json b/chimeric-lib/fabric/src/gametest/resources/fabric.mod.json new file mode 100644 index 000000000..f16c89c18 --- /dev/null +++ b/chimeric-lib/fabric/src/gametest/resources/fabric.mod.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "chimericlib_test", + "version": "1.0.0", + "name": "ChimericLib GameTest Fixtures", + "description": "Test-only fixture content and GameTests for ChimericLib. Never shipped.", + "authors": [ + "chimericdream" + ], + "license": "MIT", + "environment": "*", + "entrypoints": { + "main": [ + "com.chimericdream.lib.fabric.test.fixture.ChimericLibTestEntrypoint" + ], + "fabric-gametest": [ + "com.chimericdream.lib.fabric.test.RegisterableBlockGameTest", + "com.chimericdream.lib.fabric.test.ImplementedInventoryGameTest", + "com.chimericdream.lib.fabric.test.ScreenHandlerGameTest", + "com.chimericdream.lib.fabric.test.SimpleSeatEntityGameTest", + "com.chimericdream.lib.fabric.test.LootTableModifierGameTest" + ] + }, + "depends": { + "fabricloader": "*", + "minecraft": "*", + "fabric-api": "*", + "architectury": "*", + "chimericlib": "*" + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/blocks/BlockConfigTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/blocks/BlockConfigTest.java new file mode 100644 index 000000000..d73f2afa3 --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/blocks/BlockConfigTest.java @@ -0,0 +1,68 @@ +package com.chimericdream.chimericlib.test.blocks; + +import com.chimericdream.lib.blocks.BlockConfig; +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Blocks; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +// Derives the fallback texture through BuiltInRegistries, so it bootstraps Minecraft. +public class BlockConfigTest extends BootstrapMinecraft { + private static final Identifier CUSTOM_TEXTURE = + Identifier.fromNamespaceAndPath("chimericlib", "block/custom"); + + /** + * The regression: {@code getTexture()} used {@code Map.getOrDefault}, which evaluates its default + * argument unconditionally. A config with an explicit texture but no ingredient therefore blew up + * inside {@code getIngredient()} even though the texture it was asked for was right there. + */ + @Test + void explicitTextureIsReturnedWithoutAnIngredient() { + BlockConfig config = new BlockConfig().texture(CUSTOM_TEXTURE); + + assertDoesNotThrow(() -> config.getTexture()); + assertEquals(CUSTOM_TEXTURE, config.getTexture()); + } + + @Test + void explicitTextureWinsOverTheIngredient() { + BlockConfig config = new BlockConfig() + .ingredient(Blocks.OAK_PLANKS) + .texture(CUSTOM_TEXTURE); + + assertEquals(CUSTOM_TEXTURE, config.getTexture()); + } + + @Test + void fallsBackToTheIngredientTextureWhenNoneIsSet() { + BlockConfig config = new BlockConfig().ingredient(Blocks.OAK_PLANKS); + + assertEquals( + Identifier.fromNamespaceAndPath("minecraft", "block/oak_planks"), + config.getTexture() + ); + } + + /** With neither a texture nor an ingredient there is nothing to derive, so the throw stands. */ + @Test + void stillThrowsWithNeitherATextureNorAnIngredient() { + BlockConfig config = new BlockConfig(); + + assertThrows(IllegalStateException.class, config::getTexture); + } + + @Test + void namedTextureLookupIsUnaffected() { + BlockConfig config = new BlockConfig() + .ingredient(Blocks.OAK_PLANKS) + .texture("side", CUSTOM_TEXTURE); + + assertEquals(CUSTOM_TEXTURE, config.getTexture("side")); + assertNull(config.getTexture("top")); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/colors/ColorHelpersTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/colors/ColorHelpersTest.java new file mode 100644 index 000000000..8556a8380 --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/colors/ColorHelpersTest.java @@ -0,0 +1,146 @@ +package com.chimericdream.chimericlib.test.colors; + +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import com.chimericdream.lib.colors.ColorHelpers; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// Touches Items/Blocks/DyeColor, so it bootstraps Minecraft (see BootstrapMinecraft). +public class ColorHelpersTest extends BootstrapMinecraft { + @Test + void rgbFromIntRoundTrips() { + ColorHelpers.RGB rgb = new ColorHelpers.RGB(18, 240, 7); + + // The stored int drops alpha (alpha 0), so re-reading the channels must be lossless. + assertEquals(rgb, ColorHelpers.RGB.fromInt(rgb.getColor())); + assertEquals(rgb.getColor(), rgb.toInt()); + } + + @Test + void rgbFromIntIgnoresAlpha() { + // 0xAARRGGBB with a non-zero alpha byte still decodes to the RGB channels. + ColorHelpers.RGB rgb = ColorHelpers.RGB.fromInt(0xFF12F007); + + assertEquals(0x12, rgb.r()); + assertEquals(0xF0, rgb.g()); + assertEquals(0x07, rgb.b()); + } + + @Test + void rgbToHexZeroPadsChannels() { + assertEquals("#000000", new ColorHelpers.RGB(0, 0, 0).toHex()); + assertEquals("#ffffff", new ColorHelpers.RGB(255, 255, 255).toHex()); + assertEquals("#ff0010", new ColorHelpers.RGB(255, 0, 16).toHex()); + } + + @Test + void getTintReturnsVariantAtIndex() { + int[] variants = {0xaaaaaa, 0xbbbbbb, 0xcccccc}; + + assertEquals(0xaaaaaa, ColorHelpers.getTint(0, variants)); + assertEquals(0xcccccc, ColorHelpers.getTint(2, variants)); + } + + @Test + void getTintFallsBackToFirstVariantWhenOutOfRange() { + int[] variants = {0xaaaaaa, 0xbbbbbb}; + + assertEquals(0xaaaaaa, ColorHelpers.getTint(-1, variants)); + assertEquals(0xaaaaaa, ColorHelpers.getTint(2, variants)); + assertEquals(0xaaaaaa, ColorHelpers.getTint(99, variants)); + } + + @Test + void getTintsReturnsDefensiveCopy() { + int[] first = ColorHelpers.getTints("white"); + int[] second = ColorHelpers.getTints("white"); + + // Same values every call, but a fresh array so a caller can't corrupt the shared palette. + assertArrayEquals(first, second); + first[0] = 0; + assertNotEquals(0, ColorHelpers.getTints("white")[0]); + } + + @Test + void getTintsRejectsUnknownColor() { + assertThrows(RuntimeException.class, () -> ColorHelpers.getTints("chartreuse")); + } + + @Test + void getNameTitleCasesKnownColors() { + assertEquals("White", ColorHelpers.getName("white")); + assertEquals("Light Gray", ColorHelpers.getName("light_gray")); + assertEquals("Pink", ColorHelpers.getName("pink")); + // DyeColor overload delegates to the serialized name. + assertEquals("Light Blue", ColorHelpers.getName(DyeColor.LIGHT_BLUE)); + } + + @Test + void getNameRejectsUnknownColor() { + assertThrows(RuntimeException.class, () -> ColorHelpers.getName("chartreuse")); + } + + @Test + void getColorsListsAllSixteenInOrder() { + String[] colors = ColorHelpers.getColors(); + + assertEquals(16, colors.length); + assertEquals("white", colors[0]); + assertEquals("pink", colors[15]); + // Every entry must resolve through getName without throwing. + for (String color : colors) { + ColorHelpers.getName(color); + } + } + + @Test + void getDyeAndWoolResolveForEveryColor() { + for (String color : ColorHelpers.getColors()) { + assertNotEquals(Items.AIR, ColorHelpers.getDye(color), color); + assertNotEquals(Blocks.AIR, ColorHelpers.getWool(color), color); + } + assertSame(Items.RED_DYE, ColorHelpers.getDye(DyeColor.RED)); + assertSame(Blocks.RED_WOOL, ColorHelpers.getWool("red")); + } + + @Test + void getDyeAndWoolRejectUnknownColor() { + assertThrows(RuntimeException.class, () -> ColorHelpers.getDye("chartreuse")); + assertThrows(RuntimeException.class, () -> ColorHelpers.getWool("chartreuse")); + } + + @Test + void mixColorsWithNoInputsReturnsNull() { + assertNull(ColorHelpers.mixColors(null, List.of())); + } + + @Test + void mixColorsWithSingleSeedIsIdentity() { + ColorHelpers.RGB seed = new ColorHelpers.RGB(10, 20, 30); + + assertEquals(seed, ColorHelpers.mixColors(seed, List.of())); + } + + @Test + void mixColorsBlendsTowardAddedDye() { + ColorHelpers.RGB mixed = ColorHelpers.mixColors(new ColorHelpers.RGB(0, 0, 0), List.of(DyeColor.RED)); + + // Blending black with red must produce a real color whose channels stay in byte range. + assertNotEquals(new ColorHelpers.RGB(0, 0, 0), mixed); + assertTrue(mixed.r() >= 0 && mixed.r() <= 255, "r in range"); + assertTrue(mixed.g() >= 0 && mixed.g() <= 255, "g in range"); + assertTrue(mixed.b() >= 0 && mixed.b() <= 255, "b in range"); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/ImplementedInventoryTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/ImplementedInventoryTest.java new file mode 100644 index 000000000..b88db9d2e --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/ImplementedInventoryTest.java @@ -0,0 +1,102 @@ +package com.chimericdream.chimericlib.test.inventories; + +import com.chimericdream.lib.inventories.ImplementedInventory; +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import net.minecraft.core.NonNullList; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// Builds ItemStacks and reads item components, so it bootstraps Minecraft (see BootstrapMinecraft). +public class ImplementedInventoryTest extends BootstrapMinecraft { + private static ImplementedInventory ofSize(int size) { + return ImplementedInventory.of(NonNullList.withSize(size, ItemStack.EMPTY)); + } + + /** + * The core of 1.6: two otherwise-identical partial stacks must be mergeable regardless of their + * counts. The old ItemStack.matches comparison also compared counts, so a merge only happened when + * the counts happened to coincide. + */ + @Test + void partialStacksWithDifferentCountsAreMergeable() { + ImplementedInventory inv = ofSize(1); + + ItemStack existing = new ItemStack(Items.COBBLESTONE, 10); + ItemStack incoming = new ItemStack(Items.COBBLESTONE, 5); + + assertTrue(inv.isMatchingPartialStack(incoming, existing), + "cobblestone x5 should merge into cobblestone x10"); + } + + @Test + void tryInsertMergesFittingStackAndReturnsEmpty() { + ImplementedInventory inv = ofSize(1); + inv.getItems().set(0, new ItemStack(Items.COBBLESTONE, 10)); + + ItemStack remainder = inv.tryInsert(0, new ItemStack(Items.COBBLESTONE, 5)); + + assertTrue(remainder.isEmpty(), "a fully-merged insert leaves no remainder"); + assertEquals(Items.COBBLESTONE, inv.getItem(0).getItem()); + assertEquals(15, inv.getItem(0).getCount(), "slot should hold 10 + 5 = 15"); + } + + @Test + void tryInsertPartialMergeFillsToMaxAndReturnsRemainder() { + ImplementedInventory inv = ofSize(1); + inv.getItems().set(0, new ItemStack(Items.COBBLESTONE, 60)); + + // 64 max - 60 = 4 fit; 10 - 4 = 6 remain. + ItemStack remainder = inv.tryInsert(0, new ItemStack(Items.COBBLESTONE, 10)); + + assertEquals(64, inv.getItem(0).getCount(), "slot should be filled to its max stack size"); + assertEquals(Items.COBBLESTONE, remainder.getItem()); + assertEquals(6, remainder.getCount(), "the overflow should be returned as the remainder"); + } + + @Test + void stacksWithDifferentComponentsDoNotMerge() { + ImplementedInventory inv = ofSize(1); + + ItemStack existing = new ItemStack(Items.COBBLESTONE, 10); + inv.getItems().set(0, existing); + + ItemStack incoming = new ItemStack(Items.COBBLESTONE, 5); + incoming.set(DataComponents.CUSTOM_NAME, Component.literal("Fancy Rock")); + + assertFalse(inv.isMatchingPartialStack(incoming, existing), + "stacks differing only by components must not merge"); + + ItemStack remainder = inv.tryInsert(0, incoming); + assertEquals(5, remainder.getCount(), "a non-matching stack is returned unchanged"); + assertEquals(10, inv.getItem(0).getCount(), "the existing stack is left untouched"); + } + + @Test + void tryInsertIntoEmptySlotStoresStack() { + ImplementedInventory inv = ofSize(1); + + ItemStack remainder = inv.tryInsert(0, new ItemStack(Items.COBBLESTONE, 5)); + + assertTrue(remainder.isEmpty()); + assertEquals(Items.COBBLESTONE, inv.getItem(0).getItem()); + assertEquals(5, inv.getItem(0).getCount()); + } + + @Test + void tryInsertIntoFullMatchingSlotReturnsStackUnchanged() { + ImplementedInventory inv = ofSize(1); + inv.getItems().set(0, new ItemStack(Items.COBBLESTONE, 64)); + + ItemStack remainder = inv.tryInsert(0, new ItemStack(Items.COBBLESTONE, 5)); + + assertEquals(5, remainder.getCount(), "nothing fits into a full slot"); + assertEquals(64, inv.getItem(0).getCount()); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/InventoryUtilsTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/InventoryUtilsTest.java new file mode 100644 index 000000000..ed805d807 --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/inventories/InventoryUtilsTest.java @@ -0,0 +1,44 @@ +package com.chimericdream.chimericlib.test.inventories; + +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import com.chimericdream.lib.inventories.InventoryUtils; +import net.minecraft.core.NonNullList; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// Builds ItemStacks, so it bootstraps Minecraft (see BootstrapMinecraft). +public class InventoryUtilsTest extends BootstrapMinecraft { + @Test + void convertListToInventoryMatchesSizeAndContents() { + NonNullList list = NonNullList.withSize(4, ItemStack.EMPTY); + list.set(0, new ItemStack(Items.DIAMOND, 3)); + list.set(2, new ItemStack(Items.STONE, 64)); + + SimpleContainer inventory = InventoryUtils.convertListToInventory(list); + + assertEquals(4, inventory.getContainerSize()); + + assertEquals(Items.DIAMOND, inventory.getItem(0).getItem()); + assertEquals(3, inventory.getItem(0).getCount()); + + assertTrue(inventory.getItem(1).isEmpty()); + + assertEquals(Items.STONE, inventory.getItem(2).getItem()); + assertEquals(64, inventory.getItem(2).getCount()); + + assertTrue(inventory.getItem(3).isEmpty()); + } + + @Test + void convertEmptyListYieldsEmptyInventory() { + SimpleContainer inventory = InventoryUtils.convertListToInventory(NonNullList.create()); + + assertEquals(0, inventory.getContainerSize()); + assertTrue(inventory.isEmpty()); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/items/ItemHelpersTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/items/ItemHelpersTest.java new file mode 100644 index 000000000..27231761d --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/items/ItemHelpersTest.java @@ -0,0 +1,28 @@ +package com.chimericdream.chimericlib.test.items; + +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import com.chimericdream.lib.items.ItemHelpers; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +// Resolves ids through BuiltInRegistries, so it bootstraps Minecraft (see BootstrapMinecraft). +public class ItemHelpersTest extends BootstrapMinecraft { + @Test + void getIdentifierReturnsRegisteredItemId() { + Identifier id = ItemHelpers.getIdentifier(new ItemStack(Items.DIAMOND)); + + assertEquals(Identifier.fromNamespaceAndPath("minecraft", "diamond"), id); + } + + @Test + void getIdentifierTreatsEmptyStackAsAir() { + assertEquals( + Identifier.fromNamespaceAndPath("minecraft", "air"), + ItemHelpers.getIdentifier(ItemStack.EMPTY) + ); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/resource/TextureUtilsTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/resource/TextureUtilsTest.java new file mode 100644 index 000000000..c422a4c82 --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/resource/TextureUtilsTest.java @@ -0,0 +1,49 @@ +package com.chimericdream.chimericlib.test.resource; + +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import com.chimericdream.lib.resource.TextureUtils; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Blocks; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +// The Block overloads look ids up in BuiltInRegistries, so this bootstraps Minecraft. +public class TextureUtilsTest extends BootstrapMinecraft { + @Test + void blockPrefixesIdentifierPath() { + Identifier id = Identifier.fromNamespaceAndPath("mymod", "oak_planks"); + + assertEquals(Identifier.fromNamespaceAndPath("mymod", "block/oak_planks"), TextureUtils.block(id)); + } + + @Test + void blockPrefixesAndSuffixesIdentifierPath() { + Identifier id = Identifier.fromNamespaceAndPath("mymod", "furnace"); + + assertEquals( + Identifier.fromNamespaceAndPath("mymod", "block/furnace_top"), + TextureUtils.block(id, "_top") + ); + } + + @Test + void blockPreservesNamespace() { + Identifier id = Identifier.fromNamespaceAndPath("othermod", "thing"); + + assertEquals("othermod", TextureUtils.block(id).getNamespace()); + assertEquals("block/thing", TextureUtils.block(id).getPath()); + } + + @Test + void blockResolvesRegisteredBlockId() { + assertEquals( + Identifier.fromNamespaceAndPath("minecraft", "block/stone"), + TextureUtils.block(Blocks.STONE) + ); + assertEquals( + Identifier.fromNamespaceAndPath("minecraft", "block/stone_side"), + TextureUtils.block(Blocks.STONE, "_side") + ); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/text/TextHelpersTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/text/TextHelpersTest.java new file mode 100644 index 000000000..5ac49ceca --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/text/TextHelpersTest.java @@ -0,0 +1,39 @@ +package com.chimericdream.chimericlib.test.text; + +import com.chimericdream.lib.text.TextHelpers; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.TextColor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// Pure component/string building — no registry access, so no Minecraft bootstrap is required. +public class TextHelpersTest { + @Test + void getColorTranslationKeyBuildsVanillaKey() { + assertEquals("color.minecraft.red", TextHelpers.getColorTranslationKey("red")); + assertEquals("color.minecraft.light_blue", TextHelpers.getColorTranslationKey("light_blue")); + } + + @Test + void formatTooltipAppliesAquaItalicStyle() { + MutableComponent formatted = TextHelpers.formatTooltip(Component.literal("Hello")); + + assertEquals("Hello", formatted.getString()); + assertEquals(TextColor.fromLegacyFormat(ChatFormatting.AQUA), formatted.getStyle().getColor()); + assertTrue(formatted.getStyle().isItalic()); + } + + @Test + void getTooltipWrapsTranslatableKeyWithStyle() { + MutableComponent tooltip = TextHelpers.getTooltip("mymod.tooltip.example"); + + // With no language loaded the translatable falls back to its key. + assertEquals("mymod.tooltip.example", tooltip.getString()); + assertEquals(TextColor.fromLegacyFormat(ChatFormatting.AQUA), tooltip.getStyle().getColor()); + assertTrue(tooltip.getStyle().isItalic()); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/ToolTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/ToolTest.java new file mode 100644 index 000000000..6e8531bcb --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/ToolTest.java @@ -0,0 +1,45 @@ +package com.chimericdream.chimericlib.test.util; + +import com.chimericdream.lib.testkit.BootstrapMinecraft; +import com.chimericdream.lib.tags.CommonBlockTags; +import com.chimericdream.lib.util.Tool; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.ItemTags; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ToolTest extends BootstrapMinecraft { + @Test + void getItemTagMapsDiggingToolsToVanillaTags() { + assertEquals(ItemTags.AXES, Tool.AXE.getItemTag()); + assertEquals(ItemTags.HOES, Tool.HOE.getItemTag()); + assertEquals(ItemTags.PICKAXES, Tool.PICKAXE.getItemTag()); + assertEquals(ItemTags.SHOVELS, Tool.SHOVEL.getItemTag()); + } + + @Test + void getItemTagIsNullForShearsAndNone() { + assertNull(Tool.SHEARS.getItemTag()); + assertNull(Tool.NONE.getItemTag()); + } + + @Test + void getMineableTagMapsToVanillaMineableTags() { + assertEquals(BlockTags.MINEABLE_WITH_AXE, Tool.AXE.getMineableTag()); + assertEquals(BlockTags.MINEABLE_WITH_HOE, Tool.HOE.getMineableTag()); + assertEquals(BlockTags.MINEABLE_WITH_PICKAXE, Tool.PICKAXE.getMineableTag()); + assertEquals(BlockTags.MINEABLE_WITH_SHOVEL, Tool.SHOVEL.getMineableTag()); + } + + @Test + void getMineableTagUsesCommonShearsTag() { + assertEquals(CommonBlockTags.SHEARS_MINEABLE, Tool.SHEARS.getMineableTag()); + } + + @Test + void getMineableTagIsNullForNone() { + assertNull(Tool.NONE.getMineableTag()); + } +} diff --git a/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/math/DirectionUtilsTest.java b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/math/DirectionUtilsTest.java new file mode 100644 index 000000000..dab4c4279 --- /dev/null +++ b/chimeric-lib/fabric/src/test/java/com/chimericdream/chimericlib/test/util/math/DirectionUtilsTest.java @@ -0,0 +1,33 @@ +package com.chimericdream.chimericlib.test.util.math; + +import com.chimericdream.lib.util.math.DirectionUtils; +import net.minecraft.core.Direction; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DirectionUtilsTest { + @Test + void getHitFaceTest() { + // X-axis + assertEquals(Direction.NORTH, DirectionUtils.getHitFace(Direction.Axis.X, Direction.DOWN)); + assertEquals(Direction.SOUTH, DirectionUtils.getHitFace(Direction.Axis.X, Direction.UP)); + assertEquals(Direction.WEST, DirectionUtils.getHitFace(Direction.Axis.X, Direction.NORTH)); + assertEquals(Direction.EAST, DirectionUtils.getHitFace(Direction.Axis.X, Direction.SOUTH)); + assertEquals(Direction.DOWN, DirectionUtils.getHitFace(Direction.Axis.X, Direction.WEST)); + assertEquals(Direction.UP, DirectionUtils.getHitFace(Direction.Axis.X, Direction.EAST)); + + // Y-axis passes the hit side through unchanged + for (Direction hitSide : Direction.values()) { + assertEquals(hitSide, DirectionUtils.getHitFace(Direction.Axis.Y, hitSide)); + } + + // Z-axis + assertEquals(Direction.NORTH, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.DOWN)); + assertEquals(Direction.SOUTH, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.UP)); + assertEquals(Direction.UP, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.NORTH)); + assertEquals(Direction.DOWN, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.SOUTH)); + assertEquals(Direction.WEST, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.WEST)); + assertEquals(Direction.EAST, DirectionUtils.getHitFace(Direction.Axis.Z, Direction.EAST)); + } +} diff --git a/chimeric-lib/neoforge/src/main/java/com/chimericdream/lib/neoforge/loot/LootModifierHelper.java b/chimeric-lib/neoforge/src/main/java/com/chimericdream/lib/neoforge/loot/LootModifierHelper.java new file mode 100644 index 000000000..ea430c6f7 --- /dev/null +++ b/chimeric-lib/neoforge/src/main/java/com/chimericdream/lib/neoforge/loot/LootModifierHelper.java @@ -0,0 +1,34 @@ +package com.chimericdream.lib.neoforge.loot; + +import com.mojang.serialization.MapCodec; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.registries.DeferredRegister; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +/** + * Removes the boilerplate around NeoForge global-loot-modifier registration. Every mod that ships a + * loot modifier otherwise repeats the same verbose + * {@code DeferredRegister.create(NeoForgeRegistries.GLOBAL_LOOT_MODIFIER_SERIALIZERS, modId)} line + * (plus its imports). Create the register with this helper, register each codec on the returned + * register, and bind it to the mod event bus in the NeoForge entrypoint as usual. + * + *

{@code
+ * public static final DeferredRegister> LOOT_MODIFIERS =
+ *     LootModifierHelper.createRegister(ModInfo.MOD_ID);
+ * public static final DeferredHolder<...> MY_MODIFIER =
+ *     LOOT_MODIFIERS.register("my_modifier", MyLootModifier.CODEC);
+ * // in the @Mod entrypoint: LOOT_MODIFIERS.register(modEventBus);
+ * }
+ */ +public final class LootModifierHelper { + private LootModifierHelper() { + } + + /** + * Creates a {@link DeferredRegister} for this mod's global loot modifier serializers. The caller + * still registers each codec on it and binds it to the mod event bus. + */ + public static DeferredRegister> createRegister(String modId) { + return DeferredRegister.create(NeoForgeRegistries.GLOBAL_LOOT_MODIFIER_SERIALIZERS, modId); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 4e61ce5e9..dc6087b3c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -25,28 +25,30 @@ Packages for *external* consumers — it is not part of the edit→test loop. - Run: `./gradlew :chimeric-lib:fabric:test` (chimeric-lib is currently the only mod with unit tests; ~30 tests across colors/text/inventory/items/resource/tool/math). -### Bootstrapping Minecraft +### Bootstrapping Minecraft (the components gotcha) Pure-logic tests (Direction tables, string/bit math, `Component` building) need **no** bootstrap. -Tests that touch registries/items/blocks/tags must bootstrap first: outside a live game the static -registries (`Items`, `Blocks`, `BuiltInRegistries`, …) are unpopulated until Minecraft's bootstrap -runs. `fabric-loader-junit` puts the loader and the named Minecraft jar on the test classpath; the -bootstrap then makes registry lookups resolve. +Tests that touch registries/items/blocks/tags must bootstrap first — and `Bootstrap.bootStrap()` +alone is **not enough**: item data components are data-driven and stay *unbound*, so constructing an +`ItemStack` throws `NullPointerException: Components not bound yet` from `Holder$Reference.components`. +Bake them once, the way a loading server does: ```java SharedConstants.tryDetectVersion(); -Bootstrap.bootStrap(); // guard with a static flag; fabric-loader-junit shares one classloader +Bootstrap.bootStrap(); +HolderLookup.Provider provider = VanillaRegistries.createLookup(); +BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(provider).forEach(p -> p.apply()); // guard with a static flag ``` Canonical helper: **`BootstrapMinecraft`** (`chimeric-lib/common/src/testFixtures/java/com/chimericdream/lib/testkit/`). -Subclass it for registry-touching tests. - -> **On MC 26.2 this is not sufficient** — item data components become data-driven and stay *unbound* -> after `Bootstrap.bootStrap()`, so constructing an `ItemStack` throws -> `NullPointerException: Components not bound yet`, and the helper has to bake them via -> `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS`. **That API does not exist on 26.1.2 and is not -> needed here**; components are bound at construction. If you forward-port this branch, restore the -> bake — see `docs/MC-26.2-NOTES.md` on the 26.2 line. +Subclass it for registry-touching tests. The bake is guarded by a static boolean because +fabric-loader-junit runs all tests in one classloader and `apply()` is not idempotent. + +> This behavior is **the same on 26.1.2 and 26.2** — verified by running the suite without the bake on +> 26.1.2 (8 of 43 tests fail with "Components not bound yet") and by `javap` on the 26.1.2 jar +> (`BuiltInRegistries.DATA_COMPONENT_INITIALIZERS` and `DataComponentInitializers.build(Provider) +> → List` with `apply()` are present and identically shaped). The same bake is +> required at the top of minekea's datagen `buildRecipes()`. ## GameTests (isolated `gametest` source set) diff --git a/docs/backport-26.1.2/README.md b/docs/backport-26.1.2/README.md index 59887e52b..e7aa75f34 100644 --- a/docs/backport-26.1.2/README.md +++ b/docs/backport-26.1.2/README.md @@ -306,7 +306,7 @@ Sub-agents apply this when a patch hunk rejects or the build fails. Derived from | `Blocks.WOOL.white()`, `Items.DYE.red()`, … (`ColorCollection`) | `Blocks.WHITE_WOOL`, `Items.RED_DYE`, … | | `Blocks.CUT_COPPER.weathering().exposed()` | `Blocks.EXPOSED_CUT_COPPER` | | `Blocks.COPPER_BLOCK.weathering().unaffected()` | `Blocks.COPPER_BLOCK` | -| `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(p).forEach(...)` | **Delete.** Components are not lazily bound on 26.1.2. | +| ~~`BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(p).forEach(...)`~~ | ⚠ **KEEP — this row was wrong, see the correction below.** | | `TagAppender` | `TagAppender` | | `TagAppender.add(X.builtInRegistryHolder().key())` | `TagAppender.add(X)` | | `FabricTagProvider.builder(TagKey)` | `FabricTagProvider.valueLookupBuilder(TagKey)` | @@ -325,6 +325,32 @@ Sub-agents apply this when a patch hunk rejects or the build fails. Derived from > `oshi.util.tuples` is still imported on `26.1.2` in `CompressedBlocks`, `DyedBlocks`, and > `ArmoireBlockEntity`, so the refactor is just as applicable there. +### ⚠ Correction: data components ARE lazily bound on 26.1.2 + +An earlier draft of this plan claimed the "Components not bound yet" problem and +`BuiltInRegistries.DATA_COMPONENT_INITIALIZERS` were 26.2-only. **Both claims are wrong**, found while +executing Wave 1. The original inference came from `git grep DATA_COMPONENT_INITIALIZERS 26.1.2` +returning nothing — which only proves no file in the repo used it, not that the API is absent. + +Verified two ways on 26.1.2: + +- Running chimeric-lib's unit suite **without** the bake: 8 of 43 tests fail with + `NullPointerException: Components not bound yet` at `Holder$Reference.components` / + `ItemStack.`. +- `javap` on the 26.1.2 jar: `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS` exists, and + `DataComponentInitializers.build(HolderLookup$Provider)` returns `List>` with a + public `apply()` — identical shape to 26.2. `VanillaRegistries.createLookup()` also exists. + +**Consequences for the rest of the backport:** + +1. `BootstrapMinecraft` keeps the bake exactly as `main` has it. (Done in Wave 1.) +2. **minekea's `ModDataGenerator` keeps its component-binding hunk** — `minekea.md` §4 said to delete + it; that instruction is superseded, and the file now says so. +3. `docs/TESTING.md` and `CLAUDE.md` are corrected on the shared-build branch. + +The lesson generalizes: **`git grep 26.1.2` proves a symbol is *used*, never that it is +*absent*.** For absence, use the jar — see the next section. + ### How to verify an API against 26.1.2 rather than guessing The Loom 26.1.2 sources jar may be a stub. Use the binary jar: diff --git a/docs/backport-26.1.2/chimeric-lib.md b/docs/backport-26.1.2/chimeric-lib.md index 4cccfa983..309672561 100644 --- a/docs/backport-26.1.2/chimeric-lib.md +++ b/docs/backport-26.1.2/chimeric-lib.md @@ -173,7 +173,17 @@ The port touched both, but the payload guts them (−82 lines each) down to thin payload version wholesale**, then compile — any 26.2 symbol that survives will surface immediately and is covered by §5's reverse map. -### `testkit/BootstrapMinecraft.java` — **new file, requires a deletion** +### `testkit/BootstrapMinecraft.java` — ⚠ **the instruction below was wrong; keep the bake** + +> **Outcome when this was executed:** 26.1.2 binds data components lazily exactly like 26.2, and +> `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS` exists there with an identical shape. Removing the +> bake made 8 of 43 unit tests fail with `NullPointerException: Components not bound yet`. The file +> was restored to `main`'s version (only the published-coordinate example in its javadoc was fixed: +> `chimericlib-common:-`, not `chimericlib-common-:`). The section below is +> kept for the record — **do not follow it**. See [README.md](README.md) §5's correction. + +
+Superseded instruction (do not follow) The `main` version bakes data components, which is a **26.2-only** requirement: @@ -198,6 +208,27 @@ If `:chimeric-lib:fabric:test` then fails with *"Components not bound yet"*, tha shares the lazy-binding behavior after all — in that case restore the bake and find the 26.1.2 equivalent. Expected outcome: it passes without it. +
+ +### `fixture/TestFixtures.java` — an adaptation the plan did not anticipate + +`new BlockEntityType<>(factory, blocks)` compiles on 26.2 but **not on 26.1.2**, where that constructor +is `private`. Every mod in this repo that calls it directly (minekea, shulker-stuff, hopper-xtreme) +widens it in its own access widener — and minekea's carries the comment *"This should be in +chimericlib, but I need to figure out how to get transitive accesswideners working"*. chimeric-lib has +**no** access widener enabled (commented out in `common/build.gradle` and `fabric/build.gradle`, on +both branches). + +Rather than give chimeric-lib a shipping access widener purely to serve a test fixture, the gametest +uses fabric-api's public builder — the `gametest` source set is fabric-only and never ships: + +```java +FabricBlockEntityTypeBuilder.create(TestContainerBlockEntity::new, TEST_CONTAINER_BLOCK.get()).build() +``` + +Verified present in the fabric-api resolved for 26.1.2. 26.1.2 has no public `BlockEntityType.Builder` +and no public constructor, so this is the only widener-free path. + --- ## 4. Procedure diff --git a/docs/backport-26.1.2/minekea.md b/docs/backport-26.1.2/minekea.md index d4535cfd4..725ee3547 100644 --- a/docs/backport-26.1.2/minekea.md +++ b/docs/backport-26.1.2/minekea.md @@ -184,11 +184,23 @@ Eight files were touched by both the 26.2 port and the payload. | `entity/block/containers/GlassJarBlockEntity.java` | **one line**: `EntityType.byString(id)` → `Optional.ofNullable(Identifier.tryParse(id)).flatMap(BuiltInRegistries.ENTITY_TYPE::getOptional)` | Reverse it back to `EntityType.byString(id)`. Everything else in this file is payload. `getStringOr`/`getIntOr` **exist on 26.1.2** — verified, this file already uses them there. | | `client/render/block/GlassJarBlockEntityRenderer.java` | renames only | Take payload (+3 lines). | | `common/src/main/resources/minekea.accesswidener` | added a `TextureSlot create` entry | Payload edits a *different* region (ArmorStand → `equipmentAssets`). Apply the payload hunk; **do not** add the `TextureSlot` entry — it is 26.2-only and `TextureSlot.create` is public on 26.1.2. | -| `fabric/.../data/ModDataGenerator.java` | `this::valueLookupBuilder` → `this::builder` (×3) | ⚠ **Keep 26.1.2's `valueLookupBuilder`.** And the payload's only change to this file is the 8-line `DATA_COMPONENT_INITIALIZERS` bind at the top of `buildRecipes()` — that is a **26.2-only workaround**. `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS` does not exist on 26.1.2. **Drop the entire hunk, including the `BuiltInRegistries` import.** | +| `fabric/.../data/ModDataGenerator.java` | `this::valueLookupBuilder` → `this::builder` (×3) | ⚠ **Keep 26.1.2's `valueLookupBuilder`.** The payload's only change here is the `DATA_COMPONENT_INITIALIZERS` bind at the top of `buildRecipes()` — **keep it**, see the correction below. | Also expect `TagAppender` → `TagAppender` and `.add(X.builtInRegistryHolder().key())` → `.add(X)` anywhere the payload touches datagen tag code. +> **⚠ Correction — keep the datagen component bind.** This file originally said to delete the +> `BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(registryLookup).forEach(pending -> pending.apply())` +> hunk as a 26.2-only workaround. That was wrong, and it was caught in Wave 1: 26.1.2 binds data +> components lazily too, and the API exists there with an identical shape (verified by `javap` on the +> 26.1.2 jar, and by 8 of chimeric-lib's 43 unit tests failing with "Components not bound yet" without +> the equivalent bake). **Apply the hunk as-is, including the `BuiltInRegistries` import.** +> [README.md](README.md) §5 has the full correction. +> +> Practical consequence: if `:minekea:fabric:runDatagen` throws +> `NullPointerException: Components not bound yet`, the bind is missing or is below something that +> reads components — hoist it. + --- ## 5. Regenerating the datagen output