From 03c5a2231213549013e49b6def49b61730a0b07a Mon Sep 17 00:00:00 2001 From: Map Date: Tue, 26 May 2026 00:12:10 +0100 Subject: [PATCH 1/5] CG-0MPLT8ADR0099WF3: Reimplement GymDeckRngScene to display full 52-card deck face-up in compact grid - Replace single-card draw/flip/deal/reset UI with full-deck grid display - All 52 cards rendered face-up in 8-column grid on scene load - Keep seed adjustment controls and shuffle button; remove Draw/Flip/Deal/Reset buttons - Remove event log; replace with simple status display - Cards positioned using SLL cardDisplay zone anchors - Cards scaled to 0.7x to fit 8-column grid within viewport - Update GymRegistry description to reflect new scene behavior - Update tests: remove Draw/Flip/Deal-specific tests, add full-deck and shuffle determinism tests --- example-games/gym/GymRegistry.ts | 2 +- example-games/gym/scenes/GymDeckRngScene.ts | 299 ++++++-------------- tests/gym/GymDeckRng.test.ts | 80 ++++-- 3 files changed, 139 insertions(+), 242 deletions(-) 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..212bc6a2 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. Shuffle the deck using a deterministic seed to see how card order changes.' }, { 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.\n[ Reset Seed ]: Restore default seed.\n[ Shuffle ]: Shuffle and re-display all 52 cards using the current seed.\n\nTip: Using the same seed always produces the same shuffle order.' } ]); @@ -85,28 +88,13 @@ 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.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(); + // ── Card display area: render full deck in a grid ───── + this.renderFullDeckGrid(); } // ── Actions ────────────────────────────────────────────── @@ -114,196 +102,87 @@ 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}`); } private resetSeed(): void { this.seed = DEFAULT_SEED; this.seedText.setText(String(this.seed)); - this.logEvent(`Seed reset to ${DEFAULT_SEED}`); } - 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}`); - } - - 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)`); + this.clearGridSprites(); + this.renderFullDeckGrid(); } - 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}`); - }, - }); - } - } + // ── Grid rendering ─────────────────────────────────────── - 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; + const centerY = cardDisplay?.y ?? 260; + + // Default scale of 0.7 gives ~33px wide cards, 8 columns = ~296px wide + const cardScale = 0.7; + 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); + } + }); +}); From 2e9b64c93e2b2fc173cd7f13edf70f1da03d0110 Mon Sep 17 00:00:00 2001 From: Map Date: Tue, 26 May 2026 10:14:37 +0100 Subject: [PATCH 2/5] CG-0MPLT8ADR0099WF3: Fix deck initialization bug and add browser tests for AC 9 - Fixed critical bug: this.deck was never populated before renderFullDeckGrid() was called in create(), resulting in 0 cards displayed on scene load. - Added this.deck = createStandardDeck() before renderFullDeckGrid() in create(). - Added GymDeckRngScene.browser.test.ts with 6 browser tests: - 52 card sprites rendered on load (AC 1, 6) - Removed controls absence (AC 2) - Seed controls present, event log absent (AC 4, 5) - Shuffle clears and redraws 52 cards (AC 3) - Seed adjustment controls modify seed value (AC 4) - Same seed determinism reproduces identical arrangement (AC 3, 9) - All 2726 tests pass (2625 unit + 101 browser), 5 skipped --- example-games/gym/scenes/GymDeckRngScene.ts | 3 +- tests/gym/GymDeckRngScene.browser.test.ts | 257 ++++++++++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 tests/gym/GymDeckRngScene.browser.test.ts diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index 212bc6a2..dac6ec8c 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -93,7 +93,8 @@ export class GymDeckRngScene extends GymSceneBase { // ── Status ─────────────────────────────────────────── this.statusText = this.addLabel(cx + 600, y, '52 cards displayed', { fontSize: '16px', color: '#88ff88' }); - // ── Card display area: render full deck in a grid ───── + // ── Initialize deck and render full deck in a grid ───── + this.deck = createStandardDeck(); this.renderFullDeckGrid(); } diff --git a/tests/gym/GymDeckRngScene.browser.test.ts b/tests/gym/GymDeckRngScene.browser.test.ts new file mode 100644 index 00000000..082eaa08 --- /dev/null +++ b/tests/gym/GymDeckRngScene.browser.test.ts @@ -0,0 +1,257 @@ +/** + * 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: Shuffle button actually uses the current seed ──── + + it('shuffling with same seed reproduces identical arrangement (determinism)', async () => { + const scene = await bootScene(); + + // Click Shuffle with seed 42 + const shuffleBtn = findText(scene, '[ Shuffle ]'); + expect(shuffleBtn).toBeTruthy(); + shuffleBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + const arrangements1 = getCardSnapshot(scene).map((s) => s.textureKey); + + // Change seed and shuffle + const plusBtn = findText(scene, '[ +1 ]'); + plusBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + + shuffleBtn!.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 and shuffle — should reproduce seed 42 arrangement exactly + const resetBtn = findText(scene, '[ Reset Seed ]'); + resetBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + + shuffleBtn!.emit('pointerdown'); + await new Promise((r) => requestAnimationFrame(r)); + const arrangements3 = getCardSnapshot(scene).map((s) => s.textureKey); + + expect(arrangements3).toEqual(arrangements1); + }); +}); From 56e0fa5f699553010255eeaa585fc1d2cd71fbd0 Mon Sep 17 00:00:00 2001 From: Map Date: Tue, 26 May 2026 10:20:14 +0100 Subject: [PATCH 3/5] =?UTF-8?q?CG-0MPLT8ADR0099WF3:=20Increase=20card=20si?= =?UTF-8?q?ze=20(0.7=E2=86=920.85)=20and=20shift=20grid=20down=20by=20100p?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cards were too small at scale 0.7 (~33x46px) and too close to the header area (top row at y≈122 barely below controls at y≈83). - Increased cardScale from 0.7 to 0.85 giving ~41x55px cards with clearer face-up textures. - Shifted grid center Y from 270 to 370 (SLL anchor + 100) so the top row clears the header/controls area comfortably. - All 2726 tests continue to pass. --- example-games/gym/scenes/GymDeckRngScene.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index dac6ec8c..adcf44cb 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -130,10 +130,11 @@ export class GymDeckRngScene extends GymSceneBase { private renderFullDeckGrid(): void { const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); const centerX = cardDisplay?.x ?? GAME_W / 2; - const centerY = cardDisplay?.y ?? 260; + // Shift the grid down within the cardDisplay zone to clear the header/controls + const centerY = (cardDisplay?.y ?? 270) + 100; - // Default scale of 0.7 gives ~33px wide cards, 8 columns = ~296px wide - const cardScale = 0.7; + // Scale of 0.85 gives ~41px wide × ~55px tall cards, 8 columns = ~354px wide + const cardScale = 0.85; const scaledCardW = CARD_W * cardScale; const scaledCardH = CARD_H * cardScale; const stepX = scaledCardW + GRID_GAP_X; From e72ace1a0233f41886d04c80ddcabaca5f2d7956 Mon Sep 17 00:00:00 2001 From: Map Date: Tue, 26 May 2026 10:22:07 +0100 Subject: [PATCH 4/5] CG-0MPLT8ADR0099WF3: Increase card scale from 0.85 to 1.0 (full size 48x65px) --- example-games/gym/scenes/GymDeckRngScene.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index adcf44cb..786733e6 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -133,8 +133,8 @@ export class GymDeckRngScene extends GymSceneBase { // Shift the grid down within the cardDisplay zone to clear the header/controls const centerY = (cardDisplay?.y ?? 270) + 100; - // Scale of 0.85 gives ~41px wide × ~55px tall cards, 8 columns = ~354px wide - const cardScale = 0.85; + // 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; From 0c211560f96149befdfb380dadb68c146aac7600 Mon Sep 17 00:00:00 2001 From: Map Date: Tue, 26 May 2026 10:33:06 +0100 Subject: [PATCH 5/5] CG-0MPLT8ADR0099WF3: Initial render uses seed 42, +/- auto-shuffle, Shuffle uses random seed - Initial deck rendering now shuffles with the default seed (42) instead of displaying cards in unshuffled standard order. - Shuffle button generates a random seed (0-99999) and updates the seed display so the user can reproduce the arrangement. - +/- and Reset Seed buttons now automatically re-shuffle and re-render the deck with the adjusted seed value. - Updated help text to reflect new behavior. - Updated browser determinism test to use +/- auto-shuffle instead of Shuffle (which is now random). - All 2726 tests pass. --- example-games/gym/scenes/GymDeckRngScene.ts | 18 +++++++++++------ tests/gym/GymDeckRngScene.browser.test.ts | 22 +++++++-------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index 786733e6..e495a0b0 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -65,11 +65,11 @@ export class GymDeckRngScene extends GymSceneBase { this.initHelp([ { heading: 'Overview', - body: 'Displays all 52 cards face-up in a compact grid. Shuffle the deck using a deterministic seed to see how card order changes.' + 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 and re-display all 52 cards using the current seed.\n\nTip: Using the same seed always produces the same shuffle order.' + 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.' } ]); @@ -88,14 +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.shuffleAndRedraw()); + this.addButton(cx + 450, y, '[ Shuffle ]', () => { + this.seed = Math.floor(Math.random() * 100000); + this.seedText.setText(String(this.seed)); + this.shuffleAndRedraw(); + }); // ── Status ─────────────────────────────────────────── this.statusText = this.addLabel(cx + 600, y, '52 cards displayed', { fontSize: '16px', color: '#88ff88' }); - // ── Initialize deck and render full deck in a grid ───── - this.deck = createStandardDeck(); - this.renderFullDeckGrid(); + // ── Initialize deck, shuffle with default seed, and render ── + this.seed = DEFAULT_SEED; + this.shuffleAndRedraw(); } // ── Actions ────────────────────────────────────────────── @@ -103,11 +107,13 @@ 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.shuffleAndRedraw(); } private resetSeed(): void { this.seed = DEFAULT_SEED; this.seedText.setText(String(this.seed)); + this.shuffleAndRedraw(); } /** diff --git a/tests/gym/GymDeckRngScene.browser.test.ts b/tests/gym/GymDeckRngScene.browser.test.ts index 082eaa08..2072ba0c 100644 --- a/tests/gym/GymDeckRngScene.browser.test.ts +++ b/tests/gym/GymDeckRngScene.browser.test.ts @@ -215,25 +215,19 @@ describe('GymDeckRngScene browser integration', () => { expect(findText(scene, '42')).toBeTruthy(); }); - // ── AC 3: Shuffle button actually uses the current seed ──── + // ── AC 3 & 4: Seed changes auto-shuffle deterministically ── - it('shuffling with same seed reproduces identical arrangement (determinism)', async () => { + it('same seed produces identical arrangement; different seed different (+/- auto-shuffle)', async () => { const scene = await bootScene(); - // Click Shuffle with seed 42 - const shuffleBtn = findText(scene, '[ Shuffle ]'); - expect(shuffleBtn).toBeTruthy(); - shuffleBtn!.emit('pointerdown'); - await new Promise((r) => requestAnimationFrame(r)); + // Scene loads shuffled with seed 42 → capture arrangement const arrangements1 = getCardSnapshot(scene).map((s) => s.textureKey); - // Change seed and shuffle + // 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)); - - shuffleBtn!.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 @@ -243,13 +237,11 @@ describe('GymDeckRngScene browser integration', () => { } expect(sameCount).toBeLessThan(52); - // Reset seed and shuffle — should reproduce seed 42 arrangement exactly + // 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)); - - shuffleBtn!.emit('pointerdown'); - await new Promise((r) => requestAnimationFrame(r)); const arrangements3 = getCardSnapshot(scene).map((s) => s.textureKey); expect(arrangements3).toEqual(arrangements1);