From 70cc2cad5097cd3503446a5db90fb26bb22385ff Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 31 Oct 2026 13:41:04 +0100 Subject: [PATCH] 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 --- .../progression_gates/base.json | 92 +++++++++++++- .../shoulder_passives/base.json | 12 ++ docs/addon-status.md | 6 +- docs/balance.md | 7 +- docs/checklists/phase-4.md | 2 + docs/checklists/phase-5.md | 20 ++- docs/design.md | 21 ++-- docs/licenses.md | 2 +- .../dev/cobblesurvival/CobbleSurvival.kt | 4 +- .../cobblesurvival/party/ShoulderPassives.kt | 119 ++++++++++++++++++ .../cobblesurvival/progress/CobblebaseGate.kt | 14 +-- .../dev/cobblesurvival/progress/Gates.kt | 117 +++++++++++------ .../dev/cobblesurvival/progress/HmsGate.kt | 95 ++++++++++++++ .../dev/cobblesurvival/progress/Progress.kt | 83 ------------ .../cobblesurvival/selftest/FieldMoveTests.kt | 35 +++++- .../cobblesurvival/selftest/GuardianTests.kt | 2 - .../selftest/ProgressionTests.kt | 91 +++++++------- .../dev/cobblesurvival/selftest/SelfTest.kt | 2 +- .../cobblesurvival/selftest/ShoulderTests.kt | 49 ++++++++ .../dev/cobblesurvival/zone/Guardians.kt | 6 +- .../assets/cobblesurvival/lang/en_us.json | 5 +- 21 files changed, 578 insertions(+), 206 deletions(-) create mode 100644 datapacks/core/data/cobblesurvival/shoulder_passives/base.json create mode 100644 mod/src/main/kotlin/dev/cobblesurvival/party/ShoulderPassives.kt create mode 100644 mod/src/main/kotlin/dev/cobblesurvival/progress/HmsGate.kt delete mode 100644 mod/src/main/kotlin/dev/cobblesurvival/progress/Progress.kt create mode 100644 mod/src/main/kotlin/dev/cobblesurvival/selftest/ShoulderTests.kt diff --git a/datapacks/core/data/cobblesurvival/progression_gates/base.json b/datapacks/core/data/cobblesurvival/progression_gates/base.json index daaaf1f..6847662 100644 --- a/datapacks/core/data/cobblesurvival/progression_gates/base.json +++ b/datapacks/core/data/cobblesurvival/progression_gates/base.json @@ -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:gatherer": null, "cobblebase:harvester": null, @@ -46,7 +126,13 @@ "cobblebase:breeder": "deep_dark", "cobblebase:egg_hatcher": "deep_dark" }, - "blocks": { - "poke_power:power_block": "jungle" + "field_moves": { + "flash": "dripstone", + "dive": "mire", + "rock_climb": "highlands", + "mist": "mire", + "magnet_rise": "sunscorch", + "lucky_chant": "frostpeak", + "lava_plume": "deep_dark" } } diff --git a/datapacks/core/data/cobblesurvival/shoulder_passives/base.json b/datapacks/core/data/cobblesurvival/shoulder_passives/base.json new file mode 100644 index 0000000..cf48cac --- /dev/null +++ b/datapacks/core/data/cobblesurvival/shoulder_passives/base.json @@ -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"} + ] +} diff --git a/docs/addon-status.md b/docs/addon-status.md index 5048377..07c91d5 100644 --- a/docs/addon-status.md +++ b/docs/addon-status.md @@ -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 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 | -| 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 | 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 | | 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 | -| 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 | -| 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 | +| 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 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 | | 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 | diff --git a/docs/balance.md b/docs/balance.md index 2bc549b..b1c3bfe 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -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 | | 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`) | -| 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" | -| Power Block gate | after the Verdant Jungle guardian | Placeholder | same file, `blocks` | Recipe: Poke Power's own (iron blocks, redstone blocks, Electirizer) | +| 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 | 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 | | 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 | diff --git a/docs/checklists/phase-4.md b/docs/checklists/phase-4.md index 7e5d479..d593564 100644 --- a/docs/checklists/phase-4.md +++ b/docs/checklists/phase-4.md @@ -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) +**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. - **Poke Power**: the Power Block can only be placed after the Verdant Jungle guardian. Automated: `power_block_waits_for_guardian`. diff --git a/docs/checklists/phase-5.md b/docs/checklists/phase-5.md index ef1eca7..37ff31b 100644 --- a/docs/checklists/phase-5.md +++ b/docs/checklists/phase-5.md @@ -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". +**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 - **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`. - **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) - [ ] 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. -- [ ] 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. -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? -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? +- 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. +- 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? diff --git a/docs/design.md b/docs/design.md index ccfc624..e38eff0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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`. -**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`: - -- [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 `, `... grant|revoke `. -- 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. +**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. **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`). - **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) diff --git a/docs/licenses.md b/docs/licenses.md index 7f2ab6e..31408c6 100644 --- a/docs/licenses.md +++ b/docs/licenses.md @@ -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) | | 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 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 | | 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 | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 5f3b135..ad4a9eb 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -8,8 +8,8 @@ import dev.cobblesurvival.dev.BiomePreview import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.permadeath.Permadeath +import dev.cobblesurvival.party.ShoulderPassives import dev.cobblesurvival.progress.Gates -import dev.cobblesurvival.progress.Progress import dev.cobblesurvival.selftest.SelfTest import dev.cobblesurvival.world.OnlyPokemon import dev.cobblesurvival.world.SharedLoot @@ -68,6 +68,7 @@ class CobbleSurvival { NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) Guardians.register() Gates.register() + ShoulderPassives.register() OreNodes.register() Catching.register() Wanderers.register() @@ -106,7 +107,6 @@ class CobbleSurvival { }) .then(BiomePreview.command()) .then(OreVeins.command()) - .then(Progress.command()) .then(Commands.literal("zone").executes { context -> val source = context.source val zone = Zones.at(source.level, BlockPos.containing(source.position)) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/party/ShoulderPassives.kt b/mod/src/main/kotlin/dev/cobblesurvival/party/ShoulderPassives.kt new file mode 100644 index 0000000..7dd83cf --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/party/ShoulderPassives.kt @@ -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/.json`, all files adding up: + * `{"passives": [{"types": ["fire", ...], "effect": "", "amplifier": 0, "gate": "" (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, val effect: Holder, val amplifier: Int, val gate: String?) + + var passives: List = emptyList() + private set + + private val given = HashMap>>() + private val noticed = HashSet>>() + + fun register() { + NeoForge.EVENT_BUS.addListener { it.addListener(this) } + NeoForge.EVENT_BUS.addListener(::onServerTick) + NeoForge.EVENT_BUS.addListener { given.clear(); noticed.clear() } + } + + override fun apply(files: Map, 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 = + 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): Map, Int> { + val result = HashMap, 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 = 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) { + 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) + ) + } + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/progress/CobblebaseGate.kt b/mod/src/main/kotlin/dev/cobblesurvival/progress/CobblebaseGate.kt index 83972b7..dd44216 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/progress/CobblebaseGate.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/progress/CobblebaseGate.kt @@ -2,7 +2,6 @@ package dev.cobblesurvival.progress import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import dev.cobblesurvival.CobbleSurvival -import net.minecraft.world.level.Level import net.neoforged.fml.ModList import java.lang.reflect.InvocationHandler 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 - * executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon's owner - * has unlocked that skill. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One + * executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon passes + * 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. */ object CobblebaseGate { @@ -68,11 +67,12 @@ object CobblebaseGate { private class Gate(private val original: Any) : InvocationHandler { override fun invoke(proxy: Any, method: Method, args: Array?): Any? { if (method.name == "tick" && args != null && args.size == 5) { - val level = args[0] as Level - val owner = (args[2] as PokemonEntity).pokemon.getOwnerUUID() + val entity = args[2] as PokemonEntity val skill = skillId.invoke(args[3]) as String - val server = level.server ?: return null - if (!Gates.skillOpen(Progress.of(server), owner, skill)) return null + if (!Gates.skillOpen(entity.pokemon, skill)) { + Gates.noticeIdle(entity, skill) + return null + } } return try { method.invoke(original, *(args ?: emptyArray())) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/progress/Gates.kt b/mod/src/main/kotlin/dev/cobblesurvival/progress/Gates.kt index 78af6d9..bfce2c8 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/progress/Gates.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/progress/Gates.kt @@ -1,5 +1,7 @@ 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.JsonElement import dev.cobblesurvival.CobbleSurvival @@ -8,85 +10,122 @@ import net.minecraft.ChatFormatting 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.item.Item +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items import net.neoforged.neoforge.common.NeoForge 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.ServerStoppedEvent import java.util.UUID /** - * Progression gates (Adrian, 2026-10-30: Cobblebase's skills and the power source stay in, "gated behind - * progression"): a Cobblebase skill works for a pastured Pokémon, and a gated block can be placed, only once its owner - * has beaten a given zone's Alpha guardian ([Progress]). + * Soft gates (Adrian, 2026-10-31): Cobblebase's base skills and the Cobblemon HMs auras that get around zone hazards + * open up through the Pokémon doing the work, never through flags a player earns. A gate names a zone; a Pokémon passes + * 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/.json`: - * `{"cobblebase_skills": {"": "" or null}, "blocks": {"": ""}}`; null means from the - * start, later files override earlier ones. A Cobblebase skill missing from the table stays off, so an update can't - * slip new skills past the gates; the self-test `gates_cover_every_cobblebase_skill` names any that are missing. + * Data, `data/cobblesurvival/progression_gates/.json`, later files overriding earlier ones: + * - `zones`: `{"": {"items": ["", ...], "level": }}`; + * - `cobblebase_skills`: `{"": "" or null}`, null meaning from the start. A Cobblebase skill missing + * here stays off, so an update can't slip new skills past the gates (self-test `gates_cover_every_cobblebase_skill`); + * - `field_moves`: `{"": ""}`, enforced by [HmsGate]. */ 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) { + val level: Int get() = fixedLevel ?: Zones.byId[zone]?.maxLevel ?: Int.MAX_VALUE + } + + var zones: Map = emptyMap() + private set var skills: Map = emptyMap() private set - var blocks: Map = emptyMap() + var fieldMoves: Map = emptyMap() private set + private val noticed = HashSet>() + fun register() { NeoForge.EVENT_BUS.addListener { it.addListener(this) } - NeoForge.EVENT_BUS.addListener(::onPlace) NeoForge.EVENT_BUS.addListener { CobblebaseGate.install() } + NeoForge.EVENT_BUS.addListener { noticed.clear() } + HmsGate.register() } override fun apply(files: Map, resources: ResourceManager, profiler: ProfilerFiller) { + val zoneGates = LinkedHashMap() val skillGates = LinkedHashMap() - val blockGates = LinkedHashMap() + val moveGates = LinkedHashMap() for ((_, element) in files.toSortedMap()) { 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) -> skillGates[skill] = if (zone.isJsonNull) null else zone.asString } - json.getAsJsonObject("blocks")?.entrySet()?.forEach { (block, zone) -> - blockGates[ResourceLocation.parse(block)] = zone.asString - } + json.getAsJsonObject("field_moves")?.entrySet()?.forEach { (move, zone) -> moveGates[move] = zone.asString } } + zones = zoneGates skills = skillGates - blocks = blockGates - CobbleSurvival.LOGGER.info("Loaded progression gates: {} Cobblebase skills, {} blocks", skills.size, blocks.size) + fieldMoves = moveGates + 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 val zone = skills[skill] ?: return true - return owner != null && zone in progress.beaten(owner) + return passes(pokemon, zone) } - private fun onPlace(event: BlockEvent.EntityPlaceEvent) { - val player = event.entity as? ServerPlayer ?: return - if (player.isCreative) return - val zone = blocks[BuiltInRegistries.BLOCK.getKey(event.placedBlock.block)] ?: return - if (zone in Progress.of(player.server).beaten(player.uuid)) return - event.isCanceled = true - player.displayClientMessage( - Component.translatableWithFallback( - "message.cobblesurvival.gate.block", "Beat the guardian of %s to use this.", zoneName(zone), - ).withStyle(ChatFormatting.RED), true + /** "Lv 25, or to hold Raw Iron, Iron Ingot or Thunder Stone (Westmarch) or a material of a harder zone". */ + fun requirement(zone: String): Component { + val gate = zones[zone] + val items = Component.empty() + gate?.items?.forEachIndexed { i, item -> + if (i > 0) items.append(if (i == gate.items.lastIndex) " or " else ", ") + items.append(ItemStack(item).hoverName) + } + return Component.translatableWithFallback( + "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. */ - fun announceUnlocks(player: ServerPlayer, zone: String) { - val unlocked = skills.filterValues { it == zone }.keys.map { CobblebaseGate.skillName(it) ?: it } + - blocks.filterValues { it == zone }.keys.map { BuiltInRegistries.BLOCK.get(it).name.string } - if (unlocked.isEmpty()) return - player.sendSystemMessage( + /** Tells a pastured Pokémon's owner, once, why it doesn't do [skill]; only when the owner is near. */ + fun noticeIdle(entity: PokemonEntity, skill: String) { + val pokemon = entity.pokemon + val key = pokemon.uuid to skill + if (key in noticed) return + 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( - "message.cobblesurvival.gate.unlocked", "Unlocked at your base: %s", unlocked.joinToString(), - ).withStyle(ChatFormatting.GREEN) + "message.cobblesurvival.gate.skill", "%s won't do %s at your base yet. It needs %s.", + pokemon.getDisplayName(), name, requirement(skills[skill] ?: return), + ).withStyle(ChatFormatting.YELLOW) ) } - - private fun zoneName(zone: String) = Zones.byId[zone]?.name ?: zone } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/progress/HmsGate.kt b/mod/src/main/kotlin/dev/cobblesurvival/progress/HmsGate.kt new file mode 100644 index 0000000..f6e4854 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/progress/HmsGate.kt @@ -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 = (abilities.javaClass.getMethod("getTOGGLES").invoke(abilities) as List) + .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 + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/progress/Progress.kt b/mod/src/main/kotlin/dev/cobblesurvival/progress/Progress.kt deleted file mode 100644 index 3c662c5..0000000 --- a/mod/src/main/kotlin/dev/cobblesurvival/progress/Progress.kt +++ /dev/null @@ -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 ` lists it, `... grant|revoke ` changes it. - */ -class Progress : SavedData() { - private val beaten = HashMap>() - - fun beaten(player: UUID): Set = 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 { - 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)) - } - } -} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/FieldMoveTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/FieldMoveTests.kt index f29e315..19fc039 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/FieldMoveTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/FieldMoveTests.kt @@ -1,13 +1,17 @@ package dev.cobblesurvival.selftest import dev.cobblesurvival.dev.OreVeins +import dev.cobblesurvival.progress.Gates +import dev.cobblesurvival.progress.HmsGate import net.minecraft.commands.arguments.EntityAnchorArgument import net.minecraft.core.BlockPos import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestAssertException import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.minecraft.util.RandomSource import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack import net.minecraft.world.item.Items import net.minecraft.world.level.GameType 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 * 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. + * + * 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 { private const val VEIN = "selftest" private const val OFFSET = 64 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) { val level = s.level diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt index dcff3f6..8dc6e61 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt @@ -4,7 +4,6 @@ import com.cobblemon.mod.common.battles.BattleBuilder import com.cobblemon.mod.common.battles.MoveActionResponse import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.cobblemon.mod.common.util.isInBattle -import dev.cobblesurvival.progress.Progress import dev.cobblesurvival.zone.Guardians import dev.cobblesurvival.zone.Zones import net.minecraft.core.registries.BuiltInRegistries @@ -62,7 +61,6 @@ object GuardianTests { 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(ZONE in Progress.of(level.server).beaten(player.uuid)) { "beating the guardian didn't count as progress" } } .thenIdle(100) .thenExecute { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/ProgressionTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ProgressionTests.kt index 853884b..3a492fa 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/ProgressionTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ProgressionTests.kt @@ -3,14 +3,13 @@ package dev.cobblesurvival.selftest import com.cobblemon.mod.common.entity.pokemon.PokemonEntity import dev.cobblesurvival.progress.CobblebaseGate import dev.cobblesurvival.progress.Gates -import dev.cobblesurvival.progress.Progress -import dev.cobblesurvival.zone.Guardians import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.gametest.framework.GameTestAssertException import net.minecraft.resources.ResourceLocation import net.minecraft.world.InteractionHand +import net.minecraft.world.item.Item import net.minecraft.world.item.ItemStack import net.minecraft.world.item.Items import net.minecraft.world.level.Level @@ -20,16 +19,17 @@ import net.minecraft.world.phys.Vec3 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 - * beats the zones' Alpha guardians (`data/cobblesurvival/progression_gates/`). + * Soft gates (Adrian, 2026-10-31): Cobblebase's base skills open up through the pastured Pokémon's level or a material it + * 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 { private val POWER_BLOCK = ResourceLocation.parse("poke_power:power_block") fun all() = listOf( SelfTestCase("gates_cover_every_cobblebase_skill", 20, ::gatesCoverEverySkill), - SelfTestCase("cobblebase_skills_wait_for_guardians", 20, ::skillsWaitForGuardians), - SelfTestCase("power_block_waits_for_guardian", 20, ::powerBlockWaits), + SelfTestCase("cobblebase_skills_need_level_or_material", 20, ::skillsNeedLevelOrMaterial), + SelfTestCase("power_block_gated_by_its_recipe", 20, ::powerBlockGatedByRecipe), ) 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()}" } val stale = Gates.skills.keys - skills s.check(stale.isEmpty()) { "gates for skills Cobblebase doesn't have: ${stale.sorted()}" } - val zones = (Gates.skills.values.filterNotNull() + Gates.blocks.values).toSet() - val noGuardian = zones - Guardians.definitions.keys - s.check(noGuardian.isEmpty()) { "gates name zones without a guardian: ${noGuardian.sorted()}" } - val noBlock = Gates.blocks.keys.filterNot { BuiltInRegistries.BLOCK.containsKey(it) } - s.check(noBlock.isEmpty()) { "gates for blocks that don't exist: $noBlock" } + val named = (Gates.skills.values.filterNotNull() + Gates.fieldMoves.values).toSet() + val noGate = named - Gates.zones.keys + s.check(noGate.isEmpty()) { "gates name zones without a level and materials: ${noGate.sorted()}" } + val broken = Gates.zones.values.filter { it.items.isEmpty() || it.level == Int.MAX_VALUE }.map { it.zone } + 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 } s.check(open.isEmpty()) { "Cobblebase executors not behind the gates: $open" } s.helper.succeed() } /** 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 player = s.player() - val pokemon = s.give(player, s.pokemon("pikachu", 10)) - s.check(pokemon.getOwnerUUID() == player.uuid) { "the test Pokémon has no owner" } + val pokemon = s.give(player, s.pokemon("pikachu", 1)) val entity = PokemonEntity(level, pokemon) 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 progress = Progress.of(level.server) - s.afterTest { progress.revoke(player.uuid, zone!!) } + val gated = Gates.skills.entries.mapNotNull { (skill, zone) -> zone?.let { skill to Gates.zones.getValue(it) } }.sortedBy { it.second.level } + s.check(gated.isNotEmpty()) { "no skill is gated" } + 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 val type = CobblebaseGate.executorType 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, CobblebaseGate.skillDef, CobblebaseGate.skillEntry) 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") - 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(calls == 1) { "$openSkill is open from the start but didn't run" } - run(lockedSkill) - s.check(calls == 1) { "$lockedSkill ran before the guardian of $zone was beaten" } - progress.grant(player.uuid, zone!!) - run(lockedSkill) - s.check(calls == 2) { "$lockedSkill didn't run after the guardian of $zone was beaten" } + s.check(runs(openSkill)) { "$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" } + s.check(runs(lockedSkill, gate.items.first())) { "$lockedSkill didn't run for a Pokémon holding ${gate.items.first()} (${gate.zone})" } + if (harder != null) { + s.check(runs(lockedSkill, harder.items.first())) { "$lockedSkill didn't run with ${harder.items.first()} from the harder ${harder.zone}" } + } + 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() } - 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 zone = Gates.blocks[POWER_BLOCK] ?: throw GameTestAssertException("the Power Block has no gate") val item = BuiltInRegistries.ITEM.get(POWER_BLOCK) 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 progress = Progress.of(level.server) - s.afterTest { progress.revoke(player.uuid, zone) } val floor = s.helper.absolutePos(BlockPos(7, 2, 7)) level.setBlockAndUpdate(floor, Blocks.STONE.defaultBlockState()) - val target = floor.above() - fun place() { - player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item)) - 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) - } - fun placed() = BuiltInRegistries.BLOCK.getKey(level.getBlockState(target).block) == POWER_BLOCK - - 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)}" } + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item)) + 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) + val placed = level.getBlockState(floor.above()) + s.check(BuiltInRegistries.BLOCK.getKey(placed.block) == POWER_BLOCK) { "a survival player couldn't place a Power Block: $placed" } s.helper.succeed() } } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt index 4958d06..b3fd14b 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt @@ -54,7 +54,7 @@ object SelfTest { private val cases: List get() = PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() + VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() + - CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() private var running: List = emptyList() private var forced: List = emptyList() diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/ShoulderTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ShoulderTests.kt new file mode 100644 index 0000000..a350f34 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ShoulderTests.kt @@ -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() + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt index 5ffa8c6..22f80d4 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt @@ -8,8 +8,6 @@ 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.progress.Gates -import dev.cobblesurvival.progress.Progress import net.minecraft.ChatFormatting import net.minecraft.core.BlockPos 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 * wanders no further than [LEASH] blocks from its spot. Definitions: `data/cobblesurvival/guardians/.json`, * 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") { /** 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, ).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 }) } diff --git a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json index b4267e7..c64b68f 100644 --- a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json +++ b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json @@ -4,6 +4,7 @@ "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.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.unlocked": "Unlocked at your base: %s" + "message.cobblesurvival.gate.requirement": "Lv %s, or to hold %s (%s) or a material of a harder zone", + "message.cobblesurvival.gate.skill": "%s won't do %s at your base yet. It needs %s.", + "message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s." }