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']