Rolling back is now non-destructive: instead of truncating the future, it restores the target checkpoint's snapshot and points Game.rollback_head_checkpoint_id at it. Later checkpoints/events are kept and rendered greyed; rolling forward to one redoes the rollback (same endpoint). The abandoned future is discarded only when a new action is taken from a rolled-back state -- maintain_rollback_history truncates it, clears the head, and bumps rollback_timeline_version, now the sole trigger for the frontend log reset. - Game.rollback_head_checkpoint_id (nullable; in SNAPSHOT_EXCLUDE) + Alembic migration. - crud_rollback: rollback_to_checkpoint sets the head (None at latest); _discard_future + head-clear + version-bump moved into the next scene action's capture. - EventLog.svelte: greyed future, redo (forward) affordance, a "rolled back" banner; dropped the now-misleading destructive confirm. - Tests rewritten for head-pointer semantics (+ redo, action-after- rollback truncation, scene-end head clear); HTTP integration test extended to drive the full cycle over the wire. Verified end-to-end in a browser: rollback greys the future and reverts live state (captaincy/ranks), redo restores it, no errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
371 lines
12 KiB
Svelte
371 lines
12 KiB
Svelte
<script>
|
|
import { tick } from 'svelte';
|
|
import { apiRequest } from '../lib/api';
|
|
|
|
export let state;
|
|
|
|
const PAGE_SIZE = 50;
|
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
|
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
|
|
|
|
const KIND_ICONS = {
|
|
join: '👋',
|
|
phase: '🧭',
|
|
scene: '🎬',
|
|
captain: '🏴☠️',
|
|
challenge: '⚔️',
|
|
card: '🃏',
|
|
tax: '💰',
|
|
objective: '🎯',
|
|
vote: '🗳️',
|
|
obstacle: '🌊',
|
|
joker: '🤡',
|
|
victory: '🎉',
|
|
warning: '⚠️',
|
|
rank: '🎖️',
|
|
info: '📜',
|
|
};
|
|
|
|
let open = false;
|
|
let logEl;
|
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
|
let seenIds = new Set();
|
|
let haveMore = true;
|
|
let loadingOlder = false;
|
|
let atBottom = true;
|
|
let error = '';
|
|
let timelineVersion = null;
|
|
|
|
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
|
$: canRollback = state.game?.phase === 'scene'
|
|
&& (state.player?.is_admin || state.player?.role === 'deep');
|
|
// When set, the game is at a rolled-back state; events past this checkpoint are the
|
|
// greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
|
|
$: head = state.game?.rollback_head_checkpoint_id ?? null;
|
|
|
|
$: mergePolled(state.events);
|
|
|
|
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
|
|
// otherwise events the player paged back to would vanish as the window slides.
|
|
async function mergePolled(polled) {
|
|
if (!polled) return;
|
|
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
|
|
// action from a rolled-back state truncates the abandoned future and bumps the
|
|
// version, which tells us to drop what we'd accumulated and reseed.
|
|
const version = state.game?.rollback_timeline_version ?? 0;
|
|
if (timelineVersion !== null && version !== timelineVersion) {
|
|
events = [];
|
|
seenIds = new Set();
|
|
}
|
|
timelineVersion = version;
|
|
if (events.length === 0) {
|
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
|
}
|
|
let added = false;
|
|
for (const e of polled) {
|
|
if (!seenIds.has(e.id)) {
|
|
seenIds.add(e.id);
|
|
events.push(e);
|
|
added = true;
|
|
}
|
|
}
|
|
if (added) {
|
|
events = events;
|
|
if (open && atBottom) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
atBottom = true;
|
|
}
|
|
|
|
function onScroll() {
|
|
if (!logEl) return;
|
|
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
|
|
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (loadingOlder || !haveMore || events.length === 0) return;
|
|
loadingOlder = true;
|
|
try {
|
|
const oldest = events[0].timestamp;
|
|
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
|
|
haveMore = data.have_more;
|
|
const fresh = data.events.filter(e => !seenIds.has(e.id));
|
|
for (const e of fresh) seenIds.add(e.id);
|
|
if (fresh.length) {
|
|
// Prepending grows the content above the viewport; restore the
|
|
// player's position so the log doesn't visually jump.
|
|
const prevHeight = logEl.scrollHeight;
|
|
const prevTop = logEl.scrollTop;
|
|
events = [...fresh, ...events];
|
|
await tick();
|
|
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
|
|
}
|
|
} catch (e) {
|
|
// Transient fetch failure; scrolling again retries.
|
|
} finally {
|
|
loadingOlder = false;
|
|
}
|
|
}
|
|
|
|
async function toggleOpen() {
|
|
open = !open;
|
|
if (open) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
// Both rollback (to a past event) and redo (to a greyed future event) are the same
|
|
// call — it just moves the head. It's reversible until someone takes a new action,
|
|
// so no confirm; the greyed future makes the state obvious.
|
|
async function rollback(event) {
|
|
error = '';
|
|
try {
|
|
await apiRequest(
|
|
`/game/${state.game.id}/player/${state.player.id}/rollback`,
|
|
'POST',
|
|
{ checkpoint_id: event.checkpoint_id },
|
|
);
|
|
// The state-changed ping drives Dashboard to refetch the restored state.
|
|
} catch (e) {
|
|
error = e.message;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="floating-event-log {open ? 'open' : ''}">
|
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
|
</button>
|
|
|
|
{#if open}
|
|
{#if error}
|
|
<div class="log-error">{error}</div>
|
|
{/if}
|
|
{#if head !== null}
|
|
<div class="log-rolledback">
|
|
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
|
</div>
|
|
{/if}
|
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
|
{#if events.length}
|
|
<div class="log-top">
|
|
{#if loadingOlder}
|
|
<span class="log-top-note">Hauling up older entries…</span>
|
|
{:else if haveMore}
|
|
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
|
|
{:else}
|
|
<span class="log-top-note">⚓ The story begins here</span>
|
|
{/if}
|
|
</div>
|
|
{#each events as event (event.id)}
|
|
{@const isFuture = head !== null && event.checkpoint_id > head}
|
|
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
|
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
|
<span class="log-message">{event.message}</span>
|
|
<span class="log-time">{formatTime(event.timestamp)}</span>
|
|
{#if canRollback && event.checkpoint_id > 0}
|
|
<button
|
|
class="rollback-btn"
|
|
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
|
|
on:click={() => rollback(event)}
|
|
>{isFuture ? '↪' : '↩'}</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
<div class="log-empty">No events yet.</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !atBottom}
|
|
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.floating-event-log {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
width: 380px;
|
|
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);
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 60vh;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.floating-event-log:not(.open) {
|
|
width: auto;
|
|
}
|
|
.toggle-log-btn {
|
|
background: transparent;
|
|
color: var(--text);
|
|
border: none;
|
|
padding: 10px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
border-radius: var(--radius-md);
|
|
border-bottom: 1px solid var(--edge-soft);
|
|
font-family: var(--font-heading);
|
|
}
|
|
.toggle-log-btn:hover {
|
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
|
}
|
|
.log-content {
|
|
overflow-y: auto;
|
|
padding: 10px;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
scrollbar-width: thin;
|
|
}
|
|
.log-top {
|
|
text-align: center;
|
|
padding: 2px 0 6px;
|
|
}
|
|
.log-top-note {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
.load-older-btn {
|
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
border: 1px solid var(--edge);
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.load-older-btn:hover {
|
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
|
color: var(--text);
|
|
}
|
|
.log-entry {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto auto;
|
|
gap: 8px;
|
|
align-items: baseline;
|
|
padding: 6px 8px;
|
|
background: color-mix(in srgb, var(--text) 3%, transparent);
|
|
border-left: 3px solid var(--edge);
|
|
border-radius: 0 6px 6px 0;
|
|
font-size: 0.85rem;
|
|
line-height: 1.35;
|
|
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
|
}
|
|
.rollback-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--edge);
|
|
color: var(--text-muted);
|
|
border-radius: 6px;
|
|
padding: 0 6px;
|
|
font-size: 0.8rem;
|
|
line-height: 1.4;
|
|
cursor: pointer;
|
|
align-self: center;
|
|
}
|
|
.rollback-btn:hover {
|
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
|
border-color: var(--danger);
|
|
color: var(--text);
|
|
}
|
|
.log-error {
|
|
margin: 6px 8px 0;
|
|
padding: 6px 10px;
|
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
border: 1px solid var(--danger);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text);
|
|
font-size: 0.8rem;
|
|
}
|
|
.log-rolledback {
|
|
margin: 6px 8px 0;
|
|
padding: 6px 10px;
|
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text);
|
|
font-size: 0.78rem;
|
|
line-height: 1.3;
|
|
}
|
|
.log-entry.greyed {
|
|
opacity: 0.45;
|
|
font-style: italic;
|
|
}
|
|
.log-icon {
|
|
font-size: 0.9rem;
|
|
}
|
|
.log-message {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.log-time {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
opacity: 0.8;
|
|
}
|
|
.log-empty {
|
|
padding: 10px;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
.jump-to-bottom {
|
|
position: absolute;
|
|
bottom: 12px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--accent);
|
|
color: var(--text-inverse);
|
|
border: none;
|
|
border-radius: 16px;
|
|
padding: 5px 14px;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
.jump-to-bottom:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
/* Accent colors by event kind */
|
|
.log-kind-challenge { border-left-color: var(--danger); }
|
|
.log-kind-card { border-left-color: var(--deep); }
|
|
.log-kind-captain,
|
|
.log-kind-tax,
|
|
.log-kind-rank,
|
|
.log-kind-victory { border-left-color: var(--accent); }
|
|
.log-kind-scene,
|
|
.log-kind-phase { border-left-color: var(--pirat); }
|
|
.log-kind-joker,
|
|
.log-kind-warning { border-left-color: var(--warning); }
|
|
.log-kind-obstacle { border-left-color: var(--deep); }
|
|
.log-kind-join,
|
|
.log-kind-vote,
|
|
.log-kind-objective { border-left-color: var(--mystic); }
|
|
.log-kind-victory {
|
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
|
color: var(--accent);
|
|
}
|
|
</style>
|