Uncommit feature in character creation

Players can now unlock and revise any reasonably revisable choice while
character creation is ongoing: basic Rat Records (also in recruit
creation), submitted techniques (until the swap phase begins), J/Q/K
face-card assignments (until everyone is ready), and Like/Hate answers
written for crewmates. New endpoints unsubmit-techniques and
unassign-face-techniques clear the locked-in state server-side so the
phase machine waits for the revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:05:52 -07:00
parent ba32c96b7b
commit 952b1be872
6 changed files with 224 additions and 14 deletions

View File

@@ -1,15 +1,15 @@
## Major features ## Major features
- [ ] **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.
- [ ] **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.
## Minor Gameplay Polish ## Minor Gameplay Polish
- [ ] **Uncommit feature in character creation**. While character creation is ongoing, add buttons to allow players to unlock/revise any choice that can be reasonably unlocked/revised
- [ ] **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.
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
- [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats. - [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
- [ ] **Add invite link to Admin panel** - [ ] **Add invite link to Admin panel**
- [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries - [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh). - [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
## Words Words Words ## Words Words Words

View File

@@ -16,11 +16,13 @@
}; };
let savingBasic = false; let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() { async function saveBasicDetails() {
savingBasic = true; savingBasic = true;
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) { } catch(e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -28,6 +30,16 @@
} }
} }
function reviseBasicDetails() {
basicDetails = {
avatar_look: state.player.avatar_look || '',
avatar_smell: state.player.avatar_smell || '',
first_words: state.player.first_words || '',
good_at_math: state.player.good_at_math || ''
};
editingBasic = true;
}
// Like/Hate tasks are assigned automatically when character creation starts. // Like/Hate tasks are assigned automatically when character creation starts.
// This is a fallback for games that entered the phase before assignments existed. // This is a fallback for games that entered the phase before assignments existed.
onMount(async () => { onMount(async () => {
@@ -66,6 +78,16 @@
} }
} }
async function reviseTechniques() {
[tech1 = '', tech2 = '', tech3 = ''] = createdTechniques;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unsubmit-techniques`, 'POST');
} catch(e) {
techError = e.message;
}
}
// Technique swapping (swap_techniques phase) // Technique swapping (swap_techniques phase)
let offerTech = '', offerTargetId = ''; let offerTech = '', offerTargetId = '';
let acceptChoice = {}; let acceptChoice = {};
@@ -122,6 +144,18 @@
} }
} }
async function reviseFaceTechniques() {
jackTech = state.player.tech_jack;
queenTech = state.player.tech_queen;
kingTech = state.player.tech_king;
faceError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/unassign-face-techniques`, 'POST');
} catch(e) {
faceError = e.message;
}
}
// Submit delegated answers — one draft per (player, question) so editing one // Submit delegated answers — one draft per (player, question) so editing one
// task never bleeds into another. // task never bleeds into another.
let inboxAnswers = {}; let inboxAnswers = {};
@@ -171,7 +205,7 @@
<div class="card glass-panel section-basic"> <div class="card glass-panel section-basic">
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3> <h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete} {#if basicComplete && !editingBasic}
<!-- Saved View --> <!-- Saved View -->
<div class="read-only-sheet"> <div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div> <div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
@@ -179,6 +213,7 @@
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div> <div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div> <div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
</div> </div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else} {:else}
<!-- Form View --> <!-- Form View -->
<form on:submit|preventDefault={saveBasicDetails} class="creation-form"> <form on:submit|preventDefault={saveBasicDetails} class="creation-form">
@@ -248,9 +283,12 @@
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p> <p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
<div class="inbox-list"> <div class="inbox-list">
{#each state.players as p} {#each state.players as p}
{#if p.other_like_from_player_id === state.player.id && !p.other_like} {#if p.other_like_from_player_id === state.player.id}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you LIKE about {displayName(p)}?</label> <label>
What do you LIKE about {displayName(p)}?
{#if p.other_like}<span class="text-success">(submitted: "{p.other_like}" — you can revise it)</span>{/if}
</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
@@ -258,9 +296,12 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate} {#if p.other_hate_from_player_id === state.player.id}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you HATE about {displayName(p)}?</label> <label>
What do you HATE about {displayName(p)}?
{#if p.other_hate}<span class="text-success">(submitted: "{p.other_hate}" — you can revise it)</span>{/if}
</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
@@ -269,7 +310,7 @@
</div> </div>
{/if} {/if}
{/each} {/each}
{#if state.players.filter(p => (p.other_like_from_player_id === state.player.id && !p.other_like) || (p.other_hate_from_player_id === state.player.id && !p.other_hate)).length === 0} {#if state.players.filter(p => p.other_like_from_player_id === state.player.id || p.other_hate_from_player_id === state.player.id).length === 0}
<p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p> <p class="inbox-empty-message text-muted italic">No pending tasks! Wait for other players to assign you tasks.</p>
{/if} {/if}
</div> </div>
@@ -312,6 +353,9 @@
</form> </form>
{:else if phase === 'character_creation'} {:else if phase === 'character_creation'}
<div class="submitted-techniques text-center mt-4"> <div class="submitted-techniques text-center mt-4">
{#if techError}
<div class="alert alert-danger">{techError}</div>
{/if}
<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>
@@ -319,6 +363,7 @@
<span class="spinner-small"></span> <span class="spinner-small"></span>
Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps. Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
</p> </p>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
</div> </div>
{:else if phase === 'swap_techniques'} {:else if phase === 'swap_techniques'}
<p class="section-desc"> <p class="section-desc">
@@ -429,6 +474,9 @@
</div> </div>
{:else} {:else}
<div class="assigned-techs text-center mt-4"> <div class="assigned-techs text-center mt-4">
{#if faceError}
<div class="alert alert-danger">{faceError}</div>
{/if}
<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>
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div> <div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div> <div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
@@ -436,6 +484,9 @@
<span class="spinner-small"></span> <span class="spinner-small"></span>
Ready! Waiting for other players to finish J/Q/K assignment... Ready! Waiting for other players to finish J/Q/K assignment...
</p> </p>
{#if phase === 'assign_techniques'}
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -32,11 +32,13 @@
good_at_math: state.player.good_at_math || '' good_at_math: state.player.good_at_math || ''
}; };
let savingBasic = false; let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() { async function saveBasicDetails() {
savingBasic = true; savingBasic = true;
try { try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails); await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) { } catch(e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -44,6 +46,16 @@
} }
} }
function reviseBasicDetails() {
basicDetails = {
avatar_look: me.avatar_look || '',
avatar_smell: me.avatar_smell || '',
first_words: me.first_words || '',
good_at_math: me.good_at_math || ''
};
editingBasic = true;
}
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math); $: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
$: likeDone = !me.other_like_from_player_id || !!me.other_like; $: likeDone = !me.other_like_from_player_id || !!me.other_like;
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate; $: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
@@ -113,8 +125,8 @@
// My outstanding tasks for other recruits // My outstanding tasks for other recruits
$: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({ $: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({
recruit: p, recruit: p,
like: p.other_like_from_player_id === me.id && !p.other_like, like: p.other_like_from_player_id === me.id,
hate: p.other_hate_from_player_id === me.id && !p.other_hate, hate: p.other_hate_from_player_id === me.id,
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id) techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
})).filter(t => t.like || t.hate || t.techSlots.length > 0); })).filter(t => t.like || t.hate || t.techSlots.length > 0);
@@ -141,13 +153,14 @@
<!-- MY NEW RECRUIT SHEET --> <!-- MY NEW RECRUIT SHEET -->
<div class="card glass-panel section-basic"> <div class="card glass-panel section-basic">
<h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3> <h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete} {#if basicComplete && !editingBasic}
<div class="read-only-sheet"> <div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {me.avatar_look}</div> <div class="record-line"><strong>Look:</strong> {me.avatar_look}</div>
<div class="record-line"><strong>Smell:</strong> {me.avatar_smell}</div> <div class="record-line"><strong>Smell:</strong> {me.avatar_smell}</div>
<div class="record-line"><strong>First Words:</strong> "{me.first_words}"</div> <div class="record-line"><strong>First Words:</strong> "{me.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div> <div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div>
</div> </div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else} {:else}
<form on:submit|preventDefault={saveBasicDetails} class="creation-form"> <form on:submit|preventDefault={saveBasicDetails} class="creation-form">
<div class="form-group"> <div class="form-group">
@@ -245,7 +258,10 @@
{#each myTasks as task (task.recruit.id)} {#each myTasks as task (task.recruit.id)}
{#if task.like} {#if task.like}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you LIKE about {displayName(task.recruit)}?</label> <label>
What do you LIKE about {displayName(task.recruit)}?
{#if task.recruit.other_like}<span class="text-success">(submitted: "{task.recruit.other_like}" — you can revise it)</span>{/if}
</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
@@ -255,7 +271,10 @@
{/if} {/if}
{#if task.hate} {#if task.hate}
<div class="inbox-item"> <div class="inbox-item">
<label>What do you HATE about {displayName(task.recruit)}?</label> <label>
What do you HATE about {displayName(task.recruit)}?
{#if task.recruit.other_hate}<span class="text-success">(submitted: "{task.recruit.other_hate}" — you can revise it)</span>{/if}
</label>
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/> <input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if} {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}

View File

@@ -125,6 +125,44 @@ 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 unsubmit_techniques(db: Session, player_id: str):
"""Unlocks a player's submitted techniques for revision. Only possible
while the crew is still in character_creation: clearing the submission
also keeps the swap phase from triggering until they resubmit."""
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 != "character_creation":
return False, "Techniques can't be revised once swapping has begun."
if player.needs_reroll:
return False, "You're spectating until recruit creation."
if not json.loads(player.created_techniques):
return False, "You haven't submitted techniques yet."
player.created_techniques = "[]"
db.add(player)
db.commit()
return True, "Techniques unlocked for revision."
def unassign_face_techniques(db: Session, player_id: str):
"""Unlocks a player's J/Q/K assignment for revision. Clearing is_ready
keeps the game from advancing to scene setup until they reassign."""
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 != "assign_techniques":
return False, "Face-card assignment isn't in progress."
if not player.is_ready:
return False, "You haven't assigned your face cards yet."
player.tech_jack = ""
player.tech_queen = ""
player.tech_king = ""
player.is_ready = False
db.add(player)
db.commit()
return True, "Face cards unlocked for revision."
def swap_participants(game: Game) -> list: def swap_participants(game: Game) -> list:
"""Players taking part in initial character creation (late joiners with a """Players taking part in initial character creation (late joiners with a
queued recruit re-roll spectate and are excluded).""" queued recruit re-roll spectate and are excluded)."""

View File

@@ -138,6 +138,30 @@ def player_submit_techniques(
return JSONResponse({"error": error}, status_code=400) return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Unlock your submitted techniques for revision (before swapping begins)
@router.post("/game/{game_id}/player/{player_id}/unsubmit-techniques")
def player_unsubmit_techniques(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.unsubmit_techniques(db, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Unlock your J/Q/K assignment for revision (while assignment is in progress)
@router.post("/game/{game_id}/player/{player_id}/unassign-face-techniques")
def player_unassign_face_techniques(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.unassign_face_techniques(db, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Offer one of your held techniques to another player as a blind swap # Offer one of your held techniques to another player as a blind swap
@router.post("/game/{game_id}/player/{player_id}/offer-swap") @router.post("/game/{game_id}/player/{player_id}/offer-swap")
def player_offer_swap( def player_offer_swap(

View File

@@ -1124,6 +1124,84 @@ def test_submit_techniques_rejects_collisions(session):
session.refresh(game) session.refresh(game)
assert game.phase == "swap_techniques" assert game.phase == "swap_techniques"
def test_unsubmit_techniques(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")
game.phase = "character_creation"
session.add(game)
session.commit()
# Nothing to unlock before submitting
ok, _ = crud.unsubmit_techniques(session, p1.id)
assert not ok
err = crud.submit_techniques(session, p1.id, "Tail Whip", "Pocket Sand", "Cheese Decoy")
assert err is None
# Unlock clears the submission so it can be revised
ok, msg = crud.unsubmit_techniques(session, p1.id)
assert ok, msg
session.refresh(p1)
assert json.loads(p1.created_techniques) == []
# Resubmitting (with everyone else done) still triggers the swap
err = crud.submit_techniques(session, p2.id, "Plank Pirouette", "Bilge Bomb", "Rat King Roar")
assert err is None
err = crud.submit_techniques(session, p1.id, "Tail Whip", "Pocket Sand", "Cheese Decoy")
assert err is None
session.refresh(game)
assert game.phase == "swap_techniques"
# Once swapping has begun, revision is locked out
ok, _ = crud.unsubmit_techniques(session, p1.id)
assert not ok
def test_unassign_face_techniques(session):
game, p1, p2, p3 = make_swap_game(session)
crud.auto_assign_techniques(session, game)
session.refresh(game)
assert game.phase == "scene_setup"
# Wrong phase is rejected
ok, _ = crud.unassign_face_techniques(session, p1.id)
assert not ok
# Fresh game stopped in assign_techniques: two assign, one revises
game, p1, p2, p3 = make_swap_game(session)
game.phase = "assign_techniques"
for p in (p1, p2, p3):
p.swapped_techniques = p.created_techniques
session.add(p)
session.add(game)
session.commit()
# Not assigned yet -> nothing to unlock
ok, _ = crud.unassign_face_techniques(session, p1.id)
assert not ok
t1 = json.loads(p1.swapped_techniques)
ok, msg = crud.assign_face_card_techniques(session, p1.id, t1[0], t1[1], t1[2])
assert ok, msg
ok, msg = crud.unassign_face_techniques(session, p1.id)
assert ok, msg
session.refresh(p1)
assert not p1.tech_jack and not p1.tech_queen and not p1.tech_king
assert not p1.is_ready
# Others finishing while p1 is revising must not advance the game
for p in (p2, p3):
t = json.loads(p.swapped_techniques)
ok, msg = crud.assign_face_card_techniques(session, p.id, t[0], t[1], t[2])
assert ok, msg
session.refresh(game)
assert game.phase == "assign_techniques"
# p1 reassigns (swapped order) and the game moves on
ok, msg = crud.assign_face_card_techniques(session, p1.id, t1[2], t1[0], t1[1])
assert ok, msg
session.refresh(game)
assert game.phase == "scene_setup"
def make_swap_game(session): def make_swap_game(session):
"""Helper: 3 players in the swap_techniques phase, each holding their own techniques.""" """Helper: 3 players in the swap_techniques phase, each holding their own techniques."""
game = crud.create_game(session) game = crud.create_game(session)