The discard pile lies face-up, and the chronicle names what falls

At the table the discards sit face-up for anyone to leaf through. The
view now carries the pile (deck order stays secret), the "discards N"
count in the rail became a link, and it opens a booklet-styled reader:
every cast-away card newest first, tap any to enlarge. And the
chronicle stops mumbling "discards 1 card(s)" — it names names, so a
wraith's theft reads "bob discards Fireball," exactly what a table
would see as the card hits the pile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 13:28:55 -04:00
co-authored by Claude Fable 5
parent d1eace9530
commit 0ebae03b1c
3 changed files with 75 additions and 2 deletions
+3
View File
@@ -47,6 +47,8 @@ export interface GameView {
treasures: TreasureState[]; treasures: TreasureState[];
deckCount: number; deckCount: number;
discardCount: number; discardCount: number;
/** The discard pile, face-up as at the table (last = most recent). */
discardPile: CardInstance[];
/** Cards on the stack are face-up: the whole exchange is public. */ /** Cards on the stack are face-up: the whole exchange is public. */
stack: CastStack | null; stack: CastStack | null;
pendingDiscard: PlayerId | null; pendingDiscard: PlayerId | null;
@@ -121,6 +123,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length, deckCount: state.deck.length,
discardCount: state.discard.length, discardCount: state.discard.length,
discardPile: [...state.discard],
stack: state.stack, stack: state.stack,
pendingDiscard: state.pendingDiscard, pendingDiscard: state.pendingDiscard,
sustained: state.sustained.map((s) => ({ ...s })), sustained: state.sustained.map((s) => ({ ...s })),
+70 -1
View File
@@ -26,6 +26,8 @@
let peekCreatureId = $state<string | null>(null); let peekCreatureId = $state<string | null>(null);
/** Bare-knuckle demolition: click a wall to punch it. */ /** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false); let punchWallMode = $state(false);
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -714,6 +716,26 @@
</span> </span>
</header> </header>
{#if showDiscards && view}
<div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
<div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<header class="discard-head">
<span>The discard pile — {view.discardCount} card{view.discardCount === 1 ? "" : "s"}, newest first</span>
<button class="inspector-close discard-close" onclick={() => (showDiscards = false)} aria-label="close">×</button>
</header>
<div class="discard-grid">
{#each [...view.discardPile].reverse() as card (card.instanceId)}
<Card {card} onclick={() => (peekCard = card)} />
{/each}
{#if view.discardPile.length === 0}
<span class="discard-empty">Nothing has been cast away yet.</span>
{/if}
</div>
</div>
</div>
{/if}
{#if peekCard} {#if peekCard}
<div class="inspector" role="img" aria-label="displayed card, enlarged"> <div class="inspector" role="img" aria-label="displayed card, enlarged">
<button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button> <button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button>
@@ -1095,7 +1117,9 @@
</div> </div>
{/if} {/if}
{/each} {/each}
<div class="deck-line">draw pile {view.deckCount} · discards {view.discardCount}</div> <div class="deck-line">draw pile {view.deckCount} ·
<button class="discard-link" onclick={() => (showDiscards = true)}>discards {view.discardCount}</button>
</div>
</div> </div>
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}> <div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
@@ -1837,6 +1861,51 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.discard-link {
background: none;
border: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
padding: 0;
}
.discard-link:hover { color: #43331f; }
.discard-book {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
width: min(52rem, 94vw);
max-height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
overflow: hidden;
}
.discard-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 2px solid #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #43331f;
}
.discard-close { position: static; }
.discard-grid {
overflow-y: auto;
padding: 0.9rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
gap: 0.7rem;
justify-items: center;
}
.discard-empty { font-family: "Caveat", cursive; color: #8a7a5e; font-size: 1.1rem; }
.peek-note { .peek-note {
margin-top: 0.4rem; margin-top: 0.4rem;
background: #2b2218; background: #2b2218;
+2 -1
View File
@@ -151,7 +151,8 @@ export function humanize(e: GameEvent): string | null {
} }
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null; case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`; case "cardsDiscarded":
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null; case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`; case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
case "turnEnded": return null; case "turnEnded": return null;