Fable will fix it!

This commit is contained in:
2026-06-11 15:57:08 -07:00
parent 2af98a3eeb
commit 443acd8400
23 changed files with 2151 additions and 557 deletions

View File

@@ -104,60 +104,74 @@ def test_character_creation_and_swap(session):
assert "deep" in roles
assert "pirat" in roles
def test_scene_start_and_challenges(session):
def make_scene_game(session, num_pirats=1):
"""Helper: a game in scene phase with 1 Deep player and num_pirats Pi-Rats."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
# Bypass char creation for quick scene test
p1.rank = 3
p1.role = "deep"
p1.previous_role = "deep"
p2.rank = 1
p2.role = "pirat"
p2.previous_role = "pirat"
session.add_all([p1, p2])
deep = crud.add_player(session, game.id, "Deep1", is_creator=True)
deep.rank = 3
deep.role = "deep"
deep.previous_role = "deep"
session.add(deep)
pirats = []
for i in range(num_pirats):
p = crud.add_player(session, game.id, f"Rat{i+1}")
p.rank = 1 if i == 0 else 2
p.role = "pirat"
p.previous_role = "pirat"
session.add(p)
pirats.append(p)
session.commit()
# Confirm setup (1 Deep, 1 Pi-Rat)
success, msg = crud.confirm_scene_setup(session, game.id)
assert success
# Stub shuffle so the rebuilt deck keeps its construction order (Jokers last)
# and no Joker can be drawn as an Obstacle, which would bump extra_obstacles
import random
orig_shuffle = random.shuffle
random.shuffle = lambda x: None
try:
success, msg = crud.confirm_scene_setup(session, game.id)
finally:
random.shuffle = orig_shuffle
assert success, msg
session.refresh(game)
return game, deep, pirats
def test_scene_start_and_challenges(session):
game, deep, (p2,) = make_scene_game(session)
assert game.phase == "scene"
assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles
obs_list = game.obstacles
obs = obs_list[0]
obs = game.obstacles[0]
# Give p2 a known hand for testing
p2.hand_cards = json.dumps(["10D", "2H", "JS", "Joker1"])
session.add(p2)
session.commit()
# Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10.
# Set obstacle value to 5
obs.current_value = 5
session.add(obs)
session.add_all([p2, obs])
session.commit()
# Play 10D (red) on obstacle. Let's check original obstacle color.
# If original obstacle was red, we expect a card draw.
# Playing a card without an open Challenge must be rejected
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
assert not ok
assert "Challenge" in msg
# The Deep calls a Challenge applying the obstacle
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
assert ok, msg
session.refresh(game)
challenge = game.challenges[0]
assert challenge.status == "open"
assert challenge.acting_player_id == p2.id
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
deck_before = len(crud.get_game_deck(game))
ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D")
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
assert ok
assert res["success"]
assert "Success" in res["details"]
session.refresh(p2)
session.refresh(obs)
# 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)
@@ -168,27 +182,48 @@ def test_scene_start_and_challenges(session):
assert len(hand) == 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"
def test_assistant_does_not_draw(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 5
# Helper card guaranteed to match the obstacle's color
helper_card = "9D" if cards.parse_card(obs.original_card)["color"] == "red" else "9S"
p3.hand_cards = json.dumps([helper_card])
session.add_all([obs, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
# p3 assists: plays a color-matching card but must NOT draw back
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card)
assert ok
assert res["success"]
assert res["drew_card"] is None
session.refresh(p3)
assert crud.get_player_hand(p3) == []
def test_secret_technique_auto_success(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.role = "pirat"
p1.rank = 2
game, deep, (p1,) = make_scene_game(session)
obs = game.obstacles[0]
p1.tech_jack = "Pocket Sand"
p1.hand_cards = json.dumps(["JS"])
session.add(p1)
obs = Obstacle(
game_id=game.id,
original_card="10C",
suit="C",
title="Knights",
current_value=10
)
session.add(obs)
obs.current_value = 10
session.add_all([p1, obs])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p1.id, [obs.id])
assert ok
# Play Jack. J is a face card -> auto success!
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS")
ok, msg, res = crud.play_challenge_card(session, p1.id, obs.id, "JS")
assert ok
assert res["success"]
assert res["is_technique"]
@@ -204,12 +239,12 @@ def test_joker_play(session):
deck.remove("10C")
game.deck_cards = json.dumps(deck)
session.add(game)
p1 = crud.add_player(session, game.id, "P1")
p1.role = "pirat"
p1.hand_cards = json.dumps(["Joker1"])
session.add(p1)
obs = Obstacle(
game_id=game.id,
original_card="10C",
@@ -219,16 +254,18 @@ def test_joker_play(session):
)
session.add(obs)
session.commit()
# Play Joker
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1")
# Play Joker: discards the obstacle and draws a replacement
ok, msg = crud.play_joker(session, p1.id, obs.id, "Joker1")
assert ok
assert res["is_joker"]
# Verify obstacle was deleted and replaced
session.refresh(game)
assert len(game.obstacles) == 1
assert game.obstacles[0].original_card != "10C"
# Joker left the player's hand
session.refresh(p1)
assert crud.get_player_hand(p1) == []
def test_obstacle_success_count(session):
game = crud.create_game(session)
@@ -249,23 +286,77 @@ def test_obstacle_success_count(session):
assert obs.success_count == 2
def test_non_deep_player_treatment(session):
def test_captaincy_and_hand_sizes(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "Captain Barnaby")
p2 = crud.add_player(session, game.id, "Crewmate Pip")
# p1 is deep, p2 is None (non-deep)
p3 = crud.add_player(session, game.id, "Lowly Larry")
p1.role = "deep"
p2.role = None
session.add_all([p1, p2])
p1.rank = 2
p2.role = "pirat"
p2.rank = 2
p3.role = "pirat"
p3.rank = 1
session.add_all([p1, p2, p3])
session.commit()
# Check captain status: p2 should be captain because p2 is the only non-deep player
assert crud.is_player_captain(p2, game.players)
assert not crud.is_player_captain(p1, game.players)
# Check hand size: p2 should participate in hand size calculations and get Captain privileges (+1)
assert crud.calculate_max_hand_size(p2, game.players) == 4
# No captain until the crew controls a ship
assert game.captain_player_id is None
assert not crud.is_player_captain(p2, game)
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 2
# If everyone shares a Rank, they are all 'highest' -> 4 cards
p3.rank = 2
session.add(p3)
session.commit()
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
# Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain
crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
session.refresh(game)
assert game.captain_player_id in (p2.id, p3.id)
captain = crud.get_player(session, game.captain_player_id)
assert crud.is_player_captain(captain, game)
# Captain privilege: +1 hand size
assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5
# Losing the ship vacates the Captaincy
crud.toggle_objective(session, game.id, p1.id, "crew_1", False)
session.refresh(game)
assert game.captain_player_id is None
def test_captain_tax_on_failed_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p3 (rank 2) becomes captain
crud.set_captain(session, game, p3.id)
session.refresh(game)
assert game.captain_player_id == p3.id
start_rank = p3.rank
obs = game.obstacles[0]
obs.current_value = 13
p3.hand_cards = json.dumps(["2C"])
session.add_all([obs, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p3.id, [obs.id])
assert ok
session.refresh(game)
challenge = [c for c in game.challenges if c.status == "open"][0]
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, "2C")
assert ok
assert not res["success"]
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p3)
# Captain Tax: lost a rank for personally failing a Challenge
assert p3.rank == start_rank - 1
def test_set_role_endpoint():
from fastapi.testclient import TestClient
@@ -389,22 +480,24 @@ def test_confirm_deep_refresh(session):
session.refresh(p1)
session.refresh(game)
# Hand should not contain 2C and 3C, and should have drawn 5H and 6H
# Hand should not contain 2C and 3C, and should have drawn back up to max
# hand size (4: the only player counts as highest rank)
hand = json.loads(p1.hand_cards)
assert "2C" not in hand
assert "3C" not in hand
assert "4C" in hand
assert "5H" in hand
assert "6H" in hand
assert len(hand) == 3
assert "7H" in hand
assert len(hand) == 4
# Discarded cards should be at the end of the deck
deck = json.loads(game.deck_cards)
assert "2C" in deck
assert "3C" in deck
assert deck == ["7H", "8H", "2C", "3C"]
assert deck == ["8H", "2C", "3C"]
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
assert game.phase == "scene_setup"
@@ -564,8 +657,8 @@ def test_roll_new_character(session):
assert p1.first_words == "No fear!"
assert p1.good_at_math == "Yes"
# Verify rank randomized between 1-3
assert p1.rank in [1, 2, 3]
# New recruits always start at Rank 1
assert p1.rank == 1
# Verify name is "Recruit Spiced Rum"
assert p1.name == "Recruit Spiced Rum"
@@ -588,4 +681,331 @@ def test_roll_new_character(session):
assert "3D" in deck or "3D" in hand
assert "4H" in deck or "4H" in hand
def test_gat_tax_accept(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
# p2 is gatless; p3 has a gat
p3.completed_personal_1 = True
p2.hand_cards = json.dumps(["2C", "3C"])
p3.hand_cards = json.dumps(["10C"])
session.add_all([p2, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok, msg
session.refresh(challenge)
assert challenge.tax_state == "requested"
assert challenge.tax_type == "gat"
# Plays are blocked while the tax is pending
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C")
assert not ok
ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=True)
assert ok
session.refresh(challenge)
session.refresh(p2)
session.refresh(p3)
# The gatted pi-rat takes over and received one random card from the requester
assert challenge.acting_player_id == p3.id
assert len(crud.get_player_hand(p2)) == 1
assert len(crud.get_player_hand(p3)) == 2
def test_gat_tax_refuse_and_fail(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 13
p3.completed_personal_1 = True
p2.hand_cards = json.dumps(["2C"])
session.add_all([obs, p2, p3])
session.commit()
p2_start_rank = p2.rank
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok
ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=False)
assert ok
session.refresh(p2)
session.refresh(p3)
# Refusal: the gat changes paws immediately, requester completes Objective 1 (+1 Rank)
assert p2.completed_personal_1
assert not p3.completed_personal_1
assert p2.rank == p2_start_rank + 1
# The requester attempts the challenge themselves... and fails
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C")
assert ok
assert not res["success"]
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p2)
session.refresh(p3)
# Failure: the gat goes back, the rank reverts, and no more taxes this scene
assert not p2.completed_personal_1
assert p3.completed_personal_1
assert p2.rank == p2_start_rank
assert p2.tax_banned
def test_pvp_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
p2.hand_cards = json.dumps(["7C"])
p3.hand_cards = json.dumps(["9C"])
session.add_all([p2, p3])
session.commit()
ok, msg = crud.create_pvp_challenge(session, game.id, p2.id, p3.id, "7C")
assert ok, msg
session.refresh(p2)
assert crud.get_player_hand(p2) == [] # temp obstacle card left the hand
session.refresh(game)
duel = [c for c in game.challenges if c.challenge_type == "pvp"][0]
assert duel.temp_card == "7C"
# Defender beats 7 with 9; matching color (both black) -> draws a card back
ok, msg, res = crud.play_pvp_defense(session, duel.id, p3.id, "9C")
assert ok
assert res["success"]
assert res["drew_card"] is not None
session.refresh(duel)
assert duel.status == "succeeded"
def test_obstacles_persist_across_scenes(session):
game, deep, (p2,) = make_scene_game(session)
assert len(game.obstacles) == 2
surviving_ids = {o.id for o in game.obstacles}
# End the scene, vote, and set up scene 2 with swapped roles
crud.end_scene_and_transition(session, game.id)
crud.transition_to_deep_upkeep(session, game.id)
crud.confirm_deep_refresh(session, deep.id, [])
session.refresh(game)
assert game.phase == "scene_setup"
assert game.current_scene_number == 2
deep.role = "pirat"
p2.role = "deep"
session.add_all([deep, p2])
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert success, msg
session.refresh(game)
# Unbeaten obstacles remain; the list is already at Deep(1)+1=2, so nothing new is drawn
assert {o.id for o in game.obstacles} == surviving_ids
def test_joker_obstacle_increase_is_permanent(session):
game = crud.create_game(session)
deep = crud.add_player(session, game.id, "Deep1")
deep.rank = 3
deep.role = "deep"
p2 = crud.add_player(session, game.id, "Rat1")
p2.rank = 1
p2.role = "pirat"
game.extra_obstacles = 1 # a Joker was drawn as an Obstacle earlier
session.add_all([game, deep, p2])
session.commit()
# Stub shuffle so the rebuilt deck keeps its construction order (Jokers last)
# and no Joker can be drawn as an Obstacle here
import random
orig_shuffle = random.shuffle
random.shuffle = lambda x: None
try:
success, msg = crud.confirm_scene_setup(session, game.id)
finally:
random.shuffle = orig_shuffle
assert success, msg
session.refresh(game)
# Deep(1) + 1 + permanent increase(1) = 3 obstacles
assert len(game.obstacles) == 3
# The increase is permanent, not consumed
assert game.extra_obstacles == 1
def test_first_scene_role_choice(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
p1.rank = 3
p2.rank = 1
p3.rank = 2
session.add_all([p1, p2, p3])
session.commit()
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either
assert crud.get_allowed_roles(session, game.id, p1.id) == ["deep"]
assert crud.get_allowed_roles(session, game.id, p2.id) == ["pirat"]
assert set(crud.get_allowed_roles(session, game.id, p3.id)) == {"pirat", "deep"}
# A Rank 2 player choosing Deep alongside the Rank 3 player is legal
p1.role = "deep"
p2.role = "pirat"
p3.role = "deep"
session.add_all([p1, p2, p3])
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert success, msg
def test_vote_validation(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
p1.role = "deep"
p2.role = "pirat"
p3.role = "pirat"
session.add_all([p1, p2, p3])
session.commit()
# Self-nomination is rejected
ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p2.id)
assert not ok
# Nominating a previous-scene Deep player is rejected
ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p1.id)
assert not ok
# But the Deep player may vote
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id)
assert ok
def test_rank_up_draws_immediately(session):
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)
hand_before = len(crud.get_player_hand(p2))
crud.toggle_objective(session, game.id, p2.id, "personal_1", True)
session.refresh(p2)
assert p2.rank == 2
# p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately
assert len(crud.get_player_hand(p2)) == hand_before + 2
def test_auto_delegate_all(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
session.refresh(game)
crud.auto_delegate_all(session, game)
players = [p1, p2, p3]
for p in players:
session.refresh(p)
assert p.other_like_from_player_id and p.other_like_from_player_id != p.id
assert p.other_hate_from_player_id and p.other_hate_from_player_id != p.id
# With 3+ players, like and hate go to different crewmates
assert p.other_like_from_player_id != p.other_hate_from_player_id
# Workload is even: each player answers exactly one like and one hate
like_answerers = sorted(p.other_like_from_player_id for p in players)
hate_answerers = sorted(p.other_hate_from_player_id for p in players)
assert like_answerers == sorted(p.id for p in players)
assert hate_answerers == sorted(p.id for p in players)
def test_auto_delegate_all_two_players(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
session.refresh(game)
crud.auto_delegate_all(session, game)
session.refresh(p1)
session.refresh(p2)
assert p1.other_like_from_player_id == p2.id
assert p1.other_hate_from_player_id == p2.id
assert p2.other_like_from_player_id == p1.id
assert p2.other_hate_from_player_id == p1.id
def test_submit_techniques_rejects_collisions(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
# Duplicates within a single submission are rejected
err = crud.submit_techniques(session, p1.id, "Tail Whip", "tail whip", "Cheese Decoy")
assert err is not None
session.refresh(p1)
assert json.loads(p1.created_techniques) == []
# Valid submission goes through
err = crud.submit_techniques(session, p1.id, "Tail Whip", "Pocket Sand", "Cheese Decoy")
assert err is None
# A second player colliding (case-insensitively) with the pool is rejected
err = crud.submit_techniques(session, p2.id, "POCKET SAND", "Bilge Bomb", "Rat King Roar")
assert err is not None
session.refresh(p2)
assert json.loads(p2.created_techniques) == []
# And a unique set is accepted, triggering the swap
err = crud.submit_techniques(session, p2.id, "Plank Pirouette", "Bilge Bomb", "Rat King Roar")
assert err is None
session.refresh(game)
assert game.phase == "swap_techniques"
def test_roll_new_character_smell_name_normalization(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p1.is_dead = True
session.add(p1)
session.commit()
crud.roll_new_character(
session,
game.id,
p1.id,
avatar_look="Shiny eyes",
avatar_smell="suspiciously smelling of ink and sour lemons",
first_words="No fear!",
good_at_math="Yes"
)
session.refresh(p1)
assert p1.name == "Recruit Ink and sour lemons"
def test_roll_new_character_avoids_technique_collisions(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
# Give the surviving player techniques straight from the suggestion pool
p2.tech_jack = cards.TECHNIQUE_SUGGESTIONS[0]
p2.tech_queen = cards.TECHNIQUE_SUGGESTIONS[1]
p2.tech_king = cards.TECHNIQUE_SUGGESTIONS[2]
p1.is_dead = True
session.add_all([p1, p2])
session.commit()
# Shrink the pool so a collision would be guaranteed without the exclusion logic
import pirats.crud_character # noqa: F401 (module uses cards.TECHNIQUE_SUGGESTIONS)
original = cards.TECHNIQUE_SUGGESTIONS
cards.TECHNIQUE_SUGGESTIONS = original[:6]
try:
crud.roll_new_character(
session, game.id, p1.id,
avatar_look="Shiny eyes", avatar_smell="Spiced Rum",
first_words="No fear!", good_at_math="Yes"
)
finally:
cards.TECHNIQUE_SUGGESTIONS = original
session.refresh(p1)
recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king}
assert recruit_techs == set(original[3:6])