"""Engine rules tests: costs, sigils, scales, foe machinery.""" import pytest from scrypt.engine.cards import Card, CardInstance, Cost, CostType, make_card from scrypt.engine.combat import ( LANES, CombatState, IllegalMove, Phase, Result, ScriptedPlay, ) BIT = make_card("bit", health=1) DAEMON = make_card("daemon", power=1, health=2, cost=Cost(CostType.MEM, 1)) BRUTE = make_card("brute", power=3, health=2, cost=Cost(CostType.MEM, 2)) FLYER = make_card("flyer", power=2, health=1, cost=Cost(CostType.MEM, 1), sigils=("tunneling",)) WALL = make_card("wall", power=0, health=4, cost=Cost(CostType.MEM, 1), sigils=("packet_filter",)) SPLITTER = make_card("splitter", power=1, health=1, sigils=("forked",)) VENOM = make_card("venom", power=1, health=1, cost=Cost(CostType.MEM, 2), sigils=("null_pointer",)) SPIKES = make_card("spikes", power=0, health=3, sigils=("honeypot",)) GOAT = make_card("goat", power=0, health=1, cost=Cost(CostType.MEM, 1), sigils=("privileged",)) CAT = make_card("cat", power=0, health=1, cost=Cost(CostType.MEM, 1), sigils=("auto_restart",)) BREEDER = make_card("breeder", power=1, health=1, cost=Cost(CostType.MEM, 1), sigils=("self_replicating",)) DIGGER = make_card("digger", power=0, health=3, sigils=("scavenger_loop",)) BONEY = make_card("boney", power=2, health=3, cost=Cost(CostType.DUMPS, 4)) def fresh(script=None, main=None, side=None, seed=7) -> CombatState: return CombatState( main_deck=main if main is not None else [DAEMON] * 6, side_deck=side if side is not None else [BIT] * 10, script=script or [[]], seed=seed, ) def in_main(state: CombatState) -> CombatState: """Skip past the draw phase for board-setup tests.""" if state.phase is Phase.DRAW: state.draw("side" if state.can_draw_side else "main") return state def put(state, row, lane, card_spec): inst = CardInstance(spec=card_spec) getattr(state, row)[lane] = inst return inst # ----------------------------------------------------------------- setup def test_opening_hand_is_three_main_plus_one_bit(): state = fresh() assert len(state.hand) == 4 assert sum(1 for c in state.hand if c.spec.id == "bit") == 1 def test_turn_zero_script_reaches_front_row(): raider = make_card("raider", power=1, health=1) state = fresh(script=[[ScriptedPlay(1, raider)], [ScriptedPlay(2, raider)]]) assert state.foe_row[1] is not None and state.foe_row[1].spec.id == "raider" assert state.foe_queue[2] is not None # ----------------------------------------------------------------- costs def test_free_card_plays_without_sacrifice(): state = in_main(fresh()) state.hand.append(CardInstance(spec=BIT)) state.play(len(state.hand) - 1, 0) assert state.player_row[0].spec.id == "bit" def test_mem_cost_requires_sacrifice(): state = in_main(fresh()) state.hand.append(CardInstance(spec=DAEMON)) with pytest.raises(IllegalMove): state.play(len(state.hand) - 1, 0) def test_sacrifice_pays_mem_and_grants_dumps(): state = in_main(fresh()) put(state, "player_row", 2, BIT) state.hand.append(CardInstance(spec=DAEMON)) state.play(len(state.hand) - 1, 0, sacrifices=(2,)) assert state.player_row[2] is None assert state.player_row[0].spec.id == "daemon" assert state.dumps == 1 def test_can_play_into_lane_of_sacrificed_card(): state = in_main(fresh()) put(state, "player_row", 1, BIT) state.hand.append(CardInstance(spec=DAEMON)) state.play(len(state.hand) - 1, 1, sacrifices=(1,)) assert state.player_row[1].spec.id == "daemon" def test_cannot_play_into_occupied_lane(): state = in_main(fresh()) put(state, "player_row", 1, BIT) state.hand.append(CardInstance(spec=BIT)) with pytest.raises(IllegalMove): state.play(len(state.hand) - 1, 1) def test_over_sacrifice_rejected(): state = in_main(fresh()) put(state, "player_row", 0, BIT) put(state, "player_row", 1, BIT) state.hand.append(CardInstance(spec=DAEMON)) # costs 1 mem with pytest.raises(IllegalMove, match="over-sacrifice"): state.play(len(state.hand) - 1, 2, sacrifices=(0, 1)) def test_privileged_is_worth_three(): state = in_main(fresh()) put(state, "player_row", 0, GOAT) state.hand.append(CardInstance(spec=BRUTE)) # costs 2 mem state.play(len(state.hand) - 1, 1, sacrifices=(0,)) assert state.player_row[1].spec.id == "brute" def test_auto_restart_survives_sacrifice(): state = in_main(fresh()) cat = put(state, "player_row", 0, CAT) state.hand.append(CardInstance(spec=DAEMON)) state.play(len(state.hand) - 1, 1, sacrifices=(0,)) assert state.player_row[0] is cat assert state.dumps == 0 # no death, no dump def test_dump_cost_paid_from_pool(): state = in_main(fresh()) state.dumps = 4 state.hand.append(CardInstance(spec=BONEY)) state.play(len(state.hand) - 1, 0) assert state.dumps == 0 state.hand.append(CardInstance(spec=BONEY)) with pytest.raises(IllegalMove): state.play(len(state.hand) - 1, 1) def test_self_replicating_adds_copy_to_hand(): state = in_main(fresh()) put(state, "player_row", 0, BIT) state.hand.append(CardInstance(spec=BREEDER)) before = len(state.hand) state.play(len(state.hand) - 1, 1, sacrifices=(0,)) assert len(state.hand) == before # played one, gained one assert state.hand[-1].spec.id == "breeder" # ----------------------------------------------------------------- combat def test_unblocked_attack_tips_scale(): state = in_main(fresh()) put(state, "player_row", 0, BRUTE) state.ring_bell() assert state.scale == 3 def test_win_at_five_with_overkill_cycles(): state = in_main(fresh()) big = make_card("big", power=9, health=1) put(state, "player_row", 0, big) state.ring_bell() assert state.result is Result.PLAYER_WIN assert state.scale == 5 assert state.overkill_cycles == 4 def test_loss_at_minus_five(): state = in_main(fresh()) crusher = make_card("crusher", power=5, health=5) put(state, "foe_row", 0, crusher) state.ring_bell() assert state.result is Result.PLAYER_LOSS def test_blocked_attack_damages_card_not_scale(): state = in_main(fresh()) put(state, "player_row", 1, BRUTE) foe = put(state, "foe_row", 1, make_card("meat", power=0, health=5)) state.ring_bell() assert foe.health == 2 assert state.scale == 0 def test_tunneling_flies_over_blocker(): state = in_main(fresh()) put(state, "player_row", 0, FLYER) put(state, "foe_row", 0, make_card("meat", power=0, health=5)) state.ring_bell() assert state.scale == 2 def test_packet_filter_blocks_tunneling(): state = in_main(fresh()) put(state, "player_row", 0, FLYER) wall = put(state, "foe_row", 0, WALL) state.ring_bell() assert state.scale == 0 assert wall.health == 2 def test_forked_hits_adjacent_lanes_only(): state = in_main(fresh()) put(state, "player_row", 1, SPLITTER) left = put(state, "foe_row", 0, make_card("l", power=0, health=2)) mid = put(state, "foe_row", 1, make_card("m", power=0, health=2)) right = put(state, "foe_row", 2, make_card("r", power=0, health=2)) state.ring_bell() assert left.health == 1 and right.health == 1 assert mid.health == 2 def test_null_pointer_kills_any_size(): state = in_main(fresh()) put(state, "player_row", 0, VENOM) put(state, "foe_row", 0, make_card("giant", power=0, health=99)) state.ring_bell() assert state.foe_row[0] is None def test_honeypot_recoil_kills_fragile_attacker(): state = in_main(fresh()) put(state, "player_row", 0, make_card("soft", power=1, health=1)) put(state, "foe_row", 0, SPIKES) state.ring_bell() assert state.player_row[0] is None assert state.dumps == 1 # player's card died def test_player_death_grants_dumps_foe_death_does_not(): state = in_main(fresh()) put(state, "player_row", 0, BRUTE) # kills foe meat: no dumps put(state, "foe_row", 0, make_card("meat", power=9, health=1)) # kills brute back? no — simultaneous? sequential. state.ring_bell() # Brute (3 power) kills meat (1 health) before meat ever swings. assert state.foe_row[0] is None assert state.dumps == 0 def test_scavenger_loop_yields_bone_each_turn(): state = in_main(fresh()) put(state, "player_row", 3, DIGGER) state.ring_bell() assert state.dumps == 1 def test_queue_advances_after_foe_turn(): raider = make_card("raider", power=0, health=1) state = in_main(fresh(script=[[], [ScriptedPlay(0, raider)], []])) assert state.foe_queue[0] is not None state.ring_bell() assert state.foe_row[0] is not None assert state.foe_queue[0] is None def test_draw_phase_enforced(): state = fresh() with pytest.raises(IllegalMove): state.ring_bell() state.draw("main") assert state.phase is Phase.MAIN