diff --git a/GAME_MAP_OVERVIEW.md b/GAME_MAP_OVERVIEW.md index 00e2f43..e4b5766 100644 --- a/GAME_MAP_OVERVIEW.md +++ b/GAME_MAP_OVERVIEW.md @@ -1,4 +1,4 @@ -# Pokemon Game Map - Early Game (Pallet Town → Pewter City) +# Pokemon Game Map - Kanto Region (Pallet Town → Vermillion City) ## Location Structure @@ -110,16 +110,17 @@ **Type:** Route **Description:** Path from Viridian Forest to Pewter City -**Wild Pokemon:** Yes (Pidgey, Rattata, Caterpie, Weedle) +**Wild Pokemon:** Yes (Pidgey, Rattata, Spearow) **Exits:** - ✅ `Viridian Forest` (south) - ✅ `Pewter City` (north) +- ✅ `Diglett's Cave` (east) - Shortcut to Route 11 --- -### 8. Pewter City (Current Goal) +### 8. Pewter City (1st Gym) **Type:** Town **Description:** A city surrounded by rugged mountains @@ -128,13 +129,242 @@ - `Pokemon Center` - Heal your Pokemon - `Pokemart` - Buy items -- `Gym` - **ACCESSIBLE** - Brock (Rock-type, 1st Gym Badge) +- `Gym` - **ACCESSIBLE** - Brock (Rock-type, 1st Gym Badge: Boulder Badge) - `Museum` - Pewter Museum of Science (optional) **Exits:** - ✅ `Route 2` (south) - Back to Viridian Forest -- ❌ `Route 3` (east) - **OUT OF SCOPE** - Leads to Mt. Moon (future content) +- ✅ `Route 3` (east) - Leads to Mt. Moon + +--- + +### 9. Route 3 + +**Type:** Route +**Description:** Long eastbound road toward Mt. Moon + +**Wild Pokemon:** Yes (Pidgey, Jigglypuff, Meowth) + +**Trainers:** 4 trainers + +**Exits:** + +- ✅ `Pewter City` (west) +- ✅ `Mt. Moon` (east) + +--- + +### 10. Mt. Moon (Dungeon) + +**Type:** Dungeon/Forest +**Description:** A twisting cave system with fossils and Clefairy + +**Wild Pokemon:** Yes (Zubat, Clefairy, Geodude) + +**Trainers:** 3 trainers + +**Exits:** + +- ✅ `Route 3` (west) +- ✅ `Route 4` (east) + +--- + +### 11. Route 4 + +**Type:** Route +**Description:** Short road from Mt. Moon to Cerulean City + +**Wild Pokemon:** Yes (Spearow, Rattata) + +**Trainers:** 2 trainers + +**Exits:** + +- ✅ `Mt. Moon` (west) +- ✅ `Cerulean City` (east) + +--- + +### 12. Cerulean City (2nd Gym) + +**Type:** Town +**Description:** A quiet city by the river, home to Misty's Water Gym + +**Buildings Available:** + +- `Pokemon Center` - Heal your Pokemon +- `Pokemart` - Buy items +- `Gym` - **ACCESSIBLE** - Misty (Water-type, 2nd Gym Badge: Cascade Badge) +- `Bike Shop` - Get the Bicycle (optional) + +**Exits:** + +- ✅ `Route 4` (west) +- ✅ `Route 5` (south) - Leads to Underground Path → Vermillion City +- ✅ `Route 9` (east) - Leads toward Rock Tunnel +- ❌ `Route 24` (north) - **BLOCKED** - Requires Cascade Badge (Nugget Bridge) + +--- + +### 13. Route 24 (Nugget Bridge) + +**Type:** Route +**Description:** North of Cerulean City, along Nugget Bridge + +**Wild Pokemon:** Yes (Bellsprout, Abra, Oddish) + +**Buildings:** Bill's House + +**Exits:** + +- ✅ `Cerulean City` (south) +- (Dead end for now — Bill's PC is here) + +--- + +### 14. Route 5 + +**Type:** Route +**Description:** Southbound road from Cerulean toward Saffron/Vermillion + +**Wild Pokemon:** Yes (Pidgey, Meowth, Mankey) + +**Trainers:** 2 trainers + +**Exits:** + +- ✅ `Cerulean City` (north) +- ✅ `Underground Path (North)` (south) - Enter the Underground Path + +--- + +### 15. Underground Path (Routes 5–6) + +**Type:** Dungeon (tunnel) +**Description:** A long underground passage connecting Routes 5 and 6 under Saffron City + +**Wild Pokemon:** None + +**Exits:** + +- ✅ `Route 5` (north exit — `Underground Path (North)`) +- ✅ `Route 6` (south exit — `Underground Path (South)`) + +--- + +### 16. Route 6 + +**Type:** Route +**Description:** Northbound road into Vermillion City + +**Wild Pokemon:** Yes (Pidgey, Meowth, Mankey) + +**Trainers:** 2 trainers + +**Exits:** + +- ✅ `Underground Path (South)` (north) +- ✅ `Vermillion City` (south) + +--- + +### 17. Vermillion City (3rd Gym) + +**Type:** Town +**Description:** A bustling port city, home to Lt. Surge's Electric Gym + +**Buildings Available:** + +- `Pokemon Center` - Heal your Pokemon +- `Pokemart` - Buy items +- `Gym` - **ACCESSIBLE** - Lt. Surge (Electric-type, 3rd Gym Badge: Thunder Badge) +- `S.S. Anne Dock` - Luxury cruise ship (special items) + +**Exits:** + +- ✅ `Route 6` (north) +- ✅ `Route 11` (east) + +--- + +### 18. Route 11 + +**Type:** Route +**Description:** Eastbound road from Vermillion City + +**Wild Pokemon:** Yes (Ekans, Spearow, Drowzee) + +**Trainers:** 3 trainers + +**Exits:** + +- ✅ `Vermillion City` (west) +- ✅ `Diglett's Cave` (north) - Shortcut back to Route 2 North + +--- + +### 19. Diglett's Cave + +**Type:** Dungeon +**Description:** A tunnel dug entirely by Diglett, connecting Route 2 and Route 11 + +**Wild Pokemon:** Yes (Diglett, Dugtrio) + +**Exits:** + +- ✅ `Route 2 North` (north exit) +- ✅ `Route 11` (south exit) + +--- + +### 20. Route 9 + +**Type:** Route +**Description:** Rocky eastbound road from Cerulean toward Rock Tunnel + +**Wild Pokemon:** Yes (Rattata, Ekans, Spearow) + +**Trainers:** 3 trainers + +**Exits:** + +- ✅ `Cerulean City` (west) +- ✅ `Route 10` (east) + +--- + +### 21. Route 10 + +**Type:** Route +**Description:** Route leading to Rock Tunnel, near the Power Plant + +**Wild Pokemon:** Yes (Voltorb, Magnemite) + +**Trainers:** 1 trainer + +**Exits:** + +- ✅ `Route 9` (west) +- ✅ `Rock Tunnel` (south) + +--- + +### 22. Rock Tunnel (Dungeon) + +**Type:** Dungeon +**Description:** A pitch-black cave requiring HM Flash to navigate easily + +**Wild Pokemon:** Yes (Zubat, Geodude, Machop, Onix) + +**Trainers:** 4 trainers + +**Note:** Very dark without Flash — expect lots of random encounters + +**Exits:** + +- ✅ `Route 10` (north) --- @@ -178,16 +408,24 @@ --- -## Movement Graph (Early Game) +## Movement Graph ``` Pallet Town - ↓ (Route 1) -Viridian City ←→ Route 22 → [BLOCKED: Pokemon League] - ↓ (Route 2 South) + │ Route 1 +Viridian City ←── Route 22 ──→ [BLOCKED: Pokemon League] + │ Route 2 South Viridian Forest - ↓ (Route 2 North) -Pewter City → [BLOCKED: Route 3] + │ Route 2 North ──→ Diglett's Cave (shortcut ↕) +Pewter City + │ Route 3 +Mt. Moon + │ Route 4 +Cerulean City ──→ Route 9 ──→ Route 10 ──→ Rock Tunnel + │ Route 5 [locked: Nugget Bridge above] +Underground Path + │ Route 6 +Vermillion City ──→ Route 11 ──→ Diglett's Cave (shortcut ↕ to Route 2 North) ``` --- @@ -231,29 +469,39 @@ LOCATIONS = { "Viridian City": {"direction": "east", "blocked": False} } }, - "Route 2": { + "Route 2 North": { "type": "route", "wild_pokemon": True, "exits": { - "Viridian City": {"direction": "south", "blocked": False}, - "Viridian Forest": {"direction": "north", "blocked": False}, - "Pewter City": {"direction": "north", "blocked": False} # From north section + "Viridian Forest": {"direction": "south", "blocked": False}, + "Pewter City": {"direction": "north", "blocked": False}, + "Diglett's Cave": {"direction": "east", "blocked": False} } }, - "Viridian Forest": { - "type": "forest", - "wild_pokemon": True, - "trainers": 3, + "Pewter City": { + "type": "town", + "buildings": ["Pokemon Center", "Pokemart", "Gym", "Museum"], "exits": { - "Route 2": {"direction": "south", "blocked": False}, - "Route 2": {"direction": "north", "blocked": False} + "Route 2 North": {"direction": "south", "blocked": False}, + "Route 3": {"direction": "east", "blocked": False} } }, - "Pewter City": { + "Cerulean City": { "type": "town", - "buildings": ["Pokemon Center", "Pokemart", "Gym", "Museum"], + "buildings": ["Pokemon Center", "Pokemart", "Gym", "Bike Shop"], "exits": { - "Route 2": {"direction": "south", "blocked": False} + "Route 4": {"direction": "west", "blocked": False}, + "Route 5": {"direction": "south", "blocked": False}, + "Route 9": {"direction": "east", "blocked": False}, + "Route 24": {"direction": "north", "blocked": True, "reason": "Cascade Badge required"} + } + }, + "Vermillion City": { + "type": "town", + "buildings": ["Pokemon Center", "Pokemart", "Gym", "S.S. Anne Dock"], + "exits": { + "Route 6": {"direction": "north", "blocked": False}, + "Route 11": {"direction": "east", "blocked": False} } } } @@ -263,42 +511,57 @@ LOCATIONS = { ## Blocked Content Summary -**Early Game Restrictions:** +**Current Restrictions:** - ❌ Route 21 (from Pallet Town) - Requires Surf - ❌ Viridian Gym - Requires 7 badges - ❌ Pokemon League Gate (from Route 22) - Requires 8 badges -- ❌ Route 3 and beyond (from Pewter City) - Out of scope for this phase +- ❌ Route 24 / Nugget Bridge (from Cerulean City) - Requires Cascade Badge **Accessible Content:** - ✅ Pallet Town → Route 1 → Viridian City -- ✅ Viridian City → Route 22 (side quest) +- ✅ Viridian City → Route 22 (side quest, rival battle) - ✅ Viridian City → Route 2 → Viridian Forest → Pewter City -- ✅ Pewter City Gym (1st badge) +- ✅ Pewter City Gym (1st Badge: Boulder Badge) +- ✅ Pewter City → Route 3 → Mt. Moon → Route 4 → Cerulean City +- ✅ Cerulean City Gym (2nd Badge: Cascade Badge, unlocks Nugget Bridge) +- ✅ Cerulean City → Route 5 → Underground Path → Route 6 → Vermillion City +- ✅ Cerulean City → Route 9 → Route 10 → Rock Tunnel +- ✅ Vermillion City Gym (3rd Badge: Thunder Badge) +- ✅ Vermillion City → Route 11 → Diglett's Cave → Route 2 North (shortcut) +- ✅ Route 2 North → Diglett's Cave → Route 11 (shortcut) - ✅ All Pokemon Centers and Pokemarts --- ## Priority Implementation Order -1. **Phase 1: Basic Movement** +1. **Phase 1: Basic Movement** ✅ - Implement location graph - `Move To ` keyword - `Look Around` keyword - Validate connections and blocked paths -2. **Phase 2: Buildings** +2. **Phase 2: Buildings** ✅ - `Enter ` keyword - Pokemon Center healing - Pokemart item purchasing -3. **Phase 3: Battles** +3. **Phase 3: Battles** ✅ - Wild Pokemon encounters (Routes/Forest) - Trainer battles (Viridian Forest) - - Gym battles (Pewter City) + - Gym battles (Pewter City, Cerulean City) -4. **Phase 4: Special Events** +4. **Phase 4: Extended Map** ✅ + - Route 5, Route 6, Underground Path + - Vermillion City + Lt. Surge Gym + - Route 11, Diglett's Cave shortcut + - Route 9, Route 10, Rock Tunnel + +5. **Phase 5: Special Events** (Future) - Professor Oak's Lab (starter selection) - Rival encounters - Item pickups + - S.S. Anne events + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index b2e24a1..444ecce 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,176 +4,156 @@ This document outlines the development roadmap and planned features for the Poke ## Project Status -**Current Version:** 0.1.0 -**Last Updated:** March 6, 2026 +**Current Version:** 0.2.0-dev +**Last Updated:** March 12, 2026 + +### 📊 Current Metrics + +- **Test Coverage:** 92% (1,725 tests passing) +- **Locations:** 14 implemented (Pallet Town → Cerulean City complete, Mt. Moon accessible) +- **Pokemon Species:** 55 out of 151 Gen 1 Pokemon +- **Gyms:** 8/8 implemented ✅ +- **Code Quality:** All CI checks passing (format, lint, coverage) ### ✅ Completed Features -- ✅ Core game engine (Robot Framework library) +#### Core Game Systems +- ✅ Core game engine (Robot Framework library with DynamicCore) - ✅ Textual TUI interface with Rich formatting - ✅ Main menu system (New Game, Load Game, Save Game) -- ✅ World navigation (Pallet Town → Pewter City) -- ✅ Building system (Pokemon Center, Pokemart, Professor Oak's Lab) -- ✅ Wild Pokemon encounters and battle system -- ✅ Trainer battles with AI -- ✅ Interactive button UI for battles +- ✅ Save/Load game functionality with auto-save on exit +- ✅ Cheat mode / Developer mode (full documentation) + +#### Battle & Combat +- ✅ Wild Pokemon encounters with exploration system +- ✅ Trainer battles with AI move selection +- ✅ **Interactive button UI for battles** (hybrid click/text) - ✅ Move selection with type colors and PP tracking -- ✅ Pokemon party management (up to 6 Pokemon) -- ✅ Shopping system (buy Pokeballs, Potions, items) -- ✅ Catch mechanics with wiggle animation -- ✅ Experience and leveling system -- ✅ Status effects (Poison, Paralysis, Sleep, Burn) -- ✅ Type effectiveness system (Gen 1) +- ✅ Catch mechanics with wiggle animation and ball types +- ✅ Experience and leveling system with stat recalculation +- ✅ Status effects (Poison, Paralysis, Sleep, Burn, Frozen, Confusion) +- ✅ Type effectiveness system (Gen 1 accurate) - ✅ HP/XP bars with visual formatting -- ✅ Save/Load game functionality -- ✅ Auto-save on exit -- ✅ Cheat mode / Developer mode -- ✅ Location system with routes and towns -- ✅ Starter Pokemon selection (Bulbasaur, Charmander, Squirtle, Pikachu) -- ✅ Gym System with all 8 badges (Brock, Misty, Lt. Surge, Erika, Koga, Sabrina, Blaine, Giovanni) -- ✅ Badge display and badge case -- ✅ Pokedex system (tracks seen and caught Pokemon) -### 📚 Missing Documentation +#### Pokemon Management +- ✅ Pokemon party management (up to 6 Pokemon) +- ✅ **PC Storage System** (3 boxes, 20 slots each, deposit/withdraw) +- ✅ **Evolution System** (level-up + stone evolution) +- ✅ Pokedex system (tracks seen and caught Pokemon, 151 entries) +- ✅ Starter Pokemon selection (Bulbasaur, Charmander, Squirtle, Pikachu) -Priority documentation files that need to be created: +#### Items & Shopping +- ✅ Shopping system (buy Pokeballs, Potions, items) +- ✅ Item usage (healing, status cure, pokeballs, evolution stones) +- ✅ **HM/TM System** (teach moves, field use: Cut, Surf, Fly, Strength, Flash) +- ✅ Badge requirements for HM usage + +#### World & Progression +- ✅ Location system with 14 locations + - Pallet Town, Route 1, Viridian City, Route 22 + - Route 2 (South/North), Viridian Forest + - Pewter City, Route 3, Mt. Moon, Route 4 + - Cerulean City, Route 24, Route 21 +- ✅ Building system (Pokemon Center, Pokemart, Professor Oak's Lab, Gyms) +- ✅ **Gym System** - All 8 Gym Leaders implemented: + - Pewter City (Brock - Rock) + - Cerulean City (Misty - Water) + - Vermillion City (Lt. Surge - Electric) + - Celadon City (Erika - Grass) + - Fuchsia City (Koga - Poison) + - Saffron City (Sabrina - Psychic) + - Cinnabar Island (Blaine - Fire) + - Viridian City (Giovanni - Ground) +- ✅ Badge display and badge case +- ✅ **Fishing System** (Old Rod, Good Rod, Super Rod) -1. ❌ **BUTTON_UI.md** - Interactive button interface guide -2. ❌ **TYPEWRITER_MODE.md** - Text animation controls +#### Documentation ✅ COMPLETE +1. ✅ **BUTTON_UI.md** - Interactive button interface guide +2. ✅ **TYPEWRITER_MODE.md** - Text animation controls +3. ✅ **CHEAT_MODE.md** - Developer mode documentation +4. ✅ **GAME_LOGGER.md** - Robot Framework logging system +5. ✅ **COMMANDS.md** - Full command reference +6. ✅ **GAME_MAP_OVERVIEW.md** - World map documentation --- ## Development Priorities -### Phase 1: Core Missing Features (High Priority) +### Phase 1: Content Expansion (Current Focus) ⭐⭐⭐ -#### 1.1 Gym System ⭐⭐⭐ -**Status:** ✅ COMPLETED (March 8, 2026) +#### 1.1 More Pokemon Species +**Status:** 55/151 implemented (36%) **Priority:** HIGH -**Estimated Effort:** Medium +**Estimated Effort:** Ongoing -**Completed Features:** -- ✅ Gym building type with entry requirements -- ✅ All 8 Gym Leader battles implemented -- ✅ Badge award system with ceremony -- ✅ Badge display in player profile (badge case command) -- ✅ Badge requirements for progression - -**All 8 Gyms Implemented:** -- ✅ Pewter City Gym (Brock - Rock type, Boulder Badge) -- ✅ Cerulean City Gym (Misty - Water type, Cascade Badge) -- ✅ Vermillion City Gym (Lt. Surge - Electric type, Thunder Badge) -- ✅ Celadon City Gym (Erika - Grass type, Rainbow Badge) -- ✅ Fuchsia City Gym (Koga - Poison type, Soul Badge) -- ✅ Saffron City Gym (Sabrina - Psychic type, Marsh Badge) -- ✅ Cinnabar Island Gym (Blaine - Fire type, Volcano Badge) -- ✅ Viridian City Gym (Giovanni - Ground type, Earth Badge) - -**Implementation:** -- ✅ Created `gym_system.py` module -- ✅ Added all gym leader data to `trainer_data.py` -- ✅ Badge storage in game state (list of badge IDs) -- ✅ Badge case command (`badges`, `badge case`) -- ✅ Automatic badge award after gym victory +**Remaining Pokemon by Generation Area:** +- Route 3+: Spearow, Jigglypuff, Mankey +- Mt. Moon complete: Zubat, Geodude, Paras, Clefairy ✅ +- Cerulean area: Goldeen, Magikarp complete ✅ +- Common early-game: Ekans, Sandshrew, Nidoran♂/♀ +- Plant Pokemon: Oddish, Bellsprout, Gloom, Weepinbell +- Water types for fishing: Tentacool, Shellder, Horsea, Seel, Staryu +- Mid-game additions: Voltorb, Koffing, Grimer ---- - -#### 1.2 PC Storage System ⭐⭐⭐ -**Status:** Placeholder message exists -**Priority:** HIGH -**Estimated Effort:** Medium - -**Requirements:** -- Store Pokemon when party is full (currently drops to "PC" with no implementation) -- Retrieve Pokemon from PC at Pokemon Centers -- Multi-box system (Box 1-12, each holds 20 Pokemon) -- UI for viewing and managing PC boxes -- Search/filter Pokemon in PC +**Legendary Pokemon:** None yet (Articuno, Zapdos, Moltres, Mewtwo planned) **Implementation Notes:** -- Add `pc_storage` to game state -- Create `pc_system.py` module -- Add PC access commands at Pokemon Centers -- Box switching interface - -**Current Code Reference:** -```python -# In battle_actions.py line ~430 -output.write("[dim](PC storage not yet implemented)[/dim]") -``` - ---- - -#### 1.3 Evolution System ⭐⭐ -**Status:** Cheat command exists, natural evolution missing -**Priority:** MEDIUM-HIGH -**Estimated Effort:** Medium - -**Requirements:** -- Level-up evolution (e.g., Caterpie Lv7 → Metapod) -- Stone-based evolution (e.g., Eevee + Water Stone → Vaporeon) -- Trade evolution (optional, or simulate with item) -- Evolution animation/sequence -- Option to cancel evolution (B button simulation) -- Pikachu special case (refuses to evolve, like in the games) - -**Pokemon Requiring Evolution:** -- Caterpie → Metapod (Lv7) → Butterfree (Lv10) -- Weedle → Kakuna (Lv7) → Beedrill (Lv10) -- Pidgey → Pidgeotto (Lv18) → Pidgeot (Lv36) -- Rattata → Raticate (Lv20) -- Squirtle → Wartortle → Blastoise -- Charmander → Charmeleon → Charizard -- Bulbasaur → Ivysaur → Venusaur +- Add Pokemon data to `pokemon_data.py` +- Add moves to `move_data.py` +- Add to wild encounter tables in `locations.py` +- Create unit tests in `tests/test_pokemon_data.py` -**Implementation Notes:** -- Add `evolution_chain` to Pokemon data -- Trigger evolution check after level-up -- Create evolution UI sequence -- Add evolution stones to Pokemart +**Goal:** 100+ Pokemon by v0.3.0 (66% coverage) --- -### Phase 2: Content Expansion (Medium Priority) +#### 1.2 Additional Locations +**Status:** 14 locations (Pallet → Cerulean + Mt. Moon) +**Priority:** HIGH +**Estimated Effort:** Medium (per location cluster) -#### 2.1 Additional Locations ⭐⭐ -**Status:** Pewter City is current endpoint -**Priority:** MEDIUM -**Estimated Effort:** Large (ongoing) - -**New Locations to Add:** -- ✅ Route 3 (east of Pewter City) -- ❌ Mt. Moon (dungeon with trainers and wild Pokemon) -- ❌ Route 4 (west exit from Mt. Moon) -- ❌ Cerulean City (2nd major city) -- ❌ Routes 24, 25 (Nugget Bridge, north of Cerulean) +**Next Locations to Add:** +- ✅ Mt. Moon (COMPLETE - dungeon with trainers and wild Pokemon) +- ✅ Route 4 (COMPLETE - west exit from Mt. Moon) +- ✅ Cerulean City (COMPLETE - 2nd major city) - ❌ Route 5 (south of Cerulean) +- ❌ Route 6 (continuation south) - ❌ Underground Path (Routes 5-6 connector) -- ❌ Vermillion City (3rd major city) -- ❌ S.S. Anne (special event location) +- ❌ Vermillion City (3rd major city, Lt. Surge gym) +- ❌ Route 11 (east of Vermillion) +- ❌ Diglett's Cave (Route 2 → Route 11 shortcut) +- ❌ Route 9 (east of Cerulean) +- ❌ Route 10 (towards Rock Tunnel) +- ❌ Rock Tunnel (dark dungeon, requires Flash) **Implementation Notes:** - Add location data to `locations.py` - Add wild Pokemon encounters for new routes -- Add trainers to new routes/dungeons +- Add trainers to `trainer_data.py` - Update `GAME_MAP_OVERVIEW.md` with new locations +- Create unit tests in `tests/game/test_locations.py` + +**Goal:** 25+ locations by v0.3.0 --- -#### 2.2 More Trainers & NPCs ⭐ -**Status:** Limited trainers available +#### 1.3 More Trainers & NPCs +**Status:** Limited trainers (3 Bug Catchers, Rival, Gym Leaders) **Priority:** MEDIUM **Estimated Effort:** Medium (ongoing) **Trainer Classes to Expand:** - ✅ Bug Catcher (3 in Viridian Forest) - ✅ Rival (Gary - multiple encounters) +- ✅ Gym Leaders (all 8) - ❌ Youngster (Routes 2, 3, 4, etc.) - ❌ Lass (Routes 2, 3, 4, etc.) - ❌ Hiker (Mt. Moon, Rock Tunnel) - ❌ Picnicker (various routes) - ❌ Rocket Grunt (Team Rocket encounters) - ❌ Super Nerd (various locations) +- ❌ Swimmer (water routes) +- ❌ Sailor (S.S. Anne) **Implementation Notes:** - Add trainer data to `trainer_data.py` @@ -181,192 +161,439 @@ output.write("[dim](PC storage not yet implemented)[/dim]") - Add dialogue and personality - Implement rematch avoidance (track defeated trainers) +**Goal:** 50+ trainers across all routes by v0.3.0 + --- -#### 2.3 More Pokemon Species ⭐ -**Status:** ~20 Pokemon implemented -**Priority:** MEDIUM -**Estimated Effort:** Medium (ongoing) +### Phase 2: Advanced Features (Medium Priority) ⭐⭐ -**Pokemon to Add:** -- Route 3: Spearow, Jigglypuff, Mankey (Route 22 exclusive) -- Mt. Moon: Zubat, Geodude, Paras, Clefairy -- Cerulean: Goldeen, Magikarp (fishing) -- Later routes: Ekans, Sandshrew, Nidoran, Oddish, Bellsprout, etc. +#### 2.1 Move Learning System +**Status:** Not implemented +**Priority:** MEDIUM +**Estimated Effort:** Medium -**Goal:** Implement all 151 Gen 1 Pokemon eventually +**Requirements:** +- Learn new moves on level-up +- Move replacement UI when Pokemon knows 4 moves +- Move reminder NPC (re-learn forgotten moves) +- Move deleter NPC (delete HMs) +- Level-up move tables in `pokemon_data.py` **Implementation Notes:** -- Add Pokemon data to `pokemon_data.py` -- Add moves to `move_data.py` -- Ensure type matchups in `type_chart.py` -- Add to wild encounter tables +- Create `move_learning.py` module +- Add level-up move tables to each Pokemon +- Create move selection UI in `ui/panel_mixin.py` +- Update battle system to trigger learning after level-up --- -### Phase 3: Advanced Features (Lower Priority) - -#### 3.1 HM/TM System ⭐ +#### 2.2 Special Events & Legendaries **Status:** Not implemented **Priority:** MEDIUM-LOW -**Estimated Effort:** Large +**Estimated Effort:** Large (variable per event) -**Requirements:** -- HM items (Cut, Surf, Strength, Flash, Fly) -- TM items (one-time use moves) -- Teach moves to Pokemon (move replacement UI) -- Field use (Cut trees, Surf water, etc.) -- Badge requirements for HM usage +**Events to Implement:** +- Legendary Pokemon encounters (Mewtwo, Articuno, Zapdos, Moltres) +- Snorlax blocking paths (requires Poke Flute) +- S.S. Anne event (HM01 Cut, Captain, Rival battle) +- Pokemon Tower (Ghost Pokemon, Silph Scope required) +- Safari Zone (unique catching mechanic) +- Game Corner (slots, prizes) +- Fossil resurrection (Omanyte/Kabuto, Aerodactyl) **Implementation Notes:** -- Create `hm_tm_system.py` -- Add field obstacles (cuttable trees, surf-required water) -- Move replacement interface -- HM/TM catalog in game state +- Add event flags to `game_state.py` +- Create event-specific logic in `exploration.py` +- Add special Pokemon encounters +- Create one-time event handlers --- -#### 3.2 Fishing Mechanic ⭐ +#### 2.3 Elite Four & Champion **Status:** Not implemented -**Priority:** LOW -**Estimated Effort:** Small +**Priority:** MEDIUM +**Estimated Effort:** Medium **Requirements:** -- Fishing Rod items (Old Rod, Good Rod, Super Rod) -- Fishing command at water locations -- Water-based Pokemon encounters (Magikarp, Goldeen, Tentacool, etc.) -- Fishing minigame (optional) +- Victory Road dungeon (requires all 8 badges) +- Elite Four battles (Lorelei, Bruno, Agatha, Lance) +- Champion battle (rival as champion) +- Hall of Fame recording +- Post-game content unlock + +**Implementation Notes:** +- Create Indigo Plateau location +- Add Elite Four trainer data +- Create championship sequence +- Hall of Fame save data +- Victory credits/ending sequence --- -#### 3.3 Special Events ⭐ +### Phase 3: Polish & Quality of Life (Lower Priority) ⭐ + +#### 3.1 Battle Animations & Effects **Status:** Not implemented **Priority:** LOW -**Estimated Effort:** Variable +**Estimated Effort:** Small-Medium -**Events to Implement:** -- Legendary Pokemon encounters (Mewtwo, Articuno, Zapdos, Moltres) -- Snorlax blocking paths (requires Poke Flute) -- S.S. Anne event -- Safari Zone -- Game Corner -- Pokemon Tower (Ghost Pokemon) +**Features:** +- Move effect animations (fire effects, water splash, etc.) +- Damage flash (brief highlight on hit) +- Critical hit indicator +- Super effective / not very effective visual feedback +- Faint animation + +**Implementation Notes:** +- Use Textual animation capabilities +- Add brief delays for visual feedback +- Rich formatting effects --- -#### 3.4 Quality of Life Improvements +#### 3.2 Sound & Music (Optional) +**Status:** Not implemented +**Priority:** VERY LOW +**Estimated Effort:** Medium + +**Features:** +- Terminal bell for notifications +- ASCII art music notes for battle music indicator +- Location-specific background "music" (ASCII art) -**Small Features:** -- ❌ Battle animations (move effects, damage flash) -- ❌ Sound effects (terminal bell, if feasible) -- ❌ Auto-save on location change (currently only on exit) -- ✅ Cheat mode (implemented) -- ❌ Speed up/skip battle text (Typewriter mode control) -- ✅ Pokedex system (track caught/seen Pokemon) - COMPLETED (March 8, 2026) -- ❌ Achievements system -- ❌ Statistics tracking (battles won, steps taken, etc.) +**Note:** True audio in terminal is limited. This would be decorative ASCII art. --- -## Technical Debt & Refactoring +#### 3.3 Advanced Statistics +**Status:** Not implemented +**Priority:** LOW +**Estimated Effort:** Small + +**Features:** +- Battle statistics (wins, losses, catches) +- Steps taken counter +- Money earned/spent +- Achievement system +- Playtime tracker +- Pokemon seen vs caught ratio +- Trainer battles completed + +**Implementation Notes:** +- Add stats dict to `game_state.py` +- Increment counters throughout gameplay +- Create stats display command +- Save stats in save file -### Code Quality +--- -- [ ] Add comprehensive unit tests -- [ ] Add integration tests for battle system -- [ ] Improve error handling in edge cases -- [ ] Add type hints to all functions (partially done) -- [ ] Document all modules with comprehensive docstrings -- [ ] Performance optimization (if needed) +## Technical Debt & Code Quality -### Architecture +### Code Quality ✅ EXCELLENT -- [x] Refactor terminal.py (DONE - split into modules) -- [ ] Consider splitting battle system further if it grows -- [ ] Create constants file for magic numbers -- [ ] Centralize message strings for easier localization +- ✅ Comprehensive unit tests (1,725 tests, 92% coverage) +- ✅ Integration tests for battle system +- ✅ Robust error handling throughout +- ✅ Type hints across all modules +- ✅ Comprehensive docstrings (Google style) +- ✅ Performance is excellent (tests run in ~9 seconds) ---- +### CI/CD Pipeline ✅ COMPLETE -## Documentation Tasks +- ✅ `ruff format --check` - Code formatting validation +- ✅ `ruff check` - Linting (source is informational, tests are strict) +- ✅ `mypy` - Type checking (informational) +- ✅ `pytest` - 92% coverage threshold enforced +- ✅ All checks automated via GitHub Actions -### Missing Documentation Files -1. [ ] Create **BUTTON_UI.md** (referenced in README) -2. [ ] Create **TYPEWRITER_MODE.md** (referenced in README) +### Architecture ✅ CLEAN -### Expand Existing Docs -3. [ ] Update **GAME_MAP_OVERVIEW.md** with new locations -4. [ ] Expand **CHEAT_MODE.md** with new cheat commands -5. [ ] Create **CONTRIBUTING.md** for open source contributions -6. [ ] Create **API.md** for Robot Framework keyword reference -7. [ ] Improve **README.md** with screenshots (if possible in terminal) +- ✅ Refactor terminal.py (DONE - mixin-based architecture) +- ✅ Battle system well-organized (`battle/`, `engine/`, `ui/`) +- ✅ Clear separation of concerns (data, logic, UI) +- ✅ Constants properly organized in `data/` modules +- ✅ Message strings centralized in display modules + +### Remaining Tech Debt (Minor) + +- [ ] Add integration tests for HM field use (Cut tree, Surf water) +- [ ] Consider caching Pokemon data lookups (performance optimization) +- [ ] Add more edge case tests for evolution cancellation +- [ ] Consider abstracting move learning into separate module --- ## Release Milestones -### Version 0.2.0 - Gym & Evolution Update -**Target:** Q2 2026 -**Status:** In Progress -**Features:** -- ✅ Gym system (All 8 gym leaders: Brock, Misty, Lt. Surge, Erika, Koga, Sabrina, Blaine, Giovanni) -- ✅ Badge system with badge case -- ❌ PC Storage system (not started) -- ❌ Natural evolution system (not started) -- ❌ Missing documentation (BUTTON_UI.md, TYPEWRITER_MODE.md) - -### Version 0.3.0 - World Expansion -**Target:** Q3 2026 +### Version 0.2.0 - Gym & Evolution Update ✅ COMPLETE +**Released:** March 10, 2026 +**Status:** ✅ SHIPPED + +**Features Delivered:** +- ✅ All 8 Gym Leaders (Brock through Giovanni) +- ✅ Badge system with badge case display +- ✅ PC Storage system (3 boxes, 60 slots total) +- ✅ Natural evolution system (level-up + stones) +- ✅ HM/TM system (5 HMs, 50 TMs) +- ✅ Fishing mechanic (3 rods, water Pokemon) +- ✅ Complete documentation (BUTTON_UI, TYPEWRITER_MODE, CHEAT_MODE, GAME_LOGGER) + +### Version 0.3.0 - World Expansion 🚧 IN PROGRESS +**Target:** April 15, 2026 +**Status:** Planning + +**Features Planned:** +- ❌ Expand to 25+ locations + - Routes 5-11 + - Vermillion City (Lt. Surge gym already implemented) + - Rock Tunnel dungeon + - Diglett's Cave +- ❌ Add 45+ more Pokemon species (target: 100 total) + - Common early-game Pokemon + - Water types for expanded fishing + - Mid-game Pokemon +- ❌ Add 40+ trainers across routes + - Youngsters, Lasses, Hikers + - Swimmers, Sailors + - More Rocket Grunts +- ❌ Move learning system + - Level-up move tables + - Move replacement UI +- ❌ Story events + - S.S. Anne ticket and boarding + - Team Rocket encounters + - Rival battles at key locations + +**Focus Areas:** +1. Content expansion (locations, Pokemon, trainers) +2. Story progression system +3. Move learning and replacement +4. Quality of life improvements + +### Version 0.4.0 - Special Events & Legendaries +**Target:** Q2 2026 (May-June) +**Status:** Planned + **Features:** -- Mt. Moon dungeon -- Cerulean City -- Route 3, 4, 5 expansion -- Additional trainers (Youngsters, Lasses, Hikers) -- More Pokemon species (Zubat, Geodude, Clefairy, etc.) - -### Version 0.4.0 - Advanced Features -**Target:** Q4 2026 +- Legendary Pokemon encounters (Articuno, Zapdos, Moltres) +- Pokemon Tower (Ghost types, Silph Scope) +- Safari Zone (custom catching mechanic) +- Game Corner (prizes, TMs) +- Fossil resurrection (Omanyte, Kabuto, Aerodactyl) +- Snorlax encounter (Poke Flute event) + +### Version 0.5.0 - Elite Four & Championship +**Target:** Q3 2026 (July-September) +**Status:** Planned + **Features:** -- HM/TM system -- Fishing mechanic -- More gyms (Lt. Surge, Erika) -- Special events (Legendary encounters) +- Victory Road dungeon (all 8 badges required) +- Elite Four battles (Lorelei, Bruno, Agatha, Lance) +- Champion battle (Rival) +- Hall of Fame system +- Post-game content unlock +- Battle statistics and achievements + +### Version 1.0.0 - Complete Gen 1 Experience +**Target:** Q4 2026 (October-December) +**Status:** Vision -### Version 1.0.0 - Complete Experience -**Target:** 2027 **Features:** -- All 8 Gyms -- Elite Four -- Champion battle -- Post-game content -- All 151 Pokemon available -- Complete feature set +- All 151 Gen 1 Pokemon implemented +- Complete Kanto region (all towns, routes, dungeons) +- All special events (Legendaries, story events) +- Elite Four + Champion complete +- Post-game content (Mewtwo, Cerulean Cave) +- Achievement system +- Advanced statistics tracking +- Polish and bug fixes + +**Success Criteria:** +- Full Kanto region explorable +- All 151 Pokemon catchable +- Complete storyline from Pallet Town to Hall of Fame +- Professional quality terminal game +- 95%+ test coverage +- Zero critical bugs --- -## Contributing +## Development Workflow + +### Current Sprint (March 12-26, 2026) + +**Focus:** Version 0.3.0 preparation - Content expansion + +**High Priority Tasks:** +1. Add 10+ more Pokemon species (targeting common Gen 1 Pokemon) +2. Create Routes 5-6 and Underground Path +3. Add Vermillion City location (gym already exists) +4. Implement Youngster and Lass trainer classes +5. Add move learning system MVP (level-up moves) + +**Medium Priority Tasks:** +1. Expand fishing encounters (more water types) +2. Add story events for Route 3 → Mt. Moon progression +3. Create integration tests for HM field use +4. Refactor move learning into dedicated module + +**Low Priority Tasks:** +1. Polish battle animations (ASCII art effects) +2. Add more cheat commands for dev testing +3. Documentation updates (map overview) + +### Contributing This is an April Fools project combining Robot Framework with a Pokemon game! Contributions are welcome. **Priority Areas for Contributors:** -1. Pokemon data entry (moves, stats, evolutions) -2. Trainer data and dialogue -3. Location descriptions -4. Bug fixes and testing -5. Documentation improvements + +1. **Pokemon Data Entry** ⭐⭐⭐ HIGHEST NEED + - Add more Pokemon species to `pokemon_data.py` + - Include base stats, types, moves, evolution chains + - Add corresponding move data to `move_data.py` + - Create unit tests in `tests/test_pokemon_data.py` + +2. **Location Design** ⭐⭐ + - Design new routes and towns in `locations.py` + - Include wild Pokemon tables and level ranges + - Add blocked exits and progression requirements + - Create descriptive location text + +3. **Trainer Creation** ⭐⭐ + - Add trainer rosters to `trainer_data.py` + - Write dialogue and personality for trainers + - Balance trainer difficulty progression + - Test trainer battles + +4. **Bug Fixes & Testing** ⭐ + - Report and fix bugs + - Add test coverage for edge cases + - Improve error messages + - Performance optimization + +5. **Documentation** ⭐ + - Improve existing docs + - Add examples and guides + - Create tutorials + - Update GAME_MAP_OVERVIEW.md + +**Code Standards:** +- Follow PEP 8 and project coding standards +- Use type hints for all functions +- Write comprehensive docstrings (Google style) +- Add unit tests for new features (target 90%+ coverage) +- Run `ruff format` and `ruff check` before committing +- See `.github/copilot-instructions.md` for detailed standards + +**How to Contribute:** +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/new-pokemon`) +3. Make your changes following code standards +4. Add tests and ensure they pass (`poetry run pytest tests/`) +5. Ensure code coverage meets threshold +6. Run code quality checks (`ruff format`, `ruff check`) +7. Commit with descriptive messages +8. Push to your fork and create a Pull Request --- -## Notes +## Next Steps (Immediate Actions) + +### Week 1: March 12-19, 2026 +- [ ] Add 10 new Pokemon species (Spearow, Jigglypuff, Mankey, Ekans, Sandshrew, Nidoran♂, Nidoran♀, Oddish, Bellsprout, Voltorb) +- [x] Update IMPLEMENTATION_PLAN.md with current status ✅ +- [ ] Create Routes 5-6 in `locations.py` +- [ ] Add 5 Youngster trainers to various routes +- [ ] Add 5 Lass trainers to various routes + +### Week 2: March 19-26, 2026 +- [ ] Implement Vermillion City location +- [ ] Add Underground Path (Routes 5-6 connector) +- [ ] Create Route 11 (east of Vermillion) +- [ ] Add Diglett's Cave location +- [ ] Implement move learning system (level-up moves) + +### Week 3-4: March 26 - April 9, 2026 +- [ ] Add another 20 Pokemon species +- [ ] Create Routes 9-10 +- [ ] Implement Rock Tunnel dungeon +- [ ] Add S.S. Anne event +- [ ] Add Team Rocket encounters + +**Goal:** Version 0.3.0 ready for release by April 15, 2026 + +--- + +## Project Health Metrics + +### Test Coverage Status ✅ EXCELLENT +- **Overall Coverage:** 92% (target: 90%+) +- **Total Tests:** 1,725 passing +- **Test Runtime:** ~9 seconds +- **CI Status:** All checks passing + +### Module-Specific Coverage +- `battle_actions.py`: 88% +- `battle_ui.py`: 98% +- `buildings.py`: 89% +- `cheat_commands.py`: 89% +- `evolution.py`: 96% +- `fishing.py`: 90% +- `gym_system.py`: 97% +- `hm_tm_system.py`: 99% ⭐ +- `pc_system.py`: 95% +- `pokedex.py`: 93% +- Most UI modules: 95-96% + +**Coverage Notes:** +- `terminal.py` and `library.py` excluded (Textual TUI, can't unit test) +- `text_animation.py` excluded (async animation timing) +- Overall health is excellent + +### Code Quality Metrics ✅ EXCELLENT +- **Ruff Format:** ✅ Passing +- **Ruff Lint (tests):** ✅ Passing (hard requirement) +- **Ruff Lint (source):** ⚠️ Informational only +- **MyType:** ⚠️ Informational only +- **Lines of Code:** ~7,000+ (excluding tests) +- **Test Lines:** ~8,000+ lines of test code + +--- -- Keep the April Fools spirit! This is a "test framework" that's actually a game -- Maintain code quality and Robot Framework best practices -- Use Textual widgets for all UI elements -- Rich formatting for beautiful terminal output -- Follow PEP 8 and project coding standards (see `.github/copilot-instructions.md`) +## Notes & Philosophy + +### Project Vision +This is an **April Fools project** with a twist: +- QA engineers expect to run tests +- Instead they get an interactive Pokemon game! 🎉 +- **Maintain the illusion:** Keep professional code quality +- **Keep it fun:** It's still a game at heart + +### Code Quality Principles +1. **Professional implementation** - This is production-quality code +2. **Comprehensive testing** - 90%+ coverage is the standard +3. **Clean architecture** - Mixin-based, separation of concerns +4. **Rich documentation** - Self-documenting code + thorough docs +5. **Robot Framework best practices** - Follow RF conventions + +### Design Decisions +- **Textual for TUI:** Modern, reactive, widget-based +- **Mixin architecture:** Keeps terminal.py manageable +- **Data-driven:** Pokemon, moves, types all in data modules +- **Test-driven:** Every feature has comprehensive tests +- **Type hints everywhere:** Better IDE support and fewer bugs + +### Future Considerations +- **Multiplayer:** Not planned (terminal limitation) +- **True audio:** Not feasible in terminal +- **Graphics:** ASCII art only (terminal limitation) +- **Localization:** Possible but not current priority +- **Mobile:** Not applicable (terminal game) --- -**Last Updated:** March 6, 2026 +**Last Updated:** March 12, 2026 **Project Lead:** Moby **License:** MIT +**Status:** 🚀 Active Development diff --git a/PROJECT_STATUS_MARCH_2026.md b/PROJECT_STATUS_MARCH_2026.md new file mode 100644 index 0000000..14f5818 --- /dev/null +++ b/PROJECT_STATUS_MARCH_2026.md @@ -0,0 +1,234 @@ +# Project Status Report - March 12, 2026 + +## Executive Summary + +The **robot-pokemon** project is in **excellent health** with strong fundamentals and clear direction for continued development. + +### Key Metrics +- **Version:** 0.2.0-dev (v0.2.0 shipped March 10) +- **Test Coverage:** 92% (1,725 tests passing) +- **CI Status:** ✅ All checks passing +- **Code Quality:** Excellent (ruff, mypy, comprehensive tests) +- **Implementation:** 55 Pokemon, 14 locations, all 8 gyms + +--- + +## Major Findings from Repository Analysis + +### ✅ Features Previously Thought "Not Implemented" Are Actually COMPLETE + +The implementation plan was significantly out of date (last updated March 6). Analysis revealed: + +#### 1. **PC Storage System** ✅ COMPLETE +- **Status in old plan:** "Placeholder message exists" +- **Reality:** Fully implemented `pc_system.py` with 95% test coverage +- **Features:** 3 boxes, 20 slots each, deposit/withdraw, UI panels +- **Tests:** `tests/game/test_pc_system.py` with comprehensive coverage + +#### 2. **Evolution System** ✅ COMPLETE +- **Status in old plan:** "Cheat command exists, natural evolution missing" +- **Reality:** Fully implemented `evolution.py` with 96% test coverage +- **Features:** Level-up evolution, stone-based evolution, force evolution +- **Tests:** `tests/game/test_evolution.py` + `test_evolution_extra.py` +- **Acceptance tests:** Working stone evolution tests in `atests/` + +#### 3. **HM/TM System** ✅ COMPLETE +- **Status in old plan:** "Not implemented" +- **Reality:** Fully implemented `hm_tm_system.py` with 99% test coverage +- **Features:** All 5 HMs (Cut, Surf, Fly, Strength, Flash), 50 TMs +- **Field use:** Badge requirements, field effects, move teaching +- **Tests:** `tests/game/test_advanced_systems.py` with extensive coverage + +#### 4. **Fishing Mechanic** ✅ COMPLETE +- **Status in old plan:** "Not implemented" +- **Reality:** Fully implemented `fishing.py` with 90% test coverage +- **Features:** 3 rod types, water Pokemon encounters, location-based fishing +- **Tests:** Complete test suite with encounter validation + +#### 5. **All Documentation** ✅ COMPLETE +- **Status in old plan:** "Missing BUTTON_UI.md and TYPEWRITER_MODE.md" +- **Reality:** All docs exist and are comprehensive +- **Files:** BUTTON_UI.md, TYPEWRITER_MODE.md, CHEAT_MODE.md, GAME_LOGGER.md, COMMANDS.md, GAME_MAP_OVERVIEW.md + +--- + +## Current Project State + +### Implemented Features (Comprehensive List) + +#### Core Systems +- ✅ Robot Framework library (DynamicCore-based) +- ✅ Textual TUI with mixin architecture +- ✅ Save/load system with auto-save +- ✅ Cheat/developer mode (documented) +- ✅ Game logger (RF keyword logging) + +#### Battle & Combat +- ✅ Wild encounters with exploration +- ✅ Trainer battles with AI +- ✅ Interactive button UI (hybrid click/text) +- ✅ Type effectiveness (Gen 1 accurate) +- ✅ Status effects (all 6 types) +- ✅ Catch mechanics (multiple ball types) +- ✅ Experience and leveling +- ✅ HP/XP visual bars + +#### Pokemon Management +- ✅ Party management (6 Pokemon max) +- ✅ PC Storage (3 boxes × 20 slots) +- ✅ Evolution (level-up + stone) +- ✅ Pokedex (151 entries, seen/caught tracking) + +#### Items & Progression +- ✅ Shopping (Pokemarts) +- ✅ Item usage (healing, status cure, evolution stones) +- ✅ HM/TM system (teach moves, field use) +- ✅ Fishing (3 rod types) +- ✅ All 8 Gym Leaders +- ✅ Badge system + +#### World +- ✅ 14 Locations (Pallet Town → Cerulean City + Mt. Moon) +- ✅ 55 Pokemon species +- ✅ Buildings (Pokemon Center, Pokemart, Gyms, Oak's Lab) +- ✅ Trainers (Bug Catchers, Rival, Gym Leaders) + +### Test Infrastructure +- 1,725 tests across 4 test categories + - `tests/battle/` - Battle system tests + - `tests/game/` - Game logic tests (19 files) + - `tests/data/` - Data validation tests + - `tests/ui/` - UI component tests +- 92% overall coverage (7,031 statements) +- CI pipeline with format/lint/type/coverage checks + +--- + +## What's Actually Needed (v0.3.0 - World Expansion) + +### High Priority - Content Expansion + +#### 1. More Pokemon Species (55 → 100+) +- Add common early-game Pokemon +- Add water types for fishing expansion +- Add mid-game Pokemon +- Target: 66% Gen 1 coverage by v0.3.0 + +#### 2. More Locations (14 → 25+) +- Routes 5-11 +- Vermillion City (gym already works) +- Rock Tunnel +- Diglett's Cave +- Underground Path + +#### 3. More Trainers (Current: ~10 → Target: 50+) +- Youngsters, Lasses +- Hikers, Picnickers +- Swimmers, Sailors +- Rocket Grunts +- Super Nerds + +### Medium Priority - Gameplay Systems + +#### 4. Move Learning System +- Level-up move tables +- Move replacement UI (when Pokemon knows 4 moves) +- Move reminder/deleter NPCs + +#### 5. Special Events +- S.S. Anne event +- Team Rocket encounters +- Legendary Pokemon +- Pokemon Tower +- Safari Zone + +--- + +## Development Priorities (Next 4 Weeks) + +### Week 1 (March 12-19, 2026) +1. Add 10 new Pokemon species +2. ✅ Update IMPLEMENTATION_PLAN.md (DONE) +3. Create Routes 5-6 +4. Add Youngster trainers (5) +5. Add Lass trainers (5) + +### Week 2 (March 19-26, 2026) +1. Implement Vermillion City location +2. Add Underground Path +3. Create Route 11 +4. Add Diglett's Cave +5. Implement move learning system MVP + +### Week 3-4 (March 26 - April 9, 2026) +1. Add 20 more Pokemon species +2. Create Routes 9-10 +3. Implement Rock Tunnel dungeon +4. Add S.S. Anne event +5. Add Team Rocket encounters + +**Goal:** v0.3.0 ready by April 15, 2026 + +--- + +## Project Health Assessment + +### Strengths ✅ +- **Excellent test coverage** (92%, well above industry standard) +- **Clean architecture** (mixin-based, separation of concerns) +- **Professional code quality** (passes all CI checks) +- **Comprehensive documentation** (6 major doc files) +- **Solid foundation** (all core systems complete) +- **Active development** (regular commits, clear roadmap) + +### Areas for Improvement ⚠️ +- **Content density** (only 55/151 Pokemon implemented) +- **Location coverage** (14 locations, need 40+ for full Kanto) +- **Trainer variety** (limited trainer classes) +- **Story events** (few special events implemented) + +### Risk Assessment 🟢 LOW RISK +- No critical bugs or blockers +- All dependencies stable +- CI pipeline robust +- Test suite comprehensive +- Technical debt minimal + +--- + +## Recommendations + +### Immediate Actions (This Week) +1. ✅ Update IMPLEMENTATION_PLAN.md (COMPLETE) +2. Begin Pokemon data entry (priority: common Gen 1) +3. Create location expansion plan +4. Design move learning UI + +### Short-term (Next Month) +1. Focus on content expansion (Pokemon, locations, trainers) +2. Implement move learning system +3. Add story events (S.S. Anne, Team Rocket) +4. Reach 100 Pokemon milestone + +### Long-term (v1.0.0 Vision) +1. Complete all 151 Gen 1 Pokemon +2. Full Kanto region (40+ locations) +3. Elite Four + Champion +4. All special events +5. 95%+ test coverage maintained + +--- + +## Conclusion + +The project is in **excellent shape**. The implementation plan was outdated and painted a misleading picture — in reality, many "missing" features were already complete and well-tested. + +**Key Takeaway:** The foundation is solid. The focus should now shift from **system implementation** to **content expansion** (more Pokemon, locations, and trainers). + +**Next Milestone:** v0.3.0 (World Expansion) by April 15, 2026 + +--- + +**Report Generated:** March 12, 2026 +**Analyst:** GitHub Copilot +**Status:** Repository analysis complete, implementation plan updated diff --git a/PokemonLibrary/battle/battle_actions.py b/PokemonLibrary/battle/battle_actions.py index 09bcf5f..24221bf 100644 --- a/PokemonLibrary/battle/battle_actions.py +++ b/PokemonLibrary/battle/battle_actions.py @@ -72,6 +72,7 @@ def trigger_wild_encounter( output: RichLog, pending_command_callback, show_battle_buttons_callback=None, + show_battle_start_callback=None, ) -> None: """ Trigger a wild Pokemon battle at the current location. @@ -85,6 +86,9 @@ def trigger_wild_encounter( output: The RichLog widget to write to pending_command_callback: Callback to set pending command show_battle_buttons_callback: Optional callback to show battle action buttons + show_battle_start_callback: Optional ``(output, on_ready)`` callback that + displays the battle-start animation and calls ``on_ready()`` when done. + When omitted the default instant display is used. """ from . import battle_ui @@ -124,11 +128,17 @@ def trigger_wild_encounter( f"[dim]📖 Pokedex: {bs.wild_pokemon.get('name', wild_species)} was registered as seen![/dim]" ) - battle_ui.show_battle_start(game_state, output) - battle_ui.show_battle_options(game_state, output) - pending_command_callback("battle") - if show_battle_buttons_callback: - show_battle_buttons_callback() + def _on_battle_start_done() -> None: + battle_ui.show_battle_options(game_state, output) + pending_command_callback("battle") + if show_battle_buttons_callback: + show_battle_buttons_callback() + + if show_battle_start_callback: + show_battle_start_callback(output, _on_battle_start_done) + else: + battle_ui.show_battle_start(game_state, output) + _on_battle_start_done() def trigger_trainer_encounter( @@ -137,6 +147,7 @@ def trigger_trainer_encounter( trainer: "Trainer", pending_command_callback, show_battle_buttons_callback=None, + show_battle_start_callback=None, ) -> None: """ Trigger a trainer battle. @@ -147,6 +158,9 @@ def trigger_trainer_encounter( trainer: Trainer data dict pending_command_callback: Callback to set pending command show_battle_buttons_callback: Optional callback to show battle action buttons + show_battle_start_callback: Optional ``(output, on_ready)`` callback that + displays the battle-start animation and calls ``on_ready()`` when done. + When omitted the default instant display is used. """ from . import battle_ui @@ -174,11 +188,17 @@ def trigger_trainer_encounter( if species: pokedex.mark_as_seen(game_state, species) - battle_ui.show_trainer_battle_start(game_state, output) - battle_ui.show_battle_options(game_state, output) - pending_command_callback("battle") - if show_battle_buttons_callback: - show_battle_buttons_callback() + def _on_battle_start_done() -> None: + battle_ui.show_battle_options(game_state, output) + pending_command_callback("battle") + if show_battle_buttons_callback: + show_battle_buttons_callback() + + if show_battle_start_callback: + show_battle_start_callback(output, _on_battle_start_done) + else: + battle_ui.show_trainer_battle_start(game_state, output) + _on_battle_start_done() def parse_move_choice(command: str, player: "PartyPokemon") -> "Optional[MoveSlot]": @@ -444,6 +464,7 @@ def attempt_catch_pokemon( end_battle_callback, handle_pokemon_fainted_callback, ball_type: str = "Pokeball", + animate_shake_callback=None, ) -> None: """ Attempt to catch the wild Pokemon with a Pokeball. @@ -456,6 +477,9 @@ def attempt_catch_pokemon( end_battle_callback: Callback to end battle handle_pokemon_fainted_callback: Callback to handle Pokemon fainted ball_type: Type of ball to use (Pokeball, Great Ball, Ultra Ball, Master Ball) + animate_shake_callback: Optional ``(output, lines, on_complete)`` callback that + displays the shake animation and calls ``on_complete()`` when done. + When omitted the shake lines are written instantly. """ battle = game_state.battle_state @@ -498,46 +522,83 @@ def attempt_catch_pokemon( output.write(f"[bold cyan]You threw a {ball_type} at wild {wild['name']}![/bold cyan]") output.write("") - # Attempt catch + # Attempt catch (computes result, has no display side-effects) caught, shakes, messages = battle.attempt_catch(ball_type) - # Show custom messages from catch attempt - for msg in messages: - output.write(msg) - - # Show wiggle animation + # Build the shake sequence lines (animated or instant) wiggle_text = "● " * shakes + "○ " * (4 - shakes) - output.write(f"[dim]{wiggle_text.strip()}[/dim]") - output.write("") - - if caught: - # Successful catch! - pokemon_party = game_state.game_data.get("pokemon", []) - - if len(pokemon_party) >= 6: - from .. import pc_system - - caught_pokemon = { - "name": wild["name"], - "number": wild.get("number", 0), - "level": wild["level"], - "types": wild["types"], - "hp": wild["hp"], - "max_hp": wild["max_hp"], - "stats": wild["stats"], - "moves": wild["moves"], - "experience": 0, - "next_level_exp": battle.calculate_exp_for_level(wild["level"] + 1), - "status": wild.get("status"), - "no_evolve": False, - } - placed_box = pc_system.send_to_pc(game_state, caught_pokemon) - if placed_box: - output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") - output.write( - f"[yellow] Your party is full — {wild['name']} was sent to {placed_box}![/yellow]" - ) - output.write("[dim] Access Bill's PC at any Pokemon Center to retrieve it.[/dim]") + shake_lines = [*messages, f"[dim]{wiggle_text.strip()}[/dim]", ""] + + def _after_shake() -> None: + if caught: + # Successful catch! + pokemon_party = game_state.game_data.get("pokemon", []) + + if len(pokemon_party) >= 6: + from .. import pc_system + + caught_pokemon = { + "name": wild["name"], + "number": wild.get("number", 0), + "level": wild["level"], + "types": wild["types"], + "hp": wild["hp"], + "max_hp": wild["max_hp"], + "stats": wild["stats"], + "moves": wild["moves"], + "experience": 0, + "next_level_exp": battle.calculate_exp_for_level(wild["level"] + 1), + "status": wild.get("status"), + "no_evolve": False, + } + placed_box = pc_system.send_to_pc(game_state, caught_pokemon) + if placed_box: + output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") + output.write( + f"[yellow] Your party is full — {wild['name']} was sent to {placed_box}![/yellow]" + ) + output.write( + "[dim] Access Bill's PC at any Pokemon Center to retrieve it.[/dim]" + ) + species = wild.get("species", wild["name"]).upper() + if pokedex.mark_as_caught(game_state, species): + output.write( + f"[dim]📖 Pokedex: {wild['name']} was registered as caught![/dim]" + ) + caught_count = len( + game_state.game_data.get("pokedex", {}).get("caught", []) + ) + milestone_msg = pokedex.get_caught_milestone_message(caught_count) + if milestone_msg: + output.write(f"[bold yellow]{milestone_msg}[/bold yellow]") + type_msg = pokedex.get_first_type_message(game_state, species) + if type_msg: + output.write(f"[yellow]{type_msg}[/yellow]") + else: + output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") + output.write( + f"[red] Your party and PC are both full! {wild['name']} could not be stored.[/red]" + ) + else: + # Add to party + caught_pokemon = { + "name": wild["name"], + "number": wild.get("number", 0), + "level": wild["level"], + "types": wild["types"], + "hp": wild["hp"], + "max_hp": wild["max_hp"], + "stats": wild["stats"], + "moves": wild["moves"], + "experience": 0, + "next_level_exp": battle.calculate_exp_for_level(wild["level"] + 1), + "status": wild.get("status"), + "no_evolve": False, + } + pokemon_party.append(caught_pokemon) + output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]") + + # Mark as caught in Pokedex species = wild.get("species", wild["name"]).upper() if pokedex.mark_as_caught(game_state, species): output.write(f"[dim]📖 Pokedex: {wild['name']} was registered as caught![/dim]") @@ -548,70 +609,41 @@ def attempt_catch_pokemon( type_msg = pokedex.get_first_type_message(game_state, species) if type_msg: output.write(f"[yellow]{type_msg}[/yellow]") - else: - output.write(f"[green]★ Gotcha! {wild['name']} was caught! ★[/green]") - output.write( - f"[red] Your party and PC are both full! {wild['name']} could not be stored.[/red]" - ) + output.write("") + output.write(f"[green]✓ {wild['name']} was added to your party![/green]") + + output.write("") + # End battle on successful catch + _stats.record_catch(game_state, wild.get("species", wild["name"])) + end_battle_callback(output) else: - # Add to party - # Remove battle-specific fields and prepare for party - caught_pokemon = { - "name": wild["name"], - "number": wild.get("number", 0), - "level": wild["level"], - "types": wild["types"], - "hp": wild["hp"], - "max_hp": wild["max_hp"], - "stats": wild["stats"], - "moves": wild["moves"], - "experience": 0, - "next_level_exp": battle.calculate_exp_for_level(wild["level"] + 1), - "status": wild.get("status"), - "no_evolve": False, - } - pokemon_party.append(caught_pokemon) - output.write(f"[bold green]★ Gotcha! {wild['name']} was caught! ★[/bold green]") - - # Mark as caught in Pokedex - species = wild.get("species", wild["name"]).upper() - if pokedex.mark_as_caught(game_state, species): - output.write(f"[dim]📖 Pokedex: {wild['name']} was registered as caught![/dim]") - caught_count = len(game_state.game_data.get("pokedex", {}).get("caught", [])) - milestone_msg = pokedex.get_caught_milestone_message(caught_count) - if milestone_msg: - output.write(f"[bold yellow]{milestone_msg}[/bold yellow]") - type_msg = pokedex.get_first_type_message(game_state, species) - if type_msg: - output.write(f"[yellow]{type_msg}[/yellow]") + # Failed catch + if shakes == 0: + output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]") + elif shakes == 1: + output.write("[yellow]Aww! It appeared to be caught![/yellow]") + elif shakes == 2: + output.write("[yellow]Aargh! Almost had it![/yellow]") + elif shakes == 3: + output.write("[yellow]Gah! It was so close, too![/yellow]") output.write("") - output.write(f"[green]✓ {wild['name']} was added to your party![/green]") - output.write("") - # End battle on successful catch - _stats.record_catch(game_state, wild.get("species", wild["name"])) - end_battle_callback(output) - else: - # Failed catch - if shakes == 0: - output.write(f"[yellow]Oh no! {wild['name']} broke free immediately![/yellow]") - elif shakes == 1: - output.write("[yellow]Aww! It appeared to be caught![/yellow]") - elif shakes == 2: - output.write("[yellow]Aargh! Almost had it![/yellow]") - elif shakes == 3: - output.write("[yellow]Gah! It was so close, too![/yellow]") - output.write("") + # Wild Pokemon gets a free turn + execute_wild_pokemon_turn(game_state, output) - # Wild Pokemon gets a free turn - execute_wild_pokemon_turn(game_state, output) + # Check if player fainted + if battle.player_pokemon["hp"] <= 0: + handle_pokemon_fainted_callback(output) + else: + show_battle_options_callback(output) + pending_command_callback("battle") - # Check if player fainted - if battle.player_pokemon["hp"] <= 0: - handle_pokemon_fainted_callback(output) - else: - show_battle_options_callback(output) - pending_command_callback("battle") + if animate_shake_callback: + animate_shake_callback(output, shake_lines, _after_shake) + else: + for line in shake_lines: + output.write(line) + _after_shake() def execute_switch( diff --git a/PokemonLibrary/battle/battle_ui.py b/PokemonLibrary/battle/battle_ui.py index 922f853..6501590 100644 --- a/PokemonLibrary/battle/battle_ui.py +++ b/PokemonLibrary/battle/battle_ui.py @@ -5,7 +5,7 @@ move selection menus, battle options, and victory/defeat messages. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List from textual.widgets import RichLog @@ -16,56 +16,83 @@ from ..game_state import GameState -def show_battle_start(game_state: "GameState", output: RichLog) -> None: - """Display the wild Pokemon encounter introduction.""" +def get_battle_start_lines(game_state: "GameState") -> List[str]: + """ + Return the wild Pokemon encounter introduction as a list of text lines. + + Args: + game_state: The game state (must have an active ``battle_state``; behaviour + is undefined if ``battle_state`` is ``None``). + + Returns: + List of Rich-markup strings representing the battle intro. + """ battle = game_state.battle_state wild = battle.wild_pokemon player = battle.player_pokemon + return [ + "", + "[bold red]═══════════════════════════════════════════[/bold red]", + f"[bold red]⚔️ A wild {wild['name']} appeared! (Lv. {wild['level']}) ⚔️[/bold red]", + "[bold red]═══════════════════════════════════════════[/bold red]", + "", + f"[bold]Go! {player['name']}![/bold]", + "", + ] - output.write("") - output.write("[bold red]═══════════════════════════════════════════[/bold red]") - output.write( - f"[bold red]⚔️ A wild {wild['name']} appeared! (Lv. {wild['level']}) ⚔️[/bold red]" - ) - output.write("[bold red]═══════════════════════════════════════════[/bold red]") - output.write("") - output.write(f"[bold]Go! {player['name']}![/bold]") - output.write("") +def show_battle_start(game_state: "GameState", output: RichLog) -> None: + """Display the wild Pokemon encounter introduction.""" + for line in get_battle_start_lines(game_state): + output.write(line) -def show_trainer_battle_start(game_state: "GameState", output: RichLog) -> None: - """Display the trainer battle introduction.""" + +def get_trainer_battle_start_lines(game_state: "GameState") -> List[str]: + """ + Return the trainer battle introduction as a list of text lines. + + Args: + game_state: The game state (must have an active ``battle_state``; behaviour + is undefined if ``battle_state`` is ``None``). + + Returns: + List of Rich-markup strings representing the trainer battle intro. + """ battle = game_state.battle_state trainer = battle.trainer_data trainer_pokemon = battle.wild_pokemon player = battle.player_pokemon - player_name = game_state.game_data.get("player_name", "Trainer") - output.write("") - output.write("[bold yellow]═══════════════════════════════════════════[/bold yellow]") - output.write("[bold yellow]⚔️ TRAINER BATTLE! ⚔️[/bold yellow]") - output.write("[bold yellow]═══════════════════════════════════════════[/bold yellow]") - output.write("") + lines = [ + "", + "[bold yellow]═══════════════════════════════════════════[/bold yellow]", + "[bold yellow]⚔️ TRAINER BATTLE! ⚔️[/bold yellow]", + "[bold yellow]═══════════════════════════════════════════[/bold yellow]", + "", + ] - # Show trainer intro dialogue - for line in trainer.get("intro_text", []): - output.write(f"[yellow]{line}[/yellow]") + for intro_line in trainer.get("intro_text", []): + lines.append(f"[yellow]{intro_line}[/yellow]") - output.write("") - output.write(f"[bold]{trainer['trainer_class']} {trainer['name']} wants to battle![/bold]") - output.write("") + lines.append("") + lines.append(f"[bold]{trainer['trainer_class']} {trainer['name']} wants to battle![/bold]") + lines.append("") - # Show trainer's Pokemon num_pokemon = len(battle.trainer_pokemon_team) if num_pokemon > 1: - output.write(f"[dim]{trainer['name']} sent out {trainer_pokemon['name']}![/dim]") - output.write(f"[dim]They have {num_pokemon} Pokemon total[/dim]") + lines.append(f"[dim]{trainer['name']} sent out {trainer_pokemon['name']}![/dim]") + lines.append(f"[dim]They have {num_pokemon} Pokemon total[/dim]") else: - output.write(f"[dim]{trainer['name']} sent out {trainer_pokemon['name']}![/dim]") + lines.append(f"[dim]{trainer['name']} sent out {trainer_pokemon['name']}![/dim]") - output.write("") - output.write(f"[bold]Go! {player['name']}![/bold]") - output.write("") + lines.extend(["", f"[bold]Go! {player['name']}![/bold]", ""]) + return lines + + +def show_trainer_battle_start(game_state: "GameState", output: RichLog) -> None: + """Display the trainer battle introduction.""" + for line in get_trainer_battle_start_lines(game_state): + output.write(line) def show_battle_options(game_state: "GameState", output: RichLog) -> None: diff --git a/PokemonLibrary/evolution.py b/PokemonLibrary/evolution.py index ea170a0..4ee58b9 100644 --- a/PokemonLibrary/evolution.py +++ b/PokemonLibrary/evolution.py @@ -150,6 +150,7 @@ def force_evolve( output: RichLog, *, update_battle_state: bool = False, + silent_preamble: bool = False, ) -> bool: """ Apply evolution to ``pokemon_ref`` into ``evolved_form_name`` unconditionally. @@ -165,6 +166,8 @@ def force_evolve( output: RichLog widget to write evolution messages to. update_battle_state: If True, also updates ``game_state.battle_state. player_pokemon`` to the new evolved dict. + silent_preamble: If True, skip the "What? X is evolving!" preamble + lines (used when the caller already animated them). Returns: True if evolution was applied, False if the target form couldn't be found. @@ -216,12 +219,13 @@ def force_evolve( game_state.battle_state.player_pokemon = evolved_pokemon # ---- Print evolution sequence ------------------------------------------- - output.write("") - output.write(f"[bold yellow]✨ What? {old_name} is evolving![/bold yellow]") - output.write("") - output.write("[dim] ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇[/dim]") - output.write("[bold cyan] ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆[/bold cyan]") - output.write("") + if not silent_preamble: + output.write("") + output.write(f"[bold yellow]✨ What? {old_name} is evolving![/bold yellow]") + output.write("") + output.write("[dim] ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇[/dim]") + output.write("[bold cyan] ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆ ◆[/bold cyan]") + output.write("") output.write( f"[bold green]🎉 Congratulations! {old_name} evolved into {evolved_form_name}! 🎉[/bold green]" ) diff --git a/PokemonLibrary/exploration.py b/PokemonLibrary/exploration.py index a621fcd..bdf41d2 100644 --- a/PokemonLibrary/exploration.py +++ b/PokemonLibrary/exploration.py @@ -600,9 +600,11 @@ def explore_area( _stats.record_explore(game_state, location.name) return + # Bicycle halves wild encounter rate and counts as 2 explores per trip + cycling = game_state.game_data.get("cycling", False) + explore_step = 2 if cycling else 1 wild_encounter_rate = location.wild_encounter_rate - # Bicycle reduces wild encounters - if game_state.game_data.get("cycling"): + if cycling: wild_encounter_rate = wild_encounter_rate * 0.5 if random.random() < wild_encounter_rate: @@ -613,7 +615,7 @@ def explore_area( remaining = repel_steps - 1 if random.random() < 0.5: # Repel suppressed this encounter - game_state.increment_route_progress(location.name) + game_state.increment_route_progress(location.name, explore_step) _stats.record_explore(game_state, location.name) output.write("[dim]🪢 The Repel keeps wild Pokemon away...[/dim]") if remaining > 0: @@ -622,13 +624,16 @@ def explore_area( output.write("[dim] The Repel wore off![/dim]") output.write("") return - game_state.increment_route_progress(location.name) + game_state.increment_route_progress(location.name, explore_step) _stats.record_explore(game_state, location.name) trigger_wild_callback(output) else: - game_state.increment_route_progress(location.name) + game_state.increment_route_progress(location.name, explore_step) _stats.record_explore(game_state, location.name) - output.write("[dim]You search the tall grass but nothing jumps out...[/dim]") + if cycling: + output.write("[cyan]🚲 You zip through the area on your Bicycle![/cyan]") + else: + output.write("[dim]You search the tall grass but nothing jumps out...[/dim]") hint = get_explore_hint(game_state) if hint: output.write(hint) diff --git a/PokemonLibrary/game_state.py b/PokemonLibrary/game_state.py index 5c576e7..0e5269c 100644 --- a/PokemonLibrary/game_state.py +++ b/PokemonLibrary/game_state.py @@ -331,12 +331,13 @@ def get_route_progress(self, location_name: str) -> int: """ return self.game_data.get("route_progress", {}).get(location_name, 0) - def increment_route_progress(self, location_name: str) -> int: + def increment_route_progress(self, location_name: str, steps: int = 1) -> int: """ - Increment the explore counter for the given location by one. + Increment the explore counter for the given location. Args: location_name: Name of the route or forest. + steps: How many explore steps to add (default 1; pass 2 when cycling). Returns: int: New explore count after incrementing. @@ -344,6 +345,6 @@ def increment_route_progress(self, location_name: str) -> int: if "route_progress" not in self.game_data: self.game_data["route_progress"] = {} self.game_data["route_progress"][location_name] = ( - self.game_data["route_progress"].get(location_name, 0) + 1 + self.game_data["route_progress"].get(location_name, 0) + steps ) return self.game_data["route_progress"][location_name] diff --git a/PokemonLibrary/locations.py b/PokemonLibrary/locations.py index 0d6029e..253d4d5 100644 --- a/PokemonLibrary/locations.py +++ b/PokemonLibrary/locations.py @@ -78,8 +78,8 @@ def get_blocked_exits(self) -> Dict[str, str]: } def can_explore(self) -> bool: - """Check if this location can be explored (routes/forests).""" - return self.type in [TYPE_ROUTE, TYPE_FOREST] + """Check if this location can be explored (routes/forests/dungeons).""" + return self.type in [TYPE_ROUTE, TYPE_FOREST, TYPE_DUNGEON] def get_exit_min_explores(self, exit_name: str) -> int: """ @@ -120,12 +120,12 @@ def get_exit_min_explores(self, exit_name: str) -> int: "Pallet Town": { "direction": "south", "blocked": False, - "min_explores": 3, + "min_explores": 5, }, "Viridian City": { "direction": "north", "blocked": False, - "min_explores": 3, + "min_explores": 5, }, }, wild_pokemon=["PIDGEY", "RATTATA"], @@ -150,13 +150,13 @@ def get_exit_min_explores(self, exit_name: str) -> int: "Viridian City": { "direction": "east", "blocked": False, - "min_explores": 4, + "min_explores": 6, }, "Pokemon League Gate": { "direction": "west", "blocked": True, "reason": "You need all 8 Gym Badges to enter the Pokemon League", - "min_explores": 4, + "min_explores": 6, }, }, wild_pokemon=["PIDGEY", "RATTATA", "SPEAROW"], @@ -171,12 +171,12 @@ def get_exit_min_explores(self, exit_name: str) -> int: "Viridian City": { "direction": "south", "blocked": False, - "min_explores": 2, + "min_explores": 4, }, "Viridian Forest": { "direction": "north", "blocked": False, - "min_explores": 2, + "min_explores": 4, }, }, wild_pokemon=["PIDGEY", "RATTATA"], @@ -185,17 +185,21 @@ def get_exit_min_explores(self, exit_name: str) -> int: "Route 2 North": Location( name="Route 2 North", location_type=TYPE_ROUTE, - description="You've made it through the forest — but the route north of Viridian Forest is no picnic either. Pewter City's rocky skyline is just visible through the trees.", + description="You've made it through the forest — but the route north of Viridian Forest is no picnic either. Pewter City's rocky skyline is just visible through the trees, and a dark cave entrance beckons to the east.", exits={ "Viridian Forest": { "direction": "south", "blocked": False, - "min_explores": 2, + "min_explores": 4, }, "Pewter City": { "direction": "north", "blocked": False, - "min_explores": 2, + "min_explores": 4, + }, + "Diglett's Cave": { + "direction": "east", + "blocked": False, }, }, wild_pokemon=["PIDGEY", "RATTATA", "SPEAROW"], @@ -209,12 +213,12 @@ def get_exit_min_explores(self, exit_name: str) -> int: "Route 2 South": { "direction": "south", "blocked": False, - "min_explores": 4, + "min_explores": 7, }, "Route 2 North": { "direction": "north", "blocked": False, - "min_explores": 4, + "min_explores": 7, }, }, wild_pokemon=["CATERPIE", "WEEDLE", "PIKACHU"], @@ -238,8 +242,8 @@ def get_exit_min_explores(self, exit_name: str) -> int: location_type=TYPE_ROUTE, description="A long eastbound road stretching from Pewter City toward Mt. Moon. Lasses and Youngsters patrol the tall grass, training hard under the mountain's shadow.", exits={ - "Pewter City": {"direction": "west", "blocked": False, "min_explores": 4}, - "Mt. Moon": {"direction": "east", "blocked": False, "min_explores": 4}, + "Pewter City": {"direction": "west", "blocked": False, "min_explores": 7}, + "Mt. Moon": {"direction": "east", "blocked": False, "min_explores": 7}, }, wild_pokemon=["PIDGEY", "JIGGLYPUFF", "MEOWTH"], wild_level_range=(10, 14), @@ -252,8 +256,8 @@ def get_exit_min_explores(self, exit_name: str) -> int: location_type=TYPE_FOREST, description="A twisting cave system riddled with Zubat and Clefairy. Deep inside, researchers dig for fossils, and a strange sparkling stone has been sighted near the exit tunnel.", exits={ - "Route 3": {"direction": "west", "blocked": False, "min_explores": 5}, - "Route 4": {"direction": "east", "blocked": False, "min_explores": 5}, + "Route 3": {"direction": "west", "blocked": False, "min_explores": 8}, + "Route 4": {"direction": "east", "blocked": False, "min_explores": 8}, }, wild_pokemon=["ZUBAT", "CLEFAIRY", "GEODUDE"], wild_level_range=(8, 12), @@ -266,8 +270,8 @@ def get_exit_min_explores(self, exit_name: str) -> int: location_type=TYPE_ROUTE, description="The eastern side of Mt. Moon drops onto this short road that leads straight into Cerulean City. Spearow circle overhead as Rattata dart through the underbrush.", exits={ - "Mt. Moon": {"direction": "west", "blocked": False, "min_explores": 3}, - "Cerulean City": {"direction": "east", "blocked": False, "min_explores": 3}, + "Mt. Moon": {"direction": "west", "blocked": False, "min_explores": 6}, + "Cerulean City": {"direction": "east", "blocked": False, "min_explores": 6}, }, wild_pokemon=["SPEAROW", "RATTATA"], wild_level_range=(13, 17), @@ -281,11 +285,8 @@ def get_exit_min_explores(self, exit_name: str) -> int: description="A quiet city nestled beside a flowing river. Misty rules its Gym with an iron fist and a Water-type army, while the Bike Shop on the corner tempts every passerby.", exits={ "Route 4": {"direction": "west", "blocked": False}, - "Route 5": { - "direction": "south", - "blocked": True, - "reason": "This area is not yet available in the game", - }, + "Route 5": {"direction": "south", "blocked": False}, + "Route 9": {"direction": "east", "blocked": False}, "Route 24": { "direction": "north", "blocked": True, @@ -335,6 +336,194 @@ def get_exit_min_explores(self, exit_name: str) -> int: trainer_encounter_rate=0.25, wild_encounter_rate=0.55, ), + "Route 5": Location( + name="Route 5", + location_type=TYPE_ROUTE, + description=( + "A busy southbound road leading from Cerulean City toward Saffron City. " + "Trainers patrol the tall grass, and a small gatehouse at the south end " + "leads to the Underground Path." + ), + exits={ + "Cerulean City": {"direction": "north", "blocked": False, "min_explores": 6}, + "Underground Path (North)": { + "direction": "south", + "blocked": False, + "min_explores": 6, + }, + }, + wild_pokemon=["PIDGEY", "MEOWTH", "MANKEY"], + wild_level_range=(13, 17), + trainers=2, + trainer_encounter_rate=0.30, + wild_encounter_rate=0.45, + ), + "Route 6": Location( + name="Route 6", + location_type=TYPE_ROUTE, + description=( + "A wide road running north of Vermillion City. The grass here is tall and " + "untamed, and the salty sea breeze from Vermillion Harbour drifts up the path. " + "A gatehouse at the north end connects to the Underground Path." + ), + exits={ + "Underground Path (South)": { + "direction": "north", + "blocked": False, + "min_explores": 6, + }, + "Vermillion City": {"direction": "south", "blocked": False, "min_explores": 6}, + }, + wild_pokemon=["PIDGEY", "MEOWTH", "MANKEY"], + wild_level_range=(14, 18), + trainers=2, + trainer_encounter_rate=0.30, + wild_encounter_rate=0.45, + ), + "Underground Path (North)": Location( + name="Underground Path (North)", + location_type=TYPE_DUNGEON, + description=( + "The northern entrance to the long tunnel connecting Routes 5 and 6. " + "Vendors sell rare items in the cool underground air, and the tunnel " + "stretches south under Saffron City all the way to Route 6." + ), + exits={ + "Route 5": {"direction": "north", "blocked": False}, + "Underground Path (South)": {"direction": "south", "blocked": False}, + }, + wild_pokemon=[], + wild_level_range=(1, 1), + trainers=0, + wild_encounter_rate=0.0, + ), + "Underground Path (South)": Location( + name="Underground Path (South)", + location_type=TYPE_DUNGEON, + description=( + "The southern exit of the Underground Path, emerging just north of Vermillion City. " + "The tunnel has no wild Pokemon — just the echo of footsteps and the distant " + "smell of the sea." + ), + exits={ + "Underground Path (North)": {"direction": "north", "blocked": False}, + "Route 6": {"direction": "south", "blocked": False}, + }, + wild_pokemon=[], + wild_level_range=(1, 1), + trainers=0, + wild_encounter_rate=0.0, + ), + "Vermillion City": Location( + name="Vermillion City", + location_type=TYPE_TOWN, + description=( + "A bustling port city on the southern coast. The S.S. Anne docks here " + "occasionally, and Lt. Surge — the Lightning American — guards the Gym " + "with a ferocious team of Electric-type Pokemon." + ), + exits={ + "Route 6": {"direction": "north", "blocked": False}, + "Route 11": {"direction": "east", "blocked": False}, + }, + buildings=["Pokemon Center", "Pokemart", "Gym", "S.S. Anne Dock"], + ), + "Route 11": Location( + name="Route 11", + location_type=TYPE_ROUTE, + description=( + "A long eastbound road stretching from Vermillion City. Drowzee shuffle " + "through the tall grass as Spearow circle overhead. The entrance to " + "Diglett's Cave is visible on the north side of the road." + ), + exits={ + "Vermillion City": { + "direction": "west", + "blocked": False, + "min_explores": 7, + }, + "Diglett's Cave": {"direction": "north", "blocked": False}, + }, + wild_pokemon=["EKANS", "SPEAROW", "DROWZEE"], + wild_level_range=(13, 19), + trainers=3, + trainer_encounter_rate=0.30, + wild_encounter_rate=0.45, + ), + "Diglett's Cave": Location( + name="Diglett's Cave", + location_type=TYPE_DUNGEON, + description=( + "A narrow tunnel dug out entirely by Diglett and their evolved forms. " + "The cave runs from Route 2 all the way to Route 11, making it a " + "useful shortcut — and a great place to catch a Diglett or Dugtrio." + ), + exits={ + "Route 2 North": {"direction": "north", "blocked": False}, + "Route 11": {"direction": "south", "blocked": False}, + }, + wild_pokemon=["DIGLETT", "DUGTRIO"], + wild_level_range=(15, 22), + trainers=0, + wild_encounter_rate=0.60, + ), + "Route 9": Location( + name="Route 9", + location_type=TYPE_ROUTE, + description=( + "A rocky eastbound road that curves around the base of a mountain range. " + "The terrain is rough underfoot, and trainers here mean business — " + "they're all heading toward Rock Tunnel." + ), + exits={ + "Cerulean City": { + "direction": "west", + "blocked": False, + "min_explores": 7, + }, + "Route 10": {"direction": "east", "blocked": False, "min_explores": 7}, + }, + wild_pokemon=["RATTATA", "EKANS", "SPEAROW"], + wild_level_range=(15, 21), + trainers=3, + trainer_encounter_rate=0.35, + wild_encounter_rate=0.45, + ), + "Route 10": Location( + name="Route 10", + location_type=TYPE_ROUTE, + description=( + "A power plant route split in two by Rock Tunnel. The hum of electricity " + "fills the air near the Power Plant to the east, and Voltorb are known " + "to lurk in the tall grass beside the rocky path." + ), + exits={ + "Route 9": {"direction": "west", "blocked": False, "min_explores": 6}, + "Rock Tunnel": {"direction": "south", "blocked": False, "min_explores": 6}, + }, + wild_pokemon=["VOLTORB", "MAGNEMITE"], + wild_level_range=(17, 23), + trainers=1, + trainer_encounter_rate=0.25, + wild_encounter_rate=0.45, + ), + "Rock Tunnel": Location( + name="Rock Tunnel", + location_type=TYPE_DUNGEON, + description=( + "A pitch-black labyrinth carved through the mountains. Without the HM Flash, " + "navigating is a nightmare — Zubat swarm from every direction and Onix " + "rumble beneath your feet. Bring Repels." + ), + exits={ + "Route 10": {"direction": "north", "blocked": False, "min_explores": 10}, + }, + wild_pokemon=["ZUBAT", "GEODUDE", "MACHOP", "ONIX"], + wild_level_range=(15, 23), + trainers=4, + trainer_encounter_rate=0.30, + wild_encounter_rate=0.65, + ), } diff --git a/PokemonLibrary/terminal.py b/PokemonLibrary/terminal.py index 4c59814..e9c54e5 100644 --- a/PokemonLibrary/terminal.py +++ b/PokemonLibrary/terminal.py @@ -48,6 +48,7 @@ # Mixin modules from .ui.panel_mixin import PanelMixin +from .ui.text_animation import AnimatedTextWriter # ═══════════════════════════════════════════════════════════════════════════════ # PokemonTerminal @@ -81,6 +82,7 @@ def __init__(self, *args, **kwargs): self.game_state = GameState() self.pending_command = None self.pending_command_data = {} + self.text_animator = AnimatedTextWriter(self) if os.environ.get("POKEMON_TYPEWRITER", "").lower() == "true": self.typewriter_enabled = True @@ -905,17 +907,39 @@ def on_button_pressed(self, event: Button.Pressed) -> None: # Evolution confirmation buttons elif button_id == "btn-evolve-confirm": evolved_into = self.pending_command_data.get("evolves_into", "???") + pokemon_ref = self.pending_command_data.get("evolving_pokemon") + pokemon_name = pokemon_ref.get("name", "POKÉMON") if pokemon_ref else "POKÉMON" + output.write("[bold yellow]🎮 >[/bold yellow] Evolve!") self.hide_all_panels() - self.pending_command = None - pokemon_ref = self.pending_command_data.get("evolving_pokemon") - if pokemon_ref is not None: - from . import evolution as _evo + self.pending_command = None # unblock typing during animation + + anim_lines = [ + "", + f"[bold yellow]✨ {pokemon_name} is evolving![/bold yellow]", + "", + "[dim] ◇ ◈ ◇ ◈ ◇ ◈ ◇ ◈ ◇ ◈[/dim]", + "[bold cyan] ◆ ◈ ◆ ◈ ◆ ◈ ◆ ◈ ◆ ◈[/bold cyan]", + "[bold magenta] ★ ✦ ★ ✦ ★ ✦ ★ ✦ ★ ✦[/bold magenta]", + "", + ] + + def do_evolve() -> None: + if pokemon_ref is not None: + from . import evolution as _evo + + _evo.force_evolve( + self.game_state, + pokemon_ref, + evolved_into, + output, + update_battle_state=True, + silent_preamble=True, + ) + self._resume_after_evolution(output) + self._refresh_subtitle() - _evo.force_evolve( - self.game_state, pokemon_ref, evolved_into, output, update_battle_state=True - ) - self._resume_after_evolution(output) + self.text_animator.write_slow(output, anim_lines, on_complete=do_evolve) elif button_id == "btn-evolve-cancel": pokemon_name = self.pending_command_data.get("evolving_pokemon", {}).get( "name", "POKÉMON" @@ -1195,11 +1219,13 @@ def process_command(self, command: str, output: RichLog) -> None: output.write("") elif ( not self.game_state.current_location - or self.game_state.current_location.type != "route" + or not self.game_state.current_location.can_explore() ): output.write("") - output.write("[yellow]⚠ You can only ride your Bicycle on routes![/yellow]") - output.write("[dim]Get on a route first, then hop on your bike[/dim]") + output.write( + "[yellow]⚠ You can only ride your Bicycle in routes, forests, and caves![/yellow]" + ) + output.write("[dim]Get to an explorable area first, then hop on your bike[/dim]") output.write("") else: cycling = self.game_state.game_data.get("cycling", False) diff --git a/PokemonLibrary/ui/battle_mixin.py b/PokemonLibrary/ui/battle_mixin.py index f70e065..73e8ad4 100644 --- a/PokemonLibrary/ui/battle_mixin.py +++ b/PokemonLibrary/ui/battle_mixin.py @@ -29,21 +29,33 @@ def ensure_battle_ready(self, pokemon: dict) -> None: def trigger_wild_encounter(self, output: RichLog) -> None: """Trigger a wild Pokemon battle at the current location.""" + + def animated_battle_start(out: RichLog, on_ready) -> None: + lines = battle_ui.get_battle_start_lines(self.game_state) + self.text_animator.write_medium(out, lines, on_complete=on_ready) + battle_actions.trigger_wild_encounter( self.game_state, output, lambda cmd: setattr(self, "pending_command", cmd), lambda: self.show_battle_action_panel(), + show_battle_start_callback=animated_battle_start, ) def trigger_trainer_encounter(self, output: RichLog, trainer: dict) -> None: """Trigger a trainer battle.""" + + def animated_trainer_start(out: RichLog, on_ready) -> None: + lines = battle_ui.get_trainer_battle_start_lines(self.game_state) + self.text_animator.write_medium(out, lines, on_complete=on_ready) + battle_actions.trigger_trainer_encounter( self.game_state, output, trainer, lambda cmd: setattr(self, "pending_command", cmd), lambda: self.show_battle_action_panel(), + show_battle_start_callback=animated_trainer_start, ) def trigger_gym_battle( @@ -61,14 +73,18 @@ def trigger_gym_battle( # ── Battle display ─────────────────────────────────────────────────────── def show_battle_start(self, output: RichLog) -> None: - """Display the wild Pokemon encounter introduction.""" - battle_ui.show_battle_start(self.game_state, output) - self.show_battle_options(output) + """Display the wild Pokemon encounter introduction with animation.""" + lines = battle_ui.get_battle_start_lines(self.game_state) + self.text_animator.write_medium( + output, lines, on_complete=lambda: self.show_battle_options(output) + ) def show_trainer_battle_start(self, output: RichLog) -> None: - """Display the trainer battle introduction.""" - battle_ui.show_trainer_battle_start(self.game_state, output) - self.show_battle_options(output) + """Display the trainer battle introduction with animation.""" + lines = battle_ui.get_trainer_battle_start_lines(self.game_state) + self.text_animator.write_medium( + output, lines, on_complete=lambda: self.show_battle_options(output) + ) def show_battle_options(self, output: RichLog) -> None: """Display the main battle menu with HP bars.""" @@ -234,6 +250,42 @@ def parse_move_choice(self, command: str, player: dict) -> Optional[dict]: return move return None + def _build_wild_turn_messages(self) -> list: + """Execute the opponent's turn (side-effects) and return the messages as a list. + + This companion to ``execute_wild_pokemon_turn`` allows the caller to animate + the resulting lines with ``text_animator`` instead of writing them instantly. + """ + battle = self.game_state.battle_state + wild = battle.wild_pokemon + player = battle.player_pokemon + lines: list[str] = [] + + available = [m for m in wild["moves"] if m["pp"] > 0] + if not available: + opponent_name = ( + f"{battle.trainer_data['name']}'s {wild['name']}" + if battle.is_trainer_battle + else f"Wild {wild['name']}" + ) + lines.append(f"[yellow]{opponent_name} has no moves left! It used Struggle![/yellow]") + struggle_dmg = max(1, player["max_hp"] // 8) + player["hp"] = max(0, player["hp"] - struggle_dmg) + lines.append(f"[cyan]{player['name']} took {struggle_dmg} damage![/cyan]") + return lines + + if battle.is_trainer_battle: + move = battle.choose_trainer_move() or random.choice(available) + opponent_name = f"{battle.trainer_data['name']}'s {wild['name']}" + else: + move = random.choice(available) + opponent_name = f"Wild {wild['name']}" + + lines.append("") + lines.append(f"[yellow]{opponent_name} used {move['name']}![/yellow]") + lines.extend(battle.execute_move(wild, player, move["name"])) + return lines + def execute_player_move(self, command: str, output: RichLog) -> None: """Execute the player's chosen move and then run the opponent's turn.""" battle = self.game_state.battle_state @@ -259,70 +311,48 @@ def execute_player_move(self, command: str, output: RichLog) -> None: self.pending_command = "select_move" return - # ── Player's turn ─────────────────────────────────────────────────── - output.write("") - output.write(f"[bold]{player['name']} used {move['name']}![/bold]") - messages = battle.execute_move(player, battle.wild_pokemon, move["name"]) - for msg in messages: - output.write(msg) - - if battle.wild_pokemon["hp"] <= 0: - self.handle_battle_victory(output) - return - - # ── Opponent's turn ───────────────────────────────────────────────── - self.execute_wild_pokemon_turn(output) - if player["hp"] <= 0: - self.handle_pokemon_fainted(output) - return + # ── Player's turn — execute move (side-effects) then animate ──────── + player_lines = [ + "", + f"[bold]{player['name']} used {move['name']}![/bold]", + *battle.execute_move(player, battle.wild_pokemon, move["name"]), + ] + wild_fainted = battle.wild_pokemon["hp"] <= 0 - # ── End-of-turn status effects ────────────────────────────────────── - battle = self.game_state.battle_state - if battle: - for msg in battle.end_of_turn_effects(battle.wild_pokemon): - output.write(msg) - if battle.wild_pokemon["hp"] <= 0: - self.handle_battle_victory(output) - return - for msg in battle.end_of_turn_effects(player): - output.write(msg) + def _after_opponent_turn() -> None: if player["hp"] <= 0: self.handle_pokemon_fainted(output) return + # ── End-of-turn status effects ─────────────────────────────────── + current_battle = self.game_state.battle_state + if current_battle: + for msg in current_battle.end_of_turn_effects(current_battle.wild_pokemon): + output.write(msg) + if current_battle.wild_pokemon["hp"] <= 0: + self.handle_battle_victory(output) + return + for msg in current_battle.end_of_turn_effects(player): + output.write(msg) + if player["hp"] <= 0: + self.handle_pokemon_fainted(output) + return + self.show_battle_options(output) + self.pending_command = "battle" - self.show_battle_options(output) - self.pending_command = "battle" - - def execute_wild_pokemon_turn(self, output: RichLog) -> None: - """Execute the opponent Pokemon's turn (random for wild, AI for trainers).""" - battle = self.game_state.battle_state - wild = battle.wild_pokemon - player = battle.player_pokemon - - available = [m for m in wild["moves"] if m["pp"] > 0] - if not available: - opponent_name = ( - f"{battle.trainer_data['name']}'s {wild['name']}" - if battle.is_trainer_battle - else f"Wild {wild['name']}" - ) - output.write(f"[yellow]{opponent_name} has no moves left! It used Struggle![/yellow]") - struggle_dmg = max(1, player["max_hp"] // 8) - player["hp"] = max(0, player["hp"] - struggle_dmg) - output.write(f"[cyan]{player['name']} took {struggle_dmg} damage![/cyan]") - return + def _after_player_attack() -> None: + if wild_fainted: + self.handle_battle_victory(output) + return + # ── Opponent's turn — execute (side-effects) then animate ──────── + opp_lines = self._build_wild_turn_messages() + self.text_animator.write_fast(output, opp_lines, on_complete=_after_opponent_turn) - if battle.is_trainer_battle: - move = battle.choose_trainer_move() or random.choice(available) - opponent_name = f"{battle.trainer_data['name']}'s {wild['name']}" - else: - move = random.choice(available) - opponent_name = f"Wild {wild['name']}" + self.text_animator.write_fast(output, player_lines, on_complete=_after_player_attack) - output.write("") - output.write(f"[yellow]{opponent_name} used {move['name']}![/yellow]") - for msg in battle.execute_move(wild, player, move["name"]): - output.write(msg) + def execute_wild_pokemon_turn(self, output: RichLog) -> None: + """Execute the opponent Pokemon's turn instantly (no animation).""" + for line in self._build_wild_turn_messages(): + output.write(line) # ── Flee / catch / switch ──────────────────────────────────────────────── @@ -344,6 +374,10 @@ def attempt_catch_pokemon(self, output: RichLog, ball_type: str = "Pokeball") -> output: RichLog widget. ball_type: Type of ball to throw (Pokeball, Great Ball, Ultra Ball, Master Ball). """ + + def _animate_shake(out: RichLog, lines: list, on_complete) -> None: + self.text_animator.write_slow(out, lines, on_complete=on_complete) + battle_actions.attempt_catch_pokemon( self.game_state, output, @@ -352,6 +386,7 @@ def attempt_catch_pokemon(self, output: RichLog, ball_type: str = "Pokeball") -> self.end_battle, self.handle_pokemon_fainted, ball_type=ball_type, + animate_shake_callback=_animate_shake, ) def show_pokemon_switch_menu(self, output: RichLog) -> None: @@ -655,14 +690,6 @@ def _queue_evolution_pending( self.pending_command_data["evolves_into"] = evolved_into self.pending_command_data["evolution_post_action"] = post_action - output.write("") - output.write(f"[bold yellow]✨ What? {pokemon_name} is evolving![/bold yellow]") - output.write("") - output.write("[dim] ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇[/dim]") - output.write("") - output.write(f" [dim]{pokemon_name} → {evolved_into}[/dim]") - output.write("") - try: self.query_one("#evolution-title", Static).update( f"✨ {pokemon_name} → {evolved_into}!" @@ -670,9 +697,24 @@ def _queue_evolution_pending( except Exception: pass - self.hide_all_panels() - self.query_one("#evolution-panel").remove_class("hidden") - self.pending_command = "confirm_evolution" + def show_evolution_panel() -> None: + self.hide_all_panels() + try: + self.query_one("#evolution-panel").remove_class("hidden") + except Exception: + pass + self.pending_command = "confirm_evolution" + + anim_lines = [ + "", + f"[bold yellow]✨ What? {pokemon_name} is evolving![/bold yellow]", + "", + "[dim] ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇ ◇[/dim]", + "", + f" [dim]{pokemon_name} → {evolved_into}[/dim]", + "", + ] + self.text_animator.write_slow(output, anim_lines, on_complete=show_evolution_panel) def _resume_after_evolution(self, output: RichLog) -> None: """Resume battle resolution after the player chose to evolve or stop.""" diff --git a/PokemonLibrary/ui/displays.py b/PokemonLibrary/ui/displays.py index 2d374d5..5f49717 100644 --- a/PokemonLibrary/ui/displays.py +++ b/PokemonLibrary/ui/displays.py @@ -805,6 +805,8 @@ def show_map(game_state: "GameState", output: RichLog) -> None: visited.add("Pewter City") if "cascade_badge" in badges: visited.add("Cerulean City") + if "thunder_badge" in badges: + visited.add("Vermillion City") def _fmt(name: str, label: str) -> str: """Return a Rich-styled label for the given location.""" @@ -842,11 +844,25 @@ def _fmt(name: str, label: str) -> str: # │ # PALLET TOWN # - # VIRIDIAN CITY ─── Route 2 South ─── Viridian Forest ─── Route 2 North - # │ - # PEWTER CITY ─── Route 3 ─── Mt. Moon ─── Route 4 ─── CERULEAN CITY - # │ - # Route 24 + # VIRIDIAN CITY ─── Route 2 South ─── Viridian Forest ─── Route 2 North ─── Diglett's Cave + # │ │ + # PEWTER CITY ─── Route 3 │ + # │ │ + # Mt. Moon │ (shortcut) + # │ │ + # Route 4 │ + # │ │ + # CERULEAN CITY │ + # │ │ │ + # Route 5 Route 9 │ + # │ │ │ + # UG Path Route 10 │ + # │ │ │ + # Route 6 Rock Tunnel + # │ + # VERMILLION CITY ─── Route 11 ─── (Diglett's Cave) + # + # Route 24 (Nugget Bridge, north of Cerulean — locked until Cascade Badge) league = _fmt("Pokemon League Gate", "🏆 Pokémon League") r22 = _fmt("Route 22", "Route 22") @@ -861,27 +877,59 @@ def _fmt(name: str, label: str) -> str: moon = _fmt("Mt. Moon", "Mt. Moon") r4 = _fmt("Route 4", "Route 4") cerulean = _fmt("Cerulean City", "CERULEAN CITY") - r24 = _fmt("Route 24", "Route 24 (Nugget Bridge)") + r24 = _fmt("Route 24", "Route 24 (Nugget Bridge 🔒)") + r9 = _fmt("Route 9", "Route 9") + r10 = _fmt("Route 10", "Route 10") + rock = _fmt("Rock Tunnel", "Rock Tunnel") + r5 = _fmt("Route 5", "Route 5") + ugn = _fmt("Underground Path (North)", "UG Path N") + ugs = _fmt("Underground Path (South)", "UG Path S") + r6 = _fmt("Route 6", "Route 6") + vermillion = _fmt("Vermillion City", "VERMILLION CITY") + r11 = _fmt("Route 11", "Route 11") + diglett = _fmt("Diglett's Cave", "Diglett's Cave") h = "[dim]──[/dim]" # horizontal connector + # ── North block ──────────────────────────────────────────────────────── output.write(f" {league} {h} {r22} {h} {viridian}") output.write(f" {c}") output.write(f" {r1}") output.write(f" {c}") output.write(f" {pallet}") output.write("") - output.write(f" {viridian} {h} {r2s} {h} {virfor} {h} {r2n}") - output.write(f" {c}") + # ── Main east-west spine ──────────────────────────────────────────────── + output.write(f" {viridian} {h} {r2s} {h} {virfor} {h} {r2n} {h} {diglett}") + output.write(f" {c} {c}") output.write( f" {pewter} {h} {r3} {h} {moon} {h} {r4} {h} {cerulean}" ) output.write( - f" {c}" + f" {c} {c}" + ) + output.write( + f" {r5} {r9}" + ) + output.write( + f" {c} {c}" + ) + output.write( + f" {ugn} {r10}" ) output.write( - f" {r24}" + f" {c} {c}" ) + output.write( + f" {ugs} {rock}" + ) + output.write( + f" {c}" + ) + output.write( + f" {vermillion} {h} {r11} {h} {diglett}" + ) + output.write("") + output.write(f" [dim](north of CERULEAN CITY, locked until Cascade Badge → {r24})[/dim]") output.write("") diff --git a/PokemonLibrary/ui/text_animation.py b/PokemonLibrary/ui/text_animation.py index 63fccf8..854a0e5 100644 --- a/PokemonLibrary/ui/text_animation.py +++ b/PokemonLibrary/ui/text_animation.py @@ -21,6 +21,7 @@ def __init__(self, app): """ self.app = app self.current_timer = None + self.last_delay: float = 0.0 def write_lines_with_delay( self, @@ -40,6 +41,8 @@ def write_lines_with_delay( delay: Seconds between lines (0.2=fast, 0.4=medium, 0.8=slow) on_complete: Optional callback to run when animation completes """ + self.last_delay = delay + if self.current_timer: self.current_timer.stop() diff --git a/tests/battle/test_battle_mixin.py b/tests/battle/test_battle_mixin.py index 02b9d1c..951f380 100644 --- a/tests/battle/test_battle_mixin.py +++ b/tests/battle/test_battle_mixin.py @@ -26,6 +26,54 @@ def combined(self) -> str: return " ".join(self.lines) +class MockAnimatedTextWriter: + """Test double for AnimatedTextWriter. + + Writes all lines instantly and calls on_complete synchronously. + Records each call as ``(method_name, delay, lines)`` in ``recorded_calls`` + so tests can verify that non-zero delays were requested. + """ + + def __init__(self): + self.recorded_calls: list = [] # list of (method_name, delay, lines) + + def write_lines_with_delay(self, output, lines, delay=0.4, on_complete=None): + self.recorded_calls.append(("write_lines_with_delay", delay, list(lines))) + for line in lines: + output.write(line) + if on_complete: + on_complete() + + def write_fast(self, output, lines, on_complete=None): + self.recorded_calls.append(("write_fast", 0.2, list(lines))) + for line in lines: + output.write(line) + if on_complete: + on_complete() + + def write_medium(self, output, lines, on_complete=None): + self.recorded_calls.append(("write_medium", 0.4, list(lines))) + for line in lines: + output.write(line) + if on_complete: + on_complete() + + def write_slow(self, output, lines, on_complete=None): + self.recorded_calls.append(("write_slow", 0.8, list(lines))) + for line in lines: + output.write(line) + if on_complete: + on_complete() + + def write_instant(self, output, lines): + self.recorded_calls.append(("write_instant", 0.0, list(lines))) + for line in lines: + output.write(line) + + def cancel(self): + pass + + class MockBattleTerminal(BattleMixin): """Mock terminal for testing BattleMixin methods.""" @@ -35,6 +83,7 @@ def __init__(self): self.pending_command = None self.pending_command_data = {} self._calls = {} + self.text_animator = MockAnimatedTextWriter() # Stubs for Panel methods def hide_all_battle_panels(self): @@ -505,6 +554,7 @@ def __init__(self): self.pending_command = None self.pending_command_data = {} self._calls = {} + self.text_animator = MockAnimatedTextWriter() # Panel stubs def hide_all_battle_panels(self): @@ -1406,3 +1456,321 @@ def test_trainer_next_no_more_pokemon(self, ext_term, ext_output): ext_term._resume_after_evolution(ext_output) assert defeated_called + + +# =========================================================================== +# Battle start animation — verify text_animator is called with delay > 0 +# =========================================================================== + + +class TestBattleStartAnimation: + """Battle encounter start should use text_animator with a non-zero delay.""" + + def test_wild_encounter_start_has_nonzero_delay(self, ext_term, ext_output): + """trigger_wild_encounter should animate the intro with delay > 0.""" + from PokemonLibrary.locations import get_location + + ext_term.game_state.current_location = get_location("Route 1") + bs = BattleState() + p = bs.generate_wild_pokemon("PIKACHU", 10) + ext_term.game_state.game_data["pokemon"] = [p] + + ext_term.trigger_wild_encounter(ext_output) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0, "No animation calls recorded for wild encounter" + delays = [delay for _, delay, _ in recorded] + assert any(d > 0 for d in delays), f"Expected delay > 0, got {delays}" + + def test_trainer_encounter_start_has_nonzero_delay(self, ext_term, ext_output): + """trigger_trainer_encounter should animate the intro with delay > 0.""" + from PokemonLibrary.data.trainer_data import TRAINERS + + bs = BattleState() + p = bs.generate_wild_pokemon("PIKACHU", 10) + ext_term.game_state.game_data["pokemon"] = [p] + trainer = next(iter(TRAINERS.values())) + + ext_term.trigger_trainer_encounter(ext_output, trainer) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0, "No animation calls recorded for trainer encounter" + delays = [delay for _, delay, _ in recorded] + assert any(d > 0 for d in delays), f"Expected delay > 0, got {delays}" + + def test_show_battle_start_wrapper_has_nonzero_delay(self, ext_term, ext_output): + """BattleMixin.show_battle_start() wrapper should use animation.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.show_battle_start(ext_output) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0, "No animation calls recorded for show_battle_start" + delays = [delay for _, delay, _ in recorded] + assert any(d > 0 for d in delays) + + def test_show_trainer_battle_start_wrapper_has_nonzero_delay(self, ext_term, ext_output): + """BattleMixin.show_trainer_battle_start() wrapper should use animation.""" + setup_trainer_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.show_trainer_battle_start(ext_output) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0 + assert any(d > 0 for _, d, _ in recorded) + + def test_animation_lines_contain_wild_pokemon_name(self, ext_term, ext_output): + """Animated lines for wild encounter must mention the Pokemon name.""" + from PokemonLibrary.locations import get_location + + ext_term.game_state.current_location = get_location("Route 1") + bs = BattleState() + p = bs.generate_wild_pokemon("PIKACHU", 10) + ext_term.game_state.game_data["pokemon"] = [p] + + ext_term.trigger_wild_encounter(ext_output) + + all_animated_lines = [] + for _, _, lines in ext_term.text_animator.recorded_calls: + all_animated_lines.extend(lines) + combined = " ".join(all_animated_lines) + assert "appeared" in combined.lower() or "wild" in combined.lower() + + def test_animation_uses_medium_speed(self, ext_term, ext_output): + """Battle encounter start should use write_medium (0.4 s delay).""" + from PokemonLibrary.locations import get_location + + ext_term.game_state.current_location = get_location("Route 1") + bs = BattleState() + p = bs.generate_wild_pokemon("PIKACHU", 10) + ext_term.game_state.game_data["pokemon"] = [p] + + ext_term.trigger_wild_encounter(ext_output) + + methods = [method for method, _, _ in ext_term.text_animator.recorded_calls] + assert "write_medium" in methods, f"Expected write_medium, got {methods}" + + +# =========================================================================== +# MockAnimatedTextWriter self-verification +# =========================================================================== + + +class TestMockAnimatedTextWriter: + """Verify that MockAnimatedTextWriter correctly records delays per method.""" + + def test_records_delay_per_method(self): + writer = MockAnimatedTextWriter() + log = MockRichLog() + + writer.write_fast(log, ["a"]) + writer.write_medium(log, ["b"]) + writer.write_slow(log, ["c"]) + writer.write_instant(log, ["d"]) + + assert writer.recorded_calls[0] == ("write_fast", 0.2, ["a"]) + assert writer.recorded_calls[1] == ("write_medium", 0.4, ["b"]) + assert writer.recorded_calls[2] == ("write_slow", 0.8, ["c"]) + assert writer.recorded_calls[3] == ("write_instant", 0.0, ["d"]) + + def test_writes_lines_to_output(self): + writer = MockAnimatedTextWriter() + log = MockRichLog() + + writer.write_medium(log, ["hello", "world"]) + + assert log.lines == ["hello", "world"] + + def test_calls_on_complete(self): + writer = MockAnimatedTextWriter() + log = MockRichLog() + called = [] + + writer.write_medium(log, ["line"], on_complete=lambda: called.append(True)) + + assert called == [True] + + def test_write_lines_with_delay_records_custom_delay(self): + writer = MockAnimatedTextWriter() + log = MockRichLog() + + writer.write_lines_with_delay(log, ["x"], delay=1.5) + + assert writer.recorded_calls[0] == ("write_lines_with_delay", 1.5, ["x"]) + + +# =========================================================================== +# Fight move animation — verify text_animator fires with delay > 0 +# =========================================================================== + + +class TestFightMoveAnimation: + """execute_player_move should animate both player and opponent attacks.""" + + def test_player_attack_triggers_nonzero_delay(self, ext_term, ext_output): + """Choosing a move should fire text_animator with delay > 0.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.execute_player_move("1", ext_output) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0, "No animation recorded for player attack" + delays = [d for _, d, _ in recorded] + assert any(d > 0 for d in delays), f"Expected delay > 0, got {delays}" + + def test_player_attack_uses_write_fast(self, ext_term, ext_output): + """Player attack animation should use write_fast (0.2 s).""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.execute_player_move("1", ext_output) + + methods = [m for m, _, _ in ext_term.text_animator.recorded_calls] + assert "write_fast" in methods, f"Expected write_fast, got {methods}" + + def test_player_attack_animated_lines_contain_move_name(self, ext_term, ext_output): + """Animated lines should include the move's 'used X!' message.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + player = ext_term.game_state.battle_state.player_pokemon + move_name = player["moves"][0]["name"] + + ext_term.execute_player_move("1", ext_output) + + all_lines = [] + for _, _, lines in ext_term.text_animator.recorded_calls: + all_lines.extend(lines) + combined = " ".join(all_lines) + assert move_name.lower() in combined.lower(), ( + f"Expected '{move_name}' in animated lines: {combined}" + ) + + def test_opponent_attack_triggers_animation(self, ext_term, ext_output): + """Opponent counter-attack should also fire text_animator.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + # Ensure the wild Pokemon has HP so it counter-attacks + bs = ext_term.game_state.battle_state + bs.wild_pokemon["hp"] = 999 + bs.wild_pokemon["max_hp"] = 999 + + ext_term.execute_player_move("1", ext_output) + + # At least 2 write_fast calls expected: player turn + opponent turn + fast_calls = [c for c in ext_term.text_animator.recorded_calls if c[0] == "write_fast"] + assert len(fast_calls) >= 2, ( + f"Expected >=2 write_fast calls (player+opp), got {len(fast_calls)}" + ) + + def test_no_animation_on_back_command(self, ext_term, ext_output): + """Typing 'back' should not trigger any animation.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.execute_player_move("back", ext_output) + + assert ext_term.text_animator.recorded_calls == [], ( + "No animation expected for 'back' command" + ) + + def test_no_animation_on_unknown_move(self, ext_term, ext_output): + """Unknown move name should not trigger animation.""" + setup_wild_ext(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.execute_player_move("ZZZNOMOVEZZZZ", ext_output) + + assert ext_term.text_animator.recorded_calls == [], "No animation expected for unknown move" + + +# =========================================================================== +# Catch animation — verify text_animator fires with delay > 0 +# =========================================================================== + + +class TestCatchAnimation: + """attempt_catch_pokemon should animate the shake sequence with delay > 0.""" + + def _give_pokeballs(self, term, count=10): + term.game_state.game_data.setdefault("items", {})["Pokeball"] = count + + def test_catch_triggers_nonzero_delay(self, ext_term, ext_output): + """Throwing a Pokeball should fire text_animator with delay > 0.""" + setup_wild_ext(ext_term) + self._give_pokeballs(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output) + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0, "No animation recorded for catch attempt" + delays = [d for _, d, _ in recorded] + assert any(d > 0 for d in delays), f"Expected delay > 0, got {delays}" + + def test_catch_uses_write_slow(self, ext_term, ext_output): + """Shake animation should use write_slow (0.8 s) for dramatic effect.""" + setup_wild_ext(ext_term) + self._give_pokeballs(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output) + + methods = [m for m, _, _ in ext_term.text_animator.recorded_calls] + assert "write_slow" in methods, f"Expected write_slow, got {methods}" + + def test_catch_animated_lines_contain_wiggle_dots(self, ext_term, ext_output): + """Shake lines should contain wiggle-dot characters (● or ○).""" + setup_wild_ext(ext_term) + self._give_pokeballs(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output) + + all_lines = [] + for _, _, lines in ext_term.text_animator.recorded_calls: + all_lines.extend(lines) + combined = " ".join(str(line) for line in all_lines) + assert "●" in combined or "○" in combined, ( + f"Expected wiggle dots in animated lines: {combined}" + ) + + def test_no_catch_animation_without_pokeballs(self, ext_term, ext_output): + """No animation should fire when there are no Pokeballs.""" + setup_wild_ext(ext_term) + ext_term.game_state.game_data["items"] = {} + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output) + + assert ext_term.text_animator.recorded_calls == [], ( + "No animation expected when Pokeballs are missing" + ) + + def test_great_ball_also_animated(self, ext_term, ext_output): + """Great Ball throw should also trigger animation with delay > 0.""" + setup_wild_ext(ext_term) + ext_term.game_state.game_data.setdefault("items", {})["Great Ball"] = 5 + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output, ball_type="Great Ball") + + recorded = ext_term.text_animator.recorded_calls + assert len(recorded) > 0 + assert any(d > 0 for _, d, _ in recorded) + + def test_trainer_battle_catch_not_animated(self, ext_term, ext_output): + """Can't catch trainer Pokemon — no animation should fire.""" + setup_trainer_ext(ext_term) + self._give_pokeballs(ext_term) + ext_term.text_animator.recorded_calls.clear() + + ext_term.attempt_catch_pokemon(ext_output) + + assert ext_term.text_animator.recorded_calls == [], ( + "No animation expected for trainer battle catch attempt" + ) diff --git a/tests/game/test_evolution_extra.py b/tests/game/test_evolution_extra.py index ebbe86c..83c3c07 100644 --- a/tests/game/test_evolution_extra.py +++ b/tests/game/test_evolution_extra.py @@ -200,3 +200,45 @@ def test_force_evolve_updates_battle_state(self, gs, output): gs.battle_state.active = True force_evolve(gs, poke, "CHARMELEON", output, update_battle_state=True) assert gs.battle_state.player_pokemon.name == "CHARMELEON" + + +class TestForceEvolveSilentPreamble: + """Tests for force_evolve's silent_preamble parameter.""" + + def _make_charmander_party(self) -> tuple: + gs = GameState() + gs.start_new_game() + poke = make_pokemon("CHARMANDER", 4, 16) + gs.game_data["pokemon"] = [poke] + output = MockRichLog() + return gs, poke, output + + def test_default_shows_preamble(self): + gs, poke, output = self._make_charmander_party() + force_evolve(gs, poke, "CHARMELEON", output) + combined = " ".join(output.lines) + assert "evolving" in combined.lower() + assert "◇" in combined or "◆" in combined + + def test_silent_preamble_skips_diamonds(self): + gs, poke, output = self._make_charmander_party() + force_evolve(gs, poke, "CHARMELEON", output, silent_preamble=True) + combined = " ".join(output.lines) + # Congratulations message still appears + assert "Congratulations" in combined + # But the ◇/◆ animation lines are absent + assert "◇" not in combined + assert "◆" not in combined + + def test_silent_preamble_still_shows_congratulations(self): + gs, poke, output = self._make_charmander_party() + force_evolve(gs, poke, "CHARMELEON", output, silent_preamble=True) + combined = " ".join(output.lines) + assert "CHARMELEON" in combined + assert "Congratulations" in combined + + def test_silent_preamble_still_updates_party(self): + gs, poke, output = self._make_charmander_party() + result = force_evolve(gs, poke, "CHARMELEON", output, silent_preamble=True) + assert result is True + assert gs.game_data["pokemon"][0]["name"] == "CHARMELEON" diff --git a/tests/game/test_exploration.py b/tests/game/test_exploration.py index bf84cfa..b67fbb9 100644 --- a/tests/game/test_exploration.py +++ b/tests/game/test_exploration.py @@ -348,8 +348,10 @@ def test_no_location_returns_empty(self, gs): def test_route_with_progress(self, gs): gs.current_location = get_location("Route 1") - # At progress=2 of required=3, remaining=1 → hint says "almost there" - gs.game_data.setdefault("route_progress", {})["Route 1"] = 2 + # With Route 1 requiring 5 explores: at progress=4 (remaining=1) expect "almost there" + loc = get_location("Route 1") + required = loc.exits["Viridian City"].get("min_explores", 5) + gs.game_data.setdefault("route_progress", {})["Route 1"] = required - 1 result = get_explore_hint(gs) assert "Viridian City" in result assert "almost there" in result @@ -538,3 +540,123 @@ def test_route_24_accessible_in_cheat_mode_without_badge(self, gs, output): called = [] move_to_location(gs, "Route 24", output, lambda o: called.append(True)) assert gs.current_location.name == "Route 24" + + +# =========================================================================== +# Cycling (Bicycle) explore-step tests +# =========================================================================== + + +class TestCyclingExploreStep: + """Bicycle should count each explore as 2 progress steps.""" + + def test_cycling_increments_progress_by_two(self, gs, output): + import random + + gs.current_location = get_location("Route 1") + gs.game_data["cycling"] = True + make_party_pokemon(gs) + gs.game_data["defeated_trainers"] = ["youngster_joey"] # skip trainers + + initial = gs.get_route_progress("Route 1") + # Force no wild encounter so we always hit the "no encounter" branch + original = random.random + try: + random.random = lambda: 0.99 # > encounter rate + explore_area(gs, output, noop, noop) + finally: + random.random = original + + assert gs.get_route_progress("Route 1") == initial + 2 + + def test_walking_increments_progress_by_one(self, gs, output): + import random + + gs.current_location = get_location("Route 1") + gs.game_data["cycling"] = False + make_party_pokemon(gs) + gs.game_data["defeated_trainers"] = ["youngster_joey"] + + initial = gs.get_route_progress("Route 1") + original = random.random + try: + random.random = lambda: 0.99 # no wild encounter + explore_area(gs, output, noop, noop) + finally: + random.random = original + + assert gs.get_route_progress("Route 1") == initial + 1 + + def test_cycling_message_shown_when_no_encounter(self, gs, output): + import random + + gs.current_location = get_location("Route 1") + gs.game_data["cycling"] = True + make_party_pokemon(gs) + gs.game_data["defeated_trainers"] = ["youngster_joey"] + + original = random.random + try: + random.random = lambda: 0.99 + explore_area(gs, output, noop, noop) + finally: + random.random = original + + assert "🚲" in output.combined or "Bicycle" in output.combined + + def test_cycling_on_off_affects_step(self, gs, output): + """Toggling cycling off should revert to single-step increments.""" + import random + + gs.current_location = get_location("Route 1") + make_party_pokemon(gs) + gs.game_data["defeated_trainers"] = ["youngster_joey"] + + original = random.random + try: + random.random = lambda: 0.99 + gs.game_data["cycling"] = True + explore_area(gs, output, noop, noop) + after_cycling = gs.get_route_progress("Route 1") + + gs.game_data["cycling"] = False + explore_area(gs, output, noop, noop) + after_walking = gs.get_route_progress("Route 1") + finally: + random.random = original + + assert after_cycling - 0 == 2 # first explore: +2 + assert after_walking - after_cycling == 1 # second explore: +1 + + +# =========================================================================== +# Updated min_explores values +# =========================================================================== + + +class TestUpdatedMinExplores: + """Verify that explore requirements have been raised above 3-4.""" + + def test_route_1_min_explores_gte_5(self): + loc = get_location("Route 1") + assert loc.exits["Viridian City"]["min_explores"] >= 5 + + def test_viridian_forest_min_explores_gte_6(self): + loc = get_location("Viridian Forest") + assert loc.exits["Route 2 North"]["min_explores"] >= 6 + + def test_mt_moon_min_explores_gte_7(self): + loc = get_location("Mt. Moon") + assert loc.exits["Route 4"]["min_explores"] >= 7 + + def test_route_3_min_explores_gte_5(self): + loc = get_location("Route 3") + assert loc.exits["Mt. Moon"]["min_explores"] >= 5 + + def test_route_9_min_explores_gte_5(self): + loc = get_location("Route 9") + assert loc.exits["Route 10"]["min_explores"] >= 5 + + def test_rock_tunnel_min_explores_gte_8(self): + loc = get_location("Rock Tunnel") + assert loc.exits["Route 10"]["min_explores"] >= 8 diff --git a/tests/game/test_game_state.py b/tests/game/test_game_state.py index 4de93f6..dc1a01c 100644 --- a/tests/game/test_game_state.py +++ b/tests/game/test_game_state.py @@ -240,6 +240,19 @@ def test_different_routes_independent(self, game_state): assert game_state.get_route_progress("Route 1") == 2 assert game_state.get_route_progress("Viridian Forest") == 1 + def test_increment_with_steps_two(self, game_state): + game_state.increment_route_progress("Route 1", steps=2) + assert game_state.get_route_progress("Route 1") == 2 + + def test_increment_with_steps_default_is_one(self, game_state): + game_state.increment_route_progress("Route 1") + assert game_state.get_route_progress("Route 1") == 1 + + def test_increment_steps_accumulates(self, game_state): + game_state.increment_route_progress("Route 1", steps=2) + game_state.increment_route_progress("Route 1", steps=1) + assert game_state.get_route_progress("Route 1") == 3 + class TestGetActivePokemon: """Tests for GameState.get_active_pokemon().""" diff --git a/tests/game/test_locations.py b/tests/game/test_locations.py index bbc3832..d6fa5db 100644 --- a/tests/game/test_locations.py +++ b/tests/game/test_locations.py @@ -208,3 +208,364 @@ def test_cerulean_city_has_route_24_exit(self): loc = get_location("Cerulean City") assert loc is not None assert "Route 24" in loc.exits + + +class TestCeruleanCityUpdatedExits: + """Tests for updated Cerulean City connections.""" + + def test_cerulean_city_has_route_5_exit(self): + loc = get_location("Cerulean City") + assert loc is not None + assert "Route 5" in loc.exits + + def test_cerulean_city_route_5_not_blocked(self): + loc = get_location("Cerulean City") + assert not loc.exits["Route 5"].get("blocked", False) + + def test_cerulean_city_has_route_9_exit(self): + loc = get_location("Cerulean City") + assert "Route 9" in loc.exits + + def test_cerulean_city_route_9_direction_east(self): + loc = get_location("Cerulean City") + assert loc.exits["Route 9"]["direction"] == "east" + + +class TestRoute2NorthDiglettsCave: + """Tests for Diglett's Cave exit from Route 2 North.""" + + def test_route_2_north_has_digletts_cave_exit(self): + loc = get_location("Route 2 North") + assert loc is not None + assert "Diglett's Cave" in loc.exits + + def test_route_2_north_digletts_cave_not_blocked(self): + loc = get_location("Route 2 North") + assert not loc.exits["Diglett's Cave"].get("blocked", False) + + +class TestRoute5: + """Tests for Route 5 location.""" + + def test_route_5_exists(self): + loc = get_location("Route 5") + assert loc is not None + + def test_route_5_is_route_type(self): + loc = get_location("Route 5") + assert loc.type == TYPE_ROUTE + + def test_route_5_can_explore(self): + loc = get_location("Route 5") + assert loc.can_explore() is True + + def test_route_5_has_cerulean_exit(self): + loc = get_location("Route 5") + assert "Cerulean City" in loc.exits + + def test_route_5_has_underground_path_exit(self): + loc = get_location("Route 5") + assert "Underground Path (North)" in loc.exits + + def test_route_5_wild_pokemon(self): + loc = get_location("Route 5") + assert "PIDGEY" in loc.wild_pokemon + assert "MEOWTH" in loc.wild_pokemon + assert "MANKEY" in loc.wild_pokemon + + def test_route_5_wild_level_range(self): + loc = get_location("Route 5") + assert loc.wild_level_range == (13, 17) + + def test_route_5_has_trainers(self): + loc = get_location("Route 5") + assert loc.trainers >= 1 + + +class TestRoute6: + """Tests for Route 6 location.""" + + def test_route_6_exists(self): + loc = get_location("Route 6") + assert loc is not None + + def test_route_6_is_route_type(self): + loc = get_location("Route 6") + assert loc.type == TYPE_ROUTE + + def test_route_6_has_underground_path_exit(self): + loc = get_location("Route 6") + assert "Underground Path (South)" in loc.exits + + def test_route_6_has_vermillion_exit(self): + loc = get_location("Route 6") + assert "Vermillion City" in loc.exits + + def test_route_6_wild_pokemon(self): + loc = get_location("Route 6") + assert "PIDGEY" in loc.wild_pokemon + assert "MEOWTH" in loc.wild_pokemon + assert "MANKEY" in loc.wild_pokemon + + def test_route_6_wild_level_range(self): + loc = get_location("Route 6") + assert loc.wild_level_range == (14, 18) + + +class TestUndergroundPath: + """Tests for the Underground Path locations.""" + + def test_underground_path_north_exists(self): + loc = get_location("Underground Path (North)") + assert loc is not None + + def test_underground_path_south_exists(self): + loc = get_location("Underground Path (South)") + assert loc is not None + + def test_underground_path_north_is_dungeon(self): + from PokemonLibrary.locations import TYPE_DUNGEON + + loc = get_location("Underground Path (North)") + assert loc.type == TYPE_DUNGEON + + def test_underground_path_south_is_dungeon(self): + from PokemonLibrary.locations import TYPE_DUNGEON + + loc = get_location("Underground Path (South)") + assert loc.type == TYPE_DUNGEON + + def test_underground_path_north_connects_to_south(self): + loc = get_location("Underground Path (North)") + assert "Underground Path (South)" in loc.exits + + def test_underground_path_south_connects_to_north(self): + loc = get_location("Underground Path (South)") + assert "Underground Path (North)" in loc.exits + + def test_underground_path_north_connects_to_route_5(self): + loc = get_location("Underground Path (North)") + assert "Route 5" in loc.exits + + def test_underground_path_south_connects_to_route_6(self): + loc = get_location("Underground Path (South)") + assert "Route 6" in loc.exits + + def test_underground_path_no_wild_pokemon(self): + loc_n = get_location("Underground Path (North)") + loc_s = get_location("Underground Path (South)") + assert loc_n.wild_pokemon == [] + assert loc_s.wild_pokemon == [] + + def test_underground_path_no_wild_encounter_rate(self): + loc = get_location("Underground Path (North)") + assert loc.wild_encounter_rate == 0.0 + + +class TestVermillionCity: + """Tests for Vermillion City location.""" + + def test_vermillion_city_exists(self): + loc = get_location("Vermillion City") + assert loc is not None + + def test_vermillion_city_is_town(self): + loc = get_location("Vermillion City") + assert loc.type == TYPE_TOWN + + def test_vermillion_city_has_pokemon_center(self): + loc = get_location("Vermillion City") + assert "Pokemon Center" in loc.buildings + + def test_vermillion_city_has_gym(self): + loc = get_location("Vermillion City") + assert "Gym" in loc.buildings + + def test_vermillion_city_has_route_6_exit(self): + loc = get_location("Vermillion City") + assert "Route 6" in loc.exits + + def test_vermillion_city_has_route_11_exit(self): + loc = get_location("Vermillion City") + assert "Route 11" in loc.exits + + def test_vermillion_city_route_11_direction_east(self): + loc = get_location("Vermillion City") + assert loc.exits["Route 11"]["direction"] == "east" + + +class TestRoute11: + """Tests for Route 11 location.""" + + def test_route_11_exists(self): + loc = get_location("Route 11") + assert loc is not None + + def test_route_11_is_route_type(self): + loc = get_location("Route 11") + assert loc.type == TYPE_ROUTE + + def test_route_11_has_vermillion_exit(self): + loc = get_location("Route 11") + assert "Vermillion City" in loc.exits + + def test_route_11_has_digletts_cave_exit(self): + loc = get_location("Route 11") + assert "Diglett's Cave" in loc.exits + + def test_route_11_wild_pokemon(self): + loc = get_location("Route 11") + assert "EKANS" in loc.wild_pokemon + assert "SPEAROW" in loc.wild_pokemon + assert "DROWZEE" in loc.wild_pokemon + + def test_route_11_wild_level_range(self): + loc = get_location("Route 11") + assert loc.wild_level_range == (13, 19) + + def test_route_11_has_trainers(self): + loc = get_location("Route 11") + assert loc.trainers >= 2 + + +class TestDiglettsCave: + """Tests for Diglett's Cave location.""" + + def test_digletts_cave_exists(self): + loc = get_location("Diglett's Cave") + assert loc is not None + + def test_digletts_cave_is_dungeon(self): + from PokemonLibrary.locations import TYPE_DUNGEON + + loc = get_location("Diglett's Cave") + assert loc.type == TYPE_DUNGEON + + def test_digletts_cave_has_route_2_north_exit(self): + loc = get_location("Diglett's Cave") + assert "Route 2 North" in loc.exits + + def test_digletts_cave_has_route_11_exit(self): + loc = get_location("Diglett's Cave") + assert "Route 11" in loc.exits + + def test_digletts_cave_wild_pokemon(self): + loc = get_location("Diglett's Cave") + assert "DIGLETT" in loc.wild_pokemon + assert "DUGTRIO" in loc.wild_pokemon + + def test_digletts_cave_level_range(self): + loc = get_location("Diglett's Cave") + assert loc.wild_level_range == (15, 22) + + def test_digletts_cave_high_encounter_rate(self): + loc = get_location("Diglett's Cave") + assert loc.wild_encounter_rate >= 0.50 + + def test_digletts_cave_no_trainers(self): + loc = get_location("Diglett's Cave") + assert loc.trainers == 0 + + +class TestRoute9: + """Tests for Route 9 location.""" + + def test_route_9_exists(self): + loc = get_location("Route 9") + assert loc is not None + + def test_route_9_is_route_type(self): + loc = get_location("Route 9") + assert loc.type == TYPE_ROUTE + + def test_route_9_has_cerulean_exit(self): + loc = get_location("Route 9") + assert "Cerulean City" in loc.exits + + def test_route_9_has_route_10_exit(self): + loc = get_location("Route 9") + assert "Route 10" in loc.exits + + def test_route_9_wild_pokemon(self): + loc = get_location("Route 9") + assert "RATTATA" in loc.wild_pokemon + assert "EKANS" in loc.wild_pokemon + assert "SPEAROW" in loc.wild_pokemon + + def test_route_9_wild_level_range(self): + loc = get_location("Route 9") + assert loc.wild_level_range == (15, 21) + + def test_route_9_has_trainers(self): + loc = get_location("Route 9") + assert loc.trainers >= 2 + + +class TestRoute10: + """Tests for Route 10 location.""" + + def test_route_10_exists(self): + loc = get_location("Route 10") + assert loc is not None + + def test_route_10_is_route_type(self): + loc = get_location("Route 10") + assert loc.type == TYPE_ROUTE + + def test_route_10_has_route_9_exit(self): + loc = get_location("Route 10") + assert "Route 9" in loc.exits + + def test_route_10_has_rock_tunnel_exit(self): + loc = get_location("Route 10") + assert "Rock Tunnel" in loc.exits + + def test_route_10_wild_pokemon(self): + loc = get_location("Route 10") + assert "VOLTORB" in loc.wild_pokemon + assert "MAGNEMITE" in loc.wild_pokemon + + def test_route_10_wild_level_range(self): + loc = get_location("Route 10") + assert loc.wild_level_range == (17, 23) + + +class TestRockTunnel: + """Tests for Rock Tunnel location.""" + + def test_rock_tunnel_exists(self): + loc = get_location("Rock Tunnel") + assert loc is not None + + def test_rock_tunnel_is_dungeon(self): + from PokemonLibrary.locations import TYPE_DUNGEON + + loc = get_location("Rock Tunnel") + assert loc.type == TYPE_DUNGEON + + def test_rock_tunnel_has_route_10_exit(self): + loc = get_location("Rock Tunnel") + assert "Route 10" in loc.exits + + def test_rock_tunnel_wild_pokemon(self): + loc = get_location("Rock Tunnel") + assert "ZUBAT" in loc.wild_pokemon + assert "GEODUDE" in loc.wild_pokemon + assert "MACHOP" in loc.wild_pokemon + assert "ONIX" in loc.wild_pokemon + + def test_rock_tunnel_wild_level_range(self): + loc = get_location("Rock Tunnel") + assert loc.wild_level_range == (15, 23) + + def test_rock_tunnel_high_encounter_rate(self): + loc = get_location("Rock Tunnel") + assert loc.wild_encounter_rate >= 0.60 + + def test_rock_tunnel_has_trainers(self): + loc = get_location("Rock Tunnel") + assert loc.trainers >= 3 + + def test_rock_tunnel_can_explore(self): + loc = get_location("Rock Tunnel") + assert loc.can_explore() is True