Dev mode revamp

This commit is contained in:
2026-06-12 11:50:34 -07:00
parent f61201144f
commit 3395b127b4
13 changed files with 347 additions and 132 deletions

View File

@@ -1,8 +1,6 @@
A roughly triaged list of features and improvements needed for the app, in descending order of criticality. A roughly triaged list of features and improvements needed for the app, in descending order of criticality.
- [ ] **Dev Mode.** Move the dev mode toggle in character creation to the hamburger mode (and leave it around for all phases in case we want to add other dev mode features). It's now only visible to admin player, but affects all players. For non-admin players, the "suggest" buttons in the character creator are hidden unless dev mode is on. For the admin player, they also get the "auto-assign everything" button in the hamburger menu, renamed to "Skip Character Creation", which fully automates any incomplete parts of character creation for all players and moves on to the pre-scene phase. - [ ] **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.
- [ ] **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. 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.

View File

@@ -117,6 +117,11 @@
default = false; default = false;
description = "Open ports in the firewall for the service."; description = "Open ports in the firewall for the service.";
}; };
devModeDefault = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether new games start with Dev Mode (testing shortcuts) enabled.";
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -129,6 +134,9 @@
environment = { environment = {
DATABASE_URL = "sqlite:///${cfg.databasePath}"; DATABASE_URL = "sqlite:///${cfg.databasePath}";
# Unset means "local checkout" and defaults Dev Mode on, so the
# service must always set it explicitly.
PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault;
}; };
serviceConfig = { serviceConfig = {

View File

@@ -428,26 +428,3 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* --- Dev mode --- */
.dev-toggle {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 0.5rem;
}
.dev-banner {
background: var(--well);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin: 0 auto 1.5rem;
max-width: 56rem;
}

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions'; import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards'; import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte'; import FaceCardAssigner from './FaceCardAssigner.svelte';
@@ -110,61 +110,9 @@
$: 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);
// Dev Mode // Dev Mode (toggled by the creator from the corner menu) reveals the
let devMode = false; // Suggest buttons; in real games players write their own material.
let autoFilling = false; $: devMode = !!state.game.dev_mode;
async function autoFillAll() {
autoFilling = true;
try {
// 1. Basic details — drawn from the same pools as the Suggest buttons
if (!basicComplete) {
basicDetails = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math')
};
await saveBasicDetails();
}
// 2. Answer pending inbox questions with pool suggestions
for (const p of state.players) {
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
await submitDelegatedAnswer('like', p.id);
}
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
await submitDelegatedAnswer('hate', p.id);
}
}
// 3. Techniques — 3 distinct pool picks; retry if another player
// grabbed the same name first (the backend rejects collisions)
if (createdTechniques.length === 0) {
for (let attempt = 0; attempt < 5; attempt++) {
[tech1, tech2, tech3] = await getTechniqueSuggestions(3);
await submitTechniques();
if (!techError) break;
}
}
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
// to process the state, because swapped_techniques comes from the server once everyone is done.
if (!techniquesComplete && swappedTechniques.length === 3) {
jackTech = swappedTechniques[0];
queenTech = swappedTechniques[1];
kingTech = swappedTechniques[2];
await assignFaceTechniques();
}
} catch (e) {
console.error(e);
} finally {
autoFilling = false;
}
}
</script> </script>
@@ -172,24 +120,8 @@
<div class="view-header text-center"> <div class="view-header text-center">
<h2>Character Sheet Creation</h2> <h2>Character Sheet Creation</h2>
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p> <p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
<div class="dev-toggle">
<input type="checkbox" id="devModeToggle" bind:checked={devMode}/>
<label for="devModeToggle">Dev Mode</label>
</div>
</div> </div>
{#if devMode}
<div class="dev-banner">
<span class="text-accent font-bold">🛠 Dev Mode Tools</span>
<button
class="btn btn-primary"
on:click={autoFillAll}
disabled={autoFilling}>
{autoFilling ? 'Auto-Filling...' : 'Auto-Fill Available Fields'}
</button>
</div>
{/if}
<!-- MAIN PANEL: Grid for Layout --> <!-- MAIN PANEL: Grid for Layout -->
<div class="creation-grid max-w-5xl mx-auto"> <div class="creation-grid max-w-5xl mx-auto">
@@ -212,28 +144,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label> <label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field"> <input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label> <label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field"> <input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="first_words">What were your first words after transforming?</label> <label for="first_words">What were your first words after transforming?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field"> <input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label> <label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field"> <input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button> <button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
@@ -279,7 +211,7 @@
<label>What do you LIKE about {displayName(p)}?</label> <label>What do you LIKE about {displayName(p)}?</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"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -289,7 +221,7 @@
<label>What do you HATE about {displayName(p)}?</label> <label>What do you HATE about {displayName(p)}?</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"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -315,21 +247,21 @@
<label for="tech1">Technique #1:</label> <label for="tech1">Technique #1:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field"> <input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech1 = await getTechniqueSuggestion([tech2, tech3])}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="tech2">Technique #2:</label> <label for="tech2">Technique #2:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field"> <input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech2 = await getTechniqueSuggestion([tech1, tech3])}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="tech3">Technique #3:</label> <label for="tech3">Technique #3:</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field"> <input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={async () => tech3 = await getTechniqueSuggestion([tech1, tech2])}>Suggest</button>{/if}
</div> </div>
</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>

View File

@@ -25,13 +25,22 @@
{#if open} {#if open}
<nav class="menu-dropdown"> <nav class="menu-dropdown">
{#each links as link} {#each links as link}
<a {#if link.action}
href={link.href} <button
target={link.external ? '_blank' : undefined} class="menu-item"
rel={link.external ? 'noopener' : undefined} title={link.title}
title={link.title} on:click={() => { open = false; link.action(); }}
on:click={() => (open = false)} >{link.label}</button>
>{link.label}</a> {:else}
<a
class="menu-item"
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noopener' : undefined}
title={link.title}
on:click={() => (open = false)}
>{link.label}</a>
{/if}
{/each} {/each}
</nav> </nav>
{/if} {/if}
@@ -68,7 +77,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
} }
.menu-dropdown a { .menu-item {
padding: 8px 16px; padding: 8px 16px;
color: var(--accent); color: var(--accent);
font-size: 0.85rem; font-size: 0.85rem;
@@ -77,11 +86,14 @@
font-family: var(--font-heading); font-family: var(--font-heading);
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
background: none;
border: none;
cursor: pointer;
} }
.menu-dropdown a:hover { .menu-item:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent);
} }
.menu-dropdown a + a { .menu-item + .menu-item {
border-top: 1px solid var(--edge-soft); border-top: 1px solid var(--edge-soft);
} }
</style> </style>

View File

@@ -16,6 +16,10 @@
return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : []; return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : [];
} }
// Dev Mode (toggled by the creator from the corner menu) reveals the
// Suggest buttons; in real games players write their own material.
$: devMode = !!state.game.dev_mode;
$: me = state.player; $: me = state.player;
$: recruits = state.players.filter(p => p.needs_reroll); $: recruits = state.players.filter(p => p.needs_reroll);
$: iAmRecruit = me.needs_reroll; $: iAmRecruit = me.needs_reroll;
@@ -150,28 +154,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label> <label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field"> <input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label> <label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field"> <input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="first_words">What were your first words after transforming?</label> <label for="first_words">What were your first words after transforming?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field"> <input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label> <label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row"> <div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field"> <input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button> <button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
@@ -244,7 +248,7 @@
<label>What do you LIKE about {displayName(task.recruit)}?</label> <label>What do you LIKE about {displayName(task.recruit)}?</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"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -254,7 +258,7 @@
<label>What do you HATE about {displayName(task.recruit)}?</label> <label>What do you HATE about {displayName(task.recruit)}?</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"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button> <button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
</div> </div>
</div> </div>
@@ -270,7 +274,7 @@
{/if} {/if}
<div class="input-row"> <div class="input-row">
<input type="text" class="input-field" bind:value={techDrafts[`${task.recruit.id}:${slot.slot}`]} placeholder="e.g. Parabolic boarding bounce"/> <input type="text" class="input-field" bind:value={techDrafts[`${task.recruit.id}:${slot.slot}`]} placeholder="e.g. Parabolic boarding bounce"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => suggestTechnique(task.recruit.id, slot.slot)}>Suggest</button> {#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggestTechnique(task.recruit.id, slot.slot)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(techDrafts[`${task.recruit.id}:${slot.slot}`] || '').trim()} on:click={() => contributeTechnique(task.recruit.id, slot.slot)}>Submit</button> <button class="btn btn-primary" disabled={!(techDrafts[`${task.recruit.id}:${slot.slot}`] || '').trim()} on:click={() => contributeTechnique(task.recruit.id, slot.slot)}>Submit</button>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { extraMenuLinks } from '../lib/menu'; import { extraMenuLinks } from '../lib/menu';
import { getSuggestion } from '../lib/suggestions';
import LobbyPhase from '../components/LobbyPhase.svelte'; import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte'; import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
@@ -58,12 +59,60 @@
intervalId = setInterval(fetchState, 30000); intervalId = setInterval(fetchState, 30000);
}); });
// Surface the creator-only Admin link in the corner menu // Creator-only corner menu entries: Dev Mode toggle (all phases), the
$: extraMenuLinks.set( // Skip Character Creation shortcut (Dev Mode only), and the Admin link.
state?.player?.is_creator async function toggleDevMode() {
? [{ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' }] try {
: [] await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', {
); enabled: !state.game.dev_mode
});
} catch (err) {
error = err.message;
}
}
async function skipCharacterCreation() {
// Suggested values for everyone's free-text fields; the backend only
// applies them to fields players haven't filled in themselves.
const fills = {};
for (const p of state.players) {
fills[p.id] = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math'),
like: getSuggestion('like'),
hate: getSuggestion('hate'),
};
}
try {
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
fills: JSON.stringify(fills)
});
} catch (err) {
error = err.message;
}
}
$: {
const items = [];
if (state?.player?.is_creator) {
items.push({
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
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')) {
items.push({
label: '⏭️ Skip Character Creation',
action: skipCharacterCreation,
title: 'Auto-fill anything unfinished for all players and jump to scene setup',
});
}
items.push({ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' });
}
extraMenuLinks.set(items);
}
onDestroy(() => { onDestroy(() => {
destroyed = true; destroyed = true;

View File

@@ -1,4 +1,5 @@
import json import json
import os
import random import random
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -222,6 +223,17 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
# --- CRUD Operations --- # --- CRUD Operations ---
def dev_mode_default() -> bool:
"""
Initial Dev Mode state for new games, from $PIRATS_DEV_MODE. Unset means a
local checkout, where defaulting on is convenient for testing; production
deployments (the Nix service) always set it, "false" by default.
"""
val = os.environ.get("PIRATS_DEV_MODE")
if val is None:
return True
return val.strip().lower() in ("1", "true", "yes", "on")
def create_game(db: Session, crew_name: Optional[str] = None) -> Game: def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck() deck = cards.get_fresh_deck()
game = Game( game = Game(
@@ -229,7 +241,8 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
phase="lobby", phase="lobby",
current_scene_number=1, current_scene_number=1,
extra_obstacles=0, extra_obstacles=0,
crew_name=crew_name crew_name=crew_name,
dev_mode=dev_mode_default()
) )
db.add(game) db.add(game)
db.commit() db.commit()

View File

@@ -263,6 +263,79 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
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")
def skip_character_creation(db: Session, game: Game, fills: dict):
"""
Dev Mode shortcut: auto-completes every unfinished part of character
creation for ALL players and advances to scene setup. `fills` maps
player_id -> suggested values for the free-text fields (avatar_look,
avatar_smell, first_words, good_at_math, like, hate), supplied by the
admin's client from the frontend suggestion pools. Techniques come from
the backend pool. Already-filled fields are left untouched.
"""
from .cards import TECHNIQUE_SUGGESTIONS
if game.phase not in ("character_creation", "swap_techniques"):
return False, "Character creation isn't in progress."
if game.phase == "character_creation":
# 1. Basic Rat Records
for p in game.players:
f = fills.get(p.id, {})
p.avatar_look = p.avatar_look or f.get("avatar_look", "A nondescript test rat")
p.avatar_smell = p.avatar_smell or f.get("avatar_smell", "of fresh paint")
p.first_words = p.first_words or f.get("first_words", "Is this thing on?")
p.good_at_math = p.good_at_math or f.get("good_at_math", "Untested")
if not p.completed_personal_2:
p.name = recruit_name(p)
db.add(p)
# 2. Delegated Like/Hate questions
auto_delegate_all(db, game)
for p in game.players:
f = fills.get(p.id, {})
if p.other_like_from_player_id and not p.other_like:
p.other_like = f.get("like", "Their impeccable punctuality")
if p.other_hate_from_player_id and not p.other_hate:
p.other_hate = f.get("hate", "Their impeccable punctuality")
db.add(p)
# 3. Created techniques, drawn from the backend pool without collisions
taken = set()
for p in game.players:
for t in json.loads(p.created_techniques):
taken.add(t.strip().lower())
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in taken]
random.shuffle(available)
for p in game.players:
techs = json.loads(p.created_techniques)
if len(techs) < 3 or not all(techs):
techs = [available.pop() for _ in range(3)]
p.created_techniques = json.dumps(techs)
db.add(p)
db.commit()
# 4. Shuffle and distribute (moves the game to swap_techniques)
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":
return False, "Could not finish character creation; check the event log."
return True, "Character creation skipped."
# --- Recruit Creation (death re-rolls and late joiners) --- # --- Recruit Creation (death re-rolls and late joiners) ---
# #
# Dead Pi-Rats who choose to re-roll (and players who joined after initial # Dead Pi-Rats who choose to re-roll (and players who joined after initial

View File

@@ -0,0 +1,32 @@
"""Add Game.dev_mode
Revision ID: 6071db83ba81
Revises: 71c6d3e50ba3
Create Date: 2026-06-12 11:37:36.965417
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '6071db83ba81'
down_revision = '71c6d3e50ba3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.add_column(sa.Column('dev_mode', sa.Boolean(), nullable=False, server_default=sa.false()))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('game', schema=None) as batch_op:
batch_op.drop_column('dev_mode')
# ### end Alembic commands ###

View File

@@ -8,6 +8,7 @@ class Game(SQLModel, table=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", "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) 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)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]

View File

@@ -1,10 +1,61 @@
from fastapi import APIRouter, Request, Depends, HTTPException import json
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlmodel import Session from sqlmodel import Session
from .database import get_session from .database import get_session
from . import crud from . import crud
router = APIRouter() router = APIRouter()
def _get_creator(db: Session, game_id: str, player_id: str):
"""Resolves (game, player), requiring the player to be the game's creator."""
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player or player.game_id != game.id:
raise HTTPException(status_code=404, detail="Not found")
if not player.is_creator:
raise HTTPException(status_code=403, detail="Only the game creator can do that.")
return game, player
# Creator toggles Dev Mode for the whole table (reveals testing shortcuts)
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
def set_dev_mode(
game_id: str,
player_id: str,
enabled: bool = Form(...),
db: Session = Depends(get_session)
):
game, player = _get_creator(db, game_id, player_id)
if game.dev_mode != enabled:
game.dev_mode = enabled
db.add(game)
db.commit()
crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info")
return {"status": "ok", "dev_mode": game.dev_mode}
# Dev Mode shortcut: auto-complete character creation for everyone and move on
@router.post("/game/{game_id}/player/{player_id}/skip-character-creation")
def skip_character_creation_route(
game_id: str,
player_id: str,
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
db: Session = Depends(get_session)
):
game, player = _get_creator(db, game_id, player_id)
if not game.dev_mode:
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
try:
fills_dict = json.loads(fills) if fills else {}
if not isinstance(fills_dict, dict):
raise ValueError
except ValueError:
raise HTTPException(status_code=400, detail="fills must be a JSON object.")
crud.add_game_event(db, game.id, f"🛠 {player.name} is skipping the rest of character creation (Dev Mode).", kind="info")
ok, msg = crud.skip_character_creation(db, game, fills_dict)
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

@@ -1291,3 +1291,68 @@ def test_websocket_state_push():
assert ws.receive_json() == {"type": "state_changed"} assert ws.receive_json() == {"type": "state_changed"}
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_skip_character_creation(session):
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")
session.refresh(game)
game.phase = "character_creation"
game.dev_mode = True
session.add(game)
session.commit()
# p1 did part of the work by hand; the skip must not overwrite it
p1.avatar_look = "Hand-written bandana"
session.add(p1)
session.commit()
crud.submit_techniques(session, p1.id, "Carlos Strike", "Hide", "Roll")
fills = {p.id: {
"avatar_look": f"Look for {p.name}",
"avatar_smell": "of test cheese",
"first_words": "Test!",
"good_at_math": "Only in tests",
"like": "Their test coverage",
"hate": "Their flaky tests",
} for p in game.players}
ok, msg = crud.skip_character_creation(session, game, fills)
assert ok, msg
session.refresh(game)
assert game.phase == "scene_setup"
for p in (p1, p2, p3):
session.refresh(p)
assert p.avatar_look and p.avatar_smell and p.first_words and p.good_at_math
assert p.other_like and p.other_hate
assert p.tech_jack and p.tech_queen and p.tech_king
# Everyone holds techniques created by someone else
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)
session.refresh(p1)
assert p1.avatar_look == "Hand-written bandana"
assert json.loads(p1.created_techniques) == ["Carlos Strike", "Hide", "Roll"]
# Ranks were rolled: one rank-3 Deep, one rank-1 Pi-Rat, rest rank 2
ranks = sorted(p.rank for p in (p1, p2, p3))
assert ranks == [1, 2, 3]
def test_skip_character_creation_wrong_phase(session):
game = crud.create_game(session)
crud.add_player(session, game.id, "Carlos", is_creator=True)
session.refresh(game)
ok, msg = crud.skip_character_creation(session, game, {})
assert not ok
def test_dev_mode_default_env(session, monkeypatch):
# Unset = local checkout, defaults on
monkeypatch.delenv("PIRATS_DEV_MODE", raising=False)
assert crud.create_game(session).dev_mode is True
# Set = deployment controls it explicitly
monkeypatch.setenv("PIRATS_DEV_MODE", "false")
assert crud.create_game(session).dev_mode is False
monkeypatch.setenv("PIRATS_DEV_MODE", "true")
assert crud.create_game(session).dev_mode is True