diff --git a/README.md b/README.md index d08c130..6080138 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A free NeoForge 1.21.1 modpack on Cobblemon 1.8: one large island with soft-gated, Palworld/ARK-style progression, plus a companion mod for the mechanics no addon covers. The plan is in [docs/roadmap.md](docs/roadmap.md); decisions taken so far are in [docs/design.md](docs/design.md). -**Status:** Phases 0–4 approved (2026-10-31); Phase 5 (field moves and party passives) in progress. Checks per phase: `docs/checklists/`. +**Status:** Phases 0–5 approved (2026-10-31); Phase 6 (Pokémon machines) in progress. Checks per phase: `docs/checklists/`. ## What's in the pack diff --git a/datapacks/core/data/cobblesurvival/loot_table/blocks/altar_core.json b/datapacks/core/data/cobblesurvival/loot_table/blocks/altar_core.json new file mode 100644 index 0000000..dadf06b --- /dev/null +++ b/datapacks/core/data/cobblesurvival/loot_table/blocks/altar_core.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "cobblesurvival:altar_core" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "cobblesurvival:blocks/altar_core" +} diff --git a/datapacks/core/data/cobblesurvival/loot_table/blocks/dynamo_core.json b/datapacks/core/data/cobblesurvival/loot_table/blocks/dynamo_core.json new file mode 100644 index 0000000..d3bbbab --- /dev/null +++ b/datapacks/core/data/cobblesurvival/loot_table/blocks/dynamo_core.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "cobblesurvival:dynamo_core" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "cobblesurvival:blocks/dynamo_core" +} diff --git a/datapacks/core/data/cobblesurvival/loot_table/blocks/heat_core.json b/datapacks/core/data/cobblesurvival/loot_table/blocks/heat_core.json new file mode 100644 index 0000000..765bd54 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/loot_table/blocks/heat_core.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "cobblesurvival:heat_core" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "cobblesurvival:blocks/heat_core" +} diff --git a/datapacks/core/data/cobblesurvival/loot_table/blocks/rebirth_core.json b/datapacks/core/data/cobblesurvival/loot_table/blocks/rebirth_core.json new file mode 100644 index 0000000..01cf0bf --- /dev/null +++ b/datapacks/core/data/cobblesurvival/loot_table/blocks/rebirth_core.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "cobblesurvival:rebirth_core" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "cobblesurvival:blocks/rebirth_core" +} diff --git a/datapacks/core/data/cobblesurvival/loot_table/blocks/synthesis_core.json b/datapacks/core/data/cobblesurvival/loot_table/blocks/synthesis_core.json new file mode 100644 index 0000000..ed92182 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/loot_table/blocks/synthesis_core.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "cobblesurvival:synthesis_core" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "cobblesurvival:blocks/synthesis_core" +} diff --git a/datapacks/core/data/cobblesurvival/machines/altar_core.json b/datapacks/core/data/cobblesurvival/machines/altar_core.json new file mode 100644 index 0000000..82c886a --- /dev/null +++ b/datapacks/core/data/cobblesurvival/machines/altar_core.json @@ -0,0 +1,132 @@ +{ + "_comment": "Sacrifice Altar (Adrian, 2026-10-31): sneak-use the core to see what the Pokémon inside would give, sneak-use again within confirm_ticks to sacrifice it (gone for good). The highest tier its level reaches gives its rewards; specials add theirs. Rare candies only from Lv 80, above the highest natural level (Basalt Furnace Lv 70, guardians Lv 75). Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Placeholder rewards; see docs/balance.md. Read by the companion mod (machine/).", + "core": "cobblesurvival:altar_core", + "kind": "altar", + "min_level": 20, + "layers": [ + [ + "###", + "###", + "# #" + ], + [ + "#W#", + "W.W", + "#C#" + ], + [ + "###", + "#W#", + "###" + ] + ], + "keys": { + "#": "#cobblesurvival:altar_casing", + "W": [ + "#cobblesurvival:altar_casing", + "#c:glass_blocks" + ] + }, + "altar": { + "confirm_ticks": 100, + "tiers": [ + { + "min_level": 20, + "rewards": [ + { + "item": "cobblemon:exp_candy_s", + "count": 3 + } + ] + }, + { + "min_level": 35, + "rewards": [ + { + "item": "cobblemon:exp_candy_m", + "count": 3 + } + ] + }, + { + "min_level": 50, + "rewards": [ + { + "item": "cobblemon:exp_candy_l", + "count": 3 + } + ] + }, + { + "min_level": 65, + "rewards": [ + { + "item": "cobblemon:exp_candy_xl", + "count": 2 + }, + { + "item": "cobblemon:ability_capsule", + "count": 1 + } + ] + }, + { + "min_level": 80, + "rewards": [ + { + "item": "cobblemon:rare_candy", + "count": 3 + }, + { + "item": "cobblemon:exp_candy_xl", + "count": 2 + } + ] + }, + { + "min_level": 90, + "rewards": [ + { + "item": "cobblemon:rare_candy", + "count": 5 + }, + { + "item": "cobblemon:ability_patch", + "count": 1 + } + ] + }, + { + "min_level": 100, + "rewards": [ + { + "item": "cobblemon:rare_candy", + "count": 8 + }, + { + "item": "cobblemon:ability_patch", + "count": 1 + }, + { + "item": "minecraft:nether_star", + "count": 1 + } + ] + } + ], + "specials": [ + { + "types": [ + "dragon" + ], + "min_level": 100, + "rewards": [ + { + "item": "minecraft:dragon_egg", + "count": 1 + } + ] + } + ] + } +} diff --git a/datapacks/core/data/cobblesurvival/machines/dynamo_core.json b/datapacks/core/data/cobblesurvival/machines/dynamo_core.json new file mode 100644 index 0000000..8696022 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/machines/dynamo_core.json @@ -0,0 +1,38 @@ +{ + "_comment": "Generator (Adrian, 2026-10-31): an Electric Pokémon in the chamber makes Forge Energy, stored in the core and pushed to whatever touches the core and takes energy. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Output per tick: fe_per_level × level × (1 + per_special_attack × Special Attack), after Poke Power's 50 FE per level. Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).", + "core": "cobblesurvival:dynamo_core", + "kind": "generator", + "types": [ + "electric" + ], + "layers": [ + [ + "###", + "###", + "# #" + ], + [ + "#W#", + "W.W", + "#C#" + ], + [ + "###", + "#W#", + "###" + ] + ], + "keys": { + "#": "#cobblesurvival:dynamo_casing", + "W": [ + "#cobblesurvival:dynamo_casing", + "#c:glass_blocks" + ] + }, + "energy": { + "fe_per_level": 50, + "per_special_attack": 0.005, + "capacity": 200000, + "max_output": 16000 + } +} diff --git a/datapacks/core/data/cobblesurvival/machines/heat_core.json b/datapacks/core/data/cobblesurvival/machines/heat_core.json new file mode 100644 index 0000000..cd29c59 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/machines/heat_core.json @@ -0,0 +1,42 @@ +{ + "_comment": "Large Furnace (Adrian, 2026-10-31): a Fire Pokémon in the chamber heats vanilla furnaces, blast furnaces and smokers standing against the outside of the shell; they burn without fuel while they have something to cook, and cook faster. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Furnaces heated: 1 + level / levels_per_furnace, at most max. Cooking speed: base + per_level × level + per_special_attack × Special Attack. Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).", + "core": "cobblesurvival:heat_core", + "kind": "furnace", + "types": [ + "fire" + ], + "layers": [ + [ + "###", + "###", + "# #" + ], + [ + "#W#", + "W.W", + "#C#" + ], + [ + "###", + "#W#", + "###" + ] + ], + "keys": { + "#": "#cobblesurvival:heat_casing", + "W": [ + "#cobblesurvival:heat_casing", + "#c:glass_blocks" + ] + }, + "furnaces": { + "tag": "cobblesurvival:machine_furnaces", + "max": 8, + "levels_per_furnace": 15, + "speed": { + "base": 1.0, + "per_level": 0.04, + "per_special_attack": 0.01 + } + } +} diff --git a/datapacks/core/data/cobblesurvival/machines/rebirth_core.json b/datapacks/core/data/cobblesurvival/machines/rebirth_core.json new file mode 100644 index 0000000..5dc7ca9 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/machines/rebirth_core.json @@ -0,0 +1,40 @@ +{ + "_comment": "Rebirth Chamber (Adrian, 2026-10-31): a Lv 100 Pokémon put in is reborn after 'ticks' of server time (also while the chunk is unloaded): first stage of its line, Lv 1, shiny, perfect_ivs of its IVs raised to 31 (none lowered), moves, ability, nickname and original trainer kept. 'extra' goes to the owner with it: an Ability Patch, so the hidden ability is their choice. Taking it out early cancels. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).", + "core": "cobblesurvival:rebirth_core", + "kind": "rebirth", + "min_level": 100, + "layers": [ + [ + "###", + "###", + "# #" + ], + [ + "#W#", + "W.W", + "#C#" + ], + [ + "###", + "#W#", + "###" + ] + ], + "keys": { + "#": "#cobblesurvival:rebirth_casing", + "W": [ + "#cobblesurvival:rebirth_casing", + "#c:glass_blocks" + ] + }, + "rebirth": { + "ticks": 144000, + "perfect_ivs": 3, + "extra": [ + { + "item": "cobblemon:ability_patch", + "count": 1 + } + ] + } +} diff --git a/datapacks/core/data/cobblesurvival/machines/synthesis_core.json b/datapacks/core/data/cobblesurvival/machines/synthesis_core.json new file mode 100644 index 0000000..79728f3 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/machines/synthesis_core.json @@ -0,0 +1,42 @@ +{ + "_comment": "Drop Synthesizer (Adrian, 2026-10-31): any Pokémon; the core takes Forge Energy (up to max_input per tick, so more energy is faster) and for every fe_per_roll rolls the Pokémon's own drop table once, as if it were defeated. The shell's tier is the lowest of 'tiers' among its casing blocks (iron, gold, diamond, netherite = looting 0-3): each looting level adds 0-1 to every drop, and chance_bonus_per_looting × looting is the chance of a second roll. What it makes waits in the core (output_slots) for hoppers. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).", + "core": "cobblesurvival:synthesis_core", + "kind": "synthesis", + "layers": [ + [ + "###", + "###", + "# #" + ], + [ + "#W#", + "W.W", + "#C#" + ], + [ + "###", + "#W#", + "###" + ] + ], + "keys": { + "#": "#cobblesurvival:synthesis_casing", + "W": [ + "#cobblesurvival:synthesis_casing", + "#c:glass_blocks" + ] + }, + "synthesis": { + "fe_per_roll": 2000000, + "max_input": 32000, + "capacity": 4000000, + "output_slots": 9, + "tiers": [ + "cobblesurvival:synthesis_tier/iron", + "cobblesurvival:synthesis_tier/gold", + "cobblesurvival:synthesis_tier/diamond", + "cobblesurvival:synthesis_tier/netherite" + ], + "chance_bonus_per_looting": 0.25 + } +} diff --git a/datapacks/core/data/cobblesurvival/recipe/altar_core.json b/datapacks/core/data/cobblesurvival/recipe/altar_core.json new file mode 100644 index 0000000..f7979ad --- /dev/null +++ b/datapacks/core/data/cobblesurvival/recipe/altar_core.json @@ -0,0 +1,24 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "OGO", + "GEG", + "OGO" + ], + "key": { + "O": { + "item": "minecraft:obsidian" + }, + "G": { + "item": "minecraft:gold_block" + }, + "E": { + "item": "minecraft:echo_shard" + } + }, + "result": { + "id": "cobblesurvival:altar_core", + "count": 1 + } +} diff --git a/datapacks/core/data/cobblesurvival/recipe/dynamo_core.json b/datapacks/core/data/cobblesurvival/recipe/dynamo_core.json new file mode 100644 index 0000000..b32b045 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/recipe/dynamo_core.json @@ -0,0 +1,24 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "CRC", + "RTR", + "CRC" + ], + "key": { + "C": { + "item": "minecraft:copper_block" + }, + "R": { + "item": "minecraft:redstone" + }, + "T": { + "item": "cobblemon:thunder_stone" + } + }, + "result": { + "id": "cobblesurvival:dynamo_core", + "count": 1 + } +} \ No newline at end of file diff --git a/datapacks/core/data/cobblesurvival/recipe/heat_core.json b/datapacks/core/data/cobblesurvival/recipe/heat_core.json new file mode 100644 index 0000000..162c8fa --- /dev/null +++ b/datapacks/core/data/cobblesurvival/recipe/heat_core.json @@ -0,0 +1,24 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "BIB", + "IFI", + "BIB" + ], + "key": { + "B": { + "item": "minecraft:bricks" + }, + "I": { + "item": "minecraft:iron_ingot" + }, + "F": { + "item": "minecraft:blast_furnace" + } + }, + "result": { + "id": "cobblesurvival:heat_core", + "count": 1 + } +} \ No newline at end of file diff --git a/datapacks/core/data/cobblesurvival/recipe/rebirth_core.json b/datapacks/core/data/cobblesurvival/recipe/rebirth_core.json new file mode 100644 index 0000000..2df6809 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/recipe/rebirth_core.json @@ -0,0 +1,24 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "ADA", + "DSD", + "ADA" + ], + "key": { + "A": { + "item": "minecraft:amethyst_block" + }, + "D": { + "item": "minecraft:diamond" + }, + "S": { + "item": "cobblemon:shiny_stone" + } + }, + "result": { + "id": "cobblesurvival:rebirth_core", + "count": 1 + } +} diff --git a/datapacks/core/data/cobblesurvival/recipe/synthesis_core.json b/datapacks/core/data/cobblesurvival/recipe/synthesis_core.json new file mode 100644 index 0000000..83e6bda --- /dev/null +++ b/datapacks/core/data/cobblesurvival/recipe/synthesis_core.json @@ -0,0 +1,27 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "IRI", + "HXH", + "IRI" + ], + "key": { + "I": { + "item": "minecraft:iron_block" + }, + "R": { + "item": "minecraft:redstone_block" + }, + "H": { + "item": "minecraft:hopper" + }, + "X": { + "item": "minecraft:crafter" + } + }, + "result": { + "id": "cobblesurvival:synthesis_core", + "count": 1 + } +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/altar_casing.json b/datapacks/core/data/cobblesurvival/tags/block/altar_casing.json new file mode 100644 index 0000000..056fa1e --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/altar_casing.json @@ -0,0 +1,8 @@ +{ + "values": [ + "minecraft:obsidian", + "minecraft:crying_obsidian", + "minecraft:blackstone", + "minecraft:polished_blackstone_bricks" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/dynamo_casing.json b/datapacks/core/data/cobblesurvival/tags/block/dynamo_casing.json new file mode 100644 index 0000000..7852946 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/dynamo_casing.json @@ -0,0 +1,20 @@ +{ + "values": [ + "minecraft:copper_block", + "minecraft:cut_copper", + "minecraft:exposed_copper", + "minecraft:exposed_cut_copper", + "minecraft:weathered_copper", + "minecraft:weathered_cut_copper", + "minecraft:oxidized_copper", + "minecraft:oxidized_cut_copper", + "minecraft:waxed_copper_block", + "minecraft:waxed_cut_copper", + "minecraft:waxed_exposed_copper", + "minecraft:waxed_exposed_cut_copper", + "minecraft:waxed_weathered_copper", + "minecraft:waxed_weathered_cut_copper", + "minecraft:waxed_oxidized_copper", + "minecraft:waxed_oxidized_cut_copper" + ] +} \ No newline at end of file diff --git a/datapacks/core/data/cobblesurvival/tags/block/heat_casing.json b/datapacks/core/data/cobblesurvival/tags/block/heat_casing.json new file mode 100644 index 0000000..10389aa --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/heat_casing.json @@ -0,0 +1 @@ +{"values": ["minecraft:bricks", "minecraft:mud_bricks", "minecraft:deepslate_bricks"]} diff --git a/datapacks/core/data/cobblesurvival/tags/block/machine_furnaces.json b/datapacks/core/data/cobblesurvival/tags/block/machine_furnaces.json new file mode 100644 index 0000000..7e84b71 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/machine_furnaces.json @@ -0,0 +1,7 @@ +{ + "values": [ + "minecraft:furnace", + "minecraft:blast_furnace", + "minecraft:smoker" + ] +} \ No newline at end of file diff --git a/datapacks/core/data/cobblesurvival/tags/block/rebirth_casing.json b/datapacks/core/data/cobblesurvival/tags/block/rebirth_casing.json new file mode 100644 index 0000000..daa94f1 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/rebirth_casing.json @@ -0,0 +1,7 @@ +{ + "values": [ + "minecraft:amethyst_block", + "minecraft:quartz_block", + "minecraft:calcite" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/synthesis_casing.json b/datapacks/core/data/cobblesurvival/tags/block/synthesis_casing.json new file mode 100644 index 0000000..3697f26 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/synthesis_casing.json @@ -0,0 +1,8 @@ +{ + "values": [ + "#cobblesurvival:synthesis_tier/iron", + "#cobblesurvival:synthesis_tier/gold", + "#cobblesurvival:synthesis_tier/diamond", + "#cobblesurvival:synthesis_tier/netherite" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/diamond.json b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/diamond.json new file mode 100644 index 0000000..bf4198f --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/diamond.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:diamond_block" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/gold.json b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/gold.json new file mode 100644 index 0000000..6dd80de --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/gold.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:gold_block" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/iron.json b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/iron.json new file mode 100644 index 0000000..3c5a5c7 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/iron.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:iron_block" + ] +} diff --git a/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/netherite.json b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/netherite.json new file mode 100644 index 0000000..8257904 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/tags/block/synthesis_tier/netherite.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:netherite_block" + ] +} diff --git a/datapacks/core/data/minecraft/tags/block/mineable/pickaxe.json b/datapacks/core/data/minecraft/tags/block/mineable/pickaxe.json new file mode 100644 index 0000000..0528923 --- /dev/null +++ b/datapacks/core/data/minecraft/tags/block/mineable/pickaxe.json @@ -0,0 +1,10 @@ +{ + "replace": false, + "values": [ + "cobblesurvival:heat_core", + "cobblesurvival:dynamo_core", + "cobblesurvival:synthesis_core", + "cobblesurvival:altar_core", + "cobblesurvival:rebirth_core" + ] +} diff --git a/docs/balance.md b/docs/balance.md index 0cc35fd..6f626fe 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -29,6 +29,13 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue | Mire poison | Poison I for 7 s, renewed every 5 s while in the Mire without Mire Waders | Decided (Adrian, 2026-10-30) | `datapacks/core/data/cobblesurvival/zone_hazards/mire.json` | Vanilla Poison stops at half a heart | | Field moves (Cobblemon HMs) | mod defaults: all 39 abilities on, no level minimum, no fullness or friendship cost; Rock Smash breaks up to 32 connected ore blocks with no cooldown | Placeholder | `config/cobblemon_hms.json` (generated by the mod; the pack ships none yet) | Rock Smash mines any ore, whatever pickaxe the player has. HMs' `minLevel` is the player's XP level, not the Pokémon's | | Breeding (Cobbreeding) | mod defaults: an egg every 8,000–14,000 ticks per breeding pasture, hatch multiplier 1.0 (about 10 min for 20 egg cycles), Masuda shiny boost ×4, no random eggs from two Dittos, no gate | Placeholder | `config/cobbreeding/main.json` (written by the mod; the pack ships none) | Adrian, 2026-10-31: breeding without a gate | +| Machine shells | 3×3×3, core in a wall, chamber in the centre; casing: Heat bricks/mud bricks/deepslate bricks; Dynamo copper blocks; Synthesis iron/gold/diamond/netherite blocks; Altar obsidian/crying obsidian/blackstone/polished blackstone bricks; Rebirth amethyst/quartz/calcite; glass allowed at the window spots | Placeholder | `datapacks/core/data/cobblesurvival/machines/*.json`, `tags/block/*_casing.json` | Adrian, 2026-10-31 (Phase 6) | +| Core recipes | Heat: 4 bricks, 4 iron ingots, blast furnace; Dynamo: 4 copper blocks, 4 redstone, thunder stone; Synthesis: 4 iron blocks, 2 redstone blocks, 2 hoppers, crafter; Altar: 4 obsidian, 4 gold blocks, echo shard; Rebirth: 4 amethyst blocks, 4 diamonds, shiny stone | Placeholder | `datapacks/core/data/cobblesurvival/recipe/*_core.json` | | +| Large Furnace | furnaces heated: 1 + level/15 (max 8); cooking speed 1 + 0.04 × level + 0.01 × Special Attack (e.g. Lv 50, SpA 70: 3.7×); no fuel | Placeholder | `machines/heat_core.json` | | +| Generator | 50 FE/t per level × (1 + 0.005 × Special Attack) (Lv 30, SpA 50: 1,875 FE/t); stores 200,000 FE, pushes up to 16,000 FE/t | Placeholder | `machines/dynamo_core.json` | After Poke Power's 50 FE per level | +| Drop Synthesizer | 2,000,000 FE per drop roll, takes up to 32,000 FE/t (one roll a minute at most), stores 4,000,000 FE; looting by casing tier: iron 0, gold I, diamond II, netherite III (+0–1 per drop per level; 25 % per level for a second roll); 9 output stacks | Placeholder | `machines/synthesis_core.json` | | +| Sacrifice Altar | from Lv 20: 3 Exp. Candy S; 35: 3 M; 50: 3 L; 65: 2 XL + Ability Capsule; 80: 3 rare candies + 2 XL; 90: 5 rare candies + Ability Patch; 100: 8 rare candies + Ability Patch + nether star; Lv 100 Dragon type: + dragon egg; confirm within 5 s | Placeholder | `machines/altar_core.json` | Rare candies above the highest natural level (Lv 75 guardians) | +| Rebirth Chamber | 144,000 ticks (2 hours of server time); 3 IVs raised to 31; an Ability Patch comes with it | Placeholder | `machines/rebirth_core.json` | | | Level pressure | full experience up to 5 levels above the zone's band, then 10 % less per level, at least 10 % (Exp. Candy and commands exempt) | Decided (Adrian, 2026-10-30) | `world/zones.json` → `level_pressure` | E.g. on Button Isle (up to Lv 10): Lv 15 full, Lv 20 half, Lv 24+ a tenth | | Strong flyer | ridable through the air with top speed ≥ 60 or top stamina ≥ 85 (Cobblemon ride stats) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`STRONG_FLYER_*`) | 29 species | | High zone for strong flyers | band starts at Lv 30 or more (Sunscorch, Frostpeak, Deep Sea, Deep Dark, Basalt Furnace) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`HIGH_ZONE_LEVEL`) | | diff --git a/docs/checklists/phase-5.md b/docs/checklists/phase-5.md index a2723b5..79b1bd6 100644 --- a/docs/checklists/phase-5.md +++ b/docs/checklists/phase-5.md @@ -39,6 +39,8 @@ ## Ask Adrian +**Phase 5 approved 2026-10-31** ("continue with the next phase"). Still open, with the default kept meanwhile: Mining keeps relying on an Item Gatherer; Dive, Mist and Rock Climb stay gated; the gate levels and materials stay placeholders. + - ~~Hide gated jobs in Cobblebase's Skills tab?~~ **Answered 2026-10-31:** leave as is; taking the job back with a message is enough. - Mining should deliver straight into the chest instead of relying on an Item Gatherer? - The gate levels, materials and which aura sits behind which zone are placeholders (`docs/balance.md`). Dive, Mist and Rock Climb were added to your four because they get around hazards the same way; say if any should stay open. diff --git a/docs/checklists/phase-6.md b/docs/checklists/phase-6.md new file mode 100644 index 0000000..64bd38c --- /dev/null +++ b/docs/checklists/phase-6.md @@ -0,0 +1,40 @@ +# Phase 6 – checks for Adrian + +## Decided + +**2026-10-31:** the Large Furnace has a Fire Pokémon heat real furnaces (hoppers must work in and out); an Electric generator is built too; machine work costs the Pokémon nothing. Three more machines: a drop synthesizer (energy into the Pokémon's drops; full drop roll; looting tiers), a sacrifice altar (high-level Pokémon for hard-to-get items; rare candies only above the highest natural level; lower tiers too) and a rebirth chamber (Lv 100 into a shiny Lv 1, first stage, three perfect IVs with none lowered, moves kept, hidden ability left to the player). Details: `docs/design.md` → "Phase 6: Pokémon machines". + +## Built so far + +Five machines, each a 3×3×3 shell around a chamber, with its core in the middle of one wall (recipes and numbers in `docs/balance.md`): + +| Machine | Core | Casing | Takes | +| --- | --- | --- | --- | +| Large Furnace | Heat Core | bricks, mud bricks or deepslate bricks | Fire types | +| Generator | Dynamo Core | copper blocks | Electric types | +| Drop Synthesizer | Synthesis Core | iron, gold, diamond or netherite blocks (tier = looting) | any Pokémon | +| Sacrifice Altar | Altar Core | obsidian, crying obsidian, blackstone, polished blackstone bricks | Lv 20+ | +| Rebirth Chamber | Rebirth Core | amethyst, quartz or calcite blocks | Lv 100 | + +The middle of each side wall and the top centre may be glass, to see the Pokémon; the block under the core is free for a hopper. + +- Send the Pokémon out near the core and use the core with an empty hand: it walks in and leaves the party. Use the core again to take it back. Sneak-use shows what the machine is doing (for the altar: what a sacrifice gives). +- Breaking the core or the shell sends it home (party, else PC), even if you're offline. Automated: `machine_pokemon_goes_in_and_out`, `machine_refuses_what_doesnt_fit`, `machine_shell_break_returns_pokemon`, `machine_core_break_returns_pokemon_to_pc`, `machine_survives_unload`, `machine_survives_restart`. +- Each machine's work: `heat_core_cooks_with_hoppers`, `dynamo_core_powers_cables`, `synthesis_core_rolls_drops`, `altar_core_sacrifice_needs_confirmation`, `rebirth_core_makes_a_shiny_first_stage`. + +## In game (Prism instance) + +Creative mode gives the cores in the Functional Blocks tab; each has a recipe for survival. + +- [ ] Build a Heat Core shell of bricks with glass in a side window, send out a Fire type and use the core: the Pokémon stands in the chamber, small enough to fit, and doesn't move. **Does it look right?** (the roadmap asks you to confirm this) +- [ ] Put a furnace against the outside of that shell, a hopper with raw iron above it and a hopper with a chest below: the iron cooks without fuel, faster with a stronger Pokémon, and ends in the chest. +- [ ] Build a Dynamo Core shell of copper with an Electric type, lay Poke Power cables from the core: sneak-use shows the energy made. +- [ ] Put a Synthesis Core shell of gold blocks next to it (cables between), with a Pokémon inside and a hopper and chest under its core: its drops arrive in the chest. Swap the shell for diamond blocks: sneak-use shows looting 2. +- [ ] Put a Lv 20+ Pokémon in an Altar Core shell: sneak-use shows the reward, sneak-use again within 5 seconds sacrifices it. +- [ ] Put a Lv 100 Pokémon in a Rebirth Core shell: sneak-use shows the time left (two hours). To check without waiting: `/data merge block {rebirth_at:1L}` then wait a second, take it out: a shiny Lv 1 first stage and an Ability Patch. +- [ ] Break a casing block while a Pokémon is inside: it comes back to your party with a message. + +## Ask Adrian + +- Does the Pokémon in the chamber look right (size, place, facing out through the core's side)? +- All numbers, casings and recipes are placeholders (`docs/balance.md`). diff --git a/docs/design.md b/docs/design.md index 21fe585..1dcda6c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -228,6 +228,39 @@ Adrian asked for existing mods for the multiblocks, or at least their logic. Pha For the Pokémon inside, the best prior art is already in the pack: Poke Power's Power Block (MIT, [source](https://github.com/Matthiesen-dev/poke-power)) stores Pokémon with their owner, returns them on break, saves and syncs them, and renders the model in the block. Study it (and Cobblemon's Pasture Block, as the roadmap says); don't depend on it. +## Phase 6: Pokémon machines (Adrian, 2026-10-31) + +Adrian's choices: the Large Furnace has a Fire Pokémon heat real furnaces, and hoppers must work in and out; an Electric generator making Forge Energy is built too (alongside Poke Power's Power Block); machine work costs the Pokémon nothing. Then three more (Adrian, 2026-10-31): a drop synthesizer that turns a lot of energy into what the Pokémon inside would drop, faster with more energy, with a full drop roll and tiers that add looting; a sacrifice altar that trades a high-level Pokémon for hard-to-get items, rare candies only above the highest natural level, lower tiers too; and a rebirth chamber that turns a Lv 100 Pokémon into a shiny Lv 1 after a long time. + +**Shape.** Every machine is a 3×3×3 shell with the machine's core block in the middle of one wall and air in the centre, where the Pokémon stands. The block under the core is free (a hopper can go there), and the middle of each side wall and the top centre are window spots that may be glass instead of casing, to see the Pokémon. The shell is data (`datapacks/core/data/cobblesurvival/machines/.json`: layers bottom to top, a key per character listing the blocks allowed, the core and the chamber marked), checked in all four horizontal rotations around the core every second. Casing is a block tag per machine, so the casing is the materials gate: + +| Machine | Core | Casing | Pokémon | +| --- | --- | --- | --- | +| Large Furnace | Heat Core | `#cobblesurvival:heat_casing`: bricks, mud bricks, deepslate bricks | any Fire type | +| Generator | Dynamo Core | `#cobblesurvival:dynamo_casing`: copper blocks (any weathering, waxed or not, cut or not) | any Electric type | +| Drop Synthesizer | Synthesis Core | `#cobblesurvival:synthesis_casing`: iron, gold, diamond or netherite blocks; the lowest metal present sets the tier | any Pokémon | +| Sacrifice Altar | Altar Core | `#cobblesurvival:altar_casing`: obsidian, crying obsidian, blackstone, polished blackstone bricks | Lv 20 or more | +| Rebirth Chamber | Rebirth Core | `#cobblesurvival:rebirth_casing`: amethyst, quartz and calcite blocks | Lv 100 | + + +**A Pokémon goes in** when its owner sends it out near the core and uses the core with an empty hand: the nearest of their sent-out Pokémon that fits walks in (it leaves the party and its data is kept in the core). Using the core again takes it out, back to the party or else the PC. Sneak-use shows the machine's state. Breaking the core or any part of the shell sends the Pokémon back to its owner, online or not. The core is the only place the Pokémon exists while inside, so nothing can be duplicated by breaking and inserting at once. + +**What's shown** is a real Cobblemon Pokémon entity, a copy, standing in the chamber: scaled to fit, no AI, invulnerable, silent, uncatchable, and it can't be battled or used. It's never kept in the world save: one loaded from disk is dropped, and the core puts a fresh one there. + +**Large Furnace.** Furnaces, blast furnaces and smokers (`#cobblesurvival:machine_furnaces`) standing against the outside of the shell burn without fuel while they have something to cook, and cook faster. They stay ordinary vanilla blocks, so their screens, recipes and hoppers work as always (input from above, output from below). How many are heated and how fast depend on the Pokémon's level and Special Attack (numbers in the data file and `docs/balance.md`). + +**Drop Synthesizer.** Takes Forge Energy into the Synthesis Core (from a Dynamo Core or cables) and, for every so much energy, rolls the Pokémon's own drop table once, as if it had been defeated. The shell's tier adds looting: iron none, gold I, diamond II, netherite III (more of each drop, rare drops likelier). It takes energy up to a cap per tick, so more energy makes it faster. What it makes waits in the core (nine stacks; it stops when full) for a hopper under the core, or pipes from other mods on any side; breaking the core drops it. + +**Sacrifice Altar.** Sneak-use the Altar Core with the Pokémon inside to see what it would give, sneak-use again within five seconds to sacrifice it: the Pokémon is gone for good and the rewards go to the owner. The highest level tier reached decides the reward: Exp. Candies from Lv 20, Ability Capsules from Lv 65, rare candies only from Lv 80 (above the highest natural level: Basalt Furnace Lv 70, guardians Lv 75), an Ability Patch from Lv 90, a nether star at Lv 100; a Lv 100 Dragon type also gives a dragon egg. + +**Rebirth Chamber.** A Lv 100 Pokémon put in starts a long rebirth (placeholder: two hours of server time, also while the chunk is unloaded). Then it is the first stage of its line at Lv 1 and shiny, keeps its nickname, original trainer, held item and every move it learned, gets three of its IVs set to 31 (no IV ever goes down), and keeps its ability; an Ability Patch comes with it, so taking the hidden ability is the player's choice. Taking it out before the end cancels the rebirth. + +**Cores** are crafted (soft gates by material): Heat Core from bricks, iron and a blast furnace; Dynamo Core from copper blocks, redstone and a thunder stone; Synthesis Core from iron and redstone blocks, hoppers and a crafter; Altar Core from obsidian, gold blocks and an echo shard; Rebirth Core from amethyst blocks, diamonds and a shiny stone. Their textures are drawn by `tools/machine_assets.py`. + +Self-tests (`MachineTests`): in and out, wrong Pokémon refused, broken shell and broken core (to the PC when the party is full), chunk unload, server restart, and each machine's work (a furnace fed and emptied by hoppers without fuel, a Poke Power cable charged by a Dynamo Core, Magikarp's salmon through a hopper under a gold Synthesis Core, a confirmed sacrifice with a Lv 100 Dragonite's rewards, a rebirth from Charizard to a shiny Lv 1 Charmander with its moves and ability). + +**Generator.** The Dynamo Core stores Forge Energy and pushes it to whatever touches the core and takes energy (cables, machines from other mods); its output per tick depends on the Pokémon's level and Special Attack, in line with Poke Power's 50 FE per level. + ## Open decisions for Adrian (proceeding with the default, flagged) | Decision | Default used for now | diff --git a/docs/licenses.md b/docs/licenses.md index db1863a..59b7dad 100644 --- a/docs/licenses.md +++ b/docs/licenses.md @@ -67,7 +67,7 @@ Licenses of mods planned for later phases are in `docs/addon-status.md` and move | Part | License | | --- | --- | -| Companion mod (`mod/`) | MIT **(placeholder, see open decisions in `docs/design.md`)** | +| Companion mod (`mod/`), including the machine cores' textures drawn by `tools/machine_assets.py` | MIT **(placeholder, see open decisions in `docs/design.md`)** | | Datapack, KubeJS scripts, configs, quests, tools | not chosen yet (same open decision) | Build tooling, not shipped in the pack: the companion mod's Gradle setup follows Cobblemon's official [NeoForge Kotlin MDK](https://gitlab.com/cable-mc/cobblemon-mdks); Gradle wrapper (Apache-2.0); [packwiz](https://github.com/packwiz/packwiz) and [packwiz-installer](https://github.com/packwiz/packwiz-installer) (MIT); [Chunky](https://modrinth.com/mod/chunky) by pop4959 (GPL-3.0), used only to pregenerate the world template. diff --git a/docs/pokemon-drops.md b/docs/pokemon-drops.md index 1e6778a..a838d68 100644 --- a/docs/pokemon-drops.md +++ b/docs/pokemon-drops.md @@ -55,7 +55,7 @@ Generated by `tools/drop_audit.py` (roadmap Phase 4, "unique drops"): every item | Dirt (`minecraft:dirt`) | Dugtrio (basalt, ×0-2); Excadrill (basalt, ×0-2) | Break Dirt; Break Farmland; Break Grass Block; Break Mycelium; Break Podzol | Coarse Dirt, Loamy Mulch, Mulch, Peat Block | | Dragon's Breath (`minecraft:dragon_breath`) | Drampa (sunscorch, 5%); Dratini (westmarch, ×0-1); Zweilous (deep_dark, ×0-2) | – | brewing: lingering potions, Ability Capsule | | Dusk Stone (`cobblemon:dusk_stone`) | – | Brush suspicious blocks in Rooted Gimmi Tower (cobblemon); Crafting (shapeless); Loot in Crumbling Arch (worldgen feature); Loot in Decaying Crypt Ruins (cobblemon), Deep Crypt (worldgen feature), Deserted Gimmi Tower (cobblemon), Frozen Gimmi Tower (cobblemon), Lush Gimmi Tower (cobblemon), Reclaimed Deserted Monument (cobblemon) and 5 more; Loot in Decaying Crypt Ruins (cobblemon), Rooted Gimmi Tower (cobblemon) … | Dusk Stone Block | -| Echo Shard (`minecraft:echo_shard`) | – | Loot in Ancient City | Beast Ball, Recovery Compass | +| Echo Shard (`minecraft:echo_shard`) | – | Loot in Ancient City | Altar Core, Beast Ball, Recovery Compass | | Egg (`minecraft:egg`) | Chansey (southwold, 50%); Exeggcute (jungle, ×0-3); Exeggutor (jungle, ×0-3) | Loot in Trial Chambers | Cake, Open-Faced Sandwich, Oran Lucky Egg, Pumpkin Pie | | Electirizer (`cobblemon:electirizer`) | Electabuzz (frostpeak, 5%); Electivire (frostpeak, 10%) | Break Electirizer; Crafting | Power Block | | Electric Seed (`cobblemon:electric_seed`) | Togedemaru (frostpeak, 5%) | Break Electric Seed | Miracle Seed | @@ -153,7 +153,7 @@ Generated by `tools/drop_audit.py` (roadmap Phase 4, "unique drops"): every item | Roseli Berry (`cobblemon:roseli_berry`) | Absol (basalt, 5%); Jangmo-o (jungle, 2.5%); Morpeko (button_isle, 5%); Nickit (westmarch, 2.5%); Sableye (button_isle, 5%); Scrafty (sunscorch, 5%); Scraggy (sunscorch, 2.5%); Thievul (westmarch, 5%); Zoroark (highlands, 5%); Zweilous (deep_dark, 5%) | Break Roseli Berry; Wild berry trees in Cherry Grove | Berry Juice, Candied Berries, Red Dye | | Rotten Flesh (`minecraft:rotten_flesh`) | Electrike (highlands, ×0-1); Houndoom (basalt, ×0-2); Houndstone (deep_dark, ×0-2); Joltik (lush_caves, ×0-1); Liepard (basalt, ×0-2); Lycanroc (frostpeak, ×0-2); Mabosstiff (deep_dark, ×0-2); Manectric (highlands, ×0-2); Mightyena (highlands, ×0-2); Rattata (button_isle, ×0-1); Salandit (frostpeak, ×0-1); Spinarak (button_isle, ×0-1) | Fishing; Fishing with a Poké Rod; Loot in Desert Pyramid; Loot in Jungle Pyramid; Loot in Monster Room (worldgen feature) … | Mulch | | Sand (`minecraft:sand`) | Hippopotas (sunscorch, ×0-1); Hippowdon (sunscorch, ×0-2); Sandygast (shallows, ×0-1) | Break Sand; Loot in Desert Pyramid; Use c:tools/brush on your Hippopotas; Use c:tools/brush on your Hippowdon; Use c:tools/brush on your Palossand … | Black Concrete Powder, Blue Concrete Powder, Brown Concrete Powder, Cyan Concrete Powder, Glass, Gray Concrete Powder, Green Concrete Powder, Light Blue Concrete Powder … | -| Shiny Stone (`cobblemon:shiny_stone`) | Togekiss (frostpeak, 10%); Togetic (frostpeak, 5%) | Brush suspicious blocks in Deserted Gimmi Tower (cobblemon); Brush suspicious blocks in Deserted Gimmi Tower (cobblemon), Rooted Gimmi Tower (cobblemon), Temperate Gimmi Tower (cobblemon); Crafting (shapeless); Loot in Crumbling Arch (worldgen feature); Loot in Decaying Crypt Ruins (cobblemon), Deep Crypt (worldgen feature), Deserted Gimmi Tower (cobblemon), Frozen Gimmi Tower (cobblemon), Lush Gimmi Tower (cobblemon), Reclaimed Deserted Monument (cobblemon) and 5 more … | Shiny Stone Block | +| Shiny Stone (`cobblemon:shiny_stone`) | Togekiss (frostpeak, 10%); Togetic (frostpeak, 5%) | Brush suspicious blocks in Deserted Gimmi Tower (cobblemon); Brush suspicious blocks in Deserted Gimmi Tower (cobblemon), Rooted Gimmi Tower (cobblemon), Temperate Gimmi Tower (cobblemon); Crafting (shapeless); Loot in Crumbling Arch (worldgen feature); Loot in Decaying Crypt Ruins (cobblemon), Deep Crypt (worldgen feature), Deserted Gimmi Tower (cobblemon), Frozen Gimmi Tower (cobblemon), Lush Gimmi Tower (cobblemon), Reclaimed Deserted Monument (cobblemon) and 5 more … | Rebirth Core, Shiny Stone Block | | Shuca Berry (`cobblemon:shuca_berry`) | Glimmora (deep_dark, 5%); Luxio (sunscorch, 5%); Luxray (sunscorch, 10%); Magnemite (southwold, 2.5%); Orthworm (dripstone, 5%); Ponyta (highlands, 2.5%); Revavroom (deep_dark, 5%); Salandit (frostpeak, 2.5%); Togedemaru (frostpeak, 5%); Toxel (southwold, 2.5%) | Break Shuca Berry; Wild berry trees in Badlands, Desert, Plains | Berry Juice, Brown Dye, Candied Berries | | Shulker Shell (`minecraft:shulker_shell`) | Cloyster (deep_sea, 10%); Shuckle (lush_caves, 10%) | – | Master Ball, Shulker Box | | Silk Scarf (`cobblemon:silk_scarf`) | Eevee (southwold, 5%); Furfrou (southwold, 5%); Furret (southwold, 5%); Minccino (southwold, 2.5%); Sentret (southwold, 2.5%); Skitty (button_isle, 2.5%); Trubbish (lush_caves, 2.5%) | Crafting | Choice Scarf | @@ -171,7 +171,7 @@ Generated by `tools/drop_audit.py` (roadmap Phase 4, "unique drops"): every item | Sweet Berries (`minecraft:sweet_berries`) | Abomasnow (frostpeak, ×2-4); Cherrim (frostpeak, ×2-4); Grotle (highlands, ×2-4); Nickit (westmarch, ×1-3); Ninetales (highlands, ×2-4); Teddiursa (westmarch, ×1-3); Thievul (westmarch, ×2-4); Vulpix (highlands, ×1-3); Zoroark (highlands, ×2-4); Zorua (highlands, ×1-3) | Use c:fertilizers on your Grotle | Fine Remedy, Jubilife Muffin, Superb Remedy, Whipped Dream | | Tanga Berry (`cobblemon:tanga_berry`) | Cacnea (sunscorch, 2.5%); Cacturne (sunscorch, 5%); Espathra (sunscorch, 5%); Exeggcute (jungle, 2.5%); Exeggutor (jungle, 5%); Fomantis (jungle, 2.5%); Grumpig (frostpeak, 5%); Inkay (shallows, 2.5%); Krokorok (sunscorch, 5%); Krookodile (sunscorch, 10%); Mabosstiff (deep_dark, 5%); Nuzleaf (westmarch, 5%); Seedot (westmarch, 2.5%) | Break Tanga Berry; Wild berry trees in Cherry Grove, Forest, Jungle, Mangrove Swamp | Berry Juice, Candied Berries, Green Dye | | Terracotta (`minecraft:terracotta`) | Crustle (sunscorch, ×0-2); Dwebble (sunscorch, ×0-1) | Break Terracotta; Smelting | Black Terracotta, Blue Terracotta, Brown Terracotta, Cyan Terracotta, Gray Terracotta, Green Terracotta, Light Blue Terracotta, Light Gray Terracotta … | -| Thunder Stone (`cobblemon:thunder_stone`) | Pichu (westmarch, 2.5%); Pikachu (westmarch, 5%) | Crafting (shapeless); Loot in Crumbling Arch (worldgen feature); Loot in Decaying Crypt Ruins (cobblemon), Deep Crypt (worldgen feature), Deserted Gimmi Tower (cobblemon), Frozen Gimmi Tower (cobblemon), Lush Gimmi Tower (cobblemon), Reclaimed Deserted Monument (cobblemon) and 5 more; Loot in Decaying Crypt Ruins (cobblemon), Rooted Gimmi Tower (cobblemon); Loot in Deep Crypt (worldgen feature), Lush Gimmi Tower (cobblemon) … | Light Ball, Metal Alloy, Thunder Stone Block | +| Thunder Stone (`cobblemon:thunder_stone`) | Pichu (westmarch, 2.5%); Pikachu (westmarch, 5%) | Crafting (shapeless); Loot in Crumbling Arch (worldgen feature); Loot in Decaying Crypt Ruins (cobblemon), Deep Crypt (worldgen feature), Deserted Gimmi Tower (cobblemon), Frozen Gimmi Tower (cobblemon), Lush Gimmi Tower (cobblemon), Reclaimed Deserted Monument (cobblemon) and 5 more; Loot in Decaying Crypt Ruins (cobblemon), Rooted Gimmi Tower (cobblemon); Loot in Deep Crypt (worldgen feature), Lush Gimmi Tower (cobblemon) … | Dynamo Core, Light Ball, Metal Alloy, Thunder Stone Block | | Turtle Scute (`minecraft:turtle_scute`) | Chewtle (highlands, ×0-1); Drednaw (highlands, ×0-2); Squirtle (highlands, ×0-1); Tirtouga (lush_caves, ×0-2); Turtonator (basalt, ×0-2); Wartortle (highlands, ×0-2) | – | Rocky Helmet, Safety Goggles, Turtle Shell | | Vines (`minecraft:vine`) | Tangela (jungle, ×0-1); Tangrowth (jungle, ×0-2) | Break Vines; Use c:fertilizers on your Tangela; Use c:fertilizers on your Tangrowth | Mossy Cobblestone, Mossy Stone Bricks | | Wacan Berry (`cobblemon:wacan_berry`) | Buizel (highlands, 2.5%); Corviknight (deep_dark, 10%); Cramorant (shallows, 5%); Ducklett (mire, 2.5%); Floatzel (highlands, 5%); Goldeen (southwold, 2.5%); Gyarados (westmarch, 5%); Magikarp (westmarch, 2.5%); Mantine (deep_sea, 5%); Mantyke (deep_sea, 2.5%); Masquerain (mire, 5%); Surskit (mire, 2.5%); Swanna (mire, 5%); Wingull (shallows, 2.5%) | Break Wacan Berry; Wild berry trees in Cherry Grove, Plains, Windswept Hills | Berry Juice, Candied Berries, Yellow Dye | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 4154780..3d475b2 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -9,6 +9,7 @@ import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.ore.BaseLoot import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.permadeath.Permadeath +import dev.cobblesurvival.machine.Machines import dev.cobblesurvival.party.ShoulderPassives import dev.cobblesurvival.progress.Gates import dev.cobblesurvival.selftest.SelfTest @@ -61,6 +62,7 @@ class CobbleSurvival { LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION) MOD_BUS.addListener(::onAddPackFinders) InZoneCondition.register(MOD_BUS) + Machines.register(MOD_BUS) NeoForge.EVENT_BUS.addListener(::onRegisterCommands) NeoForge.EVENT_BUS.addListener(::onServerStarted) NeoForge.EVENT_BUS.addListener(::onPortalSpawn) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt new file mode 100644 index 0000000..e84ce2b --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt @@ -0,0 +1,67 @@ +package dev.cobblesurvival.machine + +import com.mojang.serialization.MapCodec +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.InteractionResult +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.context.BlockPlaceContext +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.BaseEntityBlock +import net.minecraft.world.level.block.Block +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.RenderShape +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.block.entity.BlockEntity +import net.minecraft.world.level.block.entity.BlockEntityTicker +import net.minecraft.world.level.block.entity.BlockEntityType +import net.minecraft.world.level.block.state.BlockState +import net.minecraft.world.level.block.state.StateDefinition +import net.minecraft.world.level.block.state.properties.BlockStateProperties +import net.minecraft.world.phys.BlockHitResult + +/** A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it. */ +class MachineBlock(properties: Properties) : BaseEntityBlock(properties) { + init { + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)) + } + + override fun codec(): MapCodec = CODEC + + override fun createBlockStateDefinition(builder: StateDefinition.Builder) { + builder.add(FACING) + } + + override fun getStateForPlacement(context: BlockPlaceContext): BlockState = + defaultBlockState().setValue(FACING, context.horizontalDirection.opposite) + + override fun rotate(state: BlockState, rotation: Rotation): BlockState = state.setValue(FACING, rotation.rotate(state.getValue(FACING))) + + override fun mirror(state: BlockState, mirror: Mirror): BlockState = state.rotate(mirror.getRotation(state.getValue(FACING))) + + override fun getRenderShape(state: BlockState) = RenderShape.MODEL + + override fun newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state) + + override fun getTicker(level: Level, state: BlockState, type: BlockEntityType): BlockEntityTicker? = + if (level.isClientSide) null + else createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, _, _, core -> core.serverTick(tickLevel as ServerLevel) }) + + override fun useWithoutItem(state: BlockState, level: Level, pos: BlockPos, player: Player, hitResult: BlockHitResult): InteractionResult { + if (level.isClientSide) return InteractionResult.SUCCESS + (level.getBlockEntity(pos) as? MachineBlockEntity)?.use(player as ServerPlayer) + return InteractionResult.CONSUME + } + + override fun onRemove(state: BlockState, level: Level, pos: BlockPos, newState: BlockState, movedByPiston: Boolean) { + if (!state.`is`(newState.block) && level is ServerLevel) (level.getBlockEntity(pos) as? MachineBlockEntity)?.onBroken(level) + super.onRemove(state, level, pos, newState, movedByPiston) + } + + companion object { + val FACING = BlockStateProperties.HORIZONTAL_FACING + val CODEC: MapCodec = simpleCodec(::MachineBlock) + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt new file mode 100644 index 0000000..8d47e5f --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt @@ -0,0 +1,279 @@ +package dev.cobblesurvival.machine + +import com.cobblemon.mod.common.Cobblemon +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import com.cobblemon.mod.common.pokemon.Pokemon +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.ChatFormatting +import net.minecraft.core.BlockPos +import net.minecraft.core.HolderLookup +import net.minecraft.core.RegistryAccess +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.nbt.CompoundTag +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.Containers +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.phys.AABB +import net.neoforged.neoforge.energy.IEnergyStorage +import net.neoforged.neoforge.items.IItemHandler +import net.neoforged.neoforge.items.ItemStackHandler +import java.util.UUID + +/** + * The core of a Pokémon machine (roadmap Phase 6). It keeps the Pokémon put in (its data lives only here while it's + * inside), checks the shell once a second, shows the Pokémon in the chamber ([MachineDisplay]) and runs the machine's + * work ([MachineWork]). Breaking the core or the shell sends the Pokémon back to its owner, party first, else PC. + */ +class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machines.BLOCK_ENTITY.get(), pos, state) { + var pokemon: Pokemon? = null + private set + var owner: UUID? = null + private set + private var ownerName = "" + /** Forge Energy: made by a generator, or taken in by a synthesizer. */ + var energy = 0 + internal set + internal var receivedThisTick = 0 + /** What a synthesizer made, waiting for hoppers or the owner. */ + val output = ItemStackHandler(OUTPUT_SLOTS) + /** Game time a rebirth finishes; 0 when none is running. */ + var rebirthAt = 0L + internal set + /** The rebirth happened; its extras go out with the Pokémon. */ + var reborn = false + internal set + + internal var shell: Shell.Match? = null + internal var furnaces: List = emptyList() + internal var tier = 0 + internal val heatCarry = HashMap() + internal var confirmBy: UUID? = null + internal var confirmUntil = 0L + private var display: PokemonEntity? = null + private var ticks = 0 + + val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)] + val machineName: Component get() = blockState.block.name + + fun serverTick(level: ServerLevel) { + val def = def ?: return + if (ticks++ % CHECK_TICKS == 0) refreshShell(level, def) + val pokemon = pokemon + if (pokemon != null) { + if (shell == null) { + giveBack(level, Component.translatableWithFallback("message.cobblesurvival.machine.shell_broke", "The shell of the %s broke.", machineName)) + return + } + if (display?.isAlive != true) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing()) + } + MachineWork.tick(this, level, def) + receivedThisTick = 0 + } + + private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) { + val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH) + val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH) + if (!level.hasChunksAt(min, max)) return // a neighbouring chunk isn't loaded: keep what we knew + val match = def.shell.match(level, worldPosition) + if (match?.chamber != shell?.chamber) removeDisplay() + shell = match + if (match != null) MachineWork.onShell(this, level, def, match) + } + + fun facing() = blockState.getValue(MachineBlock.FACING) + + /** Using the core with an empty hand: put a Pokémon in, take it out, or (sneaking) see the state. */ + fun use(player: ServerPlayer) { + val level = level as? ServerLevel ?: return + val def = def ?: return tell(player, Component.literal("This core has no machine data.").withStyle(ChatFormatting.RED)) + val inside = pokemon + when { + player.isShiftKeyDown && inside != null && def.kind == MachineDefs.Kind.ALTAR && owner == player.uuid -> MachineWork.altarUse(this, level, def, player, inside) + player.isShiftKeyDown -> tell(player, MachineWork.status(this, level, def)) + inside != null && owner != player.uuid -> tell(player, Component.translatableWithFallback( + "message.cobblesurvival.machine.not_yours", "This %s holds %s's %s.", machineName, ownerName, inside.getDisplayName())) + inside != null -> giveBack(level, null) + else -> insert(level, def, player) + } + } + + private fun insert(level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer) { + val match = def.shell.match(level, worldPosition) + if (match == null) { + val (pos, wanted) = def.shell.firstMismatch(level, worldPosition) ?: return + tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.incomplete", + "The %s's shell isn't complete: %s belongs at %s %s %s.", machineName, wanted, pos.x, pos.y, pos.z).withStyle(ChatFormatting.YELLOW)) + return + } + val party = Cobblemon.storage.getParty(player) + val entity = level.getEntitiesOfClass(PokemonEntity::class.java, AABB(worldPosition).inflate(REACH)) { + !it.isBattling && it.pokemon.getOwnerUUID() == player.uuid && party.any { p -> p.uuid == it.pokemon.uuid } && MachineWork.fits(def, it.pokemon) + }.minByOrNull { it.distanceToSqr(worldPosition.center) } + if (entity == null) { + tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.send_out", + "Send out %s near the %s, then use it again.", MachineWork.wanted(def), machineName).withStyle(ChatFormatting.YELLOW)) + return + } + val pokemon = entity.pokemon + pokemon.recall() + if (entity.isAlive) entity.discard() + if (!party.remove(pokemon)) return + this.pokemon = pokemon + owner = player.uuid + ownerName = player.gameProfile.name + shell = match + MachineWork.onInsert(this, level, def) + setChanged() + display = MachineDisplay.spawn(level, pokemon, worldPosition, match.chamber, facing()) + tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.inserted", "%s went into the %s.", pokemon.getDisplayName(), machineName) + .withStyle(ChatFormatting.GREEN)) + tell(player, MachineWork.status(this, level, def)) + } + + /** Sends the Pokémon back to its owner (party, else PC), online or not; [why] is told to the owner. */ + fun giveBack(level: ServerLevel, why: Component?): Boolean { + val pokemon = pokemon ?: return true + val owner = owner ?: return false + val registries = level.registryAccess() + MachineWork.onTakeOut(this, level, pokemon) + val where = when { + Cobblemon.storage.getParty(owner, registries).add(pokemon) -> "party" + Cobblemon.storage.getPC(owner, registries).add(pokemon) -> "PC" + else -> { + CobbleSurvival.LOGGER.error("Couldn't return {} from the machine at {} to {}", pokemon.species.name, worldPosition, owner) + return false + } + } + level.server.playerList.getPlayer(owner)?.let { player -> + why?.let { tell(player, it.copy().withStyle(ChatFormatting.YELLOW)) } + tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.returned", "%s came back to your %s.", pokemon.getDisplayName(), where)) + } + clear() + return true + } + + /** The Pokémon is gone for good (sacrificed). */ + internal fun consume() = clear() + + private fun clear() { + pokemon = null + owner = null + ownerName = "" + rebirthAt = 0 + reborn = false + confirmBy = null + removeDisplay() + setChanged() + } + + internal fun respawnDisplay() = removeDisplay() + + private fun removeDisplay() { + display?.discard() + display = null + } + + /** The core is gone: the Pokémon goes home, what the machine made drops. */ + fun onBroken(level: ServerLevel) { + giveBack(level, Component.translatableWithFallback("message.cobblesurvival.machine.core_broke", "The %s was broken.", machineName)) + for (slot in 0 until output.slots) Containers.dropItemStack(level, worldPosition.x + 0.5, worldPosition.y + 0.5, worldPosition.z + 0.5, output.getStackInSlot(slot)) + } + + override fun onChunkUnloaded() { + super.onChunkUnloaded() + removeDisplay() + } + + override fun setRemoved() { + super.setRemoved() + removeDisplay() + } + + fun energyStorage(): IEnergyStorage? = when (def?.kind) { + MachineDefs.Kind.GENERATOR -> MachineEnergy.Output(this) + MachineDefs.Kind.SYNTHESIS -> MachineEnergy.Input(this) + else -> null + } + + fun outputHandler(): IItemHandler? = if (def?.kind == MachineDefs.Kind.SYNTHESIS) MachineEnergy.ExtractOnly(output) else null + + override fun saveAdditional(tag: CompoundTag, registries: HolderLookup.Provider) { + super.saveAdditional(tag, registries) + pokemon?.let { tag.put("pokemon", it.saveToNBT(registries as RegistryAccess, CompoundTag())) } + owner?.let { tag.putUUID("owner", it) } + tag.putString("owner_name", ownerName) + tag.putInt("energy", energy) + tag.put("output", output.serializeNBT(registries)) + tag.putLong("rebirth_at", rebirthAt) + tag.putBoolean("reborn", reborn) + } + + override fun loadAdditional(tag: CompoundTag, registries: HolderLookup.Provider) { + super.loadAdditional(tag, registries) + pokemon = if (tag.contains("pokemon")) Pokemon().loadFromNBT(registries as RegistryAccess, tag.getCompound("pokemon")) else null + owner = if (tag.hasUUID("owner")) tag.getUUID("owner") else null + ownerName = tag.getString("owner_name") + energy = tag.getInt("energy") + if (tag.contains("output")) output.deserializeNBT(registries, tag.getCompound("output")) + rebirthAt = tag.getLong("rebirth_at") + reborn = tag.getBoolean("reborn") + } + + companion object { + const val OUTPUT_SLOTS = 9 + private const val CHECK_TICKS = 20 + /** How far from the core its shell can reach. */ + private const val SHELL_REACH = 2 + /** How near the core a sent-out Pokémon must be to walk in. */ + private const val REACH = 16.0 + + fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message) + } +} + +/** Energy and item views of a core, for other blocks (cables, hoppers). */ +object MachineEnergy { + /** A generator: others can take energy, not put it in. */ + class Output(private val core: MachineBlockEntity) : IEnergyStorage { + override fun receiveEnergy(amount: Int, simulate: Boolean) = 0 + override fun extractEnergy(amount: Int, simulate: Boolean): Int { + val out = minOf(amount, core.energy, core.def?.energy?.maxOutput ?: 0) + if (!simulate && out > 0) { core.energy -= out; core.setChanged() } + return out + } + override fun getEnergyStored() = core.energy + override fun getMaxEnergyStored() = core.def?.energy?.capacity ?: 0 + override fun canExtract() = true + override fun canReceive() = false + } + + /** A synthesizer: takes energy up to its cap per tick, gives none back. */ + class Input(private val core: MachineBlockEntity) : IEnergyStorage { + override fun receiveEnergy(amount: Int, simulate: Boolean): Int { + val rules = core.def?.synthesis ?: return 0 + val room = minOf(rules.capacity - core.energy, rules.maxInput - core.receivedThisTick) + val taken = minOf(amount, room).coerceAtLeast(0) + if (!simulate && taken > 0) { core.energy += taken; core.receivedThisTick += taken; core.setChanged() } + return taken + } + override fun extractEnergy(amount: Int, simulate: Boolean) = 0 + override fun getEnergyStored() = core.energy + override fun getMaxEnergyStored() = core.def?.synthesis?.capacity ?: 0 + override fun canExtract() = false + override fun canReceive() = true + } + + /** Hoppers and pipes may take what was made, never put anything in. */ + class ExtractOnly(private val handler: ItemStackHandler) : IItemHandler { + override fun getSlots() = handler.slots + override fun getStackInSlot(slot: Int): ItemStack = handler.getStackInSlot(slot) + override fun insertItem(slot: Int, stack: ItemStack, simulate: Boolean): ItemStack = stack + override fun extractItem(slot: Int, amount: Int, simulate: Boolean): ItemStack = handler.extractItem(slot, amount, simulate) + override fun getSlotLimit(slot: Int) = handler.getSlotLimit(slot) + override fun isItemValid(slot: Int, stack: ItemStack) = false + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDefs.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDefs.kt new file mode 100644 index 0000000..532d65a --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDefs.kt @@ -0,0 +1,132 @@ +package dev.cobblesurvival.machine + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.core.registries.Registries +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.packs.resources.ResourceManager +import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener +import net.minecraft.tags.TagKey +import net.minecraft.util.profiling.ProfilerFiller +import net.minecraft.world.item.Item +import net.minecraft.world.level.block.Block +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.AddReloadListenerEvent + +/** + * Pokémon machines (roadmap Phase 6), one file per machine in `data/cobblesurvival/machines/`: the core block, the shell + * around it ([Shell]), which Pokémon fit, and the numbers for what the machine does. The format is described in each + * file's comment. + */ +object MachineDefs : SimpleJsonResourceReloadListener(Gson(), "machines") { + enum class Kind { FURNACE, GENERATOR, SYNTHESIS, ALTAR, REBIRTH } + + class FurnaceRules(val tag: TagKey, val max: Int, val levelsPerFurnace: Int, val base: Double, val perLevel: Double, val perSpecialAttack: Double) { + fun count(level: Int) = minOf(max, 1 + level / levelsPerFurnace) + fun speed(level: Int, specialAttack: Int) = base + perLevel * level + perSpecialAttack * specialAttack + } + + class EnergyRules(val fePerLevel: Int, val perSpecialAttack: Double, val capacity: Int, val maxOutput: Int) { + fun perTick(level: Int, specialAttack: Int) = (fePerLevel * level * (1 + perSpecialAttack * specialAttack)).toInt() + } + + /** Synthesis tiers: casing blocks of each tier, lowest first; a shell's tier is the lowest one among its blocks. */ + class SynthesisRules(val fePerRoll: Int, val maxInput: Int, val capacity: Int, val outputSlots: Int, val tiers: List>, val chanceBonusPerLooting: Double) + + class Reward(val item: Item, val count: Int) + class AltarTier(val minLevel: Int, val rewards: List) + class AltarSpecial(val types: Set, val minLevel: Int, val rewards: List) + class AltarRules(val tiers: List, val specials: List, val confirmTicks: Int) + + class RebirthRules(val ticks: Long, val perfectIvs: Int, val extra: List) + + class Def( + val id: ResourceLocation, + val core: ResourceLocation, + val kind: Kind, + /** Empty = any type. */ + val types: Set, + val minLevel: Int, + val shell: Shell, + val furnaces: FurnaceRules? = null, + val energy: EnergyRules? = null, + val synthesis: SynthesisRules? = null, + val altar: AltarRules? = null, + val rebirth: RebirthRules? = null, + ) + + var byCore: Map = emptyMap() + private set + + fun register() { + NeoForge.EVENT_BUS.addListener { it.addListener(this) } + } + + override fun apply(files: Map, resources: ResourceManager, profiler: ProfilerFiller) { + byCore = files.mapNotNull { (id, element) -> + runCatching { parse(id, element.asJsonObject) } + .onFailure { CobbleSurvival.LOGGER.error("Bad machine definition {}: {}", id, it.message) } + .getOrNull() + }.associateBy { it.core } + CobbleSurvival.LOGGER.info("Loaded {} machines", byCore.size) + } + + private fun blockTag(id: String) = TagKey.create(Registries.BLOCK, ResourceLocation.parse(id.removePrefix("#"))) + + private fun rewards(json: JsonObject, key: String = "rewards") = json.getAsJsonArray(key).map { it.asJsonObject }.map { + val id = ResourceLocation.parse(it.get("item").asString) + require(BuiltInRegistries.ITEM.containsKey(id)) { "unknown item $id" } + Reward(BuiltInRegistries.ITEM.get(id), it.get("count")?.asInt ?: 1) + } + + private fun parse(id: ResourceLocation, json: JsonObject): Def { + val kind = Kind.valueOf(json.get("kind").asString.uppercase()) + val shell = Shell.parse( + json.getAsJsonArray("layers").map { layer -> layer.asJsonArray.map { it.asString } }, + json.getAsJsonObject("keys").entrySet().associate { (key, value) -> + key.single() to if (value.isJsonArray) value.asJsonArray.map { it.asString } else listOf(value.asString) + }, + ) + val def = Def( + id, ResourceLocation.parse(json.get("core").asString), kind, + json.getAsJsonArray("types")?.map { it.asString.lowercase() }?.toSet().orEmpty(), + json.get("min_level")?.asInt ?: 1, + shell, + furnaces = json.getAsJsonObject("furnaces")?.let { f -> + val speed = f.getAsJsonObject("speed") + FurnaceRules(blockTag(f.get("tag").asString), f.get("max").asInt, f.get("levels_per_furnace").asInt, + speed.get("base").asDouble, speed.get("per_level").asDouble, speed.get("per_special_attack").asDouble) + }, + energy = json.getAsJsonObject("energy")?.let { e -> + EnergyRules(e.get("fe_per_level").asInt, e.get("per_special_attack").asDouble, e.get("capacity").asInt, e.get("max_output").asInt) + }, + synthesis = json.getAsJsonObject("synthesis")?.let { s -> + SynthesisRules(s.get("fe_per_roll").asInt, s.get("max_input").asInt, s.get("capacity").asInt, s.get("output_slots").asInt, + s.getAsJsonArray("tiers").map { blockTag(it.asString) }, s.get("chance_bonus_per_looting").asDouble) + }, + altar = json.getAsJsonObject("altar")?.let { a -> + AltarRules( + a.getAsJsonArray("tiers").map { it.asJsonObject }.map { AltarTier(it.get("min_level").asInt, rewards(it)) }.sortedBy { it.minLevel }, + a.getAsJsonArray("specials")?.map { it.asJsonObject }?.map { s -> + AltarSpecial(s.getAsJsonArray("types").map { it.asString.lowercase() }.toSet(), s.get("min_level").asInt, rewards(s)) + }.orEmpty(), + a.get("confirm_ticks").asInt, + ) + }, + rebirth = json.getAsJsonObject("rebirth")?.let { r -> + RebirthRules(r.get("ticks").asLong, r.get("perfect_ivs").asInt, if (r.has("extra")) rewards(r, "extra") else emptyList()) + }, + ) + require(when (kind) { + Kind.FURNACE -> def.furnaces != null + Kind.GENERATOR -> def.energy != null + Kind.SYNTHESIS -> def.synthesis != null + Kind.ALTAR -> def.altar != null + Kind.REBIRTH -> def.rebirth != null + }) { "a ${kind.name.lowercase()} machine needs its settings (furnaces, energy, synthesis, altar or rebirth)" } + return def + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt new file mode 100644 index 0000000..40b14a3 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt @@ -0,0 +1,74 @@ +package dev.cobblesurvival.machine + +import com.cobblemon.mod.common.api.Priority +import com.cobblemon.mod.common.api.events.CobblemonEvents +import com.cobblemon.mod.common.api.events.battles.BattleStartedEvent +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import com.cobblemon.mod.common.pokemon.Pokemon +import com.cobblemon.mod.common.pokemon.properties.UncatchableProperty +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent +import net.neoforged.neoforge.event.entity.player.AttackEntityEvent +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent +import java.util.UUID + +/** + * The Pokémon a machine shows in its chamber (roadmap Phase 6: a real Cobblemon entity, so its own model and + * animations are used). It's a copy of the Pokémon kept in the core, with its own id, scaled to fit the chamber, + * without AI, invulnerable, silent, uncatchable, and it can't be used, hit or battled. It's never meant to be in the + * world save: one that comes back from disk is dropped, and the core puts a fresh one there. + */ +object MachineDisplay { + /** Key in the copy's persistent data; its value is the core's position. */ + const val TAG = "cobblesurvival_machine" + /** The chamber is one block; leave a little room. */ + private const val FIT = 0.9f + + fun register() { + NeoForge.EVENT_BUS.addListener(::onJoin) + NeoForge.EVENT_BUS.addListener { if (isDisplay(it.target)) it.isCanceled = true } + NeoForge.EVENT_BUS.addListener { if (isDisplay(it.target)) it.isCanceled = true } + NeoForge.EVENT_BUS.addListener { if (isDisplay(it.target)) it.isCanceled = true } + CobblemonEvents.BATTLE_STARTED_PRE.subscribe(Priority.HIGHEST, ::onBattle) + } + + fun isDisplay(entity: Entity?): Boolean = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG) + + fun spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity { + val copy = Pokemon().loadFromNBT(level.registryAccess(), pokemon.saveToNBT(level.registryAccess(), CompoundTag())) + copy.uuid = UUID.randomUUID() + copy.persistentData.putLong(TAG, core.asLong()) + UncatchableProperty.uncatchable().apply(copy) + val entity = PokemonEntity(level, copy) + val size = maxOf(entity.bbWidth, entity.bbHeight) + if (size > FIT) { + copy.scaleModifier = copy.scaleModifier * FIT / size + entity.refreshDimensions() + } + entity.setNoAi(true) + entity.setNoGravity(true) + entity.isInvulnerable = true + entity.isSilent = true + entity.setPersistenceRequired() + entity.busyLocks.add(this) + entity.moveTo(chamber.x + 0.5, chamber.y.toDouble(), chamber.z + 0.5, facing.toYRot(), 0f) + entity.yHeadRot = facing.toYRot() + entity.yBodyRot = facing.toYRot() + level.addFreshEntity(entity) + return entity + } + + /** A copy saved with its chunk comes back without its core knowing it: drop it. */ + private fun onJoin(event: EntityJoinLevelEvent) { + if (event.loadedFromDisk() && isDisplay(event.entity)) event.isCanceled = true + } + + private fun onBattle(event: BattleStartedEvent.Pre) { + if (event.battle.actors.any { actor -> actor.pokemonList.any { isDisplay(it.entity) } }) event.cancel() + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt new file mode 100644 index 0000000..1ab6685 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt @@ -0,0 +1,251 @@ +package dev.cobblesurvival.machine + +import com.cobblemon.mod.common.api.drop.ItemDropEntry +import com.cobblemon.mod.common.api.pokemon.stats.Stats +import com.cobblemon.mod.common.pokemon.FormData +import com.cobblemon.mod.common.pokemon.Pokemon +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.ChatFormatting +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.inventory.ContainerData +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.AbstractFurnaceBlock +import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity +import net.neoforged.neoforge.capabilities.Capabilities +import net.neoforged.neoforge.items.ItemHandlerHelper +import kotlin.random.Random + +/** What each kind of machine does with the Pokémon inside ([MachineDefs.Kind]); the numbers come from its data file. */ +object MachineWork { + private val furnaceData by lazy { + AbstractFurnaceBlockEntity::class.java.getDeclaredField("dataAccess").apply { isAccessible = true } + } + private const val LIT_TIME = 0 + private const val LIT_DURATION = 1 + private const val PROGRESS = 2 + private const val TOTAL = 3 + private const val KEEP_LIT = 40 + private const val TICKS_PER_MINUTE = 1200 + + fun fits(def: MachineDefs.Def, pokemon: Pokemon): Boolean = + (def.types.isEmpty() || pokemon.types.any { it.name.lowercase() in def.types }) && pokemon.level >= def.minLevel + + /** "a Fire-type Pokémon", "a Lv 100 Pokémon". */ + fun wanted(def: MachineDefs.Def): String { + val type = def.types.joinToString("/") { it.replaceFirstChar(Char::titlecase) } + val level = if (def.minLevel > 1) "Lv ${def.minLevel}+ " else "" + return if (type.isEmpty()) "a ${level}Pokémon" else "a $level$type-type Pokémon" + } + + fun onShell(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def, shell: Shell.Match) { + def.furnaces?.let { rules -> + core.furnaces = (shell.blocks + core.blockPos).asSequence() + .flatMap { block -> Direction.entries.asSequence().map { block.relative(it) } } + .filter { it !in shell.blocks && it != core.blockPos && it != shell.chamber } + .distinct() + .filter { level.getBlockState(it).`is`(rules.tag) } + .sortedWith(compareBy({ it.y }, { it.z }, { it.x })) + .toList() + } + def.synthesis?.let { rules -> + // Glass and anything outside the tier tags doesn't count; the lowest metal decides. + core.tier = shell.blocks.mapNotNull { pos -> rules.tiers.indexOfFirst { level.getBlockState(pos).`is`(it) }.takeIf { it >= 0 } }.minOrNull() ?: 0 + } + } + + fun onInsert(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def) { + def.rebirth?.let { core.rebirthAt = level.gameTime + it.ticks } + } + + fun onTakeOut(core: MachineBlockEntity, level: ServerLevel, pokemon: Pokemon) { + val owner = core.owner?.let { level.server.playerList.getPlayer(it) } + if (core.rebirthAt > 0 && owner != null) { + MachineBlockEntity.tell(owner, Component.translatableWithFallback("message.cobblesurvival.machine.rebirth_cancelled", + "%s's rebirth was cancelled.", pokemon.getDisplayName()).withStyle(ChatFormatting.YELLOW)) + } + if (core.reborn) core.def?.rebirth?.extra?.forEach { give(level, core, owner, ItemStack(it.item, it.count)) } + } + + fun tick(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def) { + val pokemon = core.pokemon + when (def.kind) { + MachineDefs.Kind.FURNACE -> if (pokemon != null) heat(core, level, def.furnaces!!, pokemon) + MachineDefs.Kind.GENERATOR -> { + val rules = def.energy!! + if (pokemon != null) core.energy = minOf(rules.capacity, core.energy + rules.perTick(pokemon.level, pokemon.specialAttack)) + push(core, level, rules) + } + MachineDefs.Kind.SYNTHESIS -> if (pokemon != null) synthesize(core, level, def.synthesis!!, pokemon) + MachineDefs.Kind.REBIRTH -> if (pokemon != null && core.rebirthAt > 0 && level.gameTime >= core.rebirthAt) rebirth(core, level, def.rebirth!!, pokemon) + MachineDefs.Kind.ALTAR -> {} + } + } + + // --- Large Furnace ------------------------------------------------------------------------------------------- + + private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) { + val speed = rules.speed(pokemon.level, pokemon.specialAttack) + for (pos in core.furnaces.take(rules.count(pokemon.level))) { + val furnace = level.getBlockEntity(pos) as? AbstractFurnaceBlockEntity ?: continue + if (furnace.getItem(0).isEmpty) { + core.heatCarry.remove(pos) + continue + } + val data = furnaceData.get(furnace) as ContainerData + if (data.get(LIT_TIME) < 2) { + data.set(LIT_TIME, KEEP_LIT) + data.set(LIT_DURATION, KEEP_LIT) + } + val state = level.getBlockState(pos) + if (state.hasProperty(AbstractFurnaceBlock.LIT) && !state.getValue(AbstractFurnaceBlock.LIT)) { + level.setBlock(pos, state.setValue(AbstractFurnaceBlock.LIT, true), 3) + } + // The furnace adds one step itself; the Pokémon adds the rest. Stop one short of done so the furnace's own + // tick finishes the item. + val total = data.get(TOTAL) + if (total > 0) { + val carry = (core.heatCarry[pos] ?: 0.0) + speed - 1 + val extra = carry.toInt() + core.heatCarry[pos] = carry - extra + if (extra > 0) data.set(PROGRESS, minOf(data.get(PROGRESS) + extra, total - 1)) + } + furnace.setChanged() + } + } + + // --- Generator ------------------------------------------------------------------------------------------------- + + private fun push(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.EnergyRules) { + for (side in Direction.entries) { + if (core.energy <= 0) break + val target = level.getCapability(Capabilities.EnergyStorage.BLOCK, core.blockPos.relative(side), side.opposite) ?: continue + if (!target.canReceive()) continue + core.energy -= target.receiveEnergy(minOf(core.energy, rules.maxOutput), false) + } + core.setChanged() + } + + // --- Drop Synthesizer ------------------------------------------------------------------------------------------ + + private fun synthesize(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.SynthesisRules, pokemon: Pokemon) { + if (core.energy < rules.fePerRoll) return + val used = (0 until rules.outputSlots.coerceAtMost(core.output.slots)).count { !core.output.getStackInSlot(it).isEmpty } + if (used >= rules.outputSlots) return + core.energy -= rules.fePerRoll + for (stack in roll(pokemon, core.tier, rules.chanceBonusPerLooting)) ItemHandlerHelper.insertItemStacked(core.output, stack, false) + core.setChanged() + } + + /** One drop roll as if the Pokémon were defeated, with [looting] extra items per drop and a chance of a second roll. */ + fun roll(pokemon: Pokemon, looting: Int, chanceBonus: Double): List { + val rolls = if (looting > 0 && Random.nextDouble() < chanceBonus * looting) 2 else 1 + return (1..rolls).flatMap { pokemon.form.drops.getDrops(pokemon = pokemon) }.filterIsInstance().mapNotNull { entry -> + val item = BuiltInRegistries.ITEM.getOptional(entry.item).orElse(null) ?: return@mapNotNull null + val count = (entry.quantityRange?.random() ?: entry.quantity) + (if (looting > 0) Random.nextInt(looting + 1) else 0) + ItemStack(item, count).takeIf { !it.isEmpty } + } + } + + // --- Sacrifice Altar ------------------------------------------------------------------------------------------- + + fun altarRewards(rules: MachineDefs.AltarRules, pokemon: Pokemon): List { + val tier = rules.tiers.lastOrNull { pokemon.level >= it.minLevel }?.rewards.orEmpty() + val specials = rules.specials.filter { s -> pokemon.level >= s.minLevel && pokemon.types.any { it.name.lowercase() in s.types } }.flatMap { it.rewards } + return (tier + specials).map { ItemStack(it.item, it.count) } + } + + fun altarUse(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer, pokemon: Pokemon) { + val rules = def.altar!! + val rewards = altarRewards(rules, pokemon) + if (core.confirmBy == player.uuid && level.gameTime <= core.confirmUntil) { + core.consume() + rewards.forEach { give(level, core, player, it.copy()) } + CobbleSurvival.LOGGER.info("{} sacrificed {} (Lv {}) at {}", player.gameProfile.name, pokemon.species.name, pokemon.level, core.blockPos) + MachineBlockEntity.tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.sacrificed", + "%s is gone. You received %s.", pokemon.getDisplayName(), list(rewards)).withStyle(ChatFormatting.DARK_PURPLE)) + return + } + core.confirmBy = player.uuid + core.confirmUntil = level.gameTime + rules.confirmTicks + MachineBlockEntity.tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.sacrifice_confirm", + "Sacrificing %s (Lv %s) gives %s. Sneak-use again within %s seconds to sacrifice it: it's gone for good.", + pokemon.getDisplayName(), pokemon.level, list(rewards), rules.confirmTicks / 20).withStyle(ChatFormatting.RED)) + } + + // --- Rebirth Chamber ------------------------------------------------------------------------------------------- + + private fun rebirth(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.RebirthRules, pokemon: Pokemon) { + rebirth(pokemon, rules.perfectIvs) + core.rebirthAt = 0 + core.reborn = true + core.respawnDisplay() + core.setChanged() + core.owner?.let { level.server.playerList.getPlayer(it) }?.let { + MachineBlockEntity.tell(it, Component.translatableWithFallback("message.cobblesurvival.machine.reborn", + "%s was reborn in the %s.", pokemon.getDisplayName(), core.machineName).withStyle(ChatFormatting.LIGHT_PURPLE)) + } + } + + /** First stage of its line at Lv 1, shiny, [perfectIvs] IVs raised to 31 (none lowered); moves, ability, nickname kept. */ + fun rebirth(pokemon: Pokemon, perfectIvs: Int) { + val ability = pokemon.ability + var form: FormData = pokemon.form + while (true) form = form.preEvolution?.form ?: break + if (form.species != pokemon.species) pokemon.species = form.species + if (pokemon.form != form) pokemon.form = form + pokemon.level = 1 + pokemon.shiny = true + Stats.PERMANENT.filter { (pokemon.ivs[it] ?: 0) < 31 }.shuffled().take(perfectIvs).forEach { pokemon.setIV(it, 31) } + if (form.abilities.any { it.template == ability.template }) pokemon.updateAbility(ability) else pokemon.rollAbility() + } + + // --- Status ---------------------------------------------------------------------------------------------------- + + fun status(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def): Component { + val pokemon = core.pokemon ?: return Component.translatableWithFallback("message.cobblesurvival.machine.status_empty", + "%s: empty. Send out %s near it and use it to put the Pokémon in.", core.machineName, wanted(def)) + val who = Component.empty().append(pokemon.getDisplayName()).append(" (Lv ${pokemon.level})") + val line = when (def.kind) { + MachineDefs.Kind.FURNACE -> { + val rules = def.furnaces!! + val heated = minOf(core.furnaces.size, rules.count(pokemon.level)) + if (core.furnaces.isEmpty()) "no furnaces stand against the shell yet; furnaces, blast furnaces and smokers there are heated" + else "heats $heated of ${core.furnaces.size} furnaces (up to ${rules.count(pokemon.level)}), cooking %.1f× as fast".format(rules.speed(pokemon.level, pokemon.specialAttack)) + } + MachineDefs.Kind.GENERATOR -> { + val rules = def.energy!! + "makes ${rules.perTick(pokemon.level, pokemon.specialAttack)} FE/t; ${core.energy} of ${rules.capacity} FE stored" + } + MachineDefs.Kind.SYNTHESIS -> { + val rules = def.synthesis!! + "${core.energy} of ${rules.fePerRoll} FE toward the next drop roll (takes up to ${rules.maxInput} FE/t), looting ${core.tier}" + } + MachineDefs.Kind.ALTAR -> "sneak-use to see what sacrificing it gives" + MachineDefs.Kind.REBIRTH -> when { + core.reborn -> "reborn: take it out" + else -> "reborn in ${((core.rebirthAt - level.gameTime).coerceAtLeast(0) / TICKS_PER_MINUTE) + 1} minutes" + } + } + return Component.empty().append(core.machineName).append(": ").append(who).append(" $line.") + } + + private fun list(stacks: List): Component { + val text = Component.empty() + stacks.forEachIndexed { i, stack -> + if (i > 0) text.append(", ") + text.append("${stack.count}× ").append(stack.hoverName) + } + return if (stacks.isEmpty()) Component.literal("nothing") else text + } + + private fun give(level: ServerLevel, core: MachineBlockEntity, player: ServerPlayer?, stack: ItemStack) { + if (player != null && player.inventory.add(stack)) return + val pos: BlockPos = core.blockPos.above() + net.minecraft.world.Containers.dropItemStack(level, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, stack) + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt new file mode 100644 index 0000000..71d60ca --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt @@ -0,0 +1,63 @@ +package dev.cobblesurvival.machine + +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.core.registries.Registries +import net.minecraft.world.item.CreativeModeTabs +import net.minecraft.world.level.block.SoundType +import net.minecraft.world.level.block.entity.BlockEntityType +import net.minecraft.world.level.block.state.BlockBehaviour +import net.minecraft.world.level.material.MapColor +import net.neoforged.bus.api.IEventBus +import net.neoforged.neoforge.capabilities.Capabilities +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent +import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent +import net.neoforged.neoforge.registries.DeferredBlock +import net.neoforged.neoforge.registries.DeferredHolder +import net.neoforged.neoforge.registries.DeferredRegister +import java.util.function.Supplier + +/** The machine cores (roadmap Phase 6): blocks, their items and block entity, and the energy and item views. */ +object Machines { + private val BLOCKS = DeferredRegister.createBlocks(CobbleSurvival.MOD_ID) + private val ITEMS = DeferredRegister.createItems(CobbleSurvival.MOD_ID) + private val BLOCK_ENTITIES = DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, CobbleSurvival.MOD_ID) + + val CORES: List> = listOf( + core("heat_core", MapColor.COLOR_RED, SoundType.STONE), + core("dynamo_core", MapColor.COLOR_ORANGE, SoundType.COPPER), + core("synthesis_core", MapColor.METAL, SoundType.METAL), + core("altar_core", MapColor.COLOR_BLACK, SoundType.STONE), + core("rebirth_core", MapColor.COLOR_PURPLE, SoundType.AMETHYST), + ) + + init { + CORES.forEach { ITEMS.registerSimpleBlockItem(it) } + } + + val BLOCK_ENTITY: DeferredHolder, BlockEntityType> = BLOCK_ENTITIES.register("machine", Supplier { + BlockEntityType.Builder.of(::MachineBlockEntity, *CORES.map { it.get() }.toTypedArray()).build(null) + }) + + private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock = BLOCKS.registerBlock( + name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound), + ) + + fun register(bus: IEventBus) { + BLOCKS.register(bus) + ITEMS.register(bus) + BLOCK_ENTITIES.register(bus) + bus.addListener(::onCapabilities) + bus.addListener(::onCreativeTab) + MachineDefs.register() + MachineDisplay.register() + } + + private fun onCapabilities(event: RegisterCapabilitiesEvent) { + event.registerBlockEntity(Capabilities.EnergyStorage.BLOCK, BLOCK_ENTITY.get()) { core, _ -> core.energyStorage() } + event.registerBlockEntity(Capabilities.ItemHandler.BLOCK, BLOCK_ENTITY.get()) { core, _ -> core.outputHandler() } + } + + private fun onCreativeTab(event: BuildCreativeModeTabContentsEvent) { + if (event.tabKey == CreativeModeTabs.FUNCTIONAL_BLOCKS) CORES.forEach { event.accept(it) } + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt new file mode 100644 index 0000000..3286d6d --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt @@ -0,0 +1,73 @@ +package dev.cobblesurvival.machine + +import net.minecraft.core.BlockPos +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.core.registries.Registries +import net.minecraft.resources.ResourceLocation +import net.minecraft.tags.TagKey +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.block.state.BlockState + +/** + * A machine's shell: what must stand at each offset from the core, written with the core in the south wall, and the + * chamber where the Pokémon stands. [match] tries the four horizontal rotations around the core. + */ +class Shell(private val parts: Map, private val chamber: BlockPos) { + class BlockRule(val text: String, val test: (BlockState) -> Boolean) + + /** The shell found around a core: where the chamber is and the shell's blocks (the core not included). */ + class Match(val chamber: BlockPos, val blocks: Set) + + fun match(level: Level, core: BlockPos): Match? = ROTATIONS.firstNotNullOfOrNull { rotation -> + if (parts.all { (offset, rule) -> rule.test(level.getBlockState(core.offset(offset.rotate(rotation)))) }) { + Match(core.offset(chamber.rotate(rotation)), parts.keys.filter { it != chamber }.mapTo(HashSet()) { core.offset(it.rotate(rotation)) }) + } else null + } + + /** For a message: the first wrong block in the rotation that comes closest, and what belongs there. */ + fun firstMismatch(level: Level, core: BlockPos): Pair? = ROTATIONS + .map { rotation -> + parts.entries.filterNot { (offset, rule) -> rule.test(level.getBlockState(core.offset(offset.rotate(rotation)))) } + .map { (offset, rule) -> core.offset(offset.rotate(rotation)) to rule.text } + } + .minByOrNull { it.size }?.firstOrNull() + + /** Every block position of the shell in the orientation where the core is in the south wall (for building it). */ + fun layout(): Map = parts.mapValues { it.value.text } + + companion object { + private val ROTATIONS = listOf(Rotation.NONE, Rotation.CLOCKWISE_90, Rotation.CLOCKWISE_180, Rotation.COUNTERCLOCKWISE_90) + private const val CORE = 'C' + private const val CHAMBER = '.' + const val AIR = "air" + + /** [layers] bottom to top, each a list of rows north to south, each row west to east; ' ' = anything. A key + * lists the blocks (ids or #tags) allowed for its character. */ + fun parse(layers: List>, keys: Map>): Shell { + val cells = layers.flatMapIndexed { y, rows -> rows.flatMapIndexed { z, row -> row.mapIndexed { x, c -> BlockPos(x, y, z) to c } } } + val core = cells.single { it.second == CORE }.first + val chamber = cells.single { it.second == CHAMBER }.first.subtract(core) + val parts = cells.filter { it.second != CORE && it.second != ' ' }.associate { (pos, c) -> + pos.subtract(core) to when (c) { + CHAMBER -> BlockRule(AIR) { it.isAir } + else -> anyOf(keys[c] ?: error("no key for '$c'")) + } + } + return Shell(parts, chamber) + } + + private fun anyOf(specs: List): BlockRule { + val rules = specs.map(::rule) + return BlockRule(specs.joinToString(" or ")) { state -> rules.any { it.test(state) } } + } + + private fun rule(spec: String): BlockRule = if (spec.startsWith("#")) { + val tag = TagKey.create(Registries.BLOCK, ResourceLocation.parse(spec.substring(1))) + BlockRule(spec) { it.`is`(tag) } + } else { + val id = ResourceLocation.parse(spec) + BlockRule(spec) { BuiltInRegistries.BLOCK.getKey(it.block) == id } + } + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt new file mode 100644 index 0000000..f671ce4 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt @@ -0,0 +1,360 @@ +package dev.cobblesurvival.selftest + +import com.cobblemon.mod.common.Cobblemon +import com.cobblemon.mod.common.api.pokemon.stats.Stats +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import com.cobblemon.mod.common.pokemon.Pokemon +import dev.cobblesurvival.CobbleSurvival +import dev.cobblesurvival.machine.MachineBlock +import dev.cobblesurvival.machine.MachineBlockEntity +import dev.cobblesurvival.machine.MachineDefs +import dev.cobblesurvival.machine.MachineDisplay +import dev.cobblesurvival.machine.MachineWork +import dev.cobblesurvival.machine.Shell +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.level.ChunkPos +import net.minecraft.world.level.block.Block +import net.minecraft.world.level.block.Blocks +import net.minecraft.world.level.block.HopperBlock +import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity +import net.minecraft.world.level.block.entity.HopperBlockEntity +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.capabilities.Capabilities + +/** + * Pokémon machines (roadmap Phase 6). The roadmap's done-when: putting a Pokémon in and taking it out, a broken shell or + * core, chunk unload and server restart, all without losing or duplicating the Pokémon; and each machine's work. + * Machines are built with the core in the south wall, the chamber north of it. + */ +object MachineTests { + /** Core position in a test's area; the shell spans x 6-8, y 0-2, z 5-7. */ + private val CORE = BlockPos(7, 1, 7) + /** Blocks south (unload) and north (restart) of spawn: far beyond view distance, clear of the vein tests (east, west). */ + private const val UNLOAD_OFFSET = 352 + private const val RESTART_OFFSET = -352 + + fun all() = listOf( + SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut), + SelfTestCase("machine_refuses_what_doesnt_fit", 20, ::refuses), + SelfTestCase("machine_shell_break_returns_pokemon", 100, ::shellBreak), + SelfTestCase("machine_core_break_returns_pokemon_to_pc", 60, ::coreBreakToPc), + SelfTestCase("machine_survives_unload", 600, ::survivesUnload), + SelfTestCase("machine_filled_before_restart", 100, ::beforeRestart), + SelfTestCase("machine_survives_restart", 200, ::afterRestart, afterRestart = true), + SelfTestCase("heat_core_cooks_with_hoppers", 800, ::heatCore), + SelfTestCase("dynamo_core_powers_cables", 100, ::dynamo), + SelfTestCase("synthesis_core_rolls_drops", 100, ::synthesis), + SelfTestCase("altar_core_sacrifice_needs_confirmation", 60, ::altar), + SelfTestCase("rebirth_core_makes_a_shiny_first_stage", 100, ::rebirth), + ) + + private fun id(name: String) = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, name) + + fun build(level: ServerLevel, core: BlockPos, name: String, casing: Block): MachineBlockEntity { + val def = MachineDefs.byCore[id(name)] ?: throw GameTestAssertException("no machine data for ${id(name)}") + for ((offset, text) in def.shell.layout()) { + level.setBlockAndUpdate(core.offset(offset), if (text == Shell.AIR) Blocks.AIR.defaultBlockState() else casing.defaultBlockState()) + } + level.setBlockAndUpdate(core, BuiltInRegistries.BLOCK.get(id(name)).defaultBlockState().setValue(MachineBlock.FACING, Direction.SOUTH)) + return level.getBlockEntity(core) as? MachineBlockEntity ?: throw GameTestAssertException("no machine at $core") + } + + private fun build(s: Scenario, name: String, casing: Block) = build(s.level, s.helper.absolutePos(CORE), name, casing) + + /** Sends [pokemon] (in [player]'s party) out two blocks in front of the core. */ + private fun sendOut(s: Scenario, core: BlockPos, pokemon: Pokemon): PokemonEntity { + val spot = core.south(2) + if (s.level.getBlockState(spot.below()).isAir) s.level.setBlockAndUpdate(spot.below(), Blocks.STONE.defaultBlockState()) + return s.spawn(PokemonEntity(s.level, pokemon), Vec3.atBottomCenterOf(spot)) + } + + private fun insert(s: Scenario, core: MachineBlockEntity, player: ServerPlayer, pokemon: Pokemon) { + sendOut(s, core.blockPos, pokemon) + core.use(player) + s.check(core.pokemon?.uuid == pokemon.uuid) { "${pokemon.species.name} didn't go into the ${core.machineName.string}" } + } + + private fun displays(level: ServerLevel, core: BlockPos) = + level.getEntitiesOfClass(PokemonEntity::class.java, AABB(core).inflate(3.0)) { MachineDisplay.isDisplay(it) } + + private fun inParty(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getParty(player).any { it.uuid == pokemon.uuid } + private fun inPc(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getPC(player).any { it.uuid == pokemon.uuid } + + private fun inAndOut(s: Scenario) { + val player = s.player() + val core = build(s, "heat_core", Blocks.BRICKS) + val pokemon = s.give(player, s.pokemon("charmander", 20)) + s.helper.startSequence() + .thenExecute { + insert(s, core, player, pokemon) + s.check(!inParty(player, pokemon)) { "Charmander is in the machine and still in the party" } + } + .thenWaitUntil { s.check(displays(s.level, core.blockPos).size == 1) { "${displays(s.level, core.blockPos).size} Pokémon shown in the chamber, not 1" } } + .thenExecute { + val shown = displays(s.level, core.blockPos).single() + s.check(shown.pokemon.uuid != pokemon.uuid) { "the shown Pokémon is the real one, not a copy" } + s.check(shown.isNoAi && shown.isInvulnerable && shown.pokemon.isUncatchable()) { "the shown Pokémon can move, be hurt or be caught" } + core.use(player) + s.check(core.pokemon == null) { "Charmander is still in the machine after taking it out" } + s.check(inParty(player, pokemon)) { "Charmander didn't come back to the party" } + } + .thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the chamber still shows a Pokémon" } } + .thenSucceed() + } + + private fun refuses(s: Scenario) { + val player = s.player() + val core = build(s, "heat_core", Blocks.BRICKS) + val pikachu = s.give(player, s.pokemon("pikachu", 20)) + sendOut(s, core.blockPos, pikachu) + core.use(player) + s.check(core.pokemon == null && inParty(player, pikachu)) { "a Pikachu went into the Heat Core (Fire types only)" } + val charmander = s.give(player, s.pokemon("charmander", 20)) + sendOut(s, core.blockPos, charmander) + s.level.setBlockAndUpdate(core.blockPos.offset(-1, 1, -2), Blocks.AIR.defaultBlockState()) + core.use(player) + s.check(core.pokemon == null && inParty(player, charmander)) { "Charmander went into a Heat Core with a hole in its shell" } + s.helper.succeed() + } + + private fun shellBreak(s: Scenario) { + val player = s.player() + val core = build(s, "heat_core", Blocks.BRICKS) + val pokemon = s.give(player, s.pokemon("charmander", 20)) + s.helper.startSequence() + .thenExecute { + insert(s, core, player, pokemon) + s.level.setBlockAndUpdate(core.blockPos.offset(1, 1, -2), Blocks.AIR.defaultBlockState()) + } + .thenWaitUntil { s.check(core.pokemon == null) { "Charmander stayed in a machine with a broken shell" } } + .thenExecute { s.check(inParty(player, pokemon) && !inPc(player, pokemon)) { "Charmander didn't come back to the party (only)" } } + .thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } } + .thenSucceed() + } + + private fun coreBreakToPc(s: Scenario) { + val player = s.player() + val core = build(s, "heat_core", Blocks.BRICKS) + val pokemon = s.give(player, s.pokemon("charmander", 20)) + insert(s, core, player, pokemon) + repeat(6) { s.give(player, s.pokemon("rattata", 5)) } + s.level.destroyBlock(core.blockPos, true) + s.check(!inParty(player, pokemon) && inPc(player, pokemon)) { "with a full party, Charmander didn't go to the PC when the core broke" } + s.helper.startSequence() + .thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } } + .thenSucceed() + } + + private fun spot(level: ServerLevel, offset: Int) = level.sharedSpawnPos.let { BlockPos(it.x, SelfTest.PLATFORM_Y, it.z + offset) } + + private fun chunks(core: BlockPos) = listOf(core.offset(-2, 0, -3), core.offset(2, 0, 3), core.offset(-2, 0, 3), core.offset(2, 0, -3)) + .map(::ChunkPos).distinct() + + private fun force(level: ServerLevel, core: BlockPos, on: Boolean) = chunks(core).forEach { level.setChunkForced(it.x, it.z, on) } + + private fun clearMachine(level: ServerLevel, core: BlockPos) { + BlockPos.betweenClosed(core.offset(-1, -1, -2), core.offset(1, 1, 2)).forEach { level.setBlockAndUpdate(it, Blocks.AIR.defaultBlockState()) } + level.setBlockAndUpdate(core.south(2).below(), Blocks.AIR.defaultBlockState()) + } + + private fun survivesUnload(s: Scenario) { + val level = s.level + val pos = spot(level, UNLOAD_OFFSET) + val chunk = ChunkPos(pos) + val player = s.player() + val pokemon = s.give(player, s.pokemon("charmander", 20)) + s.afterTest { + force(level, pos, true) + clearMachine(level, pos) + force(level, pos, false) + } + s.helper.startSequence() + .thenExecute { force(level, pos, true) } + .thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } } + .thenExecute { + insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon) + force(level, pos, false) + } + .thenWaitUntil { s.check(!level.hasChunk(chunk.x, chunk.z)) { "the machine's chunk is still loaded" } } + .thenExecute { force(level, pos, true) } + .thenWaitUntil { + val back = level.getBlockEntity(pos) as? MachineBlockEntity + s.check(back?.pokemon?.uuid == pokemon.uuid) { "after unload the core holds ${back?.pokemon?.species?.name}, not Charmander" } + s.check(displays(level, pos).size == 1) { "after unload ${displays(level, pos).size} Pokémon are shown, not 1" } + } + .thenExecute { + val back = level.getBlockEntity(pos) as MachineBlockEntity + back.use(player) + s.check(inParty(player, pokemon) && back.pokemon == null) { "Charmander didn't come out after the unload" } + } + .thenSucceed() + } + + private fun beforeRestart(s: Scenario) { + val level = s.level + val pos = spot(level, RESTART_OFFSET) + val player = s.player() + val pokemon = s.give(player, s.pokemon("charmander", 20)) + s.helper.startSequence() + .thenExecute { force(level, pos, true) } + .thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } } + .thenExecute { + insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon) + force(level, pos, false) + } + .thenSucceed() + } + + private fun afterRestart(s: Scenario) { + val level = s.level + val pos = spot(level, RESTART_OFFSET) + s.afterTest { + clearMachine(level, pos) + force(level, pos, false) + } + s.helper.startSequence() + .thenExecute { force(level, pos, true) } + .thenWaitUntil { + val core = level.getBlockEntity(pos) as? MachineBlockEntity + s.check(core?.pokemon?.species?.name == "Charmander") { "after the restart the core holds ${core?.pokemon?.species?.name}, not Charmander" } + s.check(displays(level, pos).size == 1) { "after the restart ${displays(level, pos).size} Pokémon are shown, not 1" } + } + .thenExecute { + val core = level.getBlockEntity(pos) as MachineBlockEntity + s.check(core.giveBack(level, null)) { "Charmander couldn't go back to its (offline) owner after the restart" } + } + .thenSucceed() + } + + private fun heatCore(s: Scenario) { + val level = s.level + val player = s.player() + val core = build(s, "heat_core", Blocks.BRICKS) + insert(s, core, player, s.give(player, s.pokemon("charmander", 50))) + // A furnace against the east wall, a hopper with raw iron above it and an empty one below; no fuel anywhere. + val furnace = core.blockPos.offset(2, 0, -1) + level.setBlockAndUpdate(furnace, Blocks.FURNACE.defaultBlockState()) + level.setBlockAndUpdate(furnace.above(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN)) + level.setBlockAndUpdate(furnace.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN)) + (level.getBlockEntity(furnace.above()) as HopperBlockEntity).setItem(0, ItemStack(Items.RAW_IRON, 4)) + val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity + s.helper.startSequence() + .thenWaitUntil { + val ingots = (0 until output.containerSize).sumOf { output.getItem(it).takeIf { s -> s.`is`(Items.IRON_INGOT) }?.count ?: 0 } + s.check(ingots >= 4) { "the hopper under the furnace has $ingots iron ingots, not 4" } + } + .thenExecute { + val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1) + s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" } + } + .thenSucceed() + } + + private fun dynamo(s: Scenario) { + val level = s.level + val player = s.player() + val core = build(s, "dynamo_core", Blocks.COPPER_BLOCK) + insert(s, core, player, s.give(player, s.pokemon("pikachu", 30))) + val cable = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("poke_power:power_cable")) + s.check(cable != Blocks.AIR) { "Poke Power's cable isn't installed" } + val cablePos = core.blockPos.south() + level.setBlockAndUpdate(cablePos, cable.defaultBlockState()) + s.helper.startSequence() + .thenWaitUntil { + val stored = level.getCapability(Capabilities.EnergyStorage.BLOCK, cablePos, Direction.NORTH)?.energyStored ?: 0 + s.check(stored > 0) { "the cable in front of the Dynamo Core got no energy (core: ${core.energy} FE)" } + } + .thenSucceed() + } + + private fun synthesis(s: Scenario) { + val level = s.level + val player = s.player() + val core = build(s, "synthesis_core", Blocks.GOLD_BLOCK) + insert(s, core, player, s.give(player, s.pokemon("magikarp", 10))) + val rules = MachineDefs.byCore[id("synthesis_core")]!!.synthesis!! + val energy = level.getCapability(Capabilities.EnergyStorage.BLOCK, core.blockPos, Direction.SOUTH) + ?: throw GameTestAssertException("the Synthesis Core takes no energy") + val taken = energy.receiveEnergy(Int.MAX_VALUE, false) + s.check(taken == rules.maxInput) { "the Synthesis Core took $taken FE in one tick, not its cap ${rules.maxInput}" } + level.setBlockAndUpdate(core.blockPos.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN)) + val hopper = level.getBlockEntity(core.blockPos.below()) as HopperBlockEntity + s.helper.startSequence() + .thenWaitUntil { s.check(core.tier == 1) { "a gold shell is tier ${core.tier}, not 1 (looting I)" } } + .thenExecute { core.energy = rules.fePerRoll } + .thenWaitUntil { + val salmon = (0 until hopper.containerSize).sumOf { hopper.getItem(it).takeIf { s -> s.`is`(Items.SALMON) }?.count ?: 0 } + s.check(salmon > 0) { "no salmon from Magikarp's drops reached the hopper under the core" } + } + .thenSucceed() + } + + private fun count(player: ServerPlayer, item: Item) = player.inventory.items.sumOf { if (it.`is`(item)) it.count else 0 } + + private fun altar(s: Scenario) { + val level = s.level + val player = s.player() + val rules = MachineDefs.byCore[id("altar_core")]!!.altar!! + val rareCandy = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:rare_candy")) + s.check(MachineWork.altarRewards(rules, s.pokemon("geodude", 79)).none { it.`is`(rareCandy) }) { "a Lv 79 Pokémon gives rare candies" } + val core = build(s, "altar_core", Blocks.OBSIDIAN) + val pokemon = s.give(player, s.pokemon("dragonite", 100)) + insert(s, core, player, pokemon) + player.isShiftKeyDown = true + core.use(player) + s.check(core.pokemon != null) { "Dragonite was sacrificed without confirming" } + core.use(player) + player.isShiftKeyDown = false + s.check(core.pokemon == null && !inParty(player, pokemon) && !inPc(player, pokemon)) { "Dragonite wasn't sacrificed" } + s.check(count(player, rareCandy) >= 8) { "no rare candies for a Lv 100 sacrifice" } + s.check(count(player, Items.DRAGON_EGG) == 1) { "no dragon egg for a Lv 100 Dragon type" } + s.check(count(player, Items.NETHER_STAR) == 1) { "no nether star for a Lv 100 sacrifice" } + s.helper.startSequence() + .thenWaitUntil { s.check(displays(level, core.blockPos).isEmpty()) { "the altar still shows the sacrificed Pokémon" } } + .thenSucceed() + } + + private fun rebirth(s: Scenario) { + val level = s.level + // The rebirth itself, on its own. + val test = s.pokemon("charizard", 100) + Stats.PERMANENT.forEach { test.setIV(it, 10) } + test.setIV(Stats.SPEED, 31) + val moves = test.moveSet.getMoves().map { it.name } + val ability = test.ability.name + MachineWork.rebirth(test, 3) + s.check(test.species.name == "Charmander" && test.level == 1 && test.shiny) { "rebirth gave ${test.species.name} Lv ${test.level} shiny=${test.shiny}" } + val perfect = Stats.PERMANENT.count { test.ivs[it] == 31 } + s.check(perfect == 4 && Stats.PERMANENT.all { (test.ivs[it] ?: 0) >= 10 }) { "after rebirth $perfect IVs are 31 (wanted 4) or one went down: ${test.ivs}" } + s.check(test.moveSet.getMoves().map { it.name } == moves) { "rebirth changed the moves: $moves -> ${test.moveSet.getMoves().map { it.name }}" } + s.check(test.ability.name == ability) { "rebirth changed the ability: $ability -> ${test.ability.name}" } + + // And in the machine: the time runs out, the owner takes it out with an Ability Patch. + val player = s.player() + val core = build(s, "rebirth_core", Blocks.AMETHYST_BLOCK) + val pokemon = s.give(player, s.pokemon("charizard", 100)) + insert(s, core, player, pokemon) + s.check(core.rebirthAt > level.gameTime) { "the rebirth didn't start" } + val patch = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:ability_patch")) + s.helper.startSequence() + .thenExecute { core.rebirthAt = level.gameTime } + .thenWaitUntil { s.check(core.reborn) { "the rebirth didn't finish" } } + .thenExecute { + core.use(player) + val back = Cobblemon.storage.getParty(player).firstOrNull { it.uuid == pokemon.uuid } + s.check(back?.species?.name == "Charmander" && back.level == 1 && back.shiny) { "${back?.species?.name} came out, not a shiny Lv 1 Charmander" } + s.check(count(player, patch) == 1) { "no Ability Patch came with the reborn Pokémon" } + } + .thenSucceed() + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt index 3761f84..0e38672 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt @@ -54,7 +54,7 @@ object SelfTest { private val cases: List get() = PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() + VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() + - CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all() private var running: List = emptyList() private var forced: List = emptyList() diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json new file mode 100644 index 0000000..fed5d26 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "cobblesurvival:block/altar_core" + }, + "facing=east": { + "model": "cobblesurvival:block/altar_core", + "y": 90 + }, + "facing=south": { + "model": "cobblesurvival:block/altar_core", + "y": 180 + }, + "facing=west": { + "model": "cobblesurvival:block/altar_core", + "y": 270 + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json new file mode 100644 index 0000000..ac94e09 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "cobblesurvival:block/dynamo_core" + }, + "facing=east": { + "model": "cobblesurvival:block/dynamo_core", + "y": 90 + }, + "facing=south": { + "model": "cobblesurvival:block/dynamo_core", + "y": 180 + }, + "facing=west": { + "model": "cobblesurvival:block/dynamo_core", + "y": 270 + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json new file mode 100644 index 0000000..877b72e --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "cobblesurvival:block/heat_core" + }, + "facing=east": { + "model": "cobblesurvival:block/heat_core", + "y": 90 + }, + "facing=south": { + "model": "cobblesurvival:block/heat_core", + "y": 180 + }, + "facing=west": { + "model": "cobblesurvival:block/heat_core", + "y": 270 + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json new file mode 100644 index 0000000..49f5562 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "cobblesurvival:block/rebirth_core" + }, + "facing=east": { + "model": "cobblesurvival:block/rebirth_core", + "y": 90 + }, + "facing=south": { + "model": "cobblesurvival:block/rebirth_core", + "y": 180 + }, + "facing=west": { + "model": "cobblesurvival:block/rebirth_core", + "y": 270 + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json new file mode 100644 index 0000000..0ee236c --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "cobblesurvival:block/synthesis_core" + }, + "facing=east": { + "model": "cobblesurvival:block/synthesis_core", + "y": 90 + }, + "facing=south": { + "model": "cobblesurvival:block/synthesis_core", + "y": 180 + }, + "facing=west": { + "model": "cobblesurvival:block/synthesis_core", + "y": 270 + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json index 21d3e25..76c2c95 100644 --- a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json +++ b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json @@ -7,5 +7,22 @@ "message.cobblesurvival.gate.requirement": "Lv %s, or to hold %s (%s) or a material of a harder zone", "message.cobblesurvival.gate.skill": "%s won't do %s at your base yet. It needs %s.", "message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s.", - "message.cobblesurvival.gate.job": "%s can't take the %s job yet. It needs %s." + "message.cobblesurvival.gate.job": "%s can't take the %s job yet. It needs %s.", + "block.cobblesurvival.heat_core": "Heat Core", + "block.cobblesurvival.dynamo_core": "Dynamo Core", + "block.cobblesurvival.synthesis_core": "Synthesis Core", + "block.cobblesurvival.altar_core": "Altar Core", + "block.cobblesurvival.rebirth_core": "Rebirth Core", + "message.cobblesurvival.machine.shell_broke": "The shell of the %s broke.", + "message.cobblesurvival.machine.not_yours": "This %s holds %s's %s.", + "message.cobblesurvival.machine.incomplete": "The %s's shell isn't complete: %s belongs at %s %s %s.", + "message.cobblesurvival.machine.send_out": "Send out %s near the %s, then use it again.", + "message.cobblesurvival.machine.inserted": "%s went into the %s.", + "message.cobblesurvival.machine.returned": "%s came back to your %s.", + "message.cobblesurvival.machine.core_broke": "The %s was broken.", + "message.cobblesurvival.machine.rebirth_cancelled": "%s's rebirth was cancelled.", + "message.cobblesurvival.machine.sacrificed": "%s is gone. You received %s.", + "message.cobblesurvival.machine.sacrifice_confirm": "Sacrificing %s (Lv %s) gives %s. Sneak-use again within %s seconds to sacrifice it: it's gone for good.", + "message.cobblesurvival.machine.reborn": "%s was reborn in the %s.", + "message.cobblesurvival.machine.status_empty": "%s: empty. Send out %s near it and use it to put the Pokémon in." } diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core.json b/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core.json new file mode 100644 index 0000000..4477e67 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/orientable", + "textures": { + "top": "cobblesurvival:block/altar_core_top", + "front": "cobblesurvival:block/altar_core_front", + "side": "cobblesurvival:block/altar_core_side" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core.json b/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core.json new file mode 100644 index 0000000..b11dfe5 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/orientable", + "textures": { + "top": "cobblesurvival:block/dynamo_core_top", + "front": "cobblesurvival:block/dynamo_core_front", + "side": "cobblesurvival:block/dynamo_core_side" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core.json b/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core.json new file mode 100644 index 0000000..bc2c408 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/orientable", + "textures": { + "top": "cobblesurvival:block/heat_core_top", + "front": "cobblesurvival:block/heat_core_front", + "side": "cobblesurvival:block/heat_core_side" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core.json b/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core.json new file mode 100644 index 0000000..7f8a329 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/orientable", + "textures": { + "top": "cobblesurvival:block/rebirth_core_top", + "front": "cobblesurvival:block/rebirth_core_front", + "side": "cobblesurvival:block/rebirth_core_side" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core.json b/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core.json new file mode 100644 index 0000000..2619560 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core.json @@ -0,0 +1,8 @@ +{ + "parent": "minecraft:block/orientable", + "textures": { + "top": "cobblesurvival:block/synthesis_core_top", + "front": "cobblesurvival:block/synthesis_core_front", + "side": "cobblesurvival:block/synthesis_core_side" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/item/altar_core.json b/mod/src/main/resources/assets/cobblesurvival/models/item/altar_core.json new file mode 100644 index 0000000..c413c43 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/item/altar_core.json @@ -0,0 +1,3 @@ +{ + "parent": "cobblesurvival:block/altar_core" +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/item/dynamo_core.json b/mod/src/main/resources/assets/cobblesurvival/models/item/dynamo_core.json new file mode 100644 index 0000000..dcb9745 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/item/dynamo_core.json @@ -0,0 +1,3 @@ +{ + "parent": "cobblesurvival:block/dynamo_core" +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/item/heat_core.json b/mod/src/main/resources/assets/cobblesurvival/models/item/heat_core.json new file mode 100644 index 0000000..aa23b9a --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/item/heat_core.json @@ -0,0 +1,3 @@ +{ + "parent": "cobblesurvival:block/heat_core" +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/item/rebirth_core.json b/mod/src/main/resources/assets/cobblesurvival/models/item/rebirth_core.json new file mode 100644 index 0000000..de10dc6 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/item/rebirth_core.json @@ -0,0 +1,3 @@ +{ + "parent": "cobblesurvival:block/rebirth_core" +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/item/synthesis_core.json b/mod/src/main/resources/assets/cobblesurvival/models/item/synthesis_core.json new file mode 100644 index 0000000..a66a209 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/item/synthesis_core.json @@ -0,0 +1,3 @@ +{ + "parent": "cobblesurvival:block/synthesis_core" +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front.png new file mode 100644 index 0000000..39b1efe Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_side.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_side.png new file mode 100644 index 0000000..e9c08ee Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_side.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_top.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_top.png new file mode 100644 index 0000000..dd0d529 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_top.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front.png new file mode 100644 index 0000000..6d1e92c Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_side.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_side.png new file mode 100644 index 0000000..5d132fc Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_side.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_top.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_top.png new file mode 100644 index 0000000..a17a139 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_top.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front.png new file mode 100644 index 0000000..085a183 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_side.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_side.png new file mode 100644 index 0000000..e2da16f Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_side.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_top.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_top.png new file mode 100644 index 0000000..e55222f Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_top.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front.png new file mode 100644 index 0000000..17a4668 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_side.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_side.png new file mode 100644 index 0000000..ea2c469 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_side.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_top.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_top.png new file mode 100644 index 0000000..d8e6415 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_top.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front.png new file mode 100644 index 0000000..1dd7334 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_side.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_side.png new file mode 100644 index 0000000..524e9fa Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_side.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_top.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_top.png new file mode 100644 index 0000000..2724a90 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_top.png differ diff --git a/tools/machine_assets.py b/tools/machine_assets.py new file mode 100644 index 0000000..f7c9e64 --- /dev/null +++ b/tools/machine_assets.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Writes the machine cores' textures, block models, item models and blockstates (Phase 6). + +Each core is an "orientable" block (front, side, top) facing the player who placed it. The 16x16 textures are drawn +here, from a palette and an emblem per machine, so they can be redrawn after a change. Our own art (pack license). + +Usage: tools/machine_assets.py +""" +import json +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +ASSETS = ROOT / "mod/src/main/resources/assets/cobblesurvival" + +# name: (dark, mid, light, accent, emblem) +CORES = { + "heat_core": ((92, 34, 26), (150, 62, 44), (186, 92, 66), (255, 160, 40), "flame"), + "dynamo_core": ((112, 58, 36), (184, 104, 70), (224, 150, 108), (255, 230, 70), "bolt"), + "synthesis_core": ((70, 74, 80), (128, 134, 142), (180, 186, 194), (90, 220, 120), "drop"), + "altar_core": ((22, 18, 26), (52, 44, 58), (84, 72, 92), (190, 60, 220), "eye"), + "rebirth_core": ((70, 40, 110), (130, 90, 190), (186, 150, 236), (255, 200, 245), "star"), +} + +EMBLEMS = { + "flame": [ + "......#.........", + ".....##.........", + ".....###..#.....", + "....####.##.....", + "...#########....", + "...##########...", + "..###++######...", + "..##++++#####...", + "..##+++++####...", + "...##+++++##....", + "....########....", + ], + "bolt": [ + "........####....", + ".......####.....", + "......####......", + ".....####.......", + "....#######.....", + ".......####.....", + "......####......", + ".....###........", + "....##..........", + "...#............", + ], + "drop": [ + ".......#........", + "......###.......", + ".....#####......", + "....###+###.....", + "...###+++###....", + "...##+++++##....", + "...###+++###....", + "....#######.....", + ".....#####......", + ], + "eye": [ + "................", + ".....######.....", + "...##......##...", + "..#...####...#..", + ".#...##++##...#.", + "..#...####...#..", + "...##......##...", + ".....######.....", + ], + "star": [ + ".......#........", + ".......#........", + "......###.......", + "..###########...", + "....#######.....", + ".....#####......", + "....###.###.....", + "...##.....##....", + ], +} + + +def frame(image, dark, light): + for i in range(16): + image.putpixel((i, 0), light) + image.putpixel((0, i), light) + image.putpixel((i, 15), dark) + image.putpixel((15, i), dark) + + +def side(dark, mid, light): + image = Image.new("RGBA", (16, 16), mid + (255,)) + for y in range(16): + for x in range(16): + # Plates of 8x4 with a seam, offset every other row, like bricks or panels. + if y % 4 == 0 or (x + (4 if (y // 4) % 2 else 0)) % 8 == 0: + image.putpixel((x, y), dark + (255,)) + elif (x + y) % 7 == 0: + image.putpixel((x, y), light + (255,)) + frame(image, dark + (255,), light + (255,)) + return image + + +def top(dark, mid, light, accent): + image = side(dark, mid, light) + for y in range(5, 11): + for x in range(5, 11): + edge = x in (5, 10) or y in (5, 10) + image.putpixel((x, y), (dark if edge else accent) + (255,)) + return image + + +def front(dark, mid, light, accent, emblem): + image = Image.new("RGBA", (16, 16), dark + (255,)) + for y in range(2, 14): + for x in range(2, 14): + image.putpixel((x, y), (mid if (x + y) % 2 else dark) + (255,)) + frame(image, dark + (255,), light + (255,)) + rows = EMBLEMS[emblem] + top_row = (16 - len(rows)) // 2 + glow = tuple(min(255, c + 60) for c in accent) + for y, row in enumerate(rows): + for x, c in enumerate(row): + if c == "#": + image.putpixel((x, top_row + y), accent + (255,)) + elif c == "+": + image.putpixel((x, top_row + y), glow + (255,)) + return image + + +def write_json(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +def main(): + textures = ASSETS / "textures/block" + textures.mkdir(parents=True, exist_ok=True) + for name, (dark, mid, light, accent, emblem) in CORES.items(): + side(dark, mid, light).save(textures / f"{name}_side.png") + top(dark, mid, light, accent).save(textures / f"{name}_top.png") + front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png") + write_json(ASSETS / f"models/block/{name}.json", { + "parent": "minecraft:block/orientable", + "textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{name}_front", + "side": f"cobblesurvival:block/{name}_side"}, + }) + write_json(ASSETS / f"models/item/{name}.json", {"parent": f"cobblesurvival:block/{name}"}) + rotation = {"north": 0, "east": 90, "south": 180, "west": 270} + write_json(ASSETS / f"blockstates/{name}.json", { + "variants": {f"facing={facing}": {"model": f"cobblesurvival:block/{name}", **({"y": y} if y else {})} + for facing, y in rotation.items()}, + }) + print(f"wrote {len(CORES)} cores to {ASSETS.relative_to(ROOT)}") + + +if __name__ == "__main__": + main()