File size: 4,443 Bytes
bb3fbf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""

State validators that run after every step() call to catch integrity bugs.

"""

from typing import TYPE_CHECKING, List

import numpy as np

if TYPE_CHECKING:
    from engine.game.game_state import GameState

from engine.game.enums import Phase


class StateValidator:
    """Validates game state integrity after each step."""

    @staticmethod
    def validate_post_step(gs: "GameState") -> List[str]:
        """

        Run comprehensive validation after every step() call.

        Returns list of violation messages (empty = valid).

        """
        errors = []

        # V1: No looked_cards without pending choices
        if gs.looked_cards and not gs.pending_choices:
            errors.append(
                f"STALE_LOOKED_CARDS: {len(gs.looked_cards)} cards in looked_cards "
                f"with no pending choice (phase={gs.phase.name})"
            )

        # V2: PlayerState has no dynamic attributes outside __slots__
        for p in gs.players:
            if hasattr(p, "__dict__") and p.__dict__:
                errors.append(
                    f"DYNAMIC_ATTRS: Player {p.player_id} has attrs outside __slots__: {list(p.__dict__.keys())}"
                )

        # V3: Phase-specific action validation
        legal = gs.get_legal_actions()
        phase_errors = StateValidator._validate_phase_actions(gs.phase, legal)
        errors.extend(phase_errors)

        # V4: Game state consistency
        consistency_errors = StateValidator._validate_consistency(gs)
        errors.extend(consistency_errors)

        return errors

    @staticmethod
    def _validate_phase_actions(phase: Phase, legal: np.ndarray) -> List[str]:
        """Verify only appropriate actions are legal for each phase."""
        errors = []
        legal_indices = np.where(legal)[0]

        if phase == Phase.LIVE_SET:
            # Only action 0 (pass) or 400-459 (set live) should be valid
            for i in legal_indices:
                if i != 0 and not (400 <= i <= 459):
                    errors.append(f"ILLEGAL_PHASE_ACTION: Action {i} valid in LIVE_SET")

        elif phase == Phase.MULLIGAN_P1 or phase == Phase.MULLIGAN_P2:
            # Only action 0 (confirm) or 300-359 (toggle mulligan) valid
            for i in legal_indices:
                if i != 0 and not (300 <= i <= 359):
                    errors.append(f"ILLEGAL_PHASE_ACTION: Action {i} valid in MULLIGAN")

        elif phase == Phase.ACTIVE or phase == Phase.ENERGY or phase == Phase.DRAW:
            # Auto-phases should only have action 0
            for i in legal_indices:
                if i != 0:
                    errors.append(f"ILLEGAL_PHASE_ACTION: Action {i} valid in {phase.name}")

        return errors

    @staticmethod
    def _validate_consistency(gs: "GameState") -> List[str]:
        """Validate general state consistency."""
        errors = []

        for p in gs.players:
            # Hand and hand_added_turn should be same length
            if len(p.hand) != len(p.hand_added_turn):
                errors.append(
                    f"HAND_DESYNC: Player {p.player_id} hand={len(p.hand)} vs hand_added_turn={len(p.hand_added_turn)}"
                )

            # live_zone and live_zone_revealed should be same length
            if len(p.live_zone) != len(p.live_zone_revealed):
                errors.append(
                    f"LIVE_ZONE_DESYNC: Player {p.player_id} live_zone={len(p.live_zone)} "
                    f"vs live_zone_revealed={len(p.live_zone_revealed)}"
                )

            # Stage energy should have 3 slots
            if p.stage_energy_count.shape[0] != 3:
                errors.append(f"STAGE_ENERGY_SIZE: Player {p.player_id} has {p.stage_energy_count.shape[0]} slots")

        # Triggered abilities shouldn't persist if no pending choices
        if gs.triggered_abilities and not gs.pending_choices and not gs.pending_effects:
            errors.append(f"STALE_TRIGGERED: {len(gs.triggered_abilities)} triggered abilities with no pending work")

        return errors

    @staticmethod
    def assert_valid(gs: "GameState") -> None:
        """Raise AssertionError if state is invalid."""
        errors = StateValidator.validate_post_step(gs)
        if errors:
            raise AssertionError("State validation failed:\n" + "\n".join(errors))