File size: 5,334 Bytes
4be6a52 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | from dataclasses import FrozenInstanceError, replace
import pytest
from stackcraft.engine import legal_actions, new_game, place, step
from stackcraft.pieces import piece_at
from stackcraft.schema import COLORS, HEIGHT, PIECES, WIDTH, GameState
def custom_state(piece: str, rows: list[list[int]]) -> GameState:
return replace(new_game(42), current=piece, board=tuple(tuple(row) for row in rows))
def blank() -> list[list[int]]:
return [[0] * WIDTH for _ in range(HEIGHT)]
@pytest.mark.parametrize("piece,count", list(zip(PIECES, (17, 9, 34, 17, 17, 34, 34), strict=True)))
def test_empty_board_legal_actions(piece: str, count: int) -> None:
state = replace(new_game(42), current=piece)
actions = legal_actions(state)
assert len(actions) == len({action.id for action in actions}) == count
assert [(a.rotation, a.x) for a in actions] == sorted((a.rotation, a.x) for a in actions)
for action in actions:
assert action.id == f"r{action.rotation}x{action.x}"
assert len(set(action.cells)) == 4
assert all(0 <= x < WIDTH and 0 <= y < HEIGHT for x, y in action.cells)
assert max(y for _, y in action.cells) == HEIGHT - 1
after = step(state, action.id).state
assert sum(cell != 0 for row in after.board for cell in row) == 4
assert all(after.board[y][x] == COLORS[piece] for x, y in action.cells)
assert not any(cell for row in state.board for cell in row)
@pytest.mark.parametrize("lines,score", [(1, 100), (2, 300), (3, 500), (4, 800)])
def test_simultaneous_line_clears_and_scoring(lines: int, score: int) -> None:
rows = blank()
for y in range(HEIGHT - lines, HEIGHT):
rows[y] = [7] * WIDTH
rows[y][4] = 0
state = custom_state("I", rows)
result = step(state, "r1x4")
assert result.cleared == result.state.lines == lines
assert result.state.score == score
assert sum(cell != 0 for row in result.state.board for cell in row) == 4 - lines
assert all(not any(row) for row in result.state.board[:lines])
assert state.board == tuple(tuple(row) for row in rows)
def test_existing_counters_accumulate_and_rows_keep_order() -> None:
rows = blank()
rows[-1] = [7] * 9 + [0]
rows[-5][0] = 3
state = replace(custom_state("I", rows), lines=8, score=1600)
result = step(state, "r1x9")
assert result.state.lines == 9
assert result.state.score == 1700
assert result.state.board[-4][0] == 3
def test_collision_stops_drop_and_does_not_tuck_under_overhang() -> None:
rows = blank()
rows[10][0] = 2
state = custom_state("O", rows)
action = next(a for a in legal_actions(state) if a.id == "r0x0")
assert action.y == 8
assert (0, 10) not in action.cells
result = step(state, action.id)
assert result.state.board[10][0] == 2
assert result.state.board[19][0] == 0
def test_spawn_collision_excludes_a_column_even_if_space_below() -> None:
rows = blank()
rows[0][0] = 1
assert "r0x0" not in {a.id for a in legal_actions(custom_state("O", rows))}
def test_top_out_after_placement_without_completed_rows() -> None:
rows = blank()
rows[0] = rows[1] = [0, 0] + [7] * 7 + [0]
rows[2][0] = 7
state = replace(custom_state("O", rows), next_piece="O")
result = step(state, "r0x0")
assert result.cleared == 0
assert result.state.terminal
assert legal_actions(result.state) == ()
with pytest.raises(ValueError, match="illegal action"):
step(result.state, "r0x0")
def test_illegal_action_never_mutates_state() -> None:
state = new_game(10)
original = new_game(10)
for action in ("r0x-1", "r0x10", "r99x0", "garbage", "r0x0 "):
with pytest.raises(ValueError):
step(state, action)
assert state == original
with pytest.raises(FrozenInstanceError):
state.score = 5 # ty: ignore[invalid-assignment]
def test_piece_advancement_and_repeated_game_equivalence() -> None:
state = new_game(-53)
other = new_game(-53)
for index in range(100):
assert state.current == piece_at(-53, index)
assert state.next_piece == piece_at(-53, index + 1)
actions = legal_actions(state)
if not actions:
assert state.terminal
break
action = actions[(index * 3) % len(actions)].id
state = step(state, action).state
other = step(other, action).state
assert state == other
assert state.piece_index == index + 1
assert len(state.board) == HEIGHT
assert all(len(row) == WIDTH for row in state.board)
assert not any(all(row) for row in state.board)
def test_malformed_board_is_rejected() -> None:
with pytest.raises(ValueError, match="board"):
replace(new_game(0), board=((0,) * WIDTH,))
rows = blank()
rows[0][0] = 8
with pytest.raises(ValueError, match="cells"):
custom_state("T", rows)
def test_seedless_placement_matches_step_and_rejects_overlap() -> None:
state = new_game(42)
action = legal_actions(state)[0]
board, cleared = place(state.board, state.current, action)
transition = step(state, action.id)
assert board == transition.state.board
assert cleared == transition.cleared
with pytest.raises(ValueError, match="empty"):
place(board, state.current, action)
|