Add per-player autosaving game notebooks

This commit is contained in:
2026-09-04 20:13:14 -07:00
parent 0ae1701b82
commit 145f526634
12 changed files with 317 additions and 69 deletions
+1 -1
View File
@@ -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] 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.
- [ ] Add a note taking area to store game state between sessions
- [x] Add a note taking area to store game state between sessions
## Polish
-25
View File
@@ -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) {
.scene-view-layout {
grid-template-columns: 1fr;
+3 -2
View File
@@ -8,6 +8,7 @@
// Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase.
export let inline = false;
export let headerless = false;
const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
@@ -195,12 +196,12 @@
</button>
{/key}
{/if}
{#if inline}
{#if inline && !headerless}
<div class="log-header">
📜 Event Log
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
</div>
{:else}
{:else if !inline}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
+148
View File
@@ -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 = 'Couldnt 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 = 'Couldnt 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 -18
View File
@@ -4,12 +4,9 @@
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import CrewColumn from './scene/CrewColumn.svelte';
import EventLog from './EventLog.svelte';
export let state;
// The inline Event Log can be toggled from a fixed corner button to declutter.
let logOpen = true;
let dismissedDuelIds = [];
$: 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;
</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 -->
<CrewColumn {state} />
@@ -81,19 +78,8 @@
</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>
<button
class="log-reopen-btn"
aria-expanded={logOpen}
title={logOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (logOpen = !logOpen)}
>
{logOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
<style>
.scene-content { display: contents; }
</style>
+2 -1
View File
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can
// 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, ...] }.
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: 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.'] },
+4 -20
View File
@@ -16,7 +16,7 @@
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.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 GatModal from '../components/GatModal.svelte';
import RankBonusModal from '../components/RankBonusModal.svelte';
@@ -35,9 +35,7 @@
let reconnectDelay = 1000;
let destroyed = false;
let staleSession = false;
// Non-scene phases use the same pinned Event Log column and fixed corner
// toggle that ScenePhase owns for the main play screen.
let phaseLogOpen = true;
let panelOpen = true;
function discardStaleSession(message) {
staleSession = true;
@@ -230,10 +228,10 @@
{#if state}
<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'}
<ScenePhase state={rosterState} />
{:else}
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar state={rosterState} />
<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)}
@@ -261,25 +259,11 @@
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div>
{/if}
<GameNotebook {state} bind:open={panelOpen} />
</div>
{/if}
</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} />
<GatModal {state} />
<RankBonusModal {state} />
+28
View File
@@ -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 {"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")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
+1 -1
View File
@@ -81,7 +81,7 @@ def _estimate_game_bytes(game: Game) -> int:
any leftover rollback checkpoints' state_json."""
total = len(json.dumps(game.model_dump(), default=str))
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:
total += len(json.dumps(row.model_dump(), default=str))
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 ###
+9
View File
@@ -35,6 +35,7 @@ class Game(SQLModel, table=True):
votes: List["Vote"] = 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)
notebooks: List["PlayerNotebook"] = Relationship(back_populates="game", cascade_delete=True)
checkpoints: List["Checkpoint"] = Relationship(back_populates="game", cascade_delete=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)
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")
+76
View File
@@ -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 captains 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() == []