Big, tough bosses; working machines show it; JEI drop pages on the roadmap

Bosses (Adrian, 2026-11-02: Eternatus too small and too easy; guardians way
bigger and tougher; only defensive stats up; no attack takes more than 25 %):
- Guardians scale to about 8 blocks tall (1.5x-12x), Eternatus to 15 blocks
  (2.5x, was 0.2x). Cave guardians look for a spot with room above them.
  Bosses already in a world grow on the next check outside battle.
- Perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive.
- The Guardian's Ward, a Showdown-only held item (held_items/guardiansward.js)
  handed to every boss by a HeldItemProvider: Defense and Sp. Def x2 while
  defending, and one attack (all hits together) takes at most 25 % of max HP.
- Area attacks that start at the boss reach half its width further.

Machines (Adrian, 2026-11-02): a working core is active: it glows, its front
animates (flickering fire on the furnace core, pulsing emblems elsewhere), and
particles show in the chamber and at the core (client side). The Pokemon
inside flinches about every 15 s (red flash, Cobblemon's hit sound, no damage).

Roadmap Phase 8: every item that can drop gets a JEI page naming its sources.

Self-tests: practice_bosses_spawn checks size and training,
boss_takes_a_quarter_per_attack runs a real battle (each Lv 100 Bullet Seed
takes exactly a quarter), heat_core_cooks_with_hoppers checks the active state
and the flinch. Self-tests and the island check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-03 10:00:57 +01:00
1 parent 4f0bc03919
commit 120c664cb0
58 files changed
+681 -86

No files matched your search

@@ -3,10 +3,10 @@
"end": { "end": {
"species": "eternatus", "species": "eternatus",
"level": 80, "level": 80,
"scale": 0.2,
"respawn_hours": 5, "respawn_hours": 5,
"xp_first_win": 12000, "xp_first_win": 12000,
"xp": 500 "xp": 500,
"scale": 2.5
}, },
"arena": 40, "arena": 40,
"patterns": [ "patterns": [
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "basalt", "zone": "basalt",
"pokemon": "moltres level=75 uncatchable=yes", "pokemon": "moltres level=75 uncatchable=yes",
"scale": 1.5, "scale": 2.0,
"drops": [ "drops": [
{ {
"item": "cobblemon:exp_candy_xl", "item": "cobblemon:exp_candy_xl",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "button_isle", "zone": "button_isle",
"pokemon": "morpeko level=15 uncatchable=yes", "pokemon": "morpeko level=15 uncatchable=yes",
"scale": 1.5, "scale": 12,
"drops": [ "drops": [
{ {
"item": "cobblemon:poke_ball", "item": "cobblemon:poke_ball",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "deep_dark", "zone": "deep_dark",
"pokemon": "regigigas level=65 uncatchable=yes", "pokemon": "regigigas level=65 uncatchable=yes",
"scale": 1.0, "scale": 1.5,
"drops": [ "drops": [
{ {
"item": "cobblemon:icy_rock", "item": "cobblemon:icy_rock",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "deep_sea", "zone": "deep_sea",
"pokemon": "kingdra level=55 uncatchable=yes", "pokemon": "kingdra level=55 uncatchable=yes",
"scale": 1.5, "scale": 2.96,
"drops": [ "drops": [
{ {
"item": "cobblemon:mystic_water", "item": "cobblemon:mystic_water",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "frostpeak", "zone": "frostpeak",
"pokemon": "abomasnow level=60 uncatchable=yes", "pokemon": "abomasnow level=60 uncatchable=yes",
"scale": 1.5, "scale": 3.2,
"drops": [ "drops": [
{ {
"item": "cobblemon:icy_rock", "item": "cobblemon:icy_rock",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "highlands", "zone": "highlands",
"pokemon": "mudsdale level=40 uncatchable=yes", "pokemon": "mudsdale level=40 uncatchable=yes",
"scale": 1.5, "scale": 3.08,
"drops": [ "drops": [
{ {
"item": "cobblemon:icy_rock", "item": "cobblemon:icy_rock",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "jungle", "zone": "jungle",
"pokemon": "tangrowth level=35 uncatchable=yes", "pokemon": "tangrowth level=35 uncatchable=yes",
"scale": 1.5, "scale": 3.33,
"drops": [ "drops": [
{ {
"item": "cobblesurvival:mire_waders", "item": "cobblesurvival:mire_waders",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "mire", "zone": "mire",
"pokemon": "swampert level=43 uncatchable=yes", "pokemon": "swampert level=43 uncatchable=yes",
"scale": 1.5, "scale": 5.0,
"drops": [ "drops": [
{ {
"item": "cobblemon:icy_rock", "item": "cobblemon:icy_rock",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "southwold", "zone": "southwold",
"pokemon": "pinsir level=23 uncatchable=yes", "pokemon": "pinsir level=23 uncatchable=yes",
"scale": 1.5, "scale": 6.13,
"drops": [ "drops": [
{ {
"item": "cobblemon:great_ball", "item": "cobblemon:great_ball",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "sunscorch", "zone": "sunscorch",
"pokemon": "flygon level=50 uncatchable=yes", "pokemon": "flygon level=50 uncatchable=yes",
"scale": 1.5, "scale": 2.78,
"drops": [ "drops": [
{ {
"item": "cobblemon:heat_rock", "item": "cobblemon:heat_rock",
@@ -2,7 +2,7 @@
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": "westmarch", "zone": "westmarch",
"pokemon": "scyther level=30 uncatchable=yes", "pokemon": "scyther level=30 uncatchable=yes",
"scale": 1.5, "scale": 4.73,
"drops": [ "drops": [
{ {
"item": "cobblesurvival:mire_waders", "item": "cobblesurvival:mire_waders",
@@ -0,0 +1,49 @@
/*
* Cobblemon Survival Pack: the Guardian's Ward, a Showdown held item (Cobblemon loads data/<ns>/held_items/<id>.js).
* No Minecraft item exists for it: the companion mod gives it to every boss in battle (boss/BossToughness.kt), so it
* can't be stolen, knocked off or suppressed (the work is done by a lasting condition it starts). Adrian, 2026-11-02:
* bosses are tougher but not stronger, and no single attack takes more than a quarter of their HP.
*
* Showdown names the item after "name" (guardiansward), which must match this file's name. Cobblemon joins the file
* into one line before Showdown reads it: block comments only.
* Tunables (docs/balance.md, "Boss toughness"): DEF_FACTOR, SPD_FACTOR and HP_SHARE below.
*/
{
name: "Guardian's Ward",
spritenum: 0,
ignoreKlutz: true,
onTakeItem: false,
onStart(pokemon) {
pokemon.addVolatile('guardiansward');
},
condition: {
noCopy: true,
/* Defense and Sp. Def only while the boss defends: Body Press (its own Defense as attack) stays as it is. */
onModifyDefPriority: 1,
onModifyDef(def, pokemon) {
const DEF_FACTOR = 2;
if (this.activePokemon !== pokemon) return this.chainModify(DEF_FACTOR);
},
onModifySpDPriority: 1,
onModifySpD(spd, pokemon) {
const SPD_FACTOR = 2;
if (this.activePokemon !== pokemon) return this.chainModify(SPD_FACTOR);
},
/* One attack (every hit of a multi-hit move together) takes at most HP_SHARE of the boss's max HP. */
onDamagePriority: -100,
onDamage(damage, target, source, effect) {
const HP_SHARE = 0.25;
if (!effect || effect.effectType !== 'Move') return;
if (this.effectState.move !== effect) {
this.effectState.move = effect;
this.effectState.taken = 0;
}
const left = Math.max(0, Math.floor(target.maxhp * HP_SHARE) - this.effectState.taken);
const dealt = Math.min(damage, left);
this.effectState.taken += dealt;
return dealt;
},
},
num: -2001,
gen: 9,
}
+5 -2
View File
@@ -24,7 +24,7 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
| Sewn-in insulation | goat fur 2 cold, hoglin hide 2 heat, wool 0.5 cold, leather 0.5/0.5 (Cold Sweat: wool 1.5, leather 1/1, rabbit hide 1.5 heat) | Decided (Adrian, 2026-10-30) | `config/coldsweat/item.toml` | | | Sewn-in insulation | goat fur 2 cold, hoglin hide 2 heat, wool 0.5 cold, leather 0.5/0.5 (Cold Sweat: wool 1.5, leather 1/1, rabbit hide 1.5 heat) | Decided (Adrian, 2026-10-30) | `config/coldsweat/item.toml` | |
| Goat Fur / Hoglin Hide recipes | 8 wool + 1 Heat Rock → 8 Goat Fur; 8 leather + 1 Icy Rock → 8 Hoglin Hide (a set takes 24) | Decided (Adrian, 2026-10-30) | `kubejs/server_scripts/insulation.js` | Heat and Icy Rocks come from the guardians before Frostpeak and Sunscorch (archaeology finds too) | | Goat Fur / Hoglin Hide recipes | 8 wool + 1 Heat Rock → 8 Goat Fur; 8 leather + 1 Icy Rock → 8 Hoglin Hide (a set takes 24) | Decided (Adrian, 2026-10-30) | `kubejs/server_scripts/insulation.js` | Heat and Icy Rocks come from the guardians before Frostpeak and Sunscorch (archaeology finds too) |
| Guardian respawn | 5 hours of game time after being beaten | Decided (Adrian, 2026-10-29) | `world/zones.json` → `guardian_respawn_hours` | Fixed spots | | Guardian respawn | 5 hours of game time after being beaten | Decided (Adrian, 2026-10-29) | `world/zones.json` → `guardian_respawn_hours` | Fixed spots |
| Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, 1.5× size, uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Pinned by hand: Southwold Pinsir, Highland Meadows Mudsdale, Sunscorch Flygon, Frostpeak Abomasnow, Deep Dark Regigigas (normal size), Basalt Furnace Moltres (Lv 75; drops 3 Exp. Candy XL + 2 netherite scrap, placeholder) (Adrian, 2026-11-02: guardians fit their zone, a legendary for the Deep Dark) | | Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, scaled to stand about 8 blocks tall (1.5× to 12× its normal size), uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Pinned by hand: Southwold Pinsir, Highland Meadows Mudsdale, Sunscorch Flygon, Frostpeak Abomasnow, Deep Dark Regigigas, Basalt Furnace Moltres (Lv 75; drops 3 Exp. Candy XL + 2 netherite scrap, placeholder) (Adrian, 2026-11-02: guardians fit their zone, a legendary for the Deep Dark) |
| Guardian drops | Button Isle 10 Poké Balls + 4 Potions; Southwold 6 Great Balls + 4 Exp. Candy S; Westmarch and Jungle Mire Waders + 4 Pecha Berries + 2 Exp. Candy M; Mire and Highlands 3 Icy Rocks + 4 Super Potions; Sunscorch 3 Heat Rocks + 2 Exp. Candy L; Frostpeak and Deep Dark 3 Icy Rocks + 2 Exp. Candy L; Deep Sea Mystic Water + 3 Exp. Candy M | Decided (Adrian, 2026-10-30) | `world/zones.json` → `guardian.drops` | 3 rocks make one full fur or hide set | | Guardian drops | Button Isle 10 Poké Balls + 4 Potions; Southwold 6 Great Balls + 4 Exp. Candy S; Westmarch and Jungle Mire Waders + 4 Pecha Berries + 2 Exp. Candy M; Mire and Highlands 3 Icy Rocks + 4 Super Potions; Sunscorch 3 Heat Rocks + 2 Exp. Candy L; Frostpeak and Deep Dark 3 Icy Rocks + 2 Exp. Candy L; Deep Sea Mystic Water + 3 Exp. Candy M | Decided (Adrian, 2026-10-30) | `world/zones.json` → `guardian.drops` | 3 rocks make one full fur or hide set |
| 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 | | 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 | | 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 |
@@ -38,8 +38,11 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
| 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` | | | 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` | |
| Boss attacks (guardians) | by guardian level: up to Lv 20 a circle under the player; up to 35 + a line from the boss; up to 50 + a cone; up to 65 + a ring around the boss at half HP; above + a wide ring at a quarter HP. Telegraph 2.5 s at the lowest tier down to 1.5 s; damage level/6 health points (Morpeko 3, Moltres 12); circle every 7.4 s down to 5.6 s, line and cone less often; Fire types burn, Ice types freeze, Poison types poison | Placeholder | `tools/zone_bosses.py` → `datapacks/core/data/cobblesurvival/bosses/*.json` | Adrian, 2026-11-02: every guardian, harder in harder zones | | Boss attacks (guardians) | by guardian level: up to Lv 20 a circle under the player; up to 35 + a line from the boss; up to 50 + a cone; up to 65 + a ring around the boss at half HP; above + a wide ring at a quarter HP. Telegraph 2.5 s at the lowest tier down to 1.5 s; damage level/6 health points (Morpeko 3, Moltres 12); circle every 7.4 s down to 5.6 s, line and cone less often; Fire types burn, Ice types freeze, Poison types poison | Placeholder | `tools/zone_bosses.py` → `datapacks/core/data/cobblesurvival/bosses/*.json` | Adrian, 2026-11-02: every guardian, harder in harder zones |
| Boss arena | 24 blocks around where the boss stood; Basalt Furnace 32; the End 40; leaving it or dying loses the fight | Placeholder | same files, `arena` | | | Boss arena | 24 blocks around where the boss stood; Basalt Furnace 32; the End 40; leaving it or dying loses the fight | Placeholder | same files, `arena` | |
| End boss | Eternatus Lv 80, 0.2× size (its model is 20 m tall), back 5 hours after a win; 12,000 XP the first time, 500 after | Placeholder | `bosses/end.json` (`tools/zone_bosses.py` → `END`) | Stands in for Giratina, which Cobblemon 1.8.4 has no model for | | End boss | Eternatus Lv 80, 2.5× size (15 blocks tall; it was 0.2×, about a block, until Adrian found it too small, 2026-11-02), back 5 hours after a win; 12,000 XP the first time, 500 after | Placeholder | `bosses/end.json` (`tools/zone_bosses.py` → `END`) | Stands in for Giratina, which Cobblemon 1.8.4 has no model for |
| End spawns | the End is a zone, Lv 50–70; Cobblemon's End lines spawn there (Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack), all in the common bucket, each line's heaviest entry weight 10, Metagross 2 | Placeholder | `world/zones.json` → `dimensions` (`levels`, `bucket`, `line_weight`, `rare_factor`), then `tools/zone_layout.py` and `tools/zone_spawns.py` | Adrian, 2026-11-02: the End was barren; Lv 50–70 his pick | | End spawns | the End is a zone, Lv 50–70; Cobblemon's End lines spawn there (Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack), all in the common bucket, each line's heaviest entry weight 10, Metagross 2 | Placeholder | `world/zones.json` → `dimensions` (`levels`, `bucket`, `line_weight`, `rare_factor`), then `tools/zone_layout.py` and `tools/zone_spawns.py` | Adrian, 2026-11-02: the End was barren; Lv 50–70 his pick |
| Boss size | guardians about 8 blocks tall (Morpeko 4.9 at the 12× cap, Moltres 8, Regigigas 9.8 at the 1.5× floor); Eternatus 15 blocks; a cave guardian's spot needs that much air above it, else the roomiest floor within 24 blocks | Placeholder | `tools/zone_spawns.py` (`GUARDIAN_HEIGHT`, `GUARDIAN_SCALE_MIN`, `GUARDIAN_SCALE_MAX`), `tools/zone_bosses.py` (`END_HEIGHT`) | Adrian, 2026-11-02: way bigger. Area attacks that start at the boss reach half its width further |
| Boss toughness | perfect IVs and 170 EVs each in HP, Defense and Sp. Def (510, the games' limit), nothing in Attack, Sp. Atk or Speed; in battle the Guardian's Ward: Defense and Sp. Def ×2 while defending, and one attack (all hits of a multi-hit move together) takes at most 25 % of max HP | Placeholder | `BossToughness.EVS`; `datapacks/core/data/cobblesurvival/held_items/guardiansward.js` (`DEF_FACTOR`, `SPD_FACTOR`, `HP_SHARE`) | Adrian, 2026-11-02: tougher, not stronger; the 25 % cap is his number. Applies to guardians, the End boss and practice copies |
| Machine look | a working core glows (light 10), its front animates (8 frames, 3 ticks each) and particles show; it keeps looking busy 2 s after the work stops; the Pokémon inside flinches (red flash, hit sound, no damage) about every 15 s while it works | Placeholder | `MachineBlock.ACTIVE_LIGHT`, `tools/machine_assets.py` (`FRAMES`, `FRAME_TICKS`), `MachineEffects` (particle and sound chances), `MachineBlockEntity` (`ACTIVE_LINGER`, `FLINCH_ONE_IN`) | Adrian, 2026-11-02. Working means: a heated furnace has something to cook, the generator isn't full, the synthesizer gets energy, a rebirth runs, the altar holds a Pokémon |
| 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 | | 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 | | 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`) | | | 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`) | |
+2
View File
@@ -20,6 +20,7 @@ The middle of each side wall and the top centre may be glass, to see the Pokémo
- 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). - 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`. - 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`.
- **A working machine shows it** (added 2026-11-02): the core glows, its front animates (flickering fire on the furnace core, pulsing emblems on the others) and particles show around the Pokémon and at the core; about every 15 s the Pokémon flinches (red flash, hit sound) without taking damage. Automated in `heat_core_cooks_with_hoppers`.
- 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`. - 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) ## In game (Prism instance)
@@ -31,6 +32,7 @@ Elsewhere, creative mode gives the cores in the Functional Blocks tab; each has
- [ ] Use a bare core (third row): ghost blocks show its shell for ten seconds, and chat lists what's missing. Place a new core anywhere: the same. - [ ] Use a bare core (third row): ghost blocks show its shell for ten seconds, and chat lists what's missing. Place a new core anywhere: the same.
- [ ] Hover a core in the inventory: the tooltip says what it does and what its shell is made of. - [ ] Hover a core in the inventory: the tooltip says what it does and what its shell is made of.
- [ ] Look at a working machine from the front: the core is see-through (frame and tinted glass), the Pokémon shows behind it. - [ ] Look at a working machine from the front: the core is see-through (frame and tinted glass), the Pokémon shows behind it.
- [ ] Watch the working machines in the first row: each core glows with an animated front (the furnace core's fire flickers), particles show in the chamber (flames, sparks, glyphs, souls, end rods) and at the core; now and then the Pokémon flashes red with a hit sound. An empty machine, or a furnace with nothing to cook, stays dark.
- [ ] Sacrifice a Pokémon, or finish a rebirth: the Pokémon in the chamber faints with its cry pitched down, then disappears (a reborn one comes back in its new form). - [ ] Sacrifice a Pokémon, or finish a rebirth: the Pokémon in the chamber faints with its cry pitched down, then disappears (a reborn one comes back in its new form).
- [ ] 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) - [ ] 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)
+3
View File
@@ -10,6 +10,7 @@
- **Boss fights:** a battle with a guardian (or the End boss) becomes a fight with area attacks: red or coloured particles on the ground show where an attack lands, about 1.5–2.5 seconds later it hits whoever stands inside. Circles, lines, cones and rings; harder zones get more of them. Fire, Ice and Poison bosses burn, freeze or poison. Automated: `boss_attack_shapes`, `boss_attacks_land_and_stop_with_the_battle`. - **Boss fights:** a battle with a guardian (or the End boss) becomes a fight with area attacks: red or coloured particles on the ground show where an attack lands, about 1.5–2.5 seconds later it hits whoever stands inside. Circles, lines, cones and rings; harder zones get more of them. Fire, Ice and Poison bosses burn, freeze or poison. Automated: `boss_attack_shapes`, `boss_attacks_land_and_stop_with_the_battle`.
- **Arena:** leaving it (24 blocks; Basalt 32; End 40) or dying ends the battle as lost; the boss heals and stays. Automated: `boss_arena_leaving_loses`. - **Arena:** leaving it (24 blocks; Basalt 32; End 40) or dying ends the battle as lost; the boss heals and stays. Automated: `boss_arena_leaving_loses`.
- **The End:** the Ender Dragon is gone; Eternatus appears on the main island, with the dragon's boss bar; beating it opens the exit portal and gateway, leaves the egg the first time and gives experience. Automated: `end_boss_replaces_the_dragon`. - **The End:** the Ender Dragon is gone; Eternatus appears on the main island, with the dragon's boss bar; beating it opens the exit portal and gateway, leaves the egg the first time and gives experience. Automated: `end_boss_replaces_the_dragon`.
- **Big and tough bosses** (Adrian, 2026-11-02): guardians stand about 8 blocks tall, Eternatus 15. Only HP, Defense and Sp. Def are raised (training, and the Guardian's Ward in battle doubles Defense and Sp. Def); no single attack takes more than a quarter of their HP. Guardians already in your world grow on the next check. Automated: `practice_bosses_spawn`, `boss_takes_a_quarter_per_attack`.
- **End spawns:** the End is a zone (Lv 50–70) with Cobblemon's End lines: Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Automated: `end_has_spawns`. - **End spawns:** the End is a zone (Lv 50–70) with Cobblemon's End lines: Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Automated: `end_has_spawns`.
## In game (Prism instance) ## In game (Prism instance)
@@ -22,6 +23,8 @@ On the island save (or a new one), in creative or survival:
- [ ] Leave the End and come back without beating Eternatus: still only one (fixed 2026-11-02). - [ ] Leave the End and come back without beating Eternatus: still only one (fixed 2026-11-02).
- [ ] Go to the End: no dragon; Eternatus stands south of the portal, the boss bar shows its HP. Beat it: the exit portal opens, the egg appears. - [ ] Go to the End: no dragon; Eternatus stands south of the portal, the boss bar shows its HP. Beat it: the exit portal opens, the egg appears.
- [ ] Find Regigigas in a Deep Dark cave and Moltres in the Basalt Furnace. - [ ] Find Regigigas in a Deep Dark cave and Moltres in the Basalt Furnace.
- [ ] Go to the End: Eternatus is huge now (15 blocks). Fight it: no attack takes more than a quarter of its HP bar, even a super-effective one; it needs at least four hits. Its attacks are as strong as before.
- [ ] Visit a guardian (`/cobblesurvival boss <zone>` spawns a practice copy next to you): about 8 blocks tall; Regigigas and Moltres stand in caves with room above them.
- [ ] Walk around the End's islands for a minute: Beheeyem, Reuniclus and now and then Metagross appear, Lv 50–70 (`/cobblesurvival zone` shows "The End"). - [ ] Walk around the End's islands for a minute: Beheeyem, Reuniclus and now and then Metagross appear, Lv 50–70 (`/cobblesurvival zone` shows "The End").
## Ask Adrian ## Ask Adrian
+6 -2
View File
@@ -261,6 +261,8 @@ Adrian's choices: the Large Furnace has a Fire Pokémon heat real furnaces, and
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). 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).
**A working machine shows it** (Adrian, 2026-11-02: particles around the Pokémon and an animated core, like the fire in a furnace; the Pokémon should now and then look as if it takes a hit). While its machine works, the core's block state is `active`: the core gives off light, its front switches to an animated face (the furnace's flame flickers; the other emblems pulse with sparks rising; `tools/machine_assets.py`), and particles show (`MachineEffects`, client side): flames around a Fire Pokémon and at the furnace core's front with smoke and the furnace crackle; electric sparks in the generator; enchanting glyphs flowing into the Pokémon in the synthesizer; souls and soul fire at the altar; end rods and glyphs in the rebirth chamber. It stays active two seconds after the work stops, so it doesn't flicker. About every 15 seconds of work the Pokémon inside flinches: it flashes red and Cobblemon's hit sound plays, but nothing is hurt (`MachineDisplay.flinch`). Working means: a heated furnace has something to cook, the generator isn't full, the synthesizer gets energy, a rebirth runs, the altar holds a Pokémon. Self-test: `heat_core_cooks_with_hoppers` also checks that the core turns active while cooking and back afterwards, and that a flinch leaves the Pokémon unharmed.
**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. **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.
## Phase 7: boss battles with area attacks (Adrian, 2026-11-02) ## Phase 7: boss battles with area attacks (Adrian, 2026-11-02)
@@ -274,7 +276,7 @@ Adrian's choices: every zone guardian gets area attacks (more and harder in hard
| Highland Meadows | Cinderace | **Mudsdale** | a draft horse of highland pastures, not a starter | | Highland Meadows | Cinderace | **Mudsdale** | a draft horse of highland pastures, not a starter |
| Sunscorch | Delphox | **Flygon** | the desert spirit of sandstorms | | Sunscorch | Delphox | **Flygon** | the desert spirit of sandstorms |
| Frostpeak Range | Tyranitar | **Abomasnow** | lives on snowy mountains; spawns there | | Frostpeak Range | Tyranitar | **Abomasnow** | lives on snowy mountains; spawns there |
| Deep Dark | Crobat | **Regigigas** (normal size) | a legendary; the ancient colossus sleeping in ruins, like the Ancient Cities | | Deep Dark | Crobat | **Regigigas** | a legendary; the ancient colossus sleeping in ruins, like the Ancient Cities |
| Basalt Furnace | none | **Moltres** (Lv 75) | the flame bird of the volcano; stands in for Groudon | | Basalt Furnace | none | **Moltres** (Lv 75) | the flame bird of the volcano; stands in for Groudon |
| The End | Ender Dragon | **Eternatus** | the cosmic final boss; stands in for Giratina | | The End | Ender Dragon | **Eternatus** | the cosmic final boss; stands in for Giratina |
@@ -288,7 +290,9 @@ The others fit and stay: Morpeko (Button Isle), Pinsir (Southwold), Scyther (Wes
Self-tests (`BossTests`): every guardian has a fight and a Cobblemon model; each attack shape hits a player standing in it and misses one ten blocks away; attacks stop when the battle ends; leaving the arena ends the battle and the boss heals; the dragon can't join the End, Eternatus appears, and beating it opens the exit portal with the egg. Self-tests (`BossTests`): every guardian has a fight and a Cobblemon model; each attack shape hits a player standing in it and misses one ten blocks away; attacks stop when the battle ends; leaving the arena ends the battle and the boss heals; the dragon can't join the End, Eternatus appears, and beating it opens the exit portal with the egg.
**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, scaled down to fit) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). Only one at a time: it waits until the entities around its spot have loaded before deciding it's missing, and a second copy never joins while the recorded one is alive (Adrian met a second Eternatus on his second visit, 2026-11-02); extra copies left in older worlds are removed. **Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, 15 blocks tall) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). Only one at a time: it waits until the entities around its spot have loaded before deciding it's missing, and a second copy never joins while the recorded one is alive (Adrian met a second Eternatus on his second visit, 2026-11-02); extra copies left in older worlds are removed.
**Bosses are big and tough, not strong** (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be way bigger and tougher; only defensive stats go up, never Attack or Speed; no single attack may take more than a quarter of a boss's HP). Every boss (guardian, End boss, practice copy) is scaled to stand about 8 blocks tall (Eternatus 15; `tools/zone_spawns.py`, `tools/zone_bosses.py`) and gets perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive (`BossToughness`). In battle it brings the **Guardian's Ward**, a held item that exists only inside Cobblemon's battle engine (Showdown): Cobblemon loads it from `datapacks/core/data/cobblesurvival/held_items/guardiansward.js`, and the companion mod hands it to every boss ahead of whatever it holds, so it can't be stolen, knocked off or suppressed. It starts a lasting condition that doubles Defense and Sp. Def while the boss defends (not for Body Press) and caps one attack, all hits of a multi-hit move together, at a quarter of the boss's max HP. Guardians and an Eternatus already standing in a world grow and get their training on the next check outside battle; a cave guardian moves to a spot it fits in. Area attacks that start at the boss (rings, lines, cones) reach half its width further, so a big boss's ring starts at its edge. Self-tests: `practice_bosses_spawn` (size, training, full health) and `boss_takes_a_quarter_per_attack` (a real battle: a Lv 100 Garchomp's Bullet Seed, every hit enough to knock out the Lv 15 boss, takes exactly a quarter per use).
**The End has Pokémon** (Adrian, 2026-11-02: it looked barren). The End is a zone of its own, "The End", Lv 50–70 (`world/zones.json` → `dimensions`; `Zones.at` returns it anywhere in the End). Every line Cobblemon spawns in End biomes spawns there too, on top of its home on the island: Beheeyem, Reuniclus and Metagross anywhere; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Gothita had no island home (it only spawns in the End), so it's new. Cobblemon has all of these uncommon or rare, and it rolls the bucket before the Pokémon, so in the End they move to the common bucket or the End would stay nearly empty; each line weighs the same, Metagross a fifth. Being a zone, the End also has level pressure (full experience up to Lv 75, like the Basalt Furnace) and the wild-Pokémon cleanup above Lv 80; Eternatus is exempt as an uncatchable boss. Self-test `end_has_spawns`. **The End has Pokémon** (Adrian, 2026-11-02: it looked barren). The End is a zone of its own, "The End", Lv 50–70 (`world/zones.json` → `dimensions`; `Zones.at` returns it anywhere in the End). Every line Cobblemon spawns in End biomes spawns there too, on top of its home on the island: Beheeyem, Reuniclus and Metagross anywhere; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Gothita had no island home (it only spawns in the End), so it's new. Cobblemon has all of these uncommon or rare, and it rolls the bucket before the Pokémon, so in the End they move to the common bucket or the End would stay nearly empty; each line weighs the same, Metagross a fifth. Being a zone, the End also has level pressure (full experience up to Lv 75, like the Basalt Furnace) and the wild-Pokémon cleanup above Lv 80; Eternatus is exempt as an uncatchable boss. Self-test `end_has_spawns`.
+2 -1
View File
@@ -182,9 +182,10 @@ Goal: a quest book that points players onward, laid out like a research tree who
- **Soft dependencies.** Use FTB Quests' Flexible progression mode, where a quest's tasks can be completed before its dependencies. A community fix mod reports a [soft-lock in that mode](https://modrinth.com/project/SWZGEk5x) when tasks finish first; test for it and add the fix if needed. Hide the per-quest lock icon ("Hide Lock Icon") so nothing looks locked, and keep dependency lines visible. - **Soft dependencies.** Use FTB Quests' Flexible progression mode, where a quest's tasks can be completed before its dependencies. A community fix mod reports a [soft-lock in that mode](https://modrinth.com/project/SWZGEk5x) when tasks finish first; test for it and add the fix if needed. Hide the per-quest lock icon ("Hide Lock Icon") so nothing looks locked, and keep dependency lines visible.
- **Layout.** One chapter per zone plus chapters for base building, Pokémon jobs and machines. Each chapter reads left to right, from basics to advanced. - **Layout.** One chapter per zone plus chapters for base building, Pokémon jobs and machines. Each chapter reads left to right, from basics to advanced.
- **Content.** Tasks: catch a species, obtain a drop, visit a biome, build a machine, beat a guardian. Rewards stay small and never unlock anything. Cobblemon-specific task types come from a Cobblemon–FTB Quests integration addon; check its 1.8 support. - **Content.** Tasks: catch a species, obtain a drop, visit a biome, build a machine, beat a guardian. Rewards stay small and never unlock anything. Cobblemon-specific task types come from a Cobblemon–FTB Quests integration addon; check its 1.8 support.
- **Drop sources in JEI** (Adrian, 2026-11-02). Every item that can drop gets a JEI page (JEI is the pack's NEI) showing where it drops: which Pokémon (chance, amount, and the zone they spawn in), guardian and End boss rewards, the Sacrifice Altar's level tiers, the Drop Synthesizer, zone ore veins and the pasture mining/finder tables. Generate the pages from the data the game itself uses (species drops, `guardians/`, `bosses/`, `machines/`, loot tables) through a JEI plugin in the companion mod, so they can't drift. First check whether a Cobblemon–JEI addon already shows Pokémon drops on 1.8 and build on it if so.
- **Source of truth.** Write quests in `docs/quests.yaml` (id, title, description, tasks, soft dependencies, grid position) and generate FTB Quests SNBT with a script in `tools/`. Adrian can then rearrange nodes in the in-game editor; note which file wins on regeneration. - **Source of truth.** Write quests in `docs/quests.yaml` (id, title, description, tasks, soft dependencies, grid position) and generate FTB Quests SNBT with a script in `tools/`. Adrian can then rearrange nodes in the in-game editor; note which file wins on regeneration.
**Done when:** every zone has a chapter, every quest is completable in any order, and the generator round-trips without losing Adrian's manual position edits. **Done when:** every zone has a chapter, every quest is completable in any order, the generator round-trips without losing Adrian's manual position edits, and every item that can drop has a JEI page naming all its sources.
## Phase 9 – Balancing and release ## Phase 9 – Balancing and release
+1 -1
View File
@@ -64,7 +64,7 @@ Beldum (Metagross), Elgyem (Beheeyem), Gothita (Gothitelle), Inkay (Malamar), Si
## Alpha guardians ## Alpha guardians
One per zone at its sample point, uncatchable, five levels above the band; it returns 5 hours after it is beaten. Its drops help with the next zone. One per zone at its sample point, uncatchable, about 8 blocks tall, five levels above the band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns 5 hours after it is beaten. Its drops help with the next zone.
| Zone | Guardian | Level | Drops | | Zone | Guardian | Level | Drops |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -11,6 +11,7 @@ import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.boss.BossCommands import dev.cobblesurvival.boss.BossCommands
import dev.cobblesurvival.boss.BossFights import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.BossToughness
import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.machine.MachineShowcase import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.Machines import dev.cobblesurvival.machine.Machines
@@ -78,6 +79,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register() Guardians.register()
BossFights.register() BossFights.register()
BossToughness.register()
EndBoss.register() EndBoss.register()
Gates.register() Gates.register()
ShoulderPassives.register() ShoulderPassives.register()
@@ -53,8 +53,9 @@ object BossCommands {
Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null) Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null)
} }
val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create() val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create()
pokemon.scaleModifier = scale
pokemon.persistentData.putString(BossFights.TAG, def.id.toString()) pokemon.persistentData.putString(BossFights.TAG, def.id.toString())
BossToughness.toughen(pokemon, scale)
pokemon.heal()
val entity = PokemonEntity(level, pokemon) val entity = PokemonEntity(level, pokemon)
entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f) entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f)
entity.setPersistenceRequired() entity.setPersistenceRequired()
@@ -36,14 +36,18 @@ object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") {
val effect: Effect, val effect: Effect,
val trigger: Trigger, val trigger: Trigger,
) { ) {
/** Where this pattern lands, aimed from [boss] at [target]. */ /**
fun area(boss: Vec3, target: Vec3): Area { * Where this pattern lands, aimed from [boss] at [target]. Shapes that start at the boss reach [body] further
* (the boss's half width): a big boss's ring starts at its edge, not inside it.
*/
fun area(boss: Vec3, target: Vec3, body: Double = 0.0): Area {
val origin = if (aim == Aim.BOSS) boss else target val origin = if (aim == Aim.BOSS) boss else target
val grow = if (aim == Aim.BOSS) body else 0.0
return when (shape) { return when (shape) {
Shape.CIRCLE -> Area.Circle(origin, radius) Shape.CIRCLE -> Area.Circle(origin, radius + grow)
Shape.RING -> Area.Ring(origin, inner, outer) Shape.RING -> Area.Ring(origin, inner + grow, outer + grow)
Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2) Shape.LINE -> Area.Line(boss, heading(boss, target), length + body, width / 2)
Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2)) Shape.CONE -> Area.Cone(boss, heading(boss, target), length + body, Math.toRadians(angle / 2))
} }
} }
@@ -79,7 +79,7 @@ object BossFights {
fights += fight fights += fight
battle.players.forEach { battle.players.forEach {
it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.start", it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.start",
"Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight.", "Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight. No single attack takes more than a quarter of its HP.",
def.arena.toInt()).withStyle(ChatFormatting.GOLD)) def.arena.toInt()).withStyle(ChatFormatting.GOLD))
} }
CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name }) CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name })
@@ -135,7 +135,7 @@ object BossFights {
/** Starts [pattern], aimed at [target]: it telegraphs, then lands. */ /** Starts [pattern], aimed at [target]: it telegraphs, then lands. */
fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph { fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph {
val level = fight.boss.level() as ServerLevel val level = fight.boss.level() as ServerLevel
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks) val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target, fight.boss.bbWidth / 2.0), level.gameTime + pattern.telegraphTicks)
fight.telegraphs += telegraph fight.telegraphs += telegraph
val origin = telegraph.area.origin val origin = telegraph.area.origin
level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.HOSTILE, 1.2f, 0.6f) level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.HOSTILE, 1.2f, 0.6f)
@@ -0,0 +1,78 @@
package dev.cobblesurvival.boss
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.battles.interpreter.BattleMessage
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemManager
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemProvider
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.IVs
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.zone.Guardians
import net.minecraft.network.chat.Component
/**
* Bosses are big and tough, not strong (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be
* way bigger and tougher; only their defensive stats go up, never Attack or Speed; no single attack may take more
* than a quarter of their HP). Every boss (zone guardian, End boss, practice copy) gets:
* - its size ([toughen]'s scale, from tools/zone_spawns.py and tools/zone_bosses.py);
* - perfect IVs and [EVS] EVs in HP, Defense and Sp. Def, nothing else (510 in all, the games' limit);
* - in battle the Guardian's Ward, a Showdown held item no Minecraft item stands for
* (`data/cobblesurvival/held_items/guardiansward.js`): Defense and Sp. Def multiplied, and at most a quarter of
* its max HP lost to one attack. This provider hands it out ahead of whatever the boss really holds.
*/
object BossToughness : HeldItemManager {
const val WARD = "guardiansward"
const val EVS = 170
private val DEFENSIVE = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
fun register() = HeldItemProvider.register(this, Priority.HIGHEST)
fun isBoss(pokemon: Pokemon): Boolean {
val data = pokemon.persistentData
return data.contains(Guardians.TAG) || data.contains(EndBoss.TAG) || data.contains(BossFights.TAG)
}
/** Gives [pokemon] its boss [scale] and training; true if anything changed (a boss from before these rules). */
fun toughen(pokemon: Pokemon, scale: Float): Boolean {
var changed = false
if (pokemon.scaleModifier != scale) {
pokemon.scaleModifier = scale
changed = true
}
for (stat in DEFENSIVE) {
if (pokemon.ivs[stat] != IVs.MAX_VALUE) {
pokemon.setIV(stat, IVs.MAX_VALUE)
changed = true
}
if (pokemon.evs[stat] != EVS) {
pokemon.setEV(stat, EVS)
changed = true
}
}
return changed
}
/** [toughen]s a boss already in the world, outside battle: at full health and its new size. */
fun refresh(entity: PokemonEntity, scale: Float): Boolean {
if (entity.isBattling || !toughen(entity.pokemon, scale)) return false
entity.pokemon.heal()
entity.refreshDimensions()
return true
}
override fun showdownId(pokemon: BattlePokemon): String? = WARD.takeIf { isBoss(pokemon.effectedPokemon) }
override fun nameOf(showdownId: String): Component =
Component.translatableWithFallback("item.cobblesurvival.guardian_ward", "Guardian's Ward")
override fun handleStartInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
override fun handleEndInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
override fun give(pokemon: BattlePokemon, itemID: String) {}
override fun take(pokemon: BattlePokemon, itemID: String) {}
}
@@ -89,6 +89,7 @@ object EndBoss {
.filter { it !== (boss ?: it) } .filter { it !== (boss ?: it) }
.forEach { it.discard() } .forEach { it.discard() }
if (boss != null) { if (boss != null) {
BossToughness.refresh(boss, def.scale)
if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5)
level.dragonFight?.let { fight -> level.dragonFight?.let { fight ->
(bossBar.get(fight) as ServerBossEvent).apply { (bossBar.get(fight) as ServerBossEvent).apply {
@@ -111,8 +112,9 @@ object EndBoss {
fun spawn(level: ServerLevel): PokemonEntity? { fun spawn(level: ServerLevel): PokemonEntity? {
val def = BossDefs.end?.end ?: return null val def = BossDefs.end?.end ?: return null
val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create() val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create()
pokemon.scaleModifier = def.scale
pokemon.persistentData.putBoolean(TAG, true) pokemon.persistentData.putBoolean(TAG, true)
BossToughness.toughen(pokemon, def.scale)
pokemon.heal()
val entity = PokemonEntity(level, pokemon) val entity = PokemonEntity(level, pokemon)
val spot = spot(level) val spot = spot(level)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f) entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f)
@@ -22,18 +22,22 @@ import net.minecraft.world.level.block.entity.BlockEntityType
import net.minecraft.world.level.block.state.BlockState import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.level.block.state.StateDefinition import net.minecraft.world.level.block.state.StateDefinition
import net.minecraft.world.level.block.state.properties.BlockStateProperties import net.minecraft.world.level.block.state.properties.BlockStateProperties
import net.minecraft.world.level.block.state.properties.BooleanProperty
import net.minecraft.world.phys.BlockHitResult 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. */ /**
* A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it.
* While its machine works it is [ACTIVE]: it glows, its face animates and particles show ([MachineEffects]).
*/
class MachineBlock(properties: Properties) : BaseEntityBlock(properties) { class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
init { init {
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)) registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(ACTIVE, false))
} }
override fun codec(): MapCodec<out BaseEntityBlock> = CODEC override fun codec(): MapCodec<out BaseEntityBlock> = CODEC
override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) { override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) {
builder.add(FACING) builder.add(FACING, ACTIVE)
} }
override fun getStateForPlacement(context: BlockPlaceContext): BlockState = override fun getStateForPlacement(context: BlockPlaceContext): BlockState =
@@ -55,7 +59,7 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
override fun newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state) override fun newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state)
override fun <T : BlockEntity> getTicker(level: Level, state: BlockState, type: BlockEntityType<T>): BlockEntityTicker<T>? = override fun <T : BlockEntity> getTicker(level: Level, state: BlockState, type: BlockEntityType<T>): BlockEntityTicker<T>? =
if (level.isClientSide) null if (level.isClientSide) createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, pos, tickState, _ -> MachineEffects.clientTick(tickLevel, pos, tickState) })
else createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, _, _, core -> core.serverTick(tickLevel as ServerLevel) }) 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 { override fun useWithoutItem(state: BlockState, level: Level, pos: BlockPos, player: Player, hitResult: BlockHitResult): InteractionResult {
@@ -71,6 +75,9 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
companion object { companion object {
val FACING = BlockStateProperties.HORIZONTAL_FACING val FACING = BlockStateProperties.HORIZONTAL_FACING
val ACTIVE: BooleanProperty = BooleanProperty.create("active")
/** Light a working core gives off. */
const val ACTIVE_LIGHT = 10
val CODEC: MapCodec<MachineBlock> = simpleCodec(::MachineBlock) val CODEC: MapCodec<MachineBlock> = simpleCodec(::MachineBlock)
} }
} }
@@ -15,6 +15,7 @@ import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Containers import net.minecraft.world.Containers
import net.minecraft.world.item.ItemStack import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.block.entity.BlockEntity import net.minecraft.world.level.block.entity.BlockEntity
import net.minecraft.world.level.block.state.BlockState import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.phys.AABB import net.minecraft.world.phys.AABB
@@ -57,6 +58,8 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
/** No Pokémon shown before this game time (the old one is still fainting). */ /** No Pokémon shown before this game time (the old one is still fainting). */
private var showFrom = 0L private var showFrom = 0L
private var ticks = 0 private var ticks = 0
/** The core shows as working ([MachineBlock.ACTIVE]) until this game time; work keeps pushing it on. */
private var activeUntil = 0L
val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)] val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)]
val machineName: Component get() = blockState.block.name val machineName: Component get() = blockState.block.name
@@ -73,9 +76,22 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
if (display?.isAlive != true && level.gameTime >= showFrom) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing()) if (display?.isAlive != true && level.gameTime >= showFrom) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing())
} }
MachineWork.tick(this, level, def) MachineWork.tick(this, level, def)
showWork(level, def)
receivedThisTick = 0 receivedThisTick = 0
} }
/**
* Shows that the machine works (Adrian, 2026-11-02): the core turns [MachineBlock.ACTIVE] (it glows, its face
* animates, particles show) and stays so [ACTIVE_LINGER] ticks after the work stops, so it doesn't flicker; now and
* then the Pokémon inside flinches as if hit, without taking damage ([MachineDisplay.flinch]).
*/
private fun showWork(level: ServerLevel, def: MachineDefs.Def) {
if (MachineWork.working(this, level, def)) activeUntil = level.gameTime + ACTIVE_LINGER
val active = level.gameTime < activeUntil
if (blockState.getValue(MachineBlock.ACTIVE) != active) level.setBlock(worldPosition, blockState.setValue(MachineBlock.ACTIVE, active), Block.UPDATE_CLIENTS)
if (active && level.random.nextInt(FLINCH_ONE_IN) == 0) display?.takeIf { it.isAlive }?.let { MachineDisplay.flinch(level, it) }
}
private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) { private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) {
val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH) val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH)
val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH) val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH)
@@ -268,6 +284,10 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
private const val SHELL_REACH = 2 private const val SHELL_REACH = 2
/** How near the core a sent-out Pokémon must be to walk in. */ /** How near the core a sent-out Pokémon must be to walk in. */
private const val REACH = 16.0 private const val REACH = 16.0
/** Ticks a core keeps showing as working after its work stops. */
private const val ACTIVE_LINGER = 40
/** While working, the Pokémon inside flinches on average once in this many ticks (15 s). */
private const val FLINCH_ONE_IN = 300
fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message) fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message)
} }
@@ -65,6 +65,18 @@ object MachineDisplay {
fading += Fading(entity, level.gameTime + FAINT_TICKS) fading += Fading(entity, level.gameTime + FAINT_TICKS)
} }
/**
* The shown Pokémon flinches as if hit (Adrian, 2026-11-02): it flashes red as a hurt entity does and a hit sounds,
* but it takes no damage (the display copy can't, and the Pokémon in the core is untouched).
*/
fun flinch(level: ServerLevel, entity: PokemonEntity) {
level.broadcastDamageEvent(entity, level.damageSources().generic())
level.playSound(null, entity.x, entity.y, entity.z, HIT, SoundSource.NEUTRAL, 0.7f, 0.9f + level.random.nextFloat() * 0.2f)
}
/** Cobblemon's own sound for a Pokémon taking a hit in battle. */
private val HIT = SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath("cobblemon", "impact.normal"))
fun isDisplay(entity: Entity?): Boolean = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG) 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 { fun spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity {
@@ -0,0 +1,68 @@
package dev.cobblesurvival.machine
import net.minecraft.core.BlockPos
import net.minecraft.core.particles.ParticleOptions
import net.minecraft.core.particles.ParticleTypes
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.sounds.SoundEvent
import net.minecraft.sounds.SoundEvents
import net.minecraft.sounds.SoundSource
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.state.BlockState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* What a working machine looks like (Adrian, 2026-11-02: particles around the Pokémon and an animated core show that
* something is happening, like the fire in a furnace). Client side, from the core's [MachineBlock.ACTIVE] state:
* particles rise around the Pokémon in the chamber and flicker at the core's face, now and then with a sound. The core's
* animated face and its glow come with the active state itself (models from tools/machine_assets.py, light in [Machines]).
*/
object MachineEffects {
/** [chamber] around the Pokémon, [face] at the core's front (with [smoke] beside it), [sound] now and then. */
private class Look(val chamber: ParticleOptions, val face: ParticleOptions, val smoke: ParticleOptions?, val sound: SoundEvent?, val volume: Float = 0.6f)
private val LOOKS = mapOf(
"heat_core" to Look(ParticleTypes.FLAME, ParticleTypes.FLAME, ParticleTypes.SMOKE, SoundEvents.FURNACE_FIRE_CRACKLE, 1f),
"dynamo_core" to Look(ParticleTypes.ELECTRIC_SPARK, ParticleTypes.ELECTRIC_SPARK, null, SoundEvents.COPPER_BULB_TURN_ON, 0.3f),
"synthesis_core" to Look(ParticleTypes.ENCHANT, ParticleTypes.HAPPY_VILLAGER, null, SoundEvents.BEACON_AMBIENT, 0.3f),
"altar_core" to Look(ParticleTypes.SOUL, ParticleTypes.SOUL_FIRE_FLAME, ParticleTypes.SMOKE, SoundEvents.SCULK_CATALYST_BLOOM, 0.5f),
"rebirth_core" to Look(ParticleTypes.END_ROD, ParticleTypes.ENCHANT, null, SoundEvents.AMETHYST_BLOCK_CHIME),
)
/** Per tick: chance of a particle around the Pokémon, at the face, and of a sound. */
private const val CHAMBER_CHANCE = 0.35
private const val FACE_CHANCE = 0.2
private const val SOUND_CHANCE = 0.02
fun clientTick(level: Level, pos: BlockPos, state: BlockState) {
if (!state.getValue(MachineBlock.ACTIVE)) return
val look = LOOKS[BuiltInRegistries.BLOCK.getKey(state.block).path] ?: return
val random = level.random
val facing = state.getValue(MachineBlock.FACING)
val chamber = pos.relative(facing.opposite)
if (random.nextDouble() < CHAMBER_CHANCE) {
val angle = random.nextDouble() * 2 * PI
val radius = 0.35 + random.nextDouble() * 0.15
val x = chamber.x + 0.5 + cos(angle) * radius
val y = chamber.y + 0.1 + random.nextDouble() * 0.8
val z = chamber.z + 0.5 + sin(angle) * radius
// Enchanting glyphs fly from where they start to the given offset's origin: here, into the Pokémon.
if (look.chamber == ParticleTypes.ENCHANT) level.addParticle(look.chamber, chamber.x + 0.5, chamber.y + 0.5, chamber.z + 0.5, x - chamber.x - 0.5, y - chamber.y - 0.5, z - chamber.z - 0.5)
else level.addParticle(look.chamber, x, y, z, 0.0, 0.02, 0.0)
}
if (random.nextDouble() < FACE_CHANCE) {
// Like a lit furnace: low on the core's front, a little in front of it.
val across = random.nextDouble() * 0.6 - 0.3
val x = pos.x + 0.5 + facing.stepX * 0.52 + facing.stepZ * across
val y = pos.y + 0.1 + random.nextDouble() * 0.4
val z = pos.z + 0.5 + facing.stepZ * 0.52 + facing.stepX * across
level.addParticle(look.face, x, y, z, 0.0, 0.0, 0.0)
look.smoke?.let { level.addParticle(it, x, y + 0.2, z, 0.0, 0.0, 0.0) }
}
if (look.sound != null && random.nextDouble() < SOUND_CHANCE) {
level.playLocalSound(pos, look.sound, SoundSource.BLOCKS, look.volume, 0.9f + random.nextFloat() * 0.2f, false)
}
}
}
@@ -86,6 +86,23 @@ object MachineWork {
} }
} }
/**
* Whether the machine is doing something this tick, for how it looks ([MachineBlockEntity.showWork]): a furnace it
* heats has something to cook; the generator isn't full; the synthesizer gets energy; a rebirth runs; the altar
* holds a Pokémon.
*/
fun working(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def): Boolean {
val pokemon = core.pokemon ?: return false
return when (def.kind) {
MachineDefs.Kind.FURNACE -> core.furnaces.take(def.furnaces!!.count(pokemon.level))
.any { (level.getBlockEntity(it) as? AbstractFurnaceBlockEntity)?.getItem(0)?.isEmpty == false }
MachineDefs.Kind.GENERATOR -> core.energy < def.energy!!.capacity
MachineDefs.Kind.SYNTHESIS -> core.receivedThisTick > 0
MachineDefs.Kind.REBIRTH -> core.rebirthAt > 0
MachineDefs.Kind.ALTAR -> true
}
}
// --- Large Furnace ------------------------------------------------------------------------------------------- // --- Large Furnace -------------------------------------------------------------------------------------------
private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) { private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) {
@@ -41,7 +41,8 @@ object Machines {
private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock<MachineBlock> = BLOCKS.registerBlock( private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock<MachineBlock> = BLOCKS.registerBlock(
name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound) name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound)
// See-through, so the Pokémon in the chamber behind the core shows (Adrian, 2026-11-02). // See-through, so the Pokémon in the chamber behind the core shows (Adrian, 2026-11-02).
.noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false }, .noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false }
.lightLevel { if (it.getValue(MachineBlock.ACTIVE)) MachineBlock.ACTIVE_LIGHT else 0 },
) )
fun register(bus: IEventBus) { fun register(bus: IEventBus) {
@@ -2,10 +2,14 @@ package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.pokemon.PokemonProperties import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.battles.BattleRegistry import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.util.getBattleState
import dev.cobblesurvival.boss.Area import dev.cobblesurvival.boss.Area
import dev.cobblesurvival.boss.BossDefs import dev.cobblesurvival.boss.BossDefs
import dev.cobblesurvival.boss.BossFights import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.BossToughness
import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.zone.Guardians import dev.cobblesurvival.zone.Guardians
import net.minecraft.core.BlockPos import net.minecraft.core.BlockPos
@@ -33,8 +37,13 @@ object BossTests {
SelfTestCase("boss_arena_leaving_loses", 400, ::arena), SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss), SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
SelfTestCase("practice_bosses_spawn", 20, ::practice), SelfTestCase("practice_bosses_spawn", 20, ::practice),
SelfTestCase("boss_takes_a_quarter_per_attack", 1200, ::damageCap),
) )
/** Guardians stand at least this tall (Morpeko, the smallest, reaches about 5 blocks), the End boss [END_HEIGHT]. */
private const val GUARDIAN_HEIGHT = 4.5f
private const val END_HEIGHT = 12f
/** `/cobblesurvival boss <zone|end>`: a copy that fights like the real boss, for every guardian and the End. */ /** `/cobblesurvival boss <zone|end>`: a copy that fights like the real boss, for every guardian and the End. */
private fun practice(s: Scenario) { private fun practice(s: Scenario) {
for (which in Guardians.definitions.keys + "end") { for (which in Guardians.definitions.keys + "end") {
@@ -42,11 +51,51 @@ object BossTests {
?: throw GameTestAssertException("no practice boss for $which") ?: throw GameTestAssertException("no practice boss for $which")
s.check(BossFights.defOf(entity) != null && entity.pokemon.isUncatchable()) { "the practice $which is no uncatchable boss" } s.check(BossFights.defOf(entity) != null && entity.pokemon.isUncatchable()) { "the practice $which is no uncatchable boss" }
s.check(entity.pokemon.persistentData.getString(Guardians.TAG).isEmpty()) { "the practice $which counts as the real guardian" } s.check(entity.pokemon.persistentData.getString(Guardians.TAG).isEmpty()) { "the practice $which counts as the real guardian" }
// Adrian, 2026-11-02: way bigger, tougher but not stronger.
val tall = if (which == "end") END_HEIGHT else GUARDIAN_HEIGHT
s.check(entity.bbHeight >= tall) { "the $which boss is only %.1f blocks tall".format(entity.bbHeight) }
val p = entity.pokemon
val defensive = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
s.check(defensive.all { p.ivs[it] == 31 && p.evs[it] == BossToughness.EVS }) { "the $which boss lacks its HP/Defense/Sp. Def training" }
s.check(listOf(Stats.ATTACK, Stats.SPECIAL_ATTACK, Stats.SPEED).all { p.evs[it] == 0 }) { "the $which boss has offensive EVs" }
s.check(p.currentHealth == p.maxHealth && BossToughness.isBoss(p)) { "the $which boss isn't at full health or isn't a boss" }
entity.discard() entity.discard()
} }
s.helper.succeed() s.helper.succeed()
} }
/**
* The Guardian's Ward (Adrian, 2026-11-02: no single attack takes more than a quarter of a boss's HP): a Lv 100
* Garchomp's Bullet Seed, every hit enough to knock out a Lv 15 boss, takes a quarter at most per use.
*/
private fun damageCap(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("garchomp", 100, "bulletseed"))
val bossPokemon = s.pokemon("morpeko", 15, "splash")
bossPokemon.persistentData.putString(BossFights.TAG, TEST_BOSS)
val boss = s.wild(bossPokemon, s.at(7.5, 1.0, 3.5))
val battle = { BattleRegistry.getBattleByParticipatingPlayer(player) }
s.afterTest { battle()?.end() }
// Two attacks, then the battle waits for a third choice that never comes.
var attacks = 0
s.answerBattles(player) { actor, _ ->
if (attacks == 2) null else MoveActionResponse("bulletseed", actor.getSide().getOppositeSide().activePokemon.first().getPNX()).also { attacks++ }
}
s.startWildBattle(player, boss)
.thenWaitUntil {
val b = battle()
val waiting = player.getBattleState()?.second?.let { it.mustChoose && it.request != null } == true
s.check(b == null || (attacks == 2 && b.turn >= 3 && waiting)) { "two attacks aren't done yet: ${s.battleState(player)}" }
}
.thenExecute {
val max = boss.pokemon.maxHealth
val hp = boss.pokemon.currentHealth
s.check(hp in (max - 2 * (max / 4))..<max) { "after two attacks that each could knock it out, the boss has $hp of $max HP (a quarter at most per attack)" }
battle()?.end()
}
.thenSucceed()
}
private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true
private fun coverage(s: Scenario) { private fun coverage(s: Scenario) {
@@ -247,7 +247,17 @@ object MachineTests {
level.setBlockAndUpdate(furnace.below(), 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)) (level.getBlockEntity(furnace.above()) as HopperBlockEntity).setItem(0, ItemStack(Items.RAW_IRON, 4))
val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity
val active = { core.blockState.getValue(MachineBlock.ACTIVE) }
s.helper.startSequence() s.helper.startSequence()
// Adrian, 2026-11-02: a working machine shows it (the core glows and animates, particles show).
.thenWaitUntil { s.check(active()) { "the Large Furnace's core doesn't show that it works" } }
.thenExecute {
// The Pokémon inside flinches as if hit, but takes no damage.
val shown = displays(level, core.blockPos).firstOrNull() ?: throw GameTestAssertException("no Pokémon shown in the chamber")
MachineDisplay.flinch(level, shown)
val inside = core.pokemon!!
s.check(shown.isAlive && inside.currentHealth == inside.maxHealth) { "a flinch hurt the Pokémon (${inside.currentHealth}/${inside.maxHealth})" }
}
.thenWaitUntil { .thenWaitUntil {
val ingots = (0 until output.containerSize).sumOf { output.getItem(it).takeIf { s -> s.`is`(Items.IRON_INGOT) }?.count ?: 0 } 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" } s.check(ingots >= 4) { "the hopper under the furnace has $ingots iron ingots, not 4" }
@@ -256,6 +266,7 @@ object MachineTests {
val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1) val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1)
s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" } s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" }
} }
.thenWaitUntil { s.check(!active()) { "the Large Furnace's core still shows work with nothing left to cook" } }
.thenSucceed() .thenSucceed()
} }
@@ -8,6 +8,7 @@ import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonElement import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.boss.BossToughness
import net.minecraft.ChatFormatting import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup import net.minecraft.core.HolderLookup
@@ -31,6 +32,7 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID import java.util.UUID
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
/** /**
* One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point. * One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point.
@@ -48,6 +50,10 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
private const val NEAR = 96.0 private const val NEAR = 96.0
private const val LEASH = 24.0 private const val LEASH = 24.0
private const val TICKS_PER_HOUR = 72_000L private const val TICKS_PER_HOUR = 72_000L
/** How far (blocks, each way) a cave guardian's spot may lie from the zone's sample point. */
private const val CAVE_SEARCH = 24
/** A cave spot needs at least this much air above it, even when no spot fits the whole guardian. */
private const val MIN_ROOM = 3
class Definition( class Definition(
val zone: String, val zone: String,
@@ -94,6 +100,11 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
val entry = state.entry(zone) val entry = state.entry(zone)
val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive } val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
if (guardian != null) { if (guardian != null) {
// A guardian from before it grew (Adrian, 2026-11-02): grown, it may need a roomier cave spot.
if (BossToughness.refresh(guardian, definition.scale) && sample.y != null) {
spotFor(level, zone, sample, guardian.bbHeight)?.let { entry.spot = it }
entry.spot?.let { guardian.teleportTo(it.x + 0.5, it.y.toDouble(), it.z + 0.5) }
}
entry.spot?.let { leash(guardian, it) } entry.spot?.let { leash(guardian, it) }
continue continue
} }
@@ -103,21 +114,29 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
// The old guardian may still be loading from disk with its chunk; wait for that. // The old guardian may still be loading from disk with its chunk; wait for that.
val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z)) val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z))
if (!level.areEntitiesLoaded(chunk.toLong())) continue if (!level.areEntitiesLoaded(chunk.toLong())) continue
val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue val entity = create(level, definition)
val spawned = spawn(level, definition, spot) ?: continue // A spot picked for a smaller guardian is kept only while this one still fits there.
val spot = entry.spot?.takeIf { sample.y == null || room(level, it, entity.bbHeight) >= ceil(entity.bbHeight) }
?: spotFor(level, zone, sample, entity.bbHeight)?.also { entry.spot = it } ?: continue
val spawned = spawn(level, entity, spot) ?: continue
entry.entity = spawned.uuid entry.entity = spawned.uuid
} }
} }
private fun spawn(level: ServerLevel, definition: Definition, spot: BlockPos): PokemonEntity? { private fun create(level: ServerLevel, definition: Definition): PokemonEntity {
val pokemon = PokemonProperties.parse(definition.pokemon).create() val pokemon = PokemonProperties.parse(definition.pokemon).create()
pokemon.scaleModifier = definition.scale
pokemon.persistentData.putString(TAG, definition.zone) pokemon.persistentData.putString(TAG, definition.zone)
val entity = PokemonEntity(level, pokemon) BossToughness.toughen(pokemon, definition.scale)
pokemon.heal()
return PokemonEntity(level, pokemon)
}
private fun spawn(level: ServerLevel, entity: PokemonEntity, spot: BlockPos): PokemonEntity? {
val zone = entity.pokemon.persistentData.getString(TAG)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f) entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f)
entity.setPersistenceRequired() entity.setPersistenceRequired()
if (!level.addFreshEntity(entity)) return null if (!level.addFreshEntity(entity)) return null
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}", definition.zone, spot.toShortString()) CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}, {} blocks tall", zone, spot.toShortString(), "%.1f".format(entity.bbHeight))
return entity return entity
} }
@@ -128,18 +147,33 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
} }
} }
/** Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it. */ /** Air above [pos], counted up to the [height] a guardian needs. */
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? { private fun room(level: ServerLevel, pos: BlockPos, height: Float): Int {
val need = ceil(height).toInt()
return (0 until need).firstOrNull { !level.getBlockState(pos.above(it)).isAir } ?: need
}
/**
* Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it with room for
* its [height]; when no floor has that much, the roomiest one found (at least [MIN_ROOM]).
*/
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample, height: Float): BlockPos? {
val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z) val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
for (radius in 0..16 step 2) for (dx in -radius..radius) for (dz in -radius..radius) { var best: BlockPos? = null
var bestRoom = MIN_ROOM - 1
for (radius in 0..CAVE_SEARCH step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
if (maxOf(abs(dx), abs(dz)) != radius) continue if (maxOf(abs(dx), abs(dz)) != radius) continue
for (dy in 0..24) for (sign in intArrayOf(1, -1)) { for (dy in 0..24) for (sign in intArrayOf(1, -1)) {
val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz) val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz)
val open = (0 until 3).all { level.getBlockState(pos.above(it)).isAir } if (!level.getBlockState(pos).isAir || !level.getBlockState(pos.below()).isSolid) continue
if (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos val room = room(level, pos, height)
if (room <= bestRoom || Zones.at(level, pos)?.id != zone) continue
best = pos
bestRoom = room
if (room >= ceil(height)) return pos
} }
} }
return null return best
} }
/** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */ /** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */
@@ -1,19 +1,34 @@
{ {
"variants": { "variants": {
"facing=north": { "active=false,facing=north": {
"model": "cobblesurvival:block/altar_core" "model": "cobblesurvival:block/altar_core"
}, },
"facing=east": { "active=false,facing=east": {
"model": "cobblesurvival:block/altar_core", "model": "cobblesurvival:block/altar_core",
"y": 90 "y": 90
}, },
"facing=south": { "active=false,facing=south": {
"model": "cobblesurvival:block/altar_core", "model": "cobblesurvival:block/altar_core",
"y": 180 "y": 180
}, },
"facing=west": { "active=false,facing=west": {
"model": "cobblesurvival:block/altar_core", "model": "cobblesurvival:block/altar_core",
"y": 270 "y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/altar_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/altar_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/altar_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/altar_core_on",
"y": 270
} }
} }
} }
@@ -1,19 +1,34 @@
{ {
"variants": { "variants": {
"facing=north": { "active=false,facing=north": {
"model": "cobblesurvival:block/dynamo_core" "model": "cobblesurvival:block/dynamo_core"
}, },
"facing=east": { "active=false,facing=east": {
"model": "cobblesurvival:block/dynamo_core", "model": "cobblesurvival:block/dynamo_core",
"y": 90 "y": 90
}, },
"facing=south": { "active=false,facing=south": {
"model": "cobblesurvival:block/dynamo_core", "model": "cobblesurvival:block/dynamo_core",
"y": 180 "y": 180
}, },
"facing=west": { "active=false,facing=west": {
"model": "cobblesurvival:block/dynamo_core", "model": "cobblesurvival:block/dynamo_core",
"y": 270 "y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/dynamo_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 270
} }
} }
} }
@@ -1,19 +1,34 @@
{ {
"variants": { "variants": {
"facing=north": { "active=false,facing=north": {
"model": "cobblesurvival:block/heat_core" "model": "cobblesurvival:block/heat_core"
}, },
"facing=east": { "active=false,facing=east": {
"model": "cobblesurvival:block/heat_core", "model": "cobblesurvival:block/heat_core",
"y": 90 "y": 90
}, },
"facing=south": { "active=false,facing=south": {
"model": "cobblesurvival:block/heat_core", "model": "cobblesurvival:block/heat_core",
"y": 180 "y": 180
}, },
"facing=west": { "active=false,facing=west": {
"model": "cobblesurvival:block/heat_core", "model": "cobblesurvival:block/heat_core",
"y": 270 "y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/heat_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/heat_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/heat_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/heat_core_on",
"y": 270
} }
} }
} }
@@ -1,19 +1,34 @@
{ {
"variants": { "variants": {
"facing=north": { "active=false,facing=north": {
"model": "cobblesurvival:block/rebirth_core" "model": "cobblesurvival:block/rebirth_core"
}, },
"facing=east": { "active=false,facing=east": {
"model": "cobblesurvival:block/rebirth_core", "model": "cobblesurvival:block/rebirth_core",
"y": 90 "y": 90
}, },
"facing=south": { "active=false,facing=south": {
"model": "cobblesurvival:block/rebirth_core", "model": "cobblesurvival:block/rebirth_core",
"y": 180 "y": 180
}, },
"facing=west": { "active=false,facing=west": {
"model": "cobblesurvival:block/rebirth_core", "model": "cobblesurvival:block/rebirth_core",
"y": 270 "y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/rebirth_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 270
} }
} }
} }
@@ -1,19 +1,34 @@
{ {
"variants": { "variants": {
"facing=north": { "active=false,facing=north": {
"model": "cobblesurvival:block/synthesis_core" "model": "cobblesurvival:block/synthesis_core"
}, },
"facing=east": { "active=false,facing=east": {
"model": "cobblesurvival:block/synthesis_core", "model": "cobblesurvival:block/synthesis_core",
"y": 90 "y": 90
}, },
"facing=south": { "active=false,facing=south": {
"model": "cobblesurvival:block/synthesis_core", "model": "cobblesurvival:block/synthesis_core",
"y": 180 "y": 180
}, },
"facing=west": { "active=false,facing=west": {
"model": "cobblesurvival:block/synthesis_core", "model": "cobblesurvival:block/synthesis_core",
"y": 270 "y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/synthesis_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 270
} }
} }
} }
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/altar_core_top",
"front": "cobblesurvival:block/altar_core_front_on",
"side": "cobblesurvival:block/altar_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/dynamo_core_top",
"front": "cobblesurvival:block/dynamo_core_front_on",
"side": "cobblesurvival:block/dynamo_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/heat_core_top",
"front": "cobblesurvival:block/heat_core_front_on",
"side": "cobblesurvival:block/heat_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/rebirth_core_top",
"front": "cobblesurvival:block/rebirth_core_front_on",
"side": "cobblesurvival:block/rebirth_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/synthesis_core_top",
"front": "cobblesurvival:block/synthesis_core_front_on",
"side": "cobblesurvival:block/synthesis_core_side"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
+58 -8
View File
@@ -5,10 +5,15 @@ Each core is an "orientable" block (front, side, top) facing the player who plac
here, from a palette and an emblem per machine, so they can be redrawn after a change. Our own art (pack license). here, from a palette and an emblem per machine, so they can be redrawn after a change. Our own art (pack license).
The cores are see-through (Adrian, 2026-11-02: the Pokémon in the chamber behind the core must be visible): a solid The cores are see-through (Adrian, 2026-11-02: the Pokémon in the chamber behind the core must be visible): a solid
frame around tinted glass, the emblem faint on the front; the models render translucent. frame around tinted glass, the emblem faint on the front; the models render translucent.
A working core (block state active=true; Adrian, 2026-11-02: show that something is happening, like fire in a furnace)
switches to an animated front: the emblem glows and pulses, sparks drift up the glass, and the furnace's flame
flickers. The core also gives off light then, and particles show (mod: MachineEffects).
Usage: tools/machine_assets.py Usage: tools/machine_assets.py
""" """
import json import json
import math
import random
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
@@ -87,6 +92,10 @@ EMBLEMS = {
GLASS_ALPHA = 70 GLASS_ALPHA = 70
EMBLEM_ALPHA = 150 EMBLEM_ALPHA = 150
# The working front: this many frames, each shown this many ticks, blended into one another.
FRAMES = 8
FRAME_TICKS = 3
FLAME_COLORS = [(255, 70, 20), (255, 140, 30), (255, 200, 60), (255, 240, 150)]
def frame(image, dark, mid, light): def frame(image, dark, mid, light):
@@ -136,6 +145,43 @@ def front(dark, mid, light, accent, emblem):
return image return image
def lerp(a, b, t):
return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
def working_front(dark, mid, light, accent, emblem, name):
"""The animated front of a working core: FRAMES frames stacked top to bottom."""
rng = random.Random(name)
rows = EMBLEMS[emblem]
top_row = (16 - len(rows)) // 2
glow = tuple(min(255, c + 60) for c in accent)
white = (255, 255, 240)
strip = Image.new("RGBA", (16, 16 * FRAMES), (0, 0, 0, 0))
sparks = [(rng.randint(3, 12), rng.randint(0, 15)) for _ in range(3)]
for f in range(FRAMES):
pulse = 0.5 + 0.5 * math.sin(2 * math.pi * f / FRAMES)
image = glass(dark, mid, light, accent)
for y, row in enumerate(rows):
for x, c in enumerate(row):
if not (2 <= x <= 13 and 2 <= top_row + y <= 13 and c in "#+"):
continue
if emblem == "flame":
# Flicker: each pixel picks a flame colour, hotter towards the core; the tips come and go.
if y < 3 and rng.random() < 0.35:
continue
heat = (1 if c == "+" else 0) + rng.choice((0, 0, 1))
color = FLAME_COLORS[min(len(FLAME_COLORS) - 1, heat + (1 if pulse > 0.6 else 0))]
else:
color = lerp(accent if c == "#" else glow, white, pulse * (0.6 if c == "+" else 0.35))
image.putpixel((x, top_row + y), color + (round(EMBLEM_ALPHA + (255 - EMBLEM_ALPHA) * (0.5 + 0.5 * pulse)),))
# Sparks drifting up the glass, two pixels a frame.
for sx, sy in sparks:
y = 13 - (sy + 2 * f) % 12
image.putpixel((sx, y), glow + (230,))
strip.paste(image, (0, 16 * f))
return strip
def write_json(path, data): def write_json(path, data):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n") path.write_text(json.dumps(data, indent=2) + "\n")
@@ -148,17 +194,21 @@ def main():
side(dark, mid, light, accent).save(textures / f"{name}_side.png") side(dark, mid, light, accent).save(textures / f"{name}_side.png")
top(dark, mid, light, accent).save(textures / f"{name}_top.png") top(dark, mid, light, accent).save(textures / f"{name}_top.png")
front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png") front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png")
write_json(ASSETS / f"models/block/{name}.json", { working_front(dark, mid, light, accent, emblem, name).save(textures / f"{name}_front_on.png")
"parent": "minecraft:block/orientable", write_json(textures / f"{name}_front_on.png.mcmeta", {"animation": {"frametime": FRAME_TICKS, "interpolate": True}})
"render_type": "minecraft:translucent", for model, front_texture in ((name, f"{name}_front"), (f"{name}_on", f"{name}_front_on")):
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{name}_front", write_json(ASSETS / f"models/block/{model}.json", {
"side": f"cobblesurvival:block/{name}_side"}, "parent": "minecraft:block/orientable",
}) "render_type": "minecraft:translucent",
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{front_texture}",
"side": f"cobblesurvival:block/{name}_side"},
})
write_json(ASSETS / f"models/item/{name}.json", {"parent": f"cobblesurvival:block/{name}"}) write_json(ASSETS / f"models/item/{name}.json", {"parent": f"cobblesurvival:block/{name}"})
rotation = {"north": 0, "east": 90, "south": 180, "west": 270} rotation = {"north": 0, "east": 90, "south": 180, "west": 270}
write_json(ASSETS / f"blockstates/{name}.json", { write_json(ASSETS / f"blockstates/{name}.json", {
"variants": {f"facing={facing}": {"model": f"cobblesurvival:block/{name}", **({"y": y} if y else {})} "variants": {f"active={str(active).lower()},facing={facing}": {
for facing, y in rotation.items()}, "model": f"cobblesurvival:block/{name}{'_on' if active else ''}", **({"y": y} if y else {})}
for active in (False, True) for facing, y in rotation.items()},
}) })
print(f"wrote {len(CORES)} cores to {ASSETS.relative_to(ROOT)}") print(f"wrote {len(CORES)} cores to {ASSETS.relative_to(ROOT)}")
+11 -6
View File
@@ -22,7 +22,9 @@ MODS = ROOT / "build/server/mods"
ARENA = 24 ARENA = 24
ARENA_BY_ZONE = {"basalt": 32} ARENA_BY_ZONE = {"basalt": 32}
END = {"species": "eternatus", "level": 80, "scale": 0.2, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500} END = {"species": "eternatus", "level": 80, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500}
# Adrian, 2026-11-02: Eternatus was too small (0.2x, about a block). Scaled to stand this many blocks tall.
END_HEIGHT = 15
END_ARENA = 40 END_ARENA = 40
# Up to this level: these patterns. # Up to this level: these patterns.
@@ -61,19 +63,21 @@ def boss(level, types, arena, **extra):
return {**extra, "arena": arena, "patterns": [pattern(name, tier, damage, effect) for name in TIERS[tier][1]]} return {**extra, "arena": arena, "patterns": [pattern(name, tier, damage, effect) for name in TIERS[tier][1]]}
def species_types(jar): def species_data(jar):
types = {} """({species: its types}, {species: how tall it stands at scale 1, in blocks})."""
types, heights = {}, {}
with zipfile.ZipFile(jar) as z: with zipfile.ZipFile(jar) as z:
for name in z.namelist(): for name in z.namelist():
if name.startswith("data/cobblemon/species/") and name.endswith(".json"): if name.startswith("data/cobblemon/species/") and name.endswith(".json"):
d = json.loads(z.read(name)) d = json.loads(z.read(name))
types[d["name"].lower()] = {t for t in (d.get("primaryType"), d.get("secondaryType")) if t} types[d["name"].lower()] = {t for t in (d.get("primaryType"), d.get("secondaryType")) if t}
return types heights[d["name"].lower()] = (d.get("hitbox") or {}).get("height", 1) * d.get("baseScale", 1)
return types, heights
def main(): def main():
jar = next(MODS.glob("Cobblemon-*.jar")) jar = next(MODS.glob("Cobblemon-*.jar"))
types = species_types(jar) types, heights = species_data(jar)
OUT.mkdir(parents=True, exist_ok=True) OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.json"): for old in OUT.glob("*.json"):
old.unlink() old.unlink()
@@ -86,7 +90,8 @@ def main():
data = boss(level, types.get(species, set()), ARENA_BY_ZONE.get(zone, ARENA), guardian=zone) data = boss(level, types.get(species, set()), ARENA_BY_ZONE.get(zone, ARENA), guardian=zone)
(OUT / f"{zone}.json").write_text(json.dumps({"_comment": comment, **data}, indent=1) + "\n") (OUT / f"{zone}.json").write_text(json.dumps({"_comment": comment, **data}, indent=1) + "\n")
print(f"{zone}: {species} Lv {level}, {len(data['patterns'])} patterns") print(f"{zone}: {species} Lv {level}, {len(data['patterns'])} patterns")
end = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=END) end_def = {**END, "scale": round(END_HEIGHT / heights[END["species"]], 2)}
end = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=end_def)
(OUT / "end.json").write_text(json.dumps({"_comment": comment, **end}, indent=1) + "\n") (OUT / "end.json").write_text(json.dumps({"_comment": comment, **end}, indent=1) + "\n")
print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns") print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns")
+27 -5
View File
@@ -30,8 +30,8 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
of day, excluded biomes and the "natural" ground preset are dropped (there's no sky or day underground). of day, excluded biomes and the "natural" ground preset are dropped (there's no sky or day underground).
5. Strong flyers (STRONG_FLYER_*) only spawn in zones whose band starts at HIGH_ZONE_LEVEL or more. 5. Strong flyers (STRONG_FLYER_*) only spawn in zones whose band starts at HIGH_ZONE_LEVEL or more.
6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the 6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the
strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big (or "scale"), five levels strongest Pokémon spawning in the zone (or "species"), uncatchable, scaled to stand about GUARDIAN_HEIGHT blocks
above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours. tall (or "scale"; Adrian, 2026-11-02: way bigger), five levels above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
7. world/zones.json → "dimensions" are zones that are a whole other dimension (the End; Adrian, 2026-11-02: it was 7. world/zones.json → "dimensions" are zones that are a whole other dimension (the End; Adrian, 2026-11-02: it was
barren). Every line Cobblemon spawns in their "biome_tags" spawns there too, on top of its island home, with barren). Every line Cobblemon spawns in their "biome_tags" spawns there too, on top of its island home, with
its stages fitted to the band as in 2. The entries go into "bucket" (Cobblemon rolls the bucket first, and its its stages fitted to the band as in 2. The entries go into "bucket" (Cobblemon rolls the bucket first, and its
@@ -58,7 +58,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world" OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians" GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
GUARDIAN_SCALE = 1.5 # "Alpha": half again as big as a normal one # Adrian, 2026-11-02: guardians way bigger. Each is scaled to stand about this many blocks tall (its Cobblemon hitbox
# height times base scale), but at least GUARDIAN_SCALE_MIN and at most GUARDIAN_SCALE_MAX times its normal size.
GUARDIAN_HEIGHT = 8
GUARDIAN_SCALE_MIN = 1.5
GUARDIAN_SCALE_MAX = 12
GUARDIAN_LEVELS_ABOVE_BAND = 5 GUARDIAN_LEVELS_ABOVE_BAND = 5
REPORT = ROOT / "docs/zone-species.md" REPORT = ROOT / "docs/zone-species.md"
EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"} EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"}
@@ -118,6 +122,22 @@ def zone_biomes(plan, layout, preview):
return shares return shares
def natural_heights(jar):
"""{species id: how tall its entity stands at scale 1, in blocks} from Cobblemon's species files."""
heights = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if m:
data = json.loads(z.read(name))
heights[f"cobblemon:{m[1]}"] = (data.get("hitbox") or {}).get("height", 1) * data.get("baseScale", 1)
return heights
def guardian_scale(height):
return round(min(GUARDIAN_SCALE_MAX, max(GUARDIAN_SCALE_MIN, GUARDIAN_HEIGHT / height)), 2)
def species_data(jar): def species_data(jar):
"""({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files.""" """({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files."""
totals, flyers = {}, set() totals, flyers = {}, set()
@@ -369,6 +389,7 @@ def main():
for old in GUARDIANS.glob("*.json"): for old in GUARDIANS.glob("*.json"):
old.unlink() old.unlink()
guardians = {} guardians = {}
heights = natural_heights(jar)
for zid, zone in zones.items(): for zid, zone in zones.items():
config = zone.get("guardian") config = zone.get("guardian")
if not config: if not config:
@@ -383,7 +404,7 @@ def main():
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": zid, "zone": zid,
"pokemon": f"{species_name} level={level} uncatchable=yes", "pokemon": f"{species_name} level={level} uncatchable=yes",
"scale": config.get("scale", GUARDIAN_SCALE), "scale": config.get("scale") or guardian_scale(heights.get(f"cobblemon:{species_name}", 1)),
"drops": [{"item": item, "count": count} for item, count in config["drops"]], "drops": [{"item": item, "count": count} for item, count in config["drops"]],
"respawn_hours": plan["guardian_respawn_hours"], "respawn_hours": plan["guardian_respawn_hours"],
}, indent=1) + "\n") }, indent=1) + "\n")
@@ -398,7 +419,8 @@ def main():
lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""] lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""]
lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""] lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""]
lines_out += ["## Alpha guardians", "", lines_out += ["## Alpha guardians", "",
f"One per zone at its sample point, uncatchable, five levels above the band; it returns " f"One per zone at its sample point, uncatchable, about {GUARDIAN_HEIGHT} blocks tall, five levels above the "
f"band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns "
f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "", f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "",
"| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"] "| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"]
for zid, species_name in guardians.items(): for zid, species_name in guardians.items():
+1 -1
View File
@@ -76,7 +76,7 @@
"caves": [ "caves": [
{"id": "lush_caves", "types": ["grass", "bug", "fairy", "poison", "water"], "lines": 20, "name": "Lush Caves", "biome": "minecraft:lush_caves", "levels": [12, 28], "hazard": "none"}, {"id": "lush_caves", "types": ["grass", "bug", "fairy", "poison", "water"], "lines": 20, "name": "Lush Caves", "biome": "minecraft:lush_caves", "levels": [12, 28], "hazard": "none"},
{"id": "dripstone", "types": ["rock", "ground", "steel", "fairy", "psychic", "dragon"], "lines": 20, "name": "Dripstone Depths", "biome": "minecraft:dripstone_caves", "levels": [22, 38], "hazard": "none"}, {"id": "dripstone", "types": ["rock", "ground", "steel", "fairy", "psychic", "dragon"], "lines": 20, "name": "Dripstone Depths", "biome": "minecraft:dripstone_caves", "levels": [22, 38], "hazard": "none"},
{"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"species": "regigigas", "scale": 1.0, "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"}, {"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"species": "regigigas", "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"},
{"id": "basalt", "types": ["fire", "rock", "ground", "steel", "dark"], "guardian": {"species": "moltres", "drops": [["cobblemon:exp_candy_xl", 3], ["minecraft:netherite_scrap", 2]]}, "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"} {"id": "basalt", "types": ["fire", "rock", "ground", "steel", "dark"], "guardian": {"species": "moltres", "drops": [["cobblemon:exp_candy_xl", 3], ["minecraft:netherite_scrap", 2]]}, "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"}
], ],
"dimensions": [ "dimensions": [