- Implement rules_validator.py with config-driven action validation for all 11 action types - Implement win_conditions.py with point/prize-based, knockout, deck-out, turn limit, and timeout checks - Add ForcedAction model to GameState for blocking actions (e.g., select new active after KO) - Add ActiveConfig with max_active setting for future double-battle support - Add TrainerConfig.stadium_same_name_replace option - Add DeckConfig.starting_hand_size option - Rename from_energy_deck to from_energy_zone for consistency - Fix unreachable code bug in GameState.get_opponent_id() - Add 16 coverage gap tests for edge cases (card registry corruption, forced actions, etc.) - 584 tests passing at 97% coverage Completes HIGH-005, HIGH-006, TEST-009, TEST-010 from PROJECT_PLAN.json
628 lines
20 KiB
Python
628 lines
20 KiB
Python
"""Tests for the RulesConfig and sub-configuration models.
|
|
|
|
These tests verify that:
|
|
1. Default values match Mantimon TCG house rules from GAME_RULES.md
|
|
2. Configuration can be customized via constructor or JSON
|
|
3. Nested configs work correctly
|
|
4. Helper methods function as expected
|
|
"""
|
|
|
|
import json
|
|
|
|
from app.core.config import (
|
|
ActiveConfig,
|
|
BenchConfig,
|
|
DeckConfig,
|
|
EnergyConfig,
|
|
EvolutionConfig,
|
|
FirstTurnConfig,
|
|
PrizeConfig,
|
|
RetreatConfig,
|
|
RulesConfig,
|
|
StatusConfig,
|
|
TrainerConfig,
|
|
WinConditionsConfig,
|
|
)
|
|
from app.core.models.enums import EnergyType, PokemonVariant
|
|
|
|
|
|
class TestDeckConfig:
|
|
"""Tests for DeckConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify DeckConfig defaults match Mantimon TCG house rules.
|
|
|
|
Per GAME_RULES.md: 40-card main deck, separate 20-card energy deck.
|
|
"""
|
|
config = DeckConfig()
|
|
|
|
assert config.min_size == 40
|
|
assert config.max_size == 40
|
|
assert config.exact_size_required is True
|
|
assert config.max_copies_per_card == 4
|
|
assert config.max_copies_basic_energy is None # Unlimited
|
|
assert config.min_basic_pokemon == 1
|
|
assert config.energy_deck_enabled is True
|
|
assert config.energy_deck_size == 20
|
|
assert config.starting_hand_size == 7
|
|
|
|
def test_custom_values(self) -> None:
|
|
"""
|
|
Verify DeckConfig can be customized.
|
|
|
|
This is important for free play mode where users can adjust rules.
|
|
"""
|
|
config = DeckConfig(
|
|
min_size=60,
|
|
max_size=60,
|
|
energy_deck_enabled=False,
|
|
)
|
|
|
|
assert config.min_size == 60
|
|
assert config.max_size == 60
|
|
assert config.energy_deck_enabled is False
|
|
# Other values should still be defaults
|
|
assert config.max_copies_per_card == 4
|
|
|
|
def test_custom_starting_hand_size(self) -> None:
|
|
"""
|
|
Verify starting_hand_size can be customized.
|
|
|
|
Some game variants may use different starting hand sizes.
|
|
"""
|
|
config = DeckConfig(starting_hand_size=5)
|
|
assert config.starting_hand_size == 5
|
|
|
|
def test_starting_hand_size_standard_tcg(self) -> None:
|
|
"""
|
|
Verify starting_hand_size defaults to 7 (standard Pokemon TCG).
|
|
|
|
This is the standard starting hand size across all Pokemon TCG eras.
|
|
"""
|
|
config = DeckConfig()
|
|
assert config.starting_hand_size == 7
|
|
|
|
|
|
class TestActiveConfig:
|
|
"""Tests for ActiveConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify ActiveConfig defaults to standard single-battle (1 active).
|
|
|
|
Standard Pokemon TCG has exactly one active Pokemon per player.
|
|
"""
|
|
config = ActiveConfig()
|
|
assert config.max_active == 1
|
|
|
|
def test_double_battle_config(self) -> None:
|
|
"""
|
|
Verify ActiveConfig can be configured for double battles.
|
|
|
|
Double battle variants allow 2 active Pokemon per player.
|
|
"""
|
|
config = ActiveConfig(max_active=2)
|
|
assert config.max_active == 2
|
|
|
|
def test_triple_battle_config(self) -> None:
|
|
"""
|
|
Verify ActiveConfig supports exotic battle formats.
|
|
|
|
While unusual, the config should support any number of active Pokemon.
|
|
"""
|
|
config = ActiveConfig(max_active=3)
|
|
assert config.max_active == 3
|
|
|
|
|
|
class TestBenchConfig:
|
|
"""Tests for BenchConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify BenchConfig defaults to standard 5 Pokemon bench.
|
|
"""
|
|
config = BenchConfig()
|
|
assert config.max_size == 5
|
|
|
|
def test_custom_bench_size(self) -> None:
|
|
"""
|
|
Verify bench size can be customized for variant rules.
|
|
"""
|
|
config = BenchConfig(max_size=8)
|
|
assert config.max_size == 8
|
|
|
|
|
|
class TestEnergyConfig:
|
|
"""Tests for EnergyConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify EnergyConfig defaults to Pokemon Pocket-style energy system.
|
|
"""
|
|
config = EnergyConfig()
|
|
|
|
assert config.attachments_per_turn == 1
|
|
assert config.auto_flip_from_deck is True
|
|
assert len(config.types_enabled) == 10
|
|
assert EnergyType.FIRE in config.types_enabled
|
|
assert EnergyType.COLORLESS in config.types_enabled
|
|
|
|
def test_all_energy_types_enabled_by_default(self) -> None:
|
|
"""
|
|
Verify all 10 energy types are enabled by default.
|
|
"""
|
|
config = EnergyConfig()
|
|
expected_types = set(EnergyType)
|
|
actual_types = set(config.types_enabled)
|
|
assert actual_types == expected_types
|
|
|
|
def test_restrict_energy_types(self) -> None:
|
|
"""
|
|
Verify energy types can be restricted for themed games.
|
|
"""
|
|
config = EnergyConfig(
|
|
types_enabled=[EnergyType.FIRE, EnergyType.WATER, EnergyType.COLORLESS]
|
|
)
|
|
assert len(config.types_enabled) == 3
|
|
assert EnergyType.FIRE in config.types_enabled
|
|
assert EnergyType.GRASS not in config.types_enabled
|
|
|
|
|
|
class TestPrizeConfig:
|
|
"""Tests for PrizeConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify PrizeConfig defaults match Mantimon TCG house rules.
|
|
|
|
Per GAME_RULES.md: 4 points to win, points instead of prize cards.
|
|
Knockout points are based on variant (EX, V, etc.), not evolution stage.
|
|
"""
|
|
config = PrizeConfig()
|
|
|
|
assert config.count == 4
|
|
assert config.per_knockout_normal == 1
|
|
assert config.per_knockout_ex == 2
|
|
assert config.per_knockout_gx == 2
|
|
assert config.per_knockout_v == 2
|
|
assert config.per_knockout_vmax == 3
|
|
assert config.per_knockout_vstar == 3
|
|
assert config.use_prize_cards is False
|
|
|
|
def test_points_for_knockout_normal(self) -> None:
|
|
"""
|
|
Verify points_for_knockout returns correct value for normal Pokemon.
|
|
|
|
Normal Pokemon (non-variant) are worth 1 point regardless of evolution stage.
|
|
"""
|
|
config = PrizeConfig()
|
|
assert config.points_for_knockout(PokemonVariant.NORMAL) == 1
|
|
|
|
def test_points_for_knockout_ex(self) -> None:
|
|
"""
|
|
Verify points_for_knockout returns 2 for EX Pokemon.
|
|
|
|
EX Pokemon are worth 2 knockout points per GAME_RULES.md.
|
|
"""
|
|
config = PrizeConfig()
|
|
assert config.points_for_knockout(PokemonVariant.EX) == 2
|
|
|
|
def test_points_for_knockout_vmax(self) -> None:
|
|
"""
|
|
Verify points_for_knockout returns 3 for VMAX Pokemon.
|
|
|
|
VMAX are the highest-value Pokemon in the game.
|
|
"""
|
|
config = PrizeConfig()
|
|
assert config.points_for_knockout(PokemonVariant.VMAX) == 3
|
|
|
|
def test_points_for_knockout_all_variants(self) -> None:
|
|
"""
|
|
Verify points_for_knockout works for all Pokemon variants.
|
|
|
|
Knockout points are determined by variant, not evolution stage.
|
|
A Basic EX is worth the same as a Stage 2 EX.
|
|
"""
|
|
config = PrizeConfig()
|
|
|
|
assert config.points_for_knockout(PokemonVariant.NORMAL) == 1
|
|
assert config.points_for_knockout(PokemonVariant.EX) == 2
|
|
assert config.points_for_knockout(PokemonVariant.GX) == 2
|
|
assert config.points_for_knockout(PokemonVariant.V) == 2
|
|
assert config.points_for_knockout(PokemonVariant.VMAX) == 3
|
|
assert config.points_for_knockout(PokemonVariant.VSTAR) == 3
|
|
|
|
def test_custom_knockout_points(self) -> None:
|
|
"""
|
|
Verify knockout point values can be customized.
|
|
"""
|
|
config = PrizeConfig(per_knockout_normal=2, per_knockout_ex=3)
|
|
assert config.points_for_knockout(PokemonVariant.NORMAL) == 2
|
|
assert config.points_for_knockout(PokemonVariant.EX) == 3
|
|
|
|
|
|
class TestFirstTurnConfig:
|
|
"""Tests for FirstTurnConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify FirstTurnConfig defaults match Mantimon TCG house rules.
|
|
|
|
Per GAME_RULES.md:
|
|
- First player CAN draw, attack, and play supporters on turn 1
|
|
- First player CANNOT attach energy or evolve on turn 1
|
|
"""
|
|
config = FirstTurnConfig()
|
|
|
|
assert config.can_draw is True
|
|
assert config.can_attack is True
|
|
assert config.can_play_supporter is True
|
|
assert config.can_attach_energy is False
|
|
assert config.can_evolve is False
|
|
|
|
def test_standard_pokemon_tcg_first_turn(self) -> None:
|
|
"""
|
|
Verify configuration for standard Pokemon TCG first turn rules.
|
|
|
|
In standard rules, first player cannot attack but can attach energy.
|
|
"""
|
|
config = FirstTurnConfig(
|
|
can_attack=False,
|
|
can_play_supporter=False,
|
|
can_attach_energy=True,
|
|
)
|
|
|
|
assert config.can_attack is False
|
|
assert config.can_play_supporter is False
|
|
assert config.can_attach_energy is True
|
|
|
|
|
|
class TestWinConditionsConfig:
|
|
"""Tests for WinConditionsConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify WinConditionsConfig defaults enable all standard win conditions.
|
|
"""
|
|
config = WinConditionsConfig()
|
|
|
|
assert config.all_prizes_taken is True
|
|
assert config.no_pokemon_in_play is True
|
|
assert config.cannot_draw is True
|
|
assert config.turn_limit_enabled is True
|
|
assert config.turn_limit == 30
|
|
assert config.turn_timer_enabled is False
|
|
assert config.turn_timer_seconds == 90
|
|
assert config.game_timer_enabled is False
|
|
assert config.game_timer_minutes == 30
|
|
|
|
def test_turn_limit_config(self) -> None:
|
|
"""
|
|
Verify turn limit settings for AI/single-player matches.
|
|
|
|
Turn limits are useful when real-time timers don't make sense,
|
|
such as playing against AI or in puzzle mode.
|
|
"""
|
|
config = WinConditionsConfig(
|
|
turn_limit_enabled=True,
|
|
turn_limit=20,
|
|
)
|
|
|
|
assert config.turn_limit_enabled is True
|
|
assert config.turn_limit == 20
|
|
|
|
def test_multiplayer_timer_config(self) -> None:
|
|
"""
|
|
Verify timer settings for multiplayer mode.
|
|
"""
|
|
config = WinConditionsConfig(
|
|
turn_timer_enabled=True,
|
|
turn_timer_seconds=120,
|
|
game_timer_enabled=True,
|
|
game_timer_minutes=45,
|
|
)
|
|
|
|
assert config.turn_timer_enabled is True
|
|
assert config.turn_timer_seconds == 120
|
|
assert config.game_timer_enabled is True
|
|
assert config.game_timer_minutes == 45
|
|
|
|
|
|
class TestStatusConfig:
|
|
"""Tests for StatusConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify StatusConfig defaults match GAME_RULES.md.
|
|
|
|
Per documentation:
|
|
- Poison: 10 damage between turns
|
|
- Burn: 20 damage between turns, flip to remove
|
|
- Confusion: 30 self-damage on tails
|
|
"""
|
|
config = StatusConfig()
|
|
|
|
assert config.poison_damage == 10
|
|
assert config.burn_damage == 20
|
|
assert config.burn_flip_to_remove is True
|
|
assert config.sleep_flip_to_wake is True
|
|
assert config.confusion_self_damage == 30
|
|
|
|
|
|
class TestTrainerConfig:
|
|
"""Tests for TrainerConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify TrainerConfig defaults match standard rules.
|
|
|
|
Per GAME_RULES.md:
|
|
- Items: Unlimited per turn
|
|
- Supporters: One per turn
|
|
- Stadiums: One per turn
|
|
- Tools: One per Pokemon
|
|
- Same-name stadium replacement: Blocked (standard rules)
|
|
"""
|
|
config = TrainerConfig()
|
|
|
|
assert config.supporters_per_turn == 1
|
|
assert config.stadiums_per_turn == 1
|
|
assert config.items_per_turn is None # Unlimited
|
|
assert config.tools_per_pokemon == 1
|
|
assert config.stadium_same_name_replace is False
|
|
|
|
def test_stadium_same_name_replace_enabled(self) -> None:
|
|
"""
|
|
Verify stadium_same_name_replace can be enabled for house rules.
|
|
|
|
Some variants may allow replacing a stadium with the same stadium
|
|
(e.g., to refresh its effects or reset counters).
|
|
"""
|
|
config = TrainerConfig(stadium_same_name_replace=True)
|
|
assert config.stadium_same_name_replace is True
|
|
|
|
def test_stadium_same_name_replace_disabled_by_default(self) -> None:
|
|
"""
|
|
Verify stadium_same_name_replace is disabled by default.
|
|
|
|
In standard Pokemon TCG rules, you cannot play a stadium if a
|
|
stadium with the same name is already in play.
|
|
"""
|
|
config = TrainerConfig()
|
|
assert config.stadium_same_name_replace is False
|
|
|
|
|
|
class TestEvolutionConfig:
|
|
"""Tests for EvolutionConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify EvolutionConfig defaults to standard evolution rules.
|
|
|
|
By default, Pokemon cannot evolve:
|
|
- The same turn they were played
|
|
- The same turn they already evolved
|
|
- On the first turn of the game
|
|
"""
|
|
config = EvolutionConfig()
|
|
|
|
assert config.same_turn_as_played is False
|
|
assert config.same_turn_as_evolution is False
|
|
assert config.first_turn_of_game is False
|
|
|
|
|
|
class TestRetreatConfig:
|
|
"""Tests for RetreatConfig."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""
|
|
Verify RetreatConfig defaults to standard retreat rules.
|
|
"""
|
|
config = RetreatConfig()
|
|
|
|
assert config.retreats_per_turn == 1
|
|
assert config.free_retreat_cost is False
|
|
|
|
|
|
class TestRulesConfig:
|
|
"""Tests for the master RulesConfig."""
|
|
|
|
def test_default_instantiation(self) -> None:
|
|
"""
|
|
Verify RulesConfig can be instantiated with all defaults.
|
|
|
|
All nested configs should be created with their own defaults.
|
|
"""
|
|
rules = RulesConfig()
|
|
|
|
assert rules.deck.min_size == 40
|
|
assert rules.active.max_active == 1
|
|
assert rules.bench.max_size == 5
|
|
assert rules.energy.attachments_per_turn == 1
|
|
assert rules.prizes.count == 4
|
|
assert rules.first_turn.can_attack is True
|
|
assert rules.win_conditions.all_prizes_taken is True
|
|
assert rules.status.poison_damage == 10
|
|
assert rules.trainer.supporters_per_turn == 1
|
|
assert rules.trainer.stadium_same_name_replace is False
|
|
assert rules.evolution.same_turn_as_played is False
|
|
assert rules.retreat.retreats_per_turn == 1
|
|
|
|
def test_partial_override(self) -> None:
|
|
"""
|
|
Verify RulesConfig allows partial overrides of nested configs.
|
|
|
|
Only the specified values should change; others remain default.
|
|
"""
|
|
rules = RulesConfig(
|
|
deck=DeckConfig(min_size=60, max_size=60),
|
|
prizes=PrizeConfig(count=6),
|
|
)
|
|
|
|
# Overridden values
|
|
assert rules.deck.min_size == 60
|
|
assert rules.deck.max_size == 60
|
|
assert rules.prizes.count == 6
|
|
|
|
# Default values still in place
|
|
assert rules.deck.max_copies_per_card == 4
|
|
assert rules.bench.max_size == 5
|
|
assert rules.first_turn.can_attack is True
|
|
|
|
def test_standard_pokemon_tcg_preset(self) -> None:
|
|
"""
|
|
Verify standard_pokemon_tcg() returns configuration for official rules.
|
|
|
|
This preset should override Mantimon defaults to match standard Pokemon TCG.
|
|
"""
|
|
rules = RulesConfig.standard_pokemon_tcg()
|
|
|
|
# Standard rules: 60-card deck, no energy deck
|
|
assert rules.deck.min_size == 60
|
|
assert rules.deck.max_size == 60
|
|
assert rules.deck.energy_deck_enabled is False
|
|
|
|
# Standard rules: 6 prizes with prize cards
|
|
assert rules.prizes.count == 6
|
|
assert rules.prizes.use_prize_cards is True
|
|
|
|
# Standard rules: first player cannot attack or play supporter
|
|
assert rules.first_turn.can_attack is False
|
|
assert rules.first_turn.can_play_supporter is False
|
|
assert rules.first_turn.can_attach_energy is True
|
|
|
|
def test_json_round_trip(self) -> None:
|
|
"""
|
|
Verify RulesConfig serializes and deserializes correctly.
|
|
|
|
This is critical for:
|
|
- Saving game configurations to database
|
|
- Sending rules to clients
|
|
- Loading preset rule configurations
|
|
"""
|
|
original = RulesConfig(
|
|
deck=DeckConfig(min_size=50),
|
|
prizes=PrizeConfig(count=5, per_knockout_ex=3),
|
|
)
|
|
|
|
json_str = original.model_dump_json()
|
|
restored = RulesConfig.model_validate_json(json_str)
|
|
|
|
assert restored.deck.min_size == 50
|
|
assert restored.prizes.count == 5
|
|
assert restored.prizes.per_knockout_ex == 3
|
|
# Defaults should be preserved
|
|
assert restored.bench.max_size == 5
|
|
|
|
def test_json_output_format(self) -> None:
|
|
"""
|
|
Verify JSON output uses expected field names for compatibility.
|
|
|
|
Field names in JSON should match the Python attribute names.
|
|
"""
|
|
rules = RulesConfig()
|
|
data = rules.model_dump()
|
|
|
|
# Verify top-level keys
|
|
assert "deck" in data
|
|
assert "active" in data
|
|
assert "bench" in data
|
|
assert "energy" in data
|
|
assert "prizes" in data
|
|
assert "first_turn" in data
|
|
assert "win_conditions" in data
|
|
assert "status" in data
|
|
assert "trainer" in data
|
|
assert "evolution" in data
|
|
assert "retreat" in data
|
|
|
|
# Verify nested keys
|
|
assert "min_size" in data["deck"]
|
|
assert "max_size" in data["deck"]
|
|
assert "max_active" in data["active"]
|
|
assert "attachments_per_turn" in data["energy"]
|
|
assert "stadium_same_name_replace" in data["trainer"]
|
|
|
|
def test_nested_config_independence(self) -> None:
|
|
"""
|
|
Verify nested configs don't share state between RulesConfig instances.
|
|
|
|
Each RulesConfig should have its own copies of nested configs.
|
|
"""
|
|
rules1 = RulesConfig()
|
|
rules2 = RulesConfig()
|
|
|
|
# Modify rules1's nested config
|
|
rules1.deck.min_size = 100
|
|
|
|
# rules2 should be unaffected
|
|
assert rules2.deck.min_size == 40
|
|
|
|
|
|
class TestRulesConfigFromJson:
|
|
"""Tests for loading RulesConfig from JSON strings."""
|
|
|
|
def test_load_minimal_json(self) -> None:
|
|
"""
|
|
Verify RulesConfig can be loaded from minimal JSON.
|
|
|
|
Missing fields should use defaults.
|
|
"""
|
|
json_str = '{"prizes": {"count": 8}}'
|
|
rules = RulesConfig.model_validate_json(json_str)
|
|
|
|
assert rules.prizes.count == 8
|
|
assert rules.deck.min_size == 40 # Default
|
|
|
|
def test_load_full_json(self) -> None:
|
|
"""
|
|
Verify RulesConfig can be loaded from comprehensive JSON.
|
|
|
|
This tests the format documented in GAME_RULES.md.
|
|
"""
|
|
config_dict = {
|
|
"deck": {
|
|
"min_size": 60,
|
|
"max_size": 60,
|
|
"max_copies_per_card": 4,
|
|
"min_basic_pokemon": 1,
|
|
"energy_deck_enabled": False,
|
|
},
|
|
"prizes": {
|
|
"count": 6,
|
|
"per_knockout_basic": 1,
|
|
"per_knockout_ex": 2,
|
|
"use_prize_cards": True,
|
|
},
|
|
"energy": {"attachments_per_turn": 1},
|
|
"first_turn": {
|
|
"can_draw": True,
|
|
"can_attack": False,
|
|
"can_play_supporter": False,
|
|
},
|
|
"win_conditions": {
|
|
"all_prizes_taken": True,
|
|
"no_pokemon_in_play": True,
|
|
"cannot_draw": True,
|
|
},
|
|
}
|
|
json_str = json.dumps(config_dict)
|
|
rules = RulesConfig.model_validate_json(json_str)
|
|
|
|
assert rules.deck.min_size == 60
|
|
assert rules.deck.energy_deck_enabled is False
|
|
assert rules.prizes.count == 6
|
|
assert rules.prizes.use_prize_cards is True
|
|
assert rules.first_turn.can_attack is False
|
|
|
|
def test_empty_json_uses_all_defaults(self) -> None:
|
|
"""
|
|
Verify empty JSON object creates config with all defaults.
|
|
"""
|
|
rules = RulesConfig.model_validate_json("{}")
|
|
|
|
assert rules.deck.min_size == 40
|
|
assert rules.prizes.count == 4
|
|
assert rules.first_turn.can_attack is True
|