Spaces:
Running
Running
| use serde::{Deserialize, Serialize}; | |
| use serde_repr::{Deserialize_repr, Serialize_repr}; | |
| use serde_json::json; | |
| use crate::core::enums::*; | |
| use crate::core::mcts::{MCTS, SearchHorizon, EvalMode}; | |
| use crate::core::heuristics::{Heuristic, OriginalHeuristic, SimpleHeuristic}; | |
| use std::collections::HashMap; | |
| use rand::seq::SliceRandom; | |
| use rand::prelude::IndexedRandom; | |
| use rand_pcg::Pcg64; | |
| use rand::SeedableRng; | |
| use rand::rngs::SmallRng; | |
| use smallvec::SmallVec; | |
| // --- OPCODES --- | |
| pub const O_RETURN: i32 = 1; | |
| pub const O_JUMP: i32 = 2; | |
| pub const O_JUMP_F: i32 = 3; | |
| // State Modification (10-99) | |
| pub const O_DRAW: i32 = 10; | |
| pub const O_BLADES: i32 = 11; | |
| pub const O_HEARTS: i32 = 12; | |
| pub const O_REDUCE_COST: i32 = 13; | |
| pub const O_LOOK_DECK: i32 = 14; | |
| pub const O_RECOV_L: i32 = 15; | |
| pub const O_BOOST: i32 = 16; | |
| pub const O_RECOV_M: i32 = 17; | |
| pub const O_BUFF: i32 = 18; | |
| pub const O_IMMUNITY: i32 = 19; | |
| pub const O_MOVE_MEMBER: i32 = 20; | |
| pub const O_SWAP_CARDS: i32 = 21; | |
| pub const O_SEARCH_DECK: i32 = 22; | |
| pub const O_CHARGE: i32 = 23; | |
| pub const O_SET_BLADES: i32 = 24; | |
| pub const O_SET_HEARTS: i32 = 25; | |
| pub const O_FORMATION: i32 = 26; | |
| pub const O_NEGATE: i32 = 27; | |
| pub const O_ORDER_DECK: i32 = 28; | |
| pub const O_META_RULE: i32 = 29; | |
| pub const O_SELECT_MODE: i32 = 30; | |
| pub const O_MOVE_TO_DECK: i32 = 31; | |
| pub const O_TAP_O: i32 = 32; | |
| pub const O_PLACE_UNDER: i32 = 33; | |
| pub const O_FLAVOR: i32 = 34; | |
| pub const O_RESTRICTION: i32 = 35; | |
| pub const O_BATON_MOD: i32 = 36; | |
| pub const O_SET_SCORE: i32 = 37; | |
| pub const O_SWAP_ZONE: i32 = 38; | |
| pub const O_TRANSFORM_COLOR: i32 = 39; | |
| pub const O_REVEAL: i32 = 40; | |
| pub const O_LOOK_AND_CHOOSE: i32 = 41; | |
| pub const O_CHEER_REVEAL: i32 = 42; | |
| pub const O_ACTIVATE_MEMBER: i32 = 43; | |
| pub const O_ADD_H: i32 = 44; | |
| pub const O_COLOR_SELECT: i32 = 45; | |
| pub const O_REPLACE_EFFECT: i32 = 46; | |
| pub const O_TRIGGER_REMOTE: i32 = 47; | |
| pub const O_REDUCE_HEART_REQ: i32 = 48; | |
| pub const O_MODIFY_SCORE_RULE: i32 = 49; | |
| pub const O_ADD_STAGE_ENERGY: i32 = 50; | |
| pub const O_SET_TAPPED: i32 = 51; | |
| pub const O_ACTIVATE_ENERGY: i32 = 81; | |
| pub const O_ADD_CONTINUOUS: i32 = 52; | |
| pub const O_TAP_M: i32 = 53; | |
| pub const O_PLAY_MEMBER_FROM_HAND: i32 = 57; | |
| pub const O_MOVE_TO_DISCARD: i32 = 58; | |
| pub const O_GRANT_ABILITY: i32 = 60; | |
| pub const O_INCREASE_COST: i32 = 70; | |
| pub const O_PAY_ENERGY: i32 = 64; | |
| pub const O_DRAW_UNTIL: i32 = 66; | |
| pub const O_REVEAL_UNTIL: i32 = 69; | |
| pub const O_PLAY_MEMBER_FROM_DISCARD: i32 = 63; | |
| pub const O_SWAP_AREA: i32 = 72; | |
| pub const O_PLAY_LIVE_FROM_DISCARD: i32 = 76; | |
| pub const O_SELECT_CARDS: i32 = 74; | |
| pub const O_OPPONENT_CHOOSE: i32 = 75; | |
| // Conditions (200-299) | |
| pub const C_TR1: i32 = 200; | |
| pub const C_HAS_MEMBER: i32 = 201; | |
| pub const C_CLR: i32 = 202; | |
| pub const C_STG: i32 = 203; | |
| pub const C_HND: i32 = 204; | |
| pub const C_DSR: i32 = 205; | |
| pub const C_CTR: i32 = 206; | |
| pub const C_LLD: i32 = 207; | |
| pub const C_GRP: i32 = 208; | |
| pub const C_GRP_FLT: i32 = 209; | |
| pub const C_OPH: i32 = 210; | |
| pub const C_SLF_GRP: i32 = 211; | |
| pub const C_MODAL_ANSWER: i32 = 212; | |
| pub const C_ENR: i32 = 213; | |
| pub const C_HAS_LIVE: i32 = 214; | |
| pub const C_COST_CHK: i32 = 215; | |
| pub const C_RARITY_CHK: i32 = 216; | |
| pub const C_HND_NO_LIVE: i32 = 217; | |
| pub const C_SCS_LIV: i32 = 218; | |
| pub const C_OPP_HND_DIF: i32 = 219; | |
| pub const C_CMP: i32 = 220; | |
| pub const C_HAS_CHOICE: i32 = 221; | |
| pub const C_OPPONENT_CHOICE: i32 = 222; | |
| pub const C_HRT: i32 = 223; | |
| pub const C_BLD: i32 = 224; | |
| pub const C_OPP_ENR_DIF: i32 = 225; | |
| pub const C_HAS_KWD: i32 = 226; | |
| pub const C_DK_REFR: i32 = 227; | |
| pub const C_HAS_MOVED: i32 = 228; | |
| pub const C_HND_INC: i32 = 229; | |
| pub const C_LIV_ZN: i32 = 230; | |
| pub const C_BATON: i32 = 231; | |
| pub const C_TYPE_CHECK: i32 = 232; | |
| pub const C_IS_IN_DISCARD: i32 = 233; | |
| // --- ABILITY FLAGS --- | |
| // Pre-computed bitflags for fast heuristic checks | |
| pub const FLAG_DRAW: u64 = 1 << 0; // 10, 41 | |
| pub const FLAG_SEARCH: u64 = 1 << 1; // 22 | |
| pub const FLAG_RECOVER: u64 = 1 << 2; // 15, 17 | |
| pub const FLAG_BUFF: u64 = 1 << 3; // 11, 12 | |
| pub const FLAG_CHARGE: u64 = 1 << 4; // 23 | |
| pub const FLAG_TEMPO: u64 = 1 << 5; // 43, 51 (Untap/Tap) | |
| pub const FLAG_REDUCE: u64 = 1 << 6; // 13 | |
| pub const FLAG_BOOST: u64 = 1 << 7; // 16 | |
| pub const FLAG_TRANSFORM: u64 = 1 << 8; // 39 | |
| pub const FLAG_WIN_COND: u64 = 1 << 9; // 48 (Reduce Heart) | |
| pub const FLAG_MOVE: u64 = 1 << 10; // 20, 21 (Move/Swap) | |
| pub const FLAG_TAP: u64 = 1 << 11; // 32, 53 (Tap O/M) | |
| // --- CHOICE FLAGS (For Ability Struct) --- | |
| pub const CHOICE_FLAG_LOOK: u8 = 1 << 0; // O_LOOK_AND_CHOOSE | |
| pub const CHOICE_FLAG_MODE: u8 = 1 << 1; // O_SELECT_MODE | |
| pub const CHOICE_FLAG_COLOR: u8 = 1 << 2; // O_COLOR_SELECT | |
| pub const CHOICE_FLAG_ORDER: u8 = 1 << 3; // O_ORDER_DECK | |
| // --- FEATURE FLAGS (For Heuristic Encoding) --- | |
| pub const SYN_FLAG_GROUP: u32 = 1 << 0; | |
| pub const SYN_FLAG_COLOR: u32 = 1 << 1; | |
| pub const SYN_FLAG_BATON: u32 = 1 << 2; | |
| pub const SYN_FLAG_CENTER: u32 = 1 << 3; | |
| pub const SYN_FLAG_LIFE_LEAD: u32 = 1 << 4; | |
| pub const COST_FLAG_DISCARD: u32 = 1 << 0; | |
| pub const COST_FLAG_TAP: u32 = 1 << 1; | |
| // --- MODELS --- | |
| pub enum Phase { | |
| Setup = -2, | |
| MulliganP1 = -1, | |
| MulliganP2 = 0, | |
| Active = 1, | |
| Energy = 2, | |
| Draw = 3, | |
| Main = 4, | |
| LiveSet = 5, | |
| PerformanceP1 = 6, | |
| PerformanceP2 = 7, | |
| LiveResult = 8, | |
| Terminal = 9, | |
| Response = 10, | |
| } | |
| pub const ACTION_SPACE: usize = 3000; | |
| pub struct Condition { | |
| pub condition_type: ConditionType, | |
| pub value: i32, | |
| pub attr: u32, | |
| pub target_slot: u8, | |
| pub is_negated: bool, | |
| pub params: serde_json::Value, | |
| } | |
| pub struct Effect { | |
| pub effect_type: EffectType, | |
| pub value: i32, | |
| pub target: TargetType, | |
| pub is_optional: bool, | |
| pub params: serde_json::Value, | |
| } | |
| pub struct Cost { | |
| pub cost_type: AbilityCostType, | |
| pub value: i32, | |
| pub params: serde_json::Value, | |
| } | |
| pub struct AbilityContext { | |
| pub player_id: u8, | |
| pub area_idx: i16, | |
| pub source_card_id: i16, | |
| pub target_slot: i16, | |
| pub choice_index: i16, | |
| pub selected_color: i16, | |
| pub program_counter: u16, | |
| pub ability_index: i16, | |
| } | |
| pub struct Ability { | |
| pub raw_text: String, | |
| pub trigger: TriggerType, | |
| pub effects: Vec<Effect>, | |
| pub conditions: Vec<Condition>, | |
| pub costs: Vec<Cost>, | |
| pub is_once_per_turn: bool, | |
| pub bytecode: Vec<i32>, | |
| pub modal_options: serde_json::Value, | |
| pub choice_flags: u8, | |
| pub choice_count: u8, | |
| } | |
| pub struct MemberCard { | |
| pub card_id: u16, | |
| pub card_no: String, | |
| pub name: String, | |
| pub cost: u32, | |
| pub hearts: [u8; 7], | |
| pub blade_hearts: [u8; 7], | |
| pub blades: u32, | |
| pub groups: Vec<u8>, | |
| pub units: Vec<u8>, | |
| pub abilities: Vec<Ability>, | |
| pub volume_icons: u32, | |
| pub draw_icons: u32, | |
| pub ability_text: String, | |
| pub char_id: u32, | |
| pub img_path: String, | |
| pub semantic_flags: u32, | |
| pub ability_flags: u64, | |
| pub synergy_flags: u32, | |
| pub cost_flags: u32, | |
| } | |
| pub struct LiveCard { | |
| pub card_id: u16, | |
| pub card_no: String, | |
| pub name: String, | |
| pub score: u32, | |
| pub required_hearts: [u8; 7], | |
| pub abilities: Vec<Ability>, | |
| pub groups: Vec<u8>, | |
| pub units: Vec<u8>, | |
| pub volume_icons: u32, | |
| pub blade_hearts: [u8; 7], | |
| pub rare: String, | |
| pub ability_text: String, | |
| pub img_path: String, | |
| pub semantic_flags: u32, | |
| pub synergy_flags: u32, | |
| } | |
| pub enum Card { | |
| Member(MemberCard), | |
| Live(LiveCard), | |
| } | |
| pub struct PlayerState { | |
| pub player_id: u8, | |
| pub hand: SmallVec<[u16; 16]>, | |
| pub deck: SmallVec<[u16; 64]>, | |
| pub discard: SmallVec<[u16; 32]>, | |
| pub exile: SmallVec<[u16; 16]>, | |
| pub energy_deck: SmallVec<[u16; 16]>, | |
| pub energy_zone: SmallVec<[u16; 16]>, | |
| pub success_lives: SmallVec<[u16; 8]>, | |
| pub live_zone: [i16; 3], // -1 or ID (max 30144 fits in i16) | |
| pub live_zone_revealed: [bool; 3], | |
| pub stage: [i16; 3], | |
| pub stage_energy_count: [u8; 3], | |
| pub tapped_members: [bool; 3], | |
| pub tapped_energy: SmallVec<[bool; 16]>, | |
| pub baton_touch_count: u8, | |
| pub baton_touch_limit: u8, | |
| pub score: u32, | |
| pub current_turn_volume: u32, | |
| pub used_abilities: SmallVec<[u32; 16]>, | |
| pub cannot_live: bool, | |
| pub live_score_bonus: i32, | |
| pub blade_buffs: [i32; 3], | |
| pub heart_buffs: [[i32; 7]; 3], | |
| pub cost_reduction: i32, | |
| pub deck_refreshed_this_turn: bool, | |
| pub moved_members_this_turn: [bool; 3], | |
| pub hand_increased_this_turn: u32, | |
| pub stage_energy: [SmallVec<[u16; 4]>; 3], | |
| pub color_transforms: SmallVec<[(u8, u8); 4]>, | |
| pub heart_req_reductions: [i32; 7], | |
| // pub meta_rules: Vec<String>, // Removed: Unused heap allocation | |
| pub mulligan_selection: u64, | |
| pub hand_added_turn: SmallVec<[u16; 16]>, | |
| pub has_immunity: bool, | |
| pub restrictions: SmallVec<[u8; 8]>, | |
| pub looked_cards: SmallVec<[u16; 16]>, // Shared buffer for revealing cards to UI | |
| pub live_deck: SmallVec<[u16; 32]>, // Live cards available for Live Set phase | |
| pub granted_abilities: Vec<(i16, u16, u16)>, // (target_cid, source_cid, ab_idx) | |
| pub cost_modifiers: Vec<(Condition, i32)>, // (condition, amount) | |
| } | |
| impl Default for PlayerState { | |
| fn default() -> Self { | |
| PlayerState { | |
| player_id: 0, | |
| hand: SmallVec::new(), | |
| deck: SmallVec::new(), | |
| discard: SmallVec::new(), | |
| exile: SmallVec::new(), | |
| energy_deck: SmallVec::new(), | |
| energy_zone: SmallVec::new(), | |
| success_lives: SmallVec::new(), | |
| live_zone: [-1; 3], | |
| live_zone_revealed: [false; 3], | |
| stage: [-1; 3], | |
| stage_energy_count: [0; 3], | |
| tapped_members: [false; 3], | |
| tapped_energy: SmallVec::new(), | |
| baton_touch_count: 0, | |
| baton_touch_limit: 1, | |
| score: 0, | |
| current_turn_volume: 0, | |
| used_abilities: SmallVec::new(), | |
| cannot_live: false, | |
| live_score_bonus: 0, | |
| blade_buffs: [0; 3], | |
| heart_buffs: [[0; 7]; 3], | |
| cost_reduction: 0, | |
| deck_refreshed_this_turn: false, | |
| moved_members_this_turn: [false; 3], | |
| hand_increased_this_turn: 0, | |
| stage_energy: [SmallVec::new(), SmallVec::new(), SmallVec::new()], | |
| color_transforms: SmallVec::new(), | |
| heart_req_reductions: [0; 7], | |
| // meta_rules: Vec::new(), | |
| mulligan_selection: 0, | |
| hand_added_turn: SmallVec::new(), | |
| has_immunity: false, | |
| restrictions: SmallVec::new(), | |
| looked_cards: SmallVec::new(), | |
| live_deck: SmallVec::new(), | |
| granted_abilities: Vec::new(), | |
| cost_modifiers: Vec::new(), | |
| } | |
| } | |
| } | |
| impl PlayerState { | |
| pub fn untap_all(&mut self) { | |
| self.tapped_members = [false; 3]; | |
| for tapped in &mut self.tapped_energy { | |
| *tapped = false; | |
| } | |
| self.baton_touch_count = 0; | |
| self.blade_buffs = [0; 3]; | |
| self.heart_buffs = [[0; 7]; 3]; | |
| self.cost_reduction = 0; | |
| self.live_score_bonus = 0; | |
| self.used_abilities.clear(); | |
| self.color_transforms.clear(); | |
| self.heart_req_reductions = [0; 7]; | |
| self.mulligan_selection = 0; | |
| self.moved_members_this_turn = [false; 3]; | |
| self.granted_abilities.clear(); | |
| self.cost_modifiers.clear(); | |
| } | |
| pub fn copy_from(&mut self, other: &PlayerState) { | |
| self.player_id = other.player_id; | |
| self.hand.clear(); self.hand.extend_from_slice(&other.hand); | |
| self.deck.clear(); self.deck.extend_from_slice(&other.deck); | |
| self.discard.clear(); self.discard.extend_from_slice(&other.discard); | |
| self.exile.clear(); self.exile.extend_from_slice(&other.exile); | |
| self.energy_deck.clear(); self.energy_deck.extend_from_slice(&other.energy_deck); | |
| self.energy_zone.clear(); self.energy_zone.extend_from_slice(&other.energy_zone); | |
| self.success_lives.clear(); self.success_lives.extend_from_slice(&other.success_lives); | |
| self.live_zone = other.live_zone; | |
| self.live_zone_revealed = other.live_zone_revealed; | |
| self.stage = other.stage; | |
| self.stage_energy_count = other.stage_energy_count; | |
| self.tapped_members = other.tapped_members; | |
| self.tapped_energy.clear(); self.tapped_energy.extend_from_slice(&other.tapped_energy); | |
| self.baton_touch_count = other.baton_touch_count; | |
| self.baton_touch_limit = other.baton_touch_limit; | |
| self.score = other.score; | |
| self.current_turn_volume = other.current_turn_volume; | |
| self.used_abilities.clear(); self.used_abilities.extend_from_slice(&other.used_abilities); | |
| self.cannot_live = other.cannot_live; | |
| self.live_score_bonus = other.live_score_bonus; | |
| self.blade_buffs = other.blade_buffs; | |
| self.heart_buffs = other.heart_buffs; | |
| self.cost_reduction = other.cost_reduction; | |
| self.deck_refreshed_this_turn = other.deck_refreshed_this_turn; | |
| self.moved_members_this_turn = other.moved_members_this_turn; | |
| self.hand_increased_this_turn = other.hand_increased_this_turn; | |
| for i in 0..3 { | |
| self.stage_energy[i].clear(); | |
| self.stage_energy[i].extend_from_slice(&other.stage_energy[i]); | |
| } | |
| self.color_transforms.clear(); self.color_transforms.extend_from_slice(&other.color_transforms); | |
| self.heart_req_reductions = other.heart_req_reductions; | |
| // self.meta_rules.clear(); self.meta_rules.extend_from_slice(&other.meta_rules); | |
| self.mulligan_selection = other.mulligan_selection; | |
| self.hand_added_turn.clear(); self.hand_added_turn.extend_from_slice(&other.hand_added_turn); | |
| self.has_immunity = other.has_immunity; | |
| self.restrictions.clear(); self.restrictions.extend_from_slice(&other.restrictions); | |
| self.looked_cards.clear(); self.looked_cards.extend_from_slice(&other.looked_cards); | |
| self.live_deck.clear(); self.live_deck.extend_from_slice(&other.live_deck); | |
| self.granted_abilities.clear(); self.granted_abilities.extend_from_slice(&other.granted_abilities); | |
| self.cost_modifiers.clear(); self.cost_modifiers.extend(other.cost_modifiers.iter().cloned()); | |
| } | |
| } | |
| pub struct GameState { | |
| pub players: [PlayerState; 2], | |
| pub current_player: u8, | |
| pub first_player: u8, | |
| pub phase: Phase, | |
| pub yell_cards: SmallVec<[u16; 8]>, | |
| pub prev_card_id: i16, | |
| pub turn: u16, | |
| pub silent: bool, | |
| pub rule_log: Vec<String>, | |
| pub performance_results: HashMap<u8, serde_json::Value>, | |
| pub last_performance_results: HashMap<u8, serde_json::Value>, | |
| pub performance_history: Vec<serde_json::Value>, | |
| pub trigger_depth: u16, | |
| pub live_set_pending_draws: [u8; 2], | |
| pub pending_ctx: Option<AbilityContext>, | |
| pub pending_card_id: i16, | |
| pub pending_ab_idx: i16, | |
| pub pending_effect_opcode: i16, | |
| pub pending_choice_type: String, | |
| pub rng: SmallRng, | |
| } | |
| impl Default for GameState { | |
| fn default() -> Self { | |
| GameState { | |
| players: [PlayerState::default(), PlayerState::default()], | |
| current_player: 0, | |
| first_player: 0, | |
| phase: Phase::Setup, | |
| yell_cards: SmallVec::new(), | |
| prev_card_id: -1, | |
| turn: 1, | |
| silent: false, | |
| rule_log: Vec::new(), | |
| performance_results: std::collections::HashMap::new(), | |
| last_performance_results: std::collections::HashMap::new(), | |
| performance_history: Vec::new(), | |
| trigger_depth: 0, | |
| live_set_pending_draws: [0, 0], | |
| pending_ctx: None, | |
| pending_card_id: -1, | |
| pending_ab_idx: -1, | |
| pending_effect_opcode: -1, | |
| pending_choice_type: String::new(), | |
| rng: SmallRng::from_os_rng(), | |
| } | |
| } | |
| } | |
| // --- LOGIC --- | |
| pub struct CardDatabase { | |
| pub members: HashMap<u16, MemberCard>, | |
| pub lives: HashMap<u16, LiveCard>, | |
| // Optimization 1: Fast Lookup Vectors | |
| pub members_vec: Vec<Option<MemberCard>>, | |
| pub lives_vec: Vec<Option<LiveCard>>, | |
| } | |
| pub const LIVE_ID_OFFSET: u32 = 10000; | |
| pub const BASE_ID_MASK: u32 = 0xFFFFF; | |
| pub const ENERGY_ID_OFFSET: u32 = 20000; | |
| impl CardDatabase { | |
| pub fn from_json(json_str: &str) -> serde_json::Result<Self> { | |
| let raw: serde_json::Value = serde_json::from_str(json_str)?; | |
| let mut db = Self { | |
| members: HashMap::new(), | |
| lives: HashMap::new(), | |
| members_vec: vec![None; 10000], // Large enough for members | |
| lives_vec: vec![None; 2000], // 2000 enough for ~200 lives | |
| }; | |
| if let Some(members_raw) = raw.get("member_db").and_then(|m| m.as_object()) { | |
| for (_, val) in members_raw { | |
| let mut card = serde_json::from_value::<MemberCard>(val.clone())?; | |
| // Pre-compute ability flags | |
| let mut flags = 0u64; | |
| for ab in &mut card.abilities { | |
| if Self::has_opcode_static_fast(&ab.bytecode, 10) || Self::has_opcode_static_fast(&ab.bytecode, 41) { flags |= FLAG_DRAW; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 22) { flags |= FLAG_SEARCH; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 15) || Self::has_opcode_static_fast(&ab.bytecode, 17) { flags |= FLAG_RECOVER; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 11) || Self::has_opcode_static_fast(&ab.bytecode, 12) { flags |= FLAG_BUFF; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 20) || Self::has_opcode_static_fast(&ab.bytecode, 21) { flags |= FLAG_MOVE; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 32) || Self::has_opcode_static_fast(&ab.bytecode, 53) { flags |= FLAG_TAP; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 23) { flags |= FLAG_CHARGE; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 43) || Self::has_opcode_static_fast(&ab.bytecode, 51) { flags |= FLAG_TEMPO; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 13) { flags |= FLAG_REDUCE; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 16) { flags |= FLAG_BOOST; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 39) { flags |= FLAG_TRANSFORM; } | |
| if Self::has_opcode_static_fast(&ab.bytecode, 48) { flags |= FLAG_WIN_COND; } | |
| let mut s_flags = 0u32; | |
| if ab.trigger == TriggerType::OnPlay { s_flags |= 0x01; } | |
| if ab.trigger == TriggerType::Activated { s_flags |= 0x02; } | |
| if ab.trigger == TriggerType::TurnStart || ab.trigger == TriggerType::TurnEnd { s_flags |= 0x04; } | |
| if ab.is_once_per_turn { s_flags |= 0x08; } | |
| // Check for unflagged logic (semantic extension) | |
| let flagged_ops = [ | |
| O_DRAW, 17, 15, 11, 12, // O_RECOV_M, O_RECOV_L, O_BLADES, O_HEARTS | |
| 22, 16, 23, 20, // O_SEARCH_DECK, O_BOOST, O_CHARGE, O_MOVE_MEMBER | |
| 21, 32, 49, // O_SWAP_CARDS, O_TAP_O, O_MODIFY_SCORE_RULE | |
| 13, 48 // O_REDUCE_COST, O_REDUCE_HEART_REQ | |
| ]; | |
| if ab.bytecode.iter().any(|op| !flagged_ops.contains(op)) { | |
| s_flags |= 0x10; | |
| } | |
| card.semantic_flags |= s_flags; | |
| // Compute Choice Flags and counts | |
| ab.choice_flags = 0; | |
| ab.choice_count = 0; | |
| if Self::has_opcode_static_fast(&ab.bytecode, O_LOOK_AND_CHOOSE) { | |
| ab.choice_flags |= CHOICE_FLAG_LOOK; | |
| ab.choice_count = ab.bytecode.chunks(4).find(|c| !c.is_empty() && c[0] == O_LOOK_AND_CHOOSE).map(|c| c[1] as u8).unwrap_or(3); | |
| } | |
| if Self::has_opcode_static_fast(&ab.bytecode, O_SELECT_MODE) { | |
| ab.choice_flags |= CHOICE_FLAG_MODE; | |
| let count = ab.bytecode.chunks(4).find(|c| !c.is_empty() && c[0] == O_SELECT_MODE).map(|c| c[1] as u8).unwrap_or(2); | |
| if ab.choice_count == 0 { ab.choice_count = count; } | |
| } | |
| if Self::has_opcode_static_fast(&ab.bytecode, O_COLOR_SELECT) { | |
| ab.choice_flags |= CHOICE_FLAG_COLOR; | |
| if ab.choice_count == 0 { ab.choice_count = 6; } | |
| } | |
| if Self::has_opcode_static_fast(&ab.bytecode, O_ORDER_DECK) { | |
| ab.choice_flags |= CHOICE_FLAG_ORDER; | |
| if ab.choice_count == 0 { ab.choice_count = 3; } | |
| } | |
| // Compute Synergy Flags | |
| let mut syn_flags = 0u32; | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::CountGroup || c.condition_type == ConditionType::SelfIsGroup) { syn_flags |= SYN_FLAG_GROUP; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::HasColor) { syn_flags |= SYN_FLAG_COLOR; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::Baton) { syn_flags |= SYN_FLAG_BATON; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::IsCenter) { syn_flags |= SYN_FLAG_CENTER; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::LifeLead) { syn_flags |= SYN_FLAG_LIFE_LEAD; } | |
| card.synergy_flags |= syn_flags; | |
| // Compute Cost Flags | |
| let mut c_flags = 0u32; | |
| if ab.costs.iter().any(|c| c.cost_type == AbilityCostType::DiscardHand || c.cost_type == AbilityCostType::DiscardMember) { c_flags |= COST_FLAG_DISCARD; } | |
| if ab.costs.iter().any(|c| c.cost_type == AbilityCostType::TapSelf || c.cost_type == AbilityCostType::TapMember) { c_flags |= COST_FLAG_TAP; } | |
| card.cost_flags |= c_flags; | |
| } | |
| card.ability_flags = flags; | |
| db.members.insert(card.card_id, card.clone()); | |
| // Populate Vector | |
| if (card.card_id as usize) < db.members_vec.len() { | |
| db.members_vec[card.card_id as usize] = Some(card.clone()); | |
| } | |
| } | |
| } | |
| if let Some(lives_raw) = raw.get("live_db").and_then(|l| l.as_object()) { | |
| for (_, val) in lives_raw { | |
| let mut card = serde_json::from_value::<LiveCard>(val.clone())?; | |
| let mut s_flags = 0u32; | |
| for ab in &card.abilities { | |
| if ab.trigger == TriggerType::OnPlay { s_flags |= 0x01; } | |
| // Compute Synergy Flags | |
| let mut syn_flags = 0u32; | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::CountGroup || c.condition_type == ConditionType::SelfIsGroup) { syn_flags |= SYN_FLAG_GROUP; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::HasColor) { syn_flags |= SYN_FLAG_COLOR; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::Baton) { syn_flags |= SYN_FLAG_BATON; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::IsCenter) { syn_flags |= SYN_FLAG_CENTER; } | |
| if ab.conditions.iter().any(|c| c.condition_type == ConditionType::LifeLead) { syn_flags |= SYN_FLAG_LIFE_LEAD; } | |
| card.synergy_flags |= syn_flags; | |
| } | |
| card.semantic_flags = s_flags; | |
| db.lives.insert(card.card_id, card.clone()); | |
| // Populate Vector (Shift back for index) | |
| if card.card_id >= LIVE_ID_OFFSET as u16 { | |
| let raw_id = card.card_id - LIVE_ID_OFFSET as u16; | |
| if (raw_id as usize) < db.lives_vec.len() { | |
| db.lives_vec[raw_id as usize] = Some(card.clone()); | |
| } | |
| } | |
| } | |
| } | |
| Ok(db) | |
| } | |
| // Fast Lookups | |
| pub fn get_member(&self, id: u16) -> Option<&MemberCard> { | |
| let base_id = id & BASE_ID_MASK as u16; | |
| if (base_id as usize) < self.members_vec.len() { | |
| self.members_vec[base_id as usize].as_ref() | |
| } else { | |
| self.members.get(&base_id) | |
| } | |
| } | |
| pub fn get_live(&self, id: u16) -> Option<&LiveCard> { | |
| let base_id = id & BASE_ID_MASK as u16; | |
| if base_id >= LIVE_ID_OFFSET as u16 { | |
| let idx = (base_id - LIVE_ID_OFFSET as u16) as usize; | |
| if idx < self.lives_vec.len() { | |
| self.lives_vec[idx].as_ref() | |
| } else { | |
| self.lives.get(&base_id) | |
| } | |
| } else { | |
| None | |
| } | |
| } | |
| fn has_opcode_static(bytecode: &[i32], target_op: i32) -> bool { | |
| let mut i = 0; | |
| while i < bytecode.len() { | |
| if i + 3 >= bytecode.len() { break; } | |
| let op = bytecode[i]; | |
| if op == target_op { return true; } | |
| i += 4; | |
| } | |
| false | |
| } | |
| // Optimized opcode check that just checks 0th element of chunks(4) | |
| fn has_opcode_static_fast(bytecode: &[i32], target_op: i32) -> bool { | |
| let len = bytecode.len(); | |
| let mut i = 0; | |
| while i < len { | |
| if bytecode[i] == target_op { return true; } | |
| i += 4; | |
| } | |
| false | |
| } | |
| } | |
| pub fn bytecode_has_choice(bytecode: &[i32]) -> bool { | |
| bytecode.chunks(4).any(|chunk| { | |
| if chunk.is_empty() { return false; } | |
| let op = chunk[0]; | |
| op == O_SELECT_MODE || op == O_LOOK_AND_CHOOSE || op == O_COLOR_SELECT || | |
| op == O_TAP_O || op == O_ORDER_DECK || op == O_PLAY_MEMBER_FROM_HAND || | |
| op == O_PLAY_MEMBER_FROM_DISCARD || op == O_OPPONENT_CHOOSE | |
| }) | |
| } | |
| /// Helper to check if bytecode requires an EARLY pause (before execution begins). | |
| /// O_ORDER_DECK is EXCLUDED because it needs O_LOOK_DECK to run first to populate looked_cards. | |
| /// O_LOOK_AND_CHOOSE may also need prior opcodes to run, but typically doesn't have setup. | |
| pub fn bytecode_needs_early_pause(bytecode: &[i32]) -> bool { | |
| bytecode.chunks(4).any(|chunk| { | |
| if chunk.is_empty() { return false; } | |
| let op = chunk[0]; | |
| // Only opcodes that can pause BEFORE any execution | |
| op == O_SELECT_MODE || op == O_COLOR_SELECT | |
| }) | |
| } | |
| pub fn bytecode_needs_early_pause_opcode(bytecode: &[i32]) -> i32 { | |
| bytecode.chunks(4).find(|chunk| { | |
| if chunk.is_empty() { return false; } | |
| let op = chunk[0]; | |
| op == O_SELECT_MODE || op == O_COLOR_SELECT | |
| }).map(|chunk| chunk[0]).unwrap_or(-1) | |
| } | |
| impl GameState { | |
| pub fn safe_set_mask(&self, mask: &mut [bool], aid: usize) { | |
| if aid < mask.len() { | |
| mask[aid] = true; | |
| } else { | |
| panic!("[ACTION_OOB] Action ID {} exceeds mask len {} in phase {:?}", aid, mask.len(), self.phase); | |
| } | |
| } | |
| pub fn log(&mut self, msg: String) { | |
| if self.silent { return; } | |
| let full_msg = if msg.starts_with("[Turn") { | |
| msg | |
| } else { | |
| format!("[Turn {}] {}", self.turn, msg) | |
| }; | |
| println!("{}", full_msg); | |
| self.rule_log.push(full_msg); | |
| } | |
| pub fn log_rule(&mut self, rule: &str, msg: &str) { | |
| if self.silent { return; } | |
| self.log(format!("[{}] {}", rule, msg)); | |
| } | |
| pub fn setup_turn_log(&mut self) { | |
| if self.silent { return; } | |
| let p_idx = self.current_player; | |
| self.log(format!("=== Player {}'s Turn ===", p_idx)); | |
| } | |
| pub fn initialize_game(&mut self, p0_deck: Vec<u16>, p1_deck: Vec<u16>, p0_energy: Vec<u16>, p1_energy: Vec<u16>, p0_lives: Vec<u16>, p1_lives: Vec<u16>) { | |
| self.initialize_game_with_seed(p0_deck, p1_deck, p0_energy, p1_energy, p0_lives, p1_lives, None); | |
| } | |
| pub fn copy_from(&mut self, other: &GameState) { | |
| self.players[0].copy_from(&other.players[0]); | |
| self.players[1].copy_from(&other.players[1]); | |
| self.current_player = other.current_player; | |
| self.first_player = other.first_player; | |
| self.phase = other.phase; | |
| self.yell_cards.clear(); self.yell_cards.extend_from_slice(&other.yell_cards); | |
| self.prev_card_id = other.prev_card_id; | |
| self.turn = other.turn; | |
| self.silent = other.silent; | |
| // self.rule_log.clear(); // We purposely do not copy rule log to avoid overhead | |
| self.trigger_depth = other.trigger_depth; | |
| self.live_set_pending_draws = other.live_set_pending_draws; | |
| self.pending_ctx = other.pending_ctx; | |
| self.pending_card_id = other.pending_card_id; | |
| self.pending_ab_idx = other.pending_ab_idx; | |
| self.pending_effect_opcode = other.pending_effect_opcode; | |
| self.pending_choice_type.clear(); self.pending_choice_type.push_str(&other.pending_choice_type); | |
| } | |
| pub fn initialize_game_with_seed(&mut self, p0_deck: Vec<u16>, p1_deck: Vec<u16>, p0_energy: Vec<u16>, p1_energy: Vec<u16>, p0_lives: Vec<u16>, p1_lives: Vec<u16>, seed: Option<u64>) { | |
| // Lives are NOT added to deck - they are separate cards for Live Set phase | |
| // Rule 5.1: Deck contains exactly 60 member cards | |
| self.players[0].deck = SmallVec::from_vec(p0_deck); | |
| self.players[1].deck = SmallVec::from_vec(p1_deck); | |
| // Store lives separately for Live Set phase selection | |
| self.players[0].live_deck = SmallVec::from_vec(p0_lives); | |
| self.players[1].live_deck = SmallVec::from_vec(p1_lives); | |
| self.players[0].energy_deck = SmallVec::from_vec(p0_energy); | |
| self.players[1].energy_deck = SmallVec::from_vec(p1_energy); | |
| // Reset state | |
| for i in 0..2 { | |
| self.players[i].hand.clear(); | |
| self.players[i].energy_zone.clear(); | |
| self.players[i].tapped_energy.clear(); | |
| self.players[i].stage = [-1; 3]; | |
| self.players[i].tapped_members = [false; 3]; | |
| self.players[i].discard.clear(); | |
| self.players[i].success_lives.clear(); | |
| self.players[i].mulligan_selection = 0; | |
| self.players[i].hand_added_turn.clear(); | |
| } | |
| let mut rng = match seed { | |
| Some(s) => Pcg64::seed_from_u64(s), | |
| None => Pcg64::from_os_rng(), | |
| }; | |
| self.players[0].deck.shuffle(&mut rng); | |
| self.players[1].deck.shuffle(&mut rng); | |
| self.players[0].energy_deck.shuffle(&mut rng); | |
| self.players[1].energy_deck.shuffle(&mut rng); | |
| // Initial Hands (6 cards) | |
| self.log("Rule 6.2.1.5: Both players draw 6 cards as starting hand.".to_string()); | |
| self.draw_cards(0, 6); | |
| self.draw_cards(1, 6); | |
| // Initial Energy (3 cards) | |
| self.log("Rule 6.2.1.7: Both players place 3 cards from Energy Deck to Energy Zone.".to_string()); | |
| for i in 0..2 { | |
| for _ in 0..3 { | |
| if let Some(cid) = self.players[i].energy_deck.pop() { | |
| self.players[i].energy_zone.push(cid); | |
| self.players[i].tapped_energy.push(false); | |
| } | |
| } | |
| } | |
| // Setup Lives | |
| self.players[0].live_zone = [-1; 3]; | |
| self.players[1].live_zone = [-1; 3]; | |
| for i in 0..3 { | |
| self.players[0].live_zone_revealed[i] = false; | |
| self.players[1].live_zone_revealed[i] = false; | |
| } | |
| // Setup first player | |
| self.phase = Phase::MulliganP1; | |
| self.current_player = self.first_player; | |
| self.setup_turn_log(); | |
| } | |
| // Helper for shuffling decks | |
| fn fisher_yates_shuffle(&mut self, player_idx: usize) { | |
| self.players[player_idx].deck.shuffle(&mut self.rng); | |
| } | |
| pub fn resolve_deck_refresh(&mut self, player_idx: usize) { | |
| let player = &mut self.players[player_idx]; | |
| if player.deck.is_empty() && !player.discard.is_empty() { | |
| let discard = player.discard.drain(..).collect::<Vec<_>>(); | |
| player.deck.extend(discard); | |
| player.deck.shuffle(&mut self.rng); | |
| self.players[player_idx].deck_refreshed_this_turn = true; | |
| } | |
| } | |
| pub fn draw_cards(&mut self, player_idx: usize, count: u32) { | |
| for _ in 0..count { | |
| if self.players[player_idx].deck.is_empty() { self.resolve_deck_refresh(player_idx); } | |
| if let Some(card_id) = self.players[player_idx].deck.pop() { | |
| self.players[player_idx].hand.push(card_id); | |
| self.players[player_idx].hand_added_turn.push(self.turn); | |
| } | |
| } | |
| } | |
| pub fn draw_energy_cards(&mut self, player_idx: usize, count: i32) { | |
| for _ in 0..count { | |
| if let Some(cid) = self.players[player_idx].energy_deck.pop() { | |
| self.players[player_idx].energy_zone.push(cid); | |
| self.players[player_idx].tapped_energy.push(false); | |
| } | |
| } | |
| } | |
| pub fn pay_energy(&mut self, player_idx: usize, count: i32) { | |
| let mut paid = 0; | |
| for i in 0..self.players[player_idx].tapped_energy.len() { | |
| if paid >= count { break; } | |
| if !self.players[player_idx].tapped_energy[i] { | |
| self.players[player_idx].tapped_energy[i] = true; | |
| paid += 1; | |
| } | |
| } | |
| } | |
| pub fn activate_energy(&mut self, player_idx: usize, count: i32) { | |
| let mut activated = 0; | |
| for i in 0..self.players[player_idx].tapped_energy.len() { | |
| if activated >= count { break; } | |
| if self.players[player_idx].tapped_energy[i] { | |
| self.players[player_idx].tapped_energy[i] = false; | |
| activated += 1; | |
| } | |
| } | |
| } | |
| pub fn set_member_tapped(&mut self, player_idx: usize, slot_idx: usize, tapped: bool) { | |
| if slot_idx < self.players[player_idx].tapped_members.len() { | |
| self.players[player_idx].tapped_members[slot_idx] = tapped; | |
| } | |
| } | |
| pub fn check_win_condition(&mut self) { | |
| if self.phase == Phase::Terminal { return; } | |
| let p0_lives = self.players[0].success_lives.len(); | |
| let p1_lives = self.players[1].success_lives.len(); | |
| if p0_lives >= 3 || p1_lives >= 3 { | |
| self.phase = Phase::Terminal; | |
| } | |
| // Rule 1.2.1.1: Win condition based on successful lives | |
| let p0_success = self.players[0].success_lives.len(); | |
| let p1_success = self.players[1].success_lives.len(); | |
| if p0_success >= 3 && p1_success < 3 { | |
| self.phase = Phase::Terminal; | |
| self.log("Rule 1.2.1.1: Player 0 wins by 3 successful lives.".to_string()); | |
| } else if p1_success >= 3 && p0_success < 3 { | |
| self.phase = Phase::Terminal; | |
| self.log("Rule 1.2.1.1: Player 1 wins by 3 successful lives.".to_string()); | |
| } else if p0_success >= 3 && p1_success >= 3 { | |
| self.phase = Phase::Terminal; | |
| self.log("Rule 1.2.1.2: Draw (Both players reached 3 successful lives).".to_string()); | |
| } | |
| // Turn Limit | |
| if self.turn > 40 { | |
| self.phase = Phase::Terminal; | |
| } | |
| } | |
| // --- AI Encoding --- | |
| /// Encodes game state into a fixed-size feature vector for neural network input. | |
| /// | |
| /// Layout (~750 floats, padded to 800): | |
| /// - Global Features (20): Turn, Phase, Scores, Resources, Heuristic Scores | |
| /// - P0 Stage (3 * 32 = 96): Card feature vectors | |
| /// - P1 Stage (3 * 32 = 96): Card feature vectors | |
| /// - P0 Live Zone (3 * 32 = 96): Card feature vectors | |
| /// - P1 Live Zone (3 * 32 = 96): Card feature vectors (revealed only) | |
| /// - P0 Hand (10 * 32 = 320): Card feature vectors | |
| /// - P1 Hand Count (1): Hidden info | |
| /// - Derived Heuristics (20): Suitability, Power Differential, etc. | |
| pub fn encode_state(&self, db: &CardDatabase) -> Vec<f32> { | |
| const CARD_FEAT_SIZE: usize = 48; | |
| const TOTAL_SIZE: usize = 1200; | |
| let mut feats = Vec::with_capacity(TOTAL_SIZE); | |
| // ===================== | |
| // GLOBAL FEATURES (20) | |
| // ===================== | |
| feats.push(self.turn as f32 / 50.0); | |
| feats.push(self.phase as i32 as f32 / 10.0); | |
| feats.push(self.current_player as f32); | |
| feats.push(self.players[0].score as f32 / 10.0); | |
| feats.push(self.players[1].score as f32 / 10.0); | |
| feats.push(self.players[0].success_lives.len() as f32 / 3.0); | |
| feats.push(self.players[1].success_lives.len() as f32 / 3.0); | |
| feats.push(self.players[0].hand.len() as f32 / 10.0); | |
| feats.push(self.players[1].hand.len() as f32 / 10.0); | |
| feats.push(self.players[0].energy_zone.len() as f32 / 10.0); | |
| feats.push(self.players[1].energy_zone.len() as f32 / 10.0); | |
| feats.push(self.players[0].deck.len() as f32 / 50.0); | |
| feats.push(self.players[1].deck.len() as f32 / 50.0); | |
| let p0_tapped = self.players[0].tapped_energy.iter().filter(|&&t| t).count(); | |
| let p1_tapped = self.players[1].tapped_energy.iter().filter(|&&t| t).count(); | |
| feats.push(p0_tapped as f32 / 10.0); | |
| feats.push(p1_tapped as f32 / 10.0); | |
| feats.push(self.players[0].baton_touch_count as f32 / 3.0); | |
| feats.push(self.players[1].baton_touch_count as f32 / 3.0); | |
| // Pad globals to 20 | |
| while feats.len() < 20 { feats.push(0.0); } | |
| // ===================== | |
| // P0 STAGE (3 slots * 32) | |
| // ===================== | |
| for i in 0..3 { | |
| self.encode_member_slot(&mut feats, 0, i, db); | |
| } | |
| // ===================== | |
| // P1 STAGE (3 slots * 32) | |
| // ===================== | |
| for i in 0..3 { | |
| self.encode_member_slot(&mut feats, 1, i, db); | |
| } | |
| // ===================== | |
| // P0 LIVE ZONE (3 slots * 32) - CRITICAL FIX: Was missing! | |
| // ===================== | |
| for i in 0..3 { | |
| self.encode_live_slot(&mut feats, 0, i, db, true); // Always visible to self | |
| } | |
| // ===================== | |
| // P1 LIVE ZONE (3 slots * 32) - Only if revealed | |
| // ===================== | |
| for i in 0..3 { | |
| let revealed = self.players[1].live_zone_revealed[i]; | |
| self.encode_live_slot(&mut feats, 1, i, db, revealed); | |
| } | |
| // ===================== | |
| // P0 HAND (10 slots * 32) - Feature-based, not ID-based | |
| // ===================== | |
| for slot in 0..10 { | |
| if slot < self.players[0].hand.len() { | |
| let cid = self.players[0].hand[slot]; | |
| self.encode_card_features(&mut feats, cid, db); | |
| } else { | |
| // Empty slot | |
| for _ in 0..CARD_FEAT_SIZE { feats.push(0.0); } | |
| } | |
| } | |
| // P1 Hand Count (hidden info, just count) | |
| feats.push(self.players[1].hand.len() as f32 / 10.0); | |
| // ===================== | |
| // DERIVED HEURISTIC FEATURES (20) | |
| // ===================== | |
| // Total board power | |
| let mut p0_power = 0i32; | |
| let mut p1_power = 0i32; | |
| for i in 0..3 { | |
| p0_power += self.get_effective_blades(0, i, db) as i32; | |
| p1_power += self.get_effective_blades(1, i, db) as i32; | |
| } | |
| feats.push(p0_power as f32 / 30.0); | |
| feats.push(p1_power as f32 / 30.0); | |
| feats.push((p0_power - p1_power) as f32 / 30.0); // Power differential | |
| // Heart suitability (how close P0 is to clearing a live) | |
| let mut p0_stage_hearts = [0u32; 7]; | |
| for i in 0..3 { | |
| let h = self.get_effective_hearts(0, i, db); | |
| for c in 0..7 { p0_stage_hearts[c] += h[c] as u32; } | |
| } | |
| // Calculate suitability against P0's first live | |
| let mut suitability = 0.0f32; | |
| if self.players[0].live_zone[0] >= 0 { | |
| if let Some(l) = db.get_live(self.players[0].live_zone[0] as u16) { | |
| let mut total_req = 0u32; | |
| let mut total_sat = 0u32; | |
| for c in 0..7 { | |
| let req = l.required_hearts[c] as u32; | |
| let have = p0_stage_hearts[c].min(req); | |
| total_req += req; | |
| total_sat += have; | |
| } | |
| // Use any-color hearts for remaining | |
| let remaining = total_req.saturating_sub(total_sat); | |
| let any_color = p0_stage_hearts[6]; | |
| total_sat += remaining.min(any_color); | |
| if total_req > 0 { | |
| suitability = total_sat as f32 / total_req as f32; | |
| } | |
| } | |
| } | |
| feats.push(suitability); | |
| // Lives remaining urgency | |
| feats.push((3 - self.players[0].success_lives.len()) as f32 / 3.0); | |
| feats.push((3 - self.players[1].success_lives.len()) as f32 / 3.0); | |
| // Volume accumulated this turn | |
| feats.push(self.players[0].current_turn_volume as f32 / 20.0); | |
| feats.push(self.players[1].current_turn_volume as f32 / 20.0); | |
| // Pad heuristics section | |
| while feats.len() < TOTAL_SIZE - 10 { feats.push(0.0); } | |
| // Final padding to exact size | |
| while feats.len() < TOTAL_SIZE { feats.push(0.0); } | |
| if feats.len() > TOTAL_SIZE { feats.truncate(TOTAL_SIZE); } | |
| feats | |
| } | |
| /// Encode a member card slot into a 32-float feature vector | |
| fn encode_member_slot(&self, feats: &mut Vec<f32>, p_idx: usize, slot: usize, db: &CardDatabase) { | |
| const CARD_FEAT_SIZE: usize = 48; | |
| let cid = self.players[p_idx].stage[slot]; | |
| if cid >= 0 { | |
| if let Some(m) = db.get_member(cid as u16) { | |
| feats.push(1.0); // [0] Present | |
| feats.push(1.0); // [1] Type: Member | |
| feats.push(0.0); // [2] Type: Live | |
| feats.push(0.0); // [3] Type: Energy | |
| feats.push(m.cost as f32 / 10.0); // [4] Cost | |
| feats.push(self.get_effective_blades(p_idx, slot, db) as f32 / 10.0); // [5] Power | |
| // [6-12] Hearts provided | |
| let hearts = self.get_effective_hearts(p_idx, slot, db); | |
| for h in hearts { feats.push(h as f32 / 5.0); } | |
| // [13-19] Required hearts (N/A for members, zero) | |
| for _ in 0..7 { feats.push(0.0); } | |
| // [20-26] Blade hearts bonus | |
| for &bh in &m.blade_hearts { feats.push(bh as f32 / 5.0); } | |
| // [27] Volume icons | |
| feats.push(m.volume_icons as f32 / 5.0); | |
| // Trigger Type Flags (3) | |
| feats.push(if (m.semantic_flags & 0x01) != 0 { 1.0 } else { 0.0 }); // OnPlay | |
| feats.push(if (m.semantic_flags & 0x04) != 0 { 1.0 } else { 0.0 }); // Turn | |
| feats.push(if (m.semantic_flags & 0x02) != 0 { 1.0 } else { 0.0 }); // Activated | |
| // [31] Tapped status | |
| feats.push(if self.players[p_idx].tapped_members[slot] { 1.0 } else { 0.0 }); | |
| // Pad to 48 | |
| while (feats.len() % 48) != 0 { feats.push(0.0); } | |
| return; | |
| } | |
| } | |
| // Empty slot | |
| for _ in 0..CARD_FEAT_SIZE { feats.push(0.0); } | |
| } | |
| /// Encode a live card slot into a 48-float feature vector | |
| fn encode_live_slot(&self, feats: &mut Vec<f32>, p_idx: usize, slot: usize, db: &CardDatabase, visible: bool) { | |
| const CARD_FEAT_SIZE: usize = 48; | |
| let cid = self.players[p_idx].live_zone[slot]; | |
| if cid >= 0 && visible { | |
| if let Some(l) = db.get_live(cid as u16) { | |
| feats.push(1.0); // [0] Present | |
| feats.push(0.0); // [1] Type: Member | |
| feats.push(1.0); // [2] Type: Live | |
| feats.push(0.0); // [3] Type: Energy | |
| feats.push(0.0); // [4] Cost (N/A for lives) | |
| feats.push(l.score as f32 / 10.0); // [5] Score | |
| // [6-12] Hearts provided (N/A for lives) | |
| for _ in 0..7 { feats.push(0.0); } | |
| // [13-19] Required hearts | |
| for &h in &l.required_hearts { feats.push(h as f32 / 5.0); } | |
| // [20-26] Blade hearts bonus | |
| for &bh in &l.blade_hearts { feats.push(bh as f32 / 5.0); } | |
| // [27] Volume icons | |
| feats.push(l.volume_icons as f32 / 5.0); | |
| // [28-31] Ability triggers and padding | |
| feats.push(if (l.semantic_flags & 0x01) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(0.0); // Turn trigger | |
| feats.push(0.0); // Activate | |
| // Pad to 48 | |
| while (feats.len() % 48) != 0 { feats.push(0.0); } | |
| return; | |
| } | |
| } | |
| // Empty or hidden slot | |
| for _ in 0..CARD_FEAT_SIZE { feats.push(0.0); } | |
| } | |
| /// Encode any card (from hand) into a 48-float feature vector | |
| /// Now includes semantic ability flags, conditions (synergies), and costs. | |
| fn encode_card_features(&self, feats: &mut Vec<f32>, cid: u16, db: &CardDatabase) { | |
| const CARD_FEAT_SIZE: usize = 48; | |
| // Helper: Check if any ability's bytecode contains a specific opcode | |
| fn bytecode_has_op(abilities: &[Ability], op: i32) -> bool { | |
| abilities.iter().any(|a| a.bytecode.contains(&op)) | |
| } | |
| // Helper: Check if any ability has a specific condition type | |
| // Try member first | |
| if let Some(m) = db.get_member(cid) { | |
| feats.push(1.0); // Present | |
| feats.push(1.0); feats.push(0.0); feats.push(0.0); // Type: Member | |
| feats.push(m.cost as f32 / 10.0); | |
| feats.push(m.blades as f32 / 10.0); | |
| for &h in &m.hearts { feats.push(h as f32 / 5.0); } | |
| for _ in 0..7 { feats.push(0.0); } // Required hearts N/A | |
| for &bh in &m.blade_hearts { feats.push(bh as f32 / 5.0); } | |
| feats.push(m.volume_icons as f32 / 5.0); | |
| // --- Flags --- | |
| // Trigger Type Flags (3) | |
| feats.push(if (m.semantic_flags & 0x01) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.semantic_flags & 0x04) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.semantic_flags & 0x02) != 0 { 1.0 } else { 0.0 }); | |
| // Semantic Ability Flags (10) | |
| feats.push(if (m.ability_flags & FLAG_DRAW) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_RECOVER) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_RECOVER) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_BUFF) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_SEARCH) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_BOOST) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_CHARGE) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_MOVE) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_TAP) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.ability_flags & FLAG_WIN_COND) != 0 { 1.0 } else { 0.0 }); | |
| // Synergy/Condition Flags (5) | |
| feats.push(if (m.synergy_flags & SYN_FLAG_GROUP) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.synergy_flags & SYN_FLAG_COLOR) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.synergy_flags & SYN_FLAG_BATON) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.synergy_flags & SYN_FLAG_CENTER) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.synergy_flags & SYN_FLAG_LIFE_LEAD) != 0 { 1.0 } else { 0.0 }); | |
| // Cost Flags (2) | |
| feats.push(if (m.cost_flags & COST_FLAG_DISCARD) != 0 { 1.0 } else { 0.0 }); | |
| feats.push(if (m.cost_flags & COST_FLAG_TAP) != 0 { 1.0 } else { 0.0 }); | |
| // New Semantic Extension (2) | |
| let is_reduction = (m.ability_flags & FLAG_REDUCE) != 0; | |
| feats.push(if is_reduction { 1.0 } else { 0.0 }); | |
| feats.push(if (m.semantic_flags & 0x10) != 0 { 1.0 } else { 0.0 }); | |
| // Padding to CARD_FEAT_SIZE | |
| while (feats.len() % CARD_FEAT_SIZE) != 0 { feats.push(0.0); } | |
| return; | |
| } | |
| // Try live | |
| if let Some(l) = db.get_live(cid) { | |
| feats.push(1.0); // Present | |
| feats.push(0.0); feats.push(1.0); feats.push(0.0); // Type: Live | |
| feats.push(0.0); // Cost N/A | |
| feats.push(l.score as f32 / 10.0); | |
| for _ in 0..7 { feats.push(0.0); } // Hearts provided N/A | |
| for &h in &l.required_hearts { feats.push(h as f32 / 5.0); } | |
| for &bh in &l.blade_hearts { feats.push(bh as f32 / 5.0); } | |
| feats.push(l.volume_icons as f32 / 5.0); | |
| // Flags (Trigger + Ability + Synergy + Cost) = 3 + 10 + 5 + 2 = 20 total | |
| for _ in 0..20 { feats.push(0.0); } | |
| while (feats.len() % CARD_FEAT_SIZE) != 0 { feats.push(0.0); } | |
| return; | |
| } | |
| // Energy or unknown - minimal encoding | |
| feats.push(1.0); // Present | |
| feats.push(0.0); feats.push(0.0); feats.push(1.0); // Type: Energy | |
| while (feats.len() % CARD_FEAT_SIZE) != 0 { feats.push(0.0); } | |
| } | |
| pub fn process_rule_checks(&mut self) { | |
| for i in 0..2 { | |
| if self.players[i].deck.is_empty() && !self.players[i].discard.is_empty() { | |
| self.resolve_deck_refresh(i); | |
| } | |
| // Rule 10.5.3: Energy in empty member area -> Energy Deck | |
| for slot_idx in 0..3 { | |
| if self.players[i].stage[slot_idx] < 0 { | |
| if !self.players[i].stage_energy[slot_idx].is_empty() { | |
| if !self.silent { self.log(format!("Rule 10.5.3: Reclaiming energy from empty slot {} for player {}.", slot_idx, i)); } | |
| let reclaimed: Vec<u16> = self.players[i].stage_energy[slot_idx].drain(..).collect(); | |
| self.players[i].energy_deck.extend(reclaimed); | |
| self.players[i].stage_energy_count[slot_idx] = 0; | |
| // Shuffle energy deck after returning cards? Rules say "put in energy deck". | |
| // Usually implies random position or shuffle if non-ordered. | |
| // Energy deck is unordered (Rule 4.9.2), but typically we push back. | |
| // Let's shuffle to be safe since it's "deck". | |
| let mut rng = Pcg64::from_os_rng(); | |
| self.players[i].energy_deck.shuffle(&mut rng); | |
| } | |
| } | |
| } | |
| } | |
| for p in 0..2 { | |
| for i in 0..3 { | |
| if self.players[p].stage[i] < 0 && self.players[p].stage_energy_count[i] > 0 { | |
| self.players[p].stage_energy_count[i] = 0; | |
| } | |
| } | |
| } | |
| self.check_win_condition(); | |
| } | |
| pub fn get_effective_blades(&self, player_idx: usize, slot_idx: usize, db: &CardDatabase) -> u32 { | |
| let cid = self.players[player_idx].stage[slot_idx]; | |
| if cid < 0 || self.players[player_idx].tapped_members[slot_idx] { return 0; } | |
| let base = db.get_member(cid as u16).map_or(0, |m| m.blades); | |
| let buff = self.players[player_idx].blade_buffs[slot_idx]; | |
| (base as i32 + buff).max(0) as u32 | |
| } | |
| pub fn get_effective_hearts(&self, player_idx: usize, slot_idx: usize, db: &CardDatabase) -> [u8; 7] { | |
| let cid = self.players[player_idx].stage[slot_idx]; | |
| if cid < 0 { return [0; 7]; } | |
| let hearts = db.get_member(cid as u16).map_or([0; 7], |m| { | |
| let mut h = [0i32; 7]; | |
| for i in 0..7 { h[i] = m.hearts[i] as i32; } | |
| h | |
| }); | |
| let buffs = self.players[player_idx].heart_buffs[slot_idx]; | |
| let mut result = [0u8; 7]; | |
| for i in 0..7 { | |
| result[i] = (hearts[i] + buffs[i]).max(0) as u8; | |
| } | |
| result | |
| } | |
| pub fn get_total_blades(&self, p_idx: usize, db: &CardDatabase) -> u32 { | |
| let mut total = 0u32; | |
| for i in 0..3 { | |
| total += self.get_effective_blades(p_idx, i, db); | |
| } | |
| total | |
| } | |
| pub fn get_total_hearts(&self, p_idx: usize, db: &CardDatabase) -> [u32; 7] { | |
| let mut total = [0u32; 7]; | |
| for i in 0..3 { | |
| let h = self.get_effective_hearts(p_idx, i, db); | |
| for j in 0..7 { | |
| total[j] += h[j] as u32; | |
| } | |
| } | |
| total | |
| } | |
| pub fn get_member_cost(&self, p_idx: usize, card_id: i32, slot_idx: i32, db: &CardDatabase) -> i32 { | |
| let base_id = if card_id >= 0 { card_id as u16 } else { return 0 }; | |
| let m = if let Some(m) = db.get_member(base_id) { m } else { return 0 }; | |
| let mut cost = m.cost as i32; | |
| // 1. Global reduction | |
| cost -= self.players[p_idx].cost_reduction; | |
| // 2. Baton Touch (Rule 12) | |
| if slot_idx >= 0 && slot_idx < 3 { | |
| let old_cid = self.players[p_idx].stage[slot_idx as usize]; | |
| if old_cid >= 0 { | |
| if let Some(old_m) = db.get_member(old_cid as u16) { | |
| cost -= old_m.cost as i32; | |
| } | |
| } | |
| } | |
| // 3. Self constant abilities | |
| for ab in &m.abilities { | |
| if ab.trigger == TriggerType::Constant { | |
| let ctx = AbilityContext { source_card_id: card_id as i16, player_id: p_idx as u8, area_idx: slot_idx as i16, ..Default::default() }; | |
| if ab.conditions.iter().all(|c| self.check_condition(db, p_idx, c, &ctx)) { | |
| let bc = &ab.bytecode; | |
| let mut i = 0; | |
| while i + 3 < bc.len() { | |
| let op = bc[i]; | |
| let v = bc[i+1]; | |
| if op == O_REDUCE_COST { | |
| cost -= v; | |
| } | |
| i += 4; | |
| } | |
| } | |
| } | |
| } | |
| // 4. Temporary cost modifiers | |
| for (cond, amount) in &self.players[p_idx].cost_modifiers { | |
| let ctx = AbilityContext { source_card_id: card_id as i16, player_id: p_idx as u8, area_idx: slot_idx as i16, ..Default::default() }; | |
| if self.check_condition(db, p_idx, cond, &ctx) { | |
| cost += amount; | |
| } | |
| } | |
| cost.max(0) | |
| } | |
| pub fn get_context_card_id(&self, ctx: &AbilityContext) -> Option<i32> { | |
| if ctx.source_card_id >= 0 { | |
| return Some(ctx.source_card_id as i32); | |
| } | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| let cid = self.players[ctx.player_id as usize].stage[ctx.area_idx as usize]; | |
| if cid >= 0 { | |
| return Some(cid as i32); | |
| } | |
| } | |
| None | |
| } | |
| pub fn check_condition(&self, db: &CardDatabase, p_idx: usize, cond: &Condition, ctx: &AbilityContext) -> bool { | |
| let player = &self.players[p_idx]; | |
| let opponent = &self.players[1 - p_idx]; | |
| // Helper to get cid from context or source | |
| let get_cid = || { | |
| if ctx.source_card_id >= 0 { return ctx.source_card_id as i32; } | |
| if ctx.area_idx >= 0 && ctx.area_idx < 3 { return player.stage[ctx.area_idx as usize] as i32; } | |
| -1 | |
| }; | |
| let result = match cond.condition_type { | |
| ConditionType::None => true, | |
| ConditionType::Turn1 => self.turn == 1, | |
| ConditionType::CountStage => player.stage.iter().filter(|&&id| id >= 0).count() as i32 >= cond.value, | |
| ConditionType::CountHand => player.hand.len() as i32 >= cond.value, | |
| ConditionType::CountEnergy => player.energy_zone.len() as i32 >= cond.value, | |
| ConditionType::CountSuccessLive => player.success_lives.len() as i32 >= cond.value, | |
| ConditionType::Baton => { | |
| if self.prev_card_id >= 0 { | |
| if let Some(m) = db.get_member(self.prev_card_id as u16) { | |
| m.char_id as i32 == cond.value | |
| } else { false } | |
| } else { | |
| player.baton_touch_count as i32 >= cond.value | |
| } | |
| } | |
| ConditionType::CountDiscard => player.discard.len() as i32 >= cond.value, | |
| ConditionType::IsCenter => ctx.area_idx == 1, | |
| ConditionType::LifeLead => player.score > opponent.score, | |
| ConditionType::HasMember => { | |
| player.stage.iter().any(|&cid| cid as i32 == cond.value || cid as u16 == cond.attr as u16) | |
| } | |
| ConditionType::HasColor => { | |
| let color_idx = cond.attr as usize; | |
| if color_idx > 0 && color_idx < 7 { | |
| player.stage.iter().filter(|&&cid| cid >= 0).any(|&cid| { | |
| db.get_member(cid as u16).map_or(false, |m| m.hearts[color_idx] > 0) | |
| }) | |
| } else { false } | |
| } | |
| ConditionType::CountGroup => { | |
| let group_id = cond.attr as u8; | |
| let count = player.stage.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_member(cid as u16) } else { None } | |
| }).filter(|m| m.groups.contains(&group_id)).count(); | |
| count as i32 >= cond.value | |
| } | |
| ConditionType::CostCheck => { | |
| let cid = get_cid(); | |
| if cid >= 0 { | |
| let cost = db.get_member(cid as u16).map_or(0, |m| m.cost); | |
| if cond.attr == 0 { cost as i32 <= cond.value } else { cost as i32 >= cond.value } | |
| } else { false } | |
| } | |
| ConditionType::GroupFilter => { | |
| let group_id = cond.attr as u8; | |
| if group_id > 0 { | |
| // Check self first | |
| let cid = get_cid(); | |
| if cid >= 0 { | |
| if let Some(m) = db.get_member(cid as u16) { | |
| if m.groups.contains(&group_id) { return true; } | |
| } | |
| } | |
| // Check stage | |
| player.stage.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_member(cid as u16) } else { None } | |
| }).any(|m| m.groups.contains(&group_id)) | |
| } else { | |
| // Check for "all_revealed" filter in params | |
| if let Some(ft) = cond.params.get("filter_type").and_then(|v| v.as_str()) { | |
| if ft == "all_revealed" { | |
| // Use live_zone_revealed as a proxy for now, or check generic revealed scope if added | |
| // Actually, this condition is usually for "Cheer" checks where cards are revealed. | |
| // For now, return false if we can't track revealed cards yet. | |
| false | |
| } else { false } | |
| } else { false } | |
| } | |
| } | |
| ConditionType::ModalAnswer => cond.value == ctx.choice_index as i32, | |
| ConditionType::HandHasNoLive => { | |
| !player.hand.iter().any(|&cid| db.get_live(cid).is_some()) | |
| } | |
| ConditionType::HasLiveCard => { | |
| player.stage.iter().filter(|&&cid| cid >= 0).any(|&cid| { | |
| if let Some(l) = db.get_live(cid as u16) { | |
| if let Some(mode) = cond.params.get("mode").and_then(|v| v.as_str()) { | |
| if mode == "no_ability" { | |
| return l.abilities.is_empty(); | |
| } | |
| } | |
| true | |
| } else { false } | |
| }) | |
| } | |
| ConditionType::ScoreCompare => { | |
| let comp_type = cond.params.get("type").and_then(|v| v.as_str()).unwrap_or("score"); | |
| let comp_op = cond.params.get("comparison").and_then(|v| v.as_str()).unwrap_or("GT"); | |
| let mode = cond.params.get("mode").and_then(|v| v.as_str()).unwrap_or(""); | |
| let get_val = |p_idx_local: usize, p: &PlayerState| -> i32 { | |
| match comp_type { | |
| "score" => p.score as i32, | |
| "cost" => { | |
| let mut total = 0; | |
| for i in 0..3 { | |
| if p.stage[i] >= 0 { | |
| total += self.get_member_cost(p_idx_local, p.stage[i] as i32, i as i32, db); | |
| } | |
| } | |
| total | |
| }, | |
| _ => 0 | |
| } | |
| }; | |
| let my_val = get_val(p_idx, player); | |
| let opp_val = get_val(1 - p_idx, opponent); | |
| let final_op = if mode == "REVERSED" || mode == "reversed" { | |
| match comp_op { | |
| "GT" => "LT", | |
| "GE" => "LE", | |
| "LT" => "GT", | |
| "LE" => "GE", | |
| _ => comp_op | |
| } | |
| } else { | |
| comp_op | |
| }; | |
| match final_op { | |
| "GT" => my_val > opp_val, | |
| "GE" => my_val >= opp_val, | |
| "LT" => my_val < opp_val, | |
| "LE" => my_val <= opp_val, | |
| "EQ" => my_val == opp_val, | |
| _ => my_val > opp_val | |
| } | |
| } | |
| ConditionType::CountHearts => { | |
| let totals = self.get_total_hearts(p_idx, db); | |
| let sum: u32 = totals.iter().sum(); | |
| sum as i32 >= cond.value | |
| } | |
| ConditionType::CountBlades => { | |
| let total = self.get_total_blades(p_idx, db); | |
| total as i32 >= cond.value | |
| } | |
| ConditionType::TypeCheck => { | |
| // value: 1 = live, 0 = member | |
| if let Some(card_id) = self.get_context_card_id(ctx) { | |
| if cond.value == 1 { | |
| db.get_live(card_id as u16).is_some() | |
| } else { | |
| db.get_member(card_id as u16).is_some() | |
| } | |
| } else { false } | |
| } | |
| ConditionType::IsInDiscard => { | |
| let cid = ctx.source_card_id; | |
| if cid >= 0 { | |
| player.discard.contains(&(cid as u16)) | |
| } else { false } | |
| } | |
| _ => false, | |
| }; | |
| if cond.is_negated { !result } else { result } | |
| } | |
| pub fn check_condition_opcode(&self, db: &CardDatabase, op: i32, val: i32, attr: i32, slot: i32, ctx: &AbilityContext) -> bool { | |
| let p_idx = self.current_player as usize; | |
| let player = &self.players[p_idx]; | |
| let opponent = &self.players[1 - p_idx]; | |
| let get_cid = || { | |
| if ctx.source_card_id >= 0 { return ctx.source_card_id as i32; } | |
| if ctx.area_idx >= 0 && ctx.area_idx < 3 { return player.stage[ctx.area_idx as usize] as i32; } | |
| -1 | |
| }; | |
| match op { | |
| C_TR1 => self.turn == 1, | |
| C_STG => player.stage.iter().filter(|&&id| id >= 0).count() as i32 >= val, | |
| C_HND => player.hand.len() as i32 >= val, | |
| C_ENR => player.energy_zone.len() as i32 >= val, | |
| C_SCS_LIV => player.success_lives.len() as i32 >= val, | |
| C_DSR => player.discard.len() as i32 >= val, | |
| C_CTR => ctx.area_idx == 1, | |
| C_LLD => player.score > opponent.score, | |
| C_HAS_MEMBER => player.stage.iter().any(|&cid| cid as i32 == val || cid as i32 == attr), // attr often 0 or unit id | |
| C_CLR => { | |
| let color_idx = attr as usize; | |
| if color_idx > 0 && color_idx < 7 { | |
| player.stage.iter().filter(|&&cid| cid >= 0).any(|&cid| { | |
| db.get_member(cid as u16).map_or(false, |m| m.hearts[color_idx] > 0) | |
| }) | |
| } else { false } | |
| }, | |
| C_GRP => { | |
| let group_id = attr as u8; | |
| let count = player.stage.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_member(cid as u16) } else { None } | |
| }).filter(|m| m.groups.contains(&group_id)).count(); | |
| count as i32 >= val | |
| }, | |
| C_GRP_FLT => { | |
| let group_id = attr as u8; | |
| let cid = get_cid(); | |
| if cid >= 0 { | |
| if let Some(m) = db.get_member(cid as u16) { | |
| m.groups.contains(&group_id) | |
| } else { false } | |
| } else { false } | |
| }, | |
| C_BATON => { | |
| if self.prev_card_id >= 0 { | |
| if let Some(m) = db.get_member(self.prev_card_id as u16) { | |
| m.char_id as i32 == val | |
| } else { false } | |
| } else { | |
| player.baton_touch_count as i32 >= val | |
| } | |
| }, | |
| C_MODAL_ANSWER => val == ctx.choice_index as i32, | |
| C_HND_NO_LIVE => !player.hand.iter().any(|&cid| db.get_live(cid).is_some()), | |
| C_CMP => { | |
| // attr: 0=score, 1=cost | |
| // slot >> 4: 0=GE, 1=LE, 2=GT, 3=LT, 4=EQ | |
| let comp_op = (slot >> 4) & 0x0F; | |
| let get_val = |p: &PlayerState| -> i32 { | |
| if attr == 0 { p.score as i32 } | |
| else if attr == 1 { | |
| // Simple cost sum | |
| let mut total = 0; | |
| for i in 0..3 { | |
| if p.stage[i] >= 0 { | |
| // Raw cost usually sufficient for comparisons | |
| if let Some(m) = db.get_member(p.stage[i] as u16) { | |
| total += m.cost as i32; | |
| } | |
| } | |
| } | |
| total | |
| } else { 0 } | |
| }; | |
| let my_val = get_val(player); | |
| let opp_val = get_val(opponent); | |
| match comp_op { | |
| 0 => my_val >= opp_val, // GE | |
| 1 => my_val <= opp_val, // LE | |
| 2 => my_val > opp_val, // GT | |
| 3 => my_val < opp_val, // LT | |
| 4 => my_val == opp_val, // EQ | |
| _ => my_val > opp_val // Default GT | |
| } | |
| }, | |
| C_HRT => { | |
| let totals = self.get_total_hearts(p_idx, db); | |
| let sum: u32 = totals.iter().sum(); | |
| sum as i32 >= val | |
| }, | |
| C_BLD => { | |
| let total = self.get_total_blades(p_idx, db); | |
| total as i32 >= val | |
| }, | |
| C_TYPE_CHECK => { | |
| // val: 1=live, 0=member | |
| if let Some(card_id) = self.get_context_card_id(ctx) { | |
| if val == 1 { db.get_live(card_id as u16).is_some() } | |
| else { db.get_member(card_id as u16).is_some() } | |
| } else { false } | |
| }, | |
| C_IS_IN_DISCARD => { | |
| let cid = ctx.source_card_id; | |
| if cid >= 0 { player.discard.contains(&(cid as u16)) } else { false } | |
| }, | |
| _ => true // Default to true if not strictly implemented | |
| } | |
| } | |
| pub fn check_cost(&self, p_idx: usize, cost: &Cost, ctx: &AbilityContext) -> bool { | |
| let player = &self.players[p_idx]; | |
| let val = cost.value as usize; | |
| match cost.cost_type { | |
| AbilityCostType::None => true, | |
| AbilityCostType::Energy => { | |
| let available = player.tapped_energy.iter().copied().filter(|&t| !t).count() as i32; | |
| available >= cost.value | |
| }, | |
| AbilityCostType::TapSelf => { | |
| if ctx.area_idx >= 0 { | |
| !player.tapped_members[ctx.area_idx as usize] | |
| } else { false } | |
| }, | |
| AbilityCostType::TapMember => { | |
| // Check if there are enough untapped members to tap | |
| let untapped_count = player.tapped_members.iter().filter(|&&t| !t).count(); | |
| untapped_count >= val | |
| }, | |
| AbilityCostType::TapEnergy => { | |
| let untapped_energy = player.tapped_energy.iter().filter(|&&t| !t).count(); | |
| untapped_energy >= val | |
| }, | |
| AbilityCostType::DiscardHand => { | |
| player.hand.len() >= val | |
| }, | |
| AbilityCostType::SacrificeSelf => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| player.stage[ctx.area_idx as usize] >= 0 | |
| } else { false } | |
| }, | |
| AbilityCostType::RevealHand => { | |
| player.hand.len() >= val | |
| }, | |
| AbilityCostType::SacrificeUnder => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| player.stage_energy[ctx.area_idx as usize].len() >= val | |
| } else { false } | |
| }, | |
| AbilityCostType::DiscardEnergy => { | |
| player.energy_zone.len() >= val | |
| }, | |
| AbilityCostType::ReturnMemberToDeck => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| player.stage[ctx.area_idx as usize] >= 0 | |
| } else { false } | |
| }, | |
| AbilityCostType::ReturnDiscardToDeck => { | |
| player.discard.len() >= val | |
| }, | |
| _ => true // Assume other costs executable for validation for now | |
| } | |
| } | |
| pub fn pay_cost(&mut self, db: &CardDatabase, p_idx: usize, cost: &Cost, ctx: &AbilityContext) -> bool { | |
| match cost.cost_type { | |
| AbilityCostType::None => true, | |
| AbilityCostType::Energy => { | |
| let untap_indices: Vec<usize> = self.players[p_idx].tapped_energy.iter().copied().enumerate() | |
| .filter(|item| !item.1).map(|(i, _)| i).take(cost.value as usize).collect(); | |
| if untap_indices.len() < cost.value as usize { return false; } | |
| for idx in untap_indices { self.players[p_idx].tapped_energy[idx] = true; } | |
| true | |
| } | |
| AbilityCostType::TapSelf => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| self.players[p_idx].tapped_members[ctx.area_idx as usize] = true; | |
| true | |
| } else { false } | |
| }, | |
| AbilityCostType::TapMember => { | |
| let player = &mut self.players[p_idx]; | |
| let mut needed = cost.value as usize; | |
| for i in 0..3 { | |
| if !player.tapped_members[i] && player.stage[i] >= 0 { | |
| player.tapped_members[i] = true; | |
| needed -= 1; | |
| if needed == 0 { break; } | |
| } | |
| } | |
| needed == 0 | |
| }, | |
| AbilityCostType::TapEnergy => { | |
| let player = &mut self.players[p_idx]; | |
| let mut needed = cost.value as usize; | |
| for i in 0..player.tapped_energy.len() { | |
| if !player.tapped_energy[i] { | |
| player.tapped_energy[i] = true; | |
| needed -= 1; | |
| if needed == 0 { break; } | |
| } | |
| } | |
| needed == 0 | |
| }, | |
| AbilityCostType::DiscardHand => { | |
| let player = &mut self.players[p_idx]; | |
| let count = cost.value as usize; | |
| if player.hand.len() < count { return false; } | |
| for _ in 0..count { | |
| if let Some(cid) = player.hand.pop() { | |
| player.discard.push(cid); | |
| } | |
| } | |
| true | |
| }, | |
| AbilityCostType::SacrificeSelf => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| let slot = ctx.area_idx as usize; | |
| let cid = self.players[p_idx].stage[slot]; | |
| if cid >= 0 { | |
| // Dispatch OnLeaves trigger - No mutable borrow of player active here | |
| let mut leave_ctx = ctx.clone(); | |
| leave_ctx.source_card_id = cid; | |
| leave_ctx.area_idx = ctx.area_idx; | |
| self.trigger_abilities(db, TriggerType::OnLeaves, &leave_ctx); | |
| // Now re-borrow player to finish state update | |
| let player = &mut self.players[p_idx]; | |
| player.stage[slot] = -1; | |
| player.discard.push(cid as u16); | |
| let under_cards = std::mem::take(&mut player.stage_energy[slot]); | |
| player.discard.extend(under_cards); | |
| player.stage_energy_count[slot] = 0; | |
| true | |
| } else { false } | |
| } else { false } | |
| }, | |
| AbilityCostType::RevealHand => true, | |
| AbilityCostType::SacrificeUnder => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| let player = &mut self.players[p_idx]; | |
| let count = cost.value as usize; | |
| let slot = ctx.area_idx as usize; | |
| if player.stage_energy[slot].len() < count { return false; } | |
| for _ in 0..count { | |
| if let Some(cid) = player.stage_energy[slot].pop() { | |
| player.discard.push(cid); | |
| } | |
| } | |
| player.stage_energy_count[slot] = player.stage_energy[slot].len() as u8; | |
| true | |
| } else { false } | |
| }, | |
| AbilityCostType::DiscardEnergy => { | |
| let player = &mut self.players[p_idx]; | |
| let count = cost.value as usize; | |
| if player.energy_zone.len() < count { return false; } | |
| for _ in 0..count { | |
| if let Some(cid) = player.energy_zone.pop() { | |
| player.discard.push(cid); | |
| } | |
| } | |
| true | |
| }, | |
| AbilityCostType::ReturnMemberToDeck => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| let slot = ctx.area_idx as usize; | |
| let cid = self.players[p_idx].stage[slot]; | |
| if cid >= 0 { | |
| // Dispatch OnLeaves trigger | |
| let mut leave_ctx = ctx.clone(); | |
| leave_ctx.source_card_id = cid; | |
| leave_ctx.area_idx = slot as i16; | |
| self.trigger_abilities(db, TriggerType::OnLeaves, &leave_ctx); | |
| let player = &mut self.players[p_idx]; | |
| player.stage[slot] = -1; | |
| player.deck.insert(0, cid as u16); | |
| true | |
| } else { false } | |
| } else { false } | |
| }, | |
| AbilityCostType::ReturnDiscardToDeck => { | |
| let player = &mut self.players[p_idx]; | |
| let count = cost.value as usize; | |
| if player.discard.len() < count { return false; } | |
| for _ in 0..count { | |
| if let Some(cid) = player.discard.pop() { | |
| player.deck.push(cid); | |
| } | |
| } | |
| true | |
| }, | |
| _ => false, | |
| } | |
| } | |
| // Optimization 1: Fixed-size stack | |
| pub fn resolve_bytecode(&mut self, db: &CardDatabase, bytecode: &[i32], ctx_in: &AbilityContext) { | |
| // FAST PATH: Try hardcoded native implementation first | |
| if ctx_in.program_counter == 0 { | |
| if crate::core::hardcoded::execute_hardcoded_ability(self, db, ctx_in.source_card_id as i32, ctx_in.ability_index as usize, ctx_in) { | |
| return; | |
| } | |
| } | |
| const MAX_DEPTH: usize = 8; | |
| // Stack stores (bytecode, ip, context) | |
| let mut stack: [(&[i32], usize, AbilityContext); MAX_DEPTH] = [(&[], 0, AbilityContext::default()); MAX_DEPTH]; | |
| let mut sp = 0; | |
| stack[sp] = (bytecode, ctx_in.program_counter as usize, *ctx_in); | |
| sp += 1; | |
| let mut cond = true; | |
| let mut steps = 0; | |
| while sp > 0 { | |
| sp -= 1; | |
| let (cur_bc, mut ip, mut ctx) = stack[sp]; | |
| let len = cur_bc.len(); | |
| while ip < len && steps < 1000 { | |
| steps += 1; | |
| if ip + 3 >= len { break; } | |
| let op = cur_bc[ip]; | |
| let v = cur_bc[ip+1]; | |
| let a = cur_bc[ip+2]; | |
| let s = cur_bc[ip+3]; | |
| ip += 4; | |
| // Stop execution if we paused for input | |
| if self.phase == Phase::Response { | |
| break; | |
| } | |
| if op == 0 { continue; } | |
| if op == O_RETURN { break; } | |
| let mut is_negated = false; | |
| let mut real_op = op; | |
| if real_op >= 1000 { | |
| is_negated = true; | |
| real_op -= 1000; | |
| } | |
| let mut target_slot = s; | |
| if target_slot == 10 { | |
| target_slot = ctx.target_slot as i32; | |
| } | |
| // Handle Condition Opcodes (200-255) | |
| if real_op >= 200 && real_op <= 255 { | |
| let passed = self.check_condition_opcode(db, real_op, v, a, target_slot, &ctx); | |
| cond = if is_negated { !passed } else { passed }; | |
| continue; | |
| } | |
| if real_op == O_JUMP { | |
| ip = (ip as isize + v as isize * 4) as usize; | |
| continue; | |
| } | |
| if real_op == O_JUMP_F { | |
| if !cond { | |
| ip = (ip as isize + v as isize * 4) as usize; | |
| } | |
| continue; | |
| } | |
| if real_op == O_SELECT_MODE { | |
| if ctx.choice_index == -1 { | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); // Re-execute SELECT_MODE on resumption | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_SELECT_MODE as i16; | |
| return; | |
| } | |
| let choice = ctx.choice_index as i32; | |
| let mut jumped = false; | |
| if choice >= 0 && choice < v { | |
| let jump_ip = (ip / 4) + 1 + choice as usize; | |
| if jump_ip * 4 + 1 < len { | |
| let offset = cur_bc[jump_ip * 4 + 1]; | |
| let new_ip = (jump_ip + offset as usize) * 4; | |
| if new_ip < len { | |
| ip = new_ip; | |
| jumped = true; | |
| } | |
| } | |
| } | |
| if !jumped { | |
| ip = ip + (v as usize + 1) * 4; | |
| } | |
| continue; | |
| } | |
| if real_op >= 200 { | |
| let target_slot_cond = s & 0x0F; | |
| // Condition Logic Copied/Reused | |
| let check = match real_op { | |
| C_TR1 => self.turn == 1, | |
| C_HAS_MEMBER => { | |
| let member_id = a as u16; | |
| self.players[ctx.player_id as usize].stage.iter().filter(|&&cid| { | |
| if cid < 0 { return false; } | |
| cid as u16 == member_id | |
| }).count() as i32 >= v | |
| }, | |
| C_CLR => { | |
| let color_idx = a as usize; | |
| if color_idx > 0 && color_idx < 7 { | |
| self.players[ctx.player_id as usize].stage.iter().filter(|&&cid| cid >= 0).any(|&cid| { | |
| db.get_member(cid as u16).map_or(false, |m| m.hearts[color_idx] > 0) | |
| }) | |
| } else { false } | |
| }, | |
| C_STG => self.players[ctx.player_id as usize].stage.iter().filter(|&&c| c >= 0).count() as i32 >= v, | |
| C_HND => self.players[ctx.player_id as usize].hand.len() as i32 >= v, | |
| C_DSR => self.players[ctx.player_id as usize].discard.len() as i32 >= v, | |
| C_CTR => ctx.area_idx == 1, | |
| C_LLD => { | |
| let p = &self.players[ctx.player_id as usize]; | |
| let o = &self.players[1 - ctx.player_id as usize]; | |
| p.score > o.score | |
| }, | |
| C_GRP => { | |
| let group_id = a as u8; | |
| let p = &self.players[ctx.player_id as usize]; | |
| let count = if target_slot_cond == 1 { | |
| p.live_zone.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_live(cid as u16) } else { None } | |
| }).filter(|l| l.groups.contains(&group_id)).count() | |
| } else { | |
| p.stage.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_member(cid as u16) } else { None } | |
| }).filter(|m| m.groups.contains(&group_id)).count() | |
| }; | |
| count as i32 >= v | |
| }, | |
| C_GRP_FLT => { | |
| let group_id = a as u8; | |
| let p = &self.players[ctx.player_id as usize]; | |
| let count = if target_slot_cond == 1 { | |
| p.live_zone.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_live(cid as u16) } else { None } | |
| }).filter(|l| l.groups.contains(&group_id)).count() | |
| } else { | |
| p.stage.iter().filter_map(|&cid| { | |
| if cid >= 0 { db.get_member(cid as u16) } else { None } | |
| }).filter(|m| m.groups.contains(&group_id)).count() | |
| }; | |
| count as i32 >= v | |
| }, | |
| C_OPH => self.players[1 - ctx.player_id as usize].stage.iter().filter(|&&c| c >= 0).count() as i32 >= v, | |
| C_SLF_GRP => { | |
| if ctx.area_idx >= 0 { | |
| let cid = self.players[ctx.player_id as usize].stage[ctx.area_idx as usize]; | |
| if cid >= 0 { | |
| db.get_member(cid as u16).map_or(false, |m| m.groups.contains(&(a as u8))) | |
| } else { false } | |
| } else { false } | |
| }, | |
| C_MODAL_ANSWER => ctx.choice_index == v as i16, | |
| C_ENR => self.players[ctx.player_id as usize].energy_zone.len() as i32 >= v, | |
| C_HAS_LIVE => self.players[ctx.player_id as usize].live_zone.iter().any(|&c| c >= 0), | |
| C_SCS_LIV => self.players[ctx.player_id as usize].success_lives.len() as i32 >= v, | |
| C_OPP_ENR_DIF => { | |
| let opp = &self.players[1 - ctx.player_id as usize]; | |
| let p = &self.players[ctx.player_id as usize]; | |
| (opp.energy_zone.len() as i32 - p.energy_zone.len() as i32) >= v | |
| }, | |
| C_CMP => { | |
| let p_score = self.players[ctx.player_id as usize].score; | |
| let o_score = self.players[1 - ctx.player_id as usize].score; | |
| if v > 0 { p_score as i32 >= v } else { p_score > o_score } | |
| }, | |
| C_HAS_CHOICE => true, | |
| C_OPPONENT_CHOICE => true, | |
| C_HRT => { | |
| let mut total = 0; | |
| let p = &self.players[ctx.player_id as usize]; | |
| for i in 0..3 { | |
| if p.stage[i] >= 0 { | |
| let mut h = 0; | |
| if let Some(m) = db.get_member(p.stage[i] as u16) { | |
| h += m.hearts.iter().sum::<u8>() as i32; | |
| } | |
| h += p.heart_buffs[i].iter().sum::<i32>(); | |
| total += h; | |
| } | |
| } | |
| total >= v | |
| }, | |
| C_BLD => { | |
| let mut total = 0; | |
| let p = &self.players[ctx.player_id as usize]; | |
| for i in 0..3 { | |
| if p.stage[i] >= 0 { | |
| let mut b = 0; | |
| if let Some(m) = db.get_member(p.stage[i] as u16) { | |
| b += m.blades as i32; | |
| } | |
| b += p.blade_buffs[i]; | |
| total += b; | |
| } | |
| } | |
| total >= v | |
| }, | |
| C_DK_REFR => self.players[ctx.player_id as usize].deck_refreshed_this_turn, | |
| C_HAS_MOVED => { | |
| if ctx.area_idx >= 0 && (ctx.area_idx as usize) < 3 { | |
| self.players[ctx.player_id as usize].moved_members_this_turn[ctx.area_idx as usize] | |
| } else { false } | |
| }, | |
| C_BATON => { | |
| if self.prev_card_id >= 0 { | |
| if let Some(m) = db.get_member(self.prev_card_id as u16) { | |
| m.char_id as i32 == v | |
| } else { false } | |
| } else { | |
| // Default to checking baton count if no prev card context (e.g. static check) | |
| self.players[ctx.player_id as usize].baton_touch_count as i32 >= v | |
| } | |
| }, | |
| C_TYPE_CHECK => { | |
| if let Some(card_id) = self.get_context_card_id(&ctx) { | |
| if v == 1 { | |
| db.get_live(card_id as u16).is_some() | |
| } else { | |
| db.get_member(card_id as u16).is_some() | |
| } | |
| } else { false } | |
| }, | |
| C_HND_INC => self.players[ctx.player_id as usize].hand_increased_this_turn as i32 >= v, | |
| C_LIV_ZN => self.players[ctx.player_id as usize].live_zone.iter().filter(|&&c| c >= 0).count() as i32 >= v, | |
| _ => false | |
| }; | |
| cond = if is_negated { !check } else { check }; | |
| } | |
| else if cond { | |
| let p_idx = ctx.player_id as usize; | |
| match real_op { | |
| O_DRAW => self.draw_cards(p_idx, v as u32), | |
| O_BOOST => self.players[p_idx].live_score_bonus += v, | |
| O_CHARGE => { | |
| for _ in 0..v { | |
| if let Some(cid) = self.players[p_idx].energy_deck.pop() { | |
| self.players[p_idx].energy_zone.push(cid); | |
| self.players[p_idx].tapped_energy.push(false); | |
| } | |
| } | |
| }, | |
| O_ADD_H => { | |
| if target_slot == 90 { // Source: reveal_zone (looked_cards) | |
| if !self.silent { self.log(format!(" Effect: ADD_TO_HAND from Reveal Zone ({} cards)", v)); } | |
| let count = v as usize; | |
| for _ in 0..count { | |
| if !self.players[p_idx].looked_cards.is_empty() { | |
| let cid = self.players[p_idx].looked_cards.remove(0); | |
| self.players[p_idx].hand.push(cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| if !self.silent { self.log(format!(" Added card {} to hand", cid)); } | |
| } | |
| } | |
| } else { | |
| if !self.silent { self.log(format!(" Effect: Draw {} card(s)", v)); } | |
| self.draw_cards(p_idx, v as u32) | |
| } | |
| }, | |
| O_DRAW_UNTIL => { | |
| let target_hand_size = v as usize; | |
| let current_hand_size = self.players[p_idx].hand.len(); | |
| if current_hand_size < target_hand_size { | |
| let to_draw = (target_hand_size - current_hand_size) as u32; | |
| self.draw_cards(p_idx, to_draw); | |
| } | |
| }, | |
| O_PAY_ENERGY => { | |
| let mut paid = 0; | |
| let count = v; | |
| for i in 0..self.players[p_idx].tapped_energy.len() { | |
| if paid >= count { break; } | |
| if !self.players[p_idx].tapped_energy[i] { | |
| self.players[p_idx].tapped_energy[i] = true; | |
| paid += 1; | |
| } | |
| } | |
| if !self.silent { self.log(format!(" Effect: PAID {} energy", paid)); } | |
| }, | |
| O_ACTIVATE_MEMBER => { | |
| if target_slot == 4 && ctx.area_idx >= 0 { | |
| self.players[p_idx].tapped_members[ctx.area_idx as usize] = false; | |
| } else if target_slot == 1 { | |
| for i in 0..3 { self.players[p_idx].tapped_members[i] = false; } | |
| } | |
| }, | |
| O_ACTIVATE_ENERGY => { | |
| if !self.silent { self.log(format!(" Effect: Activate {} Energy cards. State BEFORE: {:?}", v, self.players[p_idx].tapped_energy)); } | |
| let mut count = 0; | |
| for i in 0..self.players[p_idx].tapped_energy.len() { | |
| if count >= v { break; } | |
| if self.players[p_idx].tapped_energy[i] { | |
| self.players[p_idx].tapped_energy[i] = false; | |
| count += 1; | |
| } | |
| } | |
| if !self.silent { self.log(format!(" Effect: Activate Energy DONE. Count={} State AFTER: {:?}", count, self.players[p_idx].tapped_energy)); } | |
| }, | |
| O_BLADES => { | |
| if target_slot == 4 && ctx.area_idx >= 0 { | |
| let t = ctx.area_idx as usize; | |
| self.players[p_idx].blade_buffs[t] += v; | |
| if !self.silent { self.log(format!(" Effect: +{} Blades to slot {}", v, t)); } | |
| } else if target_slot == 1 { | |
| for t in 0..3 { self.players[p_idx].blade_buffs[t] += v; } | |
| if !self.silent { self.log(format!(" Effect: +{} Blades to all slots", v)); } | |
| } | |
| }, | |
| O_HEARTS => { | |
| let mut color = a as usize; | |
| if color == 0 { color = ctx.selected_color as usize; } | |
| if color < 7 { | |
| if target_slot == 4 && ctx.area_idx >= 0 { | |
| let t = ctx.area_idx as usize; | |
| self.players[p_idx].heart_buffs[t][color] += v as i32; | |
| if !self.silent { self.log(format!(" Effect: Gain {} {:?} Heart(s) in slot {}", v, color, t)); } | |
| } else if target_slot == 1 { | |
| for t in 0..3 { self.players[p_idx].heart_buffs[t][color] += v as i32; } | |
| if !self.silent { self.log(format!(" Effect: Gain {} {:?} Heart(s) in all slots", v, color)); } | |
| } | |
| } | |
| }, | |
| O_COLOR_SELECT => { | |
| if ctx.choice_index == -1 { | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_COLOR_SELECT as i16; | |
| return; | |
| } | |
| ctx.selected_color = ctx.choice_index; | |
| }, | |
| O_REDUCE_COST => self.players[p_idx].cost_reduction += v, | |
| O_RECOV_L => { | |
| for _ in 0..v { | |
| if let Some(idx) = self.players[p_idx].discard.iter().position(|&cid| db.get_live(cid).is_some()) { | |
| let cid = self.players[p_idx].discard.remove(idx); | |
| self.players[p_idx].hand.push(cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| } | |
| } | |
| }, | |
| O_RECOV_M => { | |
| for _ in 0..v { | |
| if let Some(idx) = self.players[p_idx].discard.iter().position(|&cid| db.get_member(cid).is_some()) { | |
| let cid = self.players[p_idx].discard.remove(idx); | |
| self.players[p_idx].hand.push(cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| } | |
| } | |
| }, | |
| O_SET_BLADES => { | |
| if target_slot == 4 && ctx.area_idx >= 0 { | |
| self.players[p_idx].blade_buffs[ctx.area_idx as usize] = v; | |
| } | |
| }, | |
| O_TAP_O => { | |
| if ctx.choice_index == -1 { | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_TAP_O as i16; | |
| return; | |
| } | |
| let o_idx = 1 - p_idx; | |
| let slot = ctx.choice_index as usize; | |
| if slot < 3 { self.players[o_idx].tapped_members[slot] = true; } | |
| }, | |
| O_TAP_M => { | |
| let slot = a as usize; | |
| if slot < 3 { self.players[p_idx].tapped_members[slot] = true; } | |
| }, | |
| O_SET_SCORE => { self.players[p_idx].score = v as u32; }, | |
| O_LOOK_AND_CHOOSE => { | |
| let count = v as usize; | |
| if self.players[p_idx].looked_cards.is_empty() { | |
| // Bits 12-15 of attr = Source Zone (6=Hand, 7=Discard, 8=Deck) | |
| let src_bits = (a >> 12) & 0x0F; | |
| let source_zone = if src_bits != 0 { src_bits as i32 } else { target_slot }; | |
| let deck_len = self.players[p_idx].deck.len(); | |
| let hand_len = self.players[p_idx].hand.len(); | |
| let discard_len = self.players[p_idx].discard.len(); | |
| match source_zone { | |
| 6 => { | |
| let actual = count.min(hand_len); | |
| for _ in 0..actual { | |
| if let Some(cid) = self.players[p_idx].hand.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| } | |
| } | |
| }, | |
| 7 => { | |
| let actual = count.min(discard_len); | |
| for _ in 0..actual { | |
| if let Some(cid) = self.players[p_idx].discard.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| } | |
| } | |
| }, | |
| _ => { | |
| let actual = count.min(deck_len); | |
| for _ in 0..actual { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| } | |
| } | |
| } | |
| } | |
| if !self.silent { self.log(format!("Effect: LOOK_AND_CHOOSE - Populated {} cards from source {}", self.players[p_idx].looked_cards.len(), source_zone)); } | |
| } | |
| if ctx.choice_index == -1 { | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_LOOK_AND_CHOOSE as i16; | |
| self.pending_choice_type = "LOOK_AND_CHOOSE".to_string(); | |
| return; | |
| } | |
| let choice = ctx.choice_index; | |
| let mut revealed = std::mem::take(&mut self.players[p_idx].looked_cards); | |
| if choice >= 0 && (choice as usize) < revealed.len() { | |
| let chosen = revealed.remove(choice as usize); | |
| // destination based on 'a' or 'target_slot' | |
| // For LOOK_AND_CHOOSE, 'a' often encodes destination if 's' was source | |
| let destination = if a == 1 { 7 } else if a == 30 { 6 } else { target_slot }; | |
| match destination { | |
| 7 => { self.players[p_idx].discard.push(chosen); } | |
| 8 => { self.players[p_idx].deck.push(chosen); } | |
| 6 => { | |
| self.players[p_idx].hand.push(chosen); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| if !self.silent { self.log(format!(" Choice: Added card {} to hand", chosen)); } | |
| } | |
| _ => { | |
| self.players[p_idx].hand.push(chosen); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| } | |
| } | |
| } else { | |
| if !self.silent { self.log(" Choice: Passed (None selected)".to_string()); } | |
| } | |
| let dest_discard = (a & 0x01) != 0; | |
| if dest_discard { | |
| self.players[p_idx].discard.extend(revealed); | |
| } else { | |
| self.players[p_idx].deck.extend(revealed); | |
| } | |
| }, | |
| O_SEARCH_DECK => { | |
| let search_target = ctx.target_slot as usize; | |
| if search_target < self.players[p_idx].deck.len() { | |
| let cid = self.players[p_idx].deck.remove(search_target); | |
| self.players[p_idx].hand.push(cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| let mut rng = Pcg64::from_os_rng(); | |
| self.players[p_idx].deck.shuffle(&mut rng); | |
| } | |
| }, | |
| O_MOVE_TO_DECK => { | |
| let count = v as usize; | |
| for _ in 0..count { | |
| if a == 1 { | |
| if let Some(cid) = self.players[p_idx].discard.pop() { | |
| self.players[p_idx].deck.push(cid); | |
| } | |
| } else { | |
| if let Some(cid) = self.players[p_idx].hand.pop() { | |
| self.players[p_idx].deck.push(cid); | |
| } | |
| } | |
| } | |
| let mut rng = Pcg64::from_os_rng(); | |
| self.players[p_idx].deck.shuffle(&mut rng); | |
| }, | |
| O_ORDER_DECK => { | |
| // LOOK_AND_CHOOSE_ORDER: Look at top N, choose order, discard rest | |
| let count = v as usize; | |
| let deck_len = self.players[p_idx].deck.len(); | |
| // Step 1: Populate looked_cards if empty | |
| if self.players[p_idx].looked_cards.is_empty() { | |
| if deck_len > 0 && count > 0 { | |
| let actual_count = count.min(deck_len); | |
| // Remove cards from deck and store in looked_cards | |
| for _ in 0..actual_count { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| } | |
| } | |
| if !self.silent { self.log(format!(" Effect: ORDER_DECK - Looking at {} cards", actual_count)); } | |
| } | |
| } | |
| // Step 2: Handle Selection or Pause | |
| if !self.players[p_idx].looked_cards.is_empty() { | |
| if ctx.choice_index == -1 { | |
| // Initial Pause | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_ORDER_DECK as i16; | |
| self.pending_choice_type = "ORDER_DECK".to_string(); | |
| return; | |
| } | |
| let choice = ctx.choice_index; | |
| let looked_len = self.players[p_idx].looked_cards.len(); | |
| if choice >= 0 && (choice as usize) < looked_len { | |
| // Chosen a card to put on TOP of deck | |
| let cid = self.players[p_idx].looked_cards.remove(choice as usize); | |
| self.players[p_idx].deck.push(cid); | |
| if !self.silent { self.log(format!(" Effect: ORDER_DECK - Placed card on top. {} remaining.", self.players[p_idx].looked_cards.len())); } | |
| // If more cards remain, PAUSE AGAIN (loop) | |
| if !self.players[p_idx].looked_cards.is_empty() { | |
| // Pausing again for next card | |
| self.phase = Phase::Response; | |
| // Important: Need to update the context stored in pending to NOT have a choice yet | |
| let mut new_ctx = ctx.clone(); | |
| new_ctx.choice_index = -1; | |
| new_ctx.program_counter = (ip as u16).saturating_sub(4); // Stay on O_ORDER_DECK | |
| self.pending_ctx = Some(new_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_ORDER_DECK as i16; | |
| self.pending_choice_type = "ORDER_DECK".to_string(); | |
| return; | |
| } | |
| } else { | |
| // "Done" or Invalid choice implies handling remaining cards based on remainder mode | |
| // Attr (ip-2): 0=Discard, 1=DeckTop, 2=DeckBottom | |
| let remainder_mode = a as u8; | |
| if remainder_mode == 1 { // DeckTop | |
| if !self.silent { self.log(format!(" Effect: ORDER_DECK - Returning remaining {} cards to TOP of deck", looked_len)); } | |
| for cid in self.players[p_idx].looked_cards.drain(..) { | |
| self.players[p_idx].deck.push(cid); | |
| } | |
| } else if remainder_mode == 2 { // DeckBottom | |
| if !self.silent { self.log(format!(" Effect: ORDER_DECK - Returning remaining {} cards to BOTTOM of deck", looked_len)); } | |
| // Insert at index 0 | |
| for cid in self.players[p_idx].looked_cards.drain(..) { | |
| self.players[p_idx].deck.insert(0, cid); | |
| } | |
| } else { // Discard (0 or default) | |
| if !self.silent { self.log(format!(" Effect: ORDER_DECK - Discarding remaining {} cards", looked_len)); } | |
| for cid in self.players[p_idx].looked_cards.drain(..) { | |
| self.players[p_idx].discard.push(cid); | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| O_SWAP_CARDS => { | |
| // v = count of cards to move | |
| // s = destination zone (from TargetType enum): | |
| // CARD_HAND=6, CARD_DISCARD=7, CARD_DECK_TOP=8 | |
| // Source is deck top (most common for "looked" cards) | |
| let count = v as usize; | |
| let dest = target_slot; // target_slot holds the TargetType enum value | |
| if !self.silent { self.log(format!(" Effect: SWAP_CARDS {} cards, dest={}", count, dest)); } | |
| for _ in 0..count { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| match dest { | |
| 7 => { // TargetType.CARD_DISCARD = 7 | |
| self.players[p_idx].discard.push(cid); | |
| }, | |
| 8 => { // TargetType.CARD_DECK_TOP = 8 | |
| self.players[p_idx].deck.push(cid); | |
| }, | |
| 6 => { // TargetType.CARD_HAND = 6 | |
| self.players[p_idx].hand.push(cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| }, | |
| _ => { | |
| // Default: put in discard (most common destination) | |
| self.players[p_idx].discard.push(cid); | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| O_META_RULE => { | |
| // a=0 -> cheer_mod (most common) | |
| // We store it as string to match Python or just use an enum later | |
| if a == 0 { | |
| // meta_rules removed as it was unused logic | |
| // self.players[p_idx].meta_rules.push("cheer_mod".to_string()); | |
| } | |
| }, | |
| O_BATON_MOD => { | |
| self.players[p_idx].baton_touch_limit = v as u8; | |
| }, | |
| O_SELECT_MODE => { | |
| // Struct: [SELECT_MODE, NumOptions, 0, 0] | |
| // Followed by NumOptions * [JUMP, Offset, 0, 0] | |
| let choice = ctx.choice_index.max(0) as usize; | |
| let num_options = v as usize; | |
| if choice < num_options { | |
| // Skip "choice" jump instructions (each 4 ints) | |
| ip += choice * 4; | |
| } else { | |
| // Fallback: Pick last or 0? | |
| // Ideally should not happen if choice is validated. | |
| // We consume the jump table if we do nothing, but here we MUST pick one path. | |
| // If we don't jump, we execute the first JUMP (Option 0). | |
| // So doing nothing = Select Option 0. | |
| } | |
| }, | |
| O_TRIGGER_REMOTE => { | |
| let target_cid = if target_slot >= 0 && target_slot < 3 { | |
| self.players[p_idx].stage[target_slot as usize] | |
| } else { -1 }; | |
| if target_cid >= 0 { | |
| if let Some(m) = db.get_member(target_cid as u16) { | |
| let ab_idx = v as usize; | |
| if ab_idx < m.abilities.len() { | |
| if sp < MAX_DEPTH { | |
| // Push current state to return to | |
| stack[sp] = (cur_bc, ip, ctx); | |
| sp += 1; | |
| // Start new execution frame for chosen ability | |
| stack[sp] = (&m.abilities[ab_idx].bytecode, 0, ctx); | |
| sp += 1; | |
| // Break from current frame loop to start new frame | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| O_PLAY_MEMBER_FROM_HAND => { | |
| let hand_idx = ctx.choice_index as usize; | |
| let slot_idx = ctx.target_slot as usize; | |
| if hand_idx < self.players[p_idx].hand.len() && slot_idx < 3 { | |
| let card_id = self.players[p_idx].hand[hand_idx]; | |
| if db.get_member(card_id as u16).is_none() { | |
| if !self.silent { self.log(format!("[ERROR] Invalid card type for Stage (ID: {})", card_id)); } | |
| continue; | |
| } | |
| self.players[p_idx].hand.remove(hand_idx); | |
| let old_card_id = self.players[p_idx].stage[slot_idx]; | |
| if old_card_id >= 0 { | |
| // Dispatch OnLeaves trigger | |
| let mut leave_ctx = ctx.clone(); | |
| leave_ctx.source_card_id = old_card_id; | |
| leave_ctx.area_idx = slot_idx as i16; | |
| self.trigger_abilities(db, TriggerType::OnLeaves, &leave_ctx); | |
| self.players[p_idx].discard.push(old_card_id as u16); | |
| } | |
| self.players[p_idx].stage[slot_idx] = card_id as i16; | |
| self.players[p_idx].tapped_members[slot_idx] = false; | |
| self.players[p_idx].moved_members_this_turn[slot_idx] = true; | |
| if !self.silent { self.log(format!("Effect: Play card to Slot {}", slot_idx)); } | |
| let new_ctx = AbilityContext { | |
| source_card_id: card_id as i16, | |
| player_id: p_idx as u8, | |
| area_idx: slot_idx as i16, | |
| ..Default::default() | |
| }; | |
| self.trigger_abilities(db, TriggerType::OnPlay, &new_ctx); | |
| } | |
| }, | |
| O_PLAY_MEMBER_FROM_DISCARD => { | |
| if (target_slot == 4 || target_slot == -1) && ctx.choice_index == -1 { | |
| // Pause for input: Select Stage Slot | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); // Rewind to retry this instruction | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_PLAY_MEMBER_FROM_DISCARD as i16; | |
| self.pending_choice_type = "SELECT_STAGE".to_string(); | |
| return; | |
| } | |
| let slot_idx = if target_slot == 4 || target_slot == -1 { ctx.choice_index as usize } else { target_slot as usize }; | |
| let card_id = if v == 1 { ctx.source_card_id } else { ctx.choice_index }; | |
| if slot_idx < 3 { | |
| if card_id < 0 { | |
| if !self.silent { self.log(format!("[ERROR] Invalid card ID for Stage (ID: {})", card_id)); } | |
| continue; | |
| } | |
| // Check if card is in discard | |
| if let Some(pos) = self.players[p_idx].discard.iter().position(|&cid| cid == card_id as u16) { | |
| self.players[p_idx].discard.remove(pos); | |
| let old_card_id = self.players[p_idx].stage[slot_idx]; | |
| if old_card_id >= 0 { | |
| // Dispatch OnLeaves trigger | |
| let mut leave_ctx = ctx.clone(); | |
| leave_ctx.source_card_id = old_card_id; | |
| leave_ctx.area_idx = slot_idx as i16; | |
| self.trigger_abilities(db, TriggerType::OnLeaves, &leave_ctx); | |
| self.players[p_idx].discard.push(old_card_id as u16); | |
| } | |
| self.players[p_idx].stage[slot_idx] = card_id as i16; | |
| self.players[p_idx].tapped_members[slot_idx] = false; | |
| self.players[p_idx].moved_members_this_turn[slot_idx] = true; | |
| if !self.silent { self.log(format!("Effect: Play card (ID {}) from discard to Slot {}", card_id, slot_idx)); } | |
| let new_ctx = AbilityContext { | |
| source_card_id: card_id as i16, | |
| player_id: p_idx as u8, | |
| area_idx: slot_idx as i16, | |
| ..Default::default() | |
| }; | |
| self.trigger_abilities(db, TriggerType::OnPlay, &new_ctx); | |
| } else { | |
| if !self.silent { self.log(format!("[ERROR] Card {} not found in discard", card_id)); } | |
| } | |
| } | |
| }, | |
| O_PLAY_LIVE_FROM_DISCARD => { | |
| if (target_slot == 4 || target_slot == -1) && ctx.choice_index == -1 { | |
| // Pause for input: Select Live Slot | |
| self.phase = Phase::Response; | |
| let mut p_ctx = ctx.clone(); | |
| p_ctx.program_counter = (ip as u16).saturating_sub(4); // Rewind to retry this instruction | |
| self.pending_ctx = Some(p_ctx); | |
| self.pending_card_id = ctx.source_card_id; | |
| self.pending_ab_idx = ctx.ability_index; | |
| self.pending_effect_opcode = O_PLAY_LIVE_FROM_DISCARD as i16; | |
| self.pending_choice_type = "SELECT_LIVE_SLOT".to_string(); | |
| return; | |
| } | |
| let slot_idx = if target_slot == 4 || target_slot == -1 { ctx.choice_index as usize } else { target_slot as usize }; | |
| let card_id = if v == 1 { ctx.source_card_id } else { ctx.choice_index }; | |
| if card_id < 0 { | |
| if !self.silent { self.log(format!("[ERROR] Invalid card ID for Live (ID: {})", card_id)); } | |
| continue; | |
| } | |
| // Check if card is in discard | |
| if let Some(pos) = self.players[p_idx].discard.iter().position(|&cid| cid == card_id as u16) { | |
| if slot_idx < 10 { | |
| self.players[p_idx].discard.remove(pos); | |
| let old_card_id = self.players[p_idx].live_zone[slot_idx]; | |
| if old_card_id >= 0 { | |
| // If occupied, move old live card to discard | |
| self.players[p_idx].discard.push(old_card_id as u16); | |
| } | |
| self.players[p_idx].live_zone[slot_idx] = card_id as i16; | |
| self.players[p_idx].live_zone_revealed[slot_idx] = true; // Played from discard is visible | |
| self.log(format!("Effect: Play Live card (ID {}) from discard to Live Zone slot {}", card_id, slot_idx)); | |
| } else { | |
| self.log(format!("[ERROR] Invalid live slot {}", slot_idx)); | |
| } | |
| } else { | |
| self.log(format!("[ERROR] Live Card {} not found in discard", card_id)); | |
| } | |
| } | |
| O_ADD_STAGE_ENERGY => { | |
| let slot = if target_slot == 4 && ctx.area_idx >= 0 { ctx.area_idx as usize } else { target_slot as usize }; | |
| if slot < 3 { | |
| for _ in 0..v { | |
| self.players[p_idx].stage_energy[slot].push(2000); | |
| } | |
| self.players[p_idx].stage_energy_count[slot] = self.players[p_idx].stage_energy[slot].len() as u8; | |
| } | |
| }, | |
| O_SET_TAPPED => { | |
| let slot = if target_slot == 4 && ctx.area_idx >= 0 { ctx.area_idx as usize } else { target_slot as usize }; | |
| if slot < 3 { | |
| self.players[p_idx].tapped_members[slot] = v != 0; | |
| } | |
| }, | |
| O_MOVE_MEMBER => { | |
| let src_slot = if ctx.area_idx >= 0 { ctx.area_idx as usize } else { 0 }; | |
| let dst_slot = ctx.target_slot as usize; | |
| if src_slot < 3 && dst_slot < 3 && src_slot != dst_slot { | |
| self.players[p_idx].stage.swap(src_slot, dst_slot); | |
| self.players[p_idx].tapped_members.swap(src_slot, dst_slot); | |
| self.players[p_idx].stage_energy_count.swap(src_slot, dst_slot); | |
| self.players[p_idx].stage_energy.swap(src_slot, dst_slot); | |
| self.players[p_idx].moved_members_this_turn[dst_slot] = true; | |
| self.players[p_idx].moved_members_this_turn[src_slot] = true; | |
| // Dispatch OnPositionChange triggers | |
| for &slot in &[src_slot, dst_slot] { | |
| let cid = self.players[p_idx].stage[slot]; | |
| if cid >= 0 { | |
| let mut pos_ctx = ctx.clone(); | |
| pos_ctx.source_card_id = cid; | |
| pos_ctx.area_idx = slot as i16; | |
| self.trigger_abilities(db, TriggerType::OnPositionChange, &pos_ctx); | |
| } | |
| } | |
| } | |
| }, | |
| O_PLACE_UNDER => { | |
| let slot = ctx.target_slot as usize; | |
| if slot < 3 { | |
| if a == 0 && !self.players[p_idx].hand.is_empty() { | |
| if let Some(cid) = self.players[p_idx].hand.pop() { | |
| self.players[p_idx].stage_energy[slot].push(cid); | |
| self.players[p_idx].stage_energy_count[slot] = self.players[p_idx].stage_energy[slot].len() as u8; | |
| } | |
| } else if a == 1 { | |
| if let Some(cid) = self.players[p_idx].energy_zone.pop() { | |
| self.players[p_idx].tapped_energy.pop(); | |
| self.players[p_idx].stage_energy[slot].push(cid); | |
| self.players[p_idx].stage_energy_count[slot] = self.players[p_idx].stage_energy[slot].len() as u8; | |
| } | |
| } | |
| } | |
| }, | |
| O_BUFF => { | |
| let targets = if target_slot == 4 && ctx.area_idx >= 0 { vec![ctx.area_idx as usize] } | |
| else if target_slot == 1 { vec![0, 1, 2] } else { vec![] }; | |
| for t in targets { self.players[p_idx].blade_buffs[t] += v; } | |
| }, | |
| O_TRANSFORM_COLOR => { | |
| self.players[p_idx].color_transforms.push((0, v as u8)); | |
| }, | |
| O_REDUCE_HEART_REQ => { | |
| let color = s as usize; | |
| if color < 7 { | |
| self.players[p_idx].heart_req_reductions[color] += v; | |
| } | |
| }, | |
| O_GRANT_ABILITY => { | |
| let source_cid = ctx.source_card_id; | |
| if let Some(card) = db.get_member(source_cid as u16) { | |
| // Validate ability index exists | |
| if v >= 0 && (v as usize) < card.abilities.len() { | |
| let ab_idx = v as u16; | |
| let slot_to_choose = if target_slot == 4 { ctx.area_idx } else { target_slot as i16 }; | |
| let targets = if slot_to_choose >= 0 && slot_to_choose < 3 { vec![slot_to_choose as usize] } | |
| else if target_slot == 1 { vec![0, 1, 2] } else { vec![] }; | |
| for t in targets { | |
| let target_cid = self.players[p_idx].stage[t]; | |
| if target_cid >= 0 { | |
| self.players[p_idx].granted_abilities.push((target_cid, source_cid as u16, ab_idx)); | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| O_INCREASE_COST => { | |
| // Simplified: Adds a general cost modifier for now. | |
| // Conditional cost modifiers usually come from Constant abilities. | |
| self.players[p_idx].cost_modifiers.push((Condition { | |
| condition_type: ConditionType::None, | |
| value: 0, | |
| attr: 0, | |
| target_slot: 0, | |
| is_negated: false, | |
| params: serde_json::Value::Null | |
| }, v)); | |
| }, | |
| O_IMMUNITY => { | |
| self.players[p_idx].has_immunity = v != 0; | |
| }, | |
| O_ADD_CONTINUOUS => { | |
| }, | |
| O_MOVE_TO_DISCARD => { | |
| let count = v as usize; | |
| for _ in 0..count { | |
| if a == 1 { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| self.players[p_idx].discard.push(cid); | |
| } | |
| } else if a == 2 { | |
| if let Some(cid) = self.players[p_idx].hand.pop() { | |
| self.players[p_idx].discard.push(cid); | |
| } | |
| } else if a == 3 { | |
| if let Some(cid) = self.players[p_idx].energy_zone.pop() { | |
| self.players[p_idx].tapped_energy.pop(); | |
| self.players[p_idx].discard.push(cid); | |
| } | |
| } | |
| } | |
| }, | |
| O_REVEAL_UNTIL => { | |
| let cond_type = v; | |
| let dest_slot = target_slot; | |
| let mut found = false; | |
| let mut revealed_count = 0; | |
| while !found { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| revealed_count += 1; | |
| // Check condition based on bits in 'a' (attr) | |
| let matches = match cond_type { | |
| 33 => { // TYPE_CHECK | |
| let card_type_is_live = (a & 0x01) != 0; | |
| if card_type_is_live { db.get_live(cid as u16).is_some() } | |
| else { db.get_member(cid as u16).is_some() } | |
| }, | |
| 16 => { // COST_GE | |
| let min_cost = (a >> 1) & 0x1F; | |
| if let Some(m) = db.get_member(cid as u16) { | |
| m.cost as i32 >= min_cost as i32 | |
| } else { false } | |
| } | |
| _ => true, // Stop at 1st card if unknown | |
| }; | |
| if matches { | |
| found = true; | |
| // Move the matched card to dest | |
| if let Some(idx) = self.players[p_idx].looked_cards.iter().position(|&xcid| xcid == cid) { | |
| let matched_cid = self.players[p_idx].looked_cards.remove(idx); | |
| match dest_slot { | |
| 6 => { | |
| self.players[p_idx].hand.push(matched_cid); | |
| self.players[p_idx].hand_increased_this_turn = self.players[p_idx].hand_increased_this_turn.saturating_add(1); | |
| if !self.silent { self.log(format!(" Revealed and added card {} to hand", matched_cid)); } | |
| }, | |
| _ => { | |
| self.players[p_idx].discard.push(matched_cid); | |
| if !self.silent { self.log(format!(" Revealed and moved card {} to discard/target", matched_cid)); } | |
| } | |
| } | |
| } | |
| } | |
| } else { | |
| break; // Deck empty | |
| } | |
| } | |
| // Move rest to discard | |
| let count_rem = self.players[p_idx].looked_cards.len(); | |
| if count_rem > 0 { | |
| let rest = std::mem::take(&mut self.players[p_idx].looked_cards); | |
| if !self.silent { self.log(format!(" Discarding remaining {} revealed cards", count_rem)); } | |
| for rcid in rest { | |
| self.players[p_idx].discard.push(rcid); | |
| } | |
| } | |
| if !self.silent { self.log(format!(" Effect: REVEAL_UNTIL finished. Total revealed: {}", revealed_count)); } | |
| }, | |
| // --- Missing/Stub opcodes (log for debugging) --- | |
| O_LOOK_DECK => { | |
| let count = v as usize; | |
| // Real implementation should POP from deck to looked_cards for order/draw later | |
| self.players[p_idx].looked_cards.clear(); | |
| let deck_len = self.players[p_idx].deck.len(); | |
| if deck_len > 0 { | |
| let actual_count = count.min(deck_len); | |
| for _ in 0..actual_count { | |
| if let Some(cid) = self.players[p_idx].deck.pop() { | |
| self.players[p_idx].looked_cards.push(cid); | |
| } | |
| } | |
| if !self.silent { self.log(format!(" Effect: LOOK_DECK - Looking at {} cards", actual_count)); } | |
| } | |
| }, | |
| O_SET_HEARTS => { | |
| // Set hearts to fixed value | |
| let color = a as usize; | |
| if color < 7 { | |
| let targets = if target_slot == 4 && ctx.area_idx >= 0 { vec![ctx.area_idx as usize] } | |
| else if target_slot == 1 { vec![0, 1, 2] } else { vec![] }; | |
| for t in targets { | |
| self.players[p_idx].heart_buffs[t][color] = v as i32; | |
| } | |
| } | |
| }, | |
| O_FORMATION => { | |
| let src_slot = if target_slot == 4 { ctx.area_idx as usize } else { target_slot as usize }; | |
| let dst_slot = a as usize; | |
| if src_slot < 3 && dst_slot < 3 && src_slot != dst_slot { | |
| self.players[p_idx].stage.swap(src_slot, dst_slot); | |
| self.players[p_idx].tapped_members.swap(src_slot, dst_slot); | |
| self.players[p_idx].stage_energy_count.swap(src_slot, dst_slot); | |
| self.players[p_idx].stage_energy.swap(src_slot, dst_slot); | |
| if !self.silent { self.log(format!(" Effect: FORMATION - Swapped {} and {}", src_slot, dst_slot)); } | |
| } | |
| }, | |
| O_NEGATE => { | |
| if !self.silent { self.log(format!(" Effect: NEGATE - Suppressing trigger effects (stub)")); } | |
| }, | |
| O_SWAP_ZONE => { | |
| if !self.silent { self.log(format!(" Effect: SWAP_ZONE (stub)")); } | |
| }, | |
| O_REVEAL => { | |
| if !self.silent { self.log(format!(" Effect: REVEAL {} cards", v)); } | |
| let count = v as usize; | |
| self.players[p_idx].looked_cards.clear(); | |
| let mut revealed = Vec::new(); | |
| let deck_len = self.players[p_idx].deck.len(); | |
| if deck_len > 0 { | |
| let start = if deck_len >= count { deck_len - count } else { 0 }; | |
| for i in (start..deck_len).rev() { | |
| let cid = self.players[p_idx].deck[i]; | |
| self.players[p_idx].looked_cards.push(cid); | |
| revealed.push(cid); | |
| } | |
| } | |
| // Dispatch OnReveal triggers | |
| for cid in revealed { | |
| let mut new_ctx = ctx.clone(); | |
| new_ctx.source_card_id = cid as i16; | |
| self.trigger_abilities(db, TriggerType::OnReveal, &new_ctx); | |
| } | |
| }, | |
| O_CHEER_REVEAL => { | |
| if !self.silent { self.log(format!(" Effect: CHEER_REVEAL {} cards", v)); } | |
| let count = v as usize; | |
| self.players[p_idx].looked_cards.clear(); | |
| let mut revealed = Vec::new(); | |
| let deck_len = self.players[p_idx].deck.len(); | |
| if deck_len > 0 { | |
| let start = if deck_len >= count { deck_len - count } else { 0 }; | |
| for i in (start..deck_len).rev() { | |
| let cid = self.players[p_idx].deck[i]; | |
| self.players[p_idx].looked_cards.push(cid); | |
| revealed.push(cid); | |
| } | |
| } | |
| // Dispatch OnReveal triggers | |
| for cid in revealed { | |
| let mut new_ctx = ctx.clone(); | |
| new_ctx.source_card_id = cid as i16; | |
| self.trigger_abilities(db, TriggerType::OnReveal, &new_ctx); | |
| } | |
| }, | |
| O_REPLACE_EFFECT => { | |
| if !self.silent { self.log(format!(" Effect: REPLACE_EFFECT (stub)")); } | |
| }, | |
| O_MODIFY_SCORE_RULE => { | |
| if !self.silent { self.log(format!(" Effect: MODIFY_SCORE_RULE (stub)")); } | |
| }, | |
| _ => { | |
| self.log(format!(" [WARN] Unhandled opcode: {}", real_op)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /// Get Japanese trigger label for logging | |
| fn get_trigger_label(trigger: TriggerType) -> &'static str { | |
| match trigger { | |
| TriggerType::OnPlay => "【登場】", | |
| TriggerType::OnLiveStart => "【開始】", | |
| TriggerType::OnLiveSuccess => "【成功】", | |
| TriggerType::TurnStart => "【ターン開始】", | |
| TriggerType::TurnEnd => "【ターン終了】", | |
| TriggerType::Constant => "【常時】", | |
| TriggerType::Activated => "【起動】", | |
| TriggerType::OnLeaves => "【退場】", | |
| TriggerType::OnReveal => "【公開】", | |
| TriggerType::OnPositionChange => "【移動】", | |
| TriggerType::None => "", | |
| } | |
| } | |
| pub fn trigger_abilities(&mut self, db: &CardDatabase, trigger: TriggerType, ctx: &AbilityContext) { | |
| self.trigger_abilities_from(db, trigger, ctx, 0); | |
| } | |
| // Optimization 2: Zero-Copy | |
| pub fn trigger_abilities_from(&mut self, db: &CardDatabase, trigger: TriggerType, ctx: &AbilityContext, start_ab_idx: usize) { | |
| if self.phase == Phase::Terminal { return; } | |
| // Infinite Loop Protection | |
| if self.trigger_depth > 20 { | |
| let p_idx = self.current_player as usize; | |
| let opp_idx = 1 - p_idx; | |
| if !self.silent { self.log(format!("Rule Violation: Infinite Loop Detected for Player {}. Forfeiting game.", p_idx)); } | |
| self.players[opp_idx].score = 10000; | |
| self.players[p_idx].score = 0; | |
| self.phase = Phase::Terminal; | |
| return; | |
| } | |
| self.trigger_depth += 1; | |
| let p_idx = ctx.player_id as usize; | |
| // Store (card_id, ability_index, area_idx, is_live_card) | |
| let mut abilities_to_run: Vec<(u16, u16, i16, bool)> = Vec::new(); | |
| // 1. Collect Member Abilities from Stage | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].stage[i]; | |
| if cid >= 0 { | |
| // For OnPlay, only check the slot being played to | |
| if trigger == TriggerType::OnPlay && ctx.area_idx >= 0 && i as i16 != ctx.area_idx { | |
| continue; | |
| } | |
| if let Some(card) = db.get_member(cid as u16) { | |
| for (ab_idx, ab) in card.abilities.iter().enumerate().skip(start_ab_idx as usize) { | |
| if ab.trigger == trigger { | |
| if !ab.bytecode.is_empty() { | |
| let mut actx = ctx.clone(); | |
| actx.area_idx = i as i16; | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| if self.check_once_per_turn(p_idx, cid as u16, ab_idx as usize) { | |
| abilities_to_run.push((cid as u16, ab_idx as u16, i as i16, false)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 1.4. Collect Source Card Abilities (even if not on stage, e.g. for OnReveal) | |
| if ctx.source_card_id >= 0 { | |
| let cid = ctx.source_card_id as u16; | |
| if let Some(card) = db.get_member(cid) { | |
| for (ab_idx, ab) in card.abilities.iter().enumerate().skip(start_ab_idx) { | |
| if ab.trigger == trigger && !ab.bytecode.is_empty() { | |
| let actx = ctx.clone(); | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| if self.check_once_per_turn(p_idx, cid, ab_idx) { | |
| // If this card is on stage, we might have already added it above. | |
| // But usually OnReveal happens while in deck/looked zone. | |
| // Check if already queued to avoid double triggers if it's somehow both. | |
| if !abilities_to_run.iter().any(|&(added_cid, added_ab_idx, _, _)| added_cid == cid && added_ab_idx == ab_idx as u16) { | |
| abilities_to_run.push((cid, ab_idx as u16, ctx.area_idx, false)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if let Some(live) = db.get_live(cid) { | |
| for (ab_idx, ab) in live.abilities.iter().enumerate().skip(start_ab_idx) { | |
| if ab.trigger == trigger && !ab.bytecode.is_empty() { | |
| let actx = ctx.clone(); | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| if self.check_once_per_turn(p_idx, cid, ab_idx) { | |
| if !abilities_to_run.iter().any(|&(added_cid, added_ab_idx, _, _)| added_cid == cid as u16 && added_ab_idx == ab_idx as u16) { | |
| abilities_to_run.push((cid as u16, ab_idx as u16, ctx.area_idx, true)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 1.5. Collect Granted Abilities | |
| // 1.5. Collect Granted Abilities | |
| // Use a temporary list to avoid borrowing issues while accessing DB | |
| let mut granted_to_run: Vec<(i32, u32, usize)> = Vec::new(); // (target_cid, source_cid, ab_idx) | |
| for &(t_cid, s_cid, ab_idx) in &self.players[p_idx].granted_abilities { | |
| if let Some(card) = db.get_member(s_cid as u16) { | |
| if let Some(ab) = card.abilities.get(ab_idx as usize) { | |
| if ab.trigger != trigger { continue; } | |
| if let Some(slot) = self.players[p_idx].stage.iter().position(|&scid| scid == t_cid) { | |
| if trigger == TriggerType::OnPlay && ctx.area_idx >= 0 && slot as i16 != ctx.area_idx { | |
| continue; | |
| } | |
| granted_to_run.push((t_cid as i32, s_cid as u32, ab_idx as usize)); | |
| } | |
| } | |
| } | |
| } | |
| for (target_cid, s_cid, ab_idx) in granted_to_run { | |
| if let Some(card) = db.get_member(s_cid as u16) { | |
| if let Some(ab) = card.abilities.get(ab_idx as usize) { | |
| if !ab.bytecode.is_empty() { | |
| if let Some(slot) = self.players[p_idx].stage.iter().position(|&scid| scid == target_cid as i16) { | |
| let mut actx = ctx.clone(); | |
| actx.source_card_id = target_cid as i16; | |
| actx.area_idx = slot as i16; | |
| actx.ability_index = ab_idx as i16; // Note: using original index | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| if self.check_once_per_turn(p_idx, s_cid as u16, ab_idx as usize) { | |
| if !self.silent { self.log(format!("Triggering GRANTED ability from card {} on target {}", s_cid, target_cid)); } | |
| self.resolve_bytecode(db, &ab.bytecode, &actx); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 2. Collect LiveCard Abilities from Live Zone (for OnLiveStart trigger) | |
| if trigger == TriggerType::OnLiveStart { | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].live_zone[i]; | |
| if cid >= 0 { | |
| if let Some(live_card) = db.get_live(cid as u16) { | |
| for (ab_idx, ab) in live_card.abilities.iter().enumerate() { | |
| if ab.trigger == trigger && !ab.bytecode.is_empty() { | |
| let mut actx = ctx.clone(); | |
| actx.area_idx = i as i16; | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| if self.check_once_per_turn(p_idx, cid as u16, ab_idx) { | |
| abilities_to_run.push((cid as u16, ab_idx as u16, i as i16, true)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 3. Execute Bytecode (Zero-Copy) with canonical logging for frontend translation | |
| let mut is_first = true; | |
| for (cid, ab_idx, area_idx, is_live) in abilities_to_run { | |
| if is_live { | |
| if let Some(live_card) = db.get_live(cid as u16) { | |
| if let Some(ab) = live_card.abilities.get(ab_idx as usize) { | |
| let mut actx = ctx.clone(); | |
| if !is_first { actx.program_counter = 0; } | |
| actx.area_idx = area_idx; | |
| actx.ability_index = ab_idx as i16; | |
| // Canonical format [TRIGGER:ID]CardName: Pseudocode | |
| if !self.silent { | |
| let effect_desc = if ab.raw_text.is_empty() { | |
| format!("Ability {}", ab_idx) | |
| } else { | |
| ab.raw_text.lines().next().unwrap_or(&ab.raw_text).to_string() | |
| }; | |
| let trigger_label = match trigger { | |
| TriggerType::OnPlay => "[Rule 11.3] (On Play)", | |
| TriggerType::OnLiveStart => "[Rule 8.3.2] (On Live Start)", | |
| TriggerType::TurnStart => "[Turn Start]", | |
| TriggerType::TurnEnd => "[Turn End]", | |
| TriggerType::Activated => "[Activated]", | |
| _ => "[Triggered]", | |
| }; | |
| self.log(format!("{} (Live){}: {}", trigger_label, live_card.name, effect_desc)); | |
| } | |
| self.resolve_bytecode(db, &ab.bytecode, &actx); | |
| } | |
| } | |
| } else { | |
| if let Some(card) = db.get_member(cid as u16) { | |
| if let Some(ab) = card.abilities.get(ab_idx as usize) { | |
| let mut actx = ctx.clone(); | |
| if !is_first { actx.program_counter = 0; } | |
| actx.area_idx = area_idx; | |
| actx.ability_index = ab_idx as i16; | |
| // Canonical format [TRIGGER:ID]CardName: Pseudocode | |
| if !self.silent { | |
| let effect_desc = if ab.raw_text.is_empty() { | |
| format!("Ability {}", ab_idx) | |
| } else { | |
| ab.raw_text.lines().next().unwrap_or(&ab.raw_text).to_string() | |
| }; | |
| let trigger_label = match trigger { | |
| TriggerType::OnPlay => "[Rule 11.3] (On Play)", | |
| TriggerType::OnLiveStart => "[Rule 8.3.2] (On Live Start)", | |
| TriggerType::TurnStart => "[Turn Start]", | |
| TriggerType::TurnEnd => "[Turn End]", | |
| TriggerType::Activated => "[Activated]", | |
| _ => "[Triggered]", | |
| }; | |
| self.log(format!("{} {}: {}", trigger_label, card.name, effect_desc)); | |
| } | |
| self.resolve_bytecode(db, &ab.bytecode, &actx); | |
| if self.phase == Phase::Response { break; } | |
| } | |
| } | |
| } | |
| if self.phase == Phase::Response { break; } | |
| is_first = false; | |
| } | |
| self.trigger_depth -= 1; | |
| } | |
| pub fn do_yell(&mut self, db: &CardDatabase, count: u32) -> Vec<u16> { | |
| let p_idx = self.current_player as usize; | |
| let mut revealed = Vec::new(); | |
| for _ in 0..count { | |
| if self.players[p_idx].deck.is_empty() { self.resolve_deck_refresh(p_idx); } | |
| if let Some(card_id) = self.players[p_idx].deck.pop() { | |
| revealed.push(card_id); | |
| // Dispatch OnReveal trigger | |
| let mut ctx = AbilityContext::default(); | |
| ctx.player_id = p_idx as u8; | |
| ctx.source_card_id = card_id as i16; | |
| self.trigger_abilities(db, TriggerType::OnReveal, &ctx); | |
| } | |
| } | |
| revealed | |
| } | |
| fn check_once_per_turn(&self, p_idx: usize, cid: u16, ab_idx: usize) -> bool { | |
| let uid = (cid as u32) << 16 | (ab_idx as u32); | |
| !self.players[p_idx].used_abilities.contains(&uid) | |
| } | |
| fn consume_once_per_turn(&mut self, p_idx: usize, cid: u16, ab_idx: usize) { | |
| let uid = (cid as u32) << 16 | (ab_idx as u32); | |
| if !self.players[p_idx].used_abilities.contains(&uid) { | |
| self.players[p_idx].used_abilities.push(uid); | |
| } | |
| } | |
| pub fn card_matches_filter(&self, db: &CardDatabase, cid: u16, filter_attr: i32) -> bool { | |
| // Bit 2-3: Type Filter (0=None, 1=Member, 2=Live) | |
| let type_filter = (filter_attr >> 2) & 0x03; | |
| if type_filter == 1 && db.get_member(cid).is_none() { return false; } | |
| if type_filter == 2 && db.get_live(cid).is_none() { return false; } | |
| // Bit 4: Group Filter Enable | |
| if (filter_attr & 0x10) != 0 { | |
| let group_id = (filter_attr >> 5) & 0x7F; | |
| if let Some(m) = db.get_member(cid) { | |
| if !m.groups.contains(&(group_id as u8)) { return false; } | |
| } else if let Some(l) = db.get_live(cid) { | |
| if !l.groups.contains(&(group_id as u8)) { return false; } | |
| } else { | |
| return false; | |
| } | |
| } | |
| true | |
| } | |
| pub fn check_hearts_suitability(&self, have: &[u8; 7], need: &[u8; 7]) -> bool { | |
| let mut have_u32 = [0u32; 7]; | |
| let mut need_u32 = [0u32; 7]; | |
| for i in 0..7 { | |
| have_u32[i] = have[i] as u32; | |
| need_u32[i] = need[i] as u32; | |
| } | |
| let (sat, tot) = crate::core::hearts::process_hearts(&mut have_u32, &need_u32); | |
| sat >= tot | |
| } | |
| pub fn consume_hearts_from_pool(&self, pool: &mut [u8; 7], need: &[u8; 7]) { | |
| let mut pool_u32 = [0u32; 7]; | |
| let mut need_u32 = [0u32; 7]; | |
| for i in 0..7 { | |
| pool_u32[i] = pool[i] as u32; | |
| need_u32[i] = need[i] as u32; | |
| } | |
| crate::core::hearts::process_hearts(&mut pool_u32, &need_u32); | |
| for i in 0..7 { | |
| pool[i] = pool_u32[i] as u8; | |
| } | |
| } | |
| pub fn check_live_success(&self, p_idx: usize, live: &LiveCard, total_hearts: &[u8; 7]) -> bool { | |
| // Apply reductions | |
| let mut req = live.required_hearts; | |
| let reductions = self.players[p_idx].heart_req_reductions; | |
| for i in 0..7 { | |
| req[i] = (req[i] as i32 - reductions[i]).max(0) as u8; | |
| } | |
| self.check_hearts_suitability(total_hearts, &req) | |
| } | |
| pub fn do_performance_phase(&mut self, db: &CardDatabase) { | |
| let p_idx = self.current_player as usize; | |
| // 8.3.4 Flip all cards in Live Zone | |
| for i in 0..3 { self.players[p_idx].live_zone_revealed[i] = true; } | |
| // Discard non-live cards (Rule 8.3.4) BEFORE triggering OnLiveStart (Rule 11.4/8.3.8) | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].live_zone[i]; | |
| if cid >= 0 && db.get_live(cid as u16).is_none() { | |
| if !self.silent { self.log(format!("Rule 8.3.4: Discarding non-live card #{} from Live Zone.", cid)); } | |
| self.players[p_idx].discard.push(cid as u16); | |
| self.players[p_idx].live_zone[i] = -1; | |
| } | |
| } | |
| // Rule 11.4 [ライブ開始時] (Live Start) | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].live_zone[i]; | |
| if cid >= 0 { | |
| let ctx = AbilityContext { source_card_id: cid, player_id: p_idx as u8, area_idx: i as i16, ..Default::default() }; | |
| if !self.silent { self.log("Rule 11.4: Triggering [ライブ開始時] (Live Start) abilities.".to_string()); } | |
| self.trigger_abilities(db, TriggerType::OnLiveStart, &ctx); | |
| } | |
| } | |
| // 8.3.6 If no lives, end phase | |
| if self.players[p_idx].live_zone.iter().all(|&c| c < 0) { | |
| // Create empty performance result for consistency in logs | |
| self.performance_results.insert(p_idx as u8, json!({ | |
| "success": false, | |
| "total_hearts": [0, 0, 0, 0, 0, 0, 0], | |
| "volume_icons": 0, | |
| "yell_count": 0, | |
| "lives": [], | |
| "yell_cards": [], | |
| "breakdown": { | |
| "blades": [], | |
| "hearts": [] | |
| } | |
| })); | |
| self.advance_from_performance(); | |
| return; | |
| } | |
| // 8.3.10-11 Yell | |
| let mut total_blades = 0; | |
| for i in 0..3 { | |
| total_blades += self.get_effective_blades(p_idx, i, db); | |
| } | |
| if !self.silent { self.log(format!("Rule 8.3.11: Player {} performs Yell ({} blades).", p_idx, total_blades)); } | |
| let yelled_cards = self.do_yell(db, total_blades); | |
| if !self.silent { | |
| self.log(format!("--- PLAYER {} PERFORMANCE ---", p_idx)); | |
| self.log(format!(" Blades: {}", total_blades)); | |
| } | |
| if !self.silent { | |
| let mut yell_summary = Vec::new(); | |
| for &cid in &yelled_cards { | |
| let (name, bh, vi) = if let Some(m) = db.get_member(cid as u16) { | |
| (m.name.clone(), m.blade_hearts, m.volume_icons) | |
| } else if let Some(l) = db.get_live(cid as u16) { | |
| (l.name.clone(), l.blade_hearts, l.volume_icons) | |
| } else { | |
| ("Unknown".to_string(), [0u8; 7], 0) | |
| }; | |
| let mut contribs = Vec::new(); | |
| let h_sum: u32 = bh.iter().map(|&h| h as u32).sum(); | |
| if h_sum > 0 { | |
| contribs.push(format!("+{}H", h_sum)); | |
| } | |
| if vi > 0 { | |
| contribs.push(format!("+{}V", vi)); | |
| } | |
| let desc = if contribs.is_empty() { "Nothing".to_string() } else { contribs.join(", ") }; | |
| yell_summary.push(format!("{}({})", name, desc)); | |
| } | |
| self.log(format!(" Yells: [{}]", yell_summary.join(", "))); | |
| } | |
| // 8.3.14 Calculate Owned Hearts | |
| let mut total_hearts = [0u8; 7]; | |
| for i in 0..3 { | |
| let eff_h = self.get_effective_hearts(p_idx, i, db); | |
| for h in 0..7 { total_hearts[h] += eff_h[h]; } | |
| } | |
| let mut volume_icons = 0; | |
| // Include Icons from Stage (Rule 8.3.14) | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].stage[i]; | |
| if cid >= 0 { | |
| if let Some(m) = db.get_member(cid as u16) { | |
| volume_icons += m.volume_icons; | |
| } | |
| } | |
| } | |
| for &cid in &yelled_cards { | |
| let (bh, vi) = if let Some(m) = db.get_member(cid) { (m.blade_hearts, m.volume_icons) } | |
| else if let Some(l) = db.get_live(cid) { (l.blade_hearts, l.volume_icons) } | |
| else { ([0u8; 7], 0) }; | |
| let mut adj_bh = bh; | |
| for &(src, dst) in &self.players[p_idx].color_transforms { | |
| if src == 0 && (dst as usize) < 7 { | |
| let mut sum = 0; | |
| for i in 0..7 { if i != dst as usize { sum += adj_bh[i]; adj_bh[i] = 0; } } | |
| adj_bh[dst as usize] += sum; | |
| } | |
| } | |
| for i in 0..7 { total_hearts[i] += adj_bh[i]; } | |
| volume_icons += vi; | |
| } | |
| self.players[p_idx].current_turn_volume = volume_icons; | |
| if !self.silent { | |
| self.log(format!(" Total Hearts: {:?}", total_hearts)); | |
| self.log(format!(" Volume Icons: {}", volume_icons)); | |
| } | |
| // 8.3.15-16 Check heart requirements | |
| let mut passed_flags = [false; 3]; | |
| let mut any_failed = false; | |
| // In this implementation, we consume hearts per live card (8.3.15.1.2) | |
| let mut remaining_hearts = total_hearts; | |
| for i in 0..3 { | |
| if let Some(cid) = self.players[p_idx].live_zone.get(i).copied().filter(|&c| c >= 0) { | |
| if let Some(live) = db.get_live(cid as u16) { | |
| let mut req = live.required_hearts; | |
| for h in 0..7 { req[h] = (req[h] as i32 - self.players[p_idx].heart_req_reductions[h]).max(0) as u8; } | |
| self.log(format!(" Live {}: {} - Need: {:?}, Available: {:?}", i, live.name, req, remaining_hearts)); | |
| if !any_failed && self.check_hearts_suitability(&remaining_hearts, &req) { | |
| self.consume_hearts_from_pool(&mut remaining_hearts, &req); | |
| passed_flags[i] = true; | |
| self.log(format!(" -> SUCCESS for {}", live.name)); | |
| // Dispatch OnLiveSuccess trigger: moved to LiveResult phase to ensure failure safety | |
| } else { | |
| self.log(format!(" -> FAILED hearts for {}", live.name)); | |
| any_failed = true; | |
| // Rule 8.3.16 covers this: if any fail, all are discarded later. | |
| } | |
| } | |
| } | |
| } | |
| // Rule 8.3.16 covers this: if any fail, all are discarded. | |
| // We capture IDs here so we can still report them to UI even if discarded. | |
| let live_ids_before_discard: Vec<i16> = self.players[p_idx].live_zone.to_vec(); | |
| // Rule 8.3.16: If ANY live card's requirements were not met, discard all live cards. | |
| if any_failed { | |
| self.log(" Rule 8.3.16: Performance FAILED. All live cards discarded.".to_string()); | |
| for i in 0..3 { | |
| if self.players[p_idx].live_zone[i] >= 0 { | |
| self.players[p_idx].discard.push(self.players[p_idx].live_zone[i] as u16); | |
| self.players[p_idx].live_zone[i] = -1; | |
| passed_flags[i] = false; // Ensure UI reflects failure | |
| } | |
| } | |
| } | |
| let all_met = !any_failed && live_ids_before_discard.iter().enumerate().all(|(i, &cid)| { | |
| cid < 0 || passed_flags[i] | |
| }); | |
| if all_met { | |
| for i in 0..3 { | |
| if passed_flags[i] { | |
| let cid = live_ids_before_discard[i]; | |
| if cid >= 0 { | |
| let ctx = AbilityContext { source_card_id: cid as i16, player_id: p_idx as u8, area_idx: i as i16, ..Default::default() }; | |
| self.trigger_abilities(db, TriggerType::OnLiveSuccess, &ctx); | |
| } | |
| } | |
| } | |
| } | |
| // --- Store Performance Results for UI --- | |
| let mut heart_breakdown = Vec::new(); | |
| let mut blade_breakdown = Vec::new(); | |
| // 1. Stage Contributions | |
| for i in 0..3 { | |
| let cid = self.players[p_idx].stage[i]; | |
| if cid >= 0 { | |
| if let Some(m) = db.get_member(cid as u16) { | |
| let eff_h = self.get_effective_hearts(p_idx, i, db); | |
| if eff_h.iter().any(|&h| h > 0) { | |
| heart_breakdown.push(json!({ | |
| "source": m.name, | |
| "source_id": cid, | |
| "value": eff_h, | |
| "type": "member" | |
| })); | |
| } | |
| let eff_b = self.get_effective_blades(p_idx, i, db); | |
| if eff_b > 0 { | |
| blade_breakdown.push(json!({ | |
| "source": m.name, | |
| "source_id": cid, | |
| "value": eff_b, | |
| "type": "member" | |
| })); | |
| } | |
| } | |
| // Rule 8.4.10: Participants change to Rest state | |
| self.players[p_idx].tapped_members[i] = true; | |
| } | |
| } | |
| let mut yell_cards_meta = Vec::new(); | |
| for &cid in &yelled_cards { | |
| if let Some(m) = db.get_member(cid) { | |
| yell_cards_meta.push(json!({ | |
| "id": cid, | |
| "img": m.img_path, | |
| "blade_hearts": m.blade_hearts, | |
| })); | |
| } else if let Some(l) = db.get_live(cid) { | |
| yell_cards_meta.push(json!({ | |
| "id": cid, | |
| "img": l.img_path, | |
| "blade_hearts": l.blade_hearts, | |
| })); | |
| } | |
| } | |
| let mut lives_list = Vec::new(); | |
| let mut temp_hearts_debug = total_hearts; // For simulating filling logic | |
| for i in 0..3 { | |
| let cid = live_ids_before_discard[i]; | |
| if cid >= 0 { | |
| if let Some(l) = db.get_live(cid as u16) { | |
| let mut req = l.required_hearts; | |
| for h in 0..7 { req[h] = (req[h] as i32 - self.players[p_idx].heart_req_reductions[h]).max(0) as u8; } | |
| // Calculate "filled" state for UI | |
| let mut filled = [0u8; 7]; | |
| let mut sim_have = temp_hearts_debug; | |
| // 1. Specific | |
| for ci in 0..6 { | |
| let take = sim_have[ci].min(req[ci]); | |
| filled[ci] = take; | |
| sim_have[ci] -= take; | |
| } | |
| // 2. Any | |
| let any_need = req[6]; | |
| let any_have: i32 = sim_have.iter().map(|&h| h as i32).sum(); | |
| filled[6] = (any_have as u8).min(any_need); | |
| lives_list.push(json!({ | |
| "id": cid, | |
| "name": l.name, | |
| "img": l.img_path, | |
| "passed": passed_flags[i], | |
| "score": l.score, | |
| "required": req, | |
| "filled": filled, | |
| })); | |
| // If successfully passed in sequence, permanently consume for next live card UI check | |
| if passed_flags[i] { | |
| self.consume_hearts_from_pool(&mut temp_hearts_debug, &req); | |
| } | |
| } | |
| } | |
| } | |
| self.performance_results.insert(p_idx as u8, json!({ | |
| "success": all_met, | |
| "total_hearts": total_hearts, | |
| "volume_icons": volume_icons, | |
| "yell_count": total_blades, | |
| "lives": lives_list, | |
| "yell_cards": yell_cards_meta, | |
| "breakdown": { | |
| "blades": blade_breakdown, | |
| "hearts": heart_breakdown | |
| } | |
| })); | |
| for cid in yelled_cards { self.players[p_idx].discard.push(cid); } | |
| self.advance_from_performance(); | |
| } | |
| fn advance_from_performance(&mut self) { | |
| if self.current_player == self.first_player { | |
| self.current_player = 1 - self.first_player; | |
| // Phase stays the same or moves to P2? | |
| // My enum uses PerformanceP1 and PerformanceP2. | |
| if self.phase == Phase::PerformanceP1 { | |
| self.phase = Phase::PerformanceP2; | |
| } else { | |
| self.phase = Phase::PerformanceP1; // Error path | |
| } | |
| } else { | |
| self.phase = Phase::LiveResult; | |
| self.current_player = self.first_player; | |
| } | |
| } | |
| pub fn do_live_result(&mut self, _db: &CardDatabase) { | |
| self.log("Rule 8.4: --- LIVE RESULT PHASE ---".to_string()); | |
| // Save current results to history | |
| for (pid, res) in &self.performance_results { | |
| if let serde_json::Value::Object(mut map) = res.clone() { | |
| map.insert("turn".to_string(), json!(self.turn)); | |
| map.insert("player_id".to_string(), json!(pid)); | |
| self.performance_history.push(serde_json::Value::Object(map)); | |
| } | |
| } | |
| self.last_performance_results = self.performance_results.clone(); | |
| // 0. Trigger ON_LIVE_SUCCESS for successful performances (Rule 8.3.15 sequence completion) | |
| for p in 0..2 { | |
| if let Some(res) = self.performance_results.get(&(p as u8)) { | |
| if res.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { | |
| self.log(format!("DEBUG: Live Success performance detected for player {}. Checking live zone cards for OnLiveSuccess triggers.", p)); | |
| for i in 0..3 { | |
| let cid = self.players[p].live_zone[i]; | |
| if cid >= 0 { | |
| let ctx = AbilityContext { source_card_id: cid, player_id: p as u8, area_idx: i as i16, ..Default::default() }; | |
| if !self.silent { self.log(format!("DEBUG: Triggering OnLiveSuccess for card {} in slot {}.", cid, i)); } | |
| self.trigger_abilities(_db, TriggerType::OnLiveSuccess, &ctx); | |
| // Note: If an ability is interactive, it will move to Response phase | |
| if self.phase == Phase::Response { return; } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| let mut scores = [0u32; 2]; | |
| let mut has_success = [false; 2]; | |
| // 1. Judgment Phase: Calculate scores based on SUCCESSFUL lives (still in zone) | |
| for p in 0..2 { | |
| let mut live_score = 0; | |
| let mut player_has_success = false; | |
| if let Some(res) = self.performance_results.get(&(p as u8)) { | |
| // In my updated do_performance_phase, if ANY failed, ALL were discarded. | |
| // So if any cards are in live_zone, they MUST have passed. | |
| for i in 0..3 { | |
| let cid = self.players[p].live_zone[i]; | |
| if cid >= 0 { | |
| // For scoring, we lookup the score in performance results JSON | |
| // (already calculated in do_performance_phase) | |
| if let Some(lives) = res.get("lives").and_then(|l| l.as_array()) { | |
| if let Some(l_res) = lives.get(i) { | |
| if let Some(s) = l_res.get("score").and_then(|s| s.as_u64()) { | |
| live_score += s as u32; | |
| player_has_success = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Add Volume Icons if at least one live was successful | |
| if player_has_success { | |
| if let Some(vol) = res.get("yell_score_bonus").and_then(|v| v.as_u64()) | |
| .or_else(|| res.get("volume_icons").and_then(|v| v.as_u64())) { | |
| live_score += vol as u32; | |
| } | |
| has_success[p] = true; | |
| } | |
| } | |
| scores[p] = live_score; | |
| } | |
| // 8.4.6 Compare | |
| let p0_wins = has_success[0] && (!has_success[1] || scores[0] >= scores[1]); | |
| let p1_wins = has_success[1] && (!has_success[0] || scores[1] >= scores[0]); | |
| let is_comparative_tie = p0_wins && p1_wins; | |
| if !self.silent { | |
| self.log(format!("Rule 8.4.6: P0 Score: {} (Success: {} wins: {})", scores[0], has_success[0], p0_wins)); | |
| self.log(format!("Rule 8.4.6: P1 Score: {} (Success: {} wins: {})", scores[1], has_success[1], p1_wins)); | |
| } | |
| // 2. Handling Winners (Rule 8.4.7) | |
| let mut choices_pending = false; | |
| for p in 0..2 { | |
| let wins = if p == 0 { p0_wins } else { p1_wins }; | |
| if wins { | |
| let my_live_count = self.players[p].live_zone.iter().filter(|&&c| c >= 0).count(); | |
| if is_comparative_tie && my_live_count >= 2 { | |
| if !self.silent { self.log(format!(" Rule 8.4.7.1: P{} Tie Penalty - {} cards remain in zone.", p, my_live_count)); } | |
| } else if my_live_count == 1 { | |
| // Auto-move single card | |
| for i in 0..3 { | |
| if self.players[p].live_zone[i] >= 0 { | |
| let cid = self.players[p].live_zone[i]; | |
| self.players[p].success_lives.push(cid as u16); | |
| self.players[p].live_zone[i] = -1; | |
| if !self.silent { self.log(format!("Rule 8.4.7: P{} obtained Success Live: Card ID {}", p, cid)); } | |
| break; | |
| } | |
| } | |
| } else if my_live_count > 1 { | |
| // SELECTION NEEDED | |
| if !choices_pending { | |
| self.current_player = p as u8; | |
| choices_pending = true; | |
| if !self.silent { self.log(format!("Rule 8.4.7.3: P{} must SELECT a success live card.", p)); } | |
| } | |
| } | |
| } | |
| } | |
| if choices_pending { | |
| // Stay in LiveResult phase, wait for 600-602 | |
| return; | |
| } | |
| // 3. Finalization (Cleanup and Turn Advance) | |
| self.finalize_live_result(); | |
| } | |
| pub fn finalize_live_result(&mut self) { | |
| // 8.4.8 Cleanup all live zones | |
| for p in 0..2 { | |
| for i in 0..3 { | |
| if self.players[p].live_zone[i] >= 0 { | |
| self.players[p].discard.push(self.players[p].live_zone[i] as u16); | |
| self.players[p].live_zone[i] = -1; | |
| } | |
| } | |
| self.players[p].current_turn_volume = 0; | |
| self.players[p].score = self.players[p].success_lives.len() as u32; | |
| } | |
| // 8.4.13 Determine next first player (Winner of judgement goes first) | |
| // Note: Simple logic for now, winner of judgement or host stays | |
| self.check_win_condition(); | |
| if self.phase != Phase::Terminal { | |
| self.turn += 1; | |
| // 8.4.13 Winner becomes next first player | |
| // For now, toggle host or keep winner. Let's toggle for balance if no winner. | |
| self.first_player = (self.first_player + 1) % 2; | |
| self.current_player = self.first_player; | |
| self.phase = Phase::Active; | |
| } | |
| self.performance_results.clear(); | |
| } | |
| pub fn do_active_phase(&mut self, db: &CardDatabase) { | |
| let p_idx = self.current_player as usize; | |
| self.setup_turn_log(); | |
| if !self.silent { self.log(format!("Rule 7.4.1: [Active Phase] Untapping all cards for Player {}.", p_idx)); } | |
| self.players[p_idx].untap_all(); | |
| let ctx = AbilityContext { source_card_id: -1, player_id: p_idx as u8, area_idx: -1, ..Default::default() }; | |
| self.trigger_abilities(db, TriggerType::TurnStart, &ctx); | |
| self.phase = Phase::Energy; | |
| } | |
| pub fn play_energy(&mut self, hand_idx: usize) { | |
| let p_idx = self.current_player as usize; | |
| if hand_idx < self.players[p_idx].hand.len() { | |
| if !self.silent { self.log(format!("Rule 9.6: Player {} played Energy from hand", p_idx)); } | |
| let cid = self.players[p_idx].hand.remove(hand_idx); | |
| if hand_idx < self.players[p_idx].hand_added_turn.len() { | |
| self.players[p_idx].hand_added_turn.remove(hand_idx); | |
| } | |
| self.players[p_idx].energy_zone.push(cid); | |
| self.players[p_idx].tapped_energy.push(false); | |
| } | |
| } | |
| pub fn do_energy_phase(&mut self) { | |
| let p_idx = self.current_player as usize; | |
| if let Some(card_id) = self.players[p_idx].energy_deck.pop() { | |
| if !self.silent { self.log(format!("Rule 7.5.2: Player {} placed Energy from Energy Deck", p_idx)); } | |
| self.players[p_idx].energy_zone.push(card_id); | |
| self.players[p_idx].tapped_energy.push(false); | |
| } | |
| self.phase = Phase::Draw; | |
| } | |
| pub fn do_draw_phase(&mut self) { | |
| let p_idx = self.current_player as usize; | |
| if !self.silent { self.log(format!("Rule 7.6.2: Player {} draws a card.", p_idx)); } | |
| self.draw_cards(p_idx, 1); | |
| self.phase = Phase::Main; | |
| } | |
| pub fn set_live_cards(&mut self, player_idx: usize, card_ids: Vec<u32>) -> Result<(), String> { | |
| if card_ids.len() > 3 { return Err("Too many lives".to_string()); } | |
| // For logging purposes, we'd ideally show card names, but for now just count | |
| if !self.silent { self.log(format!("Rule 8.2.2: Player {} sets {} cards to Live Zone.", player_idx, card_ids.len())); } | |
| for cid in card_ids { | |
| let mut placed = false; | |
| for i in 0..3 { | |
| if self.players[player_idx].live_zone[i] == -1 { | |
| self.players[player_idx].live_zone[i] = cid as i16; | |
| self.players[player_idx].live_zone_revealed[i] = false; | |
| // Rule 8.2.2: Draw same number of cards as placed | |
| self.draw_cards(player_idx, 1); | |
| placed = true; | |
| break; | |
| } | |
| } | |
| if !placed { return Err("No empty live slots".to_string()); } | |
| } | |
| Ok(()) | |
| } | |
| pub fn end_main_phase(&mut self, db: &CardDatabase) { | |
| if !self.silent { self.log(format!("Rule 7.7.3: Player {} ends Main Phase.", self.current_player)); } | |
| if self.current_player == self.first_player { | |
| self.current_player = 1 - self.first_player; | |
| self.phase = Phase::Active; | |
| self.do_active_phase(db); | |
| self.do_energy_phase(); | |
| self.do_draw_phase(); | |
| } else { | |
| self.phase = Phase::LiveSet; | |
| self.current_player = self.first_player; | |
| } | |
| } | |
| pub fn execute_mulligan(&mut self, player_idx: usize, discard_indices: Vec<usize>) { | |
| self.log(format!("Rule 6.2.1.6: Player {} finished mulligan ({} cards)", player_idx, discard_indices.len())); | |
| let mut count = 0; | |
| let mut discards = Vec::new(); | |
| let mut new_hand = SmallVec::new(); | |
| for (i, &cid) in self.players[player_idx].hand.iter().enumerate() { | |
| if discard_indices.contains(&i) { discards.push(cid); count += 1; } | |
| else { new_hand.push(cid); } | |
| } | |
| self.players[player_idx].hand = new_hand; | |
| for _ in 0..count { | |
| if self.players[player_idx].deck.is_empty() { self.resolve_deck_refresh(player_idx); } | |
| if let Some(card_id) = self.players[player_idx].deck.pop() { | |
| self.players[player_idx].hand.push(card_id); | |
| self.players[player_idx].hand_added_turn.push(self.turn); | |
| } | |
| } | |
| // If deck still empty and discard has cards, we already refreshed above or it's empty | |
| self.players[player_idx].deck.extend(discards); | |
| let mut rng = Pcg64::from_os_rng(); | |
| self.players[player_idx].deck.shuffle(&mut rng); | |
| self.resolve_deck_refresh(player_idx); | |
| if self.phase == Phase::MulliganP1 { self.current_player = 1 - self.first_player; self.phase = Phase::MulliganP2; } | |
| else if self.phase == Phase::MulliganP2 { self.current_player = self.first_player; self.phase = Phase::Active; } | |
| } | |
| pub fn play_member(&mut self, db: &CardDatabase, hand_idx: usize, slot_idx: usize) -> Result<(), String> { | |
| self.play_member_with_choice(db, hand_idx, slot_idx, -1, 0) | |
| } | |
| pub fn play_member_with_choice(&mut self, db: &CardDatabase, hand_idx: usize, slot_idx: usize, choice_idx: i32, start_ab_idx: usize) -> Result<(), String> { | |
| let p_idx = self.current_player as usize; | |
| if self.phase == Phase::Response { | |
| // RESUMPTION: Skip movement and cost payment. | |
| // ctx is passed in or reconstructed from pending_ctx to preserve program_counter | |
| let card_id = self.pending_card_id; | |
| let card = db.get_member(card_id as u16).ok_or("Card not found")?; | |
| let mut ctx = if let Some(p_ctx) = &self.pending_ctx { | |
| p_ctx.clone() | |
| } else { | |
| AbilityContext { source_card_id: card_id, player_id: p_idx as u8, area_idx: slot_idx as i16, choice_index: choice_idx as i16, ..Default::default() } | |
| }; | |
| // Ensure choice is updated | |
| ctx.choice_index = choice_idx as i16; | |
| if !self.silent { self.log_rule("Rule 11.3", &format!("Resuming [登場] (On Play) abilities for {} from idx {}.", card.name, start_ab_idx)); } | |
| // Standardize resumption: Reset phase to Main BEFORE execution. | |
| self.phase = Phase::Main; | |
| self.pending_ctx = None; | |
| self.pending_card_id = -1; | |
| self.pending_ab_idx = -1; | |
| self.pending_effect_opcode = -1; | |
| self.pending_choice_type = String::new(); | |
| self.trigger_abilities_from(db, TriggerType::OnPlay, &ctx, start_ab_idx); | |
| self.process_rule_checks(); | |
| return Ok(()); | |
| } | |
| if hand_idx >= self.players[p_idx].hand.len() { return Err("Invalid hand index".to_string()); } | |
| let card_id = self.players[p_idx].hand[hand_idx]; | |
| let card = db.get_member(card_id).ok_or("Card not found")?; | |
| if !self.silent { self.log(format!("Rule 7.7.2.2: Player {} plays {} to Slot {}", p_idx, card.name, slot_idx)); } | |
| let mut cost = self.get_member_cost(p_idx, card_id as i32, slot_idx as i32, db); | |
| let old_card_id = self.players[p_idx].stage[slot_idx]; | |
| if old_card_id >= 0 { | |
| if self.players[p_idx].moved_members_this_turn[slot_idx] { | |
| return Err("Already played/moved to this slot this turn".to_string()); | |
| } | |
| if self.players[p_idx].baton_touch_count >= self.players[p_idx].baton_touch_limit { | |
| return Err("Baton touch limit reached".to_string()); | |
| } | |
| let prev_card = db.get_member(old_card_id as u16).ok_or_else(|| format!("Prev card not found: ID {} in phase {:?}", old_card_id, self.phase))?; | |
| if !self.silent { self.log(format!("Rule 9.6.2.3.2: Baton Touch! Cost reduced by {}.", prev_card.cost)); } | |
| cost = cost.max(0); | |
| self.players[p_idx].baton_touch_count += 1; | |
| // Move old card to discard | |
| self.players[p_idx].discard.push(old_card_id as u16); | |
| } else { | |
| if self.players[p_idx].moved_members_this_turn[slot_idx] { | |
| return Err("Already played/moved to this slot this turn".to_string()); | |
| } | |
| } | |
| let untap_energy_indices: Vec<usize> = self.players[p_idx].tapped_energy.iter().enumerate() | |
| .filter(|&(_, &tapped)| !tapped).map(|(i, _)| i).take(cost as usize).collect(); | |
| if untap_energy_indices.len() < cost as usize { | |
| return Err("Not enough energy".to_string()); | |
| } | |
| for i in untap_energy_indices { | |
| self.players[p_idx].tapped_energy[i] = true; | |
| } | |
| self.players[p_idx].hand.remove(hand_idx); | |
| if hand_idx < self.players[p_idx].hand_added_turn.len() { | |
| self.players[p_idx].hand_added_turn.remove(hand_idx); | |
| } | |
| self.prev_card_id = old_card_id; | |
| self.players[p_idx].stage[slot_idx] = card_id as i16; | |
| self.players[p_idx].tapped_members[slot_idx] = false; | |
| self.players[p_idx].moved_members_this_turn[slot_idx] = true; | |
| let ctx = AbilityContext { | |
| source_card_id: card_id as i16, | |
| player_id: p_idx as u8, | |
| area_idx: slot_idx as i16, | |
| choice_index: -1, // Triggered abilities always start with no choice | |
| ability_index: -1, // Will be set specifically if paused for choice | |
| ..Default::default() | |
| }; | |
| // Rule 11.3: Trigger [登場] (On Play) abilities. | |
| // NEW: Check if we should pause for a choice. | |
| if self.phase != Phase::Response { | |
| let mut needs_choice = false; | |
| let mut ab_idx_with_choice = -1; | |
| for (i, ab) in card.abilities.iter().enumerate().skip(start_ab_idx) { | |
| if ab.trigger == TriggerType::OnPlay && bytecode_needs_early_pause(&ab.bytecode) { | |
| // Check conditions before pausing | |
| let mut actx = ctx.clone(); | |
| actx.area_idx = slot_idx as i16; | |
| if ab.conditions.iter().all(|cond| self.check_condition(db, p_idx, cond, &actx)) { | |
| needs_choice = true; | |
| ab_idx_with_choice = i as i32; | |
| break; | |
| } | |
| } | |
| } | |
| if needs_choice { | |
| if !self.silent { self.log(format!("Rule 11.3: Pausing for choice on {}.", card.name)); } | |
| let ab = &card.abilities[ab_idx_with_choice as usize]; | |
| // Identify the opcode that triggered the choice | |
| let opcode = ab.bytecode.chunks(4) | |
| .find(|chunk| !chunk.is_empty() && bytecode_has_choice(chunk)) | |
| .map(|chunk| chunk[0]) | |
| .unwrap_or(-1); | |
| self.phase = Phase::Response; | |
| self.pending_ctx = Some(ctx); | |
| self.pending_card_id = card_id as i16; | |
| self.pending_ab_idx = ab_idx_with_choice as i16; | |
| self.pending_effect_opcode = opcode as i16; | |
| self.pending_choice_type = match opcode { | |
| O_SELECT_MODE => "SELECT_MODE".to_string(), | |
| O_LOOK_AND_CHOOSE => "LOOK_AND_CHOOSE".to_string(), | |
| O_COLOR_SELECT => "COLOR_SELECT".to_string(), | |
| // O_ORDER_DECK is excluded here intentionally so it executes first to populate looked_cards | |
| _ => "".to_string(), | |
| }; | |
| return Ok(()); | |
| } | |
| } | |
| if !self.silent { self.log_rule("Rule 11.3", &format!("Triggering [登場] (On Play) abilities for {}.", card.name)); } | |
| self.trigger_abilities_from(db, TriggerType::OnPlay, &ctx, start_ab_idx); | |
| self.process_rule_checks(); | |
| // If we are in Response phase (paused for input), return immediately to wait for user | |
| if self.phase == Phase::Response { | |
| return Ok(()); | |
| } | |
| // Only set to Main if we are NOT in Response (e.g. fully resolved) | |
| if self.phase != Phase::Terminal { | |
| self.phase = Phase::Main; | |
| } | |
| Ok(()) | |
| } | |
| pub fn activate_ability(&mut self, db: &CardDatabase, slot_idx: usize, ab_idx: usize) -> Result<(), String> { | |
| self.activate_ability_with_choice(db, slot_idx, ab_idx, 0, 0) | |
| } | |
| pub fn activate_ability_with_choice(&mut self, db: &CardDatabase, slot_idx: usize, ab_idx: usize, choice_idx: i32, target_slot: i32) -> Result<(), String> { | |
| let p_idx = self.current_player as usize; | |
| let cid = if self.phase == Phase::Response { | |
| self.pending_card_id | |
| } else if slot_idx < 3 { | |
| let scid = self.players[p_idx].stage[slot_idx]; | |
| if scid >= 0 { scid } else { self.players[p_idx].live_zone[slot_idx] } | |
| } else if slot_idx >= 100 && slot_idx < 200 { | |
| let d_idx = slot_idx - 100; | |
| if d_idx < self.players[p_idx].discard.len() { | |
| self.players[p_idx].discard[d_idx] as i16 | |
| } else { -1 } | |
| } else { -1 }; | |
| if cid < 0 { return Err("No member found".to_string()); } | |
| if self.phase == Phase::Response { | |
| // RESUMPTION: Skip costs and OncePerTurn tracking. Handles both Member and Live cards. | |
| let (card_name, bytecode) = if let Some(mem) = db.get_member(cid as u16) { | |
| let ab = mem.abilities.get(ab_idx).ok_or("Ability not found on member")?; | |
| (mem.name.clone(), &ab.bytecode) | |
| } else if let Some(live) = db.get_live(cid as u16) { | |
| let ab = live.abilities.get(ab_idx).ok_or("Ability not found on live card")?; | |
| (live.name.clone(), &ab.bytecode) | |
| } else { | |
| // Standalone bytecode resumption fallback: if ab_idx is effectively 0 and we have a bytecode? | |
| // But we don't store raw bytecode in GS. So we assume it MUST be from a card. | |
| return Err("Card not found in database for resumption".to_string()); | |
| }; | |
| let mut ctx = if let Some(p_ctx) = &self.pending_ctx { | |
| p_ctx.clone() | |
| } else { | |
| AbilityContext { | |
| source_card_id: cid, | |
| player_id: p_idx as u8, | |
| area_idx: slot_idx as i16, | |
| choice_index: choice_idx as i16, | |
| target_slot: target_slot as i16, | |
| ability_index: ab_idx as i16, | |
| ..Default::default() | |
| } | |
| }; | |
| // Update choice | |
| ctx.choice_index = choice_idx as i16; | |
| if !self.silent { self.log(format!("Rule 7.7.2.1: Resuming ability of {} (Slot {}, Choice {})", card_name, slot_idx, choice_idx)); } | |
| // Standardize resumption: Reset phase to Main BEFORE execution. | |
| self.phase = Phase::Main; | |
| self.pending_ctx = None; | |
| self.pending_card_id = -1; | |
| self.pending_ab_idx = -1; | |
| self.pending_effect_opcode = -1; | |
| self.pending_choice_type = String::new(); | |
| self.resolve_bytecode(db, bytecode, &ctx); | |
| self.process_rule_checks(); | |
| return Ok(()); | |
| } | |
| let card = db.get_member(cid as u16).ok_or("Card not found")?; | |
| let ab = card.abilities.get(ab_idx).ok_or("Ability not found")?; | |
| if ab.trigger != TriggerType::Activated && self.phase != Phase::Response { return Err("Not an activated ability".to_string()); } | |
| if !self.silent { self.log(format!("Rule 7.7.2.1: Player {} activates ability of {} (Slot {}, Choice {})", p_idx, card.name, slot_idx, choice_idx)); } | |
| let ctx = AbilityContext { | |
| source_card_id: cid, | |
| player_id: p_idx as u8, | |
| area_idx: slot_idx as i16, | |
| choice_index: choice_idx as i16, | |
| target_slot: target_slot as i16, | |
| ability_index: ab_idx as i16, | |
| ..Default::default() | |
| }; | |
| // Check costs | |
| for cost in &ab.costs { | |
| if !self.check_cost(p_idx, cost, &ctx) { return Err("Cannot afford cost".to_string()); } | |
| } | |
| // Check conditions | |
| for cond in &ab.conditions { | |
| if !self.check_condition(db, p_idx, cond, &ctx) { return Err("Conditions not met".to_string()); } | |
| } | |
| // Track Once Per Turn | |
| if ab.is_once_per_turn { | |
| let ab_uid = (slot_idx as u32) << 16 | (ab_idx as u32); | |
| if self.players[p_idx].used_abilities.contains(&ab_uid) { | |
| return Err("Ability already used this turn".to_string()); | |
| } | |
| self.players[p_idx].used_abilities.push(ab_uid); | |
| } | |
| // Pay Costs | |
| for cost in &ab.costs { | |
| self.pay_cost(db, p_idx, cost, &ctx); | |
| } | |
| // NEW: Check if we should pause for a choice AFTER costs are paid | |
| if self.phase != Phase::Response && bytecode_needs_early_pause(&ab.bytecode) { | |
| if !self.silent { self.log(format!("Rule 7.7.2.1: Pausing for choice on {}.", card.name)); } | |
| self.phase = Phase::Response; | |
| self.pending_ctx = Some(ctx); | |
| self.pending_card_id = cid; | |
| self.pending_ab_idx = ab_idx as i16; | |
| self.pending_effect_opcode = bytecode_needs_early_pause_opcode(&ab.bytecode) as i16; | |
| return Ok(()); | |
| } | |
| // Run | |
| self.consume_once_per_turn(p_idx, cid as u16, ab_idx); | |
| self.resolve_bytecode(db, &ab.bytecode, &ctx); | |
| self.process_rule_checks(); | |
| // If we are in Response phase (paused for input), return immediately | |
| if self.phase == Phase::Response { | |
| return Ok(()); | |
| } | |
| // If not paused, and not terminal, ensure we are in Main phase (cleanup for single-shot) | |
| if self.phase != Phase::Terminal { | |
| self.phase = Phase::Main; | |
| } | |
| Ok(()) | |
| } | |
| pub fn step(&mut self, db: &CardDatabase, action: i32) -> Result<(), String> { | |
| // eprintln!("TRACE: step phase={:?} action={}", self.phase, action); | |
| match self.phase { | |
| Phase::MulliganP1 | Phase::MulliganP2 => { | |
| let p_idx = self.current_player as usize; | |
| if action == 0 { | |
| let mut discards = Vec::new(); | |
| for i in 0..60 { | |
| if (self.players[p_idx].mulligan_selection >> i) & 1 == 1 { | |
| discards.push(i as usize); | |
| } | |
| } | |
| self.execute_mulligan(p_idx, discards); | |
| } else if action >= 300 && action <= 359 { | |
| let card_idx = (action - 300) as u16; | |
| if (card_idx as usize) < self.players[p_idx].hand.len() { | |
| self.players[p_idx].mulligan_selection ^= 1u64 << card_idx; | |
| } | |
| } | |
| }, | |
| Phase::Active => { self.do_active_phase(db); }, | |
| Phase::Energy => { | |
| if action >= 100 && action <= 159 { | |
| self.play_energy((action - 100) as usize); | |
| } else { | |
| self.do_energy_phase(); | |
| } | |
| }, | |
| Phase::Draw => { self.do_draw_phase(); }, | |
| Phase::Main => { | |
| if action == 0 { self.end_main_phase(db); } | |
| else if action >= 1 && action <= 180 { | |
| let adj = action - 1; | |
| self.play_member(db, (adj / 3) as usize, (adj % 3) as usize)?; | |
| } else if action >= 200 && action < 230 { | |
| // Simple ability activation (no choice) | |
| let adj = action - 200; | |
| self.activate_ability(db, (adj / 10) as usize, (adj % 10) as usize)?; | |
| } else if action >= 550 && action < 850 { | |
| // Ability with choice: 550 + slot*100 + ab_idx*10 + choice | |
| let adj = action - 550; | |
| let slot_idx = (adj / 100) as usize; | |
| let ab_idx = ((adj % 100) / 10) as usize; | |
| let choice_idx = (adj % 10) as i32; | |
| self.activate_ability_with_choice(db, slot_idx, ab_idx, choice_idx, 0)?; | |
| } else if action >= 1000 && action < 2000 { | |
| // Play with choice: 1000 + hand*100 + slot*10 + choice | |
| let adj = (action - 1000) as usize; | |
| let hand_idx = adj / 100; | |
| let rem = adj % 100; | |
| let slot_idx = rem / 10; | |
| let choice_idx = (rem % 10) as i32; | |
| self.play_member_with_choice(db, hand_idx, slot_idx, choice_idx, 0)?; | |
| } else if action >= 2000 && action < 3000 { | |
| // Activate from discard: 2000 + discard_idx * 10 + ab_idx | |
| let adj = action - 2000; | |
| let discard_idx = (adj / 10) as usize; | |
| let ab_idx = (adj % 10) as usize; | |
| self.activate_ability_with_choice(db, 100 + discard_idx, ab_idx, -1, -1)?; | |
| } | |
| }, | |
| Phase::LiveSet => { | |
| let p_idx = self.current_player as usize; | |
| if action == 0 { | |
| let draws = self.live_set_pending_draws[p_idx]; | |
| if draws > 0 { | |
| if !self.silent { self.log(format!("Rule 8.2.2: Live Set End: Player {} draws {} cards.", p_idx, draws)); } | |
| self.draw_cards(p_idx, draws as u32); | |
| self.live_set_pending_draws[p_idx] = 0; | |
| } | |
| if self.current_player == self.first_player { | |
| self.current_player = 1 - self.first_player; | |
| } else { | |
| self.phase = Phase::PerformanceP1; | |
| self.current_player = self.first_player; | |
| } | |
| } else if action >= 400 && action < 500 { | |
| let hand_idx = (action - 400) as usize; | |
| if hand_idx < self.players[p_idx].hand.len() { | |
| let cid = self.players[p_idx].hand.remove(hand_idx); | |
| for i in 0..3 { | |
| if self.players[p_idx].live_zone[i] == -1 { | |
| self.players[p_idx].live_zone[i] = cid as i16; | |
| self.players[p_idx].live_zone_revealed[i] = false; | |
| self.live_set_pending_draws[p_idx] += 1; | |
| if !self.silent { self.log(format!("Rule 8.2.2: Player {} sets live card. Draw pending (+1).", p_idx)); } | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| Phase::PerformanceP1 | Phase::PerformanceP2 => { | |
| self.do_performance_phase(db); | |
| // Transition handled in advance_from_performance | |
| }, | |
| Phase::LiveResult => { | |
| if action == 0 { | |
| self.do_live_result(db); | |
| } else if action >= 600 && action <= 602 { | |
| let p_idx = self.current_player as usize; | |
| let slot_idx = (action - 600) as usize; | |
| let cid = self.players[p_idx].live_zone[slot_idx]; | |
| if cid >= 0 { | |
| self.players[p_idx].success_lives.push(cid as u16); | |
| self.players[p_idx].live_zone[slot_idx] = -1; | |
| if !self.silent { self.log(format!("Player {} SELECTED Success Live: Card ID {}", p_idx, cid)); } | |
| self.finalize_live_result(); | |
| } | |
| } | |
| }, | |
| Phase::Response => { | |
| if let Some(ctx) = self.pending_ctx.clone() { | |
| // Unified choice index extraction from action | |
| let choice_idx = if action == 0 { | |
| if self.pending_effect_opcode == O_ORDER_DECK as i16 || self.pending_effect_opcode == O_LOOK_AND_CHOOSE as i16 { | |
| 99 // "Done/Discard Rest" signal | |
| } else { | |
| -1 | |
| } | |
| } | |
| else if action >= 560 && action <= 569 { action - 560 } | |
| else if action >= 580 && action <= 585 { action - 580 } | |
| else if action >= 550 { | |
| // Context-encoded: 550 + slot*100 + ab*10 + choice | |
| let adj = action - 550; | |
| let slot = adj / 100; | |
| let ab = (adj % 100) / 10; | |
| // Optional: Validate context matches pending (prevents illegal cross-ability actions) | |
| if slot as usize == ctx.area_idx as usize && ab as usize == self.pending_ab_idx.max(0) as usize { | |
| (adj % 10) as i32 | |
| } else { | |
| // Fallback for legacy 600+ if not using high format, or mismatch | |
| if action >= 600 && action <= 719 { | |
| (action - 600) as i32 | |
| } else { | |
| -1 | |
| } | |
| } | |
| } else { -1 }; | |
| if self.pending_effect_opcode == O_ORDER_DECK as i16 || self.pending_effect_opcode == O_LOOK_AND_CHOOSE as i16 { | |
| let ab_idx = self.pending_ab_idx; | |
| if ab_idx < 0 { | |
| self.play_member_with_choice(db, 0, ctx.area_idx as usize, choice_idx, 0)?; | |
| } else { | |
| self.activate_ability_with_choice(db, ctx.area_idx as usize, ab_idx as usize, choice_idx, 0)?; | |
| } | |
| } else if action >= 600 && action <= 602 && self.pending_effect_opcode == O_TAP_O as i16 { | |
| let target_slot = choice_idx; | |
| let ab_idx = self.pending_ab_idx.max(0) as usize; | |
| self.activate_ability_with_choice(db, ctx.area_idx as usize, ab_idx, 0, target_slot)?; | |
| } else if (action >= 580 && action <= 585) && self.pending_effect_opcode == O_COLOR_SELECT as i16 { | |
| let start_idx = self.pending_ab_idx.max(0) as usize; | |
| self.play_member_with_choice(db, 0, ctx.area_idx as usize, choice_idx, start_idx)?; | |
| } else if action >= 560 && action <= 562 { | |
| // Stage slot selection (for PLAY_MEMBER_FROM_DISCARD etc.) | |
| let ab_idx = self.pending_ab_idx.max(0) as usize; | |
| self.activate_ability_with_choice(db, ctx.area_idx as usize, ab_idx, choice_idx, 0)?; | |
| } else if action >= 550 { | |
| // Generic list selection fallback (context-aware range) | |
| let ab_idx = self.pending_ab_idx.max(0) as usize; | |
| self.activate_ability_with_choice(db, ctx.area_idx as usize, ab_idx, choice_idx, 0)?; | |
| } | |
| } else { | |
| self.phase = Phase::Main; | |
| } | |
| }, | |
| _ => {} | |
| } | |
| Ok(()) | |
| } | |
| pub fn get_legal_action_ids(&self, db: &CardDatabase) -> Vec<i32> { | |
| let mut actions = Vec::with_capacity(32); | |
| self.generate_legal_actions(db, &mut actions); | |
| actions | |
| } | |
| pub fn get_legal_actions(&self, db: &CardDatabase) -> Vec<bool> { | |
| let mut mask = vec![false; ACTION_SPACE]; | |
| self.generate_legal_actions(db, mask.as_mut_slice()); | |
| mask | |
| } | |
| pub fn get_legal_actions_into(&self, db: &CardDatabase, mask: &mut [bool]) { | |
| self.generate_legal_actions(db, mask); | |
| } | |
| pub fn generate_legal_actions<R: ActionReceiver + ?Sized>(&self, db: &CardDatabase, receiver: &mut R) { | |
| receiver.reset(); | |
| let p_idx = self.current_player as usize; | |
| let player = &self.players[p_idx]; | |
| match self.phase { | |
| Phase::Main => { | |
| receiver.add_action(0); | |
| // Optimization 3: Loop Hoisting | |
| let available_energy = player.tapped_energy.iter().filter(|&&t| !t).count() as i32; | |
| // Pre-calculate stage slot costs | |
| let mut slot_costs = [0; 3]; | |
| for s in 0..3 { | |
| if player.stage[s] >= 0 { | |
| if let Some(prev) = db.get_member(player.stage[s] as u16) { | |
| slot_costs[s] = prev.cost as i32; | |
| } | |
| } | |
| } | |
| // 1. Play Member from Hand | |
| for (hand_idx, &cid) in player.hand.iter().enumerate().take(60) { | |
| if let Some(card) = db.get_member(cid) { | |
| let base_cost = (card.cost as i32 - player.cost_reduction).max(0); | |
| for slot_idx in 0..3 { | |
| if player.moved_members_this_turn[slot_idx] { continue; } | |
| let mut cost = base_cost; | |
| if player.stage[slot_idx] >= 0 { | |
| cost = (cost - slot_costs[slot_idx]).max(0); | |
| if player.baton_touch_count >= player.baton_touch_limit { continue; } | |
| } | |
| if cost <= available_energy { | |
| // Check for OnPlay choices (Limit to first 10 cards to stay within Action ID space) | |
| let mut has_choice_on_play = false; | |
| if hand_idx < 10 { | |
| for ab in &card.abilities { | |
| if ab.trigger == TriggerType::OnPlay { | |
| // OPTIMIZATION: Use pre-computed flags | |
| let has_look_choose = (ab.choice_flags & CHOICE_FLAG_LOOK) != 0; | |
| let has_select_mode = (ab.choice_flags & CHOICE_FLAG_MODE) != 0; | |
| let has_color_select = (ab.choice_flags & CHOICE_FLAG_COLOR) != 0; | |
| let has_order_deck = (ab.choice_flags & CHOICE_FLAG_ORDER) != 0; | |
| if has_look_choose || has_order_deck { | |
| has_choice_on_play = true; | |
| let count = ab.choice_count as i32; | |
| for c in 0..count { | |
| let choice_aid = 1000 + hand_idx * 100 + slot_idx * 10 + (c as usize); | |
| receiver.add_action(choice_aid); | |
| } | |
| } else if has_color_select { | |
| has_choice_on_play = true; | |
| for c in 0..6 { | |
| let choice_aid = 1000 + hand_idx * 100 + slot_idx * 10 + (c as usize); | |
| receiver.add_action(choice_aid); | |
| } | |
| } else if has_select_mode { | |
| has_choice_on_play = true; | |
| let count = ab.choice_count as i32; | |
| for c in 0..count { | |
| let choice_aid = 1000 + hand_idx * 100 + slot_idx * 10 + (c as usize); | |
| receiver.add_action(choice_aid); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if !has_choice_on_play { | |
| // Simple ability - no choice needed | |
| let aid = 1 + hand_idx * 3 + slot_idx; | |
| receiver.add_action(aid); | |
| } else { | |
| // NEW: We still allow playing choice cards via standard action 1-180 | |
| // but it will pause for choice. | |
| let aid = 1 + hand_idx * 3 + slot_idx; | |
| receiver.add_action(aid); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 2. Activate Stage Ability - audit for choice requirements | |
| for slot_idx in 0..3 { | |
| if let Some(cid) = player.stage.get(slot_idx).filter(|&&c| c >= 0) { | |
| if let Some(card) = db.get_member(*cid as u16) { | |
| for (ab_idx, ab) in card.abilities.iter().enumerate().take(10) { | |
| if ab.trigger == TriggerType::Activated { | |
| let ctx = AbilityContext { | |
| player_id: self.current_player, | |
| area_idx: slot_idx as i16, | |
| ..Default::default() | |
| }; | |
| let cond_ok = ab.conditions.iter().all(|c| self.check_condition(db, p_idx, c, &ctx)); | |
| let cost_ok = ab.costs.iter().all(|c| self.check_cost(p_idx, c, &ctx)); | |
| if cond_ok && cost_ok { | |
| // Check if ability needs choices (has O_LOOK_AND_CHOOSE or O_SELECT_MODE) | |
| // OPTIMIZATION: Use pre-computed flags | |
| let has_look_choose = (ab.choice_flags & CHOICE_FLAG_LOOK) != 0; | |
| let has_select_mode = (ab.choice_flags & CHOICE_FLAG_MODE) != 0; | |
| let has_color_select = (ab.choice_flags & CHOICE_FLAG_COLOR) != 0; | |
| if has_look_choose { | |
| let count = ab.choice_count as i32; | |
| for choice in 0..count { | |
| let ab_aid = 550 + slot_idx * 100 + ab_idx * 10 + (choice as usize); | |
| receiver.add_action(ab_aid); | |
| } | |
| } else if has_select_mode { | |
| let count = ab.choice_count as i32; | |
| for choice in 0..count { | |
| let ab_aid = 550 + slot_idx * 100 + ab_idx * 10 + (choice as usize); | |
| receiver.add_action(ab_aid); | |
| } | |
| } else if has_color_select { | |
| for choice in 0..6 { | |
| let ab_aid = 550 + slot_idx * 100 + ab_idx * 10 + (choice as usize); | |
| receiver.add_action(ab_aid); | |
| } | |
| } else { | |
| let ab_aid = 200 + slot_idx * 10 + ab_idx; | |
| receiver.add_action(ab_aid); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 3. Activate Discard Ability | |
| for discard_idx in 0..self.players[p_idx].discard.len().min(60) { | |
| let cid = self.players[p_idx].discard[discard_idx]; | |
| if let Some(card) = db.get_member(cid) { | |
| for (ab_idx, ab) in card.abilities.iter().enumerate().take(10) { | |
| if ab.trigger == TriggerType::Activated { | |
| let ctx = AbilityContext { | |
| player_id: self.current_player, | |
| source_card_id: cid as i16, | |
| area_idx: -1, | |
| ..Default::default() | |
| }; | |
| let cond_ok = ab.conditions.iter().all(|c| self.check_condition(db, p_idx, c, &ctx)); | |
| let cost_ok = ab.costs.iter().all(|c| self.check_cost(p_idx, c, &ctx)); | |
| if cond_ok && cost_ok { | |
| if self.check_once_per_turn(p_idx, cid as u16, ab_idx) { | |
| // Discard activations use 2000 + idx * 10 + ab_idx | |
| let ab_aid = 2000 + discard_idx * 10 + ab_idx; | |
| receiver.add_action(ab_aid); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| Phase::LiveSet => { | |
| receiver.add_action(0); | |
| if player.live_zone.iter().any(|&cid| cid == -1) { | |
| for i in 0..player.hand.len().min(60) { | |
| receiver.add_action(400 + i); | |
| } | |
| } | |
| } | |
| Phase::PerformanceP1 | Phase::PerformanceP2 => { | |
| receiver.add_action(0); | |
| } | |
| Phase::MulliganP1 | Phase::MulliganP2 => { | |
| // Action 0: Confirm | |
| receiver.add_action(0); | |
| // Actions 300-359: Toggle (One-way for AI to avoid loops) | |
| let hand_len = player.hand.len().min(60); | |
| for i in 0..hand_len { | |
| // Only legal if NOT already selected | |
| if (player.mulligan_selection >> i) & 1 == 0 { | |
| receiver.add_action(300 + i); | |
| } | |
| } | |
| } | |
| Phase::Active | Phase::Draw | Phase::LiveResult => { | |
| receiver.add_action(0); | |
| if self.phase == Phase::LiveResult { | |
| // Selection choices (600-602) | |
| for i in 0..3 { | |
| if player.live_zone[i] >= 0 { | |
| receiver.add_action(600 + i); | |
| } | |
| } | |
| } | |
| } | |
| Phase::Energy => { | |
| receiver.add_action(0); // Pass/Auto-draw from energy deck | |
| let hand_len = player.hand.len().min(60); | |
| for i in 0..hand_len { | |
| receiver.add_action(100 + i); | |
| } | |
| } | |
| Phase::Response => { | |
| // Always allow action 0 as a fallback/wait action | |
| receiver.add_action(0); | |
| // We only show choices for the pending ability | |
| if let Some(ctx) = self.pending_ctx { | |
| // Fix: Only generate choices if current player owns the pending ability | |
| if ctx.player_id as usize != p_idx { | |
| return; | |
| } | |
| let card_id = self.pending_card_id; | |
| let ab_idx = self.pending_ab_idx; | |
| let member = db.get_member(card_id as u16); | |
| let live = db.get_live(card_id as u16); | |
| let abilities = if let Some(m) = member { Some(&m.abilities) } else { live.map(|l| &l.abilities) }; | |
| // NEW: Explicitly check pending_effect_opcode first to avoid bytecode sniffing failures | |
| if self.pending_effect_opcode == O_TAP_O as i16 { | |
| for c in 600..603 { | |
| receiver.add_action(c); | |
| } | |
| return; | |
| } | |
| if self.pending_effect_opcode == O_ORDER_DECK as i16 { | |
| // Selection choice for ORDER_DECK: Choose a card index to put on top | |
| let ctx_player = ctx.player_id as usize; | |
| let count = self.players[ctx_player].looked_cards.len(); | |
| if count > 0 { | |
| let base = 550 + (ctx.area_idx as i32 * 100) + (self.pending_ab_idx.max(0) as i32 * 10); | |
| for c in 0..=count { | |
| let aid = (base + c as i32) as usize; | |
| receiver.add_action(aid); | |
| } | |
| } | |
| return; | |
| } | |
| if self.pending_effect_opcode == O_COLOR_SELECT as i16 { | |
| for c in 580..586 { | |
| receiver.add_action(c); | |
| } | |
| return; | |
| } | |
| if self.pending_effect_opcode == O_LOOK_AND_CHOOSE as i16 { | |
| let ctx_player = ctx.player_id as usize; | |
| let looked = &self.players[ctx_player].looked_cards; | |
| let mut filter_attr = 0; | |
| if let Some(abs) = abilities { | |
| let ab_idx_real = if ab_idx == -1 { | |
| // OPTIMIZATION: Use pre-computed flags | |
| abs.iter().position(|ab| (ab.choice_flags & (CHOICE_FLAG_LOOK | CHOICE_FLAG_MODE | CHOICE_FLAG_COLOR | CHOICE_FLAG_ORDER)) != 0).unwrap_or(0) | |
| } else { ab_idx as usize }; | |
| if let Some(ab) = abs.get(ab_idx_real) { | |
| if let Some(chunk) = ab.bytecode.chunks(4).find(|ch| ch[0] == O_LOOK_AND_CHOOSE) { | |
| filter_attr = chunk[2]; | |
| } | |
| } | |
| } | |
| let base = 550 + (ctx.area_idx as i32 * 100) + (self.pending_ab_idx.max(0) as i32 * 10); | |
| for (i, &cid) in looked.iter().enumerate() { | |
| if self.card_matches_filter(db, cid, filter_attr) { | |
| let aid = (base + i as i32) as usize; | |
| receiver.add_action(aid); | |
| } | |
| } | |
| return; | |
| } | |
| if self.pending_effect_opcode == O_PLAY_MEMBER_FROM_DISCARD as i16 || self.pending_effect_opcode == O_PLAY_MEMBER_FROM_HAND as i16 { | |
| for s in 0..3 { | |
| if !player.moved_members_this_turn[s] { | |
| let aid = 560 + s; | |
| receiver.add_action(aid); | |
| } | |
| } | |
| return; | |
| } | |
| // Fallback to sniffing for other opcodes | |
| if let Some(abs) = abilities { | |
| let ab_idx_real = if ab_idx == -1 { | |
| abs.iter().position(|ab| ab.trigger == TriggerType::OnPlay && bytecode_has_choice(&ab.bytecode)).unwrap_or(0) | |
| } else { | |
| ab_idx as usize | |
| }; | |
| if let Some(ab) = abs.get(ab_idx_real) { | |
| let mut count = 2; | |
| if let Some(chunk) = ab.bytecode.chunks(4).find(|chunk| !chunk.is_empty() && chunk[0] == O_LOOK_AND_CHOOSE) { | |
| count = chunk[1].max(1).min(10) as usize; | |
| } else if ab.bytecode.chunks(4).any(|chunk| !chunk.is_empty() && chunk[0] == O_SELECT_MODE) { | |
| if let Some(chunk) = ab.bytecode.chunks(4).find(|chunk| !chunk.is_empty() && chunk[0] == O_SELECT_MODE) { | |
| count = chunk[1].max(1).min(10) as usize; | |
| } | |
| } | |
| let base = 550 + (ctx.area_idx as i32 * 100) + (ab_idx_real as i32 * 10); | |
| for c in 0..count { | |
| let aid = (base + c as i32) as usize; | |
| receiver.add_action(aid); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| _ => { receiver.add_action(0); } | |
| } | |
| } | |
| pub fn integrated_step(&mut self, db: &CardDatabase, action: i32, opp_mode: u8, mcts_sims: usize, enable_rollout: bool) -> (f32, bool) { | |
| let prev_score = self.players[0].score as f32; | |
| // 1. Agent Step (if interactive) | |
| let is_interactive = match self.phase { | |
| Phase::Main | Phase::LiveSet | Phase::MulliganP1 | Phase::MulliganP2 | Phase::Response | Phase::LiveResult | Phase::Energy => true, | |
| _ => false, | |
| }; | |
| if self.current_player == 0 && is_interactive { | |
| let _ = self.step(db, action); | |
| } else { | |
| // Auto-progress or illegal call - pass | |
| let _ = self.step(db, 0); | |
| } | |
| // 2. Loop until Agent Turn or Terminal | |
| while self.phase != Phase::Terminal { | |
| let is_int = match self.phase { | |
| Phase::Main | Phase::LiveSet | Phase::MulliganP1 | Phase::MulliganP2 | Phase::Response | Phase::LiveResult | Phase::Energy => true, | |
| _ => false, | |
| }; | |
| if self.current_player == 0 && is_int { | |
| break; // Agent's turn reached | |
| } | |
| if is_int { | |
| // Interactive turn for opponent (Player 1) | |
| if opp_mode == 1 { | |
| // Default to Original Heuristic for normal integrated step | |
| // Default to Original Heuristic for normal integrated step | |
| // In-lined to support enable_rollout | |
| let sims = if mcts_sims > 0 { mcts_sims } else { 1000 }; | |
| let mut mcts = MCTS::new(); | |
| let heuristic = OriginalHeuristic; | |
| let (stats, _) = mcts.search_custom(self, db, sims, 0.0, SearchHorizon::TurnEnd, &heuristic, false, enable_rollout); | |
| let best_action = if !stats.is_empty() { stats[0].0 } else { 0 }; | |
| let _ = self.step(db, best_action); | |
| } else { | |
| self.step_opponent(db); | |
| } | |
| } else { | |
| // Non-interactive phase (Energy, Draw, LiveResult, etc.) | |
| let _ = self.step(db, 0); | |
| } | |
| } | |
| let current_score = self.players[0].score as f32; | |
| let mut reward = current_score - prev_score; | |
| // Add win/loss reward | |
| if self.phase == Phase::Terminal { | |
| let winner = self.get_winner(); | |
| if winner == 0 { reward += 100.0; } | |
| else if winner == 1 { reward -= 100.0; } | |
| } | |
| (reward, self.phase == Phase::Terminal) | |
| } | |
| } // End GameState | |
| // ======================================================================================= | |
| // Action Receiver Trait (Sparse Generation) | |
| // ======================================================================================= | |
| pub trait ActionReceiver { | |
| fn add_action(&mut self, action_id: usize); | |
| fn reset(&mut self); | |
| } | |
| impl ActionReceiver for [bool] { | |
| fn add_action(&mut self, action_id: usize) { | |
| if action_id < self.len() { | |
| self[action_id] = true; | |
| } | |
| } | |
| fn reset(&mut self) { | |
| self.fill(false); | |
| } | |
| } | |
| impl ActionReceiver for Vec<usize> { | |
| fn add_action(&mut self, action_id: usize) { | |
| self.push(action_id); | |
| } | |
| fn reset(&mut self) { | |
| self.clear(); | |
| } | |
| } | |
| impl ActionReceiver for Vec<i32> { | |
| fn add_action(&mut self, action_id: usize) { | |
| self.push(action_id as i32); | |
| } | |
| fn reset(&mut self) { | |
| self.clear(); | |
| } | |
| } | |
| impl GameState { | |
| pub fn step_opponent(&mut self, db: &CardDatabase) { | |
| // Simple Random Opponent Logic | |
| let legal = self.get_legal_actions(db); | |
| let legal_indices: Vec<i32> = legal.iter().enumerate().filter(|(_, l)| **l).map(|(i, _)| i as i32).collect(); | |
| let action = if !legal_indices.is_empty() { | |
| let mut rng = Pcg64::from_os_rng(); | |
| *legal_indices.choose(&mut rng).unwrap() | |
| } else { 0 }; | |
| let _ = self.step(db, action); | |
| } | |
| pub fn step_opponent_mcts(&mut self, db: &CardDatabase, sims: usize, _heuristic: &dyn Heuristic) { | |
| let mcts = MCTS::new(); | |
| // Default to Blind mode for fair play | |
| let stats = mcts.search_parallel_mode(self, db, sims, 0.0, crate::core::mcts::SearchHorizon::GameEnd, EvalMode::Blind); | |
| if !stats.is_empty() { | |
| let action = stats[0].0; | |
| let _ = self.step(db, action); | |
| } | |
| } | |
| pub fn step_opponent_greedy(&mut self, db: &CardDatabase, heuristic: &dyn Heuristic) { | |
| let action = self.get_greedy_action(db, heuristic); | |
| let _ = self.step(db, action); | |
| } | |
| pub fn get_mcts_suggestions(&self, db: &CardDatabase, sims: usize, horizon: SearchHorizon, eval_mode: EvalMode) -> Vec<(i32, f32, u32)> { | |
| let mcts = MCTS::new(); | |
| // Uses the specified eval_mode, which defaults to Blind in bindings | |
| mcts.search_parallel_mode(self, db, sims, 0.0, horizon, eval_mode) | |
| } | |
| pub fn get_greedy_action(&mut self, db: &CardDatabase, heuristic: &dyn Heuristic) -> i32 { | |
| let legal = self.get_legal_actions(db); | |
| let legal_indices: Vec<i32> = legal.iter().enumerate().filter(|(_, l)| **l).map(|(i, _)| i as i32).collect(); | |
| if legal_indices.is_empty() { return 0; } | |
| let mut best_action = 0; | |
| let mut best_score = f32::NEG_INFINITY; | |
| // Simple 1-ply search | |
| for &action in &legal_indices { | |
| let mut state = self.clone(); | |
| state.silent = true; | |
| // Handle determinization? | |
| // For a greedy agent, we assume perfect information or just use current state? | |
| // "Not using MCTS" implies we don't do rollouts or determinization usually, | |
| // OR we do determinization once? | |
| // Let's do determinization to be fair (IS-Greedy). | |
| let me = self.current_player as usize; | |
| let opp = 1 - me; | |
| let opp_hand_len = state.players[opp].hand.len(); | |
| let mut unseen: Vec<u16> = state.players[opp].hand.iter().cloned().collect(); | |
| unseen.extend(state.players[opp].deck.iter().cloned()); | |
| let mut rng = Pcg64::from_os_rng(); | |
| unseen.shuffle(&mut rng); | |
| state.players[opp].hand = unseen.drain(0..opp_hand_len).collect(); | |
| state.players[opp].deck = SmallVec::from_vec(unseen); | |
| let _ = state.step(db, action); | |
| // Eval from perspective of current player (self.current_player) | |
| // But state.step might change current_player? | |
| // Heuristic evaluate takes baselines. | |
| let score = heuristic.evaluate(&state, db, self.players[0].score, self.players[1].score); | |
| // Heuristic returns p0 win probability (0.0 to 1.0) | |
| // If I am p0, I want to Maximize score. | |
| // If I am p1, I want to Minimize score (make it close to 0.0). | |
| let my_utility = if me == 0 { score } else { 1.0 - score }; | |
| if my_utility > best_score { | |
| best_score = my_utility; | |
| best_action = action; | |
| } | |
| } | |
| best_action | |
| } | |
| pub fn play_asymmetric_match(&mut self, db: &CardDatabase, p0_sims: usize, p1_sims: usize, p0_heuristic_id: i32, p1_heuristic_id: i32, horizon: SearchHorizon, p0_rollout: bool, p1_rollout: bool) -> (i32, u32) { | |
| let h0: Box<dyn Heuristic> = match p0_heuristic_id { | |
| 1 => Box::new(SimpleHeuristic), | |
| _ => Box::new(OriginalHeuristic), | |
| }; | |
| let h1: Box<dyn Heuristic> = match p1_heuristic_id { | |
| 1 => Box::new(SimpleHeuristic), | |
| _ => Box::new(OriginalHeuristic), | |
| }; | |
| let mut loop_count = 0; | |
| while self.phase != Phase::Terminal && loop_count < 2000 { | |
| loop_count += 1; | |
| // Determine who needs to make a decision | |
| let acting_player = match self.phase { | |
| Phase::Response => { | |
| if let Some(ctx) = self.pending_ctx { | |
| ctx.player_id | |
| } else { | |
| self.current_player | |
| } | |
| }, | |
| _ => self.current_player, | |
| }; | |
| let is_interactive = match self.phase { | |
| Phase::Main | Phase::LiveSet | Phase::MulliganP1 | Phase::MulliganP2 | Phase::LiveResult | Phase::Energy | Phase::Response => true, | |
| _ => false, | |
| }; | |
| if is_interactive { | |
| let p_idx = acting_player as usize; | |
| let sims = if p_idx == 0 { p0_sims } else { p1_sims }; | |
| let rollout = if p_idx == 0 { p0_rollout } else { p1_rollout }; | |
| let heuristic = if p_idx == 0 { h0.as_ref() } else { h1.as_ref() }; | |
| let action = if sims > 0 { | |
| let mut mcts = MCTS::new(); | |
| let (stats, _) = mcts.search_custom(self, db, sims, 0.0, horizon, heuristic, false, rollout); | |
| if !stats.is_empty() { stats[0].0 } else { 0 } | |
| } else { | |
| self.get_greedy_action(db, heuristic) | |
| }; | |
| let _ = self.step(db, action); | |
| } else { | |
| let _ = self.step(db, 0); | |
| } | |
| } | |
| (self.get_winner(), self.turn as u32) | |
| } | |
| pub fn play_mirror_match(&mut self, db: &CardDatabase, p0_sims: usize, p1_sims: usize, p0_heuristic_id: i32, p1_heuristic_id: i32, horizon: SearchHorizon, enable_rollout: bool) -> (i32, u32) { | |
| self.play_asymmetric_match(db, p0_sims, p1_sims, p0_heuristic_id, p1_heuristic_id, horizon, enable_rollout, enable_rollout) | |
| } | |
| // Optimization 2: Zero Allocation Write | |
| pub fn write_observation(&self, db: &CardDatabase, out: &mut [f32]) { | |
| let obs = self.encode_state(db); | |
| let len = obs.len().min(out.len()); | |
| out[..len].copy_from_slice(&obs[..len]); | |
| if out.len() > len { | |
| for i in len..out.len() { out[i] = 0.0; } | |
| } | |
| } | |
| pub fn get_observation(&self, db: &CardDatabase) -> Vec<f32> { | |
| self.encode_state(db) | |
| } | |
| pub fn is_terminal(&self) -> bool { | |
| self.phase == Phase::Terminal | |
| } | |
| pub fn get_winner(&self) -> i32 { | |
| if self.phase != Phase::Terminal { return -1; } | |
| let s0 = self.players[0].score; | |
| let s1 = self.players[1].score; | |
| if s0 > s1 { 0 } | |
| else if s1 > s0 { 1 } | |
| else { 2 } // Draw | |
| } | |
| } | |