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

@@ -16,11 +16,13 @@
};
let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() {
savingBasic = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) {
console.error(e);
} 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.
// This is a fallback for games that entered the phase before assignments existed.
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)
let offerTech = '', offerTargetId = '';
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
// task never bleeds into another.
let inboxAnswers = {};
@@ -171,7 +205,7 @@
<div class="card glass-panel section-basic">
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete}
{#if basicComplete && !editingBasic}
<!-- Saved View -->
<div class="read-only-sheet">
<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>Good at Math?</strong> {state.player.good_at_math}</div>
</div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else}
<!-- Form View -->
<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>
<div class="inbox-list">
{#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">
<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">
<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}
@@ -258,9 +296,12 @@
</div>
</div>
{/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">
<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">
<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}
@@ -269,7 +310,7 @@
</div>
{/if}
{/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>
{/if}
</div>
@@ -312,6 +353,9 @@
</form>
{:else if phase === 'character_creation'}
<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">#2: {createdTechniques[1]}</div>
<div class="tech-chip">#3: {createdTechniques[2]}</div>
@@ -319,6 +363,7 @@
<span class="spinner-small"></span>
Techniques submitted! Once everyone submits, you'll trade them all away in blind swaps.
</p>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseTechniques}>✏️ Revise Techniques</button>
</div>
{:else if phase === 'swap_techniques'}
<p class="section-desc">
@@ -429,6 +474,9 @@
</div>
{:else}
<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">Q</span> {state.player.tech_queen}</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>
Ready! Waiting for other players to finish J/Q/K assignment...
</p>
{#if phase === 'assign_techniques'}
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseFaceTechniques}>✏️ Revise Assignment</button>
{/if}
</div>
{/if}
</div>

View File

@@ -32,11 +32,13 @@
good_at_math: state.player.good_at_math || ''
};
let savingBasic = false;
let editingBasic = false;
async function saveBasicDetails() {
savingBasic = true;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails);
editingBasic = false;
} catch(e) {
console.error(e);
} 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);
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
@@ -113,8 +125,8 @@
// My outstanding tasks for other recruits
$: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({
recruit: p,
like: p.other_like_from_player_id === me.id && !p.other_like,
hate: p.other_hate_from_player_id === me.id && !p.other_hate,
like: p.other_like_from_player_id === me.id,
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)
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
@@ -141,13 +153,14 @@
<!-- MY NEW RECRUIT SHEET -->
<div class="card glass-panel section-basic">
<h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete}
{#if basicComplete && !editingBasic}
<div class="read-only-sheet">
<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>First Words:</strong> "{me.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div>
</div>
<button type="button" class="btn btn-secondary btn-small mt-4" on:click={reviseBasicDetails}>✏️ Revise Records</button>
{:else}
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
<div class="form-group">
@@ -245,7 +258,10 @@
{#each myTasks as task (task.recruit.id)}
{#if task.like}
<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">
<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}
@@ -255,7 +271,10 @@
{/if}
{#if task.hate}
<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">
<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}