Polish: legible crew roster, End-Scene enforcement, decluttered Hand
- Crew roster bubbles: explicit text color + opaque distinct surface so they're legible and stand out from the page in both themes (the bug was the <button> defaulting to black-on-dark in dark mode). - End-Scene enforcement: unless Dev Mode is on, a scene can't end until every living Pi-Rat has faced a Deep Challenge or the Obstacle List is empty. end_scene_and_transition now returns (ok, msg) and the route surfaces a 400; the Deep sees a ✓/○ challenge-progress badge on each Pi-Rat bubble. 3 new tests cover the gate. - Hand panel: dropped the how-to-play blurb and the title in favor of a low-profile "Your Hand" label. - Bump VERSION to 8 with changelog entry; check off TODO.md Polish items. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
TODO.md
7
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
|
## 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:
|
- [ ] **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:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.crew-bubble {
|
.crew-bubble {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -72,22 +73,24 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.55rem 0.7rem;
|
||||||
background: var(--well);
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
border: 1px solid var(--edge);
|
border: 1px solid var(--edge);
|
||||||
border-left: 4px solid var(--pirat);
|
border-left: 4px solid var(--pirat);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crew-bubble:hover {
|
.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);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crew-bubble.is-deep {
|
.crew-bubble.is-deep {
|
||||||
border-left-color: var(--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 {
|
.crew-bubble.is-you {
|
||||||
@@ -98,6 +101,33 @@
|
|||||||
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
|
||||||
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
|
.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 {
|
.crew-bubble .bubble-icon {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
@@ -435,6 +465,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Hand --- */
|
/* --- 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 {
|
.hand-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -27,8 +27,7 @@
|
|||||||
<div class="table-column">
|
<div class="table-column">
|
||||||
{#if !isDeep}
|
{#if !isDeep}
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<h3>🃏 Your Hand</h3>
|
<span class="hand-label">🃏 Your Hand</span>
|
||||||
<p class="section-desc">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.</p>
|
|
||||||
|
|
||||||
<div class="hand-flex">
|
<div class="hand-flex">
|
||||||
{#each hand as card}
|
{#each hand as card}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { crewLabel } from '../../lib/cards';
|
import { crewLabel } from '../../lib/cards';
|
||||||
|
import { tooltip } from '../../lib/tooltip';
|
||||||
import CharacterSheet from './CharacterSheet.svelte';
|
import CharacterSheet from './CharacterSheet.svelte';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
@@ -12,6 +13,17 @@
|
|||||||
|
|
||||||
$: isDeep = state.player.role === 'deep';
|
$: 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).
|
// The Deep ticks Crew Objectives off here (moved from the old Deep panel).
|
||||||
async function toggleCrew(type) {
|
async function toggleCrew(type) {
|
||||||
objError = '';
|
objError = '';
|
||||||
@@ -51,6 +63,15 @@
|
|||||||
title="View {p.name}'s character sheet"
|
title="View {p.name}'s character sheet"
|
||||||
on:click={() => openTargetId = p.id}
|
on:click={() => openTargetId = p.id}
|
||||||
>
|
>
|
||||||
|
{#if isDeep && p.role === 'pirat'}
|
||||||
|
<span
|
||||||
|
class="challenge-check"
|
||||||
|
class:done={hasBeenChallenged(p)}
|
||||||
|
use:tooltip={hasBeenChallenged(p)
|
||||||
|
? `${p.name} has faced a Challenge this scene.`
|
||||||
|
: `${p.name} hasn't faced a Challenge yet — the scene can't end until they do (or the Obstacle List is cleared).`}
|
||||||
|
>{hasBeenChallenged(p) ? '✓' : '○'}</span>
|
||||||
|
{/if}
|
||||||
<span class="bubble-icon">{roleIcon(p)}</span>
|
<span class="bubble-icon">{roleIcon(p)}</span>
|
||||||
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
<span class="bubble-name">{crewLabel(p, captainId)}</span>
|
||||||
<span class="bubble-meta">
|
<span class="bubble-meta">
|
||||||
|
|||||||
@@ -6,10 +6,19 @@
|
|||||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
// 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, ...] }.
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
export const CHANGELOG = [
|
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,
|
version: 7,
|
||||||
date: '2026-06-15',
|
date: '2026-06-15',
|
||||||
|
|||||||
@@ -46,11 +46,32 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True, "Vote submitted."
|
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):
|
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)
|
game = get_game(db, game_id)
|
||||||
if not game:
|
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"
|
game.phase = "between_scenes"
|
||||||
|
|
||||||
# Clear ready flags for players
|
# Clear ready flags for players
|
||||||
@@ -61,6 +82,7 @@ def end_scene_and_transition(db: Session, game_id: str):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
|
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):
|
def process_between_scenes_votes(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ def become_ghost_route(
|
|||||||
# Deep ends the scene
|
# Deep ends the scene
|
||||||
@router.post("/game/{game_id}/scene/end")
|
@router.post("/game/{game_id}/scene/end")
|
||||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep clears a completed obstacle
|
# Deep clears a completed obstacle
|
||||||
|
|||||||
@@ -981,6 +981,53 @@ def test_pvp_challenge(session):
|
|||||||
session.refresh(duel)
|
session.refresh(duel)
|
||||||
assert duel.status == "succeeded"
|
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):
|
def test_obstacles_persist_across_scenes(session):
|
||||||
game, deep, (p2,) = make_scene_game(session)
|
game, deep, (p2,) = make_scene_game(session)
|
||||||
assert len(game.obstacles) == 2
|
assert len(game.obstacles) == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user