diff --git a/TODO.md b/TODO.md index ec175df..2f34721 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/frontend/src/assets/css/scene-play.css b/frontend/src/assets/css/scene-play.css index 1fbd444..97fd146 100644 --- a/frontend/src/assets/css/scene-play.css +++ b/frontend/src/assets/css/scene-play.css @@ -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; diff --git a/frontend/src/components/EventLog.svelte b/frontend/src/components/EventLog.svelte index 2eacd5e..f192e09 100644 --- a/frontend/src/components/EventLog.svelte +++ b/frontend/src/components/EventLog.svelte @@ -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 @@ {/key} {/if} - {#if inline} + {#if inline && !headerless}
📜 Event Log
- {:else} + {:else if !inline} diff --git a/frontend/src/components/GameNotebook.svelte b/frontend/src/components/GameNotebook.svelte new file mode 100644 index 0000000..eecfcaa --- /dev/null +++ b/frontend/src/components/GameNotebook.svelte @@ -0,0 +1,148 @@ + + + { if (event.key === 'Escape' && open && tab === 'notes') close(); }} /> + +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ {#if error}{error} · {/if} + + +
+ + diff --git a/frontend/src/components/ScenePhase.svelte b/frontend/src/components/ScenePhase.svelte index 157f9f1..77e88b2 100644 --- a/frontend/src/components/ScenePhase.svelte +++ b/frontend/src/components/ScenePhase.svelte @@ -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; -
+
@@ -81,19 +78,8 @@
- - {#if logOpen} -
- (logOpen = false)} /> -
- {/if} - + diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index e54d469..8d3876f 100644 --- a/frontend/src/lib/changelog.js +++ b/frontend/src/lib/changelog.js @@ -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.'] }, diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index f5f21b5..e4d8214 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -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}
+
{#if state.game.phase === 'scene'} {:else} -
{#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 @@
Unknown phase: {state.game.phase}
{/if}
- {#if phaseLogOpen} -
- (phaseLogOpen = false)} /> -
- {/if} -
{/if} + +
- {#if state.game.phase !== 'scene'} - - {/if} diff --git a/src/pirats/main.py b/src/pirats/main.py index fac3436..bb57c4e 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -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) diff --git a/src/pirats/maintenance.py b/src/pirats/maintenance.py index eaabdf7..3a83afc 100644 --- a/src/pirats/maintenance.py +++ b/src/pirats/maintenance.py @@ -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 diff --git a/src/pirats/migrations/versions/2fb61a37117c_add_private_player_notebooks.py b/src/pirats/migrations/versions/2fb61a37117c_add_private_player_notebooks.py new file mode 100644 index 0000000..ae64479 --- /dev/null +++ b/src/pirats/migrations/versions/2fb61a37117c_add_private_player_notebooks.py @@ -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 ### diff --git a/src/pirats/models.py b/src/pirats/models.py index 29f83f0..9bbb93e 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -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") diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..6263473 --- /dev/null +++ b/tests/test_notes.py @@ -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() == []