Spaces:
Running
Running
| from typing import Any, Dict, List | |
| import numpy as np | |
| from engine.game.state_utils import StateMixin | |
| from engine.models.ability import Condition, ConditionType, EffectType, TargetType, TriggerType | |
| from engine.models.card import MemberCard | |
| class PlayerState(StateMixin): | |
| """ | |
| Player state (Rule 3) | |
| Contains areas, zones, and tracking for a single player. | |
| """ | |
| __slots__ = ( | |
| "player_id", | |
| "hand", | |
| "main_deck", | |
| "energy_deck", | |
| "discard", | |
| "energy_zone", | |
| "success_lives", | |
| "live_zone", | |
| "live_zone_revealed", | |
| "stage", | |
| "stage_energy_vec", | |
| "stage_energy_count", | |
| "tapped_energy", | |
| "tapped_members", | |
| "members_played_this_turn", | |
| "mulligan_selection", | |
| "baton_touch_limit", | |
| "baton_touch_count", | |
| "negate_next_effect", | |
| "restrictions", | |
| "live_score_bonus", | |
| "passed_lives", | |
| "cannot_live", | |
| "used_abilities", | |
| "meta_rules", | |
| "continuous_effects", | |
| "continuous_effects_vec", | |
| "continuous_effects_ptr", | |
| "hand_buffer", | |
| "moved_members_this_turn", | |
| "hand_added_turn", | |
| "deck_refreshed_this_turn", | |
| "performance_abilities_processed", | |
| "rested_members", | |
| "revealed_hand", | |
| "yell_score_count", | |
| "fast_mode", | |
| "members_tapped_by_opponent_this_turn", | |
| "live_cards_set_this_turn", | |
| "live_success_triggered", | |
| ) | |
| def __init__(self, player_id: int): | |
| self.player_id = player_id | |
| self.hand: List[int] = [] | |
| self.hand_added_turn: List[int] = [] | |
| self.main_deck: List[int] = [] | |
| self.energy_deck: List[int] = [] | |
| self.discard: List[int] = [] | |
| self.energy_zone: List[int] = [] | |
| self.success_lives: List[int] = [] | |
| self.live_zone: List[int] = [] | |
| self.live_zone_revealed: List[bool] = [] | |
| self.stage: np.ndarray = np.full(3, -1, dtype=np.int32) | |
| self.stage_energy_vec: np.ndarray = np.zeros((3, 32), dtype=np.int32) | |
| self.stage_energy_count: np.ndarray = np.zeros(3, dtype=np.int32) | |
| self.tapped_energy: np.ndarray = np.zeros(100, dtype=bool) | |
| self.tapped_members: np.ndarray = np.zeros(3, dtype=bool) | |
| self.members_played_this_turn: np.ndarray = np.zeros(3, dtype=bool) | |
| self.mulligan_selection: set = set() | |
| self.baton_touch_limit: int = 1 | |
| self.baton_touch_count: int = 0 | |
| self.negate_next_effect: bool = False | |
| self.restrictions: set[str] = set() | |
| self.live_score_bonus: int = 0 | |
| self.passed_lives: List[int] = [] | |
| self.cannot_live: bool = False | |
| self.used_abilities: set[str] = set() | |
| self.moved_members_this_turn: set[int] = set() | |
| self.continuous_effects: List[Dict[str, Any]] = [] | |
| self.continuous_effects_vec: np.ndarray = np.zeros((32, 10), dtype=np.int32) | |
| self.continuous_effects_ptr: int = 0 | |
| self.meta_rules: set[str] = set() | |
| self.fast_mode: bool = False | |
| self.hand_buffer: np.ndarray = np.zeros(100, dtype=np.int32) | |
| self.deck_refreshed_this_turn: bool = False | |
| self.performance_abilities_processed: bool = False | |
| self.rested_members: np.ndarray = np.zeros(3, dtype=bool) | |
| self.revealed_hand: bool = False | |
| self.yell_score_count: int = 0 | |
| self.live_cards_set_this_turn: int = 0 | |
| self.live_success_triggered: bool = False | |
| self.members_tapped_by_opponent_this_turn: set[int] = set() | |
| def score(self) -> int: | |
| """ | |
| Game Score (Rule 1.2 / 8.4.7) | |
| - This is the number of cards in the success_lives zone. | |
| - Points are obtained during Rule 8.4 Live Judgment phase. | |
| - Only 1 success live card can be added per judgment turn. | |
| """ | |
| return len(self.success_lives) | |
| def energy_count(self) -> int: | |
| return len(self.energy_zone) | |
| def energy_count(self, value: int): | |
| current = len(self.energy_zone) | |
| if value < current: | |
| # Assume cost payment: Move from Energy Zone to Discard | |
| diff = current - value | |
| for _ in range(diff): | |
| if self.energy_zone: | |
| card_id = self.energy_zone.pop() | |
| self.discard.append(card_id) | |
| elif value > current: | |
| # Cannot magically add empty energy without cards | |
| pass | |
| def stage_energy(self) -> List[List[int]]: | |
| """Legacy compatibility property. Returns a copy of the energy state.""" | |
| res = [] | |
| for i in range(3): | |
| count = self.stage_energy_count[i] | |
| res.append(list(self.stage_energy_vec[i, :count])) | |
| return res | |
| def add_stage_energy(self, slot_idx: int, card_id: int) -> None: | |
| """Add energy to a slot using flat arrays.""" | |
| count = self.stage_energy_count[slot_idx] | |
| if count < 32: | |
| self.stage_energy_vec[slot_idx, count] = card_id | |
| self.stage_energy_count[slot_idx] = count + 1 | |
| def clear_stage_energy(self, slot_idx: int) -> None: | |
| """Clear energy from a slot.""" | |
| self.stage_energy_count[slot_idx] = 0 | |
| def _reset(self, player_id: int) -> None: | |
| """Reset state for pool reuse.""" | |
| self.player_id = player_id | |
| self.hand.clear() | |
| self.main_deck.clear() | |
| self.energy_deck.clear() | |
| self.discard.clear() | |
| self.energy_zone.clear() | |
| self.success_lives.clear() | |
| self.live_zone.clear() | |
| self.live_zone_revealed.clear() | |
| self.stage.fill(-1) | |
| self.stage_energy_vec.fill(0) | |
| self.stage_energy_count.fill(0) | |
| self.tapped_energy.fill(False) | |
| self.tapped_members.fill(False) | |
| self.members_played_this_turn.fill(False) | |
| self.mulligan_selection.clear() | |
| self.baton_touch_limit = 1 | |
| self.baton_touch_count = 0 | |
| self.negate_next_effect = False | |
| self.restrictions.clear() | |
| self.live_score_bonus = 0 | |
| self.passed_lives.clear() | |
| self.cannot_live = False | |
| self.used_abilities.clear() | |
| self.continuous_effects.clear() | |
| self.continuous_effects_vec.fill(0) | |
| self.continuous_effects_ptr = 0 | |
| self.meta_rules.clear() | |
| self.hand_added_turn.clear() | |
| self.deck_refreshed_this_turn = False | |
| self.performance_abilities_processed = False | |
| self.rested_members.fill(False) | |
| self.revealed_hand = False | |
| self.revealed_hand = False | |
| self.moved_members_this_turn.clear() | |
| self.members_tapped_by_opponent_this_turn.clear() | |
| self.live_cards_set_this_turn = 0 | |
| self.live_success_triggered = False | |
| def copy_slots_to(self, target: "PlayerState") -> None: | |
| """Hardcoded field copy for maximum performance.""" | |
| target.player_id = self.player_id | |
| target.hand = self.hand[:] | |
| target.hand_added_turn = self.hand_added_turn[:] | |
| target.main_deck = self.main_deck[:] | |
| target.energy_deck = self.energy_deck[:] | |
| target.discard = self.discard[:] | |
| target.energy_zone = self.energy_zone[:] | |
| target.success_lives = self.success_lives[:] | |
| target.live_zone = self.live_zone[:] | |
| target.live_zone_revealed = self.live_zone_revealed[:] | |
| target.baton_touch_limit = self.baton_touch_limit | |
| target.baton_touch_count = self.baton_touch_count | |
| target.negate_next_effect = self.negate_next_effect | |
| target.live_score_bonus = self.live_score_bonus | |
| target.cannot_live = self.cannot_live | |
| target.deck_refreshed_this_turn = self.deck_refreshed_this_turn | |
| target.performance_abilities_processed = self.performance_abilities_processed | |
| target.revealed_hand = self.revealed_hand | |
| target.continuous_effects_ptr = self.continuous_effects_ptr | |
| target.live_cards_set_this_turn = self.live_cards_set_this_turn | |
| target.live_success_triggered = self.live_success_triggered | |
| def copy(self) -> "PlayerState": | |
| new = PlayerState(self.player_id) | |
| self.copy_to(new) | |
| return new | |
| def copy_to(self, new: "PlayerState") -> None: | |
| # 1. Scalar/List fields | |
| self.copy_slots_to(new) | |
| # 2. NumPy arrays (memcpy speed) | |
| np.copyto(new.stage, self.stage) | |
| np.copyto(new.stage_energy_vec, self.stage_energy_vec) | |
| np.copyto(new.stage_energy_count, self.stage_energy_count) | |
| np.copyto(new.tapped_energy, self.tapped_energy) | |
| np.copyto(new.tapped_members, self.tapped_members) | |
| np.copyto(new.rested_members, self.rested_members) | |
| np.copyto(new.continuous_effects_vec, self.continuous_effects_vec) | |
| # 3. Sets and complex structures (slowest) | |
| np.copyto(new.members_played_this_turn, self.members_played_this_turn) | |
| new.used_abilities = set(self.used_abilities) | |
| new.restrictions = set(self.restrictions) | |
| new.mulligan_selection = set(self.mulligan_selection) | |
| new.meta_rules = set(self.meta_rules) | |
| new.meta_rules = set(self.meta_rules) | |
| new.moved_members_this_turn = set(self.moved_members_this_turn) | |
| new.members_tapped_by_opponent_this_turn = set(self.members_tapped_by_opponent_this_turn) | |
| new.passed_lives = list(self.passed_lives) | |
| # Legacy continuous_effects (only copy if needed or for AI skip) | |
| if hasattr(self, "fast_mode") and self.fast_mode: | |
| new.continuous_effects = [] | |
| else: | |
| new.continuous_effects = [dict(e) for e in self.continuous_effects] | |
| def untap_all(self) -> None: | |
| self.tapped_energy[:] = False | |
| self.tapped_members[:] = False | |
| self.live_cards_set_this_turn = 0 | |
| def count_untapped_energy(self) -> int: | |
| return int(np.count_nonzero(~self.tapped_energy[: len(self.energy_zone)])) | |
| return breakdown | |
| def get_blades_breakdown(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> List[Dict[str, Any]]: | |
| """Calculate blades breakdown for a slot (Rule 9.9).""" | |
| card_id = self.stage[slot_idx] | |
| if card_id < 0: | |
| return [{"source": f"Slot {slot_idx + 1}", "value": 0, "type": "empty", "source_id": -1}] | |
| # Check if member is tapped (inactive) | |
| if self.tapped_members[slot_idx]: | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(int(card_id)) | |
| name = card_db[base_id].name if base_id in card_db else "Unknown" | |
| return [{"source": f"{name} (Resting)", "value": 0, "type": "inactive", "source_id": int(card_id)}] | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(int(card_id)) | |
| if base_id not in card_db: | |
| return [{"source": "Unknown Card", "value": 0, "type": "error"}] | |
| member = card_db[base_id] | |
| breakdown = [{"source": member.name, "value": int(member.blades), "type": "base", "source_id": int(card_id)}] | |
| # Collect effects | |
| applied_effects = [] # List of (source_name, effect) | |
| for ce in self.continuous_effects: | |
| # ONLY include effects targeting this specific slot. | |
| # Global effects (target_slot == -1) are handled at the Player level to avoid overcounting. | |
| if ce.get("target_slot") == slot_idx: | |
| src = ce.get("source_name", "Effect") | |
| if "condition_text" in ce: | |
| src += f" ({ce['condition_text']})" | |
| applied_effects.append((src, ce["effect"])) | |
| for ab in member.abilities: | |
| if ab.trigger == TriggerType.CONSTANT: | |
| if all(self._check_condition_for_constant(ab_cond, slot_idx, card_db) for ab_cond in ab.conditions): | |
| for eff in ab.effects: | |
| # Construct a helpful source string | |
| src = member.name | |
| if ab.conditions: | |
| cond_texts = [] | |
| for c in ab.conditions: | |
| if c.type == ConditionType.TURN_1: | |
| cond_texts.append("Turn 1") | |
| elif c.type == ConditionType.COUNT_STAGE: | |
| cond_texts.append(f"Stage {c.params.get('value', 0)}+") | |
| elif c.type == ConditionType.COUNT_HAND: | |
| cond_texts.append(f"Hand {c.params.get('value', 0)}+") | |
| elif c.type == ConditionType.LIFE_LEAD: | |
| cond_texts.append("Life Lead") | |
| else: | |
| cond_texts.append("Cond") | |
| src += f" ({', '.join(cond_texts)})" | |
| else: | |
| src += " (Constant)" | |
| applied_effects.append((src, eff)) | |
| # Layer 4: SET | |
| for source, eff in applied_effects: | |
| if eff.effect_type == EffectType.SET_BLADES: | |
| breakdown = [ | |
| {"source": source, "value": int(eff.value), "type": "set", "source_id": ce.get("source_id", -1)} | |
| ] | |
| # Layer 4: ADD / BUFF | |
| for source, eff in applied_effects: | |
| if eff.effect_type in (EffectType.ADD_BLADES, EffectType.BUFF_POWER): | |
| val = eff.value | |
| val_desc = "" | |
| if eff.params.get("multiplier"): | |
| if eff.params.get("per_live"): | |
| val *= len(self.success_lives) | |
| val_desc = f" ({len(self.success_lives)} Lives)" | |
| elif eff.params.get("per_energy"): | |
| val *= len(self.energy_zone) | |
| val_desc = f" ({len(self.energy_zone)} Energy)" | |
| elif eff.params.get("per_member"): | |
| val *= np.sum(self.stage >= 0) | |
| val_desc = f" ({np.sum(self.stage >= 0)} Members)" | |
| final_source = source + val_desc | |
| breakdown.append( | |
| { | |
| "source": final_source, | |
| "value": int(val), | |
| "type": "mod", | |
| "source_id": ce.get("source_id", -1) if "ce" in locals() else int(card_id), | |
| } | |
| ) | |
| return breakdown | |
| def get_global_blades_breakdown(self) -> List[Dict[str, Any]]: | |
| """Calculate breakdown for global (player-wide) blade effects.""" | |
| breakdown = [] | |
| applied_effects = [] | |
| for ce in self.continuous_effects: | |
| if ce.get("target_slot") == -1: | |
| src = ce.get("source_name", "Effect") | |
| if "condition_text" in ce: | |
| src += f" ({ce['condition_text']})" | |
| applied_effects.append((ce, src, ce["effect"])) | |
| for ce, source, eff in applied_effects: | |
| if eff.effect_type in (EffectType.ADD_BLADES, EffectType.BUFF_POWER): | |
| val = eff.value | |
| val_desc = "" | |
| if eff.params.get("multiplier"): | |
| if eff.params.get("per_live"): | |
| val *= len(self.success_lives) | |
| val_desc = f" ({len(self.success_lives)} Lives)" | |
| elif eff.params.get("per_energy"): | |
| val *= len(self.energy_zone) | |
| val_desc = f" ({len(self.energy_zone)} Energy)" | |
| elif eff.params.get("per_member"): | |
| val *= np.sum(self.stage >= 0) | |
| val_desc = f" ({np.sum(self.stage >= 0)} Members)" | |
| final_source = source + val_desc | |
| breakdown.append( | |
| { | |
| "source": final_source, | |
| "value": int(val), | |
| "type": "mod", | |
| "source_id": ce.get("source_id", -1), | |
| } | |
| ) | |
| return breakdown | |
| def get_effective_blades(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> int: | |
| breakdown = self.get_blades_breakdown(slot_idx, card_db) | |
| total = sum(item["value"] for item in breakdown) | |
| return max(0, total) | |
| def _check_condition_for_constant( | |
| self, cond: Condition, slot_idx: int, card_db: Dict[int, MemberCard] = None | |
| ) -> bool: | |
| """ | |
| Check if a condition is met for a constant ability. | |
| slot_idx < 0 implies the card is not on stage (e.g. in hand for cost reduction). | |
| """ | |
| if cond.type == ConditionType.NONE: | |
| return True | |
| # Conditions that require being on stage | |
| if slot_idx < 0: | |
| if cond.type in (ConditionType.HAS_MOVED, ConditionType.IS_CENTER, ConditionType.GROUP_FILTER): | |
| # For GROUP_FILTER, if it's checking SELF, we might need the card ID context which is not passed here properly. | |
| # But for cost reduction, usually it's just Hand/Stage counts. | |
| return False | |
| if cond.type == ConditionType.HAS_MOVED: | |
| # Check if this card moved this turn. | |
| current_card_id = self.stage[slot_idx] | |
| if current_card_id >= 0: | |
| return current_card_id in self.moved_members_this_turn | |
| return False | |
| elif cond.type == ConditionType.TURN_1: | |
| # This would require access to game state to check turn number | |
| # For now, return True as a placeholder | |
| return True | |
| elif cond.type == ConditionType.IS_CENTER: | |
| # Check if the slot is the center position (index 1 in 3-slot system) | |
| return slot_idx == 1 | |
| elif cond.type == ConditionType.GROUP_FILTER: | |
| # Check if the member belongs to the specified group | |
| current_card_id = self.stage[slot_idx] | |
| if current_card_id >= 0 and card_db: | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(int(current_card_id)) | |
| if base_id in card_db: | |
| member = card_db[base_id] | |
| group_name = cond.params.get("group", "") | |
| # This would need to compare member's group with the condition's group | |
| # For now, return True as a placeholder | |
| return True | |
| return False | |
| elif cond.type == ConditionType.COUNT_GROUP: | |
| # Count members of a specific group in the stage | |
| group_name = cond.params.get("group", "") | |
| min_count = cond.params.get("min", 1) | |
| zone = cond.params.get("zone", "STAGE") | |
| count = 0 | |
| if zone == "STAGE" or zone == "OPPONENT_STAGE": | |
| from engine.game.state_utils import get_base_id | |
| for i in range(3): | |
| card_id = self.stage[i] | |
| if card_id >= 0 and card_db: | |
| base_id = get_base_id(int(card_id)) | |
| if base_id in card_db: | |
| member = card_db[base_id] | |
| # Compare member's group with the condition's group | |
| # For now, return True as a placeholder | |
| count += 1 | |
| return count >= min_count | |
| elif cond.type == ConditionType.OPPONENT_HAS: | |
| # Placeholder for opponent has condition | |
| return True | |
| elif cond.type == ConditionType.COUNT_ENERGY: | |
| min_energy = cond.params.get("min", 1) | |
| return len(self.energy_zone) >= min_energy | |
| else: | |
| # Default lenient for other conditions | |
| return True | |
| def get_hearts_breakdown(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> List[Dict[str, Any]]: | |
| """Calculate hearts breakdown for a slot, including continuous effects.""" | |
| card_id = self.stage[slot_idx] | |
| if card_id < 0: | |
| return [{"source": f"Slot {slot_idx + 1}", "value": [0] * 7, "type": "empty", "source_id": -1}] | |
| # Check if member is tapped (inactive) | |
| if self.tapped_members[slot_idx]: | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(int(card_id)) | |
| name = card_db[base_id].name if base_id in card_db else "Unknown" | |
| return [{"source": f"{name} (Resting)", "value": [0] * 7, "type": "inactive", "source_id": int(card_id)}] | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(int(card_id)) | |
| if base_id not in card_db: | |
| return [{"source": "Unknown Card", "value": [0] * 7, "type": "error"}] | |
| member = card_db[base_id] | |
| # Ensure base hearts are 7-dim | |
| base_hearts = np.zeros(7, dtype=np.int32) | |
| base_hearts[: len(member.hearts)] = member.hearts | |
| breakdown = [{"source": member.name, "value": base_hearts.tolist(), "type": "base", "source_id": int(card_id)}] | |
| # Collect effects | |
| applied_effects = [] | |
| for ce in self.continuous_effects: | |
| if ce.get("target_slot") in (-1, slot_idx): | |
| src = ce.get("source_name", "Effect") | |
| if "condition_text" in ce: | |
| src += f" ({ce['condition_text']})" | |
| applied_effects.append((src, ce["effect"])) | |
| for ab in member.abilities: | |
| if ab.trigger == TriggerType.CONSTANT: | |
| if all(self._check_condition_for_constant(ab_cond, slot_idx, card_db) for ab_cond in ab.conditions): | |
| for eff in ab.effects: | |
| # Construct a helpful source string | |
| src = member.name | |
| if ab.conditions: | |
| cond_texts = [] | |
| for c in ab.conditions: | |
| if c.type == ConditionType.TURN_1: | |
| cond_texts.append("Turn 1") | |
| elif c.type == ConditionType.COUNT_STAGE: | |
| cond_texts.append(f"Stage {c.params.get('value', 0)}+") | |
| elif c.type == ConditionType.COUNT_HAND: | |
| cond_texts.append(f"Hand {c.params.get('value', 0)}+") | |
| elif c.type == ConditionType.LIFE_LEAD: | |
| cond_texts.append("Life Lead") | |
| elif c.type == ConditionType.HAS_MEMBER: | |
| cond_texts.append("Has Member") | |
| elif c.type == ConditionType.HAS_COLOR: | |
| cond_texts.append("Has Color") | |
| else: | |
| cond_texts.append("Cond") | |
| src += f" ({', '.join(cond_texts)})" | |
| else: | |
| src += " (Constant)" | |
| applied_effects.append((src, eff)) | |
| # Apply Heart Modifications | |
| for source, eff in applied_effects: | |
| eff_val = np.zeros(7, dtype=np.int32) | |
| if eff.effect_type == EffectType.SET_HEARTS: | |
| breakdown = [ | |
| { | |
| "source": source, | |
| "value": [int(eff.value)] * 7, | |
| "type": "set", | |
| "source_id": ce.get("source_id", -1), | |
| } | |
| ] | |
| # Reset others? For SET, usually yes. | |
| continue | |
| if eff.effect_type == EffectType.ADD_HEARTS: | |
| color_map = {1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5} # P,R,Y,G,B,P | |
| target_colors = [] | |
| if eff.params.get("color"): | |
| c = eff.params["color"] | |
| if c in color_map: | |
| target_colors.append(color_map[c]) | |
| elif eff.params.get("all"): | |
| target_colors = list(range(6)) | |
| amount = eff.value | |
| # Multipliers | |
| if eff.params.get("multiplier"): | |
| if eff.params.get("per_live"): | |
| amount *= len(self.success_lives) | |
| elif eff.params.get("per_energy"): | |
| amount *= len(self.energy_zone) | |
| for c_idx in target_colors: | |
| eff_val[c_idx] += amount | |
| if not target_colors: | |
| pass | |
| # Only append if non-zero | |
| if np.any(eff_val): | |
| breakdown.append( | |
| { | |
| "source": source, | |
| "value": eff_val.tolist(), | |
| "type": "mod", | |
| "source_id": ce.get("source_id", -1) if "ce" in locals() else int(card_id), | |
| } | |
| ) | |
| return breakdown | |
| def get_effective_hearts(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> np.ndarray: | |
| breakdown = self.get_hearts_breakdown(slot_idx, card_db) | |
| total = np.zeros(7, dtype=np.int32) | |
| for item in breakdown: | |
| total += np.array(item["value"], dtype=np.int32) | |
| return np.maximum(0, total) | |
| def get_total_blades(self, card_db: Dict[int, MemberCard]) -> int: | |
| total = 0 | |
| # 1. Base + Slot-specific modifiers | |
| for i, card_id in enumerate(self.stage): | |
| if card_id >= 0 and not self.tapped_members[i]: | |
| total += self.get_effective_blades(i, card_db) | |
| # 2. Global modifiers | |
| global_mods = self.get_global_blades_breakdown() | |
| for mod in global_mods: | |
| total += mod["value"] | |
| return max(0, total) | |
| def get_total_hearts(self, card_db: Dict[int, Any]) -> np.ndarray: | |
| total = np.zeros(7, dtype=np.int32) | |
| for i, card_id in enumerate(self.stage): | |
| if card_id >= 0 and not self.tapped_members[i]: | |
| total += self.get_effective_hearts(i, card_db) | |
| return total | |
| def get_performance_guide(self, live_db: Dict[int, Any], member_db: Dict[int, Any]) -> Dict[str, Any]: | |
| """ | |
| Calculate projected performance outcome for the user guide. | |
| Now comprehensive: includes breakdown for all slots (active, resting, empty) | |
| and requirement modifications. | |
| """ | |
| if not self.live_zone: | |
| return {"can_perform": False, "reason": "No live cards"} | |
| from engine.game.state_utils import get_base_id | |
| # 1. Total Blades & Blade Breakdown | |
| total_blades = 0 | |
| blade_breakdown = [] | |
| # Always iterate 0-2 to show all slots | |
| for i in range(3): | |
| # Breakdown method handles empty/inactive cases now | |
| bd = self.get_blades_breakdown(i, member_db) | |
| blade_breakdown.extend(bd) | |
| if self.stage[i] >= 0 and not self.tapped_members[i]: | |
| # Sum up effective blades from breakdown for Active members | |
| slot_total = sum(item["value"] for item in bd if item.get("type") in ("base", "mod", "set")) | |
| total_blades += max(0, slot_total) | |
| # Apply cheer_mod | |
| extra_reveals = sum( | |
| ce["effect"].value | |
| for ce in self.continuous_effects | |
| if ce["effect"].effect_type == EffectType.META_RULE and ce["effect"].params.get("type") == "cheer_mod" | |
| ) | |
| total_blades = max(0, total_blades + extra_reveals) | |
| # 2. Total Hearts & Heart Breakdown | |
| total_hearts = np.zeros(7, dtype=np.int32) | |
| heart_breakdown = [] | |
| for i in range(3): | |
| bd = self.get_hearts_breakdown(i, member_db) | |
| heart_breakdown.extend(bd) | |
| if self.stage[i] >= 0 and not self.tapped_members[i]: | |
| # Sum up effective hearts from breakdown for Active members | |
| for item in bd: | |
| if item.get("type") in ("base", "mod", "set"): | |
| total_hearts += np.array(item["value"], dtype=np.int32) | |
| # 3. Apply TRANSFORM_COLOR (Global) | |
| transform_log = [] | |
| for ce in self.continuous_effects: | |
| if ce["effect"].effect_type == EffectType.TRANSFORM_COLOR: | |
| eff = ce["effect"] | |
| src_color = eff.params.get("from_color", eff.params.get("color")) # 1-based | |
| dest_color = eff.params.get("to_color") # 1-based | |
| if src_color and dest_color: | |
| try: | |
| # Handle possibly float/string values | |
| s_idx = int(src_color) - 1 | |
| d_idx = int(dest_color) - 1 | |
| if 0 <= s_idx < 6 and 0 <= d_idx < 6: | |
| amount_moved = total_hearts[s_idx] | |
| total_hearts[d_idx] += amount_moved | |
| total_hearts[s_idx] = 0 | |
| transform_log.append( | |
| { | |
| "source": ce.get("source_name", "Effect"), | |
| "desc": f"Color Transform (Type {src_color} -> Type {dest_color})", | |
| "type": "transform", | |
| "source_id": ce.get("source_id", -1), | |
| } | |
| ) | |
| except: | |
| pass | |
| # 4. Process Lives & Requirements | |
| lives = [] | |
| req_breakdown = [] # Log for requirement reductions | |
| for live_id in self.live_zone: | |
| l_base = get_base_id(live_id) | |
| if l_base not in live_db: | |
| continue | |
| live_card = live_db[l_base] | |
| # Base Requirement | |
| req_breakdown.append( | |
| {"source": live_card.name, "value": live_card.required_hearts.tolist(), "type": "base_req"} | |
| ) | |
| # Copy requirement to modify | |
| req = live_card.required_hearts.copy() # (7,) | |
| # Apply REDUCE_HEART_REQ | |
| for ce in self.continuous_effects: | |
| eff = ce["effect"] | |
| if eff.effect_type == EffectType.REDUCE_HEART_REQ: | |
| reduction_val = np.zeros(7, dtype=np.int32) | |
| target_color = eff.params.get("color") | |
| val = eff.value | |
| if target_color and target_color != "any": | |
| try: | |
| c_idx = int(target_color) - 1 | |
| if 0 <= c_idx < 6: | |
| reduction_val[c_idx] = val | |
| except: | |
| pass | |
| else: | |
| # Any color reduction (index 6) matches "any" param or default | |
| reduction_val[6] = val | |
| # Log reduction | |
| if np.any(reduction_val > 0): | |
| req_breakdown.append( | |
| { | |
| "source": ce.get("source_name", "Effect"), | |
| "value": (-reduction_val).tolist(), | |
| "type": "req_mod", | |
| "source_id": ce.get("source_id", -1), | |
| } | |
| ) | |
| req = np.maximum(0, req - reduction_val) | |
| # Calculate Success (Greedy) | |
| temp_hearts = total_hearts.copy() | |
| # 1. Match specific colors | |
| needed_specific = req[:6] | |
| have_specific = temp_hearts[:6] | |
| used_specific = np.minimum(needed_specific, have_specific) | |
| temp_hearts[:6] -= used_specific | |
| remaining_req = req.copy() | |
| remaining_req[:6] -= used_specific | |
| # 2. Match Any with remaining specific | |
| needed_any = remaining_req[6] | |
| have_any_from_specific = np.sum(temp_hearts[:6]) | |
| used_any_from_specific = min(needed_any, have_any_from_specific) | |
| # 3. Match Any with Any | |
| needed_any -= used_any_from_specific | |
| have_wild = temp_hearts[6] | |
| used_wild = min(needed_any, have_wild) | |
| met = np.all(remaining_req[:6] == 0) and (needed_any - used_wild <= 0) | |
| lives.append( | |
| { | |
| "name": live_card.name, | |
| "img": live_card.img_path, | |
| "score": int(live_card.score), | |
| "req": req.tolist(), | |
| "passed": bool(met), | |
| "reason": "" if met else "Not met", | |
| "base_score": int(live_card.score), | |
| "bonus_score": self.live_score_bonus, | |
| } | |
| ) | |
| return { | |
| "can_perform": True, | |
| "total_blades": int(total_blades), | |
| "total_hearts": total_hearts.tolist(), | |
| "lives": lives, | |
| "breakdown": { | |
| "blades": blade_breakdown, | |
| "hearts": heart_breakdown, | |
| "requirements": req_breakdown, | |
| "transforms": transform_log, | |
| }, | |
| } | |
| def get_member_cost(self, card_id: int, card_db: Dict[int, MemberCard]) -> int: | |
| """ | |
| Calculate effective cost of a member card in hand. | |
| """ | |
| from engine.game.state_utils import get_base_id | |
| base_id = get_base_id(card_id) | |
| if base_id not in card_db: | |
| return 0 | |
| member = card_db[base_id] | |
| cost = member.cost | |
| # Apply global cost reduction effects | |
| total_reduction = 0 | |
| for ce in self.continuous_effects: | |
| if ce["effect"].effect_type == EffectType.REDUCE_COST: | |
| total_reduction += ce["effect"].value | |
| # Q129: Apply card's OWN constant abilities if they reduce cost in hand. | |
| for ab in member.abilities: | |
| if ab.trigger == TriggerType.CONSTANT: | |
| for eff in ab.effects: | |
| if eff.effect_type == EffectType.REDUCE_COST and eff.target == TargetType.SELF: | |
| conditions_met = True | |
| for cond in ab.conditions: | |
| if not self._check_condition_for_constant(cond, slot_idx=-1, card_db=card_db): | |
| conditions_met = False | |
| break | |
| if conditions_met: | |
| val = eff.value | |
| if eff.params.get("multiplier") and eff.params.get("per_hand_other"): | |
| count = max(0, len(self.hand) - 1) | |
| val *= count | |
| total_reduction += val | |
| return max(0, cost - total_reduction) | |
| def to_dict(self, viewer_idx=0): | |
| # We now have StateMixin.to_dict() but we might want this custom one for the UI. | |
| # Actually, let's just use StateMixin.to_dict and enrich it if needed in serializer.py. | |
| # This keeps PlayerState purely about state. | |
| return super().to_dict() | |