diff --git a/TODO.md b/TODO.md
index fb669bd..c9f27f0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -20,7 +20,7 @@
## Rules
- [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules
-- [x] 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 — replacements arrive after resolution; one card per applied obstacle.
- [x] Assisting other pi-rats should only be available if there are multiple obstacles in the current challenge
- [x] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep
- [x] Double check if PvP obstacles should allow card re-draw — only the defender, on a suit-color match (including failure).
diff --git a/frontend/src/components/scene/ChallengePanel.svelte b/frontend/src/components/scene/ChallengePanel.svelte
index 64424cd..4709fb0 100644
--- a/frontend/src/components/scene/ChallengePanel.svelte
+++ b/frontend/src/components/scene/ChallengePanel.svelte
@@ -127,8 +127,8 @@
{:else}
{state.obstacles.filter(o => chObstacleIds.includes(o.id)).length > 1
- ? 'Other Pi-Rats may assist. Assistants do not draw replacement cards.'
- : 'Only the attempting Pi-Rat can play against this single Obstacle.'}
+ ? 'Other Pi-Rats may assist with one card each. Only the attempting Pi-Rat draws replacements after resolution.'
+ : 'Only the attempting Pi-Rat can play against this single Obstacle. Matching colors earn a replacement after resolution.'}
{#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)}
diff --git a/frontend/src/components/scene/ObstacleItem.svelte b/frontend/src/components/scene/ObstacleItem.svelte
index d528fd5..b679dac 100644
--- a/frontend/src/components/scene/ObstacleItem.svelte
+++ b/frontend/src/components/scene/ObstacleItem.svelte
@@ -25,6 +25,7 @@
$: challenge = openChallenges.find(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
$: canPlay = state.player.role === 'pirat' && !state.player.is_dead && !state.player.needs_reroll
&& challenge && challenge.tax_state !== 'requested'
+ && !JSON.parse(challenge.plays || '[]').some(p => p.obstacle_id === obs.id)
&& (challenge.acting_player_id === state.player.id
|| state.obstacles.filter(o => JSON.parse(challenge.obstacle_ids || '[]').includes(o.id)).length > 1);
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js
index 23faeb6..62a7ae7 100644
--- a/frontend/src/lib/changelog.js
+++ b/frontend/src/lib/changelog.js
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
-export const VERSION = 36;
+export const VERSION = 37;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [
+ { version: 37, date: '2026-09-04', changes: ['Color-match replacement cards arrive after the Deep resolves a Challenge. Each applied Obstacle accepts one card per Challenge, and each assistant can contribute one card.'] },
{ version: 36, date: '2026-09-04', changes: ['Duel instructions clarify that only the defender draws a replacement for matching the Obstacle’s color, even when the defense fails.'] },
{ version: 35, date: '2026-09-04', changes: ['Personal objectives now require a group vote. Propose the next objective from a character sheet, then vote Yes or No at the table. A majority approves; the Captain breaks tied votes.'] },
{ version: 34, date: '2026-09-04', changes: ['Assistance is available only when a Challenge has multiple active Obstacles. Gat and Name Tax takeovers still work against a single Obstacle.'] },
diff --git a/src/pirats/crud_challenge.py b/src/pirats/crud_challenge.py
index e5b906d..3f712ec 100644
--- a/src/pirats/crud_challenge.py
+++ b/src/pirats/crud_challenge.py
@@ -126,6 +126,16 @@ def play_challenge_card(
if player.id != challenge.acting_player_id and len(active_ids) < 2:
return False, "Assistance requires multiple Obstacles in the current Challenge.", {}
+ plays = json.loads(challenge.plays)
+ if any(p["obstacle_id"] == obstacle_id for p in plays):
+ return False, "A card has already been played against this Obstacle in this Challenge.", {}
+ if player.id != challenge.acting_player_id:
+ if any(p["player_id"] == player.id for p in plays):
+ return False, "You may assist with one card per Challenge.", {}
+ if any(c.status == "open" and c.id != challenge.id
+ and player.id in (c.target_player_id, c.acting_player_id) for c in game.challenges):
+ return False, "Resolve your own Challenge before assisting another Pi-Rat.", {}
+
# An Obstacle beaten as many times as there are players is spent
played_list = json.loads(obstacle.played_cards)
current_successes = sum(1 for c in played_list if c.get("success") is True)
@@ -145,29 +155,26 @@ def play_challenge_card(
acting = get_player(db, challenge.acting_player_id) or player
result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank)
- # Record the play on the Challenge
- plays = json.loads(challenge.plays)
+ # Remember draw eligibility even if this play discards the completed Obstacle.
+ draw_match = player.id == challenge.acting_player_id and cards.match_suit_color(card_code, obstacle.original_card)
plays.append({
"obstacle_id": obstacle_id,
"card": card_code,
"player_id": player.id,
"player_name": player.name,
"success": result["success"],
- "details": result["details"]
+ "details": result["details"],
+ "draw_match": draw_match,
})
challenge.plays = json.dumps(plays)
db.add(challenge)
db.commit()
- # Step 6: Draw back on suit-color match — only the attempting Pi-Rat draws.
+ # Step 6 follows resolution, so replacements cannot be played in this Challenge.
drew_card = None
- if player.id == challenge.acting_player_id:
- if cards.match_suit_color(card_code, obstacle.original_card):
- drawn = draw_cards_for_player(db, game, player, 1)
- if drawn:
- drew_card = drawn[0]
- result["details"] += " (Drew a card back due to matching suit colors!)"
- else:
+ if draw_match:
+ result["details"] += " (Draw a replacement when the Challenge resolves.)"
+ elif player.id != challenge.acting_player_id:
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
@@ -213,6 +220,12 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!", kind="challenge")
+ # Older saved plays already received their immediate draws; missing flags do not redraw.
+ draw_count = sum(1 for p in plays if p.get("draw_match") and p["player_id"] == acting.id)
+ if draw_count:
+ drawn = draw_cards_for_player(db, game, acting, draw_count)
+ add_game_event(db, game.id, f"{acting.name} draws {len(drawn)} replacement card(s) for matching Obstacle colors.", kind="card")
+
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
if challenge.tax_state == "refused" and challenge.tax_target_id:
refuser = get_player(db, challenge.tax_target_id)
@@ -282,6 +295,8 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
return False, "Only the challenged Pi-Rat can call a Gat/Name Tax."
if challenge.tax_state is not None:
return False, "A Tax has already been called on this Challenge."
+ if json.loads(challenge.plays):
+ return False, "Call a Tax before any cards are played."
if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
diff --git a/tests/test_game.py b/tests/test_game.py
index ef86306..817042d 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -216,21 +216,14 @@ def test_scene_start_and_challenges(session):
# Obstacle current value should update to 10
assert obs.current_value == 10
- # Check if hand size matches card draw rule
- # 10D was played (-1 card). If colors matched, drew 1 back (+1 card).
- hand = crud.get_player_hand(p2)
- if orig_is_red:
- assert len(hand) == 4
- assert res["drew_card"] is not None
- else:
- assert len(hand) == 3
- assert res["drew_card"] is None
-
- # The Deep resolves the challenge: at least one success -> succeeded
+ # Replacements wait until the Deep resolves the Challenge.
+ assert len(crud.get_player_hand(p2)) == 3
+ assert res["drew_card"] is None
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(challenge)
assert challenge.status == "succeeded"
+ assert len(crud.get_player_hand(p2)) == (4 if orig_is_red else 3)
def test_assistant_does_not_draw(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
@@ -2855,3 +2848,41 @@ def test_pvp_redraw_depends_on_color_not_success(session, defense, success, redr
assert crud.get_player_hand(attacker) == []
assert crud.get_player_hand(defender) == (['AD'] if redraw else [])
assert not crud.play_pvp_defense(session, duel.id, defender.id, 'AD')[0]
+
+
+def test_challenge_draw_waits_for_resolution_and_cannot_repeat(session):
+ game, deep, (rat, helper) = make_scene_game(session, num_pirats=2)
+ obs = game.obstacles[0]
+ obs.original_card, obs.suit, obs.current_value = '7C', 'C', 7
+ rat.hand_cards = '["2S", "9S"]'
+ game.deck_cards = '["AD"]'
+ session.commit()
+ assert crud.create_challenge(session, game.id, deep.id, rat.id, [obs.id])[0]
+ challenge = game.challenges[0]
+ assert crud.play_challenge_card(session, rat.id, obs.id, '2S')[0]
+ assert crud.get_player_hand(rat) == ['9S']
+ assert not crud.play_challenge_card(session, rat.id, obs.id, '9S')[0]
+ assert not crud.request_tax(session, challenge.id, rat.id, helper.id)[0]
+ assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
+ assert challenge.status == 'failed'
+ assert crud.get_player_hand(rat) == ['9S', 'AD']
+ assert not crud.resolve_challenge(session, challenge.id, deep.id)[0]
+ assert crud.get_player_hand(rat) == ['9S', 'AD']
+
+
+def test_completed_obstacle_still_awards_deferred_draw(session):
+ game, deep, (rat,) = make_scene_game(session)
+ obs = game.obstacles[0]
+ obs.original_card = '7C'
+ obs.played_cards = '[{"card":"JC", "success":true}]'
+ rat.hand_cards = '["QS"]'
+ game.deck_cards = '["AD"]'
+ session.commit()
+ assert crud.create_challenge(session, game.id, deep.id, rat.id, [obs.id])[0]
+ challenge = game.challenges[0]
+ oid = obs.id
+ assert crud.play_challenge_card(session, rat.id, oid, 'QS')[0]
+ assert session.get(Obstacle, oid) is None
+ assert crud.get_player_hand(rat) == []
+ assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
+ assert crud.get_player_hand(rat) == ['AD']