Compare commits
6 Commits
daeac91d07
...
e4c78391fc
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c78391fc | |||
| 922fc38c0a | |||
| cf9ee33b52 | |||
| 17160f9d9b | |||
| 44f7fd939b | |||
| 5b5087ac23 |
@@ -17,6 +17,14 @@ Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list
|
||||
|
||||
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
|
||||
|
||||
## Versioning & changelog
|
||||
|
||||
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
|
||||
|
||||
- Bump `VERSION` by one on **every** commit.
|
||||
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
|
||||
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
|
||||
|
||||
## Work order
|
||||
|
||||
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.
|
||||
|
||||
13
TODO.md
13
TODO.md
@@ -1,18 +1,15 @@
|
||||
## UI Polish
|
||||
## Polish
|
||||
|
||||
- [ ] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
|
||||
- [x] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
|
||||
- [x] **Version number and change log** Start keeping track of version number. It's fine to just start with v1 and increment on every commit. In the hamburger menu, add an "About" item that pops up a modal with the current version number and a change log. The change log should only cover user-facing changes. Add a note to AGENTS.md about maintaining the change log.
|
||||
- [x] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
||||
- [x] **Condense Obstacle card display**: Change how the cards in an Obstacle are laid out. Instead of being one big card and a bunch of little ones under it in a row, they should be a single column, with the cards overlapping so that you can just see the numbers and suits of the previous cards peeking out behind the latest card. This is more compact, puts the focus on the most relevant information, and is in line with the rulebook describing the card layout as a column. This is probably also how the cards in the example play section should be laid out.
|
||||
|
||||
## Words Words Words
|
||||
|
||||
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
||||
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||
- [ ] **Version number and change log**
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
||||
|
||||
## Gameplay gaps
|
||||
|
||||
- [x] **Finish the Rank-3 story bonus.** A Pi-Rat who completes their 3rd Personal Objective (RULEBOOK §93) now gets a "⭐ Your Story is Complete!" modal (`RankBonusModal.svelte`, keyed on `needs_rank_3_bonus`) to grant another eligible crewmate +1 Rank — or forfeit it when none qualify. *(Done 2026-06-15: logic in `crud_scene.grant_story_bonus_rank` reusing the shared `is_eligible_nominee` guard; route slimmed to the (ok, msg) convention; unit tests added; verified end-to-end in the browser.)*
|
||||
|
||||
@@ -205,58 +205,42 @@
|
||||
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
|
||||
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
|
||||
|
||||
.obstacle-card-wrapper {
|
||||
/* Overlapping card column for an Obstacle. Cards stack with a heavy negative
|
||||
margin so each earlier card shows only its top strip (rank + suit); the most
|
||||
recent play sits fully visible at the bottom. Rings: accent = the original
|
||||
card, green/red = a play that did / didn't beat the difficulty. */
|
||||
.obstacle-stack {
|
||||
--peek: 34px; /* visible strip of each earlier card */
|
||||
--card-h: 112px; /* medium card height (card.css) */
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 2px; /* room for the rings, which overflow:hidden would clip */
|
||||
}
|
||||
|
||||
.card-caption {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
.stack-card {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* Obstacle color (red/black) — fixed by the original card; matching it draws back */
|
||||
.obstacle-color-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
cursor: help;
|
||||
.stack-card + .stack-card {
|
||||
margin-top: calc(var(--peek) - var(--card-h));
|
||||
}
|
||||
|
||||
.obstacle-color-tag::before {
|
||||
content: '';
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
.stack-card.is-original {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.obstacle-color-tag.red {
|
||||
color: var(--suit-red);
|
||||
background: color-mix(in srgb, var(--suit-red) 14%, transparent);
|
||||
.stack-card.is-success {
|
||||
box-shadow: 0 0 0 2px var(--success);
|
||||
}
|
||||
|
||||
.obstacle-color-tag.red::before { background: var(--suit-red); }
|
||||
|
||||
.obstacle-color-tag.black {
|
||||
color: var(--suit-black);
|
||||
background: color-mix(in srgb, var(--suit-black) 14%, transparent);
|
||||
.stack-card.is-failure {
|
||||
box-shadow: 0 0 0 2px var(--danger);
|
||||
}
|
||||
|
||||
.obstacle-color-tag.black::before { background: var(--suit-black); }
|
||||
|
||||
.suit-badge {
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
@@ -277,6 +261,13 @@
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
/* The title and its "K♦" card-code prefix take the Obstacle's fixed suit color
|
||||
(red border on the left already marks color, so the old tag is gone). */
|
||||
.suit-h .obstacle-details h4, .suit-d .obstacle-details h4 { color: var(--suit-red); }
|
||||
.suit-c .obstacle-details h4, .suit-s .obstacle-details h4 { color: var(--suit-black); }
|
||||
|
||||
.obs-card-code { opacity: 0.8; }
|
||||
|
||||
.obstacle-details .in-challenge-tag {
|
||||
color: var(--deep);
|
||||
font-size: 0.8rem;
|
||||
@@ -288,6 +279,7 @@
|
||||
}
|
||||
|
||||
.obstacle-value-display {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -299,6 +291,7 @@
|
||||
}
|
||||
|
||||
.obstacle-successes-display {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -327,26 +320,6 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.played-column {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.played-column h5 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.column-cards-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clear-obstacle-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
82
frontend/src/components/AboutModal.svelte
Normal file
82
frontend/src/components/AboutModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { VERSION, CHANGELOG } from '../lib/changelog';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function close() { dispatch('close'); }
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
|
||||
|
||||
<div class="modal-backdrop" on:click|self={close}>
|
||||
<div class="modal-box sheet-modal glass-panel about-modal">
|
||||
<button class="modal-close" on:click={close} aria-label="Close">×</button>
|
||||
<h3>About</h3>
|
||||
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
|
||||
<p class="about-version">Version {VERSION}</p>
|
||||
|
||||
<h4 class="about-heading">What's New</h4>
|
||||
<ul class="changelog">
|
||||
{#each CHANGELOG as entry (entry.version)}
|
||||
<li class="changelog-entry">
|
||||
<div class="changelog-head">
|
||||
<span class="changelog-ver">v{entry.version}</span>
|
||||
<span class="changelog-date text-muted">{entry.date}</span>
|
||||
</div>
|
||||
<ul class="changelog-changes">
|
||||
{#each entry.changes as change}
|
||||
<li>{change}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.about-tagline {
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.about-version {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
.about-heading {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
border-top: 1px solid var(--edge-soft);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.changelog {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.changelog-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.changelog-ver {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.changelog-date {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.changelog-changes {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script>
|
||||
import AboutModal from './AboutModal.svelte';
|
||||
import { extraMenuLinks } from '../lib/menu';
|
||||
import { theme, toggleTheme } from '../lib/theme';
|
||||
|
||||
let open = false;
|
||||
let showAbout = false;
|
||||
|
||||
$: themeLink = $theme === 'dark'
|
||||
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
|
||||
@@ -11,6 +13,7 @@
|
||||
$: baseLinks = [
|
||||
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
|
||||
themeLink,
|
||||
{ label: 'ℹ️ About', action: () => (showAbout = true), title: 'Version and changelog' },
|
||||
];
|
||||
|
||||
$: links = [...$extraMenuLinks, ...baseLinks];
|
||||
@@ -52,6 +55,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAbout}
|
||||
<AboutModal on:close={() => (showAbout = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.corner-menu {
|
||||
position: fixed;
|
||||
|
||||
104
frontend/src/components/RankBonusModal.svelte
Normal file
104
frontend/src/components/RankBonusModal.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { displayName } from '../lib/cards';
|
||||
|
||||
export let state;
|
||||
|
||||
let error = '';
|
||||
let submitting = false;
|
||||
|
||||
$: me = state.player;
|
||||
// Mirror the backend's is_eligible_nominee guard: another living, in-play Pi-Rat.
|
||||
$: eligible = (state.players || []).filter(
|
||||
(p) => p.id !== me.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
|
||||
);
|
||||
|
||||
async function grant(targetId) {
|
||||
if (submitting) return;
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${me.id}/bonus-rank-up`, 'POST', {
|
||||
target_player_id: targetId || ''
|
||||
});
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if me.needs_rank_3_bonus}
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-box glass-panel">
|
||||
<h3>⭐ Your Story is Complete!</h3>
|
||||
<p class="info-text">
|
||||
You completed your final Personal Objective and earned your rest. Choose a
|
||||
crewmate to carry on stronger — they gain <strong>1 Rank</strong>.
|
||||
</p>
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
{#if eligible.length}
|
||||
<div class="bonus-choices">
|
||||
{#each eligible as p (p.id)}
|
||||
<button
|
||||
class="btn btn-gold btn-full"
|
||||
on:click={() => grant(p.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{displayName(p)}
|
||||
<span class="rank-hint">Rank {p.rank} → {p.rank + 1}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="info-text">No crewmate is eligible to receive the Rank right now.</p>
|
||||
<button
|
||||
class="btn btn-secondary btn-full"
|
||||
on:click={() => grant('')}
|
||||
disabled={submitting}
|
||||
>
|
||||
Forfeit the Rank Bonus
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modal-box {
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-deep);
|
||||
text-align: center;
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bonus-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.rank-hint {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -95,7 +95,6 @@
|
||||
{/if}
|
||||
|
||||
{#each openChallenges as ch}
|
||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
||||
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||
<div class="challenge-item">
|
||||
<div class="challenge-head">
|
||||
@@ -127,10 +126,6 @@
|
||||
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable) }}>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||
</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||
Attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
||||
Drag a card onto an Obstacle below to play it. Other Pi-Rats may assist (assistants don't draw back).
|
||||
</p>
|
||||
<div class="challenge-obstacles">
|
||||
{#each chObstacleIds as oid}
|
||||
{@const obs = state.obstacles.find(o => o.id === oid)}
|
||||
@@ -142,12 +137,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if chPlays.length > 0}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">
|
||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Tax: pending request aimed at me -->
|
||||
{#if ch.tax_state === 'requested'}
|
||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||
|
||||
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
112
frontend/src/components/scene/ObstacleItem.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { isJoker, cardValue } from '../../lib/cards';
|
||||
import { tooltip } from '../../lib/tooltip';
|
||||
import Card from '../Card.svelte';
|
||||
|
||||
export let state;
|
||||
export let obs;
|
||||
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
|
||||
|
||||
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
|
||||
// unlike SUIT_EMOJI which renders as fixed-color emoji.
|
||||
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
|
||||
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
|
||||
+ 'until one is played. (Color stays fixed to the original.)';
|
||||
|
||||
let error = '';
|
||||
|
||||
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
|
||||
$: column_cards = JSON.parse(obs.played_cards || '[]');
|
||||
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
|
||||
$: success_count = column_cards.filter(c => c.success).length;
|
||||
$: is_completed = success_count >= state.players.length;
|
||||
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[obs.suit] || ''}`;
|
||||
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
|
||||
|
||||
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
|
||||
// part of an open Challenge we know who that is — return their rank, else null.
|
||||
$: challengedRank = (() => {
|
||||
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
|
||||
if (!ch) return null;
|
||||
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
|
||||
})();
|
||||
|
||||
async function post(path, data) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(path, 'POST', data);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
|
||||
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
|
||||
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
|
||||
</script>
|
||||
|
||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
|
||||
class:completed={is_completed}
|
||||
class:in-challenge={in_challenge && !embedded}
|
||||
data-obstacle-id={obs.id}
|
||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||
on:drop={(e) => {
|
||||
if (is_completed) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
const cardCode = e.dataTransfer.getData('text/plain');
|
||||
if (cardCode) {
|
||||
if (isJoker(cardCode)) playJoker(cardCode);
|
||||
else playCard(cardCode);
|
||||
}
|
||||
}}>
|
||||
{#if error}
|
||||
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
|
||||
{/if}
|
||||
<!-- Card column: original at the back, each play stacked in front of it, the
|
||||
latest fully visible and earlier cards peeking their rank + suit. -->
|
||||
<div class="obstacle-stack">
|
||||
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacle’s color and never changes.'}>
|
||||
<Card card={obs.original_card} size="medium" />
|
||||
</div>
|
||||
{#each column_cards as pc, i (i)}
|
||||
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
|
||||
class:is-latest={i === column_cards.length - 1}
|
||||
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
|
||||
<Card card={pc.card} size="medium" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="obstacle-details">
|
||||
<h4><span class="obs-card-code">{cardCodeLabel}</span> — {obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
|
||||
<p class="desc">{obs.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Value Display -->
|
||||
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
|
||||
<span class="val-label">Current Difficulty</span>
|
||||
<span class="val-number">
|
||||
{#if is_face_active}
|
||||
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
|
||||
{:else}
|
||||
{obs.current_value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<div class="obstacle-successes-display text-center">
|
||||
<span class="val-label">Successes</span>
|
||||
<span class="val-number">{success_count} / {state.players.length}</span>
|
||||
</div>
|
||||
|
||||
{#if is_completed && state.player.role === 'deep'}
|
||||
<div class="clear-obstacle-row text-center">
|
||||
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
42
frontend/src/lib/changelog.js
Normal file
42
frontend/src/lib/changelog.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// App version and player-facing changelog, surfaced in the ☰ menu → About
|
||||
// (rendered by AboutModal.svelte).
|
||||
//
|
||||
// Maintenance (see AGENTS.md "Versioning & changelog"):
|
||||
// - Bump VERSION by one on every commit.
|
||||
// - 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 = 4;
|
||||
|
||||
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||
export const CHANGELOG = [
|
||||
{
|
||||
version: 4,
|
||||
date: '2026-06-15',
|
||||
changes: [
|
||||
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
date: '2026-06-15',
|
||||
changes: [
|
||||
'The rulebook’s Example of Play now has card diagrams at each step, showing how each Obstacle’s column grows as the scene unfolds.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
date: '2026-06-15',
|
||||
changes: [
|
||||
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebook’s card layout.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
date: '2026-06-15',
|
||||
changes: [
|
||||
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
|
||||
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
|
||||
],
|
||||
},
|
||||
];
|
||||
84
frontend/src/lib/tooltip.js
Normal file
84
frontend/src/lib/tooltip.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// A styled hover/focus tooltip that replaces the native `title` attribute.
|
||||
//
|
||||
// use:tooltip={"plain text"} -> rendered as text
|
||||
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
|
||||
//
|
||||
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
|
||||
// lib/cards.js); never interpolate raw player input into the `html` form.
|
||||
//
|
||||
// The floating element is appended to <body> and positioned with fixed
|
||||
// coordinates so it escapes any overflow:hidden / transformed ancestors
|
||||
// (cards, modals, scrolling columns).
|
||||
|
||||
function contentEmpty(content) {
|
||||
if (!content) return true;
|
||||
if (typeof content === 'object') return !content.html;
|
||||
return String(content).trim() === '';
|
||||
}
|
||||
|
||||
export function tooltip(node, content) {
|
||||
let current = content;
|
||||
let tip = null;
|
||||
|
||||
function show() {
|
||||
if (tip || contentEmpty(current)) return;
|
||||
tip = document.createElement('div');
|
||||
tip.className = 'tooltip-pop';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
if (typeof current === 'object' && current.html != null) {
|
||||
tip.innerHTML = current.html;
|
||||
} else {
|
||||
tip.textContent = String(current);
|
||||
}
|
||||
document.body.appendChild(tip);
|
||||
position();
|
||||
requestAnimationFrame(() => tip && tip.classList.add('visible'));
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!tip) return;
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = tip.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
// Prefer above the target; flip below when there isn't room.
|
||||
let top = r.top - t.height - margin;
|
||||
if (top < margin) top = r.bottom + margin;
|
||||
if (top + t.height > window.innerHeight - margin) {
|
||||
top = Math.max(margin, window.innerHeight - t.height - margin);
|
||||
}
|
||||
let left = r.left + r.width / 2 - t.width / 2;
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
|
||||
tip.style.top = `${top}px`;
|
||||
tip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (tip) {
|
||||
tip.remove();
|
||||
tip = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('focus', show);
|
||||
node.addEventListener('blur', hide);
|
||||
|
||||
return {
|
||||
update(newContent) {
|
||||
current = newContent;
|
||||
if (tip) {
|
||||
// Refresh the content of an already-visible tip in place.
|
||||
hide();
|
||||
show();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('focus', show);
|
||||
node.removeEventListener('blur', hide);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -63,33 +63,6 @@
|
||||
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
|
||||
</header>
|
||||
|
||||
<div class="glass-panel creation-card">
|
||||
<h2>Muster a New Crew</h2>
|
||||
<form on:submit|preventDefault={createGame}>
|
||||
<div class="form-group">
|
||||
<label for="crewName">Crew Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crewName"
|
||||
bind:value={crewName}
|
||||
required
|
||||
placeholder="e.g. The Salty Dogs"
|
||||
class="input-field input-large"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Game'}
|
||||
</button>
|
||||
</form>
|
||||
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||
</div>
|
||||
|
||||
{#if sessions.length > 0}
|
||||
<div class="glass-panel creation-card saved-sessions">
|
||||
<h2>Rejoin a Crew</h2>
|
||||
@@ -118,12 +91,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="glass-panel creation-card">
|
||||
<h2>Muster a New Crew</h2>
|
||||
<form on:submit|preventDefault={createGame}>
|
||||
<div class="form-group">
|
||||
<label for="crewName">Crew Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crewName"
|
||||
bind:value={crewName}
|
||||
required
|
||||
placeholder="e.g. The Salty Dogs"
|
||||
class="input-field input-large"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Game'}
|
||||
</button>
|
||||
</form>
|
||||
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||
</div>
|
||||
|
||||
<div class="welcome-links">
|
||||
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Space the Rejoin box off the Muster box below it. */
|
||||
.saved-sessions {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.session-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -232,6 +232,103 @@ a { color: var(--neon-cyan); }
|
||||
font-family: var(--font-heading);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Example-of-Play card diagrams. Mirror the SPA's Obstacle column:
|
||||
the original card sits at the back (gold ring), each play stacks in
|
||||
front with a heavy negative margin so only its rank + suit peeks out,
|
||||
and the newest card — which sets the difficulty — is fully visible at
|
||||
the bottom. Green ring = a play that beat the difficulty; red = one
|
||||
that didn't. Card faces stay light parchment in both themes, like the app.
|
||||
============================================================ */
|
||||
:root {
|
||||
--ex-card-face: #faf4e0;
|
||||
--ex-card-shade: #ead9b8;
|
||||
--ex-card-red: #c2303a;
|
||||
--ex-card-black: #2c3245;
|
||||
--ex-success: #3f9d5b;
|
||||
--ex-fail: #c0392b;
|
||||
--ex-card-h: 74px;
|
||||
--ex-peek: 30px;
|
||||
}
|
||||
|
||||
.ex-board {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem 2.25rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--well-bg);
|
||||
border: 1px solid var(--row-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ex-board-label {
|
||||
flex-basis: 100%;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ex-obstacle { display: flex; align-items: flex-start; gap: 0.85rem; }
|
||||
|
||||
.ex-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ex-stack-card { position: relative; border-radius: 6px; line-height: 0; }
|
||||
.ex-stack-card + .ex-stack-card { margin-top: calc(var(--ex-peek) - var(--ex-card-h)); }
|
||||
.ex-stack-card.is-original { box-shadow: 0 0 0 2px var(--gold); }
|
||||
.ex-stack-card.is-success { box-shadow: 0 0 0 2px var(--ex-success); }
|
||||
.ex-stack-card.is-fail { box-shadow: 0 0 0 2px var(--ex-fail); }
|
||||
|
||||
.ex-card {
|
||||
width: 52px;
|
||||
height: var(--ex-card-h);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(150deg, var(--ex-card-face), var(--ex-card-shade));
|
||||
border: 1px solid rgba(44, 50, 69, 0.35);
|
||||
color: var(--ex-card-black);
|
||||
padding: 4px 5px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 5px rgba(36, 62, 87, 0.25);
|
||||
}
|
||||
|
||||
.ex-card.red { color: var(--ex-card-red); }
|
||||
|
||||
.ex-card .ex-corner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ex-card .ex-corner .r { font-size: 0.82rem; }
|
||||
.ex-card .ex-corner .s { font-size: 0.72rem; }
|
||||
|
||||
.ex-card .ex-pip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.ex-meta { font-size: 0.85rem; line-height: 1.35; }
|
||||
.ex-meta .ex-title { display: block; font-family: var(--font-heading); font-weight: 700; color: var(--text-primary); }
|
||||
.ex-meta .ex-val { color: var(--gold); font-weight: 800; }
|
||||
|
||||
.ex-legend .ex-ok { color: var(--ex-success); font-weight: 700; }
|
||||
.ex-legend .ex-no { color: var(--ex-fail); font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1021,6 +1118,28 @@ a { color: var(--neon-cyan); }
|
||||
are a total of two Obstacles on the list. Sally draws from the top of the deck: 10 of ♥
|
||||
Hearts: Cheese Thief. The Obstacle List now has: 7 of ♥ Hearts: Bad Breakups (value: 7) and
|
||||
10 of ♥ Hearts: Cheese Thief (value: 10).</p>
|
||||
|
||||
<p class="aside ex-legend">In the diagrams below, each Obstacle is a column of cards. The
|
||||
<strong>original</strong> card (gold outline) stays at the back and fixes the color; each play
|
||||
stacks in front of it, and the newest card — which sets the current difficulty — sits in full
|
||||
view. <span class="ex-ok">Green</span> marks a play that beat the difficulty, <span class="ex-no">red</span>
|
||||
one that didn't.</p>
|
||||
<div class="ex-board">
|
||||
<div class="ex-board-label">Obstacle List after the draw</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">7</span><br>0 successes</div>
|
||||
</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">10</span><br>0 successes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p><strong>Step 3: Frame the Scene!</strong></p>
|
||||
<p>Sally: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and
|
||||
shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete
|
||||
@@ -1065,6 +1184,24 @@ a { color: var(--neon-cyan); }
|
||||
<p><strong>Draw Cards:</strong> Jake played a 3 of ♥ Hearts against a 10 of ♥ Hearts. Since
|
||||
both were red, Jake draws 1 card and adds it to their hand.</p>
|
||||
|
||||
<div class="ex-board">
|
||||
<div class="ex-board-label">After the First Challenge</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
|
||||
</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">3</span><br>0 successes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Second Challenge</h4>
|
||||
<p>Gabe: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table,
|
||||
looking for clues, and maybe to steal it." Sally: "Oh no you don't! Challenge time! Can you
|
||||
@@ -1089,6 +1226,25 @@ a { color: var(--neon-cyan); }
|
||||
<p><strong>Draw Cards:</strong> Gabe played a King of ♠ Spades (black) against a red
|
||||
Obstacle, so he doesn't draw any cards.</p>
|
||||
|
||||
<div class="ex-board">
|
||||
<div class="ex-board-label">After the Second Challenge</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
|
||||
</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">Rank</span><br>1 success<br><span class="aside">(King = Secret Pirate Technique)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Third Challenge</h4>
|
||||
<p>Gabe: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew
|
||||
with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come
|
||||
@@ -1123,6 +1279,27 @@ a { color: var(--neon-cyan); }
|
||||
draws 1 card. Jake played Queen of ♥ Hearts (red) against a red Obstacle, so they WOULD draw
|
||||
1 card, but they were assisting, so they didn't get to draw.</p>
|
||||
|
||||
<div class="ex-board">
|
||||
<div class="ex-board-label">After the Third Challenge</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">Q</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">Rank</span><br>2 successes<br><span class="aside">(Queen = Secret Pirate Technique)</span></div>
|
||||
</div>
|
||||
<div class="ex-obstacle">
|
||||
<div class="ex-stack">
|
||||
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s">♠</span></span><span class="ex-pip">♠</span></div></div>
|
||||
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">9</span><span class="s">♥</span></span><span class="ex-pip">♥</span></div></div>
|
||||
</div>
|
||||
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">9</span><br>2 successes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Ending the Scene</h4>
|
||||
<p>Sally: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players
|
||||
have been challenged at least once. Has anyone made progress on their Objectives?"</p>
|
||||
|
||||
Reference in New Issue
Block a user