Add CardDefinition validation for required fields (Issue #2)
- Add model_validator to enforce card-type-specific required fields - Pokemon: require hp (positive), stage, pokemon_type - Pokemon Stage 1/2 and VMAX/VSTAR: require evolves_from - Trainer: require trainer_type - Energy: require energy_type (auto-fills energy_provides) - Update all test fixtures to include required fields - Mark Issue #2 as FIXED in SYSTEM_REVIEW.md 765 tests passing
This commit is contained in:
parent
8af326ecee
commit
7fae1c61e8
@ -3,16 +3,16 @@
|
||||
**Date:** 2026-01-25
|
||||
**Updated:** 2026-01-26
|
||||
**Reviewed By:** Multi-agent system review
|
||||
**Status:** 766 tests passing, 94% coverage
|
||||
**Status:** 765 tests passing, 94% coverage
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The core engine has a solid foundation with good separation of concerns, comprehensive documentation, and thorough test coverage. The review identified **8 critical issues** - **4 have been fixed** as part of the Energy/Evolution Stack refactor. The remaining 4 critical issues and several medium-priority gaps still need attention.
|
||||
The core engine has a solid foundation with good separation of concerns, comprehensive documentation, and thorough test coverage. The review identified **8 critical issues** - **5 have been fixed** as part of the Energy/Evolution Stack refactor and CardDefinition validation work. The remaining 3 critical issues and several medium-priority gaps still need attention.
|
||||
|
||||
**Fixed:** #1, #5, #7, #8
|
||||
**Still Open:** #2 (model validation), #3 (end_turn knockouts), #4 (win condition timing), #6 (engine knockout processing)
|
||||
**Fixed:** #1, #2, #5, #7, #8
|
||||
**Still Open:** #3 (end_turn knockouts), #4 (win condition timing), #6 (engine knockout processing)
|
||||
|
||||
---
|
||||
|
||||
@ -36,16 +36,19 @@ for zone_name in ["deck", "hand", "active", "bench", "discard", "prizes", "energ
|
||||
|
||||
---
|
||||
|
||||
### 2. No Validation That Pokemon Cards Have Required Fields
|
||||
### 2. ~~No Validation That Pokemon Cards Have Required Fields~~ FIXED
|
||||
**File:** `app/core/models/card.py:163-231`
|
||||
**Severity:** CRITICAL
|
||||
**Severity:** CRITICAL
|
||||
**Status:** FIXED in commit TBD
|
||||
|
||||
`CardDefinition` accepts Pokemon-specific fields as optional (`hp: int | None`, `stage: PokemonStage | None`), but there's no validation that Pokemon cards actually have these fields set. This could lead to runtime errors.
|
||||
Added a Pydantic `model_validator` to `CardDefinition` that enforces:
|
||||
- **Pokemon cards** require: `hp` (must be positive), `stage`, `pokemon_type`
|
||||
- **Pokemon Stage 1/2** require: `evolves_from`
|
||||
- **Pokemon VMAX/VSTAR** require: `evolves_from`
|
||||
- **Trainer cards** require: `trainer_type`
|
||||
- **Energy cards** require: `energy_type` (auto-fills `energy_provides` if empty)
|
||||
|
||||
**Fix:** Add a Pydantic `model_validator` to ensure:
|
||||
- If `card_type == POKEMON`, then `hp`, `stage`, and `pokemon_type` must be set
|
||||
- If `card_type == TRAINER`, then `trainer_type` must be set
|
||||
- If `card_type == ENERGY`, then `energy_type` must be set
|
||||
This prevents invalid card definitions at construction time rather than runtime errors later.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ Usage:
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.core.models.enums import (
|
||||
CardType,
|
||||
@ -230,6 +230,54 @@ class CardDefinition(BaseModel):
|
||||
set_id: str = ""
|
||||
image_url: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_card_type_fields(self) -> "CardDefinition":
|
||||
"""Validate that required fields are set based on card_type.
|
||||
|
||||
Ensures that:
|
||||
- Pokemon cards have hp, stage, and pokemon_type
|
||||
- Stage 1/2 and VMAX/VSTAR Pokemon have evolves_from
|
||||
- Trainer cards have trainer_type
|
||||
- Energy cards have energy_type (auto-fills energy_provides if empty)
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing for the card type.
|
||||
"""
|
||||
if self.card_type == CardType.POKEMON:
|
||||
if self.hp is None:
|
||||
raise ValueError("Pokemon cards must have 'hp' set")
|
||||
if self.hp <= 0:
|
||||
raise ValueError("Pokemon 'hp' must be positive")
|
||||
if self.stage is None:
|
||||
raise ValueError("Pokemon cards must have 'stage' set")
|
||||
if self.pokemon_type is None:
|
||||
raise ValueError("Pokemon cards must have 'pokemon_type' set")
|
||||
|
||||
# Evolution chain validation
|
||||
if (
|
||||
self.stage in (PokemonStage.STAGE_1, PokemonStage.STAGE_2)
|
||||
and self.evolves_from is None
|
||||
):
|
||||
raise ValueError(f"{self.stage.value} Pokemon must have 'evolves_from' set")
|
||||
if (
|
||||
self.variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR)
|
||||
and self.evolves_from is None
|
||||
):
|
||||
raise ValueError(f"{self.variant.value} Pokemon must have 'evolves_from' set")
|
||||
|
||||
elif self.card_type == CardType.TRAINER:
|
||||
if self.trainer_type is None:
|
||||
raise ValueError("Trainer cards must have 'trainer_type' set")
|
||||
|
||||
elif self.card_type == CardType.ENERGY:
|
||||
if self.energy_type is None:
|
||||
raise ValueError("Energy cards must have 'energy_type' set")
|
||||
# Auto-fill energy_provides if empty
|
||||
if not self.energy_provides:
|
||||
self.energy_provides = [self.energy_type]
|
||||
|
||||
return self
|
||||
|
||||
def is_pokemon(self) -> bool:
|
||||
"""Check if this is a Pokemon card."""
|
||||
return self.card_type == CardType.POKEMON
|
||||
|
||||
@ -414,6 +414,7 @@ def double_colorless_energy_def() -> CardDefinition:
|
||||
id="dce_001",
|
||||
name="Double Colorless Energy",
|
||||
card_type=CardType.ENERGY,
|
||||
energy_type=EnergyType.COLORLESS,
|
||||
energy_provides=[EnergyType.COLORLESS, EnergyType.COLORLESS],
|
||||
rarity="uncommon",
|
||||
set_id="base",
|
||||
|
||||
@ -666,50 +666,24 @@ class TestAttackDamage:
|
||||
assert not result.success
|
||||
assert "must be positive" in result.message
|
||||
|
||||
def test_skips_weakness_when_source_has_no_type(
|
||||
self, game_state: GameState, rng: SeededRandom
|
||||
) -> None:
|
||||
def test_validation_prevents_pokemon_without_type(self) -> None:
|
||||
"""
|
||||
Verify attack_damage skips weakness/resistance when source has no pokemon_type.
|
||||
Verify CardDefinition validation prevents creating Pokemon without pokemon_type.
|
||||
|
||||
Some cards (like certain trainer-summoned tokens) might not have a type.
|
||||
The damage should still be dealt, just without weakness/resistance calculation.
|
||||
This ensures all Pokemon in the game have a type, which is required for
|
||||
proper weakness/resistance calculations. The attack_damage handler still
|
||||
has defensive code for edge cases (e.g., missing registry entries), but
|
||||
CardDefinition validation now prevents typeless Pokemon at creation time.
|
||||
"""
|
||||
# Make source Pokemon have no type
|
||||
game_state.card_registry["pikachu-001"] = CardDefinition(
|
||||
id="pikachu-001",
|
||||
name="Pikachu",
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=60,
|
||||
pokemon_type=None, # No type!
|
||||
)
|
||||
|
||||
# Give target weakness (which won't apply since source has no type)
|
||||
game_state.card_registry["charmander-001"] = CardDefinition(
|
||||
id="charmander-001",
|
||||
name="Charmander",
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=70,
|
||||
pokemon_type=EnergyType.FIRE,
|
||||
weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING),
|
||||
)
|
||||
|
||||
ctx = make_context(
|
||||
game_state,
|
||||
rng,
|
||||
source_card_id="pikachu-inst",
|
||||
params={"amount": 30},
|
||||
)
|
||||
|
||||
result = resolve_effect("attack_damage", ctx)
|
||||
|
||||
assert result.success
|
||||
target = game_state.players["player2"].get_active_pokemon()
|
||||
assert target is not None
|
||||
assert target.damage == 30 # No weakness applied, just base damage
|
||||
assert "weakness" not in result.details # No weakness in details
|
||||
with pytest.raises(ValueError, match="pokemon_type"):
|
||||
CardDefinition(
|
||||
id="typeless-pokemon",
|
||||
name="Typeless",
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=60,
|
||||
pokemon_type=None, # Should fail validation
|
||||
)
|
||||
|
||||
def test_skips_weakness_when_source_definition_missing(
|
||||
self, game_state: GameState, rng: SeededRandom
|
||||
|
||||
@ -63,6 +63,7 @@ def basic_pokemon_def() -> CardDefinition:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
attacks=[
|
||||
Attack(
|
||||
name="Thunder Shock",
|
||||
@ -84,7 +85,8 @@ def strong_pokemon_def() -> CardDefinition:
|
||||
stage=PokemonStage.STAGE_1,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=100,
|
||||
evolves_from="pikachu-001",
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
evolves_from="Pikachu",
|
||||
attacks=[
|
||||
Attack(
|
||||
name="Thunder",
|
||||
@ -1083,6 +1085,7 @@ class TestEvolvePokemonAction:
|
||||
stage=PokemonStage.STAGE_1,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=100,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
evolves_from=basic_pokemon_def.name, # Must match the Pokemon's name
|
||||
attacks=[
|
||||
Attack(
|
||||
@ -1446,6 +1449,7 @@ class TestUseAbilityAction:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
abilities=[
|
||||
Ability(
|
||||
name="Static",
|
||||
|
||||
@ -32,6 +32,7 @@ from app.core.models.enums import (
|
||||
PokemonStage,
|
||||
PokemonVariant,
|
||||
StatusCondition,
|
||||
TrainerType,
|
||||
TurnPhase,
|
||||
)
|
||||
from app.core.models.game_state import GameState, PlayerState
|
||||
@ -123,7 +124,7 @@ def tool_def() -> CardDefinition:
|
||||
id="choice-band-001",
|
||||
name="Choice Band",
|
||||
card_type=CardType.TRAINER,
|
||||
trainer_type="tool",
|
||||
trainer_type=TrainerType.TOOL,
|
||||
effect_id="modify_damage",
|
||||
effect_params={"amount": 30, "condition": "vs_gx_ex"},
|
||||
effect_description="+30 damage against GX/EX",
|
||||
|
||||
@ -442,6 +442,10 @@ class TestCardDefinitionPokemon:
|
||||
]
|
||||
|
||||
for variant, expected_points in variants_and_points:
|
||||
# VMAX and VSTAR require evolves_from
|
||||
evolves_from = (
|
||||
"Pikachu V" if variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR) else None
|
||||
)
|
||||
card = CardDefinition(
|
||||
id=f"test_{variant.value}",
|
||||
name="Test Pokemon",
|
||||
@ -449,6 +453,8 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=variant,
|
||||
hp=100,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
evolves_from=evolves_from,
|
||||
)
|
||||
assert card.knockout_points() == expected_points, f"Failed for {variant}"
|
||||
|
||||
@ -466,6 +472,8 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.VMAX,
|
||||
hp=300,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
evolves_from="Pikachu V",
|
||||
)
|
||||
assert vmax.requires_evolution_from_variant() is True
|
||||
|
||||
@ -477,6 +485,8 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.VSTAR,
|
||||
hp=250,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
evolves_from="Pikachu V",
|
||||
)
|
||||
assert vstar.requires_evolution_from_variant() is True
|
||||
|
||||
@ -494,6 +504,7 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=variant,
|
||||
hp=100,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
assert card.requires_evolution_from_variant() is False, f"Failed for {variant}"
|
||||
|
||||
@ -511,6 +522,7 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=50,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
basic_ex = CardDefinition(
|
||||
id="test",
|
||||
@ -519,6 +531,7 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.EX,
|
||||
hp=150,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
assert basic_normal.is_basic_pokemon() is True
|
||||
assert basic_ex.is_basic_pokemon() is True
|
||||
@ -531,6 +544,8 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.STAGE_1,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=80,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
evolves_from="Basic",
|
||||
)
|
||||
stage1_gx = CardDefinition(
|
||||
id="test",
|
||||
@ -539,6 +554,8 @@ class TestCardDefinitionPokemon:
|
||||
stage=PokemonStage.STAGE_1,
|
||||
variant=PokemonVariant.GX,
|
||||
hp=200,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
evolves_from="Basic",
|
||||
)
|
||||
assert stage1_normal.is_basic_pokemon() is False
|
||||
assert stage1_gx.is_basic_pokemon() is False
|
||||
@ -553,6 +570,7 @@ class TestCardDefinitionPokemon:
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=50,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
assert pokemon.variant == PokemonVariant.NORMAL
|
||||
|
||||
@ -649,6 +667,7 @@ class TestCardDefinitionEnergy:
|
||||
id="rainbow_energy_001",
|
||||
name="Rainbow Energy",
|
||||
card_type=CardType.ENERGY,
|
||||
energy_type=EnergyType.COLORLESS, # Base type for special energy
|
||||
energy_provides=list(EnergyType), # Provides all types
|
||||
effect_id="rainbow_damage",
|
||||
effect_params={"damage_on_attach": 10},
|
||||
@ -761,12 +780,19 @@ class TestCardDefinitionHelpers:
|
||||
def test_is_pokemon(self) -> None:
|
||||
"""Verify is_pokemon() returns correct value."""
|
||||
pokemon = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.POKEMON, stage=PokemonStage.BASIC
|
||||
id="test",
|
||||
name="Test",
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
trainer = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.TRAINER, trainer_type=TrainerType.ITEM
|
||||
)
|
||||
energy = CardDefinition(id="test", name="Test", card_type=CardType.ENERGY)
|
||||
energy = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.ENERGY, energy_type=EnergyType.COLORLESS
|
||||
)
|
||||
|
||||
assert pokemon.is_pokemon() is True
|
||||
assert trainer.is_pokemon() is False
|
||||
@ -775,7 +801,12 @@ class TestCardDefinitionHelpers:
|
||||
def test_is_trainer(self) -> None:
|
||||
"""Verify is_trainer() returns correct value."""
|
||||
pokemon = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.POKEMON, stage=PokemonStage.BASIC
|
||||
id="test",
|
||||
name="Test",
|
||||
card_type=CardType.POKEMON,
|
||||
stage=PokemonStage.BASIC,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.COLORLESS,
|
||||
)
|
||||
trainer = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.TRAINER, trainer_type=TrainerType.ITEM
|
||||
@ -786,7 +817,9 @@ class TestCardDefinitionHelpers:
|
||||
|
||||
def test_is_energy(self) -> None:
|
||||
"""Verify is_energy() returns correct value."""
|
||||
energy = CardDefinition(id="test", name="Test", card_type=CardType.ENERGY)
|
||||
energy = CardDefinition(
|
||||
id="test", name="Test", card_type=CardType.ENERGY, energy_type=EnergyType.COLORLESS
|
||||
)
|
||||
|
||||
assert energy.is_energy() is True
|
||||
|
||||
|
||||
@ -50,6 +50,7 @@ def basic_pokemon_def() -> CardDefinition:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
)
|
||||
|
||||
|
||||
@ -63,6 +64,7 @@ def ex_pokemon_def() -> CardDefinition:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.EX,
|
||||
hp=120,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ from app.core.models.enums import (
|
||||
GameEndReason,
|
||||
PokemonStage,
|
||||
PokemonVariant,
|
||||
TrainerType,
|
||||
TurnPhase,
|
||||
)
|
||||
from app.core.models.game_state import ForcedAction, GameState, PlayerState
|
||||
@ -46,6 +47,7 @@ def pokemon_def() -> CardDefinition:
|
||||
stage=PokemonStage.BASIC,
|
||||
variant=PokemonVariant.NORMAL,
|
||||
hp=60,
|
||||
pokemon_type=EnergyType.LIGHTNING,
|
||||
)
|
||||
|
||||
|
||||
@ -67,6 +69,7 @@ def trainer_def() -> CardDefinition:
|
||||
id="potion-001",
|
||||
name="Potion",
|
||||
card_type=CardType.TRAINER,
|
||||
trainer_type=TrainerType.ITEM,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user