diff --git a/packages/chess/docs/PRESET-API.md b/packages/chess/docs/PRESET-API.md index 7d3fe28..9438c58 100644 --- a/packages/chess/docs/PRESET-API.md +++ b/packages/chess/docs/PRESET-API.md @@ -599,6 +599,8 @@ Documented but NOT yet available: - Server-side piece-type manifest echo — when custom types are authored outside the shared `packages/chess`. - Save-state migration — versioning for saves that predate attribute additions. - Berolina en-passant — deferred for v1. -- Extinction-chess UI cycling — setting target type via a drawer chip. +- Extinction-chess multiplayer target sync — solo cycler shipped in + post-epic Feature 2; MP target is fixed at room creation until a + `preset-config.update` WS message lands. File issues or propose extensions via pull request. diff --git a/packages/chess/e2e/rule-variants.spec.ts b/packages/chess/e2e/rule-variants.spec.ts index ce8de37..6aa88c0 100644 --- a/packages/chess/e2e/rule-variants.spec.ts +++ b/packages/chess/e2e/rule-variants.spec.ts @@ -93,6 +93,65 @@ async function togglePresetInGameDrawer( await expect(drawer).not.toBeVisible(); } +test.describe('F2 post-epic: extinction-chess target cycler', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k); + } + }); + }); + + test('cycler renders when extinction-chess is active; cycles through target types', async ({ + page, + }) => { + // Solo game (cycler is solo-only in v1 per plan decision 2a). + await page.locator('[data-action="play-solo"]').click(); + await page.waitForURL('**/game'); + + // Open rules drawer and enable extinction-chess. + await page.locator('[data-action="open-rules-drawer"]').click(); + const toggle = page.locator( + '[data-preset="extinction-chess"] [data-role="toggle"]', + ); + await expect(toggle).toBeVisible(); + await toggle.click(); + + // Default target is "Pawns" (seeded on activation). + const cycler = page.locator('[data-testid="extinction-target-cycler"]'); + await expect(cycler).toBeVisible(); + await expect(cycler).toHaveText('Pawns'); + + // Cycle: pawn → knight → bishop → rook → queen → king → pawn. + const expected = [ + 'Knights', + 'Bishops', + 'Rooks', + 'Queens', + 'Kings', + 'Pawns', + ]; + for (const label of expected) { + await cycler.click(); + await expect(cycler).toHaveText(label); + } + }); + + test('cycler is absent when extinction-chess is not active', async ({ + page, + }) => { + await page.locator('[data-action="play-solo"]').click(); + await page.waitForURL('**/game'); + await page.locator('[data-action="open-rules-drawer"]').click(); + // Drawer open, no preset toggled → no cycler. + await expect( + page.locator('[data-testid="extinction-target-cycler"]'), + ).toHaveCount(0); + }); +}); + test.describe('Rule variants — lobby vertical slice', () => { test.beforeEach(async ({ page }) => { await resetStorage(page); diff --git a/packages/chess/src/presets/extinction-chess.ts b/packages/chess/src/presets/extinction-chess.ts index 8b4a07c..3074210 100644 --- a/packages/chess/src/presets/extinction-chess.ts +++ b/packages/chess/src/presets/extinction-chess.ts @@ -44,11 +44,12 @@ * This is idempotent: re-activating doesn't stomp a target the * consumer already set during the same activation-cycle. * - * UI wiring: intentionally deferred. Phase F cycling a target type - * via a UI chip is explicitly out of scope for D.3 per the - * rule-variants plan. The API is configuration-driven today; a - * future UI patch can wire a dropdown / radio onto this same - * presetState slot. + * UI wiring: the RulesDrawer renders a "Target: {PieceType}" chip + * cycler inside this preset's detail card when the preset is + * active in a SOLO game. Solo-only in v1 (post-epic-deferrals + * Feature 2, plan decision 2a) — multiplayer games use whatever + * target was set at room-creation time. A future `preset-config.update` + * WS message could lift the solo-only limitation; not in v1 scope. * * Hook wiring * ─────────── diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 7cf04c7..25b054f 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -170,6 +170,40 @@ function GameLayout({ applyMove(from, to, promoteTo || 'queen'); }; + // F2 (post-epic-deferrals): extinction-chess target-type cycler. + // Solo-only in v1 (plan decision 2a) — multiplayer games use the + // target that was set at room-creation time and won't render the + // cycler. We detect "solo" via `roomCode === null` and "active" + // via the activation list. + const isSolo = roomCode === null; + const extinctionActive = activations.some((a) => a.id === 'extinction-chess'); + const [extinctionTargetState, setExtinctionTargetState] = + useState('pawn'); + // Keep local state in sync with engine preset state whenever the + // drawer is about to open (cheap to re-read on every render). + useEffect(() => { + if (!isSolo || !extinctionActive || engine === null) return; + const actual = + engine + .presetState<{ targetType: PieceType }>('extinction-chess') + .get('targetType') ?? 'pawn'; + if (actual !== extinctionTargetState) setExtinctionTargetState(actual); + }, [isSolo, extinctionActive, engine, extinctionTargetState]); + const setExtinctionTarget = (next: PieceType) => { + if (engine === null) return; + engine + .presetState<{ targetType: PieceType }>('extinction-chess') + .set('targetType', next); + setExtinctionTargetState(next); + // Recompute legal highlights / panels that depend on terminal + // state (extinction target flip can flip a game-result). + refresh(); + }; + const extinctionTarget = + isSolo && extinctionActive && engine !== null + ? extinctionTargetState + : null; + const isGameOver = result !== 'ongoing'; // Confetti on any decisive win (checkmate or variant win condition). @@ -278,6 +312,12 @@ function GameLayout({ {...(sendRegisterCustomModifier !== undefined ? { onShareCustomModifierWithRoom: sendRegisterCustomModifier } : {})} + {...(extinctionTarget !== null + ? { + extinctionTarget, + onExtinctionTargetChange: setExtinctionTarget, + } + : {})} /> void; + /** + * Feature 2 (post-epic-deferrals): the current target-type for the + * `extinction-chess` preset. When provided AND extinction-chess is + * active, the drawer renders a chip cycler letting the user pick + * which piece type is the extinction target. + * + * Omitting these props (e.g. in the lobby preview, or when the + * engine is unavailable) hides the cycler — the preset still uses + * its default target ("pawn"). + * + * Solo-only in v1: multiplayer target-sync via a new WS message is + * a deferred v2 follow-up (plan decision 2a). + */ + extinctionTarget?: import('../schema.js').PieceType; + onExtinctionTargetChange?: (next: import('../schema.js').PieceType) => void; } /** Build a fresh activation list that reflects a single edit. */ @@ -69,11 +84,84 @@ function applyEdit( return next; } +/** + * Ordered list of target types for the extinction-chess cycler. + * Mirrors chess's six canonical PieceType values in a UX-friendly + * order (pawns first because they're the default, then forward + * through the value ladder). Clicking the chip advances one step and + * wraps around at the end. + */ +const EXTINCTION_TARGET_ORDER: readonly import('../schema.js').PieceType[] = [ + 'pawn', + 'knight', + 'bishop', + 'rook', + 'queen', + 'king', +] as const; + +/** + * F2 (post-epic-deferrals): compact cycler for extinction-chess's + * configurable target type. Rendered inline inside the preset's + * detail block when the preset is active. Solo-only in v1 — the + * parent (GameView) is responsible for wiring the target to the + * engine's preset state; in multiplayer games the host's initial + * target is authoritative and this cycler is hidden or read-only + * (Feature 2 plan decision 2a). + */ +function ExtinctionTargetCycler({ + target, + onChange, +}: { + target: import('../schema.js').PieceType; + onChange: (next: import('../schema.js').PieceType) => void; +}) { + const idx = EXTINCTION_TARGET_ORDER.indexOf(target); + const cycle = () => { + const next = + EXTINCTION_TARGET_ORDER[ + (idx + 1) % EXTINCTION_TARGET_ORDER.length + ]!; + onChange(next); + }; + // Plural label for prose: "Target: Pawns" reads better than "Pawn". + const plural = + target === 'knight' + ? 'Knights' + : target === 'bishop' + ? 'Bishops' + : target === 'rook' + ? 'Rooks' + : target === 'queen' + ? 'Queens' + : target === 'king' + ? 'Kings' + : 'Pawns'; + return ( +
+ + +
+ ); +} + export function RulesDrawer({ activations, setPresets, onRulesChanged, onShareCustomModifierWithRoom, + extinctionTarget, + onExtinctionTargetChange, }: RulesDrawerProps) { const [open, setOpen] = useState(false); const [modifierEditorOpen, setModifierEditorOpen] = useState(false); @@ -518,6 +606,19 @@ export function RulesDrawer({ + + {/* F2 (post-epic-deferrals): extinction-chess + target cycler. Only renders when the preset + is extinction-chess AND the parent threaded + the target-state props. */} + {preset.id === 'extinction-chess' && + extinctionTarget !== undefined && + onExtinctionTargetChange !== undefined && ( + + )} )}