Spaces:
Sleeping
Sleeping
| use crate::core::logic::*; | |
| use crate::test_helpers::{create_test_state, load_real_db}; | |
| /// Verifies that O_LOOK_AND_CHOOSE correctly enriches the choice text with the card's real original_text. | |
| fn test_enrichment_look_and_choose() { | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| // Use Honoka (120) as the source of the enrichment text (revealing real cards in deck) | |
| state.players[0].deck = vec![121, 124, 121].into(); | |
| let ctx = AbilityContext { | |
| player_id: 0, | |
| source_card_id: 120, | |
| ..Default::default() | |
| }; | |
| // O_LOOK_AND_CHOOSE 1 | |
| let bc = vec![O_LOOK_AND_CHOOSE, 1, 0, 0, O_RETURN, 0, 0, 0]; | |
| state.resolve_bytecode_cref(&db, &bc, &ctx); | |
| assert_eq!(state.phase, Phase::Response); | |
| let interaction = state.interaction_stack.last().expect("Missing interaction"); | |
| // Honoka's original_text starts with "{{toujyou.png|登場}}" | |
| assert!( | |
| interaction.choice_text.contains("登場"), | |
| "Look & Choose should be enriched with Honoka's real text" | |
| ); | |
| } | |
| /// Verifies that O_LOOK_AND_CHOOSE filters correctly based on real card attributes (Cost). | |
| fn test_look_and_choose_filter() { | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| // Deck: [Eli (121, Cost 2), Honoka (120, Cost 11), Kotori (122, Cost 13)] | |
| // Indices in looked_cards (stack order): 0=122, 1=120, 2=121 | |
| state.players[0].deck = vec![121, 120, 122].into(); | |
| let ctx = AbilityContext { | |
| player_id: 0, | |
| ..Default::default() | |
| }; | |
| // Filter Attr: Cost GE 11 → Bit 24 (Enable) | (11 << 25) (Threshold=11) | Bit 31 (Cost Type) | Bit 0 (Target=Self) | |
| // Python _pack_filter_attr now correctly uses bit 31 for cost filters. | |
| let cost_ge_11_attr = 0x01u64 | (1u64 << 24) | (11u64 << 25) | (1u64 << 31); | |
| let bc = vec![ | |
| O_LOOK_AND_CHOOSE, | |
| 3, | |
| cost_ge_11_attr as i32, | |
| 0, | |
| O_RETURN, | |
| 0, | |
| 0, | |
| 0, | |
| ]; | |
| state.resolve_bytecode_cref(&db, &bc, &ctx); | |
| assert_eq!(state.phase, Phase::Response); | |
| let legal = state.get_legal_actions(&db); | |
| // Look&Choose base action range is [ACTION_BASE_CHOICE, ACTION_BASE_CHOICE+looked.len) | |
| // Card 122 (index 0) -> Cost 13 (>=11) -> Legal (ACTION_BASE_CHOICE) | |
| // Card 120 (index 1) -> Cost 11 (>=11) -> Legal (ACTION_BASE_CHOICE + 1) | |
| // Card 121 (index 2) -> Cost 2 (<11) -> Illegal (ACTION_BASE_CHOICE + 2) | |
| assert!( | |
| legal[ACTION_BASE_CHOICE as usize + 0], | |
| "Card 122 (Cost 13) should be legal" | |
| ); | |
| assert!( | |
| legal[ACTION_BASE_CHOICE as usize + 1], | |
| "Card 120 (Cost 11) should be legal" | |
| ); | |
| assert!( | |
| !legal[ACTION_BASE_CHOICE as usize + 2], | |
| "Card 121 (Cost 2) should be illegal" | |
| ); | |
| } | |
| /// Verifies that Honoka's OnPlay trigger (ID 120) works correctly with production bytecode. | |
| fn test_trigger_on_play_honoka() { | |
| let mut db = load_real_db().clone(); | |
| // Inject fake live card 30001 (used for recovery) | |
| db.lives.insert( | |
| 30001, | |
| crate::core::logic::card_db::LiveCard { | |
| card_id: 30001, | |
| card_no: "FAKE-30001".to_string(), | |
| name: "Fake Live".to_string(), | |
| ..Default::default() | |
| }, | |
| ); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| // Setup state for Honoka's ability (ID 120): Need 2 success lives | |
| // Use card ID 6, 42 (Live cards) for Success Live area and 43 for Discard | |
| state.players[0].success_lives = vec![6, 42].into(); | |
| state.players[0].discard = vec![30001].into(); // Live card to recover (fake injected) | |
| let card = db.get_member(120).expect("Missing Honoka"); | |
| let ab = &card.abilities[0]; | |
| let ctx = AbilityContext { | |
| player_id: 0, | |
| source_card_id: 120, | |
| trigger_type: TriggerType::OnPlay, | |
| ..Default::default() | |
| }; | |
| state.resolve_bytecode_cref(&db, &ab.bytecode, &ctx); | |
| // Resume with choice | |
| if !state.interaction_stack.is_empty() { | |
| let mut next_ctx = ctx.clone(); | |
| next_ctx.program_counter = state.interaction_stack.last().unwrap().ctx.program_counter; | |
| next_ctx.choice_index = 0; | |
| state.resolve_bytecode_cref(&db, &ab.bytecode, &next_ctx); | |
| } else { | |
| println!("DEBUG: Interaction stack empty after first call!"); | |
| } | |
| // Verify manually | |
| if state.players[0].hand.len() != 1 { | |
| panic!( | |
| "Should have recovered a live card to hand, found {}", | |
| state.players[0].hand.len() | |
| ); | |
| } | |
| if !state.players[0].hand.contains(&30001) { | |
| panic!("Hand should contain the recovered live card 30001"); | |
| } | |
| } | |
| /// Verifies that Eli's Activated trigger (ID 121) works correctly with production bytecode. | |
| /// RECOVER_MEMBER always prompts the user even with 1 candidate (game rule compliance). | |
| fn test_trigger_activated_eli() { | |
| let db = load_real_db(); | |
| let mut state = create_test_state(); | |
| state.ui.silent = true; | |
| state.players[0].discard = vec![124].into(); // Member card to recover (Rin) | |
| state.players[0].stage[2] = 121; // Eli is on stage slot 2 | |
| let card = db.get_member(121).expect("Missing Eli"); | |
| let ab = &card.abilities[0]; | |
| let ctx = AbilityContext { | |
| player_id: 0, | |
| area_idx: 2, | |
| source_card_id: 121, | |
| trigger_type: TriggerType::Activated, | |
| ..Default::default() | |
| }; | |
| // First, manually process the cost (MOVE_TO_DISCARD SELF) | |
| // This moves Eli from stage to discard | |
| state.players[0].stage[2] = -1; | |
| state.players[0].discard.push(121); | |
| // Filter: CardType=Member(1)@2-3 | Zone=Discard(7)@53-55 | CharID=Rin(5)@39-45 | Target=Self(1)@0-1 | |
| // Layout: bits 2-3 (type), 39-45 (char), 53-55 (zone), 0-1 (target) | |
| let filter_attr: u64 = 0x01 | (1 << 2) | (5u64 << 39) | (7u64 << 53); | |
| let mut custom_bytecode = ab.bytecode.clone(); | |
| if custom_bytecode.len() >= 5 { | |
| custom_bytecode[2] = (filter_attr & 0xFFFFFFFF) as i32; | |
| custom_bytecode[3] = (filter_attr >> 32) as i32; | |
| } | |
| // First call: should suspend waiting for user selection (1 target still needs user choice) | |
| state.resolve_bytecode_cref(&db, &custom_bytecode, &ctx); | |
| // Game rule: RECOVER_MEMBER always prompts user even with 1 valid card | |
| assert_eq!(state.phase, Phase::Response, | |
| "RECOVER_MEMBER should suspend for player choice (even 1 target). Hand: {:?}", state.players[0].hand); | |
| // Put Eli back in hand to allow activation | |
| state.players[0].hand.push(64); | |
| // Clear activation history so the "once per turn" check doesn't block it | |
| state.players[0].used_abilities.clear(); | |
| assert_eq!(state.interaction_stack.len(), 1, "Should have 1 pending interaction"); | |
| // Resume with choice 0 (select Rin, the only valid card at index 0) | |
| let mut safety_counter = 0; | |
| while state.phase == Phase::Response && safety_counter < 5 { | |
| state.step(&db, ACTION_BASE_CHOICE + 0).expect("Failed to resume ability"); | |
| state.process_trigger_queue(&db); | |
| safety_counter += 1; | |
| } | |
| // After choice: Rin should be in hand | |
| state.process_trigger_queue(&db); | |
| assert_eq!(state.phase, Phase::Main, | |
| "Should return to Main after selection. Hand: {:?}", state.players[0].hand); | |
| assert!(state.players[0].hand.contains(&124), | |
| "Hand should contain recovered member Rin (ID 124). Hand: {:?}", state.players[0].hand); | |
| assert!(!state.players[0].discard.contains(&124), | |
| "Rin should no longer be in discard"); | |
| } | |