Defer challenge redraws until resolution and limit card plays
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin-top: 0.5rem;">{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.'}</p>
|
||||
? '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.'}</p>
|
||||
<div class="challenge-obstacles">
|
||||
{#each chObstacleIds as oid}
|
||||
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||
|
||||
@@ -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] || ''}`;
|
||||
|
||||
@@ -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.'] },
|
||||
|
||||
@@ -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:
|
||||
|
||||
+41
-10
@@ -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
|
||||
# Replacements wait until the Deep resolves the Challenge.
|
||||
assert len(crud.get_player_hand(p2)) == 3
|
||||
assert res["drew_card"] is None
|
||||
|
||||
# The Deep resolves the challenge: at least one success -> succeeded
|
||||
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']
|
||||
|
||||
Reference in New Issue
Block a user