Align crew hand sizes and card refresh with the rules

This commit is contained in:
2026-09-04 19:22:52 -07:00
parent 749bef6ff1
commit 673a26f271
5 changed files with 53 additions and 29 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
## Rules ## Rules
- [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules - [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules
- [ ] Check against the rules on pi-rat hand size and conditions for drawing more cards - [x] Check against the rules on pi-rat hand size and conditions for drawing more cards
- [ ] Assisting other pi-rats should only be available if there are multiple obstacles in the current challenge - [ ] Assisting other pi-rats should only be available if there are multiple obstacles in the current challenge
- [ ] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep - [ ] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep
- [ ] Double check if PvP obstacles should allow card re-draw - [ ] Double check if PvP obstacles should allow card re-draw
+2 -1
View File
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can // - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing. // see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 32; export const VERSION = 33;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [ export const CHANGELOG = [
{ version: 33, date: '2026-09-04', changes: ['Hand sizes compare ranks across the crew, including Pi-Rats whose players are the Deep.', 'Discarded cards return to the deck at scene setup. Only the previous Deep can refresh their hand during upkeep, once per scene.'] },
{ version: 32, date: '2026-09-04', changes: ['Obstacles are automatically discarded with their columns once they reach one success per player. Unfinished obstacles carry over between scenes.'] }, { version: 32, date: '2026-09-04', changes: ['Obstacles are automatically discarded with their columns once they reach one success per player. Unfinished obstacles carry over between scenes.'] },
{ version: 31, date: '2026-09-04', changes: ['Change your rank-up vote until everyone has voted. The final vote now reveals the result and advances automatically, without a Ready button.', 'The vote result stays visible through upkeep and next-scene setup.'] }, { version: 31, date: '2026-09-04', changes: ['Change your rank-up vote until everyone has voted. The final vote now reveals the result and advances automatically, without a Ready button.', 'The vote result stays visible through upkeep and next-scene setup.'] },
{ version: 30, date: '2026-09-04', changes: ['The crew roster shows voting progress with icons.', 'Your submitted vote now shows the crewmate you nominated.'] }, { version: 30, date: '2026-09-04', changes: ['The crew roster shows voting progress with icons.', 'Your submitted vote now shows the crewmate you nominated.'] },
+5 -13
View File
@@ -92,13 +92,10 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player], capt
- Middle Rank Pi-Rat(s): max 3 cards - Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards - Lowest Rank Pi-Rat(s): max 2 cards
If everyone shares a Rank, they are all 'highest' and get 4 cards. If everyone shares a Rank, they are all 'highest' and get 4 cards.
Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size Compare the crew's Pi-Rats regardless of who currently plays the Deep.
only matters for the between-scenes redraw) are ranked against all players. Pending recruits do not yet have a Pi-Rat to compare.
""" """
if player.role == "deep": pool = [p for p in players_in_scene if not p.needs_reroll]
pool = players_in_scene
else:
pool = [p for p in players_in_scene if p.role != "deep"]
if not pool: if not pool:
pool = [player] pool = [player]
@@ -208,19 +205,14 @@ def reshuffle_discard_pile(db: Session, game: Game):
db.commit() db.commit()
def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]: def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -> List[str]:
"""Draws count cards from the deck for a player, reshuffling if necessary.""" """Draw from the remaining deck; discards return only at scene setup."""
deck = get_game_deck(game) deck = get_game_deck(game)
hand = get_player_hand(player) hand = get_player_hand(player)
drawn = [] drawn = []
for _ in range(count): for _ in range(count):
if not deck: if not deck:
# Reshuffle discard pile break
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
if not deck:
# If still empty (all 54 cards are in hands or active), we can't draw
break
card = deck.pop(0) card = deck.pop(0)
hand.append(card) hand.append(card)
drawn.append(card) drawn.append(card)
+4 -7
View File
@@ -197,17 +197,14 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
if not game: if not game:
return return
# 1. Discard cards if game.phase != "deep_upkeep" or player.previous_role != "deep" or player.is_ready:
return
# Discards stay out of the deck until the next scene's shuffle.
hand = get_player_hand(player) hand = get_player_hand(player)
deck = get_game_deck(game)
for card in discard_cards: for card in discard_cards:
if card in hand: if card in hand:
hand.remove(card) hand.remove(card)
deck.append(card)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(player, hand) set_player_hand(player, hand)
db.add(game) db.add(game)
db.add(player) db.add(player)
+41 -7
View File
@@ -512,6 +512,7 @@ def test_transition_to_deep_upkeep(session):
def test_confirm_deep_refresh(session): def test_confirm_deep_refresh(session):
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
game.phase = "deep_upkeep"
p1.previous_role = "deep" p1.previous_role = "deep"
p1.role = "deep" p1.role = "deep"
p1.rank = 2 p1.rank = 2
@@ -548,11 +549,11 @@ def test_confirm_deep_refresh(session):
assert "7H" in hand assert "7H" in hand
assert len(hand) == 4 assert len(hand) == 4
# Discarded cards should be at the end of the deck # Discarded cards stay out until scene setup
deck = json.loads(game.deck_cards) deck = json.loads(game.deck_cards)
assert "2C" in deck assert "2C" not in deck
assert "3C" in deck assert "3C" not in deck
assert deck == ["8H", "2C", "3C"] assert deck == ["8H"]
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup! # Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
assert game.phase == "scene_setup" assert game.phase == "scene_setup"
@@ -1184,11 +1185,11 @@ def test_vote_skips_when_only_dead_options(session):
def test_rank_up_draws_immediately(session): def test_rank_up_draws_immediately(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4) # p2 starts lowest. Reaching rank 3 ties the Deep for highest.
hand_before = len(crud.get_player_hand(p2)) hand_before = len(crud.get_player_hand(p2))
crud.toggle_objective(session, game.id, p2.id, "personal_1", True) crud.change_player_rank(session, game, p2, 2)
session.refresh(p2) session.refresh(p2)
assert p2.rank == 2 assert p2.rank == 3
# p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately # p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately
assert len(crud.get_player_hand(p2)) == hand_before + 2 assert len(crud.get_player_hand(p2)) == hand_before + 2
@@ -2692,3 +2693,36 @@ def test_setup_joker_only_increases_following_scene(session, monkeypatch):
assert len(game.obstacles) == 2 assert len(game.obstacles) == 2
assert game.extra_obstacles == 1 assert game.extra_obstacles == 1
assert crud.get_game_deck(game) == ["4H"] assert crud.get_game_deck(game) == ["4H"]
def test_hand_sizes_use_crew_ranks_and_ignore_role_changes(session):
game, deep, (low, middle) = make_scene_game(session, num_pirats=2)
assert [crud.calculate_max_hand_size(p, game.players) for p in (low, middle, deep)] == [2, 3, 4]
low.role, deep.role = "deep", "pirat"
assert [crud.calculate_max_hand_size(p, game.players) for p in (low, middle, deep)] == [2, 3, 4]
def test_draw_stops_at_empty_deck_without_recycling(session):
game, deep, (rat,) = make_scene_game(session)
rat.hand_cards = '[]'
game.deck_cards = '["AH"]'
session.commit()
assert crud.draw_cards_for_player(session, game, rat, 3) == ["AH"]
assert crud.get_player_hand(rat) == ["AH"]
assert crud.get_game_deck(game) == []
def test_deep_refresh_does_not_recycle_discards_or_allow_pirats(session):
game, deep, (rat,) = make_scene_game(session)
game.phase = "deep_upkeep"
deep.hand_cards = '["KC"]'
rat.hand_cards = '["QC"]'
game.deck_cards = '["AH", "2H", "3H", "4H"]'
session.commit()
crud.confirm_deep_refresh(session, rat.id, ["QC"])
assert crud.get_player_hand(rat) == ["QC"]
crud.confirm_deep_refresh(session, deep.id, ["KC"])
assert crud.get_player_hand(deep) == ["AH", "2H", "3H", "4H"]
assert "KC" not in crud.get_game_deck(game)
crud.confirm_deep_refresh(session, deep.id, ["AH"])
assert "AH" in crud.get_player_hand(deep)