From 78d31f285623c56aa9527ad9e3ab456c2cad64a5 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 7 Feb 2026 20:35:59 +0100 Subject: [PATCH] Add test data injection script with varied runs and encounters Creates 6 runs across FireRed, Platinum, Emerald, HeartGold, Black, and Crystal with mixed statuses (failed/completed/active), diverse encounter states (caught/fainted/missed/evolved/dead), and varied rule configs. Co-Authored-By: Claude Opus 4.6 --- ...--inject-test-data-into-dev-environment.md | 42 ++- backend/src/app/seeds/inject_test_data.py | 333 ++++++++++++++++++ 2 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 backend/src/app/seeds/inject_test_data.py diff --git a/.beans/nuzlocke-tracker-ycfs--inject-test-data-into-dev-environment.md b/.beans/nuzlocke-tracker-ycfs--inject-test-data-into-dev-environment.md index 047588a..40e6683 100644 --- a/.beans/nuzlocke-tracker-ycfs--inject-test-data-into-dev-environment.md +++ b/.beans/nuzlocke-tracker-ycfs--inject-test-data-into-dev-environment.md @@ -1,22 +1,38 @@ --- # nuzlocke-tracker-ycfs title: Inject test data into dev environment -status: todo +status: completed type: task +priority: normal created_at: 2026-02-06T09:48:38Z -updated_at: 2026-02-06T09:48:38Z +updated_at: 2026-02-07T19:35:11Z --- -Create a mechanism to populate the development database with realistic test data for manual testing and development. +Create a script to populate the development database with realistic test data for manual testing. -## Goals -- Enable developers to quickly set up a populated dev environment -- Provide realistic data scenarios for testing UI components -- Support resetting/refreshing test data easily +## Requirements +- Multiple runs across different games with varied statuses: + - 2 failed runs (different games, progressed partway) + - 2 completed runs (different games, most routes visited) + - 2 active/in-progress runs (different games, early-to-mid progress) +- Each run should have a mix of encounter states: + - Caught (alive): with nicknames, catch levels + - Caught (dead): with faint_level, death_cause + - Fainted (failed to catch) + - Missed (ran away / KOd) +- Some caught Pokemon should be evolved (current_pokemon_id set via the evolutions table) +- Runs should use varied rule configurations (some with pinwheel clause off, some with hardcore mode, etc.) +- Respects the one-encounter-per-group constraint (only one child route per parent group gets an encounter, unless pinwheel zones apply) -## Potential Checklist -- [ ] Define test data requirements (users, playthroughs, encounters, etc.) -- [ ] Create test data fixtures or factory functions -- [ ] Add CLI command or script to inject test data -- [ ] Document how to use the test data injection -- [ ] Ensure test data can be reset/cleared if needed \ No newline at end of file +## Implementation +- Add inject_test_data.py to backend/src/app/seeds/ +- Follows the same async session pattern as run.py +- Queries DB for real game_ids, route_ids, pokemon_ids, and evolution chains so data is always valid +- Invocable via python -m app.seeds.inject_test_data (standalone entry block) +- Clears existing runs+encounters before injecting (idempotent) +- Prints summary of what was created + +## Checklist +- [x] Create inject_test_data.py with test data injection logic +- [x] Wire up as standalone script (python -m app.seeds.inject_test_data) +- [x] Verify it runs cleanly against a seeded database \ No newline at end of file diff --git a/backend/src/app/seeds/inject_test_data.py b/backend/src/app/seeds/inject_test_data.py new file mode 100644 index 0000000..5422188 --- /dev/null +++ b/backend/src/app/seeds/inject_test_data.py @@ -0,0 +1,333 @@ +"""Inject realistic test runs + encounters into the dev database. + +Usage: + python -m app.seeds.inject_test_data +""" + +import asyncio +import random +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import async_session +from app.models.encounter import Encounter +from app.models.evolution import Evolution +from app.models.game import Game +from app.models.nuzlocke_run import NuzlockeRun +from app.models.pokemon import Pokemon +from app.models.route import Route + +random.seed(42) # reproducible data + +# --- Nicknames pool --- +NICKNAMES = [ + "Blaze", "Thunder", "Shadow", "Luna", "Spike", "Rex", "Cinder", "Misty", + "Rocky", "Breeze", "Fang", "Nova", "Scout", "Atlas", "Pepper", "Storm", + "Bandit", "Echo", "Maple", "Titan", "Ziggy", "Bolt", "Rusty", "Pearl", + "Ivy", "Ghost", "Sunny", "Dash", "Ember", "Frost", "Jade", "Onyx", + "Willow", "Tank", "Pip", "Mochi", "Salem", "Patches", "Bean", "Rocket", +] + +DEATH_CAUSES = [ + "Crit from Gym Leader's ace", + "Poison damage after battle", + "Rival battle ambush", + "Self-destruct from wild Geodude", + "Underleveled against Elite Four", + "Double battle gone wrong", + "Missed the switch, one-shot by Hyper Beam", + "Wild Wobbuffet trapped and KO'd", + "Explosion from wild Graveler", + "Unexpected crit from trainer's Pokemon", +] + +# --- Run definitions --- +# Each run specifies a game slug, name, status, rules overrides, and +# how far through the route list it should progress (as a fraction). +RUN_DEFS = [ + # Failed runs + { + "game_slug": "firered", + "name": "Kanto Heartbreak", + "status": "failed", + "progress": 0.45, + "rules": {"hardcoreMode": True, "setModeOnly": True}, + "started_days_ago": 30, + "ended_days_ago": 20, + }, + { + "game_slug": "platinum", + "name": "Sinnoh Disaster", + "status": "failed", + "progress": 0.30, + "rules": {"pinwheelClause": False}, + "started_days_ago": 60, + "ended_days_ago": 45, + }, + # Completed runs + { + "game_slug": "emerald", + "name": "Hoenn Champion Run", + "status": "completed", + "progress": 0.90, + "rules": {"levelCaps": True}, + "started_days_ago": 90, + "ended_days_ago": 70, + }, + { + "game_slug": "heartgold", + "name": "Johto Victory Lap", + "status": "completed", + "progress": 0.85, + "rules": {}, + "started_days_ago": 120, + "ended_days_ago": 95, + }, + # Active runs + { + "game_slug": "black", + "name": "Unova Adventure", + "status": "active", + "progress": 0.35, + "rules": {}, + "started_days_ago": 5, + "ended_days_ago": None, + }, + { + "game_slug": "crystal", + "name": "Crystal Nuzlocke", + "status": "active", + "progress": 0.20, + "rules": {"hardcoreMode": True, "levelCaps": True, "setModeOnly": True}, + "started_days_ago": 2, + "ended_days_ago": None, + }, +] + +# Default rules (matches frontend DEFAULT_RULES) +DEFAULT_RULES = { + "firstEncounterOnly": True, + "permadeath": True, + "nicknameRequired": True, + "duplicatesClause": True, + "shinyClause": True, + "pinwheelClause": True, + "hardcoreMode": False, + "levelCaps": False, + "setModeOnly": False, +} + + +async def get_game_by_slug(session: AsyncSession, slug: str) -> Game | None: + result = await session.execute(select(Game).where(Game.slug == slug)) + return result.scalar_one_or_none() + + +async def get_leaf_routes(session: AsyncSession, game_id: int) -> list[Route]: + """Get routes that can have encounters (no children).""" + # Get all routes for the game + result = await session.execute( + select(Route) + .where(Route.game_id == game_id) + .order_by(Route.order) + ) + all_routes = result.scalars().all() + + parent_ids = {r.parent_route_id for r in all_routes if r.parent_route_id is not None} + leaf_routes = [r for r in all_routes if r.id not in parent_ids] + return leaf_routes + + +async def get_encounterables( + session: AsyncSession, game_id: int +) -> list[int]: + """Get pokemon IDs that appear in route encounters for this game.""" + from app.models.route_encounter import RouteEncounter + + result = await session.execute( + select(RouteEncounter.pokemon_id) + .join(Route, Route.id == RouteEncounter.route_id) + .where(Route.game_id == game_id) + .distinct() + ) + return [row[0] for row in result] + + +async def get_evolution_map(session: AsyncSession) -> dict[int, list[int]]: + """Return {from_pokemon_id: [to_pokemon_id, ...]} for all evolutions.""" + result = await session.execute(select(Evolution.from_pokemon_id, Evolution.to_pokemon_id)) + evo_map: dict[int, list[int]] = {} + for from_id, to_id in result: + evo_map.setdefault(from_id, []).append(to_id) + return evo_map + + +def pick_routes_for_run( + leaf_routes: list[Route], progress: float +) -> list[Route]: + """Pick a subset of leaf routes respecting one-per-group. + + For routes with a parent, only one sibling per parent_route_id is chosen. + """ + count = max(1, int(len(leaf_routes) * progress)) + + # Separate standalone and grouped routes + standalone = [r for r in leaf_routes if r.parent_route_id is None] + by_parent: dict[int, list[Route]] = {} + for r in leaf_routes: + if r.parent_route_id is not None: + by_parent.setdefault(r.parent_route_id, []).append(r) + + # Build a pool: all standalone + one random child per group + pool: list[Route] = list(standalone) + for children in by_parent.values(): + pool.append(random.choice(children)) + + random.shuffle(pool) + # Take routes in order up to the target count + pool.sort(key=lambda r: r.order) + return pool[:count] + + +def generate_encounter( + run_id: int, + route: Route, + pokemon_ids: list[int], + evo_map: dict[int, list[int]], + used_pokemon: set[int], + encounter_index: int, +) -> Encounter: + """Generate a single encounter with realistic varied states.""" + # Pick a pokemon, preferring ones not yet used (duplicates clause flavor) + available = [p for p in pokemon_ids if p not in used_pokemon] + if not available: + available = pokemon_ids + pokemon_id = random.choice(available) + used_pokemon.add(pokemon_id) + + # Decide status with weighted distribution + roll = random.random() + if roll < 0.55: + status = "caught" + elif roll < 0.80: + status = "fainted" + else: + status = "missed" + + nickname = None + catch_level = None + faint_level = None + death_cause = None + current_pokemon_id = None + + if status == "caught": + nickname = NICKNAMES[encounter_index % len(NICKNAMES)] + catch_level = random.randint(5, 55) + + # ~30% of caught pokemon die + if random.random() < 0.30: + faint_level = catch_level + random.randint(1, 15) + death_cause = random.choice(DEATH_CAUSES) + + # ~25% of caught pokemon that are alive evolve + if faint_level is None and random.random() < 0.25: + evos = evo_map.get(pokemon_id, []) + if evos: + current_pokemon_id = random.choice(evos) + + elif status == "fainted": + catch_level = random.randint(5, 45) + + return Encounter( + run_id=run_id, + route_id=route.id, + pokemon_id=pokemon_id, + nickname=nickname, + status=status, + catch_level=catch_level, + faint_level=faint_level, + death_cause=death_cause, + current_pokemon_id=current_pokemon_id, + ) + + +async def inject(): + """Clear existing runs and inject test data.""" + print("Injecting test data...") + + async with async_session() as session: + async with session.begin(): + # Clear existing runs and encounters + await session.execute(delete(Encounter)) + await session.execute(delete(NuzlockeRun)) + print("Cleared existing runs and encounters") + + evo_map = await get_evolution_map(session) + now = datetime.now(timezone.utc) + + total_runs = 0 + total_encounters = 0 + + for run_def in RUN_DEFS: + game = await get_game_by_slug(session, run_def["game_slug"]) + if game is None: + print(f" Warning: game '{run_def['game_slug']}' not found, skipping") + continue + + # Build rules + rules = {**DEFAULT_RULES, **run_def["rules"]} + + # Compute dates + started_at = now - timedelta(days=run_def["started_days_ago"]) + completed_at = None + if run_def["ended_days_ago"] is not None: + completed_at = now - timedelta(days=run_def["ended_days_ago"]) + + run = NuzlockeRun( + game_id=game.id, + name=run_def["name"], + status=run_def["status"], + rules=rules, + started_at=started_at, + completed_at=completed_at, + ) + session.add(run) + await session.flush() # get run.id + + # Get routes and pokemon for this game + leaf_routes = await get_leaf_routes(session, game.id) + pokemon_ids = await get_encounterables(session, game.id) + + if not leaf_routes or not pokemon_ids: + print(f" {run_def['name']}: no routes or pokemon, skipping encounters") + total_runs += 1 + continue + + chosen_routes = pick_routes_for_run(leaf_routes, run_def["progress"]) + used_pokemon: set[int] = set() + + run_encounters = 0 + for i, route in enumerate(chosen_routes): + enc = generate_encounter( + run.id, route, pokemon_ids, evo_map, used_pokemon, i + ) + session.add(enc) + run_encounters += 1 + + total_runs += 1 + total_encounters += run_encounters + + print( + f" {run_def['name']} ({game.name}, {run_def['status']}): " + f"{run_encounters} encounters across {len(chosen_routes)} routes" + ) + + print(f"\nCreated {total_runs} runs with {total_encounters} total encounters") + + print("Test data injection complete!") + + +if __name__ == "__main__": + asyncio.run(inject())