Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 tests passing.
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.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
|