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] 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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.'] },
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
{/if}
|
||||
<GameNotebook {state} bind:open={panelOpen} />
|
||||
</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} />
|
||||
<GatModal {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 {"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)
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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")
|
||||
|
||||
@@ -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