Technique swapping implementation

This commit is contained in:
2026-06-12 16:46:03 -07:00
parent 121f2f7498
commit fa37cffe47
10 changed files with 724 additions and 128 deletions

View File

@@ -1,6 +1,4 @@
## Major features ## Major features
- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered (and who offered it). This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges.
- [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well. - [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well.
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin. - [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
@@ -17,9 +15,7 @@
## Preventing blocked play ## Preventing blocked play
- [x] **Multiple admins.** The Admin player should be able to grant other players Admin privileges in the admin panel. - [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard.
- [x] **Player minimum.** You need at least three players to start a game, or to start a new scene if a player dropped out mid-game. (Dev Mode bypasses the minimum for local testing.)
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins.
## Session management ## Session management
- [ ] **Add invite link to Admin panel** - [ ] **Add invite link to Admin panel**

View File

@@ -66,6 +66,41 @@
} }
} }
// Technique swapping (swap_techniques phase)
let offerTech = '', offerTargetId = '';
let acceptChoice = {};
let swapBusy = false;
let swapError = '';
async function swapApi(path, body) {
swapBusy = true;
swapError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', body);
} catch (e) {
swapError = e.message;
} finally {
swapBusy = false;
}
}
function offerSwap() {
swapApi('offer-swap', { target_player_id: offerTargetId, technique: offerTech });
}
function cancelOffer() {
swapApi('cancel-swap-offer');
}
function respondOffer(offererId, accept) {
swapApi('respond-swap-offer', {
offerer_player_id: offererId,
accept,
technique: accept ? (acceptChoice[offererId] || '') : ''
});
}
function setSwapReady(ready) {
swapApi('swap-ready', { ready });
}
// Face Techniques // Face Techniques
let jackTech = '', queenTech = '', kingTech = ''; let jackTech = '', queenTech = '', kingTech = '';
let assigningFace = false; let assigningFace = false;
@@ -103,9 +138,16 @@
} }
// Check completion status // Check completion status
$: phase = state.game.phase;
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : []; $: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
$: heldTechniques = state.player.held_techniques ? JSON.parse(state.player.held_techniques) : [];
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : []; $: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
$: ownHeldCount = heldTechniques.filter(t => t.creator_id === state.player.id).length;
$: incomingOffers = state.players.filter(p => p.id !== state.player.id && p.swap_offer_to_id === state.player.id);
$: swapTargets = state.players.filter(p => p.id !== state.player.id && !p.needs_reroll);
$: offerTechCreator = (heldTechniques.find(t => t.text === offerTech) || {}).creator_id;
$: offerInvalid = !offerTech || !offerTargetId || offerTechCreator === offerTargetId;
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math); $: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id); $: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king); $: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
@@ -237,7 +279,9 @@
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;"> <div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3> <h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
{#if createdTechniques.length === 0} {#if state.player.needs_reroll}
<p class="text-muted italic">You're spectating for now — you'll create your Pi-Rat with the crew between scenes.</p>
{:else if phase === 'character_creation' && createdTechniques.length === 0}
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p> <p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
{#if techError} {#if techError}
<div class="alert alert-danger">{techError}</div> <div class="alert alert-danger">{techError}</div>
@@ -266,42 +310,123 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button> <button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
</form> </form>
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3} {:else if phase === 'character_creation'}
<div class="submitted-techniques text-center mt-4"> <div class="submitted-techniques text-center mt-4">
<div class="tech-chip">#1: {createdTechniques[0]}</div> <div class="tech-chip">#1: {createdTechniques[0]}</div>
<div class="tech-chip">#2: {createdTechniques[1]}</div> <div class="tech-chip">#2: {createdTechniques[1]}</div>
<div class="tech-chip">#3: {createdTechniques[2]}</div> <div class="tech-chip">#3: {createdTechniques[2]}</div>
<p class="waiting-box margin-top flex justify-center mt-4"> <p class="waiting-box margin-top flex justify-center mt-4">
<span class="spinner-small"></span> <span class="spinner-small"></span>
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them. Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
</p> </p>
</div> </div>
{:else if !techniquesComplete} {:else if phase === 'swap_techniques'}
{#if swappedTechniques.length === 3} <p class="section-desc">
<div class="max-w-4xl mx-auto space-y-6"> Trade away all 3 techniques you created by offering blind swaps to your crewmates.
<p>Drag and drop your assigned techniques onto your Face Cards:</p> They only learn that you offered a swap never which technique is on the table!
You can't offer a technique to the rat who wrote it.
{#if faceError} </p>
<div class="alert alert-danger">{faceError}</div> {#if swapError}
{/if} <div class="alert alert-danger">{swapError}</div>
{/if}
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} /> <h4>Your techniques in hand</h4>
<div class="submitted-techniques mt-4">
{#each heldTechniques as t}
<div class="tech-chip">
{t.text}
{#if t.creator_id === state.player.id}
<span class="text-warning"> yours, swap it away!</span>
{:else}
<span class="text-success"> from {getPlayerName(t.creator_id)}</span>
{/if}
</div>
{/each}
</div>
<div class="mt-6"> {#if state.player.swap_offer_to_id}
<button <div class="delegation-box glass-panel mt-4">
class="btn btn-primary btn-large w-full" <p>
disabled={assigningFace || !jackTech || !queenTech || !kingTech} You offered <strong>"{state.player.swap_offer_technique}"</strong> to
on:click={assignFaceTechniques} {getPlayerName(state.player.swap_offer_to_id)}. Waiting for their answer...
> </p>
{assigningFace ? 'Assigning...' : 'Assign to Cards'} <button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={cancelOffer}>Withdraw Offer</button>
</button> </div>
{:else if !state.player.is_ready}
<div class="delegation-box glass-panel mt-4">
<h4>Offer a swap</h4>
<div class="input-row">
<select class="input-field" bind:value={offerTech}>
<option value="" disabled>Pick a technique to give...</option>
{#each heldTechniques as t}
<option value={t.text}>{t.text}</option>
{/each}
</select>
<select class="input-field" bind:value={offerTargetId}>
<option value="" disabled>Pick a crewmate...</option>
{#each swapTargets as p}
<option value={p.id} disabled={offerTechCreator === p.id}>
{displayName(p)}{offerTechCreator === p.id ? ' (wrote it!)' : ''}
</option>
{/each}
</select>
<button class="btn btn-primary" disabled={swapBusy || offerInvalid} on:click={offerSwap}>Offer Swap</button>
</div> </div>
</div> </div>
{:else}
<div class="text-center italic text-muted">
Waiting for all players to submit techniques so they can be shuffled...
</div>
{/if} {/if}
{#each incomingOffers as offerer (offerer.id)}
<div class="delegation-box glass-panel mt-4">
<h4>📨 {displayName(offerer)} offered you a mystery technique!</h4>
<p class="text-sm text-muted">Choose one of your techniques to trade back (not one they wrote):</p>
<div class="input-row">
<select class="input-field" bind:value={acceptChoice[offerer.id]}>
<option value="" disabled selected>Pick a technique to give back...</option>
{#each heldTechniques as t}
<option value={t.text} disabled={t.creator_id === offerer.id}>
{t.text}{t.creator_id === offerer.id ? ' (they wrote it!)' : ''}
</option>
{/each}
</select>
<button class="btn btn-primary" disabled={swapBusy || !acceptChoice[offerer.id]} on:click={() => respondOffer(offerer.id, true)}>Accept</button>
<button class="btn btn-secondary" disabled={swapBusy} on:click={() => respondOffer(offerer.id, false)}>Decline</button>
</div>
</div>
{/each}
<div class="text-center mt-6">
{#if state.player.is_ready}
<p class="waiting-box flex justify-center">
<span class="spinner-small"></span>
Done swapping! Waiting for the rest of the crew...
</p>
<button class="btn btn-secondary mt-4" disabled={swapBusy} on:click={() => setSwapReady(false)}>Wait, I want to keep swapping</button>
{:else if ownHeldCount > 0}
<p class="text-muted italic">Swap away {ownHeldCount} more of your own technique{ownHeldCount === 1 ? '' : 's'} before you can ready up.</p>
{:else}
<button class="btn btn-primary btn-large" disabled={swapBusy || !!state.player.swap_offer_to_id} on:click={() => setSwapReady(true)}>I'm done swapping</button>
{/if}
</div>
{:else if !techniquesComplete}
<div class="max-w-4xl mx-auto space-y-6">
<p>Drag and drop your assigned techniques onto your Face Cards:</p>
{#if faceError}
<div class="alert alert-danger">{faceError}</div>
{/if}
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<div class="mt-6">
<button
class="btn btn-primary btn-large w-full"
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
on:click={assignFaceTechniques}
>
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
</button>
</div>
</div>
{:else} {:else}
<div class="assigned-techs text-center mt-4"> <div class="assigned-techs text-center mt-4">
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div> <div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
@@ -325,10 +450,21 @@
<li class="status-row {p.tech_jack ? 'is-ready' : ''}"> <li class="status-row {p.tech_jack ? 'is-ready' : ''}">
<span>{displayName(p)}</span> <span>{displayName(p)}</span>
<span class="text-sm"> <span class="text-sm">
{#if p.tech_jack} {#if p.needs_reroll}
<span class="text-muted">Spectating</span>
{:else if phase === 'swap_techniques'}
{#if p.is_ready}
<span class="text-success">Done Swapping</span>
{:else}
{@const ownLeft = JSON.parse(p.held_techniques || '[]').filter(t => t.creator_id === p.id).length}
<span class="text-warning">Swapping ({ownLeft} of their own left)</span>
{/if}
{:else if p.tech_jack}
<span class="text-success">Ready</span> <span class="text-success">Ready</span>
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3} {:else if phase === 'assign_techniques'}
<span class="text-warning">Assigning Face Cards</span> <span class="text-warning">Assigning Face Cards</span>
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
<span class="text-warning">Techniques Submitted</span>
{:else} {:else}
<span class="text-muted">Writing Sheet</span> <span class="text-muted">Writing Sheet</span>
{/if} {/if}

View File

@@ -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 // 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) { $: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key); localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
@@ -107,13 +116,20 @@
action: toggleDevMode, action: toggleDevMode,
title: 'Toggle Dev Mode testing shortcuts for the whole table', 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({ items.push({
label: '⏭️ Skip Character Creation', label: '⏭️ Skip Character Creation',
action: skipCharacterCreation, action: skipCharacterCreation,
title: 'Auto-fill anything unfinished for all players and jump to scene setup', 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' }); items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
} }
extraMenuLinks.set(items); extraMenuLinks.set(items);
@@ -138,7 +154,7 @@
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}"> <div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
{#if state.game.phase === 'lobby'} {#if state.game.phase === 'lobby'}
<LobbyPhase {state} /> <LobbyPhase {state} />
{: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'}
<CharacterCreationPhase {state} /> <CharacterCreationPhase {state} />
{:else if state.game.phase === 'scene_setup'} {:else if state.game.phase === 'scene_setup'}
<SceneSetupPhase {state} /> <SceneSetupPhase {state} />

View File

@@ -115,7 +115,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
return None return None
all_submitted = True all_submitted = True
for p in game.players: for p in swap_participants(game):
techs = json.loads(p.created_techniques) techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs): if len(techs) < 3 or not all(techs):
all_submitted = False 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) trigger_technique_swap(db, game)
return None 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): 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: if not players:
return return
# Build a pool of all techniques with their creators # Reject the pool if any two techniques collide (case-insensitively)
pool = []
seen_texts = set() seen_texts = set()
has_duplicates = False has_duplicates = False
for p in players: for p in players:
techs = json.loads(p.created_techniques) for t in json.loads(p.created_techniques):
for t in techs:
t_clean = t.strip().lower() t_clean = t.strip().lower()
if t_clean in seen_texts: if t_clean in seen_texts:
has_duplicates = True has_duplicates = True
seen_texts.add(t_clean) seen_texts.add(t_clean)
pool.append({"text": t, "creator_id": p.id})
if has_duplicates: if has_duplicates:
# Force players to resubmit # Force players to resubmit
for p in players: for p in players:
@@ -155,79 +161,244 @@ def trigger_technique_swap(db: Session, game: Game):
db.commit() 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") 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 return
# Swap algorithm with simple retry backtracking for p in players:
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id. p.held_techniques = json.dumps([{"text": t, "creator_id": p.id} for t in json.loads(p.created_techniques)])
# For a small number of players, random shuffling with retries is highly efficient. p.swapped_techniques = "[]"
success = False p.swap_offer_to_id = None
for attempt in range(200): p.swap_offer_technique = ""
random.shuffle(pool) p.is_ready = False
assignments = {p.id: [] for p in players} db.add(p)
temp_pool = pool.copy() game.phase = "swap_techniques"
db.add(game)
# Greedy assignment db.commit()
failed = False 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: for p in players:
valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id] for t in json.loads(p.created_techniques):
if len(valid_for_p) < 3: pool.append({"text": t, "creator_id": p.id})
# 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). # Random shuffling with retries: for a small table this finds a valid
if len(players) < 3: # creator != assignee distribution almost immediately.
valid_for_p = temp_pool.copy() assignments = None
else: for attempt in range(200):
failed = True random.shuffle(pool)
break candidate = {p.id: [] for p in players}
temp_pool = pool.copy()
# Take 3 failed = False
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 p in players: for p in players:
p.swapped_techniques = json.dumps(assignments[p.id]) valid_for_p = [t for t in temp_pool if t["creator_id"] != p.id]
p.is_ready = False # Reset ready flag for J/Q/K assignment step if len(valid_for_p) < 3:
db.add(p) # With 1-2 players a non-owned distribution can be impossible;
game.phase = "swap_techniques" # fall back to allowing own techniques.
db.add(game) if len(players) < 3:
db.commit() valid_for_p = temp_pool.copy()
add_game_event(db, game.id, "Phase changed: Swap Techniques", kind="phase") else:
success = True failed = True
break break
chosen = valid_for_p[:3]
if not success: for c in chosen:
# Fallback in case of failure (e.g., edge cases, single player testing): just distribute whatever 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: for p in players:
# Just give them the first 3 from the pool p.held_techniques = json.dumps(assignments[p.id])
p.swapped_techniques = json.dumps([t["text"] for t in pool[:3]]) 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 p.is_ready = False
db.add(p) db.add(p)
game.phase = "swap_techniques" game.phase = "assign_techniques"
db.add(game) db.add(game)
db.commit() 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): def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id) player = get_player(db, player_id)
if not player: 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_jack = jack
player.tech_queen = queen player.tech_queen = queen
player.tech_king = king player.tech_king = king
player.is_ready = True player.is_ready = True
db.add(player) db.add(player)
db.commit() db.commit()
# Check if all players are ready. If so, transition to scene_setup and assign starting ranks! # Check if all players are ready. If so, transition to scene_setup and assign starting ranks!
game = get_game(db, player.game_id) if all(p.is_ready for p in swap_participants(game)):
if not game:
return
if all(p.is_ready for p in game.players):
# 1. Randomly assign starting ranks # 1. Randomly assign starting ranks
players_list = list(game.players) players_list = swap_participants(game)
random.shuffle(players_list) 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." # "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.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase") 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): 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 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." return False, "Character creation isn't in progress."
if game.phase == "character_creation": if game.phase == "character_creation":
@@ -314,25 +486,16 @@ def skip_character_creation(db: Session, game: Game, fills: dict):
db.add(p) db.add(p)
db.commit() 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) trigger_technique_swap(db, game)
db.refresh(game) db.refresh(game)
if game.phase != "swap_techniques": if game.phase != "swap_techniques":
return False, "Technique swap failed; check the event log." return False, "Technique swap failed; check the event log."
# 5. Random J/Q/K assignment for anyone who hasn't done it; the last # 5. Auto-distribute any unswapped techniques and random-assign J/Q/K;
# assignment triggers the rank rolls and the scene_setup transition. # the last assignment triggers the rank rolls and the scene_setup transition.
for p in list(game.players): ok, msg = auto_assign_techniques(db, game)
if not (p.tech_jack and p.tech_queen and p.tech_king): if not ok:
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":
return False, "Could not finish character creation; check the event log." return False, "Could not finish character creation; check the event log."
return True, "Character creation skipped." return True, "Character creation skipped."
@@ -370,6 +533,7 @@ def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
continue continue
texts = [p.tech_jack, p.tech_queen, p.tech_king] texts = [p.tech_jack, p.tech_queen, p.tech_king]
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques) 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)] texts += [s["text"] for s in json.loads(p.incoming_techniques)]
for t in texts: for t in texts:
if t: if t:
@@ -411,7 +575,10 @@ def activate_recruit(db: Session, game: Game, player):
player.first_words = "" player.first_words = ""
player.good_at_math = "" player.good_at_math = ""
player.created_techniques = "[]" player.created_techniques = "[]"
player.held_techniques = "[]"
player.swapped_techniques = "[]" player.swapped_techniques = "[]"
player.swap_offer_to_id = None
player.swap_offer_technique = ""
player.tech_jack = "" player.tech_jack = ""
player.tech_queen = "" player.tech_queen = ""
player.tech_king = "" 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." return False, "Assign each of your 3 new techniques to exactly one face card."
player.swapped_techniques = json.dumps(texts) 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_jack = jack
player.tech_queen = queen player.tech_queen = queen
player.tech_king = king player.tech_king = king

View File

@@ -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 # 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"})), "game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
"player": player.model_dump(), "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], "obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges], "challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes], "votes": [v.model_dump() for v in game.votes],

View File

@@ -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 ###

View File

@@ -7,7 +7,7 @@ class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
crew_name: Optional[str] = Field(default=None) 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 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) current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles) 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 # Techniques
created_techniques: str = Field(default="[]") # JSON string of list of 3 strings 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 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_jack: str = Field(default="")
tech_queen: str = Field(default="") tech_queen: str = Field(default="")
tech_king: str = Field(default="") tech_king: str = Field(default="")

View File

@@ -56,6 +56,23 @@ def skip_character_creation_route(
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} 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 # Creator Admin Panel
@router.get("/game/{game_id}/admin") @router.get("/game/{game_id}/admin")
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)): def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):

View File

@@ -138,6 +138,60 @@ def player_submit_techniques(
return JSONResponse({"error": error}, status_code=400) return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"} 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) # Assign Swapped Techniques to Face Cards (Jack, Queen, King)
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques") @router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
def player_assign_face_techniques( def player_assign_face_techniques(
@@ -150,8 +204,10 @@ def player_assign_face_techniques(
): ):
if len({jack, queen, king}) < 3: if len({jack, queen, king}) < 3:
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400) 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"} return {"status": "ok"}
# Dead Pi-Rat opts to come back as a fresh recruit (created between scenes) # Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)

View File

@@ -70,23 +70,64 @@ def test_character_creation_and_swap(session):
crud.submit_techniques(session, p3.id, "Jaspar Blast", "Shoot", "Run") crud.submit_techniques(session, p3.id, "Jaspar Blast", "Shoot", "Run")
session.refresh(game) 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" assert game.phase == "swap_techniques"
session.refresh(p1) session.refresh(p1)
session.refresh(p2) session.refresh(p2)
session.refresh(p3) 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) techs_p1 = json.loads(p1.swapped_techniques)
assert len(techs_p1) == 3 assert len(techs_p1) == 3
# Check that Carlos didn't get any of his own techniques
assert "Carlos Strike" not in techs_p1 assert "Carlos Strike" not in techs_p1
# Now assign to face cards # Now assign to face cards (must use exactly the swapped techniques)
crud.assign_face_card_techniques(session, p1.id, techs_p1[0], techs_p1[1], techs_p1[2]) ok, msg = crud.assign_face_card_techniques(session, p1.id, "T2", "T3", "T4")
crud.assign_face_card_techniques(session, p2.id, "T2", "T3", "T4") assert not ok
crud.assign_face_card_techniques(session, p3.id, "T5", "T6", "T7") 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) session.refresh(game)
# All are ready, should transition to scene_setup and assign starting ranks! # All are ready, should transition to scene_setup and assign starting ranks!
assert game.phase == "scene_setup" assert game.phase == "scene_setup"
@@ -1083,6 +1124,134 @@ def test_submit_techniques_rejects_collisions(session):
session.refresh(game) session.refresh(game)
assert game.phase == "swap_techniques" 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): def test_recruit_smell_name_normalization(session):
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")