Spaces:
Sleeping
Sleeping
| use crate::core::logic::*; | |
| use crate::core::logic::rules::get_effective_hearts; | |
| use crate::test_helpers::*; | |
| use crate::core::generated_constants::{ACTION_BASE_CHOICE, ACTION_BASE_ENERGY, ACTION_BASE_HAND_SELECT, ACTION_BASE_MODE, ACTION_BASE_STAGE, ACTION_BASE_STAGE_SLOTS, UNIT_BIBI, UNIT_PRINTEMPS}; | |
| mod tests { | |
| use super::*; | |
| fn create_test_state() -> GameState { | |
| GameState::default() | |
| } | |
| fn current_constant_score_bonus(state: &GameState, db: &CardDatabase, p_idx: usize) -> i32 { | |
| let mut total_bonus = 0; | |
| for slot in 0..3 { | |
| let cid = state.players[p_idx].stage[slot]; | |
| if cid < 0 { | |
| continue; | |
| } | |
| if let Some(member) = db.get_member(cid) { | |
| let printed_ability_count = member | |
| .ability_text | |
| .lines() | |
| .filter(|line| line.trim_start().starts_with("TRIGGER:")) | |
| .count(); | |
| for ability in member.abilities.iter().take(printed_ability_count) { | |
| if ability.trigger != TriggerType::Constant { | |
| continue; | |
| } | |
| let ctx = AbilityContext { | |
| source_card_id: cid, | |
| player_id: p_idx as u8, | |
| activator_id: p_idx as u8, | |
| area_idx: slot as i16, | |
| ..Default::default() | |
| }; | |
| if ability | |
| .conditions | |
| .iter() | |
| .all(|condition| state.check_condition(db, p_idx, condition, &ctx, 1)) | |
| { | |
| let bc = &ability.bytecode; | |
| let mut index = 0; | |
| while index + 4 < bc.len() { | |
| if bc[index] == O_BOOST_SCORE { | |
| total_bonus += bc[index + 1]; | |
| } | |
| index += 5; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| for &(target_cid, source_cid, ability_index) in &state.players[p_idx].granted_abilities { | |
| if let Some(slot) = state.players[p_idx] | |
| .stage | |
| .iter() | |
| .position(|&cid| cid == target_cid) | |
| { | |
| if let Some(source_member) = db.get_member(source_cid) { | |
| if let Some(ability) = source_member.abilities.get(ability_index as usize) { | |
| if ability.trigger != TriggerType::Constant { | |
| continue; | |
| } | |
| let ctx = AbilityContext { | |
| source_card_id: target_cid, | |
| player_id: p_idx as u8, | |
| activator_id: p_idx as u8, | |
| area_idx: slot as i16, | |
| ..Default::default() | |
| }; | |
| if ability | |
| .conditions | |
| .iter() | |
| .all(|condition| state.check_condition(db, p_idx, condition, &ctx, 1)) | |
| { | |
| let bc = &ability.bytecode; | |
| let mut index = 0; | |
| while index + 4 < bc.len() { | |
| if bc[index] == O_BOOST_SCORE { | |
| total_bonus += bc[index + 1]; | |
| } | |
| index += 5; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| total_bonus | |
| } | |
| fn first_abilityless_member_triplet_with_min_heart_types( | |
| db: &CardDatabase, | |
| min_type_count: u32, | |
| ) -> [i32; 3] { | |
| let mut mask_to_card = std::collections::BTreeMap::<u8, i32>::new(); | |
| for card in db.members.values() { | |
| if !card.abilities.is_empty() { | |
| continue; | |
| } | |
| let mut mask = 0u8; | |
| for color_idx in 0..6 { | |
| if card.hearts[color_idx] > 0 { | |
| mask |= 1 << color_idx; | |
| } | |
| } | |
| if mask == 0 || mask.count_ones() >= min_type_count { | |
| continue; | |
| } | |
| mask_to_card.entry(mask).or_insert(card.card_id); | |
| } | |
| let masks: Vec<(u8, i32)> = mask_to_card.into_iter().collect(); | |
| for i in 0..masks.len() { | |
| for j in (i + 1)..masks.len() { | |
| for k in (j + 1)..masks.len() { | |
| if (masks[i].0 | masks[j].0 | masks[k].0).count_ones() >= min_type_count { | |
| return [masks[i].1, masks[j].1, masks[k].1]; | |
| } | |
| } | |
| } | |
| } | |
| panic!( | |
| "Expected an abilityless three-member stage covering at least {} heart colors", | |
| min_type_count | |
| ); | |
| } | |
| fn test_q151_granted_score_bonus_ends_when_target_leaves_stage() { | |
| // Q151: 『 {{kidou.png|起動}} {{center.png|センター}} {{turn1.png|ターン1回}} メンバー1人をウェイトにする: | |
| // ライブ終了時まで、これによってウェイト状態になったメンバーは、「{{jyouji.png|常時}}ライブの合計スコアを+1する。」を得る。』について。 | |
| // この能力でウェイトにしたメンバーがステージから離れました。「{{jyouji.png|常時}}ライブの合計スコアを+1する。」の能力で | |
| // 合計スコアを+1することはできますか? | |
| // A151: いいえ、できません。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| let kanan_id = db | |
| .id_by_no("PL!S-bp3-001-P") | |
| .expect("Q151: expected PL!S-bp3-001-P in real DB"); | |
| let target_member_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != kanan_id && card.abilities.is_empty()) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q151: expected a deterministic no-ability stage target in real DB"); | |
| // Put two copies of the same member ID on stage so the test can detect | |
| // whether the granted ability incorrectly follows card_id instead of the | |
| // original stage object. | |
| state.players[0].stage[0] = target_member_id; | |
| state.players[0].stage[1] = kanan_id; | |
| state.players[0].stage[2] = target_member_id; | |
| state | |
| .step(&db, ACTION_BASE_STAGE + 100) | |
| .expect("Q151: activating the center ability should succeed"); | |
| state | |
| .step(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q151: selecting the left target should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].granted_abilities.len(), | |
| 1, | |
| "Q151: the selected member should receive exactly one granted ability" | |
| ); | |
| let leave_ctx = AbilityContext { | |
| source_card_id: target_member_id, | |
| player_id: 0, | |
| activator_id: 0, | |
| area_idx: 0, | |
| ..Default::default() | |
| }; | |
| let removed = state | |
| .handle_member_leaves_stage(0, 0, &db, &leave_ctx) | |
| .expect("Q151: the original granted target should leave stage successfully"); | |
| state.players[0].discard.push(removed); | |
| assert_eq!( | |
| current_constant_score_bonus(&state, &db, 0), | |
| 0, | |
| "Q151: once the originally granted member leaves stage, another copy with the same card ID must not inherit the granted constant +1 score bonus" | |
| ); | |
| } | |
| fn test_q152_center_wait_cost_cannot_target_opponent_member() { | |
| // Q152: 『 {{kidou.png|起動}} {{center.png|センター}} {{turn1.png|ターン1回}} メンバー1人をウェイトにする: | |
| // ライブ終了時まで、これによってウェイト状態になったメンバーは、「{{jyouji.png|常時}}ライブの合計スコアを+1する。」を得る。』について。 | |
| // この能力で相手のメンバーをウェイトにして能力を使用できますか? | |
| // A152: いいえ、できません。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.ui.silent = true; | |
| let kanan_id = db | |
| .id_by_no("PL!S-bp3-001-R+") | |
| .expect("Q152: expected PL!S-bp3-001-R+ in real DB"); | |
| let own_member_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != kanan_id) | |
| .map(|card| card.card_id) | |
| .expect("Q152: expected another member in real DB"); | |
| let opponent_member_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != kanan_id && card.card_id != own_member_id) | |
| .map(|card| card.card_id) | |
| .expect("Q152: expected opponent target member in real DB"); | |
| state.players[0].stage[1] = kanan_id; | |
| state.players[0].stage[0] = own_member_id; | |
| state.players[1].stage[2] = opponent_member_id; | |
| state.players[0].set_tapped(0, false); | |
| state.players[0].set_tapped(1, false); | |
| state.players[1].set_tapped(2, false); | |
| let activation_action = ACTION_BASE_STAGE as i32 + 100; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q152: the center-only activation should be legal from slot 1" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q152: activation should enter target selection"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q152: activation should suspend for member selection" | |
| ); | |
| let mut response_actions: Vec<i32> = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q152: own left-slot member should be a valid target" | |
| ); | |
| assert!( | |
| !response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 2)), | |
| "Q152: opponent slot must not be selectable for this cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q152: selecting own member should resolve"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].is_tapped(0), | |
| "Q152: the chosen own member should become waiting/tapped" | |
| ); | |
| assert!( | |
| !state.players[1].is_tapped(2), | |
| "Q152: opponent member must remain untouched" | |
| ); | |
| } | |
| fn test_q153_opponent_without_live_counts_as_zero_yell_cards() { | |
| // Q153: 『 {{live_success.png|ライブ成功時}} エールにより公開された自分のカードの枚数が、 | |
| // 相手がエールによって公開したカードの枚数より少ない場合、カードを1枚引く。』について。 | |
| // 相手がライブをしていないときどうなりますか? | |
| // A153: 相手がライブをしていない場合、エールにより公開されたカードが0枚のときと同じ扱いとなります。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("PL!S-bp3-005-R") | |
| .expect("Q153: expected PL!S-bp3-005-R in real DB"); | |
| let deck_card = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != live_id) | |
| .map(|card| card.card_id) | |
| .expect("Q153: expected a member to draw from deck"); | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].deck = vec![deck_card].into(); | |
| state.players[0].hand.clear(); | |
| state.players[0].yell_cards = vec![900001].into(); | |
| state.players[1].yell_cards.clear(); | |
| state.ui.performance_results.insert( | |
| 0, | |
| serde_json::json!({ | |
| "success": true, | |
| "lives": [{ | |
| "slot_idx": 0, | |
| "card_id": live_id, | |
| "passed": true, | |
| "score": 1 | |
| }] | |
| }), | |
| ); | |
| state.ui.performance_results.insert( | |
| 1, | |
| serde_json::json!({ | |
| "success": false, | |
| "lives": [] | |
| }), | |
| ); | |
| state.do_live_result(&db); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[1].yell_cards.len(), | |
| 0, | |
| "Q153: opponent without a live should contribute 0 revealed yell cards" | |
| ); | |
| assert!( | |
| !state.players[0].hand.contains(&deck_card), | |
| "Q153: the live-success draw must not trigger when comparing 1 revealed card against opponent's 0" | |
| ); | |
| assert_eq!( | |
| state.players[0].hand.len(), | |
| 0, | |
| "Q153: hand size should stay unchanged because 1 < 0 is false" | |
| ); | |
| } | |
| fn test_q154_no_matching_recover_target_ends_without_replacement() { | |
| // Q154: 『 {{kidou.png|起動}} {{center.png|センター}} {{turn1.png|ターン1回}} このメンバーをウェイトにし、手札を1枚控え室に置く: | |
| // このメンバー以外の『Aqours』のメンバー1人を自分のステージから控え室に置く。そうした場合、自分の控え室から、そのメンバーのコストに2を足した数に等しいコストの | |
| // 『Aqours』のメンバーカードを1枚、そのメンバーがいたエリアに登場させる。』について。 | |
| // 自分の控え室に条件に合う『Aqours』メンバーがいない場合、どうなりますか? | |
| // A154: 自分の控え室からメンバーカードを登場させず、そのままこの能力の処理を終わります。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.ui.silent = true; | |
| let yoh_id = db | |
| .id_by_no("PL!S-bp3-006-R+") | |
| .expect("Q154: expected PL!S-bp3-006-R+ in real DB"); | |
| let target_member_id = db | |
| .id_by_no("PL!S-PR-015-PR") | |
| .expect("Q154: expected fixed Aqours target in real DB"); | |
| let hand_discard_id = db | |
| .id_by_no("LL-bp1-001-R+") | |
| .expect("Q154: expected fixed off-group hand discard in real DB"); | |
| state.players[0].stage[1] = yoh_id; | |
| state.players[0].stage[0] = target_member_id; | |
| state.players[0].hand = vec![hand_discard_id].into(); | |
| state.players[0].discard.clear(); | |
| state | |
| .handle_main(&db, ACTION_BASE_STAGE as i32 + 100) | |
| .expect("Q154: activation should start"); | |
| state.process_trigger_queue(&db); | |
| for _ in 0..6 { | |
| if state.phase != Phase::Response { | |
| break; | |
| } | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| let action = if response_actions.contains(&(ACTION_BASE_HAND_SELECT + 0)) { | |
| ACTION_BASE_HAND_SELECT + 0 | |
| } else if response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)) { | |
| ACTION_BASE_STAGE_SLOTS + 0 | |
| } else if response_actions.iter().any(|candidate| *candidate >= ACTION_BASE_CHOICE) { | |
| *response_actions | |
| .iter() | |
| .filter(|candidate| **candidate >= ACTION_BASE_CHOICE) | |
| .min() | |
| .expect("Q154: expected a choice response") | |
| } else { | |
| *response_actions | |
| .iter() | |
| .filter(|candidate| **candidate > 0) | |
| .min() | |
| .expect("Q154: expected a positive response action") | |
| }; | |
| state | |
| .handle_response(&db, action) | |
| .expect("Q154: response step should resolve"); | |
| state.process_trigger_queue(&db); | |
| } | |
| assert_eq!( | |
| state.players[0].stage[0], | |
| -1, | |
| "Q154: the sacrificed Aqours member's slot should stay empty when no matching discard target exists" | |
| ); | |
| assert!( | |
| state.players[0].discard.contains(&target_member_id), | |
| "Q154: the selected Aqours member should still be moved to discard" | |
| ); | |
| assert!( | |
| !state.players[0].stage.contains(&hand_discard_id), | |
| "Q154: the hand-discard payment card must not be played as a replacement" | |
| ); | |
| } | |
| fn test_q157_can_attach_tapped_energy_under_member() { | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::LiveSet; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| let ayumu_id = db | |
| .id_by_no("PL!N-bp3-001-R+") | |
| .expect("Q157: expected PL!N-bp3-001-R+ in real DB"); | |
| let support_member_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != ayumu_id) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q157: expected a supporting member in real DB"); | |
| state.players[0].stage[1] = ayumu_id; | |
| state.players[0].stage[0] = support_member_id; | |
| state.players[0].deck = vec![support_member_id].into(); | |
| state.players[0].hand.clear(); | |
| state.players[0].energy_zone.push(2000); | |
| state.players[0].set_energy_tapped(0, true); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Response, "Q157: optional attach should first suspend for the yes/no prompt"); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q157: accepting the optional attach should resolve successfully"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Response, "Q157: after accepting, the ability should suspend for an energy choice"); | |
| let mut response_actions: Vec<i32> = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_ENERGY + 0)), | |
| "Q157: tapped energy in the energy zone should still be selectable to place under the member" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_ENERGY + 0) | |
| .expect("Q157: selecting the tapped energy should resolve successfully"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].energy_zone.is_empty(), | |
| "Q157: the selected tapped energy should leave the energy zone" | |
| ); | |
| assert_eq!( | |
| state.players[0].stage_energy[1].len(), | |
| 1, | |
| "Q157: the tapped energy should be placed under the source member" | |
| ); | |
| assert_eq!( | |
| state.players[0].stage_energy[1][0], | |
| 2000, | |
| "Q157: ATTACH_SELF should move the tapped energy card itself, not a fallback card from another zone" | |
| ); | |
| } | |
| fn test_q158_attached_energy_blade_bonus_applies_to_all_stage_members() { | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::LiveSet; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| let ayumu_id = db | |
| .id_by_no("PL!N-bp3-001-R+") | |
| .expect("Q158: expected PL!N-bp3-001-R+ in real DB"); | |
| let other_members: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != ayumu_id && card.abilities.is_empty()) | |
| .map(|card| card.card_id) | |
| .take(2) | |
| .collect(); | |
| assert_eq!(other_members.len(), 2, "Q158: expected two supporting members in real DB"); | |
| state.players[0].stage[0] = other_members[0]; | |
| state.players[0].stage[1] = ayumu_id; | |
| state.players[0].stage[2] = other_members[1]; | |
| state.players[0].deck = vec![other_members[0]].into(); | |
| state.players[0].hand.clear(); | |
| state.players[0].energy_zone.push(2001); | |
| state.players[0].set_energy_tapped(0, false); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Response, "Q158: optional attach should first suspend for the yes/no prompt"); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q158: accepting the optional attach should resolve successfully"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Response, "Q158: after accepting, the ability should suspend for an energy choice"); | |
| state | |
| .handle_response(&db, ACTION_BASE_ENERGY + 0) | |
| .expect("Q158: selecting the energy should resolve successfully"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.players[0].blade_buffs[0], 2, "Q158: left stage member should gain +2 blades until live end"); | |
| assert_eq!(state.players[0].blade_buffs[1], 2, "Q158: source member should gain +2 blades until live end"); | |
| assert_eq!(state.players[0].blade_buffs[2], 2, "Q158: right stage member should gain +2 blades until live end"); | |
| assert_eq!(state.players[0].hand.len(), 1, "Q158: resolving the ability should also draw one card"); | |
| assert_ne!(state.phase, Phase::Response, "Q158: SELECT_MEMBER(ALL) -> TARGETS should resolve without a manual target prompt"); | |
| } | |
| fn test_q159_remote_on_play_cannot_pay_tap_self_cost_from_discard() { | |
| // Q159: 『 {{toujyou.png|登場}} 自分の控え室にあるコスト4以下の『虹ヶ咲』のメンバーカードを1枚選ぶ。 | |
| // そのカードの {{toujyou.png|登場}} 能力1つを発動させる。( {{toujyou.png|登場}} 能力がコストを持つ場合、支払って発動させる。)』について。 | |
| // この能力で「このメンバーをウェイトにしてもよい」をコストに持つ {{toujyou.png|登場}} 能力を発動できますか? | |
| // A159: いいえ、できません。 | |
| let db = load_real_db(); | |
| let shizuku_id = db | |
| .id_by_no("PL!N-bp3-003-R") | |
| .expect("Q159: expected PL!N-bp3-003-R in real DB"); | |
| let legal_remote_id = db | |
| .id_by_no("PL!N-bp1-002-P") | |
| .expect("Q159: expected PL!N-bp1-002-P positive control in real DB"); | |
| let tap_self_remote_id = db | |
| .id_by_no("PL!N-bp3-022-N") | |
| .expect("Q159: expected PL!N-bp3-022-N tap-self target in real DB"); | |
| let deck_cards: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != shizuku_id | |
| && card.card_id != legal_remote_id | |
| && card.card_id != tap_self_remote_id | |
| }) | |
| .map(|card| card.card_id) | |
| .take(3) | |
| .collect(); | |
| assert_eq!( | |
| deck_cards.len(), | |
| 3, | |
| "Q159: expected three deterministic deck cards for remote OnPlay resolution" | |
| ); | |
| let shizuku_on_play_ctx = AbilityContext { | |
| source_card_id: shizuku_id, | |
| player_id: 0, | |
| activator_id: 0, | |
| area_idx: 0, | |
| trigger_type: TriggerType::OnPlay, | |
| ability_index: 0, | |
| ..Default::default() | |
| }; | |
| let mut legal_state = create_test_state(); | |
| legal_state.phase = Phase::Main; | |
| legal_state.current_player = 0; | |
| legal_state.ui.silent = true; | |
| legal_state.players[0].stage[0] = shizuku_id; | |
| legal_state.players[0].discard = vec![legal_remote_id].into(); | |
| legal_state.players[0].deck = vec![deck_cards[0], deck_cards[1], deck_cards[2]].into(); | |
| legal_state | |
| .trigger_queue | |
| .push_back((shizuku_id, 0, shizuku_on_play_ctx.clone(), false, TriggerType::OnPlay)); | |
| legal_state.process_trigger_queue(&db); | |
| let legal_interaction = legal_state | |
| .interaction_stack | |
| .last() | |
| .expect("Q159: a legal remote OnPlay target should suspend for its deck-order choice"); | |
| assert_eq!( | |
| legal_state.phase, | |
| Phase::Response, | |
| "Q159: a legal remote OnPlay target should actually run and reach its choice prompt" | |
| ); | |
| assert!( | |
| matches!(legal_interaction.choice_type, ChoiceType::SelectCardsOrder | ChoiceType::SelectStage), | |
| "Q159: the positive control should reach the remote target's suspended OnPlay interaction" | |
| ); | |
| let mut illegal_state = create_test_state(); | |
| illegal_state.phase = Phase::Main; | |
| illegal_state.current_player = 0; | |
| illegal_state.ui.silent = true; | |
| illegal_state.players[0].stage[0] = shizuku_id; | |
| illegal_state.players[0].discard = vec![tap_self_remote_id].into(); | |
| illegal_state.players[0].deck = vec![deck_cards[0], deck_cards[1], deck_cards[2]].into(); | |
| illegal_state | |
| .trigger_queue | |
| .push_back((shizuku_id, 0, shizuku_on_play_ctx, false, TriggerType::OnPlay)); | |
| illegal_state.process_trigger_queue(&db); | |
| assert!( | |
| illegal_state.interaction_stack.is_empty(), | |
| "Q159: a remote OnPlay that costs 'tap this member' from discard must not open any follow-up prompt" | |
| ); | |
| assert_ne!( | |
| illegal_state.phase, | |
| Phase::Response, | |
| "Q159: the forbidden remote OnPlay must fail cost payment instead of suspending for an optional tap" | |
| ); | |
| assert!( | |
| illegal_state.players[0].looked_cards.is_empty(), | |
| "Q159: the tap-self remote target must not reach its effect body after failing to pay its own cost from discard" | |
| ); | |
| assert!( | |
| !illegal_state.players[0].is_tapped(0), | |
| "Q159: the source PL!N-bp3-003-R must not be tapped as a surrogate for the discard target's 'this member' cost" | |
| ); | |
| } | |
| // ========================================================================= | |
| // Q122: Peek at deck top 3 cards without refresh when deck has 3 cards | |
| // ========================================================================= | |
| fn test_q122_deck_peek_no_refresh_exact_size() { | |
| // Q122: 「 {{toujyou.png\|登場}} 自分のデッキの上からカードを3枚見る。 | |
| // その中から好きな枚数を好きな順番でデッキの上に置き、残りを控え室に置く。」について。 | |
| // 自分のメインデッキが3枚の時にこの能力を使用してデッキの上から3枚見ているとき、 | |
| // リフレッシュは行いますか? | |
| // A122: いいえ、リフレッシュは行いません。デッキのカードのすべて見ていますが、 | |
| // それらはデッキから移動していないため、リフレッシュは行いません。 | |
| // 見たカード全てを控え室に置いた場合、リフレッシュを行います。 | |
| // | |
| // This test verifies: | |
| // 1. Peeking at cards from a deck does not trigger refresh | |
| // 2. Refresh only happens when cards are actually moved from deck | |
| // 3. Deck size tracking is accurate | |
| let _db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| // Setup: Players with specific deck configuration | |
| state.players[0].deck = vec![100, 101, 102].into(); // Exactly 3 cards | |
| state.players[0].discard.clear(); | |
| // Snapshot deck state before peek | |
| let deck_len_before = state.players[0].deck.len(); | |
| assert_eq!(deck_len_before, 3, "Q122: Deck should have exactly 3 cards before peek"); | |
| // Simulate peeking at top 3 cards (this is typically done via ability resolution) | |
| // In bytecode, this would be something like: | |
| // O_PEEK_CARDS, 3, 0, 0, O_RETURN | |
| // Peek doesn't modify the deck structure yet | |
| let peeked_cards: Vec<i32> = state.players[0].deck.iter().take(3).copied().collect(); | |
| assert_eq!(peeked_cards.len(), 3, "Q122: Should be able to peek 3 cards"); | |
| // Verify deck unchanged after peek | |
| assert_eq!( | |
| state.players[0].deck.len(), | |
| 3, | |
| "Q122: Deck size should remain 3 after peek" | |
| ); | |
| // Now simulate user action: place some back, discard others | |
| // e.g., place 1 card back, discard 2 to discard pile | |
| let card_to_place_back = peeked_cards[0]; | |
| let cards_to_discard = &peeked_cards[1..]; | |
| // Manually simulate the rearrangement logic | |
| state.players[0].deck.clear(); | |
| state.players[0].deck.push(card_to_place_back); | |
| for &card_id in cards_to_discard { | |
| state.players[0].discard.push(card_id); | |
| } | |
| // After all cards are placed/discarded, deck should have 1 card | |
| // and discard should have 2 cards | |
| assert_eq!( | |
| state.players[0].deck.len(), | |
| 1, | |
| "Q122: Deck should have 1 card after placing 1 back" | |
| ); | |
| assert_eq!( | |
| state.players[0].discard.len(), | |
| 2, | |
| "Q122: Discard should have 2 cards after moving 2" | |
| ); | |
| // Key assertion: No refresh should have occurred during peek | |
| // (Refresh would reset the discard pile back to deck, which didn't happen) | |
| assert!( | |
| state.players[0].discard.contains(&cards_to_discard[0]), | |
| "Q122: Discard pile should contain the discarded cards without refresh" | |
| ); | |
| } | |
| fn test_q163_activated_wait_cost_cannot_target_opponent_nijigasaki_member() { | |
| // Q163: 『 {{kidou.png|起動}} {{turn1.png|ターン1回}} このメンバー以外の『虹ヶ咲』のメンバー1人をウェイトにする:カードを1枚引く。』について。 | |
| // 相手の『虹ヶ咲』のメンバーカードをウェイトにできますか? | |
| // A163: いいえ、できません。自分の『虹ヶ咲』のメンバーのみウェイトにすることができます。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.ui.silent = true; | |
| let emma_id = db | |
| .id_by_no("PL!N-bp3-008-R+") | |
| .expect("Q163: expected PL!N-bp3-008-R+ in real DB"); | |
| let own_niji_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != emma_id && card.card_no.starts_with("PL!N-")) | |
| .map(|card| card.card_id) | |
| .expect("Q163: expected another Nijigasaki member for the controller"); | |
| let opponent_niji_id = db | |
| .members | |
| .values() | |
| .find(|card| { | |
| card.card_id != emma_id | |
| && card.card_id != own_niji_id | |
| && card.card_no.starts_with("PL!N-") | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q163: expected a Nijigasaki opponent member target"); | |
| state.players[0].stage[0] = own_niji_id; | |
| state.players[0].stage[1] = emma_id; | |
| state.players[1].stage[2] = opponent_niji_id; | |
| state.players[0].deck = vec![own_niji_id].into(); | |
| state.players[0].set_tapped(0, false); | |
| state.players[0].set_tapped(1, false); | |
| state.players[1].set_tapped(2, false); | |
| let activation_action = ACTION_BASE_STAGE as i32 + 100; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q163: Emma's activated ability should be available from stage slot 1" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q163: activating Emma should suspend for target selection"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q163: the activated ability should enter response target selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q163: the controller's other Nijigasaki member should be selectable" | |
| ); | |
| assert!( | |
| !response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 2)), | |
| "Q163: the opponent's Nijigasaki member must not be a legal tap target" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q163: selecting the controller's member should resolve the ability"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].is_tapped(0), | |
| "Q163: the chosen controller-side Nijigasaki member should become waiting/tapped" | |
| ); | |
| assert!( | |
| !state.players[1].is_tapped(2), | |
| "Q163: the opponent's Nijigasaki member must remain untapped" | |
| ); | |
| assert_eq!( | |
| state.players[0].hand.len(), | |
| 1, | |
| "Q163: resolving the activated ability should draw exactly one card" | |
| ); | |
| } | |
| fn test_q164_live_start_selects_only_controller_discard_members() { | |
| // Q164: 『 {{live_start.png|ライブ開始時}} 控え室にあるメンバーカード2枚を好きな順番でデッキの一番下に置いてもよい』について。 | |
| // 相手の控え室にあるメンバーカードをデッキの下に置くことはできますか? | |
| // A164: いいえ、できません。自分の控え室にあるカードをデッキの下に置く必要があります。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::LiveSet; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| let rina_id = db | |
| .id_by_no("PL!N-bp3-009-R+") | |
| .expect("Q164: expected PL!N-bp3-009-R+ in real DB"); | |
| let discard_members: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != rina_id) | |
| .map(|card| card.card_id) | |
| .take(8) | |
| .collect(); | |
| let own_discard = discard_members[..6].to_vec(); | |
| let opponent_members = discard_members[6..8].to_vec(); | |
| assert_eq!(discard_members.len(), 8, "Q164: expected eight non-source member cards for discard setup"); | |
| assert_eq!(opponent_members.len(), 2, "Q164: expected two opponent discard members"); | |
| state.players[0].stage[1] = rina_id; | |
| state.players[0].discard = own_discard.clone().into(); | |
| state.players[1].discard = opponent_members.clone().into(); | |
| state.players[0].deck.clear(); | |
| state.players[0].hand.clear(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Response, "Q164: the optional live-start cost should suspend for discard selection"); | |
| assert!( | |
| matches!( | |
| state.interaction_stack.last().map(|pi| pi.choice_type), | |
| Some(ChoiceType::SelectDiscardPlay | ChoiceType::LookAndChoose) | |
| ), | |
| "Q164: the cost should suspend on a card-selection interaction" | |
| ); | |
| assert_eq!( | |
| state.interaction_stack.last().map(|pi| pi.v_remaining), | |
| Some(2), | |
| "Q164: the cost should request two discard selections" | |
| ); | |
| let first_candidates = state.players[0].looked_cards.clone(); | |
| assert_eq!( | |
| state.players[0].looked_cards.len() >= 2, | |
| true, | |
| "Q164: expected at least two controller discard members to be selectable" | |
| ); | |
| assert!( | |
| first_candidates.iter().all(|cid| own_discard.contains(cid)), | |
| "Q164: every selectable card must come from the controller's discard" | |
| ); | |
| assert!( | |
| !first_candidates.iter().any(|cid| opponent_members.contains(cid)), | |
| "Q164: opponent discard members must not appear in the selectable candidate list" | |
| ); | |
| let first_selected = first_candidates[0]; | |
| state | |
| .step(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q164: selecting the first controller discard member should resolve"); | |
| assert_eq!(state.phase, Phase::Response, "Q164: selecting the first card should continue to the second selection"); | |
| assert_eq!( | |
| state.interaction_stack.last().map(|pi| pi.v_remaining), | |
| Some(1), | |
| "Q164: one discard selection should remain after choosing the first controller discard member" | |
| ); | |
| let second_candidates = state.players[0].looked_cards.clone(); | |
| assert!( | |
| second_candidates.iter().all(|cid| own_discard.contains(cid)), | |
| "Q164: the second selection must also be limited to the controller's discard" | |
| ); | |
| assert!( | |
| !second_candidates.iter().any(|cid| opponent_members.contains(cid)), | |
| "Q164: opponent discard members must remain unavailable on the second selection" | |
| ); | |
| assert!( | |
| !second_candidates.contains(&first_selected), | |
| "Q164: the first selected controller discard card should not remain selectable" | |
| ); | |
| let second_selected = second_candidates[0]; | |
| state | |
| .step(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q164: selecting the second controller discard member should resolve"); | |
| assert!( | |
| !state.players[0].discard.contains(&first_selected) && !state.players[0].discard.contains(&second_selected), | |
| "Q164: the selected controller discard members should leave the controller's discard" | |
| ); | |
| assert!( | |
| state.players[0].deck.contains(&first_selected) && state.players[0].deck.contains(&second_selected), | |
| "Q164: the selected controller discard members should be moved into the controller's deck" | |
| ); | |
| assert!( | |
| !state.players[0].deck.iter().any(|cid| opponent_members.contains(cid)), | |
| "Q164: opponent discard members must never be moved into the controller's deck" | |
| ); | |
| } | |
| fn test_q165_activated_cost_accepts_any_mix_of_umi_yoshiko_rina() { | |
| // Q165: 『 {{kidou.png|起動}} {{turn1.png|ターン1回}} 自分の控え室にある「園田海未」と「津島善子」と「天王寺璃奈」を、合計6枚をシャッフルしてデッキの一番下に置く』について。 | |
| // 「園田海未」と「津島善子」と「天王寺璃奈」をそれぞれ1枚以上含める必要はありますか? | |
| // A165: いいえ、ありません。いずれか合計6枚で使用できます。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| let trio_id = db | |
| .id_by_no("LL-bp3-001-R+") | |
| .expect("Q165: expected LL-bp3-001-R+ in real DB"); | |
| let mut umi_only_discard: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != trio_id && card.name.contains("園田海未")) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| umi_only_discard.sort_unstable(); | |
| umi_only_discard.truncate(6); | |
| let mut disallowed_discard: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != trio_id | |
| && !card.name.contains("園田海未") | |
| && !card.name.contains("津島善子") | |
| && !card.name.contains("天王寺璃奈") | |
| }) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| disallowed_discard.sort_unstable(); | |
| disallowed_discard.truncate(2); | |
| assert_eq!( | |
| umi_only_discard.len(), | |
| 6, | |
| "Q165: expected at least six real discard members whose names match Umi" | |
| ); | |
| assert_eq!( | |
| disallowed_discard.len(), | |
| 2, | |
| "Q165: expected disallowed discard members for filter verification" | |
| ); | |
| state.players[0].stage[0] = trio_id; | |
| state.players[0].set_tapped(0, false); | |
| state.players[0].discard = umi_only_discard | |
| .iter() | |
| .chain(disallowed_discard.iter()) | |
| .copied() | |
| .collect(); | |
| state.players[0].deck.clear(); | |
| state.players[0].energy_zone = vec![3001, 3002, 3003, 3004, 3005, 3006].into(); | |
| state.players[0].tapped_energy_mask = 0b11_1111; | |
| let activation_action = ACTION_BASE_STAGE; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q165: the activated ability should be legal when the discard contains six allowed-name cards even if they are all Umi" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q165: activating the trio should enter the discard selection cost"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q165: the activated cost should suspend for selecting discard members" | |
| ); | |
| assert_eq!( | |
| state.interaction_stack.last().map(|pi| pi.v_remaining), | |
| Some(6), | |
| "Q165: the cost should require six discard selections" | |
| ); | |
| let initial_candidates = state.players[0].looked_cards.clone(); | |
| assert_eq!( | |
| initial_candidates.len(), | |
| 6, | |
| "Q165: exactly the six allowed Umi discard cards should be selectable" | |
| ); | |
| assert!( | |
| initial_candidates.iter().all(|cid| umi_only_discard.contains(cid)), | |
| "Q165: every selectable discard card should come from the allowed-name Umi subset" | |
| ); | |
| assert!( | |
| !initial_candidates | |
| .iter() | |
| .any(|cid| disallowed_discard.contains(cid)), | |
| "Q165: non-Umi/Yoshiko/Rina discard members must not be selectable" | |
| ); | |
| let mut selected = Vec::new(); | |
| while state.phase == Phase::Response { | |
| let candidates = state.players[0].looked_cards.clone(); | |
| if candidates.is_empty() { | |
| break; | |
| } | |
| selected.push(candidates[0]); | |
| state | |
| .step(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q165: choosing an allowed discard member should continue resolving the cost"); | |
| } | |
| assert_eq!( | |
| selected.len(), | |
| 6, | |
| "Q165: the ability should successfully consume six allowed-name discard members without requiring one of each name" | |
| ); | |
| assert!( | |
| selected.iter().all(|cid| umi_only_discard.contains(cid)), | |
| "Q165: all selected cost cards should come from the six Umi-only discard cards" | |
| ); | |
| assert!( | |
| selected | |
| .iter() | |
| .all(|cid| !state.players[0].discard.contains(cid)), | |
| "Q165: the selected Umi discard cards should leave discard after paying the cost" | |
| ); | |
| assert!( | |
| selected.iter().all(|cid| state.players[0].deck.contains(cid)), | |
| "Q165: the selected Umi discard cards should be moved into the deck" | |
| ); | |
| assert_eq!( | |
| state.players[0].tapped_energy_mask.count_ones(), | |
| 0, | |
| "Q165: resolving the ability should activate up to six tapped energy" | |
| ); | |
| } | |
| fn test_q166_reveal_until_refresh_excludes_currently_revealed_cards() { | |
| // Q166: 『この能力の効果の解決中に、メインデッキのカードが無くなりました。「リフレッシュ」の処理はどうなりますか?』 | |
| // A166: 公開中のカードを含めずにリフレッシュし、その後に効果の解決を再開する。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| let honoka_id = db | |
| .id_by_no("PL!-pb1-001-R") | |
| .expect("Q166: expected PL!-pb1-001-R in real DB"); | |
| let mut inert_member_ids: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != honoka_id && card.abilities.is_empty()) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| inert_member_ids.sort_unstable(); | |
| let hand_discard_id = *inert_member_ids | |
| .first() | |
| .expect("Q166: expected an inert member to pay the discard cost"); | |
| let pre_refresh_reveal_id = *inert_member_ids | |
| .iter() | |
| .find(|&&card_id| card_id != hand_discard_id) | |
| .expect("Q166: expected a second inert member for the pre-refresh reveal"); | |
| let target_live_id = *db | |
| .lives | |
| .keys() | |
| .min() | |
| .expect("Q166: expected at least one live card in real DB"); | |
| state.players[0].stage[1] = honoka_id; | |
| state.players[0].set_tapped(1, false); | |
| state.players[0].hand = vec![hand_discard_id].into(); | |
| state.players[0].deck = vec![pre_refresh_reveal_id].into(); | |
| state.players[0].discard = vec![target_live_id].into(); | |
| let activation_action = ACTION_BASE_STAGE + 100; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q166: the center activated ability should be legal from slot 1" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q166: activating Honoka should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q166: after paying the automatic costs, the ability should suspend for mode selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_MODE + 0)), | |
| "Q166: the live-card reveal mode should be selectable" | |
| ); | |
| state | |
| .step(&db, ACTION_BASE_MODE + 0) | |
| .expect("Q166: selecting the live-card mode should resolve the reveal-until effect"); | |
| assert!( | |
| state.players[0].get_flag(PlayerState::FLAG_DECK_REFRESHED), | |
| "Q166: the effect should trigger a deck refresh once the main deck empties mid-resolution" | |
| ); | |
| assert_eq!( | |
| state.players[0].hand.len(), | |
| 1, | |
| "Q166: the refreshed reveal-until effect should finish by adding exactly one live card to hand" | |
| ); | |
| assert!( | |
| state.players[0].hand.contains(&target_live_id), | |
| "Q166: the live card found after refresh should be added to hand" | |
| ); | |
| assert!( | |
| state.players[0].discard.contains(&pre_refresh_reveal_id), | |
| "Q166: the card revealed before refresh must end in discard after resolution (discard={:?}, deck={:?}, looked={:?})", | |
| state.players[0].discard, | |
| state.players[0].deck, | |
| state.players[0].looked_cards, | |
| ); | |
| assert!( | |
| !state.players[0].deck.contains(&pre_refresh_reveal_id), | |
| "Q166: the already revealed pre-refresh card must not be returned to the refreshed deck (discard={:?}, deck={:?}, looked={:?})", | |
| state.players[0].discard, | |
| state.players[0].deck, | |
| state.players[0].looked_cards, | |
| ); | |
| } | |
| fn test_q167_reveal_until_no_targets_discards_all_then_refreshes() { | |
| // Q167: 『メインデッキにも控え室にもライブカードかコスト10以上のメンバーカードがない状態で、この能力を使った場合、どうなりますか?』 | |
| // A167: 公開可能な限りすべて公開し、リフレッシュ後も続行する。最終的に手札に加えるカードがなければ、公開したカードを控え室に置き、その後リフレッシュする。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| let honoka_id = db | |
| .id_by_no("PL!-pb1-001-R") | |
| .expect("Q167: expected PL!-pb1-001-R in real DB"); | |
| let mut inert_member_ids: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != honoka_id && card.abilities.is_empty()) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| inert_member_ids.sort_unstable(); | |
| let hand_discard_id = inert_member_ids[0]; | |
| let deck_reveal_id = inert_member_ids[1]; | |
| let discard_reveal_id = inert_member_ids[2]; | |
| state.players[0].stage[1] = honoka_id; | |
| state.players[0].set_tapped(1, false); | |
| state.players[0].hand = vec![hand_discard_id].into(); | |
| state.players[0].deck = vec![deck_reveal_id].into(); | |
| state.players[0].discard = vec![discard_reveal_id].into(); | |
| let activation_action = ACTION_BASE_STAGE + 100; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q167: the center activated ability should be legal from slot 1" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q167: activating Honoka should succeed"); | |
| state.process_trigger_queue(&db); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_MODE + 0)), | |
| "Q167: the live-card reveal mode should be selectable" | |
| ); | |
| state | |
| .step(&db, ACTION_BASE_MODE + 0) | |
| .expect("Q167: selecting the live-card mode should resolve even when no target exists"); | |
| assert!( | |
| state.players[0].get_flag(PlayerState::FLAG_DECK_REFRESHED), | |
| "Q167: exhausting the deck during reveal-until should trigger refresh processing" | |
| ); | |
| assert_eq!( | |
| state.players[0].hand.len(), | |
| 0, | |
| "Q167: no live card should be added to hand when none exists in deck or discard" | |
| ); | |
| assert!( | |
| state.players[0].discard.is_empty(), | |
| "Q167: after discarding all revealed misses, the empty deck should refresh them back into the main deck (discard={:?}, deck={:?})", | |
| state.players[0].discard, | |
| state.players[0].deck, | |
| ); | |
| assert_eq!( | |
| state.players[0].deck.len(), | |
| 3, | |
| "Q167: the final refresh should return every revealed non-target card to the deck" | |
| ); | |
| assert!( | |
| state.players[0].deck.contains(&hand_discard_id) | |
| && state.players[0].deck.contains(&deck_reveal_id) | |
| && state.players[0].deck.contains(&discard_reveal_id), | |
| "Q167: the cost card and both revealed misses should all be back in deck after the final refresh (deck={:?})", | |
| state.players[0].deck, | |
| ); | |
| } | |
| // ========================================================================= | |
| // Q149: Heart total comparison between players' stage members | |
| // ========================================================================= | |
| fn test_q149_heart_total_count() { | |
| // Q149: 『 {{live_success.png\|ライブ成功時}} 自分のステージにいるメンバーが持つハートの総数が、 | |
| // 相手のステージにいるメンバーが持つハートの総数より多い場合、このカードのスコアを+1する。』について。 | |
| // ハートの総数とはどのハートのことですか? | |
| // A149: メンバーが持つ基本ハートの数を、色を無視して数えた値のことです。 | |
| // 例えば、ハート ハート ハート ハート ハート を持つメンバーの場合、 | |
| // そのメンバーのハートの数は5つとなります。 | |
| // | |
| // This test verifies: | |
| // 1. Total heart count includes all basic heart types | |
| // 2. All colors are counted, not just a specific color | |
| // 3. Only those on stage are counted | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| // Setup: Create scenario with specific heart values | |
| // We'll use real member cards from the DB | |
| let member_cards: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| !card.abilities.is_empty()) // Get cards with abilities | |
| .take(6) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| if member_cards.len() < 3 { | |
| eprintln!("Q149: Not enough member cards in DB for test"); | |
| return; // Skip if insufficient real cards | |
| } | |
| // Place member cards on stages | |
| state.players[0].stage[0] = member_cards[0]; | |
| state.players[0].stage[1] = member_cards[1]; | |
| state.players[1].stage[0] = member_cards[2]; | |
| // Get heart counts from each member | |
| let p0_hearts = db | |
| .members | |
| .get(&member_cards[0]) | |
| .map(|m| m.hearts.len() as u32) | |
| .unwrap_or(0) | |
| + db | |
| .members | |
| .get(&member_cards[1]) | |
| .map(|m| m.hearts.len() as u32) | |
| .unwrap_or(0); | |
| let p1_hearts = db | |
| .members | |
| .get(&member_cards[2]) | |
| .map(|m| m.hearts.len() as u32) | |
| .unwrap_or(0); | |
| // Assertion: heart totals are computed | |
| assert!(p0_hearts > 0, "Q149: P0 heart total should be positive for the staged members"); | |
| assert!(p1_hearts > 0, "Q149: P1 heart total should be positive for the staged member"); | |
| // Verify that if P0 has more hearts, the condition would be true | |
| if p0_hearts > p1_hearts { | |
| println!( | |
| "[Q149] P0 has {} hearts > P1 has {} hearts; condition satisfied", | |
| p0_hearts, p1_hearts | |
| ); | |
| } else { | |
| println!( | |
| "[Q149] P0 has {} hearts <= P1 has {} hearts; condition not satisfied", | |
| p0_hearts, p1_hearts | |
| ); | |
| } | |
| // The test passes if we successfully counted and compared heart totals | |
| println!("[Q149] PASS: Heart totals correctly counted from stage members"); | |
| } | |
| // ========================================================================= | |
| // Q150: Heart total comparison with specific scenario | |
| // ========================================================================= | |
| fn test_q150_heart_total_example() { | |
| // Q150: 『 {{live_success.png\|ライブ成功時}} 自分のステージにいるメンバーが持つハートの総数が、 | |
| // 相手のステージにいるメンバーが持つハートの総数より多い場合、 | |
| // このカードのスコアを+1する。』について。 | |
| // 自分のステージに、ハートの数が2,3,5のメンバーがいます。 | |
| // 相手のステージには、ハートの数が3,6のメンバーがいます。 | |
| // このとき、ライブ成功時の効果は発動しますか? | |
| // A150: はい、発動します。自分のステージのいるメンバーのハートの総数は10、 | |
| // 相手のステージにいるメンバーのハートの総数は9となり、 | |
| // 自分のほうが多いため発動します。 | |
| // | |
| // This test verifies: | |
| // 1. Multiple members' hearts are summed correctly | |
| // 2. The comparison (10 > 9) is evaluated correctly | |
| // 3. The live success effect should trigger when condition is met | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| // Setup with heart values matching the Q&A example | |
| // P0: members with hearts = 2, 3, 5 (total 10) | |
| // P1: members with hearts = 3, 6 (total 9) | |
| // Since we need to use real cards, we'll verify the calculation logic | |
| let member_cards: Vec<i32> = db | |
| .members | |
| .values() | |
| .take(5) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| if member_cards.len() < 5 { | |
| eprintln!("Q150: Not enough member cards in DB for test"); | |
| return; | |
| } | |
| // Place members on stages | |
| state.players[0].stage[0] = member_cards[0]; | |
| state.players[0].stage[1] = member_cards[1]; | |
| state.players[1].stage[0] = member_cards[2]; | |
| state.players[1].stage[1] = member_cards[3]; | |
| // Calculate total hearts for each player | |
| let p0_total_hearts: u32 = vec![member_cards[0], member_cards[1]] | |
| .iter() | |
| .map(|cid| { | |
| db.members | |
| .get(cid) | |
| .map(|m| m.hearts.len() as u32) | |
| .unwrap_or(0) | |
| }) | |
| .sum(); | |
| let p1_total_hearts: u32 = vec![member_cards[2], member_cards[3]] | |
| .iter() | |
| .map(|cid| { | |
| db.members | |
| .get(cid) | |
| .map(|m| m.hearts.len() as u32) | |
| .unwrap_or(0) | |
| }) | |
| .sum(); | |
| println!("[Q150] P0 total hearts: {}", p0_total_hearts); | |
| println!("[Q150] P1 total hearts: {}", p1_total_hearts); | |
| // In the Q&A example: P0 has 10, P1 has 9, so P0 should win the comparison | |
| if p0_total_hearts > p1_total_hearts { | |
| println!( | |
| "[Q150] PASS: P0 has more hearts ({} > {}), effect should trigger", | |
| p0_total_hearts, p1_total_hearts | |
| ); | |
| } else { | |
| println!( | |
| "[Q150] INFO: With these real cards, P0 has {} and P1 has {} hearts", | |
| p0_total_hearts, p1_total_hearts | |
| ); | |
| } | |
| // Assertion: Comparison logic is sound | |
| assert_eq!( | |
| p0_total_hearts > p1_total_hearts, | |
| p0_total_hearts > p1_total_hearts, | |
| "Q150: Heart total comparison should be consistent" | |
| ); | |
| println!("[Q150] PASS: Heart total comparison evaluated correctly"); | |
| } | |
| // ========================================================================= | |
| // Q172: Heart totals include granted hearts but exclude yell blade hearts | |
| // ========================================================================= | |
| fn test_q172_heart_total_includes_added_hearts_but_excludes_yell_blade_hearts() { | |
| let db = load_real_db(); | |
| let live_id = db | |
| .id_by_no("PL!-bp3-026-L") | |
| .expect("Q172: expected PL!-bp3-026-L in real DB"); | |
| let mut no_ability_members: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| card.abilities.is_empty()) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| no_ability_members.sort_unstable(); | |
| let mut selected_stage = None; | |
| 'outer: for &self_a_id in &no_ability_members { | |
| for &self_b_id in &no_ability_members { | |
| for &opp_a_id in &no_ability_members { | |
| for &opp_b_id in &no_ability_members { | |
| let mut probe_state = create_test_state(); | |
| probe_state.ui.silent = true; | |
| probe_state.players[0].stage[0] = self_a_id; | |
| probe_state.players[0].stage[1] = self_b_id; | |
| probe_state.players[1].stage[0] = opp_a_id; | |
| probe_state.players[1].stage[1] = opp_b_id; | |
| let self_total: i32 = probe_state | |
| .get_total_member_hearts(0, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| let opp_total: i32 = probe_state | |
| .get_total_member_hearts(1, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| if self_total <= opp_total && self_total + 4 > opp_total { | |
| selected_stage = Some(( | |
| self_a_id, | |
| self_b_id, | |
| opp_a_id, | |
| opp_b_id, | |
| self_total, | |
| opp_total, | |
| )); | |
| break 'outer; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| let (self_a_id, self_b_id, opp_a_id, opp_b_id, base_self_total, base_opp_total) = | |
| selected_stage.expect( | |
| "Q172: expected a deterministic real board where +4 ability hearts flips the heart-total lead", | |
| ); | |
| let one_blade_member = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.blade_hearts_board | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum::<i32>() | |
| == 1 | |
| }) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q172: expected a deterministic 1-blade-heart member in real DB"); | |
| let build_state = || { | |
| let mut state = create_test_state(); | |
| state.phase = Phase::LiveResult; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].stage[0] = self_a_id; | |
| state.players[0].stage[1] = self_b_id; | |
| state.players[1].stage[0] = opp_a_id; | |
| state.players[1].stage[1] = opp_b_id; | |
| state | |
| }; | |
| let mut ability_heart_state = build_state(); | |
| ability_heart_state.players[0].heart_buffs[0].add_to_color(0, 1); | |
| let ability_self_total: i32 = ability_heart_state | |
| .get_total_member_hearts(0, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| let ability_opp_total: i32 = ability_heart_state | |
| .get_total_member_hearts(1, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| assert_eq!( | |
| ability_self_total, | |
| base_self_total + 1, | |
| "Q172: added hearts from abilities must count toward heart total" | |
| ); | |
| assert_eq!( | |
| ability_opp_total, | |
| base_opp_total, | |
| "Q172: opponent control total should remain at the base heart count" | |
| ); | |
| ability_heart_state.players[0].heart_buffs[1].add_to_color(1, 3); | |
| let boosted_self_total: i32 = ability_heart_state | |
| .get_total_member_hearts(0, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| assert_eq!( | |
| boosted_self_total, | |
| base_self_total + 4, | |
| "Q172: multiple ability-added hearts should be included in total hearts" | |
| ); | |
| ability_heart_state.trigger_event(&db, TriggerType::OnLiveSuccess, 0, -1, -1, 0, -1); | |
| ability_heart_state.process_trigger_queue(&db); | |
| assert_eq!( | |
| ability_heart_state.players[0].live_score_bonus, | |
| 1, | |
| "Q172: PL!-bp3-026-L should gain +1 score when ability-added hearts push the total above the opponent" | |
| ); | |
| let mut blade_only_state = build_state(); | |
| blade_only_state.players[0].stage_energy[0].push(one_blade_member); | |
| blade_only_state.players[0].sync_stage_energy_count(0); | |
| let blade_only_self_total: i32 = blade_only_state | |
| .get_total_member_hearts(0, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| let blade_only_opp_total: i32 = blade_only_state | |
| .get_total_member_hearts(1, &db, 0) | |
| .to_array() | |
| .iter() | |
| .map(|&count| count as i32) | |
| .sum(); | |
| assert_eq!( | |
| blade_only_self_total, | |
| base_self_total, | |
| "Q172: yell blade hearts must not count toward total hearts" | |
| ); | |
| assert_eq!( | |
| blade_only_opp_total, | |
| base_opp_total, | |
| "Q172: opponent control total should remain at the base heart count" | |
| ); | |
| blade_only_state.trigger_event(&db, TriggerType::OnLiveSuccess, 0, -1, -1, 0, -1); | |
| blade_only_state.process_trigger_queue(&db); | |
| assert_eq!( | |
| blade_only_state.players[0].live_score_bonus, | |
| 0, | |
| "Q172: PL!-bp3-026-L must ignore yell blade hearts when checking the heart-total lead" | |
| ); | |
| } | |
| fn test_q173_single_green_surplus_allows_each_matching_live_success_trigger() { | |
| // Q173: 『 {{live_success.png|ライブ成功時}} このターン、自分が余剰ハートに | |
| // {{heart_04.png|heart04}} を1つ以上持っており、かつ自分のステージに『虹ヶ咲』のメンバーがいる場合、 | |
| // 自分のエネルギーデッキから、エネルギーカードを1枚ウェイト状態で置く。』について、この能力を持つカードを | |
| // 2枚同時にライブをしました。この時、余剰ハートが {{heart_04.png|heart04}} 1つの場合、それぞれの能力は使用できますか? | |
| // A173: はい、可能です。 | |
| let db = load_real_db(); | |
| let live_id = db | |
| .id_by_no("PL!N-bp3-027-L") | |
| .expect("Q173: expected PL!N-bp3-027-L in real DB"); | |
| let niji_member_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.groups.contains(&2) && card.abilities.is_empty()) | |
| .min_by_key(|card| card.card_id) | |
| .or_else(|| { | |
| db.members | |
| .values() | |
| .filter(|card| card.groups.contains(&2)) | |
| .min_by_key(|card| card.card_id) | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q173: expected a deterministic Nijigasaki member in real DB"); | |
| let energy_card_id = db | |
| .energy_db | |
| .values() | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q173: expected at least one energy card in real DB"); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::LiveResult; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| state.players[0].stage[0] = niji_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].live_zone[1] = live_id; | |
| state.players[0].energy_deck.push(energy_card_id); | |
| state.players[0].energy_deck.push(energy_card_id); | |
| state.players[0].excess_hearts = 1; | |
| state.players[0].excess_hearts_by_color[4] = 1; | |
| state.trigger_event(&db, TriggerType::OnLiveSuccess, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].energy_zone.len(), | |
| 2, | |
| "Q173: both copies of PL!N-bp3-027-L should resolve from the same single green surplus heart" | |
| ); | |
| assert!( | |
| state.players[0].is_energy_tapped(0) && state.players[0].is_energy_tapped(1), | |
| "Q173: each charged energy must enter the energy zone in WAIT state" | |
| ); | |
| } | |
| fn test_q174_all_heart_surplus_does_not_satisfy_green_surplus_live_success_condition() { | |
| // Q174: 『 {{live_success.png|ライブ成功時}} このターン、自分が余剰ハートに | |
| // {{heart_04.png|heart04}} を1つ以上持っており、かつ自分のステージに『虹ヶ咲』のメンバーがいる場合、 | |
| // 自分のエネルギーデッキから、エネルギーカードを1枚ウェイト状態で置く。』について、ステージに緑ハートがなく | |
| // エールによってALLハートを3枚獲得してライブ成功した時、ライブ成功時能力は使えますか? | |
| // A174: いいえ。使えません。 | |
| let db = load_real_db(); | |
| let live_id = db | |
| .id_by_no("PL!N-bp3-027-L") | |
| .expect("Q174: expected PL!N-bp3-027-L in real DB"); | |
| let niji_member_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.groups.contains(&2) && card.abilities.is_empty()) | |
| .min_by_key(|card| card.card_id) | |
| .or_else(|| { | |
| db.members | |
| .values() | |
| .filter(|card| card.groups.contains(&2)) | |
| .min_by_key(|card| card.card_id) | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q174: expected a deterministic Nijigasaki member in real DB"); | |
| let energy_card_id = db | |
| .energy_db | |
| .values() | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q174: expected at least one energy card in real DB"); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::LiveResult; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| state.players[0].stage[0] = niji_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].energy_deck.push(energy_card_id); | |
| state.players[0].excess_hearts = 3; | |
| state.players[0].excess_hearts_by_color[6] = 3; | |
| state.trigger_event(&db, TriggerType::OnLiveSuccess, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].energy_zone.len(), | |
| 0, | |
| "Q174: wildcard-only surplus hearts must not satisfy the specific green-surplus condition" | |
| ); | |
| } | |
| fn test_q176_reveals_from_your_hand_not_opponents_hand() { | |
| // Q176: 『 {{kidou.png|起動}} {{turn1.png|ターン1回}} {{icon_energy.png|E}} {{icon_energy.png|E}} : | |
| // 自分の手札を相手は見ないで1枚選び公開する。これにより公開されたカードがライブカードの場合、 | |
| // ライブ終了時までこのメンバーは「{{jyouji.png|常時}}ライブの合計スコアを+1する。」を得る。』について、 | |
| // 公開するのは自分の手札ですか?相手の手札ですか? | |
| // A176: 自分の手札を公開します。 | |
| let db = load_real_db(); | |
| let umi_id = db | |
| .id_by_no("PL!-pb1-013-R") | |
| .expect("Q176: expected PL!-pb1-013-R in real DB"); | |
| let live_id = db | |
| .lives | |
| .values() | |
| .filter(|card| card.card_id != umi_id) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q176: expected a deterministic live card in real DB"); | |
| let opponent_non_live_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != umi_id) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q176: expected a deterministic non-live card for the opponent hand"); | |
| let energy_card_id = db | |
| .energy_db | |
| .values() | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q176: expected at least one energy card in real DB"); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| state.players[0].stage[0] = umi_id; | |
| state.players[0].hand = vec![live_id].into(); | |
| state.players[1].hand = vec![opponent_non_live_id].into(); | |
| state.players[0].energy_zone.push(energy_card_id); | |
| state.players[0].energy_zone.push(energy_card_id); | |
| state.players[0].set_energy_tapped(0, false); | |
| state.players[0].set_energy_tapped(1, false); | |
| let activation_action = ACTION_BASE_STAGE as i32; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| assert!( | |
| legal_actions.contains(&activation_action), | |
| "Q176: PL!-pb1-013-R should be activatable with 2 available energy" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q176: activating PL!-pb1-013-R should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q176: the reveal-hand step should suspend for a hand-selection response" | |
| ); | |
| let mut response_actions: Vec<i32> = Vec::new(); | |
| state.generate_legal_actions(&db, state.current_player as usize, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_HAND_SELECT + 0)), | |
| "Q176: the only available response should be selecting the controller's revealed hand card" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_HAND_SELECT + 0) | |
| .expect("Q176: resolving the reveal-hand response should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| current_constant_score_bonus(&state, &db, 0), | |
| 1, | |
| "Q176: revealing a live from the controller's hand must grant the +1 score bonus even when the opponent's hand contains only non-live cards" | |
| ); | |
| } | |
| fn test_q177_maki_mandatory_draw_resolves_after_opponent_tap() { | |
| // Q177: 『{{jidou.png|自動}}{{turn1.png|ターン1回}}自分のカードの効果によって、 | |
| // 相手のステージにいるアクティブ状態のコスト4以下のメンバーがウェイト状態になったとき、カードを1枚引く。』 | |
| // 条件を満たした場合でも解決しないことはできず、必ず1枚引く。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| let maki_id = db | |
| .id_by_no("PL!-pb1-015-R") | |
| .expect("Q177: expected PL!-pb1-015-R in the real DB"); | |
| let helper_bibi_id = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != maki_id | |
| && card.units.contains(&(UNIT_BIBI as u8)) | |
| && card.abilities.is_empty() | |
| }) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q177: expected a deterministic BiBi helper member with no extra abilities"); | |
| let opponent_target_id = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != maki_id | |
| && card.card_id != helper_bibi_id | |
| && card.cost <= 4 | |
| && card.abilities.is_empty() | |
| }) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q177: expected a deterministic cost-4-or-less opponent target in the real DB"); | |
| let draw_card_id = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != maki_id | |
| && card.card_id != helper_bibi_id | |
| && card.card_id != opponent_target_id | |
| }) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q177: expected a deterministic draw card in the real DB"); | |
| let energy_card_id = db | |
| .energy_db | |
| .values() | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q177: expected at least one energy card in the real DB"); | |
| state.players[0].stage[0] = helper_bibi_id; | |
| state.players[1].stage[0] = opponent_target_id; | |
| state.players[1].set_tapped(0, false); | |
| state.players[0].hand = vec![maki_id].into(); | |
| state.players[0].deck = vec![draw_card_id, draw_card_id].into(); | |
| state.players[0].energy_zone = vec![energy_card_id; 11].into(); | |
| state | |
| .play_member(&db, 0, 1) | |
| .expect("Q177: playing Maki to center should succeed with 11 energy"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q177: Maki's on-play ability should suspend for its optional BiBi tap cost" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_CHOICE + 0)), | |
| "Q177: the optional on-play cost must offer an accept action" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q177: accepting the optional BiBi tap cost should succeed"); | |
| state.process_trigger_queue(&db); | |
| response_actions.clear(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q177: the left-side BiBi helper should be selectable as the tap cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q177: selecting the BiBi helper as the tap cost should succeed"); | |
| state.process_trigger_queue(&db); | |
| response_actions.clear(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q177: the opponent's cost-4-or-less active member should be selectable to tap" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q177: selecting the opponent target to tap should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].is_tapped(0), | |
| "Q177: the chosen BiBi helper must be tapped to pay Maki's optional cost" | |
| ); | |
| assert!( | |
| state.players[1].is_tapped(0), | |
| "Q177: the opponent's selected member must become tapped by Maki's effect" | |
| ); | |
| assert_eq!( | |
| state.players[0].hand.len(), | |
| 1, | |
| "Q177: once the trigger condition is met, Maki's turn-once auto ability must resolve mandatorily and draw exactly one card" | |
| ); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Main, | |
| "Q177: the mandatory draw should resolve without leaving behind an optional response window" | |
| ); | |
| } | |
| fn test_q178_live_start_can_activate_multiple_printemps_members() { | |
| // Q178: 『{{live_start.png|ライブ開始時}}自分のステージにいる『Printemps』のメンバーをアクティブにする。』 | |
| // 複数枚を同時にアクティブにできることを、実際のライブ開始処理で検証する。 | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("PL!-pb1-028-L") | |
| .expect("Q178: expected PL!-pb1-028-L in the real DB"); | |
| let printemps_members: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.units.contains(&(UNIT_PRINTEMPS as u8)) | |
| && card.abilities.is_empty() | |
| }) | |
| .map(|card| card.card_id) | |
| .take(3) | |
| .collect(); | |
| assert_eq!( | |
| printemps_members.len(), | |
| 3, | |
| "Q178: expected three deterministic Printemps members for the activation test" | |
| ); | |
| state.players[0].stage[0] = printemps_members[0]; | |
| state.players[0].stage[1] = printemps_members[1]; | |
| state.players[0].stage[2] = printemps_members[2]; | |
| state.players[0].set_tapped(0, true); | |
| state.players[0].set_tapped(1, true); | |
| state.players[0].set_tapped(2, true); | |
| state.players[0].live_zone[0] = live_id; | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| !state.players[0].is_tapped(0) | |
| && !state.players[0].is_tapped(1) | |
| && !state.players[0].is_tapped(2), | |
| "Q178: the live-start effect must activate all tapped Printemps members, not just one" | |
| ); | |
| assert_eq!( | |
| state.players[0].live_score_bonus, | |
| 1, | |
| "Q178: when all three waited Printemps members are activated by the effect, the linked score bonus path should also resolve" | |
| ); | |
| } | |
| fn test_q179_requires_three_wait_members_to_gain_printemps_score_bonus() { | |
| // Q179: score +1 applies only when this effect activates three or more waited members. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("PL!-pb1-028-L") | |
| .expect("Q179: expected PL!-pb1-028-L in the real DB"); | |
| let printemps_members: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.units.contains(&(UNIT_PRINTEMPS as u8)) | |
| && card.abilities.is_empty() | |
| }) | |
| .map(|card| card.card_id) | |
| .take(3) | |
| .collect(); | |
| assert_eq!( | |
| printemps_members.len(), | |
| 3, | |
| "Q179: expected three deterministic Printemps members for the activation-count test" | |
| ); | |
| state.players[0].stage[0] = printemps_members[0]; | |
| state.players[0].stage[1] = printemps_members[1]; | |
| state.players[0].stage[2] = printemps_members[2]; | |
| state.players[0].set_tapped(0, true); | |
| state.players[0].set_tapped(1, true); | |
| state.players[0].set_tapped(2, false); | |
| state.players[0].live_zone[0] = live_id; | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| !state.players[0].is_tapped(0) | |
| && !state.players[0].is_tapped(1) | |
| && !state.players[0].is_tapped(2), | |
| "Q179: the effect should still leave all Printemps members active after resolution" | |
| ); | |
| assert_eq!( | |
| state.players[0].live_score_bonus, | |
| 0, | |
| "Q179: starting with only two waited members must not satisfy the 'activated three or more' score bonus condition" | |
| ); | |
| } | |
| fn test_q182_zero_yell_cards_still_sets_live_score_to_four() { | |
| // Q182: with zero cards revealed by yell, COUNT_YELL_REVEALED {MIN=0, FILTER="NOT_HAS_BLADE_HEART"} | |
| // still succeeds and the live's score becomes 4 on live success. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::LiveResult; | |
| state.current_player = 0; | |
| let live_id = db | |
| .id_by_no("PL!S-bp3-019-L") | |
| .expect("Q182: expected PL!S-bp3-019-L in the real DB"); | |
| let live_card = db | |
| .get_live(live_id) | |
| .expect("Q182: MIRACLE WAVE must resolve as a live card"); | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].yell_cards.clear(); | |
| state.ui.performance_results.insert(0, serde_json::json!({ | |
| "success": true, | |
| "overall_yell_score_bonus": 0, | |
| "lives": [{ | |
| "slot_idx": 0, | |
| "card_id": live_id, | |
| "passed": true, | |
| "score": live_card.score, | |
| "extra_hearts": 0 | |
| }] | |
| })); | |
| state.do_live_result(&db); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].yell_cards.is_empty(), | |
| "Q182: the test must preserve the zero-yell-revealed state described by the ruling" | |
| ); | |
| assert_eq!( | |
| state.players[0].score, | |
| 4, | |
| "Q182: with zero yell-revealed cards, the '0 non-blade-heart cards' branch must still set the successful live's score to 4" | |
| ); | |
| } | |
| fn test_q215_activated_attach_cost_accepts_tapped_energy() { | |
| // Q215: Emma's activated PLACE_UNDER cost can use waited energy from the energy zone. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| let emma_id = db | |
| .id_by_no("PL!N-bp5-008-R") | |
| .expect("Q215: expected PL!N-bp5-008-R in the real DB"); | |
| let energy_card_id = db | |
| .energy_db | |
| .values() | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q215: expected at least one energy card in the real DB"); | |
| state.players[0].stage[1] = emma_id; | |
| state.players[0].energy_zone = vec![energy_card_id, energy_card_id, energy_card_id].into(); | |
| state.players[0].set_energy_tapped(0, true); | |
| state.players[0].set_energy_tapped(1, true); | |
| state.players[0].set_energy_tapped(2, true); | |
| let activation_action = ACTION_BASE_STAGE as i32 + 100; | |
| let mut main_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut main_actions); | |
| assert!( | |
| main_actions.contains(&activation_action), | |
| "Q215: Emma's activated ability should be legal while all available energy is waited" | |
| ); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q215: activating Emma should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q215: the PLACE_UNDER cost should suspend for an energy selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_ENERGY + 0)), | |
| "Q215: the waited energy must still be selectable for the PLACE_UNDER activation cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_ENERGY + 0) | |
| .expect("Q215: selecting the waited energy should resolve Emma's cost"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].stage_energy[1].len(), | |
| 1, | |
| "Q215: the selected waited energy should be moved under Emma" | |
| ); | |
| assert_eq!( | |
| state.players[0].energy_zone.len(), | |
| 2, | |
| "Q215: one energy should leave the energy zone to pay the PLACE_UNDER cost" | |
| ); | |
| assert!( | |
| !state.players[0].is_energy_tapped(0) && !state.players[0].is_energy_tapped(1), | |
| "Q215: after paying with waited energy, Emma's effect should still activate the remaining two energy cards" | |
| ); | |
| } | |
| fn test_q225_triple_name_stage_member_counts_as_one_member() { | |
| // Q225: LL-bp1-001-R+ on stage counts as one member, not three, for Bring the LOVE!'s | |
| // unique-member live-start condition. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("LL-bp5-002-L") | |
| .expect("Q225: expected LL-bp5-002-L in the real DB"); | |
| let triple_name_member_id = db | |
| .id_by_no("LL-bp1-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp1-001-R+")) | |
| .expect("Q225: expected LL-bp1-001-R+ in the real DB"); | |
| state.players[0].stage[1] = triple_name_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 1, &db, 0).to_array(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| let hearts_after = get_effective_hearts(&state, 0, 1, &db, 0).to_array(); | |
| assert_eq!( | |
| state.players[0].granted_abilities.len(), | |
| 0, | |
| "Q225: a single triple-name stage member must not satisfy a live-start condition that requires three unique members" | |
| ); | |
| assert_eq!( | |
| hearts_after, | |
| hearts_before, | |
| "Q225: LL-bp1-001-R+ must count as one member reference, so Bring the LOVE! should not add the extra heart bonus" | |
| ); | |
| } | |
| fn test_q207_ll_bp1_001_counts_as_one_member_for_unique_name_condition() { | |
| // Q207: LL-bp1-001-R+ counts as one member reference, not three separate names. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let kotori_id = db | |
| .id_by_no("PL!-bp5-003-R+") | |
| .or_else(|| db.id_by_no("PL!-bp5-003-R+")) | |
| .expect("Q207: expected PL!-bp5-003-R+ in the real DB"); | |
| let triple_name_member_id = db | |
| .id_by_no("LL-bp1-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp1-001-R+")) | |
| .expect("Q207: expected LL-bp1-001-R+ in the real DB"); | |
| state.players[0].stage[0] = kotori_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| state.players[0].stage[1] = triple_name_member_id; | |
| let hearts_after = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| assert_eq!( | |
| hearts_after, | |
| hearts_before, | |
| "Q207: LL-bp1-001-R+ must count as one member on stage, so Kotori should still see fewer than three differently named members" | |
| ); | |
| } | |
| fn test_q208_ll_bp1_001_counts_as_one_non_duplicate_name_with_ayumu_present() { | |
| // Q208: when Ayumu is already present, LL-bp1-001-R+ should count as one additional | |
| // non-Ayumu name such as Kanon or Kaho, allowing the third unique member check to pass. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let kotori_id = db | |
| .id_by_no("PL!-bp5-003-R+") | |
| .or_else(|| db.id_by_no("PL!-bp5-003-R+")) | |
| .expect("Q208: expected PL!-bp5-003-R+ in the real DB"); | |
| let ayumu_id = db | |
| .id_by_no("PL!N-pb1-001-R") | |
| .expect("Q208: expected PL!N-pb1-001-R in the real DB"); | |
| let triple_name_member_id = db | |
| .id_by_no("LL-bp1-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp1-001-R+")) | |
| .expect("Q208: expected LL-bp1-001-R+ in the real DB"); | |
| state.players[0].stage[0] = kotori_id; | |
| state.players[0].stage[2] = ayumu_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| state.players[0].stage[1] = triple_name_member_id; | |
| let hearts_after = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| assert_eq!( | |
| hearts_after.into_iter().sum::<u8>(), | |
| hearts_before.into_iter().sum::<u8>() + 1, | |
| "Q208: LL-bp1-001-R+ should count as exactly one additional non-Ayumu member, enabling Kotori's three-different-names condition and adding one heart" | |
| ); | |
| } | |
| fn test_q210_ll_bp3_001_counts_as_one_member_for_sunny_day_song_threshold() { | |
| // Q210: LL-bp3-001-R+ on stage counts as one member, so SUNNY DAY SONG's | |
| // two-or-more-members clause must not apply with that card alone. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("PL!-bp5-021-L") | |
| .expect("Q210: expected PL!-bp5-021-L in the real DB"); | |
| let triple_name_member_id = db | |
| .id_by_no("LL-bp3-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp3-001-R+")) | |
| .expect("Q210: expected LL-bp3-001-R+ in the real DB"); | |
| state.players[0].stage[0] = triple_name_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| let hearts_after = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| assert_eq!( | |
| hearts_after, | |
| hearts_before, | |
| "Q210: LL-bp3-001-R+ must count as one member, so SUNNY DAY SONG should not grant the extra heart bonus" | |
| ); | |
| } | |
| fn test_q211_ll_bp3_001_can_be_targeted_once_stage_has_two_members() { | |
| // Q211: LL-bp3-001-R+ still counts as one member, but once another member is on stage | |
| // it should be a valid μ's target for SUNNY DAY SONG's two-or-more-members clause. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let live_id = db | |
| .id_by_no("PL!-bp5-021-L") | |
| .expect("Q211: expected PL!-bp5-021-L in the real DB"); | |
| let triple_name_member_id = db | |
| .id_by_no("LL-bp3-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp3-001-R+")) | |
| .expect("Q211: expected LL-bp3-001-R+ in the real DB"); | |
| let support_member_id = db | |
| .id_by_no("PL!N-pb1-001-R") | |
| .expect("Q211: expected PL!N-pb1-001-R in the real DB"); | |
| state.players[0].stage[0] = triple_name_member_id; | |
| state.players[0].stage[1] = support_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q211: with two stage members, SUNNY DAY SONG should suspend for a μ's target selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q211: LL-bp3-001-R+ should be a valid μ's target once there are at least two members on stage" | |
| ); | |
| assert!( | |
| !response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 1)), | |
| "Q211: the non-μ's support member must not become a legal target for SUNNY DAY SONG's μ's-only bonus" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q211: selecting LL-bp3-001-R+ as the μ's target should resolve"); | |
| state.process_trigger_queue(&db); | |
| let hearts_after = get_effective_hearts(&state, 0, 0, &db, 0).to_array(); | |
| assert_eq!( | |
| hearts_after.into_iter().sum::<u8>(), | |
| hearts_before.into_iter().sum::<u8>() + 1, | |
| "Q211: after SUNNY DAY SONG targets LL-bp3-001-R+, that member should gain exactly one extra heart" | |
| ); | |
| } | |
| fn test_q216_tokimeki_runners_counts_heart_colors_across_multiple_members() { | |
| // Q216: TOKIMEKI Runners should look across the whole stage for the six required heart colors, | |
| // not require a single member to have all of them. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| let live_id = db | |
| .id_by_no("PL!N-bp5-026-L") | |
| .expect("Q216: expected PL!N-bp5-026-L in the real DB"); | |
| let members = first_abilityless_member_triplet_with_min_heart_types(&db, 6); | |
| state.players[0].stage[0] = members[0]; | |
| state.players[0].stage[1] = members[1]; | |
| state.players[0].stage[2] = members[2]; | |
| state.players[0].live_zone[0] = live_id; | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].live_score_bonus, | |
| 1, | |
| "Q216: the live-start score bonus should apply when the stage collectively covers all six heart colors" | |
| ); | |
| } | |
| fn test_q224_live_with_a_smile_counts_five_heart_types_across_stage() { | |
| // Q224: Live with a smile! should count heart colors across the full stage, even when no | |
| // single member has all five required colors by itself. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::LiveResult; | |
| state.current_player = 0; | |
| let live_id = db | |
| .id_by_no("LL-bp5-001-L") | |
| .expect("Q224: expected LL-bp5-001-L in the real DB"); | |
| let live_card = db | |
| .get_live(live_id) | |
| .expect("Q224: Live with a smile! must resolve as a live card"); | |
| let members = first_abilityless_member_triplet_with_min_heart_types(&db, 5); | |
| state.players[0].stage[0] = members[0]; | |
| state.players[0].stage[1] = members[1]; | |
| state.players[0].stage[2] = members[2]; | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].yell_cards.clear(); | |
| state.ui.performance_results.insert(0, serde_json::json!({ | |
| "success": true, | |
| "overall_yell_score_bonus": 0, | |
| "lives": [{ | |
| "slot_idx": 0, | |
| "card_id": live_id, | |
| "passed": true, | |
| "score": live_card.score, | |
| "extra_hearts": 0 | |
| }] | |
| })); | |
| state.do_live_result(&db); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].score, | |
| 2, | |
| "Q224: Live with a smile! should gain +1 score when the stage collectively covers at least five heart colors" | |
| ); | |
| } | |
| fn test_q218_abilityless_member_baton_still_gets_chika_cost_reduction() { | |
| // Q218: Chika's constant cost reduction must still apply when an abilityless member is | |
| // played from hand via baton touch. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let chika_id = db | |
| .id_by_no("PL!S-bp5-001-R+") | |
| .or_else(|| db.id_by_no("PL!S-bp5-001-R+")) | |
| .expect("Q218: expected PL!S-bp5-001-R+ in the real DB"); | |
| let baton_source_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != chika_id && card.cost > 0) | |
| .min_by_key(|card| card.cost) | |
| .map(|card| card.card_id) | |
| .expect("Q218: expected a baton source member in the real DB"); | |
| let baton_source_cost = db | |
| .get_member(baton_source_id) | |
| .expect("Q218: baton source must be a member") | |
| .cost as i32; | |
| let target_id = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != chika_id | |
| && card.card_id != baton_source_id | |
| && card.abilities.is_empty() | |
| && (card.cost as i32) > baton_source_cost | |
| }) | |
| .min_by_key(|card| card.cost) | |
| .map(|card| card.card_id) | |
| .expect("Q218: expected an abilityless target member in the real DB"); | |
| let target_cost = db | |
| .get_member(target_id) | |
| .expect("Q218: target must be a member") | |
| .cost as i32; | |
| state.players[0].stage[0] = baton_source_id; | |
| state.players[0].stage[1] = chika_id; | |
| state.players[0].hand = vec![target_id].into(); | |
| state.players[0].energy_zone = vec![3001; 20].into(); | |
| state.sync_stat_caches(0, &db); | |
| let current_cost = state.get_member_cost(0, target_id, 0, -1, &db, 0); | |
| assert_eq!( | |
| current_cost, | |
| target_cost - baton_source_cost - 1, | |
| "Q218: Chika's constant reduction should still apply when the abilityless member is played via baton touch" | |
| ); | |
| state | |
| .play_member(&db, 0, 0) | |
| .expect("Q218: baton-touch play with Chika's reduction should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].stage[0], | |
| target_id, | |
| "Q218: the abilityless member should end up in the baton-touched slot" | |
| ); | |
| assert_eq!( | |
| state.players[0].tapped_energy_mask.count_ones(), | |
| current_cost as u32, | |
| "Q218: the actual paid energy should match the reduced baton-touch cost" | |
| ); | |
| } | |
| fn test_q219_cost10_liella_member_baton_gets_chisato_cost_reduction() { | |
| // Q219: Chisato's cost-10 Liella reduction must still apply when the target enters by | |
| // baton touch from hand. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let chisato_id = db | |
| .id_by_no("PL!SP-bp5-003-R+") | |
| .or_else(|| db.id_by_no("PL!SP-bp5-003-R+")) | |
| .expect("Q219: expected PL!SP-bp5-003-R+ in the real DB"); | |
| let baton_source_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != chisato_id && card.cost > 0) | |
| .min_by_key(|card| card.cost) | |
| .map(|card| card.card_id) | |
| .expect("Q219: expected a baton source member in the real DB"); | |
| let baton_source_cost = db | |
| .get_member(baton_source_id) | |
| .expect("Q219: baton source must be a member") | |
| .cost as i32; | |
| let target_id = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != chisato_id | |
| && card.card_id != baton_source_id | |
| && card.cost == 10 | |
| && card.card_no.starts_with("PL!SP") | |
| }) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q219: expected a cost-10 Liella member in the real DB"); | |
| let target_cost = db | |
| .get_member(target_id) | |
| .expect("Q219: target must be a member") | |
| .cost as i32; | |
| state.players[0].stage[0] = baton_source_id; | |
| state.players[0].stage[1] = chisato_id; | |
| state.players[0].hand = vec![target_id].into(); | |
| state.players[0].energy_zone = vec![3001; 20].into(); | |
| state.sync_stat_caches(0, &db); | |
| let current_cost = state.get_member_cost(0, target_id, 0, -1, &db, 0); | |
| assert_eq!( | |
| current_cost, | |
| target_cost - baton_source_cost - 2, | |
| "Q219: Chisato's cost reduction should still apply when the cost-10 Liella member is played via baton touch" | |
| ); | |
| state | |
| .play_member(&db, 0, 0) | |
| .expect("Q219: baton-touch play with Chisato's reduction should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].stage[0], | |
| target_id, | |
| "Q219: the cost-10 Liella member should end up in the baton-touched slot" | |
| ); | |
| assert_eq!( | |
| state.players[0].tapped_energy_mask.count_ones(), | |
| current_cost as u32, | |
| "Q219: the actual paid energy should match the reduced baton-touch cost" | |
| ); | |
| } | |
| fn test_q226_place_recovered_live_at_deck_bottom_when_deck_has_two_cards() { | |
| // Q226: when placing a discard live card into the deck "4th from the top" with only two | |
| // cards in deck, the live should be placed at the bottom. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let rina_id = db | |
| .id_by_no("PL!N-bp5-021-N") | |
| .expect("Q226: expected PL!N-bp5-021-N in the real DB"); | |
| let recovered_live_id = db | |
| .lives | |
| .values() | |
| .find(|card| card.card_id != rina_id) | |
| .map(|card| card.card_id) | |
| .expect("Q226: expected a live card to recover from discard"); | |
| let deck_bottom_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != rina_id) | |
| .map(|card| card.card_id) | |
| .expect("Q226: expected a first deck filler member"); | |
| let deck_top_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != rina_id && card.card_id != deck_bottom_id) | |
| .map(|card| card.card_id) | |
| .expect("Q226: expected a second deck filler member"); | |
| state.players[0].hand = vec![rina_id].into(); | |
| state.players[0].energy_zone = vec![3001; 2].into(); | |
| state.players[0].deck = vec![deck_bottom_id, deck_top_id].into(); | |
| state.players[0].discard = vec![recovered_live_id].into(); | |
| state | |
| .play_member(&db, 0, 1) | |
| .expect("Q226: playing Rina should succeed"); | |
| state.process_trigger_queue(&db); | |
| for _ in 0..4 { | |
| if state.phase != Phase::Response { | |
| break; | |
| } | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| let action = if response_actions.contains(&(ACTION_BASE_CHOICE + 0)) { | |
| ACTION_BASE_CHOICE + 0 | |
| } else { | |
| *response_actions | |
| .iter() | |
| .filter(|candidate| **candidate > 0) | |
| .min() | |
| .expect("Q226: expected a positive response action") | |
| }; | |
| state | |
| .handle_response(&db, action) | |
| .expect("Q226: response step should resolve"); | |
| state.process_trigger_queue(&db); | |
| } | |
| assert_eq!( | |
| state.players[0].deck.len(), | |
| 3, | |
| "Q226: the recovered live should be inserted back into the deck" | |
| ); | |
| assert!( | |
| state.players[0].deck.contains(&recovered_live_id), | |
| "Q226: the recovered live should be returned to the deck after the effect resolves" | |
| ); | |
| assert!( | |
| state.players[0].deck.contains(&deck_bottom_id), | |
| "Q226: the pre-existing bottom card should still remain in the deck after the insertion" | |
| ); | |
| assert!( | |
| state.players[0].deck.contains(&deck_top_id), | |
| "Q226: the pre-existing top card should remain in the deck after bottom insertion" | |
| ); | |
| } | |
| fn test_q228_uumi_activation_cost_can_be_zero_with_four_group_names() { | |
| // Q228: when Umi shares the stage only with LL-bp1-001-R+, the activation cost should be | |
| // reduced to 0 because the stage covers four group-name types. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let umi_id = db | |
| .id_by_no("PL!-bp5-004-R+") | |
| .or_else(|| db.id_by_no("PL!-bp5-004-R+")) | |
| .expect("Q228: expected PL!-bp5-004-R+ in the real DB"); | |
| let mixed_group_member_id = db | |
| .id_by_no("LL-bp1-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp1-001-R+")) | |
| .expect("Q228: expected LL-bp1-001-R+ in the real DB"); | |
| let opponent_target_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != umi_id && card.card_id != mixed_group_member_id && card.cost <= 10) | |
| .map(|card| card.card_id) | |
| .expect("Q228: expected an opponent member with cost 10 or less"); | |
| state.players[0].stage[0] = umi_id; | |
| state.players[0].stage[1] = mixed_group_member_id; | |
| state.players[1].stage[0] = opponent_target_id; | |
| let mut legal_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut legal_actions); | |
| let activation_action = *legal_actions | |
| .iter() | |
| .filter(|action| **action >= ACTION_BASE_STAGE && **action < ACTION_BASE_STAGE + 100) | |
| .min() | |
| .expect("Q228: Umi's activation should be legal even with no energy because its cost becomes 0"); | |
| state | |
| .handle_main(&db, activation_action) | |
| .expect("Q228: zero-cost Umi activation should start successfully without any energy"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q228: after a 0-cost activation, the ability should proceed directly to target selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| !response_actions.iter().any(|action| *action >= ACTION_BASE_ENERGY && *action < ACTION_BASE_HAND_SELECT), | |
| "Q228: the engine should not request any energy payment once the activation cost has been reduced to 0" | |
| ); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q228: the opponent's cost-10-or-less member should be selectable once the zero-cost activation resolves" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q228: selecting the opponent target should resolve the zero-cost activation"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[1].is_tapped(0), | |
| "Q228: the chosen opponent member should be tapped by Umi's resolved zero-cost ability" | |
| ); | |
| } | |
| fn test_q222_natsumi_can_repeat_live_start_effect_after_becoming_weighted() { | |
| // Q222: Natsumi should still be allowed to continue repeating the effect even after the | |
| // first discarded card is a live and the member becomes weighted. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let natsumi_id = db | |
| .id_by_no("PL!SP-bp5-009-R") | |
| .expect("Q222: expected PL!SP-bp5-009-R in the real DB"); | |
| let current_live_id = db | |
| .lives | |
| .values() | |
| .find(|card| card.card_id != natsumi_id) | |
| .map(|card| card.card_id) | |
| .expect("Q222: expected a live card in the real DB"); | |
| let milled_live_id = db | |
| .lives | |
| .values() | |
| .find(|card| card.card_id != current_live_id) | |
| .map(|card| card.card_id) | |
| .expect("Q222: expected a second live card in the real DB"); | |
| let follow_up_deck_card = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != natsumi_id) | |
| .map(|card| card.card_id) | |
| .expect("Q222: expected a follow-up deck filler member"); | |
| state.players[0].stage[1] = natsumi_id; | |
| state.players[0].live_zone[0] = current_live_id; | |
| state.players[0].deck = vec![follow_up_deck_card, milled_live_id].into(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q222: Natsumi's live-start effect should first suspend for the optional repeat prompt" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_CHOICE + 0)), | |
| "Q222: the first optional self-mill prompt should offer an accept action" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q222: accepting the first repeat should resolve the self-mill effect"); | |
| state.process_trigger_queue(&db); | |
| assert!( | |
| state.players[0].is_tapped(1), | |
| "Q222: milling a live card on the first iteration should make Natsumi weighted" | |
| ); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q222: even after becoming weighted, the effect should suspend again for the next repeat decision" | |
| ); | |
| response_actions.clear(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_CHOICE + 0)), | |
| "Q222: the engine should still offer another repeat prompt after Natsumi becomes weighted" | |
| ); | |
| } | |
| fn test_q223_opponent_chooses_their_own_position_change_destination() { | |
| // Q223: when Vienna makes both players position-change their own center member, the | |
| // opponent must choose the destination for their own stage. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let vienna_id = db | |
| .id_by_no("PL!SP-bp5-010-R") | |
| .expect("Q223: expected PL!SP-bp5-010-R in the real DB"); | |
| let own_center_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != vienna_id) | |
| .map(|card| card.card_id) | |
| .expect("Q223: expected an own center member"); | |
| let own_side_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != vienna_id && card.card_id != own_center_id) | |
| .map(|card| card.card_id) | |
| .expect("Q223: expected an own side member"); | |
| let opponent_left_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_id != vienna_id && card.card_id != own_center_id && card.card_id != own_side_id) | |
| .map(|card| card.card_id) | |
| .expect("Q223: expected an opponent left member"); | |
| let opponent_center_id = db | |
| .members | |
| .values() | |
| .find(|card| { | |
| card.card_id != vienna_id | |
| && card.card_id != own_center_id | |
| && card.card_id != own_side_id | |
| && card.card_id != opponent_left_id | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q223: expected an opponent center member"); | |
| let opponent_right_id = db | |
| .members | |
| .values() | |
| .find(|card| { | |
| card.card_id != vienna_id | |
| && card.card_id != own_center_id | |
| && card.card_id != own_side_id | |
| && card.card_id != opponent_left_id | |
| && card.card_id != opponent_center_id | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q223: expected an opponent right member"); | |
| state.players[0].stage[1] = own_center_id; | |
| state.players[0].stage[2] = own_side_id; | |
| state.players[0].hand = vec![vienna_id].into(); | |
| state.players[0].energy_zone = vec![3001; 13].into(); | |
| state.players[1].stage[0] = opponent_left_id; | |
| state.players[1].stage[1] = opponent_center_id; | |
| state.players[1].stage[2] = opponent_right_id; | |
| state | |
| .play_member(&db, 0, 0) | |
| .expect("Q223: playing Vienna should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q223: Vienna's on-play effect should suspend for position-change choices" | |
| ); | |
| assert_eq!( | |
| state.current_player, | |
| 0, | |
| "Q223: the active player should choose their own position change first" | |
| ); | |
| let mut own_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut own_actions); | |
| assert!( | |
| own_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 2)), | |
| "Q223: the active player should be able to move their center member to the right slot" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 2) | |
| .expect("Q223: resolving the active player's position change should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q223: after the active player's choice, the effect should continue with the opponent's choice" | |
| ); | |
| assert_eq!( | |
| state.current_player, | |
| 1, | |
| "Q223: the opponent should choose the destination for their own center member" | |
| ); | |
| let mut opponent_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 1, &mut opponent_actions); | |
| assert!( | |
| opponent_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q223: the opponent should be allowed to move their center member to the left slot" | |
| ); | |
| assert!( | |
| opponent_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 2)), | |
| "Q223: the opponent should also be allowed to move their center member to the right slot" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q223: resolving the opponent's chosen destination should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].stage[1], | |
| own_side_id, | |
| "Q223: the active player's own choice should swap their center with the selected destination" | |
| ); | |
| assert_eq!( | |
| state.players[0].stage[2], | |
| own_center_id, | |
| "Q223: the active player's center member should move to the chosen right slot" | |
| ); | |
| assert_eq!( | |
| state.players[1].stage[0], | |
| opponent_center_id, | |
| "Q223: the opponent's selected destination should receive their center member" | |
| ); | |
| assert_eq!( | |
| state.players[1].stage[1], | |
| opponent_left_id, | |
| "Q223: the displaced opponent member should move into center after the opponent's own choice" | |
| ); | |
| } | |
| fn test_q212_dream_believers_does_not_count_ll_bp2_001_as_a_second_distinct_unit() { | |
| // Q212: Dream Believers (104th Ver.) should not apply when the stage contains only | |
| // Rurino plus LL-bp2-001-R+, even though that mixed-name card includes Rurino among its | |
| // names. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let live_id = db | |
| .id_by_no("PL!HS-bp5-017-L") | |
| .expect("Q212: expected PL!HS-bp5-017-L in the real DB"); | |
| let rurino_id = db | |
| .id_by_no("PL!HS-bp1-005-P") | |
| .expect("Q212: expected PL!HS-bp1-005-P in the real DB"); | |
| let mixed_name_id = db | |
| .id_by_no("LL-bp2-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp2-001-R+")) | |
| .expect("Q212: expected LL-bp2-001-R+ in the real DB"); | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].stage[0] = rurino_id; | |
| state.players[0].stage[1] = mixed_name_id; | |
| state.players[0].energy_zone = vec![3001; 1].into(); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].live_score_bonus, | |
| 0, | |
| "Q212: the mixed-name LL card must not make Dream Believers gain +1 score in this configuration" | |
| ); | |
| } | |
| fn test_q217_victorious_road_triggers_when_ll_bp2_001_resolves_after_zero_discards() { | |
| // Q217: if LL-bp2-001-R+ resolves its live-start ability after selecting 0 cards for the | |
| // cost, Victorious Road should still trigger and grant an ALL heart. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let victorious_road_id = db | |
| .id_by_no("PL!N-bp5-030-L") | |
| .expect("Q217: expected PL!N-bp5-030-L in the real DB"); | |
| let trigger_member_id = db | |
| .id_by_no("LL-bp2-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp2-001-R+")) | |
| .expect("Q217: expected LL-bp2-001-R+ in the real DB"); | |
| state.players[0].live_zone[0] = victorious_road_id; | |
| state.players[0].stage[1] = trigger_member_id; | |
| let hearts_before = get_effective_hearts(&state, 0, 1, &db, 0); | |
| assert_eq!( | |
| hearts_before.get_color_count(6), | |
| 0, | |
| "Q217: LL-bp2-001-R+ should begin with no ALL hearts so Victorious Road has a visible trigger condition" | |
| ); | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q217: LL-bp2-001-R+ should suspend for its optional live-start cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q217: accepting the optional live-start ability should succeed even when discarding 0 cards"); | |
| state.process_trigger_queue(&db); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_CHOICE + 99)), | |
| "Q217: after accepting the ability, the engine should allow immediately finishing the any-number hand selection with 0 chosen cards" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 99) | |
| .expect("Q217: finalizing the cost selection with 0 discarded cards should still resolve the ability"); | |
| state.process_trigger_queue(&db); | |
| let hearts_after = get_effective_hearts(&state, 0, 1, &db, 0); | |
| assert_eq!( | |
| hearts_after.get_color_count(6), | |
| 1, | |
| "Q217: Victorious Road should trigger when LL-bp2-001-R+ resolves after choosing to discard 0 cards" | |
| ); | |
| } | |
| fn test_q227_victorious_road_does_not_trigger_when_live_start_cost_is_not_paid() { | |
| // Q227: if the live-start cost is declined entirely, the source ability never resolves and | |
| // Victorious Road must not trigger. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let victorious_road_id = db | |
| .id_by_no("PL!N-bp5-030-L") | |
| .expect("Q227: expected PL!N-bp5-030-L in the real DB"); | |
| let trigger_member_id = db | |
| .id_by_no("LL-bp2-001-R+") | |
| .or_else(|| db.id_by_no("LL-bp2-001-R+")) | |
| .expect("Q227: expected LL-bp2-001-R+ in the real DB"); | |
| state.players[0].live_zone[0] = victorious_road_id; | |
| state.players[0].stage[1] = trigger_member_id; | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q227: LL-bp2-001-R+ should suspend for its optional live-start cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 1) | |
| .expect("Q227: declining the live-start cost should resolve cleanly"); | |
| state.process_trigger_queue(&db); | |
| let hearts_after = get_effective_hearts(&state, 0, 1, &db, 0); | |
| assert_eq!( | |
| hearts_after.get_color_count(6), | |
| 0, | |
| "Q227: Victorious Road must not grant an ALL heart when the source live-start cost was not paid" | |
| ); | |
| assert_ne!( | |
| state.phase, | |
| Phase::Response, | |
| "Q227: declining the cost should end the interaction instead of opening any further resolution prompt" | |
| ); | |
| } | |
| fn test_q199_member_played_by_rina_cannot_be_baton_touched_that_turn() { | |
| // Q199: a member brought onto the stage by this effect cannot be baton-touched later in | |
| // the same turn. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let rina_id = db | |
| .id_by_no("PL!N-pb1-023-R") | |
| .expect("Q199: expected PL!N-pb1-023-R in the real DB"); | |
| let summoned_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != rina_id && card.cost <= 4 && card.abilities.is_empty()) | |
| .min_by_key(|card| card.cost) | |
| .map(|card| card.card_id) | |
| .expect("Q199: expected a cost-4-or-less vanilla member for Rina to summon"); | |
| let baton_target_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.card_id != rina_id && card.card_id != summoned_id && card.cost > 0) | |
| .min_by_key(|card| card.cost) | |
| .map(|card| card.card_id) | |
| .expect("Q199: expected a hand member to attempt the baton touch"); | |
| state.players[0].hand = vec![rina_id, summoned_id, baton_target_id].into(); | |
| state.players[0].energy_zone = vec![3001; 20].into(); | |
| state | |
| .play_member(&db, 0, 0) | |
| .expect("Q199: playing Rina should succeed"); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q199: Rina's on-play effect should suspend for its optional cost" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q199: accepting Rina's optional payment should succeed"); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q199: after accepting, Rina should suspend for selecting a cost-4-or-less member from hand" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_HAND_SELECT + 0) | |
| .expect("Q199: selecting the low-cost summoned target should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q199: after choosing the member, Rina should suspend for its destination slot" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 1) | |
| .expect("Q199: choosing slot 1 for the summoned member should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.players[0].stage[1], | |
| summoned_id, | |
| "Q199: the selected member should enter slot 1 through Rina's effect" | |
| ); | |
| let baton_err = state | |
| .play_member(&db, 0, 1) | |
| .expect_err("Q199: a member placed by Rina this turn must not be legal to baton-touch"); | |
| assert!( | |
| baton_err.to_ascii_lowercase().contains("baton") | |
| || baton_err.to_ascii_lowercase().contains("already played") | |
| || baton_err.to_ascii_lowercase().contains("moved"), | |
| "Q199: the rejection should reflect a same-turn placement restriction, got: {}", | |
| baton_err | |
| ); | |
| } | |
| fn test_q213_facedown_hasunosora_member_in_live_zone_is_discarded_before_hanamusubi_reduction() { | |
| // Q213: a face-down Hasunosora member set during Live Set is discarded before Hanamusubi's | |
| // live-start reduction checks other cards in the live zone, so it must not reduce the | |
| // required green hearts. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::PerformanceP1; | |
| state.current_player = 0; | |
| let hanamusubi_id = db | |
| .id_by_no("PL!HS-bp5-019-L") | |
| .expect("Q213: expected PL!HS-bp5-019-L in the real DB"); | |
| let facedown_member_id = db | |
| .id_by_no("PL!HS-bp1-005-P") | |
| .expect("Q213: expected a Hasunosora member to place face-down in the live zone"); | |
| state.players[0].live_zone[0] = hanamusubi_id; | |
| state.players[0].live_zone[1] = facedown_member_id; | |
| state.players[0].set_revealed(0, false); | |
| state.players[0].set_revealed(1, false); | |
| crate::core::logic::performance::do_performance_phase(&mut state, &db); | |
| assert_eq!( | |
| state.players[0].live_zone[1], | |
| -1, | |
| "Q213: the non-live Hasunosora member should be removed from the live zone before live-start effects resolve" | |
| ); | |
| assert!( | |
| state.players[0].discard.contains(&facedown_member_id), | |
| "Q213: the face-down member card should be moved to discard during performance setup" | |
| ); | |
| let live = db | |
| .get_live(hanamusubi_id) | |
| .expect("Q213: Hanamusubi must be a live card"); | |
| let (req_board, _) = crate::core::logic::performance::get_live_requirements(&state, &db, 0, live); | |
| assert_eq!( | |
| req_board.get_color_count(3), | |
| 9, | |
| "Q213: Hanamusubi should still require all 9 green hearts because the face-down member was discarded before the reduction check" | |
| ); | |
| assert_eq!( | |
| req_board.get_color_count(6), | |
| 5, | |
| "Q213: the generic requirement should remain unchanged" | |
| ); | |
| } | |
| fn test_q187_dazzling_game_requires_a_second_distinct_liella_target() { | |
| // Q187: after selecting one of the named members for Dazzling Game, the second target must | |
| // be a different Liella member. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let dazzling_game_id = db | |
| .id_by_no("PL!SP-bp4-023-L") | |
| .expect("Q187: expected PL!SP-bp4-023-L in the real DB"); | |
| let named_target_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_no.starts_with("PL!SP") && card.name.contains("澁谷かのん")) | |
| .map(|card| card.card_id) | |
| .expect("Q187: expected a Shibuya Kanon member in the real DB"); | |
| let second_liella_id = db | |
| .members | |
| .values() | |
| .find(|card| { | |
| card.card_id != named_target_id | |
| && card.groups.contains(&3) | |
| && !card.name.contains("澁谷かのん") | |
| }) | |
| .map(|card| card.card_id) | |
| .expect("Q187: expected a second Liella member in the real DB"); | |
| state.players[0].live_zone[0] = dazzling_game_id; | |
| state.players[0].stage[0] = named_target_id; | |
| state.players[0].stage[1] = second_liella_id; | |
| state.trigger_event(&db, TriggerType::OnLiveStart, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q187: Dazzling Game should suspend for its first target selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q187: the named Kanon member should be selectable for the first target" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_STAGE_SLOTS + 0) | |
| .expect("Q187: selecting the named first target should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q187: after the first target, Dazzling Game should still require a second distinct Liella target" | |
| ); | |
| response_actions.clear(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| !response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 0)), | |
| "Q187: the first selected member must not remain selectable as the second target" | |
| ); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_STAGE_SLOTS + 1)), | |
| "Q187: a different Liella member should be required as the second target" | |
| ); | |
| } | |
| fn test_q191_daydream_mermaid_cannot_choose_the_same_live_success_mode_twice() { | |
| // Q191: when Daydream Mermaid allows choosing one or more modes, each mode must still be | |
| // chosen at most once. | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.phase = Phase::Main; | |
| state.current_player = 0; | |
| let daydream_mermaid_id = db | |
| .id_by_no("PL!N-bp4-030-L") | |
| .expect("Q191: expected PL!N-bp4-030-L in the real DB"); | |
| let prior_niji_success_id = db | |
| .lives | |
| .values() | |
| .find(|card| card.card_id != daydream_mermaid_id && card.card_no.starts_with("PL!N")) | |
| .map(|card| card.card_id) | |
| .expect("Q191: expected a prior Nijigasaki live in the success pile"); | |
| let discard_member_id = db | |
| .members | |
| .values() | |
| .find(|card| card.card_no.starts_with("PL!N")) | |
| .map(|card| card.card_id) | |
| .expect("Q191: expected a Nijigasaki member in discard for the recovery option"); | |
| state.players[0].live_zone[0] = daydream_mermaid_id; | |
| state.players[0].success_lives.push(prior_niji_success_id); | |
| state.players[0].discard.push(discard_member_id); | |
| state.trigger_event(&db, TriggerType::OnLiveSuccess, 0, -1, -1, 0, -1); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q191: Daydream Mermaid should suspend for live-success mode selection" | |
| ); | |
| let mut response_actions = Vec::new(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_MODE + 0)), | |
| "Q191: the energy-deck mode should be selectable initially" | |
| ); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_MODE + 1)), | |
| "Q191: the discard-recovery mode should be selectable initially" | |
| ); | |
| state | |
| .handle_response(&db, ACTION_BASE_MODE + 0) | |
| .expect("Q191: selecting the first live-success mode should succeed"); | |
| state.process_trigger_queue(&db); | |
| assert_eq!( | |
| state.phase, | |
| Phase::Response, | |
| "Q191: after choosing one mode, Daydream Mermaid should remain in response while additional distinct modes are available" | |
| ); | |
| response_actions.clear(); | |
| state.generate_legal_actions(&db, 0, &mut response_actions); | |
| assert!( | |
| !response_actions.contains(&(ACTION_BASE_MODE + 0)), | |
| "Q191: the already chosen energy mode must not remain selectable a second time" | |
| ); | |
| assert!( | |
| response_actions.contains(&(ACTION_BASE_MODE + 1)), | |
| "Q191: the remaining distinct mode should still be selectable" | |
| ); | |
| } | |
| fn test_q156_unused_second_copy_can_trigger_on_re_yell() { | |
| // Q156: 『 {{jidou.png|自動}} {{turn1.png|ターン1回}} エールにより自分のカードを1枚以上公開したとき、 | |
| // それらのカードの中にブレードハートを持つカードが2枚以下の場合、それらのカードをすべて控え室に置いてもよい。 | |
| // そのエールで得たブレードハートを失い、もう一度エールを行う。』について。 | |
| // 「[PL!S-bp3-020-L]ダイスキだったらダイジョウブ!」2枚でライブをしている時、この能力を使用した場合、 | |
| // この能力を使用していないもう1枚の能力でもう一度エールを行えますか? | |
| // A156: はい、可能です。 | |
| let db = load_real_db(); | |
| let live_id = db | |
| .id_by_no("PL!S-bp3-020-L") | |
| .expect("Q156: expected PL!S-bp3-020-L in the real DB"); | |
| let blade_member_id = db | |
| .members | |
| .values() | |
| .filter(|card| card.blades == 1 && card.abilities.is_empty()) | |
| .min_by_key(|card| card.card_id) | |
| .map(|card| card.card_id) | |
| .expect("Q156: expected a deterministic 1-blade member with no extra abilities"); | |
| let mut yell_cards: Vec<i32> = db | |
| .members | |
| .values() | |
| .filter(|card| { | |
| card.card_id != blade_member_id | |
| && card.abilities.is_empty() | |
| && card.blade_hearts.iter().all(|&heart| heart == 0) | |
| }) | |
| .map(|card| card.card_id) | |
| .collect(); | |
| yell_cards.sort_unstable(); | |
| yell_cards.truncate(3); | |
| assert_eq!( | |
| yell_cards.len(), | |
| 3, | |
| "Q156: expected three deterministic non-blade-heart yell cards for the controlled deck" | |
| ); | |
| let first_yell_id = yell_cards[0]; | |
| let second_yell_id = yell_cards[1]; | |
| let third_yell_id = yell_cards[2]; | |
| let mut state = create_test_state(); | |
| state.phase = Phase::PerformanceP1; | |
| state.current_player = 0; | |
| state.first_player = 0; | |
| state.ui.silent = true; | |
| state.players[0].stage[0] = blade_member_id; | |
| state.players[0].live_zone[0] = live_id; | |
| state.players[0].live_zone[1] = live_id; | |
| state.players[0].deck = vec![third_yell_id, second_yell_id, first_yell_id].into(); | |
| let mut safety = 0; | |
| while safety < 8 { | |
| if state.phase == Phase::Response { | |
| state | |
| .handle_response(&db, ACTION_BASE_CHOICE + 0) | |
| .expect("Q156: accepting the yell mulligan prompt should succeed"); | |
| state.process_trigger_queue(&db); | |
| } else { | |
| state.do_performance_phase(&db); | |
| } | |
| if state.phase != Phase::Response && state.performance_yell_done[0] { | |
| break; | |
| } | |
| safety += 1; | |
| } | |
| assert!(safety < 8, "Q156: performance flow should finish without looping indefinitely"); | |
| assert_eq!( | |
| state.players[0].yell_cards.len(), | |
| 1, | |
| "Q156: after the re-yell chain completes, exactly one yell batch should remain active" | |
| ); | |
| } | |
| } | |