diff --git a/datapacks/core/data/cobblesurvival/bosses/end.json b/datapacks/core/data/cobblesurvival/bosses/end.json index 8d03a4b..8e2f2b8 100644 --- a/datapacks/core/data/cobblesurvival/bosses/end.json +++ b/datapacks/core/data/cobblesurvival/bosses/end.json @@ -3,10 +3,10 @@ "end": { "species": "eternatus", "level": 80, - "scale": 0.2, "respawn_hours": 5, "xp_first_win": 12000, - "xp": 500 + "xp": 500, + "scale": 2.5 }, "arena": 40, "patterns": [ diff --git a/datapacks/core/data/cobblesurvival/guardians/basalt.json b/datapacks/core/data/cobblesurvival/guardians/basalt.json index 35a1c4c..8073f1c 100644 --- a/datapacks/core/data/cobblesurvival/guardians/basalt.json +++ b/datapacks/core/data/cobblesurvival/guardians/basalt.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "basalt", "pokemon": "moltres level=75 uncatchable=yes", - "scale": 1.5, + "scale": 2.0, "drops": [ { "item": "cobblemon:exp_candy_xl", diff --git a/datapacks/core/data/cobblesurvival/guardians/button_isle.json b/datapacks/core/data/cobblesurvival/guardians/button_isle.json index 769167f..2db5a47 100644 --- a/datapacks/core/data/cobblesurvival/guardians/button_isle.json +++ b/datapacks/core/data/cobblesurvival/guardians/button_isle.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "button_isle", "pokemon": "morpeko level=15 uncatchable=yes", - "scale": 1.5, + "scale": 12, "drops": [ { "item": "cobblemon:poke_ball", diff --git a/datapacks/core/data/cobblesurvival/guardians/deep_dark.json b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json index 40ec277..160d4c7 100644 --- a/datapacks/core/data/cobblesurvival/guardians/deep_dark.json +++ b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "deep_dark", "pokemon": "regigigas level=65 uncatchable=yes", - "scale": 1.0, + "scale": 1.5, "drops": [ { "item": "cobblemon:icy_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/deep_sea.json b/datapacks/core/data/cobblesurvival/guardians/deep_sea.json index f5a4ae0..1786371 100644 --- a/datapacks/core/data/cobblesurvival/guardians/deep_sea.json +++ b/datapacks/core/data/cobblesurvival/guardians/deep_sea.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "deep_sea", "pokemon": "kingdra level=55 uncatchable=yes", - "scale": 1.5, + "scale": 2.96, "drops": [ { "item": "cobblemon:mystic_water", diff --git a/datapacks/core/data/cobblesurvival/guardians/frostpeak.json b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json index 3a34619..ffab977 100644 --- a/datapacks/core/data/cobblesurvival/guardians/frostpeak.json +++ b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "frostpeak", "pokemon": "abomasnow level=60 uncatchable=yes", - "scale": 1.5, + "scale": 3.2, "drops": [ { "item": "cobblemon:icy_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/highlands.json b/datapacks/core/data/cobblesurvival/guardians/highlands.json index 8c059d8..f068073 100644 --- a/datapacks/core/data/cobblesurvival/guardians/highlands.json +++ b/datapacks/core/data/cobblesurvival/guardians/highlands.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "highlands", "pokemon": "mudsdale level=40 uncatchable=yes", - "scale": 1.5, + "scale": 3.08, "drops": [ { "item": "cobblemon:icy_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/jungle.json b/datapacks/core/data/cobblesurvival/guardians/jungle.json index 844a949..1aa3eb1 100644 --- a/datapacks/core/data/cobblesurvival/guardians/jungle.json +++ b/datapacks/core/data/cobblesurvival/guardians/jungle.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "jungle", "pokemon": "tangrowth level=35 uncatchable=yes", - "scale": 1.5, + "scale": 3.33, "drops": [ { "item": "cobblesurvival:mire_waders", diff --git a/datapacks/core/data/cobblesurvival/guardians/mire.json b/datapacks/core/data/cobblesurvival/guardians/mire.json index 0a3cd90..cb5f486 100644 --- a/datapacks/core/data/cobblesurvival/guardians/mire.json +++ b/datapacks/core/data/cobblesurvival/guardians/mire.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "mire", "pokemon": "swampert level=43 uncatchable=yes", - "scale": 1.5, + "scale": 5.0, "drops": [ { "item": "cobblemon:icy_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/southwold.json b/datapacks/core/data/cobblesurvival/guardians/southwold.json index 6c067a6..f718855 100644 --- a/datapacks/core/data/cobblesurvival/guardians/southwold.json +++ b/datapacks/core/data/cobblesurvival/guardians/southwold.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "southwold", "pokemon": "pinsir level=23 uncatchable=yes", - "scale": 1.5, + "scale": 6.13, "drops": [ { "item": "cobblemon:great_ball", diff --git a/datapacks/core/data/cobblesurvival/guardians/sunscorch.json b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json index 5f7fb2c..ac54bad 100644 --- a/datapacks/core/data/cobblesurvival/guardians/sunscorch.json +++ b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "sunscorch", "pokemon": "flygon level=50 uncatchable=yes", - "scale": 1.5, + "scale": 2.78, "drops": [ { "item": "cobblemon:heat_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/westmarch.json b/datapacks/core/data/cobblesurvival/guardians/westmarch.json index e79f588..d1dae62 100644 --- a/datapacks/core/data/cobblesurvival/guardians/westmarch.json +++ b/datapacks/core/data/cobblesurvival/guardians/westmarch.json @@ -2,7 +2,7 @@ "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "westmarch", "pokemon": "scyther level=30 uncatchable=yes", - "scale": 1.5, + "scale": 4.73, "drops": [ { "item": "cobblesurvival:mire_waders", diff --git a/datapacks/core/data/cobblesurvival/held_items/guardiansward.js b/datapacks/core/data/cobblesurvival/held_items/guardiansward.js new file mode 100644 index 0000000..85734e6 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/held_items/guardiansward.js @@ -0,0 +1,49 @@ +/* + * Cobblemon Survival Pack: the Guardian's Ward, a Showdown held item (Cobblemon loads data//held_items/.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, +} diff --git a/docs/balance.md b/docs/balance.md index 35be2b5..c2d048d 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -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` | | | 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 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 | | 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 | @@ -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` | | | 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` | | -| 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 | +| 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 | | Strong flyer | ridable through the air with top speed ≥ 60 or top stamina ≥ 85 (Cobblemon ride stats) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`STRONG_FLYER_*`) | 29 species | | High zone for strong flyers | band starts at Lv 30 or more (Sunscorch, Frostpeak, Deep Sea, Deep Dark, Basalt Furnace) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`HIGH_ZONE_LEVEL`) | | diff --git a/docs/checklists/phase-6.md b/docs/checklists/phase-6.md index 0a2e165..39a6987 100644 --- a/docs/checklists/phase-6.md +++ b/docs/checklists/phase-6.md @@ -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). - 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`. ## 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. - [ ] 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. +- [ ] 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). - [ ] 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) diff --git a/docs/checklists/phase-7.md b/docs/checklists/phase-7.md index 3af71e9..f9db304 100644 --- a/docs/checklists/phase-7.md +++ b/docs/checklists/phase-7.md @@ -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`. - **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`. +- **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`. ## 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). - [ ] 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. +- [ ] 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 ` 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"). ## Ask Adrian diff --git a/docs/design.md b/docs/design.md index cd29879..3865ec1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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). +**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. ## 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 | | Sunscorch | Delphox | **Flygon** | the desert spirit of sandstorms | | 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 | | 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. -**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`. diff --git a/docs/roadmap.md b/docs/roadmap.md index e35d72c..f8c9d5a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. - **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. +- **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. -**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 diff --git a/docs/zone-species.md b/docs/zone-species.md index 8fb2249..cacbd26 100644 --- a/docs/zone-species.md +++ b/docs/zone-species.md @@ -64,7 +64,7 @@ Beldum (Metagross), Elgyem (Beheeyem), Gothita (Gothitelle), Inkay (Malamar), Si ## 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 | | --- | --- | --- | --- | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 472396d..eb3b402 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -11,6 +11,7 @@ import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.permadeath.Permadeath import dev.cobblesurvival.boss.BossCommands import dev.cobblesurvival.boss.BossFights +import dev.cobblesurvival.boss.BossToughness import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.machine.MachineShowcase import dev.cobblesurvival.machine.Machines @@ -78,6 +79,7 @@ class CobbleSurvival { NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) Guardians.register() BossFights.register() + BossToughness.register() EndBoss.register() Gates.register() ShoulderPassives.register() diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt index 25258bc..cbd159a 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt @@ -53,8 +53,9 @@ object BossCommands { Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null) } val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create() - pokemon.scaleModifier = scale pokemon.persistentData.putString(BossFights.TAG, def.id.toString()) + BossToughness.toughen(pokemon, scale) + pokemon.heal() val entity = PokemonEntity(level, pokemon) entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f) entity.setPersistenceRequired() diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt index 8e42e16..f2b6e02 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt @@ -36,14 +36,18 @@ object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") { val effect: Effect, 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 grow = if (aim == Aim.BOSS) body else 0.0 return when (shape) { - Shape.CIRCLE -> Area.Circle(origin, radius) - Shape.RING -> Area.Ring(origin, inner, outer) - Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2) - Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2)) + Shape.CIRCLE -> Area.Circle(origin, radius + grow) + Shape.RING -> Area.Ring(origin, inner + grow, outer + grow) + Shape.LINE -> Area.Line(boss, heading(boss, target), length + body, width / 2) + Shape.CONE -> Area.Cone(boss, heading(boss, target), length + body, Math.toRadians(angle / 2)) } } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt index 3550008..99c5c60 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt @@ -79,7 +79,7 @@ object BossFights { fights += fight battle.players.forEach { 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)) } 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. */ fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph { 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 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) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossToughness.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossToughness.kt new file mode 100644 index 0000000..90f075e --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossToughness.kt @@ -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) {} +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt index 2afd0cd..8f5e1bb 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt @@ -89,6 +89,7 @@ object EndBoss { .filter { it !== (boss ?: it) } .forEach { it.discard() } 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) level.dragonFight?.let { fight -> (bossBar.get(fight) as ServerBossEvent).apply { @@ -111,8 +112,9 @@ object EndBoss { fun spawn(level: ServerLevel): PokemonEntity? { val def = BossDefs.end?.end ?: return null val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create() - pokemon.scaleModifier = def.scale pokemon.persistentData.putBoolean(TAG, true) + BossToughness.toughen(pokemon, def.scale) + pokemon.heal() val entity = PokemonEntity(level, pokemon) val spot = spot(level) entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt index 449e3ff..06e91e0 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt @@ -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.StateDefinition import net.minecraft.world.level.block.state.properties.BlockStateProperties +import net.minecraft.world.level.block.state.properties.BooleanProperty 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) { init { - registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)) + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(ACTIVE, false)) } override fun codec(): MapCodec = CODEC override fun createBlockStateDefinition(builder: StateDefinition.Builder) { - builder.add(FACING) + builder.add(FACING, ACTIVE) } 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 getTicker(level: Level, state: BlockState, type: BlockEntityType): BlockEntityTicker? = - 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) }) 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 { 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 = simpleCodec(::MachineBlock) } } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt index 5e507e1..7314679 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt @@ -15,6 +15,7 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer import net.minecraft.world.Containers import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.Block import net.minecraft.world.level.block.entity.BlockEntity import net.minecraft.world.level.block.state.BlockState 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). */ private var showFrom = 0L 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 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()) } MachineWork.tick(this, level, def) + showWork(level, def) 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) { val min = 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 /** How near the core a sent-out Pokémon must be to walk in. */ 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) } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt index 49a6894..8c985d6 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineDisplay.kt @@ -65,6 +65,18 @@ object MachineDisplay { 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 spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineEffects.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineEffects.kt new file mode 100644 index 0000000..485e583 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineEffects.kt @@ -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) + } + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt index c958bf2..0cdfe77 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineWork.kt @@ -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 ------------------------------------------------------------------------------------------- private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt index f29c500..67f3583 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt @@ -41,7 +41,8 @@ object Machines { private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock = BLOCKS.registerBlock( name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound) // 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) { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt index b111867..fe001df 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt @@ -2,10 +2,14 @@ package dev.cobblesurvival.selftest import com.cobblemon.mod.common.api.pokemon.PokemonProperties 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.util.getBattleState import dev.cobblesurvival.boss.Area import dev.cobblesurvival.boss.BossDefs import dev.cobblesurvival.boss.BossFights +import dev.cobblesurvival.boss.BossToughness import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.zone.Guardians import net.minecraft.core.BlockPos @@ -33,8 +37,13 @@ object BossTests { SelfTestCase("boss_arena_leaving_loses", 400, ::arena), SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss), 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 `: a copy that fights like the real boss, for every guardian and the End. */ private fun practice(s: Scenario) { for (which in Guardians.definitions.keys + "end") { @@ -42,11 +51,51 @@ object BossTests { ?: 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(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() } 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)).. s.`is`(Items.IRON_INGOT) }?.count ?: 0 } 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) 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() } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt index 6e7e59b..ff73f6c 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt @@ -8,6 +8,7 @@ import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.google.gson.Gson import com.google.gson.JsonElement import dev.cobblesurvival.CobbleSurvival +import dev.cobblesurvival.boss.BossToughness import net.minecraft.ChatFormatting import net.minecraft.core.BlockPos import net.minecraft.core.HolderLookup @@ -31,6 +32,7 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent import net.neoforged.neoforge.event.tick.ServerTickEvent import java.util.UUID 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. @@ -48,6 +50,10 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") { private const val NEAR = 96.0 private const val LEASH = 24.0 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( val zone: String, @@ -94,6 +100,11 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") { val entry = state.entry(zone) val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive } 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) } 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. val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z)) if (!level.areEntitiesLoaded(chunk.toLong())) continue - val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue - val spawned = spawn(level, definition, spot) ?: continue + val entity = create(level, definition) + // 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 } } - 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() - pokemon.scaleModifier = definition.scale 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.setPersistenceRequired() 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 } @@ -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. */ - private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? { + /** Air above [pos], counted up to the [height] a guardian needs. */ + 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) - 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 for (dy in 0..24) for (sign in intArrayOf(1, -1)) { 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 (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos + if (!level.getBlockState(pos).isAir || !level.getBlockState(pos.below()).isSolid) continue + 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. */ diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json index fed5d26..e6df772 100644 --- a/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/altar_core.json @@ -1,19 +1,34 @@ { "variants": { - "facing=north": { + "active=false,facing=north": { "model": "cobblesurvival:block/altar_core" }, - "facing=east": { + "active=false,facing=east": { "model": "cobblesurvival:block/altar_core", "y": 90 }, - "facing=south": { + "active=false,facing=south": { "model": "cobblesurvival:block/altar_core", "y": 180 }, - "facing=west": { + "active=false,facing=west": { "model": "cobblesurvival:block/altar_core", "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 } } } diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json index ac94e09..c6a3906 100644 --- a/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/dynamo_core.json @@ -1,19 +1,34 @@ { "variants": { - "facing=north": { + "active=false,facing=north": { "model": "cobblesurvival:block/dynamo_core" }, - "facing=east": { + "active=false,facing=east": { "model": "cobblesurvival:block/dynamo_core", "y": 90 }, - "facing=south": { + "active=false,facing=south": { "model": "cobblesurvival:block/dynamo_core", "y": 180 }, - "facing=west": { + "active=false,facing=west": { "model": "cobblesurvival:block/dynamo_core", "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 } } } diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json index 877b72e..f1f9229 100644 --- a/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/heat_core.json @@ -1,19 +1,34 @@ { "variants": { - "facing=north": { + "active=false,facing=north": { "model": "cobblesurvival:block/heat_core" }, - "facing=east": { + "active=false,facing=east": { "model": "cobblesurvival:block/heat_core", "y": 90 }, - "facing=south": { + "active=false,facing=south": { "model": "cobblesurvival:block/heat_core", "y": 180 }, - "facing=west": { + "active=false,facing=west": { "model": "cobblesurvival:block/heat_core", "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 } } } diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json index 49f5562..db4a210 100644 --- a/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/rebirth_core.json @@ -1,19 +1,34 @@ { "variants": { - "facing=north": { + "active=false,facing=north": { "model": "cobblesurvival:block/rebirth_core" }, - "facing=east": { + "active=false,facing=east": { "model": "cobblesurvival:block/rebirth_core", "y": 90 }, - "facing=south": { + "active=false,facing=south": { "model": "cobblesurvival:block/rebirth_core", "y": 180 }, - "facing=west": { + "active=false,facing=west": { "model": "cobblesurvival:block/rebirth_core", "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 } } } diff --git a/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json b/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json index 0ee236c..19246c3 100644 --- a/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json +++ b/mod/src/main/resources/assets/cobblesurvival/blockstates/synthesis_core.json @@ -1,19 +1,34 @@ { "variants": { - "facing=north": { + "active=false,facing=north": { "model": "cobblesurvival:block/synthesis_core" }, - "facing=east": { + "active=false,facing=east": { "model": "cobblesurvival:block/synthesis_core", "y": 90 }, - "facing=south": { + "active=false,facing=south": { "model": "cobblesurvival:block/synthesis_core", "y": 180 }, - "facing=west": { + "active=false,facing=west": { "model": "cobblesurvival:block/synthesis_core", "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 } } } diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core_on.json b/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core_on.json new file mode 100644 index 0000000..ea83dff --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/altar_core_on.json @@ -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" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core_on.json b/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core_on.json new file mode 100644 index 0000000..e404f12 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/dynamo_core_on.json @@ -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" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core_on.json b/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core_on.json new file mode 100644 index 0000000..2eb47ff --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/heat_core_on.json @@ -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" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core_on.json b/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core_on.json new file mode 100644 index 0000000..d3deeb1 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/rebirth_core_on.json @@ -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" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core_on.json b/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core_on.json new file mode 100644 index 0000000..62768e9 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/models/block/synthesis_core_on.json @@ -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" + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png new file mode 100644 index 0000000..b4b3e74 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png.mcmeta b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png.mcmeta new file mode 100644 index 0000000..d8e4a46 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/textures/block/altar_core_front_on.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 3, + "interpolate": true + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png new file mode 100644 index 0000000..c02c81d Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png.mcmeta b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png.mcmeta new file mode 100644 index 0000000..d8e4a46 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/textures/block/dynamo_core_front_on.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 3, + "interpolate": true + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png new file mode 100644 index 0000000..83e9956 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png.mcmeta b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png.mcmeta new file mode 100644 index 0000000..d8e4a46 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/textures/block/heat_core_front_on.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 3, + "interpolate": true + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png new file mode 100644 index 0000000..c9c1b76 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png.mcmeta b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png.mcmeta new file mode 100644 index 0000000..d8e4a46 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/textures/block/rebirth_core_front_on.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 3, + "interpolate": true + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png new file mode 100644 index 0000000..0e5fdf6 Binary files /dev/null and b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png differ diff --git a/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png.mcmeta b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png.mcmeta new file mode 100644 index 0000000..d8e4a46 --- /dev/null +++ b/mod/src/main/resources/assets/cobblesurvival/textures/block/synthesis_core_front_on.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 3, + "interpolate": true + } +} diff --git a/tools/machine_assets.py b/tools/machine_assets.py index fabd104..727ef11 100644 --- a/tools/machine_assets.py +++ b/tools/machine_assets.py @@ -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). 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. +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 """ import json +import math +import random from pathlib import Path from PIL import Image @@ -87,6 +92,10 @@ EMBLEMS = { GLASS_ALPHA = 70 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): @@ -136,6 +145,43 @@ def front(dark, mid, light, accent, emblem): 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): path.parent.mkdir(parents=True, exist_ok=True) 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") top(dark, mid, light, accent).save(textures / f"{name}_top.png") front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png") - write_json(ASSETS / f"models/block/{name}.json", { - "parent": "minecraft:block/orientable", - "render_type": "minecraft:translucent", - "textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{name}_front", - "side": f"cobblesurvival:block/{name}_side"}, - }) + working_front(dark, mid, light, accent, emblem, name).save(textures / f"{name}_front_on.png") + write_json(textures / f"{name}_front_on.png.mcmeta", {"animation": {"frametime": FRAME_TICKS, "interpolate": True}}) + for model, front_texture in ((name, f"{name}_front"), (f"{name}_on", f"{name}_front_on")): + write_json(ASSETS / f"models/block/{model}.json", { + "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}"}) rotation = {"north": 0, "east": 90, "south": 180, "west": 270} write_json(ASSETS / f"blockstates/{name}.json", { - "variants": {f"facing={facing}": {"model": f"cobblesurvival:block/{name}", **({"y": y} if y else {})} - for facing, y in rotation.items()}, + "variants": {f"active={str(active).lower()},facing={facing}": { + "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)}") diff --git a/tools/zone_bosses.py b/tools/zone_bosses.py index 612214f..5662b8e 100644 --- a/tools/zone_bosses.py +++ b/tools/zone_bosses.py @@ -22,7 +22,9 @@ MODS = ROOT / "build/server/mods" ARENA = 24 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 # 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]]} -def species_types(jar): - types = {} +def species_data(jar): + """({species: its types}, {species: how tall it stands at scale 1, in blocks}).""" + types, heights = {}, {} with zipfile.ZipFile(jar) as z: for name in z.namelist(): if name.startswith("data/cobblemon/species/") and name.endswith(".json"): d = json.loads(z.read(name)) 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(): jar = next(MODS.glob("Cobblemon-*.jar")) - types = species_types(jar) + types, heights = species_data(jar) OUT.mkdir(parents=True, exist_ok=True) for old in OUT.glob("*.json"): old.unlink() @@ -86,7 +90,8 @@ def main(): 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") 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") print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns") diff --git a/tools/zone_spawns.py b/tools/zone_spawns.py index 5034b80..0ff9d62 100755 --- a/tools/zone_spawns.py +++ b/tools/zone_spawns.py @@ -30,8 +30,8 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/.json: 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. 6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/.json: the - strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big (or "scale"), five levels - above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours. + strongest Pokémon spawning in the zone (or "species"), uncatchable, scaled to stand about GUARDIAN_HEIGHT blocks + 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 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 @@ -58,7 +58,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world" 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 REPORT = ROOT / "docs/zone-species.md" EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"} @@ -118,6 +122,22 @@ def zone_biomes(plan, layout, preview): 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): """({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files.""" totals, flyers = {}, set() @@ -369,6 +389,7 @@ def main(): for old in GUARDIANS.glob("*.json"): old.unlink() guardians = {} + heights = natural_heights(jar) for zid, zone in zones.items(): config = zone.get("guardian") 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.", "zone": zid, "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"]], "respawn_hours": plan["guardian_respawn_hours"], }, 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 += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""] 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.", "", "| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"] for zid, species_name in guardians.items(): diff --git a/world/zones.json b/world/zones.json index 8bab390..8d7d537 100644 --- a/world/zones.json +++ b/world/zones.json @@ -76,7 +76,7 @@ "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": "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)"} ], "dimensions": [