Add private-hand search, category filters and display sorting
Wrap existing physical hand cards with local name search, printed-category filters and saved/name/category display order. Preserve card IDs, actions and inspectors, keep required decisions outside filtering, and recompute results from current owned cards. Provide count, clear filters, keyboard scrolling, separate empty/no-match states and internal guidance. Filters reset on leaving the hand tab, changing seats or refreshing; saved order is never modified. Correct the stale Harass availability note to reflect connected allocation. Six focused tests cover normalized literal search, duplicate physical IDs, canonical expansion categories, stable immutable sorting, current holdings, JSON restoration, full-table per-seat privacy and empty setup messaging. Independent review found no material issue. Types, lint, all 4996 offline tests and production build pass on stable source. No HTTP, auth, schema or persistence implementation changed, so HTTP tests were not repeated. Browser QA used a new two-player Basic Harkonnen/Easy Atreides game created through genuine setup. Search/category/reset, named inspector and all three sort orders worked. An active no-match filter updated from 0 of 2 to 0 of 4 when a real auction purchase and Harkonnen bonus arrived. Leaving the hand tab and refreshing restored all four cards in their original saved order while the second auction remained at a human decision. Enter in search performed no game action. The internal guide worked by keyboard. At 390px, controls were 44px high with 16px text and 14px labels, form width/scroll width 339px; the focused overflowing card row responded to ArrowRight. Normal viewport restored. Screenshot capture timed out; full visual/screen-reader acceptance and complete expansion/card-effect combinations remain open. All 1538 opening rooms, 3258 seats and auxiliary rows remain unchanged. SQLite integrity and foreign keys pass. Only one isolated QA game, its human seat and entry receipt were added; the AI remains in game state. Healthy server reused without restart, migration, reset or automation. No AI strategy or mode/publication gate changed. Private evidence: 20260921-hand-browsing. Verified source tree: 39dce28efa52dd66baf019baaf8610e6795601b3b569ac4ac0b6f405bffc1d96
This commit is contained in:
1 parent
14a7800fe9
commit
e3cfd82540
13 files changed
+328
-27
No files matched your search
@@ -73,6 +73,10 @@ Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
|||||||
counts separately from hands, auction cards and discards. See
|
counts separately from hands, auction cards and discards. See
|
||||||
[draw-pile counts](docs/DRAW_PILES.md).
|
[draw-pile counts](docs/DRAW_PILES.md).
|
||||||
|
|
||||||
|
In **Your private hand**, search card names, filter by printed category or change
|
||||||
|
the display order. **Show all cards** clears filters. Inspectors and card actions
|
||||||
|
stay with each physical card. See [hand browsing](docs/HAND_BROWSING.md).
|
||||||
|
|
||||||
The table header offers optional **Sound effects**, with mute, volume and a test
|
The table header offers optional **Sound effects**, with mute, volume and a test
|
||||||
sample. Short cues mark phase changes and automatic action notices. Sound starts
|
sample. Short cues mark phase changes and automatic action notices. Sound starts
|
||||||
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
||||||
|
|||||||
@@ -1005,6 +1005,14 @@ footer {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
.hand-browse { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding-top: 16px; }
|
||||||
|
.hand-browse label { display: grid; gap: 6px; flex: 1 1 180px; min-width: 0; font-size: 0.875rem; }
|
||||||
|
.hand-browse input, .hand-browse select { width: 100%; min-height: 44px; font-size: 1rem; }
|
||||||
|
.hand-browse-results { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; flex: 1 1 100%; font-size: 1rem; }
|
||||||
|
.hand-browse-results button { min-height: 44px; font-size: 1rem; }
|
||||||
|
.hand-browse-results a { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
.hand-browse-empty { font-size: 1rem; line-height: 1.5; }
|
||||||
|
.hand-grid:focus-visible { outline: 2px solid #e0c384; outline-offset: 3px; }
|
||||||
.treachery-card {
|
.treachery-card {
|
||||||
flex: 0 0 240px;
|
flex: 0 0 240px;
|
||||||
min-height: 210px;
|
min-height: 210px;
|
||||||
|
|||||||
+13
-13
@@ -4,6 +4,7 @@ import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
|||||||
import { KwisatzInspector } from './kwisatz-inspector';
|
import { KwisatzInspector } from './kwisatz-inspector';
|
||||||
import { ForceInspector } from './force-inspector';
|
import { ForceInspector } from './force-inspector';
|
||||||
import { DrawPiles } from './draw-piles';
|
import { DrawPiles } from './draw-piles';
|
||||||
|
import { HandBrowser } from './hand-browser';
|
||||||
import { LobbyBotControls } from './lobby-bot-controls';
|
import { LobbyBotControls } from './lobby-bot-controls';
|
||||||
import { IxRicheseTechnology } from './ix-richese-technology';
|
import { IxRicheseTechnology } from './ix-richese-technology';
|
||||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||||
@@ -4870,8 +4871,16 @@ export function GameTable({
|
|||||||
<NullentropyBox game={g} act={act} busy={busy} />
|
<NullentropyBox game={g} act={act} busy={busy} />
|
||||||
<ResidualPoison game={g} act={act} busy={busy} />
|
<ResidualPoison game={g} act={act} busy={busy} />
|
||||||
<PortableSnooper game={g} act={act} busy={busy} />
|
<PortableSnooper game={g} act={act} busy={busy} />
|
||||||
<div className="hand-grid">
|
<HandBrowser key={`${g.code}:${me.id}`} cards={me.hand ?? []} empty={
|
||||||
{me.hand?.map((c) => {
|
<p className="muted">
|
||||||
|
{setupStage
|
||||||
|
? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
|
||||||
|
: g.status === 'lobby'
|
||||||
|
? 'Your opening cards are dealt after starting force placement.'
|
||||||
|
: 'Your Treachery hand is empty.'}
|
||||||
|
</p>
|
||||||
|
}>
|
||||||
|
{(c) => {
|
||||||
const richese = richeseCardDefinition(c);
|
const richese = richeseCardDefinition(c);
|
||||||
const presentation = cardPresentation(c);
|
const presentation = cardPresentation(c);
|
||||||
const ecaz = ecazTreacheryDefinition(c);
|
const ecaz = ecazTreacheryDefinition(c);
|
||||||
@@ -5394,17 +5403,8 @@ export function GameTable({
|
|||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
{!me.hand?.length && (
|
</HandBrowser>
|
||||||
<p className="muted">
|
|
||||||
{setupStage
|
|
||||||
? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
|
|
||||||
: g.status === 'lobby'
|
|
||||||
? 'Your opening cards are dealt after starting force placement.'
|
|
||||||
: 'Your Treachery hand is empty.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!!me.faceDancers?.length && (
|
{!!me.faceDancers?.length && (
|
||||||
<div className="notice">
|
<div className="notice">
|
||||||
<h3>Your Face Dancers</h3>
|
<h3>Your Face Dancers</h3>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
import { Fragment, useId, useState, type ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Card } from '@/game/cards';
|
||||||
|
import { browseHand, type HandCategory, type HandSort } from '@/game/hand-browsing';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export function HandBrowser({ cards, empty, children }: {
|
||||||
|
cards: readonly Card[];
|
||||||
|
empty: ReactNode;
|
||||||
|
children: (card: Card) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [category, setCategory] = useState<HandCategory>('all');
|
||||||
|
const [sort, setSort] = useState<HandSort>('hand');
|
||||||
|
const gridId = useId();
|
||||||
|
const shown = browseHand(cards, query, category, sort);
|
||||||
|
const filtered = query !== '' || category !== 'all';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cards.length > 0 && (
|
||||||
|
<search aria-label="Browse your private hand">
|
||||||
|
<form className="hand-browse" onSubmit={event => event.preventDefault()}>
|
||||||
|
<label>
|
||||||
|
Search your hand
|
||||||
|
<input type="search" placeholder="Card name" value={query} maxLength={128} autoComplete="off" spellCheck={false}
|
||||||
|
aria-controls={gridId} onChange={event => setQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Printed category
|
||||||
|
<select value={category} aria-controls={gridId} onChange={event => setCategory(event.target.value as HandCategory)}>
|
||||||
|
<option value="all">All categories</option>
|
||||||
|
<option value="weapon">Weapons</option>
|
||||||
|
<option value="defense">Defenses</option>
|
||||||
|
<option value="worthless">Worthless</option>
|
||||||
|
<option value="hero">Cheap Hero / Heroine</option>
|
||||||
|
<option value="special">Special</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display order
|
||||||
|
<select value={sort} aria-controls={gridId} onChange={event => setSort(event.target.value as HandSort)}>
|
||||||
|
<option value="hand">Hand order</option>
|
||||||
|
<option value="name">Name A–Z</option>
|
||||||
|
<option value="category">Printed category</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="hand-browse-results">
|
||||||
|
<output aria-live="polite">{shown.length} of {cards.length} {cards.length === 1 ? 'card' : 'cards'} shown</output>
|
||||||
|
{filtered && <Button type="button" variant="outline" onClick={() => { setQuery(''); setCategory('all'); }}>Show all cards</Button>}
|
||||||
|
<Link href="/rules?topic=hand-browsing#hand-browsing">Hand browsing help</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</search>
|
||||||
|
)}
|
||||||
|
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need access to this independently scrolling card row. */}
|
||||||
|
<section className="hand-grid" id={gridId} aria-label="Your hand cards" tabIndex={0}>
|
||||||
|
{cards.length === 0 ? empty : shown.length === 0
|
||||||
|
? <p className="hand-browse-empty">No cards match. Change the name or category, or choose Show all cards.</p>
|
||||||
|
: shown.map(card => <Fragment key={card.id}>{children(card)}</Fragment>)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Component inventory and verification ledger
|
# Component inventory and verification ledger
|
||||||
|
|
||||||
|
**Hand browsing, 21 September 2026:** owned cards now have name search, printed
|
||||||
|
category filters and display sorting, with physical IDs, complete existing card
|
||||||
|
controls and inspectors retained. Empty results have a clear reset, and current
|
||||||
|
cards update the visible results. This is a connected browsing prototype, not
|
||||||
|
full card-effect or visual certification. See [scope and evidence](HAND_BROWSING.md).
|
||||||
|
|
||||||
**Draw-pile counts, 21 September 2026:** the table now displays authoritative
|
**Draw-pile counts, 21 September 2026:** the table now displays authoritative
|
||||||
Treachery and Spice draw counts above the board, separate from auctions, hands,
|
Treachery and Spice draw counts above the board, separate from auctions, hands,
|
||||||
caches and discards. Existing Nexus counts and authorized card inspectors remain
|
caches and discards. Existing Nexus counts and authorized card inspectors remain
|
||||||
|
|||||||
+14
-13
@@ -6,20 +6,21 @@ gated.** Prototypes do not open completion or publication gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
Latest delivered checkpoint: `537da4a`, [public force inspection](FORCE_INSPECTION.md).
|
Latest delivered checkpoint: `14a7800`, [Treachery and Spice draw counts](DRAW_PILES.md).
|
||||||
Types, lint, **4,984 offline tests** and production build passed. All 1,499 rooms,
|
Types, lint, **4,990 offline tests**, production build and **49 HTTP tests** passed.
|
||||||
3,171 seats and auxiliary rows stayed unchanged. Browser checks covered live
|
All 1,499 opening rooms, 3,171 seats and auxiliary rows stayed unchanged; isolated
|
||||||
counts, twelve faction faces, keyboard inspection, internal guidance, refresh and
|
HTTP tests brought the baseline to 1,538 rooms and 3,258 seats. Browser counts,
|
||||||
390px layout. Screenshot capture remained unavailable; full visual acceptance is
|
internal guide, keyboard navigation, refresh and 390px layout passed. Screenshot
|
||||||
open. The healthy server was reused. Prior checkpoints include `2b071a4` for
|
capture remained unavailable; full visual acceptance is open. The healthy server
|
||||||
Nexus offers, `91dcca4` for bribes and `7814f74` for table discussion.
|
was reused. Prior checkpoints include `537da4a` for force inspection, `2b071a4`
|
||||||
|
for Nexus offers and `91dcca4` for bribes.
|
||||||
|
|
||||||
Current work: [Treachery and Spice draw counts](DRAW_PILES.md) connect public
|
Current work: [private-hand browsing](HAND_BROWSING.md) adds name search, printed
|
||||||
physical pile sizes above the board, separate from auction and private holdings.
|
category filters and stable display sorting around existing card inspectors and
|
||||||
Setup, actual draw/refill, skipped-worm return, Thumper exclusion, privacy and
|
actions. Physical IDs and saved order remain intact; required choices stay
|
||||||
JSON/rendering checks accompany source-bound independent review, browser and
|
outside the filtered row. Focused selection/privacy/rendering checks accompany
|
||||||
preservation/Git evidence. No game action or AI strategy changes. The broader
|
independent review and private browser, preservation and Git evidence. No game
|
||||||
game remains unfinished; continue independent card handling and player features.
|
action or AI strategy changes. The broader game remains unfinished.
|
||||||
The separate Bureaucrat/Emperor gift question is pending.
|
The separate Bureaucrat/Emperor gift question is pending.
|
||||||
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
|
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
|
||||||
refinement until feature completion, followed by three adjacent 75% targets.
|
refinement until feature completion, followed by three adjacent 75% targets.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Private hand browsing
|
||||||
|
|
||||||
|
Connected prototype, 21 September 2026. Name search, printed-category filtering
|
||||||
|
and display sorting now wrap the existing private hand cards. This adds a usable
|
||||||
|
browsing function without changing card actions or rule-completion gates.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
`HandBrowser` receives only `me.hand`. `browseHand` creates a display array with
|
||||||
|
the same physical card objects/IDs; it never sorts the saved hand in place.
|
||||||
|
Names match literally, ignoring case, surrounding whitespace and accents. The
|
||||||
|
shared `printedCardCategory` supplies the primary category, including canonical
|
||||||
|
expansion cards. It does not determine dynamic battle-slot or faction legality.
|
||||||
|
|
||||||
|
Players choose saved hand order, name A–Z or primary category followed by name.
|
||||||
|
Duplicate names remain separate and keep relative order. The result count and
|
||||||
|
**Show all cards** make filtered-out cards reachable. Clearing removes both
|
||||||
|
filters and retains the display sort. Empty hands retain the existing setup or
|
||||||
|
empty-hand explanation; no-match results have a separate recovery message.
|
||||||
|
|
||||||
|
The full card-render callback is reused with its physical-ID actions and
|
||||||
|
inspectors. Dedicated effect panels and mandatory choices remain outside the
|
||||||
|
filtered row. The scrollable row is keyboard-focusable, and Enter in the search
|
||||||
|
form cannot submit a game action. Native inputs and reset controls have 44px
|
||||||
|
minimum height; control text is 16px and labels 14px.
|
||||||
|
|
||||||
|
Filters stay in component memory. The room/seat key and leaving the hand tab
|
||||||
|
reset them, as does refresh. Current props drive every result, so removed cards
|
||||||
|
are not retained and new cards update the total even under an active filter.
|
||||||
|
There are no new endpoints, saved fields, credentials, storage, AI strategies
|
||||||
|
or hidden-information projections. Existing saved continuation remains shared.
|
||||||
|
|
||||||
|
The card presentation also corrects a stale Harass & Withdraw availability note:
|
||||||
|
[revealed physical allocation](HARASS_ALLOCATION.md) is connected. Richese and
|
||||||
|
other unresolved combinations remain guarded; no additional rule is enabled.
|
||||||
|
|
||||||
|
## Evidence and limits
|
||||||
|
|
||||||
|
Focused tests cover literal/normalized names, duplicate IDs, canonical expansion
|
||||||
|
categories, stable immutable sorting, current-card removal/acquisition and JSON
|
||||||
|
restoration. Full-table rendering checks that each seat receives only its own
|
||||||
|
inspectors while controls are busy, plus the undealt empty-state explanation.
|
||||||
|
Use `npm test -- hand-brows`.
|
||||||
|
|
||||||
|
Independent review, source-bound browser observations, final type/lint/offline
|
||||||
|
checks and build, saved-game preservation and Git delivery belong to the private
|
||||||
|
checkpoint. Browser samples and selection tests do not certify all card effects,
|
||||||
|
full visual accessibility or expansion combinations. Generic discard browsing
|
||||||
|
and manual drag-reordering remain outside this checkpoint.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 21 September 2026 — private-hand browsing
|
||||||
|
|
||||||
|
The private hand now supports literal name search, printed-category filtering
|
||||||
|
and stable display sorting. Physical card IDs, existing inspectors/actions and
|
||||||
|
saved order remain intact; required choices stay outside the filtered row.
|
||||||
|
[Hand browsing](HAND_BROWSING.md) documents empty results, reset, live changes
|
||||||
|
and local-only filter lifetime. A stale Harass allocation availability sentence
|
||||||
|
is corrected. Focused tests and independent review accompany the private final
|
||||||
|
browser, check/build, preservation and Git evidence. No AI strategy or gate changed.
|
||||||
|
|
||||||
## 21 September 2026 — live Treachery and Spice draw counts
|
## 21 September 2026 — live Treachery and Spice draw counts
|
||||||
|
|
||||||
The table now shows both physical draw-pile counts above the board. The public
|
The table now shows both physical draw-pile counts above the board. The public
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
|||||||
availability: ecaz.card.effect === 'recruits'
|
availability: ecaz.card.effect === 'recruits'
|
||||||
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
|
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
|
||||||
: ecaz.card.effect === 'harassWithdraw'
|
: ecaz.card.effect === 'harassWithdraw'
|
||||||
? 'Harass & Withdraw has development controls in either battle-card slot. Ambiguous physical allocations, Richese card combinations and additional optional modules remain guarded.'
|
? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Richese card combinations and additional optional modules remain guarded.'
|
||||||
: 'This battle effect is still being implemented.',
|
: 'This battle effect is still being implemented.',
|
||||||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits')),
|
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits')),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Card } from './cards';
|
||||||
|
import { CARD_CATEGORIES, printedCardCategory, type CardCategory } from './card-category';
|
||||||
|
|
||||||
|
export type HandCategory = 'all' | CardCategory;
|
||||||
|
export type HandSort = 'hand' | 'name' | 'category';
|
||||||
|
|
||||||
|
const normalized = (name: string) => name.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
|
||||||
|
|
||||||
|
/** Select already-visible physical cards without changing their saved order or identity.
|
||||||
|
* Printed categories organize the display; they never decide action legality. */
|
||||||
|
export function browseHand<T extends Card>(cards: readonly T[], query: string, category: HandCategory, sort: HandSort): T[] {
|
||||||
|
const needle = normalized(query.trim());
|
||||||
|
const result = cards.filter(card =>
|
||||||
|
(!needle || normalized(card.name).includes(needle)) &&
|
||||||
|
(category === 'all' || printedCardCategory(card) === category),
|
||||||
|
);
|
||||||
|
if (sort !== 'hand') result.sort((a, b) =>
|
||||||
|
(sort === 'category'
|
||||||
|
? CARD_CATEGORIES.indexOf(printedCardCategory(a)) - CARD_CATEGORIES.indexOf(printedCardCategory(b))
|
||||||
|
: 0) || a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -205,6 +205,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'interactive-introduction',
|
'interactive-introduction',
|
||||||
'storm-cards',
|
'storm-cards',
|
||||||
'draw-piles',
|
'draw-piles',
|
||||||
|
'hand-browsing',
|
||||||
'kwisatz-haderach',
|
'kwisatz-haderach',
|
||||||
'truthtrance-spice',
|
'truthtrance-spice',
|
||||||
'ecaz-ambassadors',
|
'ecaz-ambassadors',
|
||||||
@@ -2108,6 +2109,30 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'hand-browsing',
|
||||||
|
title: 'Browse your private hand',
|
||||||
|
category: 'Getting started',
|
||||||
|
coverage: 'Partial',
|
||||||
|
developmentStage: 'Prototyped',
|
||||||
|
summary: 'Find cards by name or printed category and arrange their display while keeping every physical card and its controls.',
|
||||||
|
steps: [
|
||||||
|
'Open Your private hand and type part of a card name in Search your hand. Search ignores capitalization, surrounding spaces and accents; punctuation is literal. The result count shows how many of your current cards are visible.',
|
||||||
|
'Printed category filters weapons, defenses, Worthless cards, Cheap Heroes or Heroines, and Special cards. These are the cards’ primary printed categories. Flexible battle roles and faction powers still use their normal choices and rules.',
|
||||||
|
'Display order offers your saved hand order, Name A–Z or printed category with names within each category. Identical names remain separate physical cards. Choosing Hand order restores their saved sequence.',
|
||||||
|
'Show all cards clears the name and category filters while keeping your selected display order. An empty hand has a different message from a search with no matches.',
|
||||||
|
'Inspect and play the displayed cards using their existing controls. Required decisions and dedicated effect panels remain outside this filter. Browsing does not play, discard, rearrange the saved hand or change a Battle Plan.',
|
||||||
|
'Incoming table updates recalculate the results from the cards you currently own. Filters and display sorting are local to the open hand panel; leaving the tab, changing seats or refreshing resets them. Saved cards and pending game decisions remain intact.',
|
||||||
|
],
|
||||||
|
related: ['draw-piles', 'battle-cards', 'privacy', 'nullentropy-search'],
|
||||||
|
checklist: [
|
||||||
|
{ area: 'Implementation', status: 'Implemented', detail: 'Search, printed-category filtering and stable display sorting select only the current owned cards without changing their physical IDs or saved order.', evidence: ['game/hand-browsing.ts'] },
|
||||||
|
{ area: 'Player controls', status: 'Implemented', detail: 'Native search/category/order controls, result count, clear filters, keyboard scrolling and existing per-card inspectors/actions are connected.', evidence: ['components/hand-browser.tsx', 'components/game-table.tsx'] },
|
||||||
|
{ area: 'AI', status: 'Partial', detail: 'This local display adds no game action. Existing legal AI participation is unchanged; strategic refinement remains deferred.' },
|
||||||
|
{ area: 'Documentation', status: 'Implemented', detail: 'Filtering, printed roles, sort order, identical cards, empty states, live changes and refresh behavior are explained.', evidence: ['docs/HAND_BROWSING.md'] },
|
||||||
|
{ area: 'Verification', status: 'Partial', detail: 'Focused selection and live table rendering cover expansion categories, physical identity, immutable sorting, current-card updates and viewer privacy. Browser and full visual acceptance have separate evidence and limits.', evidence: ['tests/hand-browsing.test.ts', 'tests/hand-browser-controls.test.tsx'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'draw-piles',
|
id: 'draw-piles',
|
||||||
title: 'Cards and draw piles',
|
title: 'Cards and draw piles',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||||
|
import { baseDeck } from '../game/cards';
|
||||||
|
|
||||||
|
const aliases = registerHooks({
|
||||||
|
resolve(specifier, context, next) {
|
||||||
|
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||||
|
},
|
||||||
|
load(url, context, next) {
|
||||||
|
if (!url.endsWith('.module.css')) return next(url, context);
|
||||||
|
const css = readFileSync(new URL(url), 'utf8');
|
||||||
|
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]]));
|
||||||
|
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { HandBrowser } = await import('../components/hand-browser');
|
||||||
|
const { GameTable } = await import('../components/game-table');
|
||||||
|
aliases.deregister();
|
||||||
|
|
||||||
|
void test('live hand browsing receives only the current seat cards and preserves named inspectors while busy', () => {
|
||||||
|
const g = createGame('HANDBROWSE', newPlayer('a', 'Atreides', 'atreides'));
|
||||||
|
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||||
|
// Projection/render fixture only; starting-game rules are tested separately.
|
||||||
|
const cards = baseDeck();
|
||||||
|
g.players[0].hand = cards.filter(c => c.name === 'Shield');
|
||||||
|
g.players[1].hand = cards.filter(c => c.name === 'Lasgun');
|
||||||
|
g.deck = cards.filter(c => !g.players.some(p => p.hand.some(h => h.id === c.id)));
|
||||||
|
const before = JSON.stringify(g);
|
||||||
|
for (const id of ['a', 'h']) {
|
||||||
|
const html = renderToStaticMarkup(createElement(GameTable, {
|
||||||
|
game: viewGame(g, id), busy: true, send: async () => { assert.fail('Browsing must not submit a game action.'); }, onExit() {},
|
||||||
|
}));
|
||||||
|
assert.match(html, /<search aria-label="Browse your private hand">/);
|
||||||
|
assert.match(html, /Search your hand/);
|
||||||
|
assert.match(html, /Your hand cards/);
|
||||||
|
assert.match(html, /tabindex="0"/);
|
||||||
|
assert.match(html, id === 'a' ? /Inspect card: Shield/ : /Inspect card: Lasgun/);
|
||||||
|
assert.doesNotMatch(html, id === 'a' ? /Inspect card: Lasgun/ : /Inspect card: Shield/);
|
||||||
|
}
|
||||||
|
assert.equal(JSON.stringify(g), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('empty hand keeps its setup explanation and does not offer useless search controls', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HandBrowser cards={[]} empty={<p>Starting cards remain undealt.</p>}>
|
||||||
|
{() => { assert.fail('No card should render.'); }}
|
||||||
|
</HandBrowser>,
|
||||||
|
);
|
||||||
|
assert.match(html, /Starting cards remain undealt/);
|
||||||
|
assert.doesNotMatch(html, /<search|No cards match|Show all cards/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseDeck, treacheryDeck, type Card } from '../game/cards';
|
||||||
|
import { richeseCards } from '../game/richese-cards';
|
||||||
|
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||||
|
import { browseHand } from '../game/hand-browsing';
|
||||||
|
|
||||||
|
const all = [...treacheryDeck(['ix', 'choam', 'ecaz']), ...richeseCards(), ...ecazTreacheryCards()];
|
||||||
|
const named = (name: string) => {
|
||||||
|
const card = all.find(c => c.name === name);
|
||||||
|
assert.ok(card, name);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
void test('name search keeps each matching physical card and treats input as literal text', () => {
|
||||||
|
const shields = all.filter(c => c.name === 'Shield');
|
||||||
|
assert.ok(shields.length > 1);
|
||||||
|
assert.deepEqual(browseHand(shields, ' SHiELD ', 'all', 'hand'), shields);
|
||||||
|
assert.equal(new Set(browseHand(shields, 'shield', 'all', 'name').map(c => c.id)).size, shields.length);
|
||||||
|
assert.deepEqual(browseHand(all, '.*', 'all', 'hand'), []);
|
||||||
|
const accented: Card[] = [{ id: 'visible-accented', name: 'Épreuve', kind: 'special' }];
|
||||||
|
assert.deepEqual(browseHand(accented, 'epreuve', 'all', 'hand'), accented);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('printed categories include canonical expansion identities without redefining battle roles', () => {
|
||||||
|
const hand = ['Weirding Way', 'Chemistry', 'Stone Burner', 'Portable Snooper', 'Harass & Withdraw', 'Juice of Sapho'].map(named);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'weapon', 'hand').map(c => c.name), ['Weirding Way', 'Stone Burner']);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'defense', 'hand').map(c => c.name), ['Chemistry', 'Portable Snooper']);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'special', 'hand').map(c => c.name), ['Harass & Withdraw', 'Juice of Sapho']);
|
||||||
|
assert.deepEqual(browseHand(hand, 'snooper', 'weapon', 'name'), []);
|
||||||
|
assert.equal(browseHand(hand, 'snooper', 'defense', 'name')[0].id, named('Portable Snooper').id);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('sorting changes a copied display list, preserves duplicate order, and can restore saved hand order', () => {
|
||||||
|
const shields = baseDeck().filter(c => c.name === 'Shield');
|
||||||
|
assert.ok(shields.length >= 2);
|
||||||
|
const hand = Object.freeze([named('Harass & Withdraw'), shields[1], named('Lasgun'), shields[0]].map(c => Object.freeze({ ...c })));
|
||||||
|
const before = JSON.stringify(hand);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'all', 'name').map(c => c.name), ['Harass & Withdraw', 'Lasgun', 'Shield', 'Shield']);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'all', 'category').map(c => c.name), ['Lasgun', 'Shield', 'Shield', 'Harass & Withdraw']);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'defense', 'name').map(c => c.id), [shields[1].id, shields[0].id]);
|
||||||
|
assert.deepEqual(browseHand(hand, '', 'all', 'hand'), hand);
|
||||||
|
assert.equal(JSON.stringify(hand), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('current owned cards drive results after a draw, removal and JSON restore, with no cached private holdings', () => {
|
||||||
|
const first = [named('Shield')];
|
||||||
|
assert.deepEqual(browseHand(first, 'sapho', 'all', 'hand'), []);
|
||||||
|
const acquired = [...first, named('Juice of Sapho')];
|
||||||
|
assert.equal(browseHand(acquired, 'sapho', 'all', 'hand')[0].id, named('Juice of Sapho').id);
|
||||||
|
const removed = acquired.filter(c => c.name !== 'Juice of Sapho');
|
||||||
|
assert.deepEqual(browseHand(removed, 'sapho', 'all', 'hand'), []);
|
||||||
|
assert.deepEqual(browseHand(JSON.parse(JSON.stringify(acquired)), 'sapho', 'all', 'name'), [named('Juice of Sapho')]);
|
||||||
|
assert.deepEqual(browseHand([], '', 'all', 'category'), []);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user