Dev mode revamp
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -1,8 +1,6 @@
|
||||
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. 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 (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.
|
||||
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
default = false;
|
||||
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 {
|
||||
@@ -129,6 +134,9 @@
|
||||
|
||||
environment = {
|
||||
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 = {
|
||||
|
||||
@@ -428,26 +428,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
|
||||
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||
|
||||
@@ -110,61 +110,9 @@
|
||||
$: 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);
|
||||
|
||||
// Dev Mode
|
||||
let devMode = false;
|
||||
let autoFilling = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
|
||||
</script>
|
||||
|
||||
@@ -172,23 +120,7 @@
|
||||
<div class="view-header text-center">
|
||||
<h2>Character Sheet Creation</h2>
|
||||
<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>
|
||||
|
||||
{#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 -->
|
||||
<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>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="first_words">What were your first words after transforming?</label>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<div class="input-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,7 +221,7 @@
|
||||
<label>What do you HATE about {displayName(p)}?</label>
|
||||
<div class="input-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,21 +247,21 @@
|
||||
<label for="tech1">Technique #1:</label>
|
||||
<div class="input-row">
|
||||
<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 class="form-group">
|
||||
<label for="tech2">Technique #2:</label>
|
||||
<div class="input-row">
|
||||
<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 class="form-group">
|
||||
<label for="tech3">Technique #3:</label>
|
||||
<div class="input-row">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
|
||||
@@ -25,13 +25,22 @@
|
||||
{#if open}
|
||||
<nav class="menu-dropdown">
|
||||
{#each links as link}
|
||||
{#if link.action}
|
||||
<button
|
||||
class="menu-item"
|
||||
title={link.title}
|
||||
on:click={() => { open = false; link.action(); }}
|
||||
>{link.label}</button>
|
||||
{: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}
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -68,7 +77,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-dropdown a {
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
@@ -77,11 +86,14 @@
|
||||
font-family: var(--font-heading);
|
||||
text-align: right;
|
||||
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);
|
||||
}
|
||||
.menu-dropdown a + a {
|
||||
.menu-item + .menu-item {
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
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;
|
||||
$: recruits = state.players.filter(p => p.needs_reroll);
|
||||
$: iAmRecruit = me.needs_reroll;
|
||||
@@ -150,28 +154,28 @@
|
||||
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="first_words">What were your first words after transforming?</label>
|
||||
<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">
|
||||
<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 class="form-group">
|
||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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"/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +258,7 @@
|
||||
<label>What do you HATE about {displayName(task.recruit)}?</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"/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,7 +274,7 @@
|
||||
{/if}
|
||||
<div class="input-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { extraMenuLinks } from '../lib/menu';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
|
||||
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||
@@ -58,12 +59,60 @@
|
||||
intervalId = setInterval(fetchState, 30000);
|
||||
});
|
||||
|
||||
// Surface the creator-only Admin link in the corner menu
|
||||
$: extraMenuLinks.set(
|
||||
state?.player?.is_creator
|
||||
? [{ label: '🛠️ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' }]
|
||||
: []
|
||||
);
|
||||
// Creator-only corner menu entries: Dev Mode toggle (all phases), the
|
||||
// Skip Character Creation shortcut (Dev Mode only), and the Admin link.
|
||||
async function toggleDevMode() {
|
||||
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(() => {
|
||||
destroyed = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlmodel import Session, select
|
||||
@@ -222,6 +223,17 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
|
||||
|
||||
# --- 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:
|
||||
deck = cards.get_fresh_deck()
|
||||
game = Game(
|
||||
@@ -229,7 +241,8 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||
phase="lobby",
|
||||
current_scene_number=1,
|
||||
extra_obstacles=0,
|
||||
crew_name=crew_name
|
||||
crew_name=crew_name,
|
||||
dev_mode=dev_mode_default()
|
||||
)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
@@ -263,6 +263,79 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
|
||||
db.commit()
|
||||
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) ---
|
||||
#
|
||||
# Dead Pi-Rats who choose to re-roll (and players who joined after initial
|
||||
|
||||
@@ -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 ###
|
||||
@@ -8,6 +8,7 @@ class Game(SQLModel, table=True):
|
||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
crew_name: Optional[str] = Field(default=None)
|
||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
|
||||
dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table
|
||||
current_scene_number: int = Field(default=1)
|
||||
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
|
||||
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
|
||||
|
||||
@@ -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 .database import get_session
|
||||
from . import crud
|
||||
|
||||
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
|
||||
@router.get("/game/{game_id}/admin")
|
||||
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
||||
|
||||
@@ -1291,3 +1291,68 @@ def test_websocket_state_push():
|
||||
assert ws.receive_json() == {"type": "state_changed"}
|
||||
finally:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user