LovecaSim / engine /game /player_state.py
trioskosmos's picture
Upload folder using huggingface_hub
bb3fbf9 verified
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()
@property
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)
@property
def energy_count(self) -> int:
return len(self.energy_zone)
@energy_count.setter
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
@property
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()