Coverage for tests / test_core / test_game.py: 100.0%

370 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-05 20:45 +0000

1"""Tests for beaverbunch.core.game — the main game engine.""" 

2 

3import pytest 

4 

5from beaverbunch.core.actions import ( 

6 DiscardDrawnAction, 

7 PeekOwnCardAction, 

8 ReplaceCardAction, 

9 SnapAction, 

10 SwapCardAction, 

11) 

12from beaverbunch.core.card import Card, FaceCard, Suit 

13from beaverbunch.core.game import Game, GamePhase, GameState 

14from beaverbunch.core.game_settings import GameSettings 

15from beaverbunch.core.hand import CardSlot, Hand 

16from beaverbunch.core.player import Player 

17from beaverbunch.core.rules import RuleViolation 

18 

19 

20# --------------------------------------------------------------------------- 

21# Helpers 

22# --------------------------------------------------------------------------- 

23 

24def _card(value: int, suit: Suit = Suit.HEARTS) -> Card: 

25 """Shorthand for creating a card (value 0 → Joker).""" 

26 if value == 0: 

27 return Card(suit=None, value=0) 

28 return Card(suit=suit, value=value) 

29 

30 

31def _setup_game( 

32 num_players: int = 2, 

33 hand_size: int = 4, 

34 deck_cards: list[Card] | None = None, 

35) -> Game: 

36 """ 

37 Create a Game with players added and a deterministic deck, ready to start(). 

38 

39 NOTE: start() shuffles the deck, so for tests needing deterministic card order, 

40 use _setup_game_manual() instead. 

41 """ 

42 if deck_cards is None: 

43 deck_cards = [_card(v % 13 + 1) for v in range(54)] 

44 

45 settings = GameSettings( 

46 initial_hand_size=hand_size, 

47 initial_cards_known=2, 

48 deck_factory=lambda: list(deck_cards), 

49 ) 

50 state = GameState() 

51 for i in range(num_players): 

52 state.add_player(Player(name=f"P{i}", hand=Hand())) 

53 return Game(state=state, settings=settings) 

54 

55 

56def _setup_game_manual( 

57 player_hands: dict[str, list[Card]], 

58 deck_remaining: list[Card], 

59 discard_pile: list[Card], 

60) -> Game: 

61 """ 

62 Create a Game with pre-set hands, deck, and discard pile. 

63 Skips start() entirely — sets phase to WAITING_FOR_DRAW directly. 

64 All players have already 'peeked'. 

65 """ 

66 state = GameState() 

67 for name, cards in player_hands.items(): 

68 hand = Hand(slots=[CardSlot(card=c) for c in cards]) 

69 # Mark first 2 as known (simulating initial peek) 

70 for i in range(min(2, len(hand.slots))): 

71 hand.slots[i].known = True 

72 state.add_player(Player(name=name, hand=hand)) 

73 

74 from beaverbunch.core.deck import Deck 

75 state.deck = Deck(cards=list(deck_remaining)) 

76 state.discard_pile = list(discard_pile) 

77 state.phase = GamePhase.WAITING_FOR_DRAW 

78 state.initial_peeks_done = {name for name in player_hands} 

79 

80 return Game(state=state) 

81 

82 

83def _peek_all(game: Game) -> None: 

84 for p in game.state.players: 

85 game.peek_initial(p.name, [0, 1]) 

86 

87 

88# =================================================================== 

89# Setup & Initial Peek 

90# =================================================================== 

91 

92class TestGameSetup: 

93 def test_start_deals_correct_hand_size(self): 

94 game = _setup_game(num_players=3, hand_size=4) 

95 game.start() 

96 for p in game.state.players: 

97 assert len(p.hand) == 4 

98 

99 def test_start_places_one_discard(self): 

100 game = _setup_game() 

101 game.start() 

102 assert len(game.state.discard_pile) == 1 

103 

104 def test_start_reduces_deck(self): 

105 """Deck should have 54 - (2*4) - 1 = 45 cards left.""" 

106 game = _setup_game(num_players=2, hand_size=4) 

107 game.start() 

108 assert len(game.state.deck) == 54 - 2 * 4 - 1 

109 

110 def test_start_phase_is_starting(self): 

111 game = _setup_game() 

112 game.start() 

113 assert game.state.phase == GamePhase.STARTING 

114 

115 def test_start_fails_without_enough_players(self): 

116 settings = GameSettings(min_players=2) 

117 state = GameState() 

118 state.add_player(Player(name="P0", hand=Hand())) 

119 game = Game(state=state, settings=settings) 

120 with pytest.raises(RuleViolation, match="Not enough players"): 

121 game.start() 

122 

123 def test_start_fails_with_duplicate_names(self): 

124 state = GameState() 

125 state.add_player(Player(name="Alice", hand=Hand())) 

126 state.add_player(Player(name="Alice", hand=Hand())) 

127 game = Game(state=state) 

128 with pytest.raises(RuleViolation, match="unique"): 

129 game.start() 

130 

131 def test_empty_player_name_raises(self): 

132 with pytest.raises(ValueError, match="empty"): 

133 Player(name="", hand=Hand()) 

134 

135 def test_start_fails_when_not_in_starting_phase(self): 

136 game = _setup_game() 

137 game.start() 

138 _peek_all(game) 

139 with pytest.raises(RuleViolation): 

140 game.start() 

141 

142 

143class TestGameStateHelpers: 

144 def test_is_game_over_false_initially(self): 

145 state = GameState() 

146 assert state.is_game_over() is False 

147 

148 def test_is_game_over_true_when_finished(self): 

149 state = GameState() 

150 state.set_phase(GamePhase.FINISHED) 

151 assert state.is_game_over() is True 

152 

153 def test_current_player_returns_active_player(self): 

154 game = _setup_game_manual( 

155 player_hands={"P0": [_card(3)] * 4, "P1": [_card(7)] * 4}, 

156 deck_remaining=[_card(2)], 

157 discard_pile=[_card(5)], 

158 ) 

159 assert game.state.current_player.name == "P0" 

160 game.state.current_player_index = 1 

161 assert game.state.current_player.name == "P1" 

162 

163 

164class TestInitialPeek: 

165 def test_peek_marks_cards_known(self): 

166 game = _setup_game() 

167 game.start() 

168 p = game.state.players[0] 

169 game.peek_initial(p.name, [0, 1]) 

170 assert p.hand.slots[0].known is True 

171 assert p.hand.slots[1].known is True 

172 assert p.hand.slots[2].known is False 

173 assert p.hand.slots[3].known is False 

174 

175 def test_peek_returns_correct_cards(self): 

176 game = _setup_game() 

177 game.start() 

178 p = game.state.players[0] 

179 result = game.peek_initial(p.name, [0, 1]) 

180 assert result == [p.hand[0], p.hand[1]] 

181 

182 def test_phase_transitions_after_all_peek(self): 

183 game = _setup_game(num_players=2) 

184 game.start() 

185 game.peek_initial("P0", [0, 1]) 

186 assert game.state.phase == GamePhase.STARTING # not all peeked yet 

187 game.peek_initial("P1", [0, 1]) 

188 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

189 

190 def test_peek_wrong_count_raises(self): 

191 game = _setup_game() 

192 game.start() 

193 with pytest.raises(RuleViolation, match="Must peek exactly"): 

194 game.peek_initial("P0", [0]) 

195 

196 def test_peek_duplicate_indices_raises(self): 

197 game = _setup_game() 

198 game.start() 

199 with pytest.raises(RuleViolation, match="Duplicate"): 

200 game.peek_initial("P0", [1, 1]) 

201 

202 def test_peek_twice_raises(self): 

203 game = _setup_game() 

204 game.start() 

205 game.peek_initial("P0", [0, 1]) 

206 with pytest.raises(RuleViolation, match="already peeked"): 

207 game.peek_initial("P0", [2, 3]) 

208 

209 

210# =================================================================== 

211# Drawing (using manual setup for deterministic tests) 

212# =================================================================== 

213 

214class TestDrawCard: 

215 def test_draw_from_deck(self): 

216 game = _setup_game_manual( 

217 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

218 deck_remaining=[_card(7), _card(8)], 

219 discard_pile=[_card(5)], 

220 ) 

221 deck_size_before = len(game.state.deck) 

222 card = game.draw_card("P0") 

223 assert isinstance(card, Card) 

224 assert card.value == 8 # top of deck (last element) 

225 assert len(game.state.deck) == deck_size_before - 1 

226 assert game.state.drawn_card == card 

227 assert game.state.phase == GamePhase.WAITING_FOR_ACTION 

228 

229 def test_draw_wrong_player_raises(self): 

230 game = _setup_game_manual( 

231 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

232 deck_remaining=[_card(7)], 

233 discard_pile=[_card(5)], 

234 ) 

235 with pytest.raises(RuleViolation, match="turn"): 

236 game.draw_card("P1") 

237 

238 def test_draw_from_discard(self): 

239 game = _setup_game_manual( 

240 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

241 deck_remaining=[_card(7)], 

242 discard_pile=[_card(5)], 

243 ) 

244 card = game.draw_from_discard("P0") 

245 assert card.value == 5 

246 assert game.state.drawn_card == card 

247 assert len(game.state.discard_pile) == 0 

248 

249 def test_draw_in_wrong_phase_raises(self): 

250 game = _setup_game_manual( 

251 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

252 deck_remaining=[_card(7), _card(8)], 

253 discard_pile=[_card(5)], 

254 ) 

255 game.draw_card("P0") # now WAITING_FOR_ACTION 

256 with pytest.raises(RuleViolation, match="WAITING_FOR_ACTION"): 

257 game.draw_card("P0") 

258 

259 

260# =================================================================== 

261# Keep / Discard drawn card 

262# =================================================================== 

263 

264class TestKeepDrawnCard: 

265 def test_keep_replaces_hand_card(self): 

266 game = _setup_game_manual( 

267 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

268 deck_remaining=[_card(7)], 

269 discard_pile=[_card(5)], 

270 ) 

271 p = game.state.players[0] 

272 old_hand_card = p.hand[0] 

273 drawn = game.draw_card("P0") 

274 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

275 assert p.hand[0] == drawn 

276 assert game.state.discard_pile[-1] == old_hand_card 

277 assert game.state.drawn_card is None 

278 

279 def test_keep_advances_turn(self): 

280 game = _setup_game_manual( 

281 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

282 deck_remaining=[_card(7)], 

283 discard_pile=[_card(5)], 

284 ) 

285 game.draw_card("P0") 

286 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

287 assert game.state.current_player_index == 1 

288 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

289 

290 def test_keep_resets_known_flag(self): 

291 game = _setup_game_manual( 

292 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

293 deck_remaining=[_card(7)], 

294 discard_pile=[_card(5)], 

295 ) 

296 p = game.state.players[0] 

297 assert p.hand.slots[0].known is True # set by _setup_game_manual 

298 game.draw_card("P0") 

299 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

300 assert p.hand.slots[0].known is False # replaced → unknown 

301 

302 

303class TestDiscardDrawnCard: 

304 def test_discard_no_bonus(self): 

305 game = _setup_game_manual( 

306 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

307 deck_remaining=[_card(5)], # 5 has no bonus 

308 discard_pile=[_card(2)], 

309 ) 

310 game.draw_card("P0") 

311 result = game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

312 assert result is None 

313 assert game.state.drawn_card is None 

314 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

315 assert game.state.current_player_index == 1 

316 

317 def test_discard_jack_triggers_bonus(self): 

318 game = _setup_game_manual( 

319 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

320 deck_remaining=[_card(11)], # Jack 

321 discard_pile=[_card(2)], 

322 ) 

323 game.draw_card("P0") 

324 result = game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

325 assert result == FaceCard.JACK 

326 assert game.state.phase == GamePhase.WAITING_FOR_BONUS 

327 assert game.state.pending_bonus == FaceCard.JACK 

328 

329 def test_discard_queen_triggers_bonus(self): 

330 game = _setup_game_manual( 

331 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

332 deck_remaining=[_card(12)], # Queen 

333 discard_pile=[_card(2)], 

334 ) 

335 game.draw_card("P0") 

336 result = game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

337 assert result == FaceCard.QUEEN 

338 assert game.state.pending_bonus == FaceCard.QUEEN 

339 

340 def test_discard_king_triggers_bonus(self): 

341 game = _setup_game_manual( 

342 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

343 deck_remaining=[_card(13)], # King 

344 discard_pile=[_card(2)], 

345 ) 

346 game.draw_card("P0") 

347 result = game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

348 assert result == FaceCard.KING 

349 assert game.state.pending_bonus == FaceCard.KING 

350 

351 

352# =================================================================== 

353# Bonus Actions 

354# =================================================================== 

355 

356class TestBonusActions: 

357 def _game_with_bonus(self, face_value: int) -> Game: 

358 """Return a game in WAITING_FOR_BONUS with the given face card as pending.""" 

359 game = _setup_game_manual( 

360 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

361 deck_remaining=[_card(face_value)], 

362 discard_pile=[_card(2)], 

363 ) 

364 game.draw_card("P0") 

365 game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

366 return game 

367 

368 # -- Jack (Peek) -- 

369 

370 def test_jack_peek(self): 

371 game = self._game_with_bonus(11) 

372 p = game.state.players[0] 

373 assert p.hand.slots[2].known is False 

374 card = game.execute_peek(PeekOwnCardAction(player_id="P0", hand_index=2)) 

375 assert p.hand.slots[2].known is True 

376 assert card == p.hand[2] 

377 assert game.state.pending_bonus is None 

378 assert game.state.current_player_index == 1 

379 

380 # -- Queen (Swap) -- 

381 

382 def test_queen_swap(self): 

383 game = self._game_with_bonus(12) 

384 p0 = game.state.players[0] 

385 p1 = game.state.players[1] 

386 p0_card = p0.hand[0] 

387 p1_card = p1.hand[0] 

388 game.execute_swap( 

389 SwapCardAction( 

390 player_id="P0", 

391 own_index=0, 

392 target_player_id="P1", 

393 target_index=0, 

394 ), 

395 ) 

396 assert p0.hand[0] == p1_card 

397 assert p1.hand[0] == p0_card 

398 assert p0.hand.slots[0].known is False 

399 assert p1.hand.slots[0].known is False 

400 assert game.state.pending_bonus is None 

401 

402 def test_queen_swap_self_raises(self): 

403 game = self._game_with_bonus(12) 

404 with pytest.raises(RuleViolation, match="yourself"): 

405 game.execute_swap( 

406 SwapCardAction( 

407 player_id="P0", 

408 own_index=0, 

409 target_player_id="P0", 

410 target_index=1, 

411 ), 

412 ) 

413 

414 # -- King (draw again) -- 

415 

416 def test_king_bonus_returns_to_draw(self): 

417 game = self._game_with_bonus(13) 

418 assert game.state.phase == GamePhase.WAITING_FOR_BONUS 

419 game.execute_king_bonus() 

420 # Same player, back to draw phase 

421 assert game.state.current_player_index == 0 

422 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

423 

424 def test_king_bonus_wrong_pending_raises(self): 

425 game = self._game_with_bonus(11) # Jack, not King 

426 with pytest.raises(RuleViolation, match="King"): 

427 game.execute_king_bonus() 

428 

429 # -- Skip bonus -- 

430 

431 def test_skip_bonus(self): 

432 game = self._game_with_bonus(11) 

433 game.skip_bonus("P0") 

434 assert game.state.pending_bonus is None 

435 assert game.state.current_player_index == 1 

436 

437 

438# =================================================================== 

439# Snap (exclusively in WAITING_FOR_DRAW) 

440# =================================================================== 

441 

442class TestSnap: 

443 def test_correct_snap_removes_card(self): 

444 # P0 has a 5 in hand, discard top is 5 → match 

445 game = _setup_game_manual( 

446 player_hands={ 

447 "P0": [_card(5), _card(3), _card(3), _card(3)], 

448 "P1": [_card(7)] * 4, 

449 }, 

450 deck_remaining=[_card(2)] * 5, 

451 discard_pile=[_card(5)], 

452 ) 

453 p0 = game.state.players[0] 

454 hand_size_before = len(p0.hand) 

455 result = game.snap(SnapAction(player_id="P0", hand_index=0)) 

456 assert result is True 

457 assert len(p0.hand) == hand_size_before - 1 

458 

459 def test_incorrect_snap_adds_penalty(self): 

460 # P0 has a 5 in hand, discard top is 7 → mismatch 

461 game = _setup_game_manual( 

462 player_hands={ 

463 "P0": [_card(5), _card(3), _card(3), _card(3)], 

464 "P1": [_card(7)] * 4, 

465 }, 

466 deck_remaining=[_card(2)] * 5, 

467 discard_pile=[_card(7)], 

468 ) 

469 p0 = game.state.players[0] 

470 hand_size_before = len(p0.hand) 

471 result = game.snap(SnapAction(player_id="P0", hand_index=0)) 

472 assert result is False 

473 assert len(p0.hand) == hand_size_before + 1 

474 

475 def test_any_player_can_snap(self): 

476 # P1 (not current player) can also snap 

477 game = _setup_game_manual( 

478 player_hands={ 

479 "P0": [_card(7)] * 4, 

480 "P1": [_card(5), _card(3), _card(3), _card(3)], 

481 }, 

482 deck_remaining=[_card(2)] * 5, 

483 discard_pile=[_card(5)], 

484 ) 

485 p1 = game.state.players[1] 

486 assert p1.hand[0].value == 5 

487 assert game.state.top_discard.value == 5 

488 result = game.snap(SnapAction(player_id="P1", hand_index=0)) 

489 assert result is True 

490 

491 def test_snap_in_wrong_phase_raises(self): 

492 game = _setup_game_manual( 

493 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

494 deck_remaining=[_card(7)], 

495 discard_pile=[_card(5)], 

496 ) 

497 game.draw_card("P0") # now WAITING_FOR_ACTION 

498 with pytest.raises(RuleViolation, match="WAITING_FOR_ACTION"): 

499 game.snap(SnapAction(player_id="P0", hand_index=0)) 

500 

501 def test_snap_stays_in_same_phase(self): 

502 game = _setup_game_manual( 

503 player_hands={ 

504 "P0": [_card(5), _card(3), _card(3), _card(3)], 

505 "P1": [_card(7)] * 4, 

506 }, 

507 deck_remaining=[_card(2)] * 5, 

508 discard_pile=[_card(5)], 

509 ) 

510 game.snap(SnapAction(player_id="P0", hand_index=0)) 

511 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

512 assert game.state.current_player_index == 0 # turn did NOT advance 

513 

514 

515# =================================================================== 

516# Last Round & Game End 

517# =================================================================== 

518 

519class TestLastRoundAndEnd: 

520 def test_joker_discard_triggers_last_round(self): 

521 # P0 has a joker in hand[0], draws something, replaces → joker goes to discard 

522 joker = _card(0) 

523 game = _setup_game_manual( 

524 player_hands={ 

525 "P0": [joker, _card(3), _card(3), _card(3)], 

526 "P1": [_card(3)] * 4, 

527 }, 

528 deck_remaining=[_card(7)] * 5, 

529 discard_pile=[_card(2)], 

530 ) 

531 game.draw_card("P0") 

532 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

533 

534 assert game.state.top_discard.is_joker 

535 assert game.state.last_round_starter == 0 

536 assert game.state.current_player_index == 1 

537 assert game.state.phase == GamePhase.LAST_ROUND 

538 

539 def test_game_finishes_after_last_round_completes(self): 

540 joker = _card(0) 

541 game = _setup_game_manual( 

542 player_hands={ 

543 "P0": [joker, _card(3), _card(3), _card(3)], 

544 "P1": [_card(3)] * 4, 

545 }, 

546 deck_remaining=[_card(7)] * 5, 

547 discard_pile=[_card(2)], 

548 ) 

549 # P0 discards joker → last round 

550 game.draw_card("P0") 

551 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

552 assert game.state.phase == GamePhase.LAST_ROUND 

553 

554 # P1 takes their last turn 

555 game.draw_card("P1") 

556 game.keep_drawn_card(ReplaceCardAction(player_id="P1", hand_index=0)) 

557 

558 # Back to P0 → FINISHED 

559 assert game.state.current_player_index == 0 

560 assert game.state.phase == GamePhase.FINISHED 

561 

562 def test_last_round_three_players(self): 

563 joker = _card(0) 

564 game = _setup_game_manual( 

565 player_hands={ 

566 "P0": [joker, _card(3), _card(3), _card(3)], 

567 "P1": [_card(3)] * 4, 

568 "P2": [_card(3)] * 4, 

569 }, 

570 deck_remaining=[_card(7)] * 10, 

571 discard_pile=[_card(2)], 

572 ) 

573 # P0 discards joker → last round 

574 game.draw_card("P0") 

575 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

576 assert game.state.phase == GamePhase.LAST_ROUND 

577 

578 # P1 plays 

579 game.draw_card("P1") 

580 game.keep_drawn_card(ReplaceCardAction(player_id="P1", hand_index=0)) 

581 assert game.state.phase == GamePhase.LAST_ROUND 

582 

583 # P2 plays → back to P0 → FINISHED 

584 game.draw_card("P2") 

585 game.keep_drawn_card(ReplaceCardAction(player_id="P2", hand_index=0)) 

586 assert game.state.phase == GamePhase.FINISHED 

587 

588 

589# =================================================================== 

590# Scoring 

591# =================================================================== 

592 

593class TestScoring: 

594 def test_get_scores(self): 

595 game = _setup_game_manual( 

596 player_hands={"P0": [_card(1)] * 4, "P1": [_card(9)] * 4}, 

597 deck_remaining=[], 

598 discard_pile=[], 

599 ) 

600 scores = game.get_scores() 

601 assert len(scores) == 2 

602 assert all(isinstance(s, tuple) and len(s) == 2 for s in scores) 

603 

604 def test_get_winner_lowest_points(self): 

605 game = _setup_game_manual( 

606 player_hands={"P0": [_card(1)] * 4, "P1": [_card(9)] * 4}, 

607 deck_remaining=[], 

608 discard_pile=[], 

609 ) 

610 winners = game.get_winner() 

611 assert len(winners) == 1 

612 assert winners[0].name == "P0" 

613 

614 def test_get_winner_tie_fewest_cards(self): 

615 # P0: 4 × 3 = 12 pts, P1: 3 × 4 = 12 pts → P1 wins (fewer cards) 

616 game = _setup_game_manual( 

617 player_hands={ 

618 "P0": [_card(3)] * 4, 

619 "P1": [_card(4)] * 3, 

620 }, 

621 deck_remaining=[], 

622 discard_pile=[], 

623 ) 

624 winners = game.get_winner() 

625 assert len(winners) == 1 

626 assert winners[0].name == "P1" 

627 

628 

629# =================================================================== 

630# Deck refill 

631# =================================================================== 

632 

633class TestDeckRefill: 

634 def test_refill_when_deck_empty(self): 

635 # Deck has 1 card, discard has 3 cards 

636 game = _setup_game_manual( 

637 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

638 deck_remaining=[_card(5)], 

639 discard_pile=[_card(2), _card(4), _card(6)], 

640 ) 

641 # P0 draws the last card from deck → deck now empty 

642 game.draw_card("P0") 

643 assert len(game.state.deck) == 0 

644 

645 # Discard it → adds to discard pile 

646 game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

647 

648 # Now P1 draws. _refill_deck_if_needed should reshuffle discard into deck. 

649 card = game.draw_card("P1") 

650 assert isinstance(card, Card) 

651 # Deck was refilled from discard (minus top card) 

652 assert game.state.drawn_card is not None 

653 

654 

655# =================================================================== 

656# Full game flow integration test 

657# =================================================================== 

658 

659class TestFullGameFlow: 

660 def test_complete_two_player_game(self): 

661 """Run through a full minimal game: start → peek → draw → keep/discard → end.""" 

662 joker = _card(0) 

663 game = _setup_game_manual( 

664 player_hands={ 

665 "P0": [joker, _card(3), _card(3), _card(3)], 

666 "P1": [_card(3)] * 4, 

667 }, 

668 deck_remaining=[_card(5)] * 5, 

669 discard_pile=[_card(2)], 

670 ) 

671 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

672 

673 # P0 draws, replaces joker (index 0) → joker on discard → last round 

674 drawn = game.draw_card("P0") 

675 assert game.state.phase == GamePhase.WAITING_FOR_ACTION 

676 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

677 assert game.state.top_discard.is_joker 

678 assert game.state.phase == GamePhase.LAST_ROUND 

679 

680 # P1's last turn 

681 game.draw_card("P1") 

682 game.keep_drawn_card(ReplaceCardAction(player_id="P1", hand_index=0)) 

683 assert game.state.phase == GamePhase.FINISHED 

684 

685 # Scoring 

686 scores = game.get_scores() 

687 assert len(scores) == 2 

688 winners = game.get_winner() 

689 assert len(winners) >= 1 

690 

691 def test_king_chain_draw(self): 

692 """King bonus allows the same player to draw again.""" 

693 game = _setup_game_manual( 

694 player_hands={"P0": [_card(3)] * 4, "P1": [_card(3)] * 4}, 

695 deck_remaining=[_card(5), _card(13)], # top=King, then 5 

696 discard_pile=[_card(2)], 

697 ) 

698 # P0 draws King, discards it → bonus 

699 game.draw_card("P0") 

700 result = game.discard_drawn_card(DiscardDrawnAction(player_id="P0")) 

701 assert result == FaceCard.KING 

702 assert game.state.phase == GamePhase.WAITING_FOR_BONUS 

703 

704 # Execute King bonus → same player draws again 

705 game.execute_king_bonus() 

706 assert game.state.current_player_index == 0 

707 assert game.state.phase == GamePhase.WAITING_FOR_DRAW 

708 

709 # P0 draws again (the 5), keeps it 

710 card = game.draw_card("P0") 

711 assert card.value == 5 

712 game.keep_drawn_card(ReplaceCardAction(player_id="P0", hand_index=0)) 

713 assert game.state.current_player_index == 1 # now P1's turn