Phase 5: soft gates instead of guardian flags, shoulder passives

Adrian (2026-10-31): HMs abilities stay open; the ones that get around
zone hazards get soft gates (materials or a high-level Pokemon, never
flags; gifted Pokemon and materials count the same); party passives from
shoulder Pokemon; Cobblebase skills and the Power Block move to soft
gates too.

- Gates: a zone gate passes at the top of the zone's band or while the
  Pokemon holds the zone's material (or a harder zone's). Cobblebase
  skills check the pastured Pokemon and tell the owner once what it needs.
- Progress record, guardian unlock messages, the Power Block placement
  gate and /cobblesurvival progress removed; the Power Block's recipe
  already needs Jungle redstone.
- HmsGate: switches off gated HMs auras (Flash, Dive, Mist, Rock Climb,
  Magnet Rise, Lucky Chant, Lava Plume) whose Pokemon doesn't pass.
- ShoulderPassives: type-based effects from shoulder Pokemon, hazard
  ones (warmth, frigidness, water breathing, night vision) gated.
- Self-tests for all of it; docs, balance table, Phase 5 checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-31 13:41:04 +01:00
1 parent 56ffdd9f2e
commit 70cc2cad50
21 files changed
+575 -203

No files matched your search

@@ -1,5 +1,85 @@
{ {
"_comment": "Progression gates (Adrian, 2026-10-30): a Cobblebase skill works for a pastured Pokémon, and a block can be placed, only once its owner has beaten that zone's Alpha guardian; null = from the start. A Cobblebase skill missing here stays off. Placeholder stages; see docs/balance.md. Read by the companion mod (progress/Gates.kt); later files in this folder override earlier ones.", "_comment": "Soft gates (Adrian, 2026-10-31): a Cobblebase skill works for a pastured Pokémon, and a gated Cobblemon HMs aura for the Pokémon giving it, when that Pokémon passes the named zone's gate: its level reaches the gate's level (default: the top of the zone's band), or it holds one of the zone's materials or a material of a zone with a higher gate. Nothing is a flag: gifted Pokémon and materials count the same. Materials are the zone's ores (and guardian drops: Heat Rock, Mystic Water). Skills: null = from the start; a Cobblebase skill missing here stays off. Placeholder stages; see docs/balance.md. Read by the companion mod (progress/Gates.kt, HmsGate.kt); later files in this folder override earlier ones.",
"zones": {
"button_isle": {
"items": [
"minecraft:coal"
]
},
"southwold": {
"items": [
"minecraft:raw_copper",
"minecraft:copper_ingot"
]
},
"lush_caves": {
"items": [
"cobblemon:shiny_stone"
]
},
"westmarch": {
"items": [
"minecraft:raw_iron",
"minecraft:iron_ingot",
"cobblemon:thunder_stone"
]
},
"jungle": {
"items": [
"minecraft:redstone",
"cobblemon:leaf_stone"
]
},
"highlands": {
"items": [
"minecraft:lapis_lazuli",
"cobblemon:sun_stone"
]
},
"mire": {
"items": [
"cobblemon:water_stone"
]
},
"dripstone": {
"items": [
"minecraft:amethyst_shard",
"cobblemon:moon_stone"
]
},
"sunscorch": {
"items": [
"minecraft:raw_gold",
"minecraft:gold_ingot",
"cobblemon:fire_stone",
"cobblemon:heat_rock"
]
},
"deep_sea": {
"items": [
"cobblemon:mystic_water"
]
},
"frostpeak": {
"items": [
"minecraft:emerald",
"cobblemon:ice_stone",
"cobblemon:dawn_stone"
]
},
"deep_dark": {
"items": [
"minecraft:diamond",
"cobblemon:dusk_stone"
]
},
"basalt": {
"items": [
"minecraft:quartz",
"minecraft:netherite_scrap"
]
}
},
"cobblebase_skills": { "cobblebase_skills": {
"cobblebase:gatherer": null, "cobblebase:gatherer": null,
"cobblebase:harvester": null, "cobblebase:harvester": null,
@@ -46,7 +126,13 @@
"cobblebase:breeder": "deep_dark", "cobblebase:breeder": "deep_dark",
"cobblebase:egg_hatcher": "deep_dark" "cobblebase:egg_hatcher": "deep_dark"
}, },
"blocks": { "field_moves": {
"poke_power:power_block": "jungle" "flash": "dripstone",
"dive": "mire",
"rock_climb": "highlands",
"mist": "mire",
"magnet_rise": "sunscorch",
"lucky_chant": "frostpeak",
"lava_plume": "deep_dark"
} }
} }
@@ -0,0 +1,12 @@
{
"_comment": "Party passives from shoulder Pokémon (Adrian, 2026-10-31): while a Pokémon of one of these types rides a player's shoulder, the player has the effect. 'gate' = a soft gate (progression_gates): the shoulder Pokémon needs that zone's gate level or material, for effects that get around a zone hazard. Placeholder choices; see docs/balance.md. Read by the companion mod (party/ShoulderPassives.kt).",
"passives": [
{"types": ["flying"], "effect": "minecraft:slow_falling"},
{"types": ["electric"], "effect": "minecraft:speed"},
{"types": ["rock", "ground", "steel"], "effect": "minecraft:haste"},
{"types": ["fire"], "effect": "cold_sweat:warmth", "gate": "highlands"},
{"types": ["ice"], "effect": "cold_sweat:frigidness", "gate": "jungle"},
{"types": ["water"], "effect": "minecraft:water_breathing", "gate": "mire"},
{"types": ["ghost", "dark"], "effect": "minecraft:night_vision", "gate": "dripstone"}
]
}
+3 -3
View File
@@ -40,7 +40,7 @@ Checked 2026-10-28 against Cobblemon **1.8.4** (NeoForge, MC 1.21.1). Re-check b
| Cobblemon Fight or Flight Reborn | wild Pokémon flee or fight back (Phase 2) | `fightorflight-neoforge-0.11.1.jar` (MR `OD7X9rYz`, CF `9156010`) | MR + CF | ✅ Declares `[1.8.2,)` | | Cobblemon Fight or Flight Reborn | wild Pokémon flee or fight back (Phase 2) | `fightorflight-neoforge-0.11.1.jar` (MR `OD7X9rYz`, CF `9156010`) | MR + CF | ✅ Declares `[1.8.2,)` |
| Cobblemon TP-Pastures | pastured Pokémon teleported back, not recalled (Phase 2) | `cobblemon_tp_pastures-1.21.1-1.3.0.jar` (MR `so0hyqC2`) | MR + CF | ⚠️ Declares `[1.7.1,)`; loads on 1.8.4, behaviour still to be checked in-game | | Cobblemon TP-Pastures | pastured Pokémon teleported back, not recalled (Phase 2) | `cobblemon_tp_pastures-1.21.1-1.3.0.jar` (MR `so0hyqC2`) | MR + CF | ⚠️ Declares `[1.7.1,)`; loads on 1.8.4, behaviour still to be checked in-game |
| Lootr | loot containers refill 1 hour after first opened, loot per player (Phase 2) | `lootr-neoforge-1.21.1-1.11.38.127.jar` (MR `SPOrIzX8`, CF `8968561`) | MR + CF | n/a (no Cobblemon dependency); converts Cobblemon's loot chests too | | Lootr | loot containers refill 1 hour after first opened, loot per player (Phase 2) | `lootr-neoforge-1.21.1-1.11.38.127.jar` (MR `SPOrIzX8`, CF `8968561`) | MR + CF | n/a (no Cobblemon dependency); converts Cobblemon's loot chests too |
| Cobblemon HMs | field moves (Phase 5) | `cobblemon-hms-neoforge-4.1.0.jar` (MR `uC02CqRy`) | MR (CF link not resolved yet) | ✅ Declares Cobblemon ≥ 1.8.0; loads and runs on 1.8.4 (self-tests, incl. Rock Smash on a regenerating vein). Added 2026-10-31 | | Cobblemon HMs | field moves (Phase 5) | `cobblemon-hms-neoforge-4.1.0.jar` (MR `uC02CqRy`) | MR; also on CF (project `1587850`), not linked in the pack yet | ✅ Declares Cobblemon ≥ 1.8.0; loads and runs on 1.8.4 (self-tests, incl. Rock Smash on a regenerating vein). Added 2026-10-31 |
| Better Advanced Tooltips | none: ships *inside* KubeJS as jar-in-jar | — | — | Modrinth marks it as a required KubeJS dependency, but KubeJS declares it optional and embeds it. Not referenced separately | | Better Advanced Tooltips | none: ships *inside* KubeJS as jar-in-jar | — | — | Modrinth marks it as a required KubeJS dependency, but KubeJS declares it optional and embeds it. Not referenced separately |
JEI declares Minecraft `[1.21, 1.21.1)` in every 1.21.1 build, which a strict reading excludes; NeoForge 21.1.265 loads it anyway (server boot checked 2026-10-28). JEI declares Minecraft `[1.21, 1.21.1)` in every 1.21.1 build, which a strict reading excludes; NeoForge 21.1.265 loads it anyway (server boot checked 2026-10-28).
@@ -53,8 +53,8 @@ JEI declares Minecraft `[1.21, 1.21.1)` in every 1.21.1 build, which a strict re
| Chunky | 1 (build tool only, not in the pack) | 1.4.23 (MR `LuFhm4eU`, CF project `485681`) | MR + CF | n/a. `tools/make_world.py` adds it to the test server only while pregenerating | | Chunky | 1 (build tool only, not in the pack) | 1.4.23 (MR `LuFhm4eU`, CF project `485681`) | MR + CF | n/a. `tools/make_world.py` adds it to the test server only while pregenerating |
| Cold Sweat | 3 | 2.4.4 (MR `UGoodVYD`, CF `9215368`) | MR + CF | **In the pack** (2026-10-29); same jar on both platforms | | Cold Sweat | 3 | 2.4.4 (MR `UGoodVYD`, CF `9215368`) | MR + CF | **In the pack** (2026-10-29); same jar on both platforms |
| Regenerating Ore Veins | 4 | 1.4.0 (CF `8066055`) | **CF only** | **In the pack** (2026-10-30); n/a (no Cobblemon dependency). Left out of the Modrinth export | | Regenerating Ore Veins | 4 | 1.4.0 (CF `8066055`) | **CF only** | **In the pack** (2026-10-30); n/a (no Cobblemon dependency). Left out of the Modrinth export |
| Cobblebase | 4 | 2.0.1+1.8.0 (MR `pFYNQ6W2`) | MR has the 1.8 build; **CF only has 2.0.0+1.7.0** | ✅ 2.0.1 requires ≥ 1.8.0 and fixes a startup crash on 1.8. Modrinth lists Xaero's Minimap, Cobbreeding and Cloth Config as required, but the jar declares only Cobblemon and Kotlin for Forge. **In the pack** (2026-10-30) without Cobbreeding and Cloth Config; every skill gated behind progression (companion mod, `progression_gates`). Breeder and Egg Hatcher do nothing without Cobbreeding | | Cobblebase | 4 | 2.0.1+1.8.0 (MR `pFYNQ6W2`) | MR has the 1.8 build; **CF only has 2.0.0+1.7.0** | ✅ 2.0.1 requires ≥ 1.8.0 and fixes a startup crash on 1.8. Modrinth lists Xaero's Minimap, Cobbreeding and Cloth Config as required, but the jar declares only Cobblemon and Kotlin for Forge. **In the pack** (2026-10-30) without Cobbreeding and Cloth Config; every skill behind a soft gate on the pastured Pokémon (companion mod, `progression_gates`). Breeder and Egg Hatcher do nothing without Cobbreeding |
| Cobblemon Poke Power | 4 | 1.3.1 (MR `vGpaTxmN`) | **MR only** | ✅ Declares `[1.8.0,)`. Needs Matthiesen Core. **In the pack** (2026-10-30, Adrian: use the existing option, gated): the Power Block opens with the Verdant Jungle guardian. Jade is an optional extra it supports | | Cobblemon Poke Power | 4 | 1.3.1 (MR `vGpaTxmN`) | **MR only** | ✅ Declares `[1.8.0,)`. Needs Matthiesen Core. **In the pack** (2026-10-30, Adrian: use the existing option, gated): the Power Block is gated by its recipe (redstone blocks from the Verdant Jungle). Jade is an optional extra it supports |
| Cobblemon Quests (FTB Quests integration) | 8 | 1.2.0 (MR `8KeptEg2`; CF project `922979`) | MR + CF | ❌ Targets 1.7. Its own changelog says it does not work with FTB Quests ≥ 2101.1.19; the pack has 2101.1.37. Blocked until updated | | Cobblemon Quests (FTB Quests integration) | 8 | 1.2.0 (MR `8KeptEg2`; CF project `922979`) | MR + CF | ❌ Targets 1.7. Its own changelog says it does not work with FTB Quests ≥ 2101.1.19; the pack has 2101.1.37. Blocked until updated |
| Quests & Teams Fixes (flexible-mode soft-lock fix) | 8 | — | MR | ❌ Only 1.20.1 builds exist. Test the soft-lock on FTB Quests 2101.1.37 ourselves | | Quests & Teams Fixes (flexible-mode soft-lock fix) | 8 | — | MR | ❌ Only 1.20.1 builds exist. Test the soft-lock on FTB Quests 2101.1.37 ourselves |
| spark | 9 | 1.10.124 (MR `v5qtqRQi`, CF `6225208`) | MR + CF | n/a | | spark | 9 | 1.10.124 (MR `v5qtqRQi`, CF `6225208`) | MR + CF | n/a |
+5 -2
View File
@@ -38,8 +38,11 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
| Regenerators breakable by players | no; explosions don't hurt veins | Fine for now (Adrian, 2026-10-30) | `tools/zone_ores.py` → `global.json` | Otherwise a player could remove a vein for good | | Regenerators breakable by players | no; explosions don't hurt veins | Fine for now (Adrian, 2026-10-30) | `tools/zone_ores.py` → `global.json` | Otherwise a player could remove a vein for good |
| Vein Locator | compass + copper ingot; 75 % chance to break per use (the mod's default) | Fine for now (Adrian, 2026-10-30) | `tools/zone_ores.py` → `vein_locator.json` | The mod's default recipe needs netherite | | Vein Locator | compass + copper ingot; 75 % chance to break per use (the mod's default) | Fine for now (Adrian, 2026-10-30) | `tools/zone_ores.py` → `vein_locator.json` | The mod's default recipe needs netherite |
| Drops for mobs the island lacks | Blaze Rod: Carkol 10 %, Torkoal 25 % ×1–2; Ghast Tear: Drifloon 5 %, Dusclops 10 %; Breeze Rod: Whimsicott 10 %; Shulker Shell: Shuckle 10 %, Cloyster 10 %; Nether Star: Charizard 5 % | Fine for now (Adrian, 2026-10-30) | `datapacks/core/data/cobblesurvival/species_additions/` | Every drop and what consumes it: `docs/pokemon-drops.md` (`tools/drop_audit.py`) | | Drops for mobs the island lacks | Blaze Rod: Carkol 10 %, Torkoal 25 % ×1–2; Ghast Tear: Drifloon 5 %, Dusclops 10 %; Breeze Rod: Whimsicott 10 %; Shulker Shell: Shuckle 10 %, Cloyster 10 %; Nether Star: Charizard 5 % | Fine for now (Adrian, 2026-10-30) | `datapacks/core/data/cobblesurvival/species_additions/` | Every drop and what consumes it: `docs/pokemon-drops.md` (`tools/drop_audit.py`) |
| Cobblebase skill gates | start: gatherer, harvester, irrigator, producer, furnace fuel, water fill, extinguisher, scout, guard; Button Isle: fishing, forager, botanist, collector, snow fill, brewing fuel, craftsman; Southwold: pharmacist, scholar, mentor (passive XP), lava fill; Westmarch: mining, excavator, architect, armorer, diving; Verdant Jungle: alchemist, trainer, smith, prospector, growth aura; Highland Meadows: speed, jump, haste, night vision, water breathing, saturation buffs; Mangrove Mire: resistance, strength buffs; Sunscorch: friend recruiter, aura boost; Frostpeak: healer, lucky charm; Deep Dark: breeder, egg hatcher | Placeholder | `datapacks/core/data/cobblesurvival/progression_gates/base.json` | A zone means "after beating its Alpha guardian" | | Cobblebase skill gates | start: gatherer, harvester, irrigator, producer, furnace fuel, water fill, extinguisher, scout, guard; Button Isle: fishing, forager, botanist, collector, snow fill, brewing fuel, craftsman; Southwold: pharmacist, scholar, mentor (passive XP), lava fill; Westmarch: mining, excavator, architect, armorer, diving; Verdant Jungle: alchemist, trainer, smith, prospector, growth aura; Highland Meadows: speed, jump, haste, night vision, water breathing, saturation buffs; Mangrove Mire: resistance, strength buffs; Sunscorch: friend recruiter, aura boost; Frostpeak: healer, lucky charm; Deep Dark: breeder, egg hatcher | Placeholder | `datapacks/core/data/cobblesurvival/progression_gates/base.json` → `cobblebase_skills` | A zone means its soft gate, checked on the pastured Pokémon (see "Soft gate levels and materials") |
| Power Block gate | after the Verdant Jungle guardian | Placeholder | same file, `blocks` | Recipe: Poke Power's own (iron blocks, redstone blocks, Electirizer) | | Power Block gate | none of its own: Poke Power's recipe takes 4 redstone blocks (Verdant Jungle ore), 4 iron blocks and an Electirizer | Placeholder | Poke Power's recipe | The Phase 4 guardian flag is gone (Adrian, 2026-10-31) |
| Soft gate levels and materials | a Pokémon passes a zone's gate at the top of its band (Button Isle 10, Southwold 18, Lush Caves 28, Westmarch 25, Jungle 30, Highlands 35, Mire 38, Dripstone 38, Sunscorch 45, Deep Sea 50, Frostpeak 55, Deep Dark 60, Basalt 70), or holding a material: coal; raw copper/copper ingot; shiny stone; raw iron/iron ingot/thunder stone; redstone/leaf stone; lapis/sun stone; water stone; amethyst shard/moon stone; raw gold/gold ingot/fire stone/Heat Rock; Mystic Water; emerald/ice stone/dawn stone; diamond/dusk stone; quartz/netherite scrap. A harder zone's material counts too | Placeholder | `progression_gates/base.json` → `zones` (an optional `level` overrides the band top) | Adrian, 2026-10-31: soft gates, no flags |
| Gated field moves (Cobblemon HMs auras) | Flash: Dripstone; Dive, Mist: Mire; Rock Climb: Highlands; Magnet Rise: Sunscorch; Lucky Chant: Frostpeak; Lava Plume: Deep Dark | Placeholder | `progression_gates/base.json` → `field_moves` | Checked on the Pokémon giving the aura |
| Shoulder passives | Flying slow falling, Electric speed, Rock/Ground/Steel haste; gated: Fire warmth (Highlands), Ice frigidness (Jungle), Water water breathing (Mire), Ghost/Dark night vision (Dripstone); all level I | Placeholder | `datapacks/core/data/cobblesurvival/shoulder_passives/base.json` | Warmth and frigidness are Cold Sweat's effects |
| Poke Power output | 50 FE/t per Pokémon level, block stores 120,000 FE, 16,000 FE/t out; cable 32,000 / 16,000 | The mod's defaults | `pack/config/poke_power/server.toml` | Nothing uses the energy before Phase 6 | | Poke Power output | 50 FE/t per Pokémon level, block stores 120,000 FE, 16,000 FE/t out; cable 32,000 / 16,000 | The mod's defaults | `pack/config/poke_power/server.toml` | Nothing uses the energy before Phase 6 |
| Cobblebase jobs | the mod's defaults (finder cooldown 240 s, passive XP 5 % a minute with a Mentor, recruiter rates, buffs) | The mod's defaults | `pack/config/cobblebase.json` (skill cooldowns in its own skill data) | Tune after play-testing | | Cobblebase jobs | the mod's defaults (finder cooldown 240 s, passive XP 5 % a minute with a Mentor, recruiter rates, buffs) | The mod's defaults | `pack/config/cobblebase.json` (skill cooldowns in its own skill data) | Tune after play-testing |
| Catch rate against stronger Pokémon | full up to the trainer's strongest party level, then falling linearly to 2 % of the normal rate at +20 levels and beyond | Placeholder (Adrian, 2026-10-30: 20+ levels higher very improbable) | `datapacks/core/data/cobblesurvival/catching/rules.json` → `catch_rate` | E.g. +10 levels: about half the normal rate | | Catch rate against stronger Pokémon | full up to the trainer's strongest party level, then falling linearly to 2 % of the normal rate at +20 levels and beyond | Placeholder (Adrian, 2026-10-30: 20+ levels higher very improbable) | `datapacks/core/data/cobblesurvival/catching/rules.json` → `catch_rate` | E.g. +10 levels: about half the normal rate |
+2
View File
@@ -24,6 +24,8 @@ The new template is the save folder "Cobblemon Survival Island (Phase 4)"; the w
## Base jobs and power (after the answers) ## Base jobs and power (after the answers)
**Superseded 2026-10-31:** the guardian flags below were replaced by soft gates (`docs/checklists/phase-5.md`); `/cobblesurvival progress` no longer exists.
- **Cobblebase**, with each of its skills unlocked by beating a zone's Alpha guardian (table in `docs/balance.md`, data in `datapacks/core/data/cobblesurvival/progression_gates/base.json`). Automated: `gates_cover_every_cobblebase_skill`, `cobblebase_skills_wait_for_guardians`, and the guardian test checks that a win counts. - **Cobblebase**, with each of its skills unlocked by beating a zone's Alpha guardian (table in `docs/balance.md`, data in `datapacks/core/data/cobblesurvival/progression_gates/base.json`). Automated: `gates_cover_every_cobblebase_skill`, `cobblebase_skills_wait_for_guardians`, and the guardian test checks that a win counts.
- **Poke Power**: the Power Block can only be placed after the Verdant Jungle guardian. Automated: `power_block_waits_for_guardian`. - **Poke Power**: the Power Block can only be placed after the Verdant Jungle guardian. Automated: `power_block_waits_for_guardian`.
+15 -5
View File
@@ -4,20 +4,30 @@
**2026-10-31:** the field moves come from Cobblemon HMs instead of our own system. Mining ore is the one hard requirement, the other moves are nice to have. Furnaces are lit by putting a Pokémon inside a Phase 6 machine, not by a field move. Details: `docs/design.md` → "Phase 5: field moves from Cobblemon HMs". **2026-10-31:** the field moves come from Cobblemon HMs instead of our own system. Mining ore is the one hard requirement, the other moves are nice to have. Furnaces are lit by putting a Pokémon inside a Phase 6 machine, not by a field move. Details: `docs/design.md` → "Phase 5: field moves from Cobblemon HMs".
**Answers (2026-10-31):** (1) HMs' abilities stay open from the start; (2) the ones that get around zone hazards get soft gates: materials (also from guardians) or a high-level Pokémon, never flags, and a gifted Pokémon or material counts the same; (3) party passives come from shoulder Pokémon. Follow-up: Cobblebase's skills and the Power Block move from guardian flags to soft gates too.
## Built so far ## Built so far
- **Cobblemon HMs** 4.1.0 is in the pack with its default config (39 abilities, all on). - **Cobblemon HMs** 4.1.0 is in the pack with its default config (39 abilities, all on).
- **Rock Smash mines the island's ore:** the ore drops, and mined vein blocks turn into regenerators that grow back. Automated: `rock_smash_mines_veins`. - **Rock Smash mines the island's ore:** the ore drops, and mined vein blocks turn into regenerators that grow back. Automated: `rock_smash_mines_veins`.
- **HMs' Trainer Ditto** (a visiting TM seller that is a wild Ditto underneath) neither hunts weak trainers nor despawns as a stray. - **HMs' Trainer Ditto** (a visiting TM seller that is a wild Ditto underneath) neither hunts weak trainers nor despawns as a stray.
- **Soft gates** replace the guardian flags. A gate names a zone; the Pokémon doing the work passes it at the top of that zone's level band, or while holding one of its materials (or a harder zone's). Table in `docs/balance.md`.
- Cobblebase skills: checked on the pastured Pokémon; its owner is told once what it needs. Automated: `cobblebase_skills_need_level_or_material`.
- Power Block: no gate of its own, its recipe takes the Jungle's redstone blocks. Automated: `power_block_gated_by_its_recipe`.
- HMs auras that get around hazards (Flash, Dive, Mist, Rock Climb, Magnet Rise, Lucky Chant, Lava Plume): switched off, with a message, while the Pokémon giving them doesn't pass. Automated: `hazard_auras_need_level_or_material`.
- **Shoulder passives:** a shoulder Pokémon gives an effect by type (Flying slow falling, Electric speed, Rock/Ground/Steel haste; gated: Fire warmth, Ice frigidness, Water water breathing, Ghost/Dark night vision). Automated: `shoulder_pokemon_give_passives`.
## In game (Prism instance) ## In game (Prism instance)
- [ ] Send out a Pokémon that knows Rock Smash, pick Rock Smash on its interaction wheel, and punch an ore node: the node breaks in one go, drops its ore and turns into a regenerator. - [ ] Send out a Pokémon that knows Rock Smash, pick Rock Smash on its interaction wheel, and punch an ore node: the node breaks in one go, drops its ore and turns into a regenerator.
- [ ] Do the same on an underground vein that needs a better pickaxe than the one you hold (e.g. iron in Westmarch with no pickaxe): it still breaks. - [ ] Do the same on an underground vein that needs a better pickaxe than the one you hold (e.g. iron in Westmarch with no pickaxe): it still breaks.
- [ ] Look at the other abilities your Pokémon offer on the wheel (Cut, Flash, Strength, Surf boosts, ...) and note any you want switched off or tuned. - [ ] Switch on Flash from a low-level Pokémon: it switches off again with a message saying what it needs. Give it an amethyst shard to hold (or a diamond), switch it on again: it stays on.
- [ ] Put a Pokémon below Lv 25 with the Mining skill in a pasture: you get a message that it needs Lv 25 or Westmarch ore. Give it an iron ingot to hold: it starts mining.
- [ ] Craft and place a Power Block in survival: no guardian needed.
- [ ] Put Pidgey (or another Flying type) on your shoulder: slow falling. A low-level Fire type on your shoulder: a message about warmth; with a sun stone or lapis held, Cold Sweat's warmth shows.
- [ ] Look at the other abilities your Pokémon offer on the wheel (Cut, Strength, Surf boosts, ...) and note any you want switched off or tuned.
## Ask Adrian: Phase 5 decisions ## Ask Adrian
1. **Progression.** HMs' abilities all work from the start. Should some wait for guardians like Cobblebase's skills (Rock Smash after an early one, so tiers still matter at first)? HMs' own `minLevel` is the *player's* XP level, which the island doesn't use otherwise. - The gate levels, materials and which aura sits behind which zone are placeholders (`docs/balance.md`). Dive, Mist and Rock Climb were added to your four because they get around hazards the same way; say if any should stay open.
2. **Zone hazards.** Some abilities get around them: Flash (night vision, lights up the Deep Dark), Lava Plume (fire immunity and walking on lava in the Basalt Furnace), Magnet Rise (flying), Lucky Chant (more ore). Leave them on, switch them off, or gate them behind the zone's guardian? - Phase 5's roadmap done-when asked for fire with `doFireTick`; there's no fire move any more (furnaces are Phase 6). Approve Phase 5 as it is?
3. **Party passives.** The roadmap's other half (e.g. Haste with a Rock type, warmth from a Fire type in Frostpeak; your idea of effects from shoulder Pokémon). Still wanted on top of HMs' auras?
+14 -7
View File
@@ -148,12 +148,7 @@ Order of play the bands suggest: Button Isle → Southwold → Westmarch or Jung
**Unique drops.** Only Pokémon live on the island, so a mob's drop needs another source. `tools/drop_audit.py` checks every item a Pokémon or a vanilla mob drops that a recipe or brewing consumes, and writes `docs/pokemon-drops.md` (item, wild Pokémon with zone and chance, other sources, consumers). Gaps it found, filled through Cobblemon species additions (`datapacks/core/data/cobblesurvival/species_additions/`, placeholders): Blaze Rod (Carkol in Westmarch, Torkoal in the Basalt Furnace; needed for brewing stands, blaze powder and Eyes of Ender), Ghast Tear (Drifloon, Dusclops), Breeze Rod (Whimsicott), Shulker Shell (Shuckle, Cloyster), Nether Star (Charizard). The tide armour trim and the wither skeleton skull stay without a source (cosmetic here). A zone spawn with its own drops overrides the species' drops, so the audit also fails if an addition targets such a spawn (Magmortar's and Centiskorch's did). Self-test: `drop_additions_apply`. **Unique drops.** Only Pokémon live on the island, so a mob's drop needs another source. `tools/drop_audit.py` checks every item a Pokémon or a vanilla mob drops that a recipe or brewing consumes, and writes `docs/pokemon-drops.md` (item, wild Pokémon with zone and chance, other sources, consumers). Gaps it found, filled through Cobblemon species additions (`datapacks/core/data/cobblesurvival/species_additions/`, placeholders): Blaze Rod (Carkol in Westmarch, Torkoal in the Basalt Furnace; needed for brewing stands, blaze powder and Eyes of Ender), Ghast Tear (Drifloon, Dusclops), Breeze Rod (Whimsicott), Shulker Shell (Shuckle, Cloyster), Nether Star (Charizard). The tide armour trim and the wither skeleton skull stay without a source (cosmetic here). A zone spawn with its own drops overrides the species' drops, so the audit also fails if an addition targets such a spawn (Magmortar's and Centiskorch's did). Self-test: `drop_additions_apply`.
**Base jobs and power, gated behind progression.** A player's progression is the zones whose Alpha guardian they have helped beat (`progress/Progress.kt`, saved with the world; the guardian win records it for every player in the battle and lists what it unlocked). The gate table is data, `datapacks/core/data/cobblesurvival/progression_gates/base.json`: **Base jobs and power.** [Cobblebase](https://modrinth.com/project/cobblebase) (Pokémon in a pasture work: harvesting, producing, finding, buffs, ...) and [Cobblemon Poke Power](https://modrinth.com/project/lIn7oRFm) (Electric-type Pokémon in a Power Block make Forge Energy; nothing in the pack uses the energy yet, Phase 6 machines). Phase 4 first gated them with flags: a skill or the Power Block opened once its owner had beaten a zone's Alpha guardian. **Replaced by soft gates on 2026-10-31** (Adrian: guardians must not set flags; see "Soft gates" under Phase 5). The companion mod still puts every Cobblebase skill executor behind the gate, by reflection, when the server starts (`progress/CobblebaseGate.kt`); a skill missing from the table stays off, so an update can't add ungated skills. Cobbreeding is not installed, so Breeder and Egg Hatcher do nothing.
- [Cobblebase](https://modrinth.com/project/cobblebase) (Pokémon in a pasture work: harvesting, producing, finding, buffs, ...): each of its 44 skills works for a pastured Pokémon only once the Pokémon's owner has beaten the skill's zone. From the start: gathering, farming, producing, fuel, guarding, scouting; then step by step through the zones, with mining and the ore finder after Westmarch, the evolution-item finder after the Jungle, the player buffs after Highland Meadows and the Mire, the recruiter after Sunscorch and the healer after Frostpeak (table in `docs/balance.md`). The companion mod puts every Cobblebase skill executor behind the gate, by reflection, when the server starts (`progress/CobblebaseGate.kt`); a skill missing from the table stays off, so an update can't add ungated skills. Cobbreeding is not installed, so Breeder and Egg Hatcher do nothing.
- [Cobblemon Poke Power](https://modrinth.com/project/lIn7oRFm) (Electric-type Pokémon in a Power Block make Forge Energy): a survival player can place a Power Block only after beating the Verdant Jungle's guardian (the zone with the redstone its recipe needs). Its recipe is the mod's own (iron blocks, redstone blocks, an Electirizer); nothing in the pack uses the energy yet (Phase 6 machines).
- Operators: `/cobblesurvival progress <player>`, `... grant|revoke <player> <zone>`.
- Self-tests: `gates_cover_every_cobblebase_skill` (every skill has a gate, every gate zone has a guardian, every executor is gated), `cobblebase_skills_wait_for_guardians` and `power_block_waits_for_guardian`; `guardian_appears_and_returns` checks that the win counts as progress.
**Catching high-level Pokémon** (Adrian, 2026-10-30). Measured against the trainer's strongest party Pokémon (`catching/Catching.kt`, values in `datapacks/core/data/cobblesurvival/catching/rules.json`): **Catching high-level Pokémon** (Adrian, 2026-10-30). Measured against the trainer's strongest party Pokémon (`catching/Catching.kt`, values in `datapacks/core/data/cobblesurvival/catching/rules.json`):
@@ -187,7 +182,19 @@ Adrian: use Cobblemon HMs. Mining ore is the one hard requirement; the other fie
- **Trainer Ditto.** HMs sends a visiting Trainer Ditto that sells TMs; underneath it is a wild Cobblemon Ditto. The island's wild-Pokémon rules (hunting weaker trainers, strays despawning) leave it alone, like guardians (`world/SpecialPokemon.kt`). - **Trainer Ditto.** HMs sends a visiting Trainer Ditto that sells TMs; underneath it is a wild Cobblemon Ditto. The island's wild-Pokémon rules (hunting weaker trainers, strays despawning) leave it alone, like guardians (`world/SpecialPokemon.kt`).
- **Config:** HMs' defaults for now (`config/cobblemon_hms.json` is written by the mod on first start; the pack ships none). Every ability can be switched off or tuned there. - **Config:** HMs' defaults for now (`config/cobblemon_hms.json` is written by the mod on first start; the pack ships none). Every ability can be switched off or tuned there.
Open, for Adrian (`docs/checklists/phase-5.md`): whether HMs' abilities wait for progression like Cobblebase's skills, what to do about the ones that get around zone hazards, and whether party passives (shoulder Pokémon) are still wanted. Adrian's answers (2026-10-31): HMs' abilities stay open; the ones that get around zone hazards get soft gates; party passives come from shoulder Pokémon.
**Soft gates** (Adrian, 2026-10-31: gate behind ores/materials or a high-level Pokémon of a hard zone; materials may come from guardians, but guardians set no flags, and a gifted Pokémon or material does the same as an earned one). This is the roadmap's first rule ("soft gates only"), which the Phase 4 flags broke. One rule for everything gated (`progress/Gates.kt`, data `datapacks/core/data/cobblesurvival/progression_gates/base.json`):
- A gate names a zone. The Pokémon doing the work passes it when its level reaches the top of that zone's band, or when it holds one of the zone's materials: its ores (raw or smelted), its evolution stone, or its guardian's drop (Heat Rock for Sunscorch, Mystic Water for the Deep Sea). A material of a zone with a higher gate counts too, so a Pokémon holding a diamond passes the Westmarch gate. Cobblemon lets a Pokémon hold any item, and Cobblebase doesn't use the held item, so holding the material costs nothing else.
- **Cobblebase skills** keep their Phase 4 stages, now as zone gates on the pastured Pokémon. When one idles, its owner (if within 64 blocks) is told once what it needs.
- **The Power Block** has no gate of its own: its recipe already takes four redstone blocks, the Verdant Jungle's ore.
- **HMs auras that get around hazards** (`progress/HmsGate.kt`): Flash (Dripstone gate, for the Deep Dark), Dive and Mist (Mire; the Deep Sea, and slipping away from Pokémon that hunt you), Rock Climb (Highland Meadows; Frostpeak's cliffs), Magnet Rise (Sunscorch; flight), Lucky Chant (Frostpeak; more ore), Lava Plume (Deep Dark; the Basalt Furnace). HMs keeps a switch per player, Pokémon and aura; every 5 ticks the companion mod switches off a gated aura whose Pokémon doesn't pass and says what it needs. Dive, Mist and Rock Climb were added to Adrian's four (Flash, Lava Plume, Magnet Rise, Lucky Chant) because they get around hazards the same way.
- The guardian record (`Progress`) and `/cobblesurvival progress` are gone; a guardian win only gives its drops.
**Shoulder passives** (`party/ShoulderPassives.kt`, data `datapacks/core/data/cobblesurvival/shoulder_passives/base.json`): while a Pokémon rides a player's shoulder, its type gives an effect: Flying slow falling, Electric speed, Rock/Ground/Steel haste; behind soft gates, Fire Cold Sweat's warmth (Highland Meadows gate, for Frostpeak), Ice its frigidness (Jungle gate, for Sunscorch), Water water breathing (Mire gate) and Ghost/Dark night vision (Dripstone gate). 93 species can ride a shoulder in Cobblemon 1.8, and every passive's types have at least one. Done in the companion mod rather than with Cobblemon's own `shoulderEffects`, which can match neither types nor gates.
Self-tests: `gates_cover_every_cobblebase_skill`, `cobblebase_skills_need_level_or_material`, `power_block_gated_by_its_recipe`, `hazard_auras_need_level_or_material`, `shoulder_pokemon_give_passives`.
## Notes for Phase 6 (researched 2026-10-31) ## Notes for Phase 6 (researched 2026-10-31)
+1 -1
View File
@@ -42,7 +42,7 @@ Read each license before adding a mod, and add a row here in the same change.
| Cold Sweat | Momo Softworks (Mikul) | GPL-3.0-or-later | [Modrinth](https://modrinth.com/mod/cold-sweat) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/cold-sweat) | Phase 3: heat and cold as zone hazards (both) | | Cold Sweat | Momo Softworks (Mikul) | GPL-3.0-or-later | [Modrinth](https://modrinth.com/mod/cold-sweat) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/cold-sweat) | Phase 3: heat and cold as zone hazards (both) |
| Cobblebase | notlown | MPL-2.0 | [Modrinth](https://modrinth.com/project/cobblebase) | Phase 4: Pokémon jobs at the base (both). The 1.8 build is Modrinth only | | Cobblebase | notlown | MPL-2.0 | [Modrinth](https://modrinth.com/project/cobblebase) | Phase 4: Pokémon jobs at the base (both). The 1.8 build is Modrinth only |
| Cobblemon Poke Power | Matthiesen-dev (Adammatthiesen) | MIT | [Modrinth](https://modrinth.com/project/lIn7oRFm) | Phase 4: Pokémon-powered Forge Energy (both). Not on CurseForge | | Cobblemon Poke Power | Matthiesen-dev (Adammatthiesen) | MIT | [Modrinth](https://modrinth.com/project/lIn7oRFm) | Phase 4: Pokémon-powered Forge Energy (both). Not on CurseForge |
| Cobblemon HMs | shiero | shiero's Mods License (in the jar): free to use, also in free modpacks; any monetized use (paid pack, paid server perks) needs an agreement with the author | [Modrinth](https://modrinth.com/project/cobblemon-hms) | Phase 5: field moves (both). Not resolved on CurseForge yet | | Cobblemon HMs | shiero | shiero's Mods License (in the jar): free to use, also in free modpacks; any monetized use (paid pack, paid server perks) needs an agreement with the author | [Modrinth](https://modrinth.com/project/cobblemon-hms) | Phase 5: field moves (both). Also on [CurseForge](https://www.curseforge.com/projects/1587850), not linked in the pack yet |
| Regenerating Ore Veins | Stargazer | MIT (jar metadata) | [CurseForge](https://www.curseforge.com/minecraft/mc-mods/regenerating-ore-veins) | Phase 4: the only ore source besides chest loot (both). Not on Modrinth | | Regenerating Ore Veins | Stargazer | MIT (jar metadata) | [CurseForge](https://www.curseforge.com/minecraft/mc-mods/regenerating-ore-veins) | Phase 4: the only ore source besides chest loot (both). Not on Modrinth |
| WorldGen Editor | waterflane | CC BY-NC-SA 4.0 | [Modrinth](https://modrinth.com/mod/worldgen-editor) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/worldgen-editor), [source](https://github.com/waterflane/WorldGen-Editor) | Non-commercial, share-alike. See the derived files below | | WorldGen Editor | waterflane | CC BY-NC-SA 4.0 | [Modrinth](https://modrinth.com/mod/worldgen-editor) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/worldgen-editor), [source](https://github.com/waterflane/WorldGen-Editor) | Non-commercial, share-alike. See the derived files below |
@@ -8,8 +8,8 @@ import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
import dev.cobblesurvival.selftest.SelfTest import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.world.SharedLoot import dev.cobblesurvival.world.SharedLoot
@@ -68,6 +68,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register() Guardians.register()
Gates.register() Gates.register()
ShoulderPassives.register()
OreNodes.register() OreNodes.register()
Catching.register() Catching.register()
Wanderers.register() Wanderers.register()
@@ -106,7 +107,6 @@ class CobbleSurvival {
}) })
.then(BiomePreview.command()) .then(BiomePreview.command())
.then(OreVeins.command()) .then(OreVeins.command())
.then(Progress.command())
.then(Commands.literal("zone").executes { context -> .then(Commands.literal("zone").executes { context ->
val source = context.source val source = context.source
val zone = Zones.at(source.level, BlockPos.containing(source.position)) val zone = Zones.at(source.level, BlockPos.containing(source.position))
@@ -0,0 +1,119 @@
package dev.cobblesurvival.party
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.pokemon.activestate.ShoulderedState
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.progress.Gates
import net.minecraft.ChatFormatting
import net.minecraft.core.Holder
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.effect.MobEffect
import net.minecraft.world.effect.MobEffectInstance
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Party passives from shoulder Pokémon (Adrian, 2026-10-31): a Pokémon riding a player's shoulder gives an effect by
* its type, e.g. slow falling from a Flying type or warmth in the cold from a Fire type. An effect that gets around a
* zone hazard has a soft gate, like Cobblemon HMs' auras: the shoulder Pokémon needs that gate's level or material
* ([Gates]); the player is told once what it needs.
*
* Data, `data/cobblesurvival/shoulder_passives/<name>.json`, all files adding up:
* `{"passives": [{"types": ["fire", ...], "effect": "<mob effect id>", "amplifier": 0, "gate": "<zone>" (optional)}]}`.
*/
object ShoulderPassives : SimpleJsonResourceReloadListener(Gson(), "shoulder_passives") {
private const val CHECK_TICKS = 20
/** Longer than the refresh, and above the 200 ticks below which night vision flickers. */
const val DURATION = 260
class Passive(val types: Set<String>, val effect: Holder<MobEffect>, val amplifier: Int, val gate: String?)
var passives: List<Passive> = emptyList()
private set
private val given = HashMap<UUID, Set<Holder<MobEffect>>>()
private val noticed = HashSet<Pair<UUID, Holder<MobEffect>>>()
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onServerTick)
NeoForge.EVENT_BUS.addListener<ServerStoppedEvent> { given.clear(); noticed.clear() }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
passives = files.toSortedMap().values.flatMap { file ->
file.asJsonObject.getAsJsonArray("passives").mapNotNull { element ->
val json = element.asJsonObject
val id = ResourceLocation.parse(json.get("effect").asString)
val effect = BuiltInRegistries.MOB_EFFECT.getHolder(id).orElse(null)
?: return@mapNotNull null.also { CobbleSurvival.LOGGER.warn("Shoulder passive with unknown effect {}", id) }
Passive(
types = json.getAsJsonArray("types").map { it.asString.lowercase() }.toSet(),
effect = effect,
amplifier = json.get("amplifier")?.asInt ?: 0,
gate = json.get("gate")?.asString,
)
}
}
CobbleSurvival.LOGGER.info("Loaded {} shoulder passives", passives.size)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (event.server.tickCount % CHECK_TICKS != 0) return
event.server.playerList.players.forEach { apply(it) }
}
/** The Pokémon riding [player]'s shoulders. */
fun shouldered(player: ServerPlayer): List<Pokemon> =
Cobblemon.storage.getParty(player).filter { (it.state as? ShoulderedState)?.isStillShouldered(player) == true }
private fun matches(passive: Passive, pokemon: Pokemon) = pokemon.types.any { it.name.lowercase() in passive.types }
/** Effect → amplifier that [shouldered] Pokémon give, gates passed. */
fun effects(shouldered: List<Pokemon>): Map<Holder<MobEffect>, Int> {
val result = HashMap<Holder<MobEffect>, Int>()
for (passive in passives) {
if (shouldered.none { matches(passive, it) && (passive.gate == null || Gates.passes(it, passive.gate)) }) continue
result.merge(passive.effect, passive.amplifier, ::maxOf)
}
return result
}
/** Gives [player] the effects of [shouldered] and takes back the ones it gave before that no longer apply. */
fun apply(player: ServerPlayer, shouldered: List<Pokemon> = shouldered(player)) {
val effects = effects(shouldered)
for ((effect, amplifier) in effects) player.addEffect(MobEffectInstance(effect, DURATION, amplifier, true, false, true))
val before = if (effects.isEmpty()) given.remove(player.uuid) else given.put(player.uuid, effects.keys)
for (effect in before.orEmpty() - effects.keys) {
val current = player.getEffect(effect)
if (current != null && current.isAmbient && current.duration <= DURATION) player.removeEffect(effect)
}
noticeGated(player, shouldered)
}
/** Tells [player] once per Pokémon and effect what a shoulder Pokémon still needs for a gated passive. */
private fun noticeGated(player: ServerPlayer, shouldered: List<Pokemon>) {
for (pokemon in shouldered) for (passive in passives) {
val gate = passive.gate ?: continue
if (!matches(passive, pokemon) || Gates.passes(pokemon, gate) || !noticed.add(pokemon.uuid to passive.effect)) continue
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.field_move", "%s can't give you %s yet. It needs %s.",
pokemon.getDisplayName(), passive.effect.value().displayName, Gates.requirement(gate),
).withStyle(ChatFormatting.YELLOW)
)
}
}
}
@@ -2,7 +2,6 @@ package dev.cobblesurvival.progress
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.CobbleSurvival import dev.cobblesurvival.CobbleSurvival
import net.minecraft.world.level.Level
import net.neoforged.fml.ModList import net.neoforged.fml.ModList
import java.lang.reflect.InvocationHandler import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
@@ -11,8 +10,8 @@ import java.lang.reflect.Proxy
/** /**
* Puts [Gates] in front of Cobblebase's skills, by reflection (Cobblebase is not a compile dependency): every skill * Puts [Gates] in front of Cobblebase's skills, by reflection (Cobblebase is not a compile dependency): every skill
* executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon's owner * executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon passes
* has unlocked that skill. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One * that skill's soft gate (level or held material); otherwise its owner is told what it needs. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One
* executor can serve several skills (all the finders), so the check is per skill, not per executor. * executor can serve several skills (all the finders), so the check is per skill, not per executor.
*/ */
object CobblebaseGate { object CobblebaseGate {
@@ -68,11 +67,12 @@ object CobblebaseGate {
private class Gate(private val original: Any) : InvocationHandler { private class Gate(private val original: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? { override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
if (method.name == "tick" && args != null && args.size == 5) { if (method.name == "tick" && args != null && args.size == 5) {
val level = args[0] as Level val entity = args[2] as PokemonEntity
val owner = (args[2] as PokemonEntity).pokemon.getOwnerUUID()
val skill = skillId.invoke(args[3]) as String val skill = skillId.invoke(args[3]) as String
val server = level.server ?: return null if (!Gates.skillOpen(entity.pokemon, skill)) {
if (!Gates.skillOpen(Progress.of(server), owner, skill)) return null Gates.noticeIdle(entity, skill)
return null
}
} }
return try { return try {
method.invoke(original, *(args ?: emptyArray())) method.invoke(original, *(args ?: emptyArray()))
@@ -1,5 +1,7 @@
package dev.cobblesurvival.progress package dev.cobblesurvival.progress
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
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
@@ -8,85 +10,122 @@ import net.minecraft.ChatFormatting
import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.resources.ResourceManager import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.neoforged.neoforge.common.NeoForge import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.level.BlockEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent import net.neoforged.neoforge.event.server.ServerStartedEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import java.util.UUID import java.util.UUID
/** /**
* Progression gates (Adrian, 2026-10-30: Cobblebase's skills and the power source stay in, "gated behind * Soft gates (Adrian, 2026-10-31): Cobblebase's base skills and the Cobblemon HMs auras that get around zone hazards
* progression"): a Cobblebase skill works for a pastured Pokémon, and a gated block can be placed, only once its owner * open up through the Pokémon doing the work, never through flags a player earns. A gate names a zone; a Pokémon passes
* has beaten a given zone's Alpha guardian ([Progress]). * it when its level reaches the gate's level (by default the top of the zone's band), or when it holds one of the
* zone's materials or a material of a zone with a higher gate. Guardians drop materials but unlock nothing, so a gifted
* Pokémon or material does the same as an earned one.
* *
* The table is data, `data/cobblesurvival/progression_gates/<name>.json`: * Data, `data/cobblesurvival/progression_gates/<name>.json`, later files overriding earlier ones:
* `{"cobblebase_skills": {"<skill id>": "<zone>" or null}, "blocks": {"<block id>": "<zone>"}}`; null means from the * - `zones`: `{"<zone>": {"items": ["<item id>", ...], "level": <optional>}}`;
* start, later files override earlier ones. A Cobblebase skill missing from the table stays off, so an update can't * - `cobblebase_skills`: `{"<skill id>": "<zone>" or null}`, null meaning from the start. A Cobblebase skill missing
* slip new skills past the gates; the self-test `gates_cover_every_cobblebase_skill` names any that are missing. * here stays off, so an update can't slip new skills past the gates (self-test `gates_cover_every_cobblebase_skill`);
* - `field_moves`: `{"<Cobblemon HMs aura id>": "<zone>"}`, enforced by [HmsGate].
*/ */
object Gates : SimpleJsonResourceReloadListener(Gson(), "progression_gates") { object Gates : SimpleJsonResourceReloadListener(Gson(), "progression_gates") {
/** Owners are told once per Pokémon and skill (per server run) why a pastured Pokémon idles, if they are this near. */
private const val NOTICE_RANGE = 64.0
class ZoneGate(val zone: String, private val fixedLevel: Int?, val items: List<Item>) {
val level: Int get() = fixedLevel ?: Zones.byId[zone]?.maxLevel ?: Int.MAX_VALUE
}
var zones: Map<String, ZoneGate> = emptyMap()
private set
var skills: Map<String, String?> = emptyMap() var skills: Map<String, String?> = emptyMap()
private set private set
var blocks: Map<ResourceLocation, String> = emptyMap() var fieldMoves: Map<String, String> = emptyMap()
private set private set
private val noticed = HashSet<Pair<UUID, String>>()
fun register() { fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) } NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onPlace)
NeoForge.EVENT_BUS.addListener<ServerStartedEvent> { CobblebaseGate.install() } NeoForge.EVENT_BUS.addListener<ServerStartedEvent> { CobblebaseGate.install() }
NeoForge.EVENT_BUS.addListener<ServerStoppedEvent> { noticed.clear() }
HmsGate.register()
} }
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) { override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
val zoneGates = LinkedHashMap<String, ZoneGate>()
val skillGates = LinkedHashMap<String, String?>() val skillGates = LinkedHashMap<String, String?>()
val blockGates = LinkedHashMap<ResourceLocation, String>() val moveGates = LinkedHashMap<String, String>()
for ((_, element) in files.toSortedMap()) { for ((_, element) in files.toSortedMap()) {
val json = element.asJsonObject val json = element.asJsonObject
json.getAsJsonObject("zones")?.entrySet()?.forEach { (zone, value) ->
val gate = value.asJsonObject
val items = gate.getAsJsonArray("items").mapNotNull { id ->
BuiltInRegistries.ITEM.get(ResourceLocation.parse(id.asString)).takeIf { it != Items.AIR }
?: null.also { CobbleSurvival.LOGGER.warn("Gate for {} names an unknown item {}", zone, id.asString) }
}
zoneGates[zone] = ZoneGate(zone, gate.get("level")?.asInt, items)
}
json.getAsJsonObject("cobblebase_skills")?.entrySet()?.forEach { (skill, zone) -> json.getAsJsonObject("cobblebase_skills")?.entrySet()?.forEach { (skill, zone) ->
skillGates[skill] = if (zone.isJsonNull) null else zone.asString skillGates[skill] = if (zone.isJsonNull) null else zone.asString
} }
json.getAsJsonObject("blocks")?.entrySet()?.forEach { (block, zone) -> json.getAsJsonObject("field_moves")?.entrySet()?.forEach { (move, zone) -> moveGates[move] = zone.asString }
blockGates[ResourceLocation.parse(block)] = zone.asString
}
} }
zones = zoneGates
skills = skillGates skills = skillGates
blocks = blockGates fieldMoves = moveGates
CobbleSurvival.LOGGER.info("Loaded progression gates: {} Cobblebase skills, {} blocks", skills.size, blocks.size) CobbleSurvival.LOGGER.info("Loaded soft gates: {} zones, {} Cobblebase skills, {} field moves", zones.size, skills.size, fieldMoves.size)
} }
fun skillOpen(progress: Progress, owner: UUID?, skill: String): Boolean { /** Whether [pokemon] passes [zone]'s gate: high enough level, or holding that zone's material or a harder zone's. */
fun passes(pokemon: Pokemon, zone: String): Boolean {
val gate = zones[zone] ?: return false
if (pokemon.level >= gate.level) return true
val held = pokemon.heldItem()
return !held.isEmpty && zones.values.any { it.level >= gate.level && held.item in it.items }
}
fun skillOpen(pokemon: Pokemon, skill: String): Boolean {
if (!skills.containsKey(skill)) return false if (!skills.containsKey(skill)) return false
val zone = skills[skill] ?: return true val zone = skills[skill] ?: return true
return owner != null && zone in progress.beaten(owner) return passes(pokemon, zone)
} }
private fun onPlace(event: BlockEvent.EntityPlaceEvent) { /** "Lv 25, or to hold Raw Iron, Iron Ingot or Thunder Stone (Westmarch) or a material of a harder zone". */
val player = event.entity as? ServerPlayer ?: return fun requirement(zone: String): Component {
if (player.isCreative) return val gate = zones[zone]
val zone = blocks[BuiltInRegistries.BLOCK.getKey(event.placedBlock.block)] ?: return val items = Component.empty()
if (zone in Progress.of(player.server).beaten(player.uuid)) return gate?.items?.forEachIndexed { i, item ->
event.isCanceled = true if (i > 0) items.append(if (i == gate.items.lastIndex) " or " else ", ")
player.displayClientMessage( items.append(ItemStack(item).hoverName)
Component.translatableWithFallback( }
"message.cobblesurvival.gate.block", "Beat the guardian of %s to use this.", zoneName(zone), return Component.translatableWithFallback(
).withStyle(ChatFormatting.RED), true "message.cobblesurvival.gate.requirement", "Lv %s, or to hold %s (%s) or a material of a harder zone",
gate?.level ?: "?", items, Zones.byId[zone]?.name ?: zone,
) )
} }
/** Tells [player] what beating [zone]'s guardian unlocked for them. */ /** Tells a pastured Pokémon's owner, once, why it doesn't do [skill]; only when the owner is near. */
fun announceUnlocks(player: ServerPlayer, zone: String) { fun noticeIdle(entity: PokemonEntity, skill: String) {
val unlocked = skills.filterValues { it == zone }.keys.map { CobblebaseGate.skillName(it) ?: it } + val pokemon = entity.pokemon
blocks.filterValues { it == zone }.keys.map { BuiltInRegistries.BLOCK.get(it).name.string } val key = pokemon.uuid to skill
if (unlocked.isEmpty()) return if (key in noticed) return
player.sendSystemMessage( val owner = pokemon.getOwnerPlayer() ?: return
if (owner.level() != entity.level() || owner.distanceToSqr(entity) > NOTICE_RANGE * NOTICE_RANGE) return
noticed += key
val name = CobblebaseGate.skillName(skill) ?: skill
owner.sendSystemMessage(
Component.translatableWithFallback( Component.translatableWithFallback(
"message.cobblesurvival.gate.unlocked", "Unlocked at your base: %s", unlocked.joinToString(), "message.cobblesurvival.gate.skill", "%s won't do %s at your base yet. It needs %s.",
).withStyle(ChatFormatting.GREEN) pokemon.getDisplayName(), name, requirement(skills[skill] ?: return),
).withStyle(ChatFormatting.YELLOW)
) )
} }
private fun zoneName(zone: String) = Zones.byId[zone]?.name ?: zone
} }
@@ -0,0 +1,95 @@
package dev.cobblesurvival.progress
import com.cobblemon.mod.common.Cobblemon
import net.minecraft.ChatFormatting
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerPlayer
import net.neoforged.fml.ModList
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Puts [Gates] in front of the Cobblemon HMs auras that get around zone hazards (Adrian, 2026-10-31: soft gates, e.g.
* Flash in the Deep Dark, Lava Plume in the Basalt Furnace). HMs keeps a switch per player, Pokémon and aura; every few
* ticks this switches off a gated aura whose Pokémon doesn't pass its gate and tells the player what the Pokémon needs.
* HMs' other abilities stay open (Adrian). By reflection: HMs is not a compile dependency.
*/
object HmsGate {
private const val PACKAGE = "com.shier.cobblemonhms"
private const val CHECK_TICKS = 5
val installed: Boolean get() = ModList.get().isLoaded("cobblemon_hms")
private val abilityType by lazy { Class.forName("$PACKAGE.ability.HMAbility") }
private val abilities by lazy { abilityType.getField("Companion").get(null) }
private val dataType by lazy { Class.forName("$PACKAGE.storage.HMsSavedData") }
private val dataCompanion by lazy { dataType.getField("Companion").get(null) }
private val partners by lazy { Class.forName("$PACKAGE.ability.Partners").let { it to it.getField("INSTANCE").get(null) } }
private val isOn by lazy { dataType.getMethod("isMountToggleOn", UUID::class.java, UUID::class.java, Int::class.javaPrimitiveType) }
fun register() {
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
/** HMs' ability for [id] (as in `config/cobblemon_hms.json`), or null. */
fun ability(id: String): Enum<*>? =
abilities.javaClass.getMethod("fromId", String::class.java).invoke(abilities, id) as Enum<*>?
/** The ids of HMs' auras, the abilities a player switches on and off. */
@Suppress("UNCHECKED_CAST")
fun auraIds(): Set<String> = (abilities.javaClass.getMethod("getTOGGLES").invoke(abilities) as List<Any>)
.map { abilityType.getMethod("getId").invoke(it) as String }.toSet()
private fun data(server: MinecraftServer): Any = dataCompanion.javaClass.getMethod("get", MinecraftServer::class.java).invoke(dataCompanion, server)
fun isOn(player: ServerPlayer, pokemon: UUID, id: String): Boolean {
val ability = ability(id) ?: return false
return isOn.invoke(data(player.server), player.uuid, pokemon, ability.ordinal) as Boolean
}
/** Switches [player]'s aura [id] from [pokemon] on, as HMs' interaction wheel does (for the self-tests). */
fun switchOn(player: ServerPlayer, pokemon: UUID, id: String) {
if (isOn(player, pokemon, id)) return
val ordinal = (ability(id) ?: return).ordinal
dataType.getMethod("toggleMount", UUID::class.java, UUID::class.java, Int::class.javaPrimitiveType)
.invoke(data(player.server), player.uuid, pokemon, ordinal)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (!installed || Gates.fieldMoves.isEmpty() || event.server.tickCount % CHECK_TICKS != 0) return
event.server.playerList.players.forEach(::check)
}
/** Switches off [player]'s gated auras whose Pokémon don't pass their gates; returns how many. */
fun check(player: ServerPlayer): Int {
val data = data(player.server)
val toggles = dataType.getMethod("mountTogglesOf", UUID::class.java).invoke(data, player.uuid) as Map<*, *>
if (toggles.isEmpty()) return 0
val party = Cobblemon.storage.getParty(player)
var switchedOff = 0
for (pokemonId in toggles.keys) {
val pokemon = party[pokemonId as UUID] ?: continue
for ((id, zone) in Gates.fieldMoves) {
val ability = ability(id) ?: continue
if (!(isOn.invoke(data, player.uuid, pokemonId, ability.ordinal) as Boolean) || Gates.passes(pokemon, zone)) continue
dataType.getMethod("switchOff", UUID::class.java, UUID::class.java, Collection::class.java)
.invoke(data, player.uuid, pokemonId, listOf(ability.ordinal))
switchedOff++
val name = abilityType.getMethod("getDisplayName").invoke(ability) as String
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.field_move", "%s can't give you %s yet. It needs %s.",
pokemon.getDisplayName(), name, Gates.requirement(zone),
).withStyle(ChatFormatting.YELLOW)
)
}
}
if (switchedOff > 0) {
val (type, instance) = partners
type.getMethod("refresh", ServerPlayer::class.java).invoke(instance, player)
}
return switchedOff
}
}
@@ -1,83 +0,0 @@
package dev.cobblesurvival.progress
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.zone.Guardians
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.commands.SharedSuggestionProvider
import net.minecraft.commands.arguments.EntityArgument
import net.minecraft.core.HolderLookup
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.ListTag
import net.minecraft.nbt.StringTag
import net.minecraft.nbt.Tag
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.world.level.saveddata.SavedData
import java.util.UUID
/**
* A player's progression: the zones whose Alpha guardian they have helped beat. [Guardians] records a win for every
* player in the battle; [Gates] unlocks base skills and blocks from it. Saved with the overworld.
*
* Operators: `/cobblesurvival progress <player>` lists it, `... grant|revoke <player> <zone>` changes it.
*/
class Progress : SavedData() {
private val beaten = HashMap<UUID, MutableSet<String>>()
fun beaten(player: UUID): Set<String> = beaten[player].orEmpty()
/** Records that [player] beat [zone]'s guardian; false if they had already. */
fun grant(player: UUID, zone: String): Boolean =
beaten.getOrPut(player) { HashSet() }.add(zone).also { if (it) setDirty() }
fun revoke(player: UUID, zone: String): Boolean = (beaten[player]?.remove(zone) == true).also { if (it) setDirty() }
override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag {
for ((player, zones) in beaten) {
tag.put(player.toString(), ListTag().apply { zones.sorted().forEach { add(StringTag.valueOf(it)) } })
}
return tag
}
companion object {
private val FACTORY = Factory(::Progress, ::load)
fun of(server: MinecraftServer): Progress = server.overworld().dataStorage.computeIfAbsent(FACTORY, "cobblesurvival_progress")
private fun load(tag: CompoundTag, registries: HolderLookup.Provider): Progress {
val progress = Progress()
for (key in tag.allKeys) {
val zones = tag.getList(key, Tag.TAG_STRING.toInt()).map { it.asString }
progress.beaten[UUID.fromString(key)] = zones.toHashSet()
}
return progress
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> {
val zone = Commands.argument("zone", StringArgumentType.word())
.suggests { _, builder -> SharedSuggestionProvider.suggest(Guardians.definitions.keys, builder) }
fun change(grant: Boolean) = Commands.literal(if (grant) "grant" else "revoke")
.then(Commands.argument("player", EntityArgument.player()).then(zone.executes { context ->
val player = EntityArgument.getPlayer(context, "player")
val id = StringArgumentType.getString(context, "zone")
val progress = of(context.source.server)
val changed = if (grant) progress.grant(player.uuid, id) else progress.revoke(player.uuid, id)
context.source.sendSuccess({ Component.literal(
"${player.gameProfile.name}: $id ${if (grant) "granted" else "revoked"}${if (changed) "" else " (no change)"}") }, true)
1
}))
return Commands.literal("progress")
.then(Commands.argument("player", EntityArgument.player()).executes { context ->
val player = EntityArgument.getPlayer(context, "player")
val zones = of(context.source.server).beaten(player.uuid).sorted()
context.source.sendSuccess({ Component.literal(
"${player.gameProfile.name} beat: ${zones.joinToString().ifEmpty { "no guardian yet" }}") }, false)
1
})
.then(change(grant = true))
.then(change(grant = false))
}
}
}
@@ -1,13 +1,17 @@
package dev.cobblesurvival.selftest package dev.cobblesurvival.selftest
import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.HmsGate
import net.minecraft.commands.arguments.EntityAnchorArgument import net.minecraft.commands.arguments.EntityAnchorArgument
import net.minecraft.core.BlockPos import net.minecraft.core.BlockPos
import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer import net.minecraft.server.level.ServerPlayer
import net.minecraft.util.RandomSource import net.minecraft.util.RandomSource
import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.entity.item.ItemEntity
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items import net.minecraft.world.item.Items
import net.minecraft.world.level.GameType import net.minecraft.world.level.GameType
import net.minecraft.world.level.block.Blocks import net.minecraft.world.level.block.Blocks
@@ -19,13 +23,42 @@ import net.minecraft.world.phys.Vec3
* must mine a regenerating vein the normal way: the ore drops and Regenerating Ore Veins turns the block into a * must mine a regenerating vein the normal way: the ore drops and Regenerating Ore Veins turns the block into a
* regenerator, so the vein grows back. The test fires HMs' own Rock Smash handler (by reflection) at a one-block * regenerator, so the vein grows back. The test fires HMs' own Rock Smash handler (by reflection) at a one-block
* coal vein, away from spawn, since HMs respects spawn protection. * coal vein, away from spawn, since HMs respects spawn protection.
*
* The auras that get around zone hazards have soft gates (Adrian, 2026-10-31): the Pokémon giving one needs the level
* or a held material of the aura's gate zone ([HmsGate]).
*/ */
object FieldMoveTests { object FieldMoveTests {
private const val VEIN = "selftest" private const val VEIN = "selftest"
private const val OFFSET = 64 private const val OFFSET = 64
private val REGENERATOR = ResourceLocation.parse("regenerating_ore_veins:regenerator_block") private val REGENERATOR = ResourceLocation.parse("regenerating_ore_veins:regenerator_block")
fun all() = listOf(SelfTestCase("rock_smash_mines_veins", 100, ::rockSmashMinesVeins)) fun all() = listOf(
SelfTestCase("rock_smash_mines_veins", 100, ::rockSmashMinesVeins),
SelfTestCase("hazard_auras_need_level_or_material", 20, ::hazardAurasNeedLevelOrMaterial),
)
private fun hazardAurasNeedLevelOrMaterial(s: Scenario) {
s.check(HmsGate.installed) { "Cobblemon HMs isn't installed" }
val notAuras = Gates.fieldMoves.keys - HmsGate.auraIds()
s.check(notAuras.isEmpty()) { "field-move gates for abilities that aren't Cobblemon HMs auras: $notAuras" }
val (aura, zone) = Gates.fieldMoves.entries.firstOrNull() ?: throw GameTestAssertException("no aura is gated")
val gate = Gates.zones[zone] ?: throw GameTestAssertException("$aura is gated by $zone, which has no gate")
val player = s.player()
val pokemon = s.give(player, s.pokemon("ampharos", 1))
fun keptOn(holding: ItemStack): Boolean {
pokemon.swapHeldItem(holding, false)
HmsGate.switchOn(player, pokemon.uuid, aura)
s.check(HmsGate.isOn(player, pokemon.uuid, aura)) { "$aura couldn't be switched on" }
HmsGate.check(player)
return HmsGate.isOn(player, pokemon.uuid, aura)
}
s.check(!keptOn(ItemStack.EMPTY)) { "a Lv 1 Pokémon holding nothing gives $aura (gate: $zone)" }
s.check(keptOn(ItemStack(gate.items.first()))) { "$aura was switched off for a Pokémon holding ${gate.items.first()} ($zone)" }
pokemon.level = gate.level
s.check(keptOn(ItemStack.EMPTY)) { "$aura was switched off for a Lv ${gate.level} Pokémon ($zone)" }
s.helper.succeed()
}
private fun rockSmashMinesVeins(s: Scenario) { private fun rockSmashMinesVeins(s: Scenario) {
val level = s.level val level = s.level
@@ -4,7 +4,6 @@ import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.MoveActionResponse import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.util.isInBattle import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.progress.Progress
import dev.cobblesurvival.zone.Guardians import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.Zones import dev.cobblesurvival.zone.Zones
import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.core.registries.BuiltInRegistries
@@ -62,7 +61,6 @@ object GuardianTests {
val have = player.inventory.countItem(BuiltInRegistries.ITEM.get(item)) val have = player.inventory.countItem(BuiltInRegistries.ITEM.get(item))
s.check(have >= count) { "the player got $have of $item from the guardian, not $count" } s.check(have >= count) { "the player got $have of $item from the guardian, not $count" }
} }
s.check(ZONE in Progress.of(level.server).beaten(player.uuid)) { "beating the guardian didn't count as progress" }
} }
.thenIdle(100) .thenIdle(100)
.thenExecute { .thenExecute {
@@ -3,14 +3,13 @@ package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.progress.CobblebaseGate import dev.cobblesurvival.progress.CobblebaseGate
import dev.cobblesurvival.progress.Gates import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
import dev.cobblesurvival.zone.Guardians
import net.minecraft.core.BlockPos import net.minecraft.core.BlockPos
import net.minecraft.core.Direction import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation import net.minecraft.resources.ResourceLocation
import net.minecraft.world.InteractionHand import net.minecraft.world.InteractionHand
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items import net.minecraft.world.item.Items
import net.minecraft.world.level.Level import net.minecraft.world.level.Level
@@ -20,16 +19,17 @@ import net.minecraft.world.phys.Vec3
import java.lang.reflect.Proxy import java.lang.reflect.Proxy
/** /**
* Progression gates (Adrian, 2026-10-30): Cobblebase's base skills and Poke Power's Power Block open up as a player * Soft gates (Adrian, 2026-10-31): Cobblebase's base skills open up through the pastured Pokémon's level or a material it
* beats the zones' Alpha guardians (`data/cobblesurvival/progression_gates/`). * holds, never through flags (`data/cobblesurvival/progression_gates/`). Poke Power's Power Block has no gate of its
* own: its recipe already takes the Verdant Jungle's redstone.
*/ */
object ProgressionTests { object ProgressionTests {
private val POWER_BLOCK = ResourceLocation.parse("poke_power:power_block") private val POWER_BLOCK = ResourceLocation.parse("poke_power:power_block")
fun all() = listOf( fun all() = listOf(
SelfTestCase("gates_cover_every_cobblebase_skill", 20, ::gatesCoverEverySkill), SelfTestCase("gates_cover_every_cobblebase_skill", 20, ::gatesCoverEverySkill),
SelfTestCase("cobblebase_skills_wait_for_guardians", 20, ::skillsWaitForGuardians), SelfTestCase("cobblebase_skills_need_level_or_material", 20, ::skillsNeedLevelOrMaterial),
SelfTestCase("power_block_waits_for_guardian", 20, ::powerBlockWaits), SelfTestCase("power_block_gated_by_its_recipe", 20, ::powerBlockGatedByRecipe),
) )
private fun gatesCoverEverySkill(s: Scenario) { private fun gatesCoverEverySkill(s: Scenario) {
@@ -39,73 +39,76 @@ object ProgressionTests {
s.check(ungated.isEmpty()) { "Cobblebase skills without a gate (they stay off): ${ungated.sorted()}" } s.check(ungated.isEmpty()) { "Cobblebase skills without a gate (they stay off): ${ungated.sorted()}" }
val stale = Gates.skills.keys - skills val stale = Gates.skills.keys - skills
s.check(stale.isEmpty()) { "gates for skills Cobblebase doesn't have: ${stale.sorted()}" } s.check(stale.isEmpty()) { "gates for skills Cobblebase doesn't have: ${stale.sorted()}" }
val zones = (Gates.skills.values.filterNotNull() + Gates.blocks.values).toSet() val named = (Gates.skills.values.filterNotNull() + Gates.fieldMoves.values).toSet()
val noGuardian = zones - Guardians.definitions.keys val noGate = named - Gates.zones.keys
s.check(noGuardian.isEmpty()) { "gates name zones without a guardian: ${noGuardian.sorted()}" } s.check(noGate.isEmpty()) { "gates name zones without a level and materials: ${noGate.sorted()}" }
val noBlock = Gates.blocks.keys.filterNot { BuiltInRegistries.BLOCK.containsKey(it) } val broken = Gates.zones.values.filter { it.items.isEmpty() || it.level == Int.MAX_VALUE }.map { it.zone }
s.check(noBlock.isEmpty()) { "gates for blocks that don't exist: $noBlock" } s.check(broken.isEmpty()) { "zone gates without materials or a level (unknown zone?): $broken" }
val open = CobblebaseGate.executorNames().filterNot { name -> CobblebaseGate.executor(name)?.let(CobblebaseGate::isGated) == true } val open = CobblebaseGate.executorNames().filterNot { name -> CobblebaseGate.executor(name)?.let(CobblebaseGate::isGated) == true }
s.check(open.isEmpty()) { "Cobblebase executors not behind the gates: $open" } s.check(open.isEmpty()) { "Cobblebase executors not behind the gates: $open" }
s.helper.succeed() s.helper.succeed()
} }
/** A probe behind the same gate as Cobblebase's executors, called the way Cobblebase calls them. */ /** A probe behind the same gate as Cobblebase's executors, called the way Cobblebase calls them. */
private fun skillsWaitForGuardians(s: Scenario) { private fun skillsNeedLevelOrMaterial(s: Scenario) {
val level = s.level val level = s.level
val player = s.player() val player = s.player()
val pokemon = s.give(player, s.pokemon("pikachu", 10)) val pokemon = s.give(player, s.pokemon("pikachu", 1))
s.check(pokemon.getOwnerUUID() == player.uuid) { "the test Pokémon has no owner" }
val entity = PokemonEntity(level, pokemon) val entity = PokemonEntity(level, pokemon)
val (openSkill, _) = Gates.skills.entries.firstOrNull { it.value == null } ?: throw GameTestAssertException("no skill is open from the start") val (openSkill, _) = Gates.skills.entries.firstOrNull { it.value == null } ?: throw GameTestAssertException("no skill is open from the start")
val (lockedSkill, zone) = Gates.skills.entries.firstOrNull { it.value != null } ?: throw GameTestAssertException("no skill is gated") val gated = Gates.skills.entries.mapNotNull { (skill, zone) -> zone?.let { skill to Gates.zones.getValue(it) } }.sortedBy { it.second.level }
val progress = Progress.of(level.server) s.check(gated.isNotEmpty()) { "no skill is gated" }
s.afterTest { progress.revoke(player.uuid, zone!!) } val (lockedSkill, gate) = gated[gated.size / 2]
val easier = Gates.zones.values.filter { it.level < gate.level && it.items.isNotEmpty() }.minByOrNull { it.level }
val harder = Gates.zones.values.filter { it.level > gate.level && it.items.isNotEmpty() }.maxByOrNull { it.level }
var calls = 0 var calls = 0
val type = CobblebaseGate.executorType val type = CobblebaseGate.executorType
val probe = Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, _ -> if (method.name == "tick") calls++; null } val probe = Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, _ -> if (method.name == "tick") calls++; null }
val gated = CobblebaseGate.wrap(probe) val wrapped = CobblebaseGate.wrap(probe)
val tick = type.getMethod("tick", Level::class.java, BlockPos::class.java, PokemonEntity::class.java, val tick = type.getMethod("tick", Level::class.java, BlockPos::class.java, PokemonEntity::class.java,
CobblebaseGate.skillDef, CobblebaseGate.skillEntry) CobblebaseGate.skillDef, CobblebaseGate.skillEntry)
val entry = CobblebaseGate.skillEntry.getConstructor(String::class.java, Int::class.javaPrimitiveType) val entry = CobblebaseGate.skillEntry.getConstructor(String::class.java, Int::class.javaPrimitiveType)
fun run(skill: String) { fun runs(skill: String, holding: Item? = null): Boolean {
pokemon.swapHeldItem(holding?.let(::ItemStack) ?: ItemStack.EMPTY, false)
val def = CobblebaseGate.skills()[skill] ?: throw GameTestAssertException("Cobblebase has no skill $skill") val def = CobblebaseGate.skills()[skill] ?: throw GameTestAssertException("Cobblebase has no skill $skill")
tick.invoke(gated, level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1)) val before = calls
tick.invoke(wrapped, level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1))
return calls > before
} }
run(openSkill) s.check(runs(openSkill)) { "$openSkill is open from the start but didn't run" }
s.check(calls == 1) { "$openSkill is open from the start but didn't run" } s.check(!runs(lockedSkill)) { "$lockedSkill (gate: ${gate.zone}) ran for a Lv 1 Pokémon holding nothing" }
run(lockedSkill) s.check(runs(lockedSkill, gate.items.first())) { "$lockedSkill didn't run for a Pokémon holding ${gate.items.first()} (${gate.zone})" }
s.check(calls == 1) { "$lockedSkill ran before the guardian of $zone was beaten" } if (harder != null) {
progress.grant(player.uuid, zone!!) s.check(runs(lockedSkill, harder.items.first())) { "$lockedSkill didn't run with ${harder.items.first()} from the harder ${harder.zone}" }
run(lockedSkill) }
s.check(calls == 2) { "$lockedSkill didn't run after the guardian of $zone was beaten" } if (easier != null) {
s.check(!runs(lockedSkill, easier.items.first())) { "$lockedSkill ran with ${easier.items.first()} from the easier ${easier.zone}" }
}
pokemon.level = gate.level
s.check(runs(lockedSkill)) { "$lockedSkill didn't run for a Lv ${gate.level} Pokémon (${gate.zone})" }
s.helper.succeed() s.helper.succeed()
} }
private fun powerBlockWaits(s: Scenario) { /** No flag stands between a player and the Power Block; its recipe needs the Jungle's redstone. */
private fun powerBlockGatedByRecipe(s: Scenario) {
val level = s.level val level = s.level
val zone = Gates.blocks[POWER_BLOCK] ?: throw GameTestAssertException("the Power Block has no gate")
val item = BuiltInRegistries.ITEM.get(POWER_BLOCK) val item = BuiltInRegistries.ITEM.get(POWER_BLOCK)
s.check(item != Items.AIR) { "Poke Power isn't installed" } s.check(item != Items.AIR) { "Poke Power isn't installed" }
val recipe = level.server.recipeManager.byKey(POWER_BLOCK).orElse(null)
?: throw GameTestAssertException("the Power Block has no recipe")
s.check(recipe.value().ingredients.any { it.test(ItemStack(Items.REDSTONE_BLOCK)) }) {
"the Power Block recipe no longer takes redstone blocks (Verdant Jungle ore), so nothing gates it"
}
val player = s.player() val player = s.player()
val progress = Progress.of(level.server)
s.afterTest { progress.revoke(player.uuid, zone) }
val floor = s.helper.absolutePos(BlockPos(7, 2, 7)) val floor = s.helper.absolutePos(BlockPos(7, 2, 7))
level.setBlockAndUpdate(floor, Blocks.STONE.defaultBlockState()) level.setBlockAndUpdate(floor, Blocks.STONE.defaultBlockState())
val target = floor.above()
fun place() {
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item)) player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item))
val hit = BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false) val hit = BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false)
player.gameMode.useItemOn(player, level, player.mainHandItem, InteractionHand.MAIN_HAND, hit) player.gameMode.useItemOn(player, level, player.mainHandItem, InteractionHand.MAIN_HAND, hit)
} val placed = level.getBlockState(floor.above())
fun placed() = BuiltInRegistries.BLOCK.getKey(level.getBlockState(target).block) == POWER_BLOCK s.check(BuiltInRegistries.BLOCK.getKey(placed.block) == POWER_BLOCK) { "a survival player couldn't place a Power Block: $placed" }
place()
s.check(!placed()) { "a player who hasn't beaten the guardian of $zone placed a Power Block" }
progress.grant(player.uuid, zone)
place()
s.check(placed()) { "the Power Block couldn't be placed after beating the guardian of $zone: ${level.getBlockState(target)}" }
s.helper.succeed() s.helper.succeed()
} }
} }
@@ -54,7 +54,7 @@ object SelfTest {
private val cases: List<SelfTestCase> get() = private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() + PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() + VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() +
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all()
private var running: List<Scenario> = emptyList() private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList() private var forced: List<ChunkPos> = emptyList()
@@ -0,0 +1,49 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.item.ItemStack
/**
* Shoulder passives (Adrian, 2026-10-31): a shoulder Pokémon gives its type's effect; a gated one only with the gate's
* level or material. Calls the passives with a given list of shoulder Pokémon rather than seating them on a shoulder.
*/
object ShoulderTests {
fun all() = listOf(SelfTestCase("shoulder_pokemon_give_passives", 20, ::shoulderPokemonGivePassives))
private fun carrier(passive: ShoulderPassives.Passive): Pokemon {
val species = PokemonSpecies.implemented.firstOrNull { species ->
species.shoulderMountable && species.types.any { it.name.lowercase() in passive.types }
} ?: throw GameTestAssertException("no Pokémon of type ${passive.types} can ride a shoulder: ${passive.effect.registeredName} is out of reach")
return species.create(1)
}
private fun shoulderPokemonGivePassives(s: Scenario) {
val passives = ShoulderPassives.passives
s.check(passives.isNotEmpty()) { "no shoulder passives loaded" }
passives.forEach(::carrier)
val noGate = passives.mapNotNull { it.gate }.filterNot { it in Gates.zones }
s.check(noGate.isEmpty()) { "shoulder passives gated by zones without a gate: $noGate" }
val open = passives.first { it.gate == null }
val gated = passives.first { it.gate != null }
val player = s.player()
s.afterTest { ShoulderPassives.apply(player, emptyList()) }
ShoulderPassives.apply(player, listOf(carrier(open)))
s.check(player.hasEffect(open.effect)) { "a ${open.types} shoulder Pokémon didn't give ${open.effect.registeredName}" }
ShoulderPassives.apply(player, emptyList())
s.check(!player.hasEffect(open.effect)) { "${open.effect.registeredName} stayed after the shoulder Pokémon left" }
val pokemon = carrier(gated)
ShoulderPassives.apply(player, listOf(pokemon))
s.check(!player.hasEffect(gated.effect)) { "a Lv 1 shoulder Pokémon holding nothing gave ${gated.effect.registeredName} (gate: ${gated.gate})" }
val material = Gates.zones.getValue(gated.gate!!).items.first()
pokemon.swapHeldItem(ItemStack(material), false)
ShoulderPassives.apply(player, listOf(pokemon))
s.check(player.hasEffect(gated.effect)) { "a shoulder Pokémon holding $material didn't give ${gated.effect.registeredName}" }
s.helper.succeed()
}
}
@@ -8,8 +8,6 @@ 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.progress.Gates
import dev.cobblesurvival.progress.Progress
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
@@ -40,7 +38,8 @@ import kotlin.math.abs
* `respawn_hours` of game time later (Adrian: fixed spots, 5 hours). It only appears while a player is near, and * `respawn_hours` of game time later (Adrian: fixed spots, 5 hours). It only appears while a player is near, and
* wanders no further than [LEASH] blocks from its spot. Definitions: `data/cobblesurvival/guardians/<zone>.json`, * wanders no further than [LEASH] blocks from its spot. Definitions: `data/cobblesurvival/guardians/<zone>.json`,
* written by tools/zone_spawns.py; state (which entity, when beaten, where it stands) lives in the world save. * written by tools/zone_spawns.py; state (which entity, when beaten, where it stands) lives in the world save.
* Every player in the winning battle gets the drops and the zone in their [Progress], which unlocks base skills. * Every player in the winning battle gets the drops. A win unlocks nothing by itself (Adrian, 2026-10-31: no flags);
* the drops are materials for the soft gates ([dev.cobblesurvival.progress.Gates]).
*/ */
object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") { object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
/** Key in the guardian Pokémon's persistent data; its value is the zone id. */ /** Key in the guardian Pokémon's persistent data; its value is the zone id. */
@@ -181,7 +180,6 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
"The guardian of %s is beaten! It returns in %s hours.", zoneName, hours, "The guardian of %s is beaten! It returns in %s hours.", zoneName, hours,
).withStyle(ChatFormatting.GOLD) ).withStyle(ChatFormatting.GOLD)
) )
if (Progress.of(player.server).grant(player.uuid, zone)) Gates.announceUnlocks(player, zone)
} }
CobbleSurvival.LOGGER.info("Guardian of {} beaten by {}", zone, players.joinToString { it.gameProfile.name }) CobbleSurvival.LOGGER.info("Guardian of {} beaten by {}", zone, players.joinToString { it.gameProfile.name })
} }
@@ -4,6 +4,7 @@
"message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust.", "message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust.",
"message.cobblesurvival.guardian.beaten": "The guardian of %s is beaten! It returns in %s hours.", "message.cobblesurvival.guardian.beaten": "The guardian of %s is beaten! It returns in %s hours.",
"message.cobblesurvival.hazard.mire": "The Mire's fumes are poisoning you. Mire Waders would keep them off.", "message.cobblesurvival.hazard.mire": "The Mire's fumes are poisoning you. Mire Waders would keep them off.",
"message.cobblesurvival.gate.block": "Beat the guardian of %s to use this.", "message.cobblesurvival.gate.requirement": "Lv %s, or to hold %s (%s) or a material of a harder zone",
"message.cobblesurvival.gate.unlocked": "Unlocked at your base: %s" "message.cobblesurvival.gate.skill": "%s won't do %s at your base yet. It needs %s.",
"message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s."
} }