diff --git a/TODO.md b/TODO.md index 5bfa062..ca88b03 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,3 @@ -## Polish - -- [x] **Event log layout**. The event log should still be collapsable into a corner button to give the player the option to declutter their screen. On screens big enough for three column layouts, since the event log has an internal scrolling area, make sure that the outer panel never extends past the bottom of the screen, so that the bottom of the log stays visible no matter what. -- [ ] **Crew roster text illegible on dark mode**. The text on the crew roster bubbles is black-on-dark in dark mode. The roster items probably need some re-styling on both dark and light mode, because their background being barely different from the page background doesn't look great. -- [ ] **Enforce End-Scene requirements** Unless dev mode is turned on, enforce that the scene cannot end unless every pi-rat has been challenged at least once or the obstacle list is empty. Leave enforcement of "had a chance to complete objective" to table talk. The Deep should probably see some kind of UI informing them which Pi-Rats have already been challenged. Maybe a checkmark floating outside their bubble to the left with a tooltip on mouseover that explains what it means. -- [ ] **Declutter the Hand panel** The hand panel definitely doesn't need the explanatory text, and probably doesn't need the title either, though if there's some low profile, low-noise visual way to communicate "this is your hand", that wouldn't be a bad idea. - ## DevOps/Maintenance - [ ] **Logging**: Add some logging to the backend. The Nix service should log to /var/log/pirats.log by default (but be configurable). At very least, stderr should go there, plus important server-side events, like: diff --git a/frontend/src/assets/css/scene-play.css b/frontend/src/assets/css/scene-play.css index 0c84d78..eb30849 100644 --- a/frontend/src/assets/css/scene-play.css +++ b/frontend/src/assets/css/scene-play.css @@ -65,6 +65,7 @@ } .crew-bubble { + position: relative; display: flex; flex-direction: column; align-items: flex-start; @@ -72,22 +73,24 @@ width: 100%; text-align: left; padding: 0.55rem 0.7rem; - background: var(--well); + background: var(--surface-raised); + color: var(--text); border: 1px solid var(--edge); border-left: 4px solid var(--pirat); border-radius: var(--radius-md); + box-shadow: var(--shadow-card); cursor: pointer; transition: var(--transition-smooth); } .crew-bubble:hover { - background: color-mix(in srgb, var(--text) 8%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised)); transform: translateX(2px); } .crew-bubble.is-deep { border-left-color: var(--deep); - background: color-mix(in srgb, var(--deep) 6%, transparent); + background: color-mix(in srgb, var(--deep) 12%, var(--surface-raised)); } .crew-bubble.is-you { @@ -98,6 +101,33 @@ .crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); } .crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); } +/* Challenge-progress badge the Deep sees floating just outside the bubble */ +.challenge-check { + position: absolute; + left: -0.6rem; + top: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border-radius: 50%; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + background: var(--surface-raised); + border: 1px solid var(--edge); + color: var(--text-muted); + box-shadow: var(--shadow-card); + cursor: help; +} + +.challenge-check.done { + background: var(--success); + border-color: var(--success); + color: var(--surface); +} + .crew-bubble .bubble-icon { font-size: 1.1rem; } @@ -435,6 +465,16 @@ } /* --- Hand --- */ +.hand-label { + display: block; + margin-bottom: 0.6rem; + font-family: var(--font-heading); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + .hand-flex { display: flex; flex-wrap: wrap; diff --git a/frontend/src/components/ScenePhase.svelte b/frontend/src/components/ScenePhase.svelte index 46e83c5..a6c9631 100644 --- a/frontend/src/components/ScenePhase.svelte +++ b/frontend/src/components/ScenePhase.svelte @@ -27,8 +27,7 @@
{#if !isDeep}
-

🃏 Your Hand

-

Keep your cards secret! When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.

+ 🃏 Your Hand
{#each hand as card} diff --git a/frontend/src/components/scene/CrewColumn.svelte b/frontend/src/components/scene/CrewColumn.svelte index 30f90b0..23f0a73 100644 --- a/frontend/src/components/scene/CrewColumn.svelte +++ b/frontend/src/components/scene/CrewColumn.svelte @@ -2,6 +2,7 @@ import { slide } from 'svelte/transition'; import { apiRequest } from '../../lib/api'; import { crewLabel } from '../../lib/cards'; + import { tooltip } from '../../lib/tooltip'; import CharacterSheet from './CharacterSheet.svelte'; export let state; @@ -12,6 +13,17 @@ $: isDeep = state.player.role === 'deep'; + // Track which Pi-Rats have faced a Deep Challenge this scene so the Deep can + // see who still needs one before ending the scene. + $: challengedIds = new Set( + (state.challenges || []) + .filter(c => c.challenge_type === 'deep') + .map(c => c.target_player_id) + ); + function hasBeenChallenged(p) { + return p.role === 'pirat' && challengedIds.has(p.id); + } + // The Deep ticks Crew Objectives off here (moved from the old Deep panel). async function toggleCrew(type) { objError = ''; @@ -51,6 +63,15 @@ title="View {p.name}'s character sheet" on:click={() => openTargetId = p.id} > + {#if isDeep && p.role === 'pirat'} + {hasBeenChallenged(p) ? '✓' : '○'} + {/if} {roleIcon(p)} {crewLabel(p, captainId)} diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index eac4626..8871d1b 100644 --- a/frontend/src/lib/changelog.js +++ b/frontend/src/lib/changelog.js @@ -6,10 +6,19 @@ // - 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 = 7; +export const VERSION = 8; // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. export const CHANGELOG = [ + { + version: 8, + date: '2026-06-15', + changes: [ + 'Crew roster bubbles are now legible and stand out from the background in both light and dark mode.', + 'A scene can no longer be ended until every Pi-Rat has faced a Challenge (or the Obstacle List is empty). The Deep sees a ✓/○ marker on each Pi-Rat showing who still needs one.', + 'Decluttered your Hand panel — dropped the how-to-play blurb for a low-profile label.', + ], + }, { version: 7, date: '2026-06-15', diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index 77e5aa9..9027b4c 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -46,21 +46,43 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str db.commit() return True, "Vote submitted." +def unchallenged_pirats(game: Game): + """Living Pi-Rats who have not yet been the target of a Deep Challenge this scene.""" + challenged_ids = {c.target_player_id for c in game.challenges if c.challenge_type == "deep"} + return [p for p in game.players + if p.role == "pirat" and not p.is_dead and p.id not in challenged_ids] + def end_scene_and_transition(db: Session, game_id: str): - """Moves game phase to between_scenes.""" + """Moves game phase to between_scenes. + + Unless Dev Mode is on, the scene can't end until every living Pi-Rat has + faced at least one Deep Challenge, or the Obstacle List has been cleared. + (Whether each Pi-Rat had a chance at their Objective is left to table talk.) + """ game = get_game(db, game_id) if not game: - return + return False, "Game not found." + + if not game.dev_mode and game.obstacles: + waiting = unchallenged_pirats(game) + if waiting: + names = ", ".join(p.name for p in waiting) + return False, ( + f"Every Pi-Rat must face a Challenge before the scene can end " + f"(still waiting on: {names}). Clear the Obstacle List to end early." + ) + game.phase = "between_scenes" - + # Clear ready flags for players for p in game.players: p.is_ready = False db.add(p) db.add(game) db.commit() - + add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene") + return True, "Scene ended." def process_between_scenes_votes(db: Session, game: Game): """ diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 8b1a1ab..1cb2715 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -184,7 +184,9 @@ def become_ghost_route( # Deep ends the scene @router.post("/game/{game_id}/scene/end") def end_scene_route(game_id: str, db: Session = Depends(get_session)): - crud.end_scene_and_transition(db, game_id) + ok, msg = crud.end_scene_and_transition(db, game_id) + if not ok: + return JSONResponse({"error": msg}, status_code=400) return {"status": "ok"} # Deep clears a completed obstacle diff --git a/tests/test_game.py b/tests/test_game.py index 09acf57..fe345bc 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -981,6 +981,53 @@ def test_pvp_challenge(session): session.refresh(duel) assert duel.status == "succeeded" +def test_end_scene_requires_every_pirat_challenged(session): + game, deep, (p2,) = make_scene_game(session) + game.dev_mode = False + session.add(game) + session.commit() + + # Obstacles remain and p2 has not been challenged yet -> blocked. + ok, msg = crud.end_scene_and_transition(session, game.id) + assert not ok + assert p2.name in msg + session.refresh(game) + assert game.phase == "scene" + + # Once p2 has faced a Deep Challenge, the scene can end. + obs = game.obstacles[0] + ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) + assert ok, msg + ok, msg = crud.end_scene_and_transition(session, game.id) + assert ok, msg + session.refresh(game) + assert game.phase == "between_scenes" + + +def test_end_scene_allowed_when_obstacle_list_empty(session): + game, deep, (p2,) = make_scene_game(session) + game.dev_mode = False + session.add(game) + # Clear the Obstacle List; nobody has been challenged. + for obs in list(game.obstacles): + session.delete(obs) + session.commit() + + ok, msg = crud.end_scene_and_transition(session, game.id) + assert ok, msg + session.refresh(game) + assert game.phase == "between_scenes" + + +def test_end_scene_dev_mode_bypasses_challenge_requirement(session): + game, deep, (p2,) = make_scene_game(session) + assert game.dev_mode # defaults on for a local checkout + ok, msg = crud.end_scene_and_transition(session, game.id) + assert ok, msg + session.refresh(game) + assert game.phase == "between_scenes" + + def test_obstacles_persist_across_scenes(session): game, deep, (p2,) = make_scene_game(session) assert len(game.obstacles) == 2