- {#if p.tech_jack}
+ {#if p.needs_reroll}
+ Spectating
+ {:else if phase === 'swap_techniques'}
+ {#if p.is_ready}
+ Done Swapping
+ {:else}
+ {@const ownLeft = JSON.parse(p.held_techniques || '[]').filter(t => t.creator_id === p.id).length}
+ Swapping ({ownLeft} of their own left)
+ {/if}
+ {:else if p.tech_jack}
Ready
- {:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
+ {:else if phase === 'assign_techniques'}
Assigning Face Cards
+ {:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
+ Techniques Submitted
{:else}
Writing Sheet
{/if}
diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte
index dde72b1..2d25d57 100644
--- a/frontend/src/pages/Dashboard.svelte
+++ b/frontend/src/pages/Dashboard.svelte
@@ -94,6 +94,15 @@
}
}
+ // Admin escape hatch: hand out every technique randomly and auto-assign J/Q/K
+ async function autoAssignTechniques() {
+ try {
+ await apiRequest(`/game/${gameId}/player/${playerId}/auto-assign-techniques`, 'POST');
+ } catch (err) {
+ error = err.message;
+ }
+ }
+
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
$: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
@@ -107,13 +116,20 @@
action: toggleDevMode,
title: 'Toggle Dev Mode testing shortcuts for the whole table',
});
- if (state.game.dev_mode && (state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques')) {
+ if (state.game.dev_mode && ['character_creation', 'swap_techniques', 'assign_techniques'].includes(state.game.phase)) {
items.push({
label: '⏭️ Skip Character Creation',
action: skipCharacterCreation,
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
});
}
+ if (state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques') {
+ items.push({
+ label: '🎲 Auto-Assign Techniques',
+ action: autoAssignTechniques,
+ title: 'Randomly distribute all techniques to eligible players and assign them to J/Q/K',
+ });
+ }
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
}
extraMenuLinks.set(items);
@@ -138,7 +154,7 @@
{#if state.game.phase === 'lobby'}
- {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques'}
+ {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
{:else if state.game.phase === 'scene_setup'}
diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py
index 8e5d177..006c109 100644
--- a/src/pirats/crud_character.py
+++ b/src/pirats/crud_character.py
@@ -115,7 +115,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
return None
all_submitted = True
- for p in game.players:
+ for p in swap_participants(game):
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
all_submitted = False
@@ -125,28 +125,34 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
trigger_technique_swap(db, game)
return None
+def swap_participants(game: Game) -> list:
+ """Players taking part in initial character creation (late joiners with a
+ queued recruit re-roll spectate and are excluded)."""
+ return [p for p in game.players if not p.needs_reroll]
+
+def holds_own_technique(player) -> bool:
+ return any(t["creator_id"] == player.id for t in json.loads(player.held_techniques))
+
def trigger_technique_swap(db: Session, game: Game):
"""
- Shuffles and distributes 3 techniques created by OTHER players to each player.
+ All techniques are in: everyone enters the swap phase holding their own 3
+ creations (tagged with their creator) and must trade them all away through
+ blind swap offers before the game moves on to face-card assignment.
"""
- players = game.players
+ players = swap_participants(game)
if not players:
return
-
- # Build a pool of all techniques with their creators
- pool = []
+
+ # Reject the pool if any two techniques collide (case-insensitively)
seen_texts = set()
has_duplicates = False
-
for p in players:
- techs = json.loads(p.created_techniques)
- for t in techs:
+ for t in json.loads(p.created_techniques):
t_clean = t.strip().lower()
if t_clean in seen_texts:
has_duplicates = True
seen_texts.add(t_clean)
- pool.append({"text": t, "creator_id": p.id})
-
+
if has_duplicates:
# Force players to resubmit
for p in players:
@@ -155,79 +161,244 @@ def trigger_technique_swap(db: Session, game: Game):
db.commit()
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.", kind="warning")
return
-
- # Swap algorithm with simple retry backtracking
- # We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
- # For a small number of players, random shuffling with retries is highly efficient.
- success = False
- for attempt in range(200):
- random.shuffle(pool)
- assignments = {p.id: [] for p in players}
- temp_pool = pool.copy()
-
- # Greedy assignment
- failed = False
+
+ for p in players:
+ p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
+ p.swapped_techniques = "[]"
+ p.swap_offer_to_id = None
+ p.swap_offer_technique = ""
+ p.is_ready = False
+ db.add(p)
+ game.phase = "swap_techniques"
+ db.add(game)
+ db.commit()
+ add_game_event(db, game.id, "Phase changed: Swap Techniques — trade away all 3 of your own techniques with blind swap offers!", kind="phase")
+
+def offer_swap(db: Session, player_id: str, target_player_id: str, technique: str):
+ """Offers one held technique to another player as a blind swap. The target
+ learns only that an offer exists, never which technique is on the table."""
+ player = get_player(db, player_id)
+ target = get_player(db, target_player_id)
+ if not player or not target or player.game_id != target.game_id:
+ return False, "Player not found."
+ game = get_game(db, player.game_id)
+ if not game or game.phase != "swap_techniques":
+ return False, "Technique swapping isn't in progress."
+ if player.id == target.id:
+ return False, "You can't swap techniques with yourself."
+ if target.needs_reroll:
+ return False, "That player is spectating until recruit creation."
+ if player.swap_offer_to_id:
+ return False, "You already have a swap offer pending. Cancel it first."
+ held = json.loads(player.held_techniques)
+ entry = next((t for t in held if t["text"] == technique), None)
+ if not entry:
+ return False, "You don't hold that technique."
+ if entry["creator_id"] == target.id:
+ return False, "You can't offer a technique back to its creator."
+ player.swap_offer_to_id = target.id
+ player.swap_offer_technique = technique
+ db.add(player)
+ db.commit()
+ add_game_event(db, game.id, f"{player.name} offered a mystery technique swap to {target.name}.", kind="info")
+ return True, "Swap offered."
+
+def cancel_swap_offer(db: Session, player_id: str):
+ player = get_player(db, player_id)
+ if not player:
+ return False, "Player not found."
+ if not player.swap_offer_to_id:
+ return False, "You have no pending swap offer."
+ target = get_player(db, player.swap_offer_to_id)
+ player.swap_offer_to_id = None
+ player.swap_offer_technique = ""
+ db.add(player)
+ db.commit()
+ if target:
+ add_game_event(db, player.game_id, f"{player.name} withdrew their swap offer to {target.name}.", kind="info")
+ return True, "Offer withdrawn."
+
+def respond_swap_offer(db: Session, player_id: str, offerer_id: str, accept: bool, technique: str = ""):
+ """Accepts (trading back one of your held techniques) or declines a swap
+ offer. On accept the two techniques change hands, creators and all."""
+ player = get_player(db, player_id)
+ offerer = get_player(db, offerer_id)
+ if not player or not offerer or player.game_id != offerer.game_id:
+ return False, "Player not found."
+ game = get_game(db, player.game_id)
+ if not game or game.phase != "swap_techniques":
+ return False, "Technique swapping isn't in progress."
+ if offerer.swap_offer_to_id != player.id:
+ return False, "That player hasn't offered you a swap."
+
+ if not accept:
+ offerer.swap_offer_to_id = None
+ offerer.swap_offer_technique = ""
+ db.add(offerer)
+ db.commit()
+ add_game_event(db, game.id, f"{player.name} declined {offerer.name}'s swap offer.", kind="info")
+ return True, "Offer declined."
+
+ my_held = json.loads(player.held_techniques)
+ my_entry = next((t for t in my_held if t["text"] == technique), None)
+ if not my_entry:
+ return False, "You don't hold that technique."
+ if my_entry["creator_id"] == offerer.id:
+ return False, "You can't trade a technique back to its creator."
+ their_held = json.loads(offerer.held_techniques)
+ their_entry = next((t for t in their_held if t["text"] == offerer.swap_offer_technique), None)
+ if not their_entry:
+ return False, "The offered technique is no longer available."
+
+ my_held.remove(my_entry)
+ my_held.append(their_entry)
+ their_held.remove(their_entry)
+ their_held.append(my_entry)
+ player.held_techniques = json.dumps(my_held)
+ offerer.held_techniques = json.dumps(their_held)
+ offerer.swap_offer_to_id = None
+ offerer.swap_offer_technique = ""
+ # If the technique we just gave away was promised to someone else, withdraw that offer
+ if player.swap_offer_technique == my_entry["text"]:
+ player.swap_offer_to_id = None
+ player.swap_offer_technique = ""
+ db.add(player)
+ db.add(offerer)
+ db.commit()
+ add_game_event(db, game.id, f"{player.name} and {offerer.name} swapped techniques!", kind="info")
+ return True, "Techniques swapped."
+
+def set_swap_ready(db: Session, player_id: str, ready: bool):
+ """Marks a player done (or not done) swapping. Readying up requires having
+ traded away all self-created techniques; once everyone is ready the game
+ moves on to face-card assignment."""
+ player = get_player(db, player_id)
+ if not player:
+ return False, "Player not found."
+ game = get_game(db, player.game_id)
+ if not game or game.phase != "swap_techniques":
+ return False, "Technique swapping isn't in progress."
+ if ready:
+ if holds_own_technique(player):
+ return False, "You still hold techniques you created. Swap them away first!"
+ if player.swap_offer_to_id:
+ return False, "You have a swap offer pending. Cancel it or wait for an answer first."
+ player.is_ready = ready
+ db.add(player)
+ db.commit()
+
+ participants = swap_participants(game)
+ if ready and participants and all(p.is_ready for p in participants):
+ finish_technique_swap(db, game)
+ return True, "Ready!" if ready else "No longer ready."
+
+def finish_technique_swap(db: Session, game: Game):
+ """Locks in everyone's held techniques and advances to J/Q/K assignment."""
+ for p in swap_participants(game):
+ p.swapped_techniques = json.dumps([t["text"] for t in json.loads(p.held_techniques)])
+ p.swap_offer_to_id = None
+ p.swap_offer_technique = ""
+ p.is_ready = False # Reset ready flag for the J/Q/K assignment step
+ db.add(p)
+ game.phase = "assign_techniques"
+ db.add(game)
+ db.commit()
+ add_game_event(db, game.id, "Phase changed: Assign Techniques — place your new techniques on your Jack, Queen, and King.", kind="phase")
+
+def auto_assign_techniques(db: Session, game: Game):
+ """
+ Admin escape hatch: randomly redistributes every created technique to an
+ eligible player (never its creator) and assigns them to J/Q/K, finishing
+ the whole technique distribution step in one click.
+ """
+ if game.phase not in ("swap_techniques", "assign_techniques"):
+ return False, "Technique distribution isn't in progress."
+
+ if game.phase == "swap_techniques":
+ players = swap_participants(game)
+ pool = []
for p in players:
- valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
- if len(valid_for_p) < 3:
- # If only 1-2 players are playing, it might be impossible to assign 3 non-owned techniques.
- # In that case, let's allow assigning their own if there are no other options (e.g. 1 player).
- if len(players) < 3:
- valid_for_p = temp_pool.copy()
- else:
- failed = True
- break
-
- # Take 3
- chosen = valid_for_p[:3]
- for c in chosen:
- assignments[p.id].append(c["text"])
- temp_pool.remove(c)
-
- if not failed:
- # Successfully matched! Save assignments.
+ for t in json.loads(p.created_techniques):
+ pool.append({"text": t, "creator_id": p.id})
+
+ # Random shuffling with retries: for a small table this finds a valid
+ # creator != assignee distribution almost immediately.
+ assignments = None
+ for attempt in range(200):
+ random.shuffle(pool)
+ candidate = {p.id: [] for p in players}
+ temp_pool = pool.copy()
+ failed = False
for p in players:
- p.swapped_techniques = json.dumps(assignments[p.id])
- p.is_ready = False # Reset ready flag for J/Q/K assignment step
- db.add(p)
- game.phase = "swap_techniques"
- db.add(game)
- db.commit()
- add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
- success = True
- break
-
- if not success:
- # Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever
+ valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
+ if len(valid_for_p) < 3:
+ # With 1-2 players a non-owned distribution can be impossible;
+ # fall back to allowing own techniques.
+ if len(players) < 3:
+ valid_for_p = temp_pool.copy()
+ else:
+ failed = True
+ break
+ chosen = valid_for_p[:3]
+ for c in chosen:
+ candidate[p.id].append(c)
+ temp_pool.remove(c)
+ if not failed:
+ assignments = candidate
+ break
+
+ if assignments is None:
+ # Last-resort fallback: deal the pool out in order
+ random.shuffle(pool)
+ assignments = {p.id: pool[i * 3:(i + 1) * 3] for i, p in enumerate(players)}
+
for p in players:
- # Just give them the first 3 from the pool
- p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]])
+ p.held_techniques = json.dumps(assignments[p.id])
+ p.swapped_techniques = json.dumps([t["text"] for t in assignments[p.id]])
+ p.swap_offer_to_id = None
+ p.swap_offer_technique = ""
p.is_ready = False
db.add(p)
- game.phase = "swap_techniques"
+ game.phase = "assign_techniques"
db.add(game)
db.commit()
- add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase")
+ add_game_event(db, game.id, "Techniques were auto-distributed to the crew!", kind="info")
+
+ # Random J/Q/K for anyone who hasn't assigned yet; the last assignment
+ # triggers the rank rolls and the scene_setup transition.
+ for p in list(swap_participants(game)):
+ if not (p.tech_jack and p.tech_queen and p.tech_king):
+ swapped = json.loads(p.swapped_techniques)
+ random.shuffle(swapped)
+ assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2])
+ elif not p.is_ready:
+ assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king)
+
+ db.refresh(game)
+ if game.phase != "scene_setup":
+ return False, "Auto-assignment couldn't finish; check the event log."
+ return True, "Techniques auto-assigned."
def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id)
if not player:
- return
+ return False, "Player not found."
+ game = get_game(db, player.game_id)
+ if not game or game.phase != "assign_techniques":
+ return False, "Face-card assignment isn't in progress."
+ if sorted([jack, queen, king]) != sorted(json.loads(player.swapped_techniques)):
+ return False, "Assign each of your 3 swapped techniques to exactly one face card."
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.is_ready = True
db.add(player)
db.commit()
-
+
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
- game = get_game(db, player.game_id)
- if not game:
- return
-
- if all(p.is_ready for p in game.players):
+ if all(p.is_ready for p in swap_participants(game)):
# 1. Randomly assign starting ranks
- players_list = list(game.players)
+ players_list = swap_participants(game)
random.shuffle(players_list)
# "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene."
@@ -262,6 +433,7 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
+ return True, "Techniques assigned."
def skip_character_creation(db: Session, game: Game, fills: dict):
"""
@@ -274,7 +446,7 @@ def skip_character_creation(db: Session, game: Game, fills: dict):
"""
from .cards import TECHNIQUE_SUGGESTIONS
- if game.phase not in ("character_creation", "swap_techniques"):
+ if game.phase not in ("character_creation", "swap_techniques", "assign_techniques"):
return False, "Character creation isn't in progress."
if game.phase == "character_creation":
@@ -314,25 +486,16 @@ def skip_character_creation(db: Session, game: Game, fills: dict):
db.add(p)
db.commit()
- # 4. Shuffle and distribute (moves the game to swap_techniques)
+ # 4. Move to the swap phase (rejects the pool if there are duplicates)
trigger_technique_swap(db, game)
db.refresh(game)
if game.phase != "swap_techniques":
return False, "Technique swap failed; check the event log."
- # 5. Random J/Q/K assignment for anyone who hasn't done it; the last
- # assignment triggers the rank rolls and the scene_setup transition.
- for p in list(game.players):
- if not (p.tech_jack and p.tech_queen and p.tech_king):
- swapped = json.loads(p.swapped_techniques)
- random.shuffle(swapped)
- assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2])
- elif not p.is_ready:
- # Edge case: techniques assigned but ready flag lost — re-assert it
- assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king)
-
- db.refresh(game)
- if game.phase != "scene_setup":
+ # 5. Auto-distribute any unswapped techniques and random-assign J/Q/K;
+ # the last assignment triggers the rank rolls and the scene_setup transition.
+ ok, msg = auto_assign_techniques(db, game)
+ if not ok:
return False, "Could not finish character creation; check the event log."
return True, "Character creation skipped."
@@ -370,6 +533,7 @@ def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
continue
texts = [p.tech_jack, p.tech_queen, p.tech_king]
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques)
+ texts += [t["text"] for t in json.loads(p.held_techniques)]
texts += [s["text"] for s in json.loads(p.incoming_techniques)]
for t in texts:
if t:
@@ -411,7 +575,10 @@ def activate_recruit(db: Session, game: Game, player):
player.first_words = ""
player.good_at_math = ""
player.created_techniques = "[]"
+ player.held_techniques = "[]"
player.swapped_techniques = "[]"
+ player.swap_offer_to_id = None
+ player.swap_offer_technique = ""
player.tech_jack = ""
player.tech_queen = ""
player.tech_king = ""
@@ -508,6 +675,7 @@ def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: s
return False, "Assign each of your 3 new techniques to exactly one face card."
player.swapped_techniques = json.dumps(texts)
+ player.held_techniques = json.dumps([{"text": s["text"], "creator_id": s["from_id"]} for s in json.loads(player.incoming_techniques)])
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
diff --git a/src/pirats/main.py b/src/pirats/main.py
index 00a9ea0..6d6d145 100644
--- a/src/pirats/main.py
+++ b/src/pirats/main.py
@@ -89,7 +89,9 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
# admin_key stays hidden except from Admins, who need it to open the admin panel
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
"player": player.model_dump(),
- "players": [p.model_dump() for p in game.players],
+ # A swap offer must reveal nothing but its existence: only the offerer
+ # gets to see which technique they put on the table.
+ "players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes],
diff --git a/src/pirats/migrations/versions/b306c59e9cc2_technique_swapping_columns.py b/src/pirats/migrations/versions/b306c59e9cc2_technique_swapping_columns.py
new file mode 100644
index 0000000..caa556d
--- /dev/null
+++ b/src/pirats/migrations/versions/b306c59e9cc2_technique_swapping_columns.py
@@ -0,0 +1,33 @@
+"""technique swapping columns
+
+Revision ID: b306c59e9cc2
+Revises: 8b2f4c9d1e07
+Create Date: 2026-06-12 16:35:11.867104
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+revision = 'b306c59e9cc2'
+down_revision = '8b2f4c9d1e07'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('player', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('held_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
+ batch_op.add_column(sa.Column('swap_offer_to_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
+ batch_op.add_column(sa.Column('swap_offer_technique', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default=''))
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('player', schema=None) as batch_op:
+ batch_op.drop_column('swap_offer_technique')
+ batch_op.drop_column('swap_offer_to_id')
+ batch_op.drop_column('held_techniques')
+
+ # ### end Alembic commands ###
diff --git a/src/pirats/models.py b/src/pirats/models.py
index 389d702..2b540fa 100644
--- a/src/pirats/models.py
+++ b/src/pirats/models.py
@@ -7,7 +7,7 @@ class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
crew_name: Optional[str] = Field(default=None)
- phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
+ phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
@@ -50,7 +50,10 @@ class Player(SQLModel, table=True):
# Techniques
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings
+ held_techniques: str = Field(default="[]") # JSON list of {"text", "creator_id"}: what the player currently holds during/after the swap phase
swapped_techniques: str = Field(default="[]") # JSON string of list of 3 strings
+ swap_offer_to_id: Optional[str] = Field(default=None) # Pending outgoing swap offer's recipient
+ swap_offer_technique: str = Field(default="") # The held technique being offered; hidden from everyone but the offerer
tech_jack: str = Field(default="")
tech_queen: str = Field(default="")
tech_king: str = Field(default="")
diff --git a/src/pirats/routes_admin.py b/src/pirats/routes_admin.py
index 78f8754..19457d7 100644
--- a/src/pirats/routes_admin.py
+++ b/src/pirats/routes_admin.py
@@ -56,6 +56,23 @@ def skip_character_creation_route(
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
+# Admin escape hatch during technique distribution: randomly distribute every
+# technique to an eligible player and auto-assign J/Q/K (no Dev Mode required)
+@router.post("/game/{game_id}/player/{player_id}/auto-assign-techniques")
+def auto_assign_techniques_route(
+ game_id: str,
+ player_id: str,
+ db: Session = Depends(get_session)
+):
+ game, player = _get_admin(db, game_id, player_id)
+ if game.phase not in ("swap_techniques", "assign_techniques"):
+ return JSONResponse({"error": "Technique distribution isn't in progress."}, status_code=400)
+ crud.add_game_event(db, game.id, f"🗝 {player.name} is auto-assigning everyone's techniques.", kind="info")
+ ok, msg = crud.auto_assign_techniques(db, game)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"status": "ok"}
+
# Creator Admin Panel
@router.get("/game/{game_id}/admin")
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
diff --git a/src/pirats/routes_character.py b/src/pirats/routes_character.py
index b280bf5..d1aea5f 100644
--- a/src/pirats/routes_character.py
+++ b/src/pirats/routes_character.py
@@ -138,6 +138,60 @@ def player_submit_techniques(
return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"}
+# Offer one of your held techniques to another player as a blind swap
+@router.post("/game/{game_id}/player/{player_id}/offer-swap")
+def player_offer_swap(
+ game_id: str,
+ player_id: str,
+ target_player_id: str = Form(...),
+ technique: str = Form(...),
+ db: Session = Depends(get_session)
+):
+ ok, msg = crud.offer_swap(db, player_id, target_player_id, technique)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"status": "ok"}
+
+# Withdraw your pending swap offer
+@router.post("/game/{game_id}/player/{player_id}/cancel-swap-offer")
+def player_cancel_swap_offer(
+ game_id: str,
+ player_id: str,
+ db: Session = Depends(get_session)
+):
+ ok, msg = crud.cancel_swap_offer(db, player_id)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"status": "ok"}
+
+# Accept (trading back one of your techniques) or decline an incoming swap offer
+@router.post("/game/{game_id}/player/{player_id}/respond-swap-offer")
+def player_respond_swap_offer(
+ game_id: str,
+ player_id: str,
+ offerer_player_id: str = Form(...),
+ accept: bool = Form(...),
+ technique: str = Form(""),
+ db: Session = Depends(get_session)
+):
+ ok, msg = crud.respond_swap_offer(db, player_id, offerer_player_id, accept, technique)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"status": "ok"}
+
+# Declare yourself done (or not done) swapping techniques
+@router.post("/game/{game_id}/player/{player_id}/swap-ready")
+def player_swap_ready(
+ game_id: str,
+ player_id: str,
+ ready: bool = Form(...),
+ db: Session = Depends(get_session)
+):
+ ok, msg = crud.set_swap_ready(db, player_id, ready)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"status": "ok"}
+
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
def player_assign_face_techniques(
@@ -150,8 +204,10 @@ def player_assign_face_techniques(
):
if len({jack, queen, king}) < 3:
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
-
- crud.assign_face_card_techniques(db, player_id, jack, queen, king)
+
+ ok, msg = crud.assign_face_card_techniques(db, player_id, jack, queen, king)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)
diff --git a/tests/test_game.py b/tests/test_game.py
index c03f4f6..982997c 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -70,23 +70,64 @@ def test_character_creation_and_swap(session):
crud.submit_techniques(session, p3.id, "Jaspar Blast", "Shoot", "Run")
session.refresh(game)
- # The swap should have automatically triggered because all 3 submitted
+ # The swap phase should have automatically begun because all 3 submitted
assert game.phase == "swap_techniques"
-
+
session.refresh(p1)
session.refresh(p2)
session.refresh(p3)
-
+
+ # Everyone starts the swap phase holding their own creations
+ held_p1 = json.loads(p1.held_techniques)
+ assert [t["text"] for t in held_p1] == ["Carlos Strike", "Hide", "Roll"]
+ assert all(t["creator_id"] == p1.id for t in held_p1)
+ assert json.loads(p1.swapped_techniques) == []
+
+ # Can't ready up while still holding your own techniques
+ ok, msg = crud.set_swap_ready(session, p1.id, True)
+ assert not ok
+
+ # A scripted set of blind swaps that leaves nobody holding their own work:
+ # (offerer, offered technique, responder, technique traded back)
+ swaps = [
+ (p1, "Carlos Strike", p2, "Barry Leap"),
+ (p1, "Hide", p3, "Jaspar Blast"),
+ (p1, "Roll", p2, "Bite"),
+ (p2, "Scream", p3, "Shoot"),
+ (p3, "Run", p2, "Carlos Strike"),
+ ]
+ for offerer, offered, responder, returned in swaps:
+ ok, msg = crud.offer_swap(session, offerer.id, responder.id, offered)
+ assert ok, msg
+ ok, msg = crud.respond_swap_offer(session, responder.id, offerer.id, True, returned)
+ assert ok, msg
+
+ for p in (p1, p2, p3):
+ session.refresh(p)
+ held = json.loads(p.held_techniques)
+ assert len(held) == 3
+ assert all(t["creator_id"] != p.id for t in held)
+ ok, msg = crud.set_swap_ready(session, p.id, True)
+ assert ok, msg
+
+ session.refresh(game)
+ assert game.phase == "assign_techniques"
+
+ # Everyone's swapped techniques were locked in from their held set
+ session.refresh(p1)
techs_p1 = json.loads(p1.swapped_techniques)
assert len(techs_p1) == 3
- # Check that Carlos didn't get any of his own techniques
assert "Carlos Strike" not in techs_p1
-
- # Now assign to face cards
- crud.assign_face_card_techniques(session, p1.id, techs_p1[0], techs_p1[1], techs_p1[2])
- crud.assign_face_card_techniques(session, p2.id, "T2", "T3", "T4")
- crud.assign_face_card_techniques(session, p3.id, "T5", "T6", "T7")
-
+
+ # Now assign to face cards (must use exactly the swapped techniques)
+ ok, msg = crud.assign_face_card_techniques(session, p1.id, "T2", "T3", "T4")
+ assert not ok
+ for p in (p1, p2, p3):
+ session.refresh(p)
+ techs = json.loads(p.swapped_techniques)
+ ok, msg = crud.assign_face_card_techniques(session, p.id, techs[0], techs[1], techs[2])
+ assert ok, msg
+
session.refresh(game)
# All are ready, should transition to scene_setup and assign starting ranks!
assert game.phase == "scene_setup"
@@ -1083,6 +1124,134 @@ def test_submit_techniques_rejects_collisions(session):
session.refresh(game)
assert game.phase == "swap_techniques"
+def make_swap_game(session):
+ """Helper: 3 players in the swap_techniques phase, each holding their own techniques."""
+ game = crud.create_game(session)
+ p1 = crud.add_player(session, game.id, "Carlos", is_creator=True)
+ p2 = crud.add_player(session, game.id, "Barry")
+ p3 = crud.add_player(session, game.id, "Jaspar")
+ crud.submit_techniques(session, p1.id, "A1", "A2", "A3")
+ crud.submit_techniques(session, p2.id, "B1", "B2", "B3")
+ crud.submit_techniques(session, p3.id, "C1", "C2", "C3")
+ session.refresh(game)
+ assert game.phase == "swap_techniques"
+ return game, p1, p2, p3
+
+def test_swap_offer_validation(session):
+ game, p1, p2, p3 = make_swap_game(session)
+
+ # Can't offer a technique you don't hold, or swap with yourself
+ ok, _ = crud.offer_swap(session, p1.id, p2.id, "B1")
+ assert not ok
+ ok, _ = crud.offer_swap(session, p1.id, p1.id, "A1")
+ assert not ok
+
+ # One pending offer at a time
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A1")
+ assert ok, msg
+ ok, _ = crud.offer_swap(session, p1.id, p3.id, "A2")
+ assert not ok
+ ok, msg = crud.cancel_swap_offer(session, p1.id)
+ assert ok, msg
+ session.refresh(p1)
+ assert p1.swap_offer_to_id is None and p1.swap_offer_technique == ""
+
+ # Accepting must not trade back a technique the offerer created
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A1")
+ assert ok, msg
+ crud.respond_swap_offer(session, p2.id, p1.id, True, "B1")
+ session.refresh(p2)
+ # p2 now holds A1 (created by p1) and can't offer it back to p1
+ ok, _ = crud.offer_swap(session, p2.id, p1.id, "A1")
+ assert not ok
+
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A2")
+ assert ok, msg
+ ok, _ = crud.respond_swap_offer(session, p2.id, p1.id, True, "A1")
+ assert not ok # A1 was created by the offerer
+ # Declining clears the offer
+ ok, msg = crud.respond_swap_offer(session, p2.id, p1.id, False)
+ assert ok, msg
+ session.refresh(p1)
+ assert p1.swap_offer_to_id is None
+
+ # Only the actual recipient can respond
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A2")
+ assert ok, msg
+ ok, _ = crud.respond_swap_offer(session, p3.id, p1.id, True, "C1")
+ assert not ok
+
+def test_swap_accept_exchanges_and_withdraws_stale_offer(session):
+ game, p1, p2, p3 = make_swap_game(session)
+
+ # p2 promises B1 to p3, then trades B1 away to p1 instead
+ ok, msg = crud.offer_swap(session, p2.id, p3.id, "B1")
+ assert ok, msg
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A1")
+ assert ok, msg
+ ok, msg = crud.respond_swap_offer(session, p2.id, p1.id, True, "B1")
+ assert ok, msg
+
+ session.refresh(p1)
+ session.refresh(p2)
+ held_p1 = json.loads(p1.held_techniques)
+ held_p2 = json.loads(p2.held_techniques)
+ # The techniques moved with their creator tags intact
+ assert {"text": "B1", "creator_id": p2.id} in held_p1
+ assert {"text": "A1", "creator_id": p1.id} in held_p2
+ assert not any(t["text"] == "A1" for t in held_p1)
+ # p2's now-impossible offer to p3 was withdrawn
+ assert p2.swap_offer_to_id is None and p2.swap_offer_technique == ""
+
+def test_auto_assign_techniques(session):
+ game, p1, p2, p3 = make_swap_game(session)
+
+ ok, msg = crud.auto_assign_techniques(session, game)
+ assert ok, msg
+ session.refresh(game)
+ assert game.phase == "scene_setup"
+ for p in (p1, p2, p3):
+ session.refresh(p)
+ assert p.tech_jack and p.tech_queen and p.tech_king
+ own = {t.lower() for t in json.loads(p.created_techniques)}
+ held = {p.tech_jack.lower(), p.tech_queen.lower(), p.tech_king.lower()}
+ assert not (own & held)
+ # Ranks were rolled too
+ assert sorted(p.rank for p in (p1, p2, p3)) == [1, 2, 3]
+
+ # Wrong phase is rejected
+ ok, _ = crud.auto_assign_techniques(session, game)
+ assert not ok
+
+def test_swap_offer_technique_hidden_in_state(session):
+ from fastapi.testclient import TestClient
+ from pirats.main import app
+ from pirats.database import get_session
+
+ game, p1, p2, p3 = make_swap_game(session)
+ ok, msg = crud.offer_swap(session, p1.id, p2.id, "A1")
+ assert ok, msg
+
+ def get_session_override():
+ yield session
+
+ app.dependency_overrides[get_session] = get_session_override
+ client = TestClient(app, base_url="http://testserver")
+ try:
+ # The recipient sees that an offer exists, but not which technique
+ state = client.get(f"/api/game/{game.id}/player/{p2.id}/state").json()
+ p1_blob = next(p for p in state["players"] if p["id"] == p1.id)
+ assert p1_blob["swap_offer_to_id"] == p2.id
+ assert "swap_offer_technique" not in p1_blob
+
+ # The offerer still sees their own offered technique
+ state = client.get(f"/api/game/{game.id}/player/{p1.id}/state").json()
+ assert state["player"]["swap_offer_technique"] == "A1"
+ p1_blob = next(p for p in state["players"] if p["id"] == p1.id)
+ assert p1_blob["swap_offer_technique"] == "A1"
+ finally:
+ app.dependency_overrides.clear()
+
def test_recruit_smell_name_normalization(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")