Add per-player autosaving game notebooks
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
- [x] PvP challenges should linger in the UI after completion so that players can see the result, then dismiss it for themselves
|
- [x] PvP challenges should linger in the UI after completion so that players can see the result, then dismiss it for themselves
|
||||||
- [x] Add hat/gun icons next to player names on the player list to indicate if they're the captain and/or have a gat
|
- [x] Add hat/gun icons next to player names on the player list to indicate if they're the captain and/or have a gat
|
||||||
- [x] Replace red/green borders of successful/failed cards with checks and crosses in the upper corner of the card.
|
- [x] Replace red/green borders of successful/failed cards with checks and crosses in the upper corner of the card.
|
||||||
- [ ] Add a note taking area to store game state between sessions
|
- [x] Add a note taking area to store game state between sessions
|
||||||
|
|
||||||
## Polish
|
## Polish
|
||||||
|
|
||||||
|
|||||||
@@ -24,31 +24,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Corner button that toggles the inline Event Log without moving. */
|
|
||||||
.log-reopen-btn {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 132px;
|
|
||||||
height: 44px;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
|
||||||
border: 1px solid var(--edge);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-deep);
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: bold;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
cursor: pointer;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
.log-reopen-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.scene-view-layout {
|
.scene-view-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// Inline mode pins the log open as a column (scene phase) instead of the
|
// Inline mode pins the log open as a column (scene phase) instead of the
|
||||||
// floating, collapsible corner panel used in every other phase.
|
// floating, collapsible corner panel used in every other phase.
|
||||||
export let inline = false;
|
export let inline = false;
|
||||||
|
export let headerless = false;
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
||||||
@@ -195,12 +196,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if inline}
|
{#if inline && !headerless}
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
📜 Event Log
|
📜 Event Log
|
||||||
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
|
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !inline}
|
||||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import EventLog from './EventLog.svelte';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
export let open = true;
|
||||||
|
let tab = 'log';
|
||||||
|
let draft = '';
|
||||||
|
let saved = '';
|
||||||
|
let loaded = false;
|
||||||
|
let error = '';
|
||||||
|
let saving = false;
|
||||||
|
let timer;
|
||||||
|
let pendingSave;
|
||||||
|
const endpoint = `/game/${state.game.id}/player/${state.player.id}/notes`;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(endpoint);
|
||||||
|
draft = saved = data.text;
|
||||||
|
loaded = true;
|
||||||
|
} catch {
|
||||||
|
error = 'Couldn’t load notes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize writes so a slow earlier request cannot overwrite a newer draft.
|
||||||
|
async function save() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (pendingSave) {
|
||||||
|
await pendingSave;
|
||||||
|
if (error) return;
|
||||||
|
}
|
||||||
|
if (!loaded || draft === saved) return;
|
||||||
|
const text = draft;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
pendingSave = (async () => {
|
||||||
|
try {
|
||||||
|
await apiRequest(endpoint, 'PUT', { text });
|
||||||
|
saved = text;
|
||||||
|
} catch {
|
||||||
|
error = 'Couldn’t save notes';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
pendingSave = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await pendingSave;
|
||||||
|
if (!error && draft !== saved) await save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function edited() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(save, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(next) {
|
||||||
|
if (tab === 'notes') save();
|
||||||
|
tab = next;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabKey(event) {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === 'Home' ? 'log' : event.key === 'End' ? 'notes' : tab === 'log' ? 'notes' : 'log';
|
||||||
|
selectTab(next);
|
||||||
|
document.getElementById(`${next}-tab`)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
save();
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
onDestroy(() => { clearTimeout(timer); save(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(event) => { if (event.key === 'Escape' && open && tab === 'notes') close(); }} />
|
||||||
|
|
||||||
|
<div class="log-column" class:closed={!open}>
|
||||||
|
<section class="notebook-panel" class:notes-active={tab === 'notes'} aria-label="Game notebook">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-tabs" role="tablist" aria-label="Game notebook sections">
|
||||||
|
<button id="log-tab" role="tab" aria-selected={tab === 'log'} aria-controls="log-content" tabindex={tab === 'log' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('log')}>📜 Event Log</button>
|
||||||
|
<button id="notes-tab" role="tab" aria-selected={tab === 'notes'} aria-controls="notes-content" tabindex={tab === 'notes' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('notes')}>✎ My Notes</button>
|
||||||
|
</div>
|
||||||
|
<button class="close-panel" title="Close panel" aria-label="Close panel" on:click={close}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="log-content" role="tabpanel" aria-labelledby="log-tab" hidden={tab !== 'log'}>
|
||||||
|
<EventLog {state} inline headerless />
|
||||||
|
</div>
|
||||||
|
<div id="notes-content" class="notes-content" role="tabpanel" aria-labelledby="notes-tab" hidden={tab !== 'notes'}>
|
||||||
|
<p class="notes-hint">Your private notes for this game.</p>
|
||||||
|
<textarea aria-label="My notes" bind:value={draft} on:input={edited} disabled={!loaded} maxlength="50000" placeholder="Where did we leave off? Names, plans, loose ends…"></textarea>
|
||||||
|
<div class="save-status" role="status">
|
||||||
|
{#if error}
|
||||||
|
<span class="save-error">{error}</span>
|
||||||
|
<button on:click={() => loaded ? save() : load()}>Retry</button>
|
||||||
|
{:else}
|
||||||
|
{ !loaded ? 'Loading…' : saving ? 'Saving…' : draft !== saved ? 'Unsaved changes' : 'Saved' }
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notebook-controls">
|
||||||
|
{#if error}<span class="control-error" role="status">{error} · <button on:click={() => selectTab('notes')}>Open notes</button></span>{/if}
|
||||||
|
<button aria-expanded={open && tab === 'log'} on:click={() => open && tab === 'log' ? close() : selectTab('log')}>{open && tab === 'log' ? '✕ Close Log' : '📜 Event Log'}</button>
|
||||||
|
<button aria-expanded={open && tab === 'notes'} on:click={() => open && tab === 'notes' ? close() : selectTab('notes')}>{open && tab === 'notes' ? '✕ Close Notes' : '✎ My Notes'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.closed { display: none; }
|
||||||
|
.notebook-panel { position: sticky; top: 3.5rem; background: var(--bg-deep); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); overflow: hidden; }
|
||||||
|
.panel-header, .panel-tabs { display: flex; align-items: stretch; }
|
||||||
|
.panel-header { border-bottom: 1px solid var(--edge-soft); }
|
||||||
|
.panel-tabs { flex: 1; }
|
||||||
|
button { cursor: pointer; color: var(--text); font-family: var(--font-heading); font-weight: bold; }
|
||||||
|
.panel-tabs button, .close-panel { background: transparent; border: 0; padding: 12px 10px; }
|
||||||
|
.panel-tabs button { flex: 1; border-bottom: 2px solid transparent; }
|
||||||
|
.panel-tabs button[aria-selected=true] { color: var(--accent); border-bottom-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||||
|
button:hover { background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep)); }
|
||||||
|
.notes-content { padding: 12px; }
|
||||||
|
.notes-content[hidden] { display: none; }
|
||||||
|
.notes-hint { margin: 0 0 10px; color: var(--text-muted); font-size: .85rem; }
|
||||||
|
textarea { display: block; box-sizing: border-box; width: 100%; height: min(55vh, 520px); min-height: 180px; resize: vertical; padding: 12px; font: inherit; line-height: 1.5; color: var(--text); background: var(--surface-raised); border: 1px solid var(--edge); border-radius: var(--radius-sm); }
|
||||||
|
textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.save-status { display: flex; align-items: center; gap: 8px; min-height: 30px; padding-top: 8px; color: var(--text-muted); font-size: .8rem; }
|
||||||
|
.save-status button { border: 1px solid var(--edge); border-radius: 4px; background: var(--surface-raised); }
|
||||||
|
.save-error, .control-error { color: var(--danger); }
|
||||||
|
.notebook-controls { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; gap: 8px; }
|
||||||
|
.notebook-controls > button { width: 132px; height: 44px; background: var(--bg-deep); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); }
|
||||||
|
.control-error { position: absolute; bottom: 52px; right: 0; width: max-content; max-width: 90vw; background: var(--bg-deep); padding: 8px; border: 1px solid var(--danger); border-radius: var(--radius-sm); font-size: .85rem; }
|
||||||
|
.control-error button { color: var(--text); background: transparent; border: 0; text-decoration: underline; }
|
||||||
|
.notebook-panel :global(.floating-event-log.inline) { position: static; border: none; border-radius: 0; box-shadow: none; max-height: calc(100dvh - 12rem); }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.notebook-panel { position: static; }
|
||||||
|
.notebook-panel.notes-active { position: fixed; z-index: 1002; top: auto; bottom: 0; left: 0; right: 0; border-radius: var(--radius-md) var(--radius-md) 0 0; max-height: 90dvh; }
|
||||||
|
.notes-active textarea { height: 55dvh; min-height: 100px; resize: none; }
|
||||||
|
.notebook-controls { right: 12px; bottom: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,12 +4,9 @@
|
|||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import CrewColumn from './scene/CrewColumn.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
import EventLog from './EventLog.svelte';
|
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
// The inline Event Log can be toggled from a fixed corner button to declutter.
|
|
||||||
let logOpen = true;
|
|
||||||
|
|
||||||
let dismissedDuelIds = [];
|
let dismissedDuelIds = [];
|
||||||
$: dismissalKey = `dismissed-duels:${state.game.id}:${state.player.id}:${state.game.current_scene_number}`;
|
$: dismissalKey = `dismissed-duels:${state.game.id}:${state.player.id}:${state.game.current_scene_number}`;
|
||||||
@@ -38,7 +35,7 @@
|
|||||||
$: showChallengeArea = isDeep || openChallenges.length > 0 || completedDuels.length > 0;
|
$: showChallengeArea = isDeep || openChallenges.length > 0 || completedDuels.length > 0;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
<div class="scene-content" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
<!-- LEFT COLUMN: The Crew -->
|
<!-- LEFT COLUMN: The Crew -->
|
||||||
<CrewColumn {state} />
|
<CrewColumn {state} />
|
||||||
|
|
||||||
@@ -81,19 +78,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
|
|
||||||
{#if logOpen}
|
|
||||||
<div class="log-column">
|
|
||||||
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<style>
|
||||||
class="log-reopen-btn"
|
.scene-content { display: contents; }
|
||||||
aria-expanded={logOpen}
|
</style>
|
||||||
title={logOpen ? 'Hide the event log' : 'Show the event log'}
|
|
||||||
on:click={() => (logOpen = !logOpen)}
|
|
||||||
>
|
|
||||||
{logOpen ? '✕ Close Log' : '📜 Event Log'}
|
|
||||||
</button>
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
||||||
|
|
||||||
export const VERSION = 47;
|
export const VERSION = 48;
|
||||||
|
|
||||||
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
export const CHANGELOG = [
|
export const CHANGELOG = [
|
||||||
|
{ version: 48, date: '2026-09-04', changes: ['Keep your own notes alongside the Event Log. Notes save automatically between sessions, including when you close the panel, and stay intact when the game rewinds or you create a new Pi-Rat.'] },
|
||||||
{ version: 47, date: '2026-09-04', changes: ['Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.'] },
|
{ version: 47, date: '2026-09-04', changes: ['Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.'] },
|
||||||
{ version: 46, date: '2026-09-04', changes: ['Added a printable TL;DR rules page, linked at the top of the full rulebook.'] },
|
{ version: 46, date: '2026-09-04', changes: ['Added a printable TL;DR rules page, linked at the top of the full rulebook.'] },
|
||||||
{ version: 44, date: '2026-09-04', changes: ['Crew Objectives opens as one connected panel, with the toggle and checklist sharing a border and background.'] },
|
{ version: 44, date: '2026-09-04', changes: ['Crew Objectives opens as one connected panel, with the toggle and checklist sharing a border and background.'] },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
import CrewSidebar from '../components/CrewSidebar.svelte';
|
import CrewSidebar from '../components/CrewSidebar.svelte';
|
||||||
import EventLog from '../components/EventLog.svelte';
|
import GameNotebook from '../components/GameNotebook.svelte';
|
||||||
import NameModal from '../components/NameModal.svelte';
|
import NameModal from '../components/NameModal.svelte';
|
||||||
import GatModal from '../components/GatModal.svelte';
|
import GatModal from '../components/GatModal.svelte';
|
||||||
import RankBonusModal from '../components/RankBonusModal.svelte';
|
import RankBonusModal from '../components/RankBonusModal.svelte';
|
||||||
@@ -35,9 +35,7 @@
|
|||||||
let reconnectDelay = 1000;
|
let reconnectDelay = 1000;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let staleSession = false;
|
let staleSession = false;
|
||||||
// Non-scene phases use the same pinned Event Log column and fixed corner
|
let panelOpen = true;
|
||||||
// toggle that ScenePhase owns for the main play screen.
|
|
||||||
let phaseLogOpen = true;
|
|
||||||
|
|
||||||
function discardStaleSession(message) {
|
function discardStaleSession(message) {
|
||||||
staleSession = true;
|
staleSession = true;
|
||||||
@@ -230,10 +228,10 @@
|
|||||||
|
|
||||||
{#if state}
|
{#if state}
|
||||||
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
|
<div class={state.game.phase === 'scene' ? 'scene-view-layout' : 'phase-view-layout'} class:log-collapsed={!panelOpen}>
|
||||||
{#if state.game.phase === 'scene'}
|
{#if state.game.phase === 'scene'}
|
||||||
<ScenePhase state={rosterState} />
|
<ScenePhase state={rosterState} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
|
|
||||||
<CrewSidebar state={rosterState} />
|
<CrewSidebar state={rosterState} />
|
||||||
<main class="phase-main-column">
|
<main class="phase-main-column">
|
||||||
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
|
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
|
||||||
@@ -261,25 +259,11 @@
|
|||||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
{#if phaseLogOpen}
|
|
||||||
<div class="log-column">
|
|
||||||
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<GameNotebook {state} bind:open={panelOpen} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if state.game.phase !== 'scene'}
|
|
||||||
<button
|
|
||||||
class="log-reopen-btn"
|
|
||||||
aria-expanded={phaseLogOpen}
|
|
||||||
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
|
|
||||||
on:click={() => (phaseLogOpen = !phaseLogOpen)}
|
|
||||||
>
|
|
||||||
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<NameModal {state} />
|
<NameModal {state} />
|
||||||
<GatModal {state} />
|
<GatModal {state} />
|
||||||
<RankBonusModal {state} />
|
<RankBonusModal {state} />
|
||||||
|
|||||||
@@ -289,6 +289,34 @@ def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_ses
|
|||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def _notes_player(db, game_id, player_id):
|
||||||
|
player = crud.get_player(db, player_id)
|
||||||
|
if not player or player.game_id != game_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/game/{game_id}/player/{player_id}/notes")
|
||||||
|
def get_notes(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||||
|
from .models import PlayerNotebook
|
||||||
|
_notes_player(db, game_id, player_id)
|
||||||
|
notebook = db.get(PlayerNotebook, player_id)
|
||||||
|
return {"text": notebook.text if notebook else ""}
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/game/{game_id}/player/{player_id}/notes")
|
||||||
|
def save_notes(game_id: str, player_id: str, text: str = Form(default="", max_length=50000),
|
||||||
|
db: Session = Depends(get_session)):
|
||||||
|
from .models import PlayerNotebook
|
||||||
|
_notes_player(db, game_id, player_id)
|
||||||
|
notebook = db.get(PlayerNotebook, player_id)
|
||||||
|
if notebook is None:
|
||||||
|
notebook = PlayerNotebook(player_id=player_id, game_id=game_id)
|
||||||
|
notebook.text = text
|
||||||
|
db.add(notebook)
|
||||||
|
db.commit()
|
||||||
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
@api.get("/game/{game_id}/player/{player_id}/state")
|
@api.get("/game/{game_id}/player/{player_id}/state")
|
||||||
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ def _estimate_game_bytes(game: Game) -> int:
|
|||||||
any leftover rollback checkpoints' state_json."""
|
any leftover rollback checkpoints' state_json."""
|
||||||
total = len(json.dumps(game.model_dump(), default=str))
|
total = len(json.dumps(game.model_dump(), default=str))
|
||||||
for rows in (game.players, game.obstacles, game.votes, game.events,
|
for rows in (game.players, game.obstacles, game.votes, game.events,
|
||||||
game.challenges, game.checkpoints):
|
game.challenges, game.checkpoints, game.notebooks):
|
||||||
for row in rows:
|
for row in rows:
|
||||||
total += len(json.dumps(row.model_dump(), default=str))
|
total += len(json.dumps(row.model_dump(), default=str))
|
||||||
return total
|
return total
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add private player notebooks
|
||||||
|
|
||||||
|
Revision ID: 2fb61a37117c
|
||||||
|
Revises: 9c04e3bec3b5
|
||||||
|
Create Date: 2026-09-04 20:07:35.320803
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '2fb61a37117c'
|
||||||
|
down_revision = '9c04e3bec3b5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('playernotebook',
|
||||||
|
sa.Column('player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('game_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['game.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('playernotebook', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_playernotebook_game_id'), ['game_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('playernotebook', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_playernotebook_game_id'))
|
||||||
|
|
||||||
|
op.drop_table('playernotebook')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -35,6 +35,7 @@ class Game(SQLModel, table=True):
|
|||||||
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
|
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
|
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
notebooks: List["PlayerNotebook"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
|
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
|
||||||
class Player(SQLModel, table=True):
|
class Player(SQLModel, table=True):
|
||||||
@@ -168,3 +169,11 @@ class Checkpoint(SQLModel, table=True):
|
|||||||
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
|
state_json: str = Field(...) # serialized gameplay snapshot (Game minus control cols + Players/Obstacles/Challenges/Votes)
|
||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="checkpoints")
|
game: Optional[Game] = Relationship(back_populates="checkpoints")
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerNotebook(SQLModel, table=True):
|
||||||
|
"""Private player notes, deliberately outside gameplay snapshots and character resets."""
|
||||||
|
player_id: str = Field(primary_key=True)
|
||||||
|
game_id: str = Field(foreign_key="game.id", index=True)
|
||||||
|
text: str = Field(default="")
|
||||||
|
game: Game = Relationship(back_populates="notebooks")
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Notebooks persist independently of public gameplay and rollback history."""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
from pirats.database import get_session
|
||||||
|
from pirats.main import app
|
||||||
|
from pirats.models import Game, Player, PlayerNotebook, Checkpoint
|
||||||
|
from pirats.crud_rollback import serialize_game_state, apply_game_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notebook_env(monkeypatch):
|
||||||
|
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
game = Game(phase='scene')
|
||||||
|
other = Game()
|
||||||
|
db.add(game)
|
||||||
|
db.add(other)
|
||||||
|
db.commit()
|
||||||
|
players = [Player(game_id=game.id, player_name=name) for name in ('One', 'Two')]
|
||||||
|
db.add_all(players)
|
||||||
|
db.commit()
|
||||||
|
ids = game.id, other.id, players[0].id, players[1].id
|
||||||
|
def session_override():
|
||||||
|
with Session(engine) as db:
|
||||||
|
yield db
|
||||||
|
app.dependency_overrides[get_session] = session_override
|
||||||
|
def unexpected(*args, **kwargs):
|
||||||
|
pytest.fail('Notes must not checkpoint gameplay or broadcast to the crew')
|
||||||
|
monkeypatch.setattr('pirats.main._checkpoint_after_mutation', unexpected)
|
||||||
|
monkeypatch.setattr('pirats.main.manager.broadcast', unexpected)
|
||||||
|
try:
|
||||||
|
yield TestClient(app), engine, ids
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_round_trip_isolation_and_validation(notebook_env):
|
||||||
|
client, engine, (gid, other, pid, second) = notebook_env
|
||||||
|
path = f'/api/game/{gid}/player/{pid}/notes'
|
||||||
|
assert client.get(path).json() == {'text': ''}
|
||||||
|
text = 'Remember the captain’s map 🐀\n Meet at dusk.\n'
|
||||||
|
assert client.put(path, data={'text': text}).json() == {'text': text}
|
||||||
|
assert client.get(path).json() == {'text': text}
|
||||||
|
assert client.get(f'/api/game/{gid}/player/{second}/notes').json() == {'text': ''}
|
||||||
|
assert text not in client.get(f'/api/game/{gid}/player/{second}/state').text
|
||||||
|
wrong = f'/api/game/{other}/player/{pid}/notes'
|
||||||
|
assert client.get(wrong).status_code == 404
|
||||||
|
assert client.put(wrong, data={'text': 'bad'}).status_code == 404
|
||||||
|
assert client.put(path, data={'text': 'x' * 50001}).status_code == 422
|
||||||
|
assert client.get(path).json()['text'] == text
|
||||||
|
assert client.put(path, data={'text': ''}).json() == {'text': ''}
|
||||||
|
assert client.get(path).json() == {'text': ''}
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_survive_rollback_and_game_cleanup(notebook_env):
|
||||||
|
client, engine, (gid, _, pid, _) = notebook_env
|
||||||
|
path = f'/api/game/{gid}/player/{pid}/notes'
|
||||||
|
with Session(engine) as db:
|
||||||
|
game = db.get(Game, gid)
|
||||||
|
snapshot = serialize_game_state(game)
|
||||||
|
client.put(path, data={'text': 'Keep this after rewinding'})
|
||||||
|
with Session(engine) as db:
|
||||||
|
game = db.get(Game, gid)
|
||||||
|
assert 'Keep this after rewinding' not in serialize_game_state(game)
|
||||||
|
apply_game_state(db, game, snapshot)
|
||||||
|
assert db.exec(select(Checkpoint)).all() == []
|
||||||
|
assert client.get(path).json()['text'] == 'Keep this after rewinding'
|
||||||
|
with Session(engine) as db:
|
||||||
|
db.delete(db.get(Game, gid))
|
||||||
|
db.commit()
|
||||||
|
assert db.exec(select(PlayerNotebook)).all() == []
|
||||||
Reference in New Issue
Block a user