diff --git a/example-games/gym/GymRegistry.ts b/example-games/gym/GymRegistry.ts index 109477df..6d5c75e2 100644 --- a/example-games/gym/GymRegistry.ts +++ b/example-games/gym/GymRegistry.ts @@ -68,7 +68,7 @@ export const GYM_SCENE_CATALOGUE: GymSceneEntry[] = [ sceneKey: GYM_DECK_RNG_KEY, title: 'Deck & Seeded RNG', description: - 'Create, shuffle, and draw from decks using deterministic seeded randomness. Verify reproducible sequences across runs.', + 'Displays all 52 cards face-up in a compact grid. Shuffle with a deterministic seed to see reproducible card arrangements.', }, { sceneKey: GYM_HAND_PILE_KEY, diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index da26d52b..e495a0b0 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -1,13 +1,12 @@ /** - * GymDeckRngScene -- Demonstrates deck lifecycle, deterministic - * seeded randomness, and card flip animations. + * GymDeckRngScene -- Demonstrates a full deck displayed face-up in a + * compact grid, with deterministic seeded randomness via shuffle. * * Features: - * - Create and shuffle a standard 52-card deck - * - Draw cards with visible state changes and flip animation - * - Enter a seed to reproduce identical shuffle/draw sequences - * - Reset the deck to start over - * - Flip animation using flipCard helper with reduced-motion fallback + * - All 52 cards displayed face-up in a compact grid on scene load + * - Shuffle button re-shuffles and re-renders the entire grid + * - Seed adjustment controls to reproduce identical shuffle sequences + * - Cards rendered using SLL zone anchors for consistent layout * * @module example-games/gym/scenes/GymDeckRngScene */ @@ -15,31 +14,35 @@ import { GymSceneBase } from './GymSceneBase'; import { GYM_DECK_RNG_KEY } from '../GymRegistry'; import { createStandardDeck, shuffleArray } from '../../../src/card-system/Deck'; +import type { Card } from '../../../src/card-system/Card'; import { createSeededRng } from '../../../src/core-engine/SeededRng'; -import { flipCard } from '../../../src/ui/flipCard'; -import { dealCard } from '../../../src/ui/dealCard'; -import { GameEventEmitter } from '../../../src/core-engine'; -import { GAME_W } from '../../../src/ui/constants'; +import { GAME_W, CARD_W, CARD_H } from '../../../src/ui/constants'; import { preloadCardAssets, getCardTexture, ensureCardTextureFallbacks } from '../../../src/ui/CardTextureHelpers'; /** Default seed for deterministic demonstrations. */ const DEFAULT_SEED = 42; +/** Grid layout: 8 columns, up to 7 rows (52 cards = 6 full rows + 4 in row 7). */ +const GRID_COLUMNS = 8; + +/** Horizontal gap between cards in the grid (pixels). */ +const GRID_GAP_X = 4; + +/** Vertical gap between cards in the grid (pixels). */ +const GRID_GAP_Y = 4; + export class GymDeckRngScene extends GymSceneBase { - private deck: ReturnType = []; - private drawn: ReturnType = []; + private deck: Card[] = []; private seed: number = DEFAULT_SEED; private rng: ReturnType = createSeededRng(DEFAULT_SEED); // UI elements private seedText!: Phaser.GameObjects.Text; - private deckCountText!: Phaser.GameObjects.Text; - private drawnCountText!: Phaser.GameObjects.Text; - private logTexts: Phaser.GameObjects.Text[] = []; - private eventLog: string[] = []; - private lastDrawnSprite: Phaser.GameObjects.Image | null = null; - private lastDrawnCard: ReturnType[0] | null = null; - private flipAnimActive: boolean = false; + private statusText!: Phaser.GameObjects.Text; + + // Grid card sprites — tracked so they can be destroyed on shuffle + private cardSprites: Phaser.GameObjects.Image[] = []; + private cardLabels: Phaser.GameObjects.Text[] = []; constructor() { super({ key: GYM_DECK_RNG_KEY }); @@ -62,11 +65,11 @@ export class GymDeckRngScene extends GymSceneBase { this.initHelp([ { heading: 'Overview', - body: 'Demonstrates deck lifecycle and deterministic seeded randomness with flip and deal animations.' + body: 'Displays all 52 cards face-up in a compact grid, shuffled with the default seed (42) on load.' }, { heading: 'Controls', - body: '[ -1 ] / [ +1 ]: Adjust seed.\n[ Reset Seed ]: Restore default seed.\n[ Shuffle ]: Shuffle the deck with the current seed.\n[ Draw ]: Draw the top card (with flip animation).\n[ Flip Last ]: Flip the last drawn card (animation demo).\n[ Deal ]: Deal a card with arc animation.\n[ Reset ]: Reset deck to unshuffled state.\n\nTip: Press "?" (or click the ? button) to toggle this help.' + body: '[ -1 ] / [ +1 ]: Adjust seed and re-shuffle the deck.\n[ Reset Seed ]: Restore default seed (42) and re-shuffle.\n[ Shuffle ]: Re-shuffle using a random seed.\n\nTip: Using the same seed always produces the same card order.' } ]); @@ -85,28 +88,18 @@ export class GymDeckRngScene extends GymSceneBase { this.addButton(cx + 180, y, '[ -1 ]', () => this.adjustSeed(-1)); this.addButton(cx + 240, y, '[ +1 ]', () => this.adjustSeed(1)); this.addButton(cx + 310, y, '[ Reset Seed ]', () => this.resetSeed()); - this.addButton(cx + 450, y, '[ Shuffle ]', () => this.shuffleDeck()); - this.addButton(cx + 560, y, '[ Draw ]', () => this.drawCard()); - - y += 28; - this.addButton(cx + 50, y, '[ Flip Last ]', () => this.flipLastDrawn()); - this.addButton(cx + 210, y, '[ Deal ]', () => this.dealCardAction()); - this.addButton(cx + 350, y, '[ Reset ]', () => this.resetDeck()); + this.addButton(cx + 450, y, '[ Shuffle ]', () => { + this.seed = Math.floor(Math.random() * 100000); + this.seedText.setText(String(this.seed)); + this.shuffleAndRedraw(); + }); // ── Status ─────────────────────────────────────────── - this.deckCountText = this.addLabel(cx + 100, y, 'Deck: 0 cards', { fontSize: '16px', color: '#88ff88' }); - this.drawnCountText = this.addLabel(cx + 400, y, 'Drawn: 0 cards', { fontSize: '16px', color: '#88ff88' }); + this.statusText = this.addLabel(cx + 600, y, '52 cards displayed', { fontSize: '16px', color: '#88ff88' }); - // ── Card display area (positioned via SLL cardDisplay zone) ── - const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); - const cardDisplayY = cardDisplay?.y ?? 280; - this.addLabel(cardDisplay?.x ?? cx, cardDisplayY - 40, '── Last Drawn Card ──', { fontSize: '12px', color: '#669966' }).setOrigin(0.5); - - // ── Event log area (positioned below card display) ────── - this.addLabel(cardDisplay?.x ?? cx, cardDisplayY + 140, '── Event Log ──', { fontSize: '12px', color: '#669966' }).setOrigin(0.5); - - // Initialize deck - this.resetDeck(); + // ── Initialize deck, shuffle with default seed, and render ── + this.seed = DEFAULT_SEED; + this.shuffleAndRedraw(); } // ── Actions ────────────────────────────────────────────── @@ -114,196 +107,90 @@ export class GymDeckRngScene extends GymSceneBase { private adjustSeed(delta: number): void { this.seed = Math.max(0, this.seed + delta); this.seedText.setText(String(this.seed)); - this.logEvent(`Seed changed to ${this.seed}`); + this.shuffleAndRedraw(); } private resetSeed(): void { this.seed = DEFAULT_SEED; this.seedText.setText(String(this.seed)); - this.logEvent(`Seed reset to ${DEFAULT_SEED}`); + this.shuffleAndRedraw(); } - private shuffleDeck(): void { + /** + * Shuffle the deck with the current seed and re-render the full grid. + */ + private shuffleAndRedraw(): void { this.rng = createSeededRng(this.seed); this.deck = createStandardDeck(); shuffleArray(this.deck, this.rng); - this.drawn = []; - this.clearLastDrawnSprite(); - this.updateStatus(); - this.logEvent(`Shuffled deck with seed=${this.seed}`); + this.clearGridSprites(); + this.renderFullDeckGrid(); } - private drawCard(): void { - if (this.deck.length === 0) { - this.logEvent('Cannot draw: deck is empty'); - return; - } - const card = this.deck.pop()!; - this.drawn.push(card); - this.clearLastDrawnSprite(); - // Show the back (card.faceUp is false by default) - this.showCardSprite(card); - this.updateStatus(); - this.logEvent(`Drew ${card.rank} of ${card.suit} (${this.deck.length} remaining)`); - } + // ── Grid rendering ─────────────────────────────────────── - private flipLastDrawn(): void { - if (this.drawn.length === 0) { - this.logEvent('No drawn card to flip'); - return; - } - if (this.flipAnimActive) { - this.logEvent('Flip animation already active'); - return; - } - if (!this.lastDrawnSprite || !this.lastDrawnCard) { - this.logEvent('No visible card sprite to flip'); - return; - } - - const sprite = this.lastDrawnSprite; - const card = this.lastDrawnCard; - - // Toggle face state - card.faceUp = !card.faceUp; - const newTexture = getCardTexture(card); - - if (this.reducedMotion) { - // Instant texture swap for reduced motion - sprite.setTexture(newTexture); - this.logEvent(`Flipped card (instant, reduced-motion) -> ${newTexture}`); - } else { - // Animated flip - this.flipAnimActive = true; - flipCard({ - scene: this, - target: sprite, - newTexture, - duration: 300, - onComplete: () => { - this.flipAnimActive = false; - this.logEvent(`Flipped card (animated) -> ${newTexture}`); - }, - }); - } - } - - private dealCardAction(): void { - if (this.deck.length === 0) { - this.logEvent('Cannot deal: deck is empty'); - return; - } - const card = this.deck.pop()!; - this.drawn.push(card); - this.clearLastDrawnSprite(); - - // Create a sprite at the deck position and deal it to the drawn area + /** + * Render all cards in the deck as a compact face-up grid within the + * cardDisplay SLL zone. Cards are scaled down to fit the available space. + */ + private renderFullDeckGrid(): void { const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); - const destX = cardDisplay?.x ?? (GAME_W / 2 + 80); - const destY = cardDisplay?.y ?? 180; - - const sprite = this.add.image(destX - 280, destY - 80, getCardTexture(card)); - this.lastDrawnSprite = sprite; - this.lastDrawnCard = card; - - if (this.reducedMotion) { - // Instant placement for reduced motion - sprite.setPosition(destX, destY); - this.updateStatus(); - this.logEvent(`Dealt ${card.rank} of ${card.suit} (instant, reduced-motion)`); - } else { - const gameEvents = new GameEventEmitter(); - gameEvents.on('card:dealt', () => { - this.updateStatus(); - this.logEvent(`Dealt ${card.rank} of ${card.suit} (animated)`); - gameEvents.removeAllListeners(); - }); - dealCard({ - scene: this, - target: sprite, - destX, - destY, - sourceX: destX - 280, - sourceY: destY - 80, - duration: 400, - gameEvents, - cardId: `${card.rank}${card.suit}`, - }); - // Update immediately for responsiveness - this.updateStatus(); + const centerX = cardDisplay?.x ?? GAME_W / 2; + // Shift the grid down within the cardDisplay zone to clear the header/controls + const centerY = (cardDisplay?.y ?? 270) + 100; + + // Full-scale cards (48×65px), 8 columns = ~412px wide + const cardScale = 1.0; + const scaledCardW = CARD_W * cardScale; + const scaledCardH = CARD_H * cardScale; + const stepX = scaledCardW + GRID_GAP_X; + const stepY = scaledCardH + GRID_GAP_Y; + + // Calculate the top-left origin of the grid so it's centered + const totalWidth = GRID_COLUMNS * stepX - GRID_GAP_X; + const totalRows = Math.ceil(this.deck.length / GRID_COLUMNS); + const totalHeight = totalRows * stepY - GRID_GAP_Y; + const gridStartX = centerX - totalWidth / 2 + scaledCardW / 2; + const gridStartY = centerY - totalHeight / 2 + scaledCardH / 2; + + for (let i = 0; i < this.deck.length; i++) { + const card = this.deck[i]; + card.faceUp = true; // Ensure all cards are face-up + + const col = i % GRID_COLUMNS; + const row = Math.floor(i / GRID_COLUMNS); + const x = gridStartX + col * stepX; + const y = gridStartY + row * stepY; + + const texture = getCardTexture(card); + const sprite = this.add.image(x, y, texture); + sprite.setScale(cardScale); + this.cardSprites.push(sprite); + + // Small label below each card for easy identification + const label = this.add.text(x, y + scaledCardH / 2 + 10, `${card.rank}${card.suit[0]}`, { + fontSize: '8px', + color: '#88aacc', + fontFamily: 'monospace', + }).setOrigin(0.5, 0); + this.cardLabels.push(label); } - } - private resetDeck(): void { - this.rng = createSeededRng(this.seed); - this.deck = createStandardDeck(); - this.drawn = []; - this.clearLastDrawnSprite(); - this.updateStatus(); - this.logEvent(`Deck reset (unshuffled, seed=${this.seed})`); + this.statusText.setText(`${this.deck.length} cards displayed · seed=${this.seed}`); } - // ── Card sprite helpers ────────────────────────────────── - - private showCardSprite(card: ReturnType[0]): void { - const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); - const spriteX = cardDisplay?.x ?? (GAME_W / 2 + 80); - const spriteY = cardDisplay?.y ?? 180; - const texture = getCardTexture(card); - const sprite = this.add.image(spriteX, spriteY, texture); - // Label below - const label = this.add.text(spriteX, spriteY + 50, `${card.rank}${card.suit}`, { - fontSize: '14px', - color: '#ffffff', - fontFamily: 'monospace', - }).setOrigin(0.5); - - // Store references; we clean up old sprites when drawing new ones - if (this.lastDrawnSprite) { - this.lastDrawnSprite.destroy(); - } - this.lastDrawnSprite = sprite; - this.lastDrawnCard = card; - - // Clean up label on scene shutdown - this.events.once('shutdown', () => { - try { label.destroy(); } catch (_) { /* ignore */ } + /** + * Destroy all card sprites and labels in the grid. + */ + private clearGridSprites(): void { + for (const sprite of this.cardSprites) { try { sprite.destroy(); } catch (_) { /* ignore */ } - }); - } - - private clearLastDrawnSprite(): void { - if (this.lastDrawnSprite) { - try { this.lastDrawnSprite.destroy(); } catch (_) { /* ignore */ } - this.lastDrawnSprite = null; } - this.lastDrawnCard = null; - } - - // ── UI helpers ─────────────────────────────────────────── - - private updateStatus(): void { - this.deckCountText.setText(`Deck: ${this.deck.length} cards`); - this.drawnCountText.setText(`Drawn: ${this.drawn.length} cards`); - } + this.cardSprites = []; - private logEvent(msg: string): void { - this.eventLog.push(msg); - if (this.eventLog.length > 12) this.eventLog.shift(); - - // Remove old log texts - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - - const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); - const baseY = cardDisplay?.y ? cardDisplay.y + 100 : 280; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = this.add.text(40, baseY + i * 18, this.eventLog[i], { - fontSize: '12px', - color: '#aaddaa', - fontFamily: 'monospace', - }); - this.logTexts.push(txt); + for (const label of this.cardLabels) { + try { label.destroy(); } catch (_) { /* ignore */ } } + this.cardLabels = []; } } diff --git a/tests/gym/GymDeckRng.test.ts b/tests/gym/GymDeckRng.test.ts index 4ab36766..c89c74ad 100644 --- a/tests/gym/GymDeckRng.test.ts +++ b/tests/gym/GymDeckRng.test.ts @@ -3,8 +3,8 @@ * * Validates that: * - createSeededRng with same seed produces identical sequences - * - Deck and Pile operations work correctly - * - Seed input and adjustment is handled correctly + * - Deck shuffle operations work correctly with seeded RNG + * - Full-deck display and shuffle visual behavior (scene-level tests) */ import { describe, expect, it } from 'vitest'; import { @@ -13,9 +13,7 @@ import { import { createStandardDeck, shuffleArray, - drawOrThrow, } from '../../src/card-system/Deck'; -import { Pile } from '../../src/card-system/Pile'; describe('Gym Deck & RNG deterministic scenarios', () => { it('same seed produces identical shuffle sequences', () => { @@ -54,35 +52,21 @@ describe('Gym Deck & RNG deterministic scenarios', () => { expect(same).toBeLessThan(deck1.length); }); - it('draw cards from deck and verify state', () => { + it('shuffled deck contains all 52 cards', () => { + const rng = createSeededRng(42); const deck = createStandardDeck(); - const drawn: ReturnType = []; - for (let i = 0; i < 5; i++) { - const card = drawOrThrow(deck); - drawn.push(card); - } - - expect(drawn.length).toBe(5); - expect(deck.length).toBe(47); - }); - - it('Pile push/pop operations', () => { - const deck = createStandardDeck(); - const pile = new Pile(deck); - - expect(pile.size()).toBe(52); - expect(pile.isEmpty()).toBe(false); + shuffleArray(deck, rng); - const topCard = pile.pop()!; - expect(topCard).toBeDefined(); - expect(pile.size()).toBe(51); + expect(deck.length).toBe(52); - pile.push(topCard); - expect(pile.size()).toBe(52); - - pile.clear(); - expect(pile.isEmpty()).toBe(true); - expect(pile.size()).toBe(0); + // Verify all ranks and suits are present exactly once + const seen = new Set(); + for (const card of deck) { + const key = `${card.rank}${card.suit}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + expect(seen.size).toBe(52); }); it('seeds of 0 and 1 produce different sequences', () => { @@ -92,4 +76,38 @@ describe('Gym Deck & RNG deterministic scenarios', () => { const vals1 = Array.from({ length: 10 }, () => rng1()); expect(vals0).not.toEqual(vals1); }); -}); \ No newline at end of file + + it('shuffling produces a different order from an unshuffled deck', () => { + const unshuffled = createStandardDeck(); + const shuffled = createStandardDeck(); + const rng = createSeededRng(99); + shuffleArray(shuffled, rng); + + // At least some cards should be in different positions + let different = 0; + for (let i = 0; i < unshuffled.length; i++) { + if (unshuffled[i].rank !== shuffled[i].rank || unshuffled[i].suit !== shuffled[i].suit) { + different++; + } + } + expect(different).toBeGreaterThan(0); + }); + + it('multiple shuffles with the same seed produce the same result', () => { + const deck1 = createStandardDeck(); + shuffleArray(deck1, createSeededRng(7)); + + const deck2 = createStandardDeck(); + shuffleArray(deck2, createSeededRng(7)); + + const deck3 = createStandardDeck(); + shuffleArray(deck3, createSeededRng(7)); + + for (let i = 0; i < 52; i++) { + expect(deck1[i].rank).toBe(deck2[i].rank); + expect(deck1[i].suit).toBe(deck2[i].suit); + expect(deck2[i].rank).toBe(deck3[i].rank); + expect(deck2[i].suit).toBe(deck3[i].suit); + } + }); +}); diff --git a/tests/gym/GymDeckRngScene.browser.test.ts b/tests/gym/GymDeckRngScene.browser.test.ts new file mode 100644 index 00000000..2072ba0c --- /dev/null +++ b/tests/gym/GymDeckRngScene.browser.test.ts @@ -0,0 +1,249 @@ +/** + * GymDeckRngScene browser integration tests. + * + * Validates that: + * - The full 52-card deck is displayed face-up in a compact grid on load + * - Shuffle produces a different visual card arrangement + * - Seed adjustment controls work correctly + * - Draw/Flip Last/Deal/Reset controls are removed + * - Event log is removed + */ +import { afterEach, describe, expect, it } from 'vitest'; +import Phaser from 'phaser'; +import { GymDeckRngScene } from '../../example-games/gym/scenes/GymDeckRngScene'; +import { GYM_DECK_RNG_KEY } from '../../example-games/gym/GymRegistry'; +import { waitForScene } from '../helpers/waitForScene'; + +describe('GymDeckRngScene browser integration', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + if (game) game.destroy(true, false); + game = null; + const container = document.getElementById('game-container'); + if (container) container.remove(); + }); + + /** + * Bootstrap the scene for each test. + */ + async function bootScene(): Promise { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.AUTO, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#1a2a1a', + scene: [GymDeckRngScene], + }); + + await waitForScene(game, GYM_DECK_RNG_KEY); + const scene = game.scene.getScene(GYM_DECK_RNG_KEY); + expect(scene).toBeTruthy(); + expect(scene.sys.isActive()).toBe(true); + return scene; + } + + /** + * Find a text object in the scene by exact text match. + */ + function findText(scene: Phaser.Scene, text: string): Phaser.GameObjects.Text | null { + return ( + scene.children.list.find( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && child.text === text, + ) ?? null + ); + } + + /** + * Get the (x, y, textureKey) tuples for all Image objects in the scene, + * sorted by grid position (row-major: left-to-right, top-to-bottom). + */ + function getCardSnapshot(scene: Phaser.Scene): { x: number; y: number; textureKey: string }[] { + return scene.children.list + .filter((child): child is Phaser.GameObjects.Image => child instanceof Phaser.GameObjects.Image) + .sort((a, b) => { + // Sort by row (y) first, then column (x) — grid positions + const yDiff = a.y - b.y; + if (Math.abs(yDiff) > 5) return yDiff; // different rows + return a.x - b.x; // same row, sort by column + }) + .map((img) => ({ + x: Math.round(img.x), + y: Math.round(img.y), + textureKey: img.texture.key, + })); + } + + // ── AC 1 & 6: Full 52-card deck displayed face-up ───────── + + it('renders exactly 52 card sprites on scene load (AC 1, 6)', async () => { + const scene = await bootScene(); + + const sprites = getCardSnapshot(scene); + + // All 52 cards should be visible as Image objects + expect(sprites.length).toBe(52); + + // Cards should be face-up (using card face textures, not 'back') + for (const sprite of sprites) { + expect(sprite.textureKey).not.toBe('back'); + } + }); + + // ── AC 2: Draw / Flip Last / Deal / Reset controls removed ─ + + it('does not contain removed controls (AC 2)', async () => { + const scene = await bootScene(); + + // These controls should NOT exist in the scene + const removedControls = ['[ Draw ]', '[ Flip Last ]', '[ Deal ]', '[ Reset ]']; + for (const label of removedControls) { + expect(findText(scene, label)).toBeNull(); + } + }); + + // ── AC 4 & 5: Seed & Shuffle controls present, event log absent ─ + + it('retains seed controls and shuffle, removes event log (AC 4, 5)', async () => { + const scene = await bootScene(); + + // Seed controls should exist + expect(findText(scene, '[ -1 ]')).toBeTruthy(); + expect(findText(scene, '[ +1 ]') || findText(scene, '[+1]')).toBeTruthy(); + expect(findText(scene, '[ Reset Seed ]')).toBeTruthy(); + expect(findText(scene, '[ Shuffle ]')).toBeTruthy(); + + // Event log-specific text should NOT exist (log entries format) + // The old event log used lines like "Drew " or longer text entries + const eventLogIndicators = scene.children.list.filter( + (child) => + child instanceof Phaser.GameObjects.Text && + (child.text.startsWith('Drew ') || + child.text.startsWith('Dealt ') || + child.text.startsWith('Flipped ') || + child.text.startsWith('Shuffled ')), + ); + expect(eventLogIndicators.length).toBe(0); + + // Status text should show card count, not event log + const statusText = scene.children.list.find( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && + child.text.includes('cards displayed'), + ); + expect(statusText).toBeTruthy(); + expect(statusText!.text).toContain('52'); + }); + + // ── AC 3 & 9: Shuffle clears and redraws full grid ───────── + + it('shuffle button clears and redraws all 52 cards (AC 3)', async () => { + const scene = await bootScene(); + + // Get initial card sprite count + const beforeCount = getCardSnapshot(scene).length; + expect(beforeCount).toBe(52); + + // Get initial texture arrangement + const texturesBefore = getCardSnapshot(scene).map((s) => s.textureKey); + + // Click Shuffle button + const shuffleBtn = findText(scene, '[ Shuffle ]'); + expect(shuffleBtn).toBeTruthy(); + shuffleBtn!.emit('pointerdown'); + + // Allow a frame for re-render + await new Promise((r) => requestAnimationFrame(r)); + + // Verify 52 sprites still present after shuffle + const afterSprites = getCardSnapshot(scene); + expect(afterSprites.length).toBe(52); + + // Verify visual arrangement changed (different textures at grid positions) + const texturesAfter = afterSprites.map((s) => s.textureKey); + let sameCount = 0; + for (let i = 0; i < 52; i++) { + if (texturesBefore[i] === texturesAfter[i]) sameCount++; + } + // Very unlikely same seed produces identical texture layout + expect(sameCount).toBeLessThan(52); + + // Count unique textures — should still be 52 unique cards + const uniqueTextures = new Set(texturesAfter); + expect(uniqueTextures.size).toBe(52); + }); + + // ── AC 4: Seed adjustment works ─────────────────────────── + + it('seed adjustment controls modify displayed seed value', async () => { + const scene = await bootScene(); + + // Seed display should start at 42 (DEFAULT_SEED) + const seedLabel = findText(scene, '42'); + expect(seedLabel).toBeTruthy(); + + // Find seed text by checking the "Seed:" label's relative position + const seedPrefix = findText(scene, 'Seed:'); + expect(seedPrefix).toBeTruthy(); + + // Click +1 + const plusBtn = findText(scene, '[ +1 ]'); + expect(plusBtn).toBeTruthy(); + plusBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + expect(findText(scene, '43')).toBeTruthy(); + + // Click -1 twice (should go to 41) + const minusBtn = findText(scene, '[ -1 ]'); + expect(minusBtn).toBeTruthy(); + minusBtn!.emit('pointerdown'); + minusBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + expect(findText(scene, '41')).toBeTruthy(); + + // Reset seed + const resetBtn = findText(scene, '[ Reset Seed ]'); + expect(resetBtn).toBeTruthy(); + resetBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + expect(findText(scene, '42')).toBeTruthy(); + }); + + // ── AC 3 & 4: Seed changes auto-shuffle deterministically ── + + it('same seed produces identical arrangement; different seed different (+/- auto-shuffle)', async () => { + const scene = await bootScene(); + + // Scene loads shuffled with seed 42 → capture arrangement + const arrangements1 = getCardSnapshot(scene).map((s) => s.textureKey); + + // Click +1 → seed 43, auto-shuffles → different arrangement + const plusBtn = findText(scene, '[ +1 ]'); + expect(plusBtn).toBeTruthy(); + plusBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + const arrangements2 = getCardSnapshot(scene).map((s) => s.textureKey); + + // Seed 43 should produce different arrangement from seed 42 + let sameCount = 0; + for (let i = 0; i < 52; i++) { + if (arrangements1[i] === arrangements2[i]) sameCount++; + } + expect(sameCount).toBeLessThan(52); + + // Reset Seed → seed 42, auto-shuffles → should reproduce seed 42 arrangement exactly + const resetBtn = findText(scene, '[ Reset Seed ]'); + expect(resetBtn).toBeTruthy(); + resetBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + const arrangements3 = getCardSnapshot(scene).map((s) => s.textureKey); + + expect(arrangements3).toEqual(arrangements1); + }); +});