From 7885b272a4805fb29d5eacbf379f95527451f571 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:22:44 +0000 Subject: [PATCH 1/3] Honor RulesConfig for prize cards vs points in frontend game board The game board now conditionally renders prize card zones based on the RulesConfig sent from the backend: - Add rules_config field to VisibleGameState in backend (visibility.py) - Add rules_config to frontend game types and game store - Update layout.ts to accept LayoutOptions with usePrizeCards and prizeCount - Update StateRenderer to conditionally create PrizeZone objects - Update Board to handle empty prize position arrays gracefully - Add game store computed properties: rulesConfig, usePrizeCards, prizeCount - Add tests for conditional prize zone rendering When use_prize_cards is false (Mantimon TCG points system), the prize zones are not rendered, saving screen space. When true (classic Pokemon TCG mode), the correct number of prize slots is rendered based on the rules config's prize count. https://claude.ai/code/session_01AAxKmpq2AGde327eX1nzUC --- backend/app/core/visibility.py | 7 + frontend/src/game/layout.spec.ts | 116 +++++++++++++++++ frontend/src/game/layout.ts | 165 ++++++++++++++++-------- frontend/src/game/sync/StateRenderer.ts | 46 ++++--- frontend/src/stores/game.ts | 25 ++++ frontend/src/types/game.ts | 5 +- 6 files changed, 294 insertions(+), 70 deletions(-) diff --git a/backend/app/core/visibility.py b/backend/app/core/visibility.py index a21c6c6..0195a70 100644 --- a/backend/app/core/visibility.py +++ b/backend/app/core/visibility.py @@ -37,6 +37,7 @@ from typing import TYPE_CHECKING from pydantic import BaseModel, Field +from app.core.config import RulesConfig from app.core.enums import GameEndReason, TurnPhase from app.core.models.card import CardDefinition, CardInstance @@ -124,6 +125,7 @@ class VisibleGameState(BaseModel): stadium_owner_id: Player who played the current stadium (public). forced_action: Current forced action, if any. card_registry: Card definitions (needed to display cards). + rules_config: Rules configuration for UI rendering decisions (e.g., prize cards vs points). """ game_id: str @@ -154,6 +156,9 @@ class VisibleGameState(BaseModel): # Card definitions for display card_registry: dict[str, CardDefinition] = Field(default_factory=dict) + # Rules configuration for UI rendering decisions + rules_config: RulesConfig = Field(default_factory=RulesConfig) + def _create_visible_zone( cards: list[CardInstance], @@ -298,6 +303,7 @@ def get_visible_state(game: GameState, viewer_id: str) -> VisibleGameState: forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, + rules_config=game.rules, ) @@ -393,4 +399,5 @@ def get_spectator_state(game: GameState) -> VisibleGameState: forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, + rules_config=game.rules, ) diff --git a/frontend/src/game/layout.spec.ts b/frontend/src/game/layout.spec.ts index ee22904..d8ab428 100644 --- a/frontend/src/game/layout.spec.ts +++ b/frontend/src/game/layout.spec.ts @@ -582,3 +582,119 @@ describe('getAllZones', () => { expect(allZones.find(z => z.label === 'oppBench[0]')).toBeDefined() }) }) + +// ============================================================================= +// Conditional Prize Zone Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - prize zone options', () => { + it('returns empty prize arrays when usePrizeCards is false', () => { + /** + * Test that prize zones are not generated in points-based mode. + * + * When using the Mantimon TCG points system instead of classic prize cards, + * the prize zones should be omitted from the layout to save screen space. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: false, + }) + + expect(layout.myPrizes).toHaveLength(0) + expect(layout.oppPrizes).toHaveLength(0) + }) + + it('returns correct prize count when usePrizeCards is true with custom count', () => { + /** + * Test that prize zones can have a custom count. + * + * Different rule variants may use 4, 6, or other prize counts. + * The layout should generate exactly the requested number of slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('defaults to 6 prizes when usePrizeCards is true without count', () => { + /** + * Test that classic mode defaults to 6 prize cards. + * + * When using prize cards without specifying a count, the standard + * Pokemon TCG count of 6 should be used. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + }) + + expect(layout.myPrizes).toHaveLength(PRIZE_SIZE) + expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE) + }) + + it('returns 6 prizes when no options provided (backwards compatibility)', () => { + /** + * Test backwards compatibility with existing code. + * + * When called without options, the layout should behave as before, + * generating 6 prize slots for each player. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + // Default behavior assumes classic prize card mode with 6 prizes + expect(layout.myPrizes).toHaveLength(PRIZE_SIZE) + expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE) + }) + + it('works correctly in portrait mode with usePrizeCards false', () => { + /** + * Test that portrait mode also respects prize card settings. + * + * The portrait layout should also omit prize zones when using + * the points system. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + usePrizeCards: false, + }) + + expect(layout.myPrizes).toHaveLength(0) + expect(layout.oppPrizes).toHaveLength(0) + }) + + it('works correctly in portrait mode with custom prize count', () => { + /** + * Test that portrait mode generates correct prize count. + * + * The portrait layout should create the correct number of + * prize positions when using prize cards with a custom count. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('prize positions are valid when generated', () => { + /** + * Test that generated prize positions are all valid. + * + * Each prize slot should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + usePrizeCards: true, + prizeCount: 4, + }) + + for (const prize of layout.myPrizes) { + expect(isValidZone(prize), 'Player prize zone should be valid').toBe(true) + } + for (const prize of layout.oppPrizes) { + expect(isValidZone(prize), 'Opponent prize zone should be valid').toBe(true) + } + }) +}) diff --git a/frontend/src/game/layout.ts b/frontend/src/game/layout.ts index 391614b..918857a 100644 --- a/frontend/src/game/layout.ts +++ b/frontend/src/game/layout.ts @@ -53,12 +53,26 @@ export const EDGE_PADDING = 16 /** Number of bench slots */ export const BENCH_SIZE = 5 -/** Number of prize slots */ +/** Default number of prize slots (for classic Pokemon TCG mode) */ export const PRIZE_SIZE = 6 /** Portrait mode threshold - below this aspect ratio we use portrait layout */ export const PORTRAIT_THRESHOLD = 0.9 +// ============================================================================= +// Layout Options +// ============================================================================= + +/** + * Options for customizing board layout based on rules config. + */ +export interface LayoutOptions { + /** Whether to use prize cards (classic mode). If false, no prize zones are rendered. */ + usePrizeCards?: boolean + /** Number of prize cards when using classic mode. Defaults to 6. */ + prizeCount?: number +} + // ============================================================================= // Orientation Detection // ============================================================================= @@ -103,19 +117,28 @@ export function getScaleFactor(width: number, height: number): number { * * @param width - Canvas width in pixels * @param height - Canvas height in pixels + * @param options - Optional layout customization based on rules config * @returns Complete board layout with all zone positions * * @example * ```ts * const layout = calculateLayout(1920, 1080) * console.log(layout.myActive) // { x: 960, y: 700, width: 120, height: 168 } + * + * // For points-based mode (no prize cards) + * const layoutNoCards = calculateLayout(1920, 1080, { usePrizeCards: false }) + * console.log(layoutNoCards.myPrizes) // [] (empty array) * ``` */ -export function calculateLayout(width: number, height: number): BoardLayout { +export function calculateLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { if (isPortrait(width, height)) { - return calculatePortraitLayout(width, height) + return calculatePortraitLayout(width, height, options) } - return calculateLandscapeLayout(width, height) + return calculateLandscapeLayout(width, height, options) } /** @@ -129,9 +152,16 @@ export function calculateLayout(width: number, height: number): BoardLayout { * * @param width - Canvas width * @param height - Canvas height + * @param options - Optional layout customization * @returns Board layout for landscape orientation */ -function calculateLandscapeLayout(width: number, height: number): BoardLayout { +function calculateLandscapeLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { + const usePrizeCards = options?.usePrizeCards ?? true + const prizeCount = options?.prizeCount ?? PRIZE_SIZE const scale = getScaleFactor(width, height) // Scale card dimensions @@ -210,15 +240,18 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myPrizes: calculatePrizePositions( - prizesStartX, - playerY - cardH / 2, - smallCardW, - smallCardH, - prizesColSpacing, - prizesRowSpacing, - false // Not flipped for player - ), + myPrizes: usePrizeCards + ? calculatePrizePositions( + prizesStartX, + playerY - cardH / 2, + smallCardW, + smallCardH, + prizesColSpacing, + prizesRowSpacing, + false, // Not flipped for player + prizeCount + ) + : [], myEnergyZone: { x: centerX + energyOffsetX, @@ -269,15 +302,18 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { rotation: Math.PI, }, - oppPrizes: calculatePrizePositions( - width - prizesStartX - prizesColSpacing, // Right side for opponent - oppY + cardH / 2, - smallCardW, - smallCardH, - -prizesColSpacing, // Reversed column spacing - -prizesRowSpacing, // Reversed row spacing - true // Flipped for opponent - ), + oppPrizes: usePrizeCards + ? calculatePrizePositions( + width - prizesStartX - prizesColSpacing, // Right side for opponent + oppY + cardH / 2, + smallCardW, + smallCardH, + -prizesColSpacing, // Reversed column spacing + -prizesRowSpacing, // Reversed row spacing + true, // Flipped for opponent + prizeCount + ) + : [], oppEnergyZone: { x: centerX - energyOffsetX, @@ -302,9 +338,16 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout { * * @param width - Canvas width * @param height - Canvas height + * @param options - Optional layout customization * @returns Board layout for portrait orientation */ -function calculatePortraitLayout(width: number, height: number): BoardLayout { +function calculatePortraitLayout( + width: number, + height: number, + options?: LayoutOptions +): BoardLayout { + const usePrizeCards = options?.usePrizeCards ?? true + const prizeCount = options?.prizeCount ?? PRIZE_SIZE const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait // Scale card dimensions - use smaller cards in portrait @@ -384,14 +427,17 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: 0, }, - myPrizes: calculatePortraitPrizePositions( - prizesX, - playerY, - smallCardW, - smallCardH, - prizesRowSpacing, - false - ), + myPrizes: usePrizeCards + ? calculatePortraitPrizePositions( + prizesX, + playerY, + smallCardW, + smallCardH, + prizesRowSpacing, + false, + prizeCount + ) + : [], myEnergyZone: { x: centerX + energyOffsetX, @@ -442,14 +488,17 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { rotation: Math.PI, }, - oppPrizes: calculatePortraitPrizePositions( - width - prizesX, - oppY, - smallCardW, - smallCardH, - -prizesRowSpacing, - true - ), + oppPrizes: usePrizeCards + ? calculatePortraitPrizePositions( + width - prizesX, + oppY, + smallCardW, + smallCardH, + -prizesRowSpacing, + true, + prizeCount + ) + : [], oppEnergyZone: { x: centerX - energyOffsetX, @@ -464,7 +513,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { } /** - * Calculate prize card positions in a 2x3 grid. + * Calculate prize card positions in a 2xN grid. * * @param startX - X position of first column * @param startY - Y position of first row center @@ -473,7 +522,8 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout { * @param colSpacing - Horizontal spacing between columns * @param rowSpacing - Vertical spacing between rows * @param flipped - Whether cards are upside down (opponent) - * @returns Array of 6 prize zone positions + * @param count - Number of prize cards (defaults to PRIZE_SIZE) + * @returns Array of prize zone positions */ function calculatePrizePositions( startX: number, @@ -482,13 +532,17 @@ function calculatePrizePositions( cardH: number, colSpacing: number, rowSpacing: number, - flipped: boolean + flipped: boolean, + count: number = PRIZE_SIZE ): ZonePosition[] { const prizes: ZonePosition[] = [] - - for (let row = 0; row < 3; row++) { - for (let col = 0; col < 2; col++) { - const index = row * 2 + col + const cols = 2 + const rows = Math.ceil(count / cols) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = row * cols + col + if (index >= count) break prizes.push({ x: startX + col * colSpacing, y: startY + row * rowSpacing, @@ -498,7 +552,7 @@ function calculatePrizePositions( }) } } - + return prizes } @@ -514,7 +568,8 @@ function calculatePrizePositions( * @param cardH - Card height * @param rowSpacing - Vertical spacing (can be negative for overlap) * @param flipped - Whether cards are upside down - * @returns Array of 6 prize zone positions + * @param count - Number of prize cards (defaults to PRIZE_SIZE) + * @returns Array of prize zone positions */ function calculatePortraitPrizePositions( x: number, @@ -522,12 +577,14 @@ function calculatePortraitPrizePositions( cardW: number, cardH: number, rowSpacing: number, - flipped: boolean + flipped: boolean, + count: number = PRIZE_SIZE ): ZonePosition[] { const prizes: ZonePosition[] = [] - const startY = centerY - (2.5 * Math.abs(rowSpacing)) // Center the stack - - for (let i = 0; i < PRIZE_SIZE; i++) { + // Center the stack based on count + const startY = centerY - ((count - 1) / 2 * Math.abs(rowSpacing)) + + for (let i = 0; i < count; i++) { prizes.push({ x, y: startY + i * Math.abs(rowSpacing), @@ -536,7 +593,7 @@ function calculatePortraitPrizePositions( rotation: flipped ? Math.PI : 0, }) } - + return prizes } diff --git a/frontend/src/game/sync/StateRenderer.ts b/frontend/src/game/sync/StateRenderer.ts index 39b96cd..302cb53 100644 --- a/frontend/src/game/sync/StateRenderer.ts +++ b/frontend/src/game/sync/StateRenderer.ts @@ -29,7 +29,7 @@ import type { } from '@/types/game' import type { BoardLayout } from '@/types/phaser' import { getMyPlayerState, getOpponentState } from '@/types/game' -import { calculateLayout } from '../layout' +import { calculateLayout, type LayoutOptions } from '../layout' import { Card } from '../objects/Card' import { ActiveZone } from '../objects/ActiveZone' import { BenchZone } from '../objects/BenchZone' @@ -51,7 +51,7 @@ interface PlayerZones { hand: HandZone deck: PileZone discard: PileZone - prizes: PrizeZone + prizes: PrizeZone | null // null when using points system instead of prize cards energyZone: PileZone } @@ -128,8 +128,14 @@ export class StateRenderer { // Get canvas dimensions const { width, height } = this.scene.cameras.main + // Extract layout options from rules config + const layoutOptions: LayoutOptions = { + usePrizeCards: state.rules_config?.prizes.use_prize_cards ?? false, + prizeCount: state.rules_config?.prizes.count ?? 4, + } + // Calculate layout - this.layout = calculateLayout(width, height) + this.layout = calculateLayout(width, height, layoutOptions) // Create zones if needed if (!this.zones) { @@ -258,13 +264,16 @@ export class StateRenderer { return } + // Check if we should create prize zones + const usePrizeCards = state.rules_config?.prizes.use_prize_cards ?? false + // Create main container this.container = this.scene.add.container(0, 0) // Create player zones this.zones = { - my: this.createPlayerZones(myState.player_id, false), - opp: this.createPlayerZones(oppState.player_id, true), + my: this.createPlayerZones(myState.player_id, false, usePrizeCards), + opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards), } } @@ -273,9 +282,14 @@ export class StateRenderer { * * @param playerId - The player's ID * @param isOpponent - Whether this is the opponent (affects layout orientation) + * @param usePrizeCards - Whether to create prize zones (false for points system) * @returns PlayerZones object */ - private createPlayerZones(playerId: string, isOpponent: boolean): PlayerZones { + private createPlayerZones( + playerId: string, + isOpponent: boolean, + usePrizeCards: boolean + ): PlayerZones { // Create zones at origin - positions updated in updateZonePositions() const zones: PlayerZones = { active: new ActiveZone(this.scene, 0, 0, playerId), @@ -283,7 +297,7 @@ export class StateRenderer { hand: new HandZone(this.scene, 0, 0, playerId), deck: new PileZone(this.scene, 0, 0, playerId, 'deck'), discard: new PileZone(this.scene, 0, 0, playerId, 'discard'), - prizes: new PrizeZone(this.scene, 0, 0, playerId), + prizes: usePrizeCards ? new PrizeZone(this.scene, 0, 0, playerId) : null, energyZone: new PileZone(this.scene, 0, 0, playerId, 'deck'), // Energy deck uses pile zone } @@ -294,7 +308,9 @@ export class StateRenderer { this.container.add(zones.hand) this.container.add(zones.deck) this.container.add(zones.discard) - this.container.add(zones.prizes) + if (zones.prizes) { + this.container.add(zones.prizes) + } this.container.add(zones.energyZone) } @@ -312,7 +328,7 @@ export class StateRenderer { zones.hand.destroy() zones.deck.destroy() zones.discard.destroy() - zones.prizes.destroy() + zones.prizes?.destroy() zones.energyZone.destroy() } @@ -344,8 +360,8 @@ export class StateRenderer { this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height) } - // Prizes - use the first prize position for the zone - if (this.layout.myPrizes.length > 0) { + // Prizes - use the first prize position for the zone (only if using prize cards) + if (this.zones.my.prizes && this.layout.myPrizes.length > 0) { const prizePositions = this.layout.myPrizes const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) @@ -375,8 +391,8 @@ export class StateRenderer { this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height) } - // Opponent prizes - if (this.layout.oppPrizes.length > 0) { + // Opponent prizes (only if using prize cards) + if (this.zones.opp.prizes && this.layout.oppPrizes.length > 0) { const prizePositions = this.layout.oppPrizes const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) @@ -449,10 +465,10 @@ export class StateRenderer { true // always visible ) - // Update prizes + // Update prizes (only if using prize cards) // Viewer sees their own prize count (cards still hidden) // Opponent just sees count - zones.prizes.setRemainingCount(playerState.prizes_count) + zones.prizes?.setRemainingCount(playerState.prizes_count) // Update energy zone this.updateZone( diff --git a/frontend/src/stores/game.ts b/frontend/src/stores/game.ts index b109de8..d7877fd 100644 --- a/frontend/src/stores/game.ts +++ b/frontend/src/stores/game.ts @@ -14,6 +14,7 @@ import type { CardDefinition, TurnPhase, Action, + RulesConfig, } from '@/types' import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } from '@/types' @@ -216,6 +217,25 @@ export const useGameStore = defineStore('game', () => { forcedAction.value?.player === gameState.value?.viewer_id ) + // --------------------------------------------------------------------------- + // Computed - Rules Config + // --------------------------------------------------------------------------- + + /** Current game rules configuration */ + const rulesConfig = computed(() => + gameState.value?.rules_config ?? null + ) + + /** Whether this game uses classic prize cards (vs points system) */ + const usePrizeCards = computed(() => + rulesConfig.value?.prizes.use_prize_cards ?? false + ) + + /** Number of prizes/points needed to win */ + const prizeCount = computed(() => + rulesConfig.value?.prizes.count ?? 4 + ) + // --------------------------------------------------------------------------- // Computed - Card Lookup // --------------------------------------------------------------------------- @@ -337,6 +357,11 @@ export const useGameStore = defineStore('game', () => { hasForcedAction, isConnected, + // Rules config + rulesConfig, + usePrizeCards, + prizeCount, + // Card lookup lookupCard, diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index f59f7c6..ee924d2 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -6,7 +6,7 @@ * and sends this structure via WebSocket. */ -import type { ModifierMode } from './rules' +import type { ModifierMode, RulesConfig } from './rules' // ============================================================================= // Enums and Constants @@ -407,6 +407,9 @@ export interface VisibleGameState { /** Card definitions for display (definition_id -> CardDefinition) */ card_registry: Record + + /** Rules configuration for UI rendering decisions (e.g., prize cards vs points) */ + rules_config?: RulesConfig } // ============================================================================= From c430a43e19647fb8bad7664e9184b37599e19472 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:28:17 +0000 Subject: [PATCH 2/3] Add configurable bench size based on RulesConfig Extends the RulesConfig support in the frontend game board to also honor the bench.max_size setting: - Add benchSize option to LayoutOptions interface - Update calculateLandscapeLayout to use configurable bench count - Update calculatePortraitLayout to use configurable bench count - Update StateRenderer to pass benchSize from rules_config.bench.max_size - Add benchSize computed property to game store - Add 7 tests for configurable bench size behavior The layout now generates the correct number of bench slots based on the rules config (defaults to 5 for backwards compatibility). Bench slots remain horizontally centered regardless of count. https://claude.ai/code/session_01AAxKmpq2AGde327eX1nzUC --- frontend/src/game/layout.spec.ts | 124 ++++++++++++++++++++++++ frontend/src/game/layout.ts | 30 +++--- frontend/src/game/sync/StateRenderer.ts | 1 + frontend/src/stores/game.ts | 6 ++ 4 files changed, 148 insertions(+), 13 deletions(-) diff --git a/frontend/src/game/layout.spec.ts b/frontend/src/game/layout.spec.ts index d8ab428..1338c34 100644 --- a/frontend/src/game/layout.spec.ts +++ b/frontend/src/game/layout.spec.ts @@ -698,3 +698,127 @@ describe('calculateLayout - prize zone options', () => { } }) }) + +// ============================================================================= +// Conditional Bench Size Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - bench size options', () => { + it('returns default 5 bench slots when no options provided', () => { + /** + * Test backwards compatibility with existing code. + * + * When called without options, the layout should generate 5 bench slots + * for each player (standard Pokemon TCG bench size). + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + expect(layout.myBench).toHaveLength(BENCH_SIZE) + expect(layout.oppBench).toHaveLength(BENCH_SIZE) + }) + + it('returns correct bench count with custom benchSize option', () => { + /** + * Test that bench zones can have a custom count. + * + * Some rule variants may use fewer bench slots (e.g., 3 for a faster game). + * The layout should generate exactly the requested number of slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 3, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + }) + + it('works with larger bench sizes', () => { + /** + * Test that larger bench sizes are supported. + * + * Some custom variants might use more than 5 bench slots. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 8, + }) + + expect(layout.myBench).toHaveLength(8) + expect(layout.oppBench).toHaveLength(8) + }) + + it('works correctly in portrait mode with custom bench size', () => { + /** + * Test that portrait mode also respects bench size settings. + * + * The portrait layout should also generate the correct number + * of bench positions based on the benchSize option. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + benchSize: 3, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + }) + + it('bench positions are valid when using custom size', () => { + /** + * Test that generated bench positions are all valid. + * + * Each bench slot should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 4, + }) + + for (const bench of layout.myBench) { + expect(isValidZone(bench), 'Player bench zone should be valid').toBe(true) + } + for (const bench of layout.oppBench) { + expect(isValidZone(bench), 'Opponent bench zone should be valid').toBe(true) + } + }) + + it('bench slots are centered properly with different sizes', () => { + /** + * Test that bench zones remain centered regardless of count. + * + * Whether using 3, 5, or 8 bench slots, they should all be + * horizontally centered on the screen. + */ + const layout3 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 3 }) + const layout5 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 5 }) + const layout7 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 7 }) + + const centerX = LANDSCAPE_WIDTH / 2 + + // Calculate average X position of each player's bench + const avg3 = layout3.myBench.reduce((sum, b) => sum + b.x, 0) / 3 + const avg5 = layout5.myBench.reduce((sum, b) => sum + b.x, 0) / 5 + const avg7 = layout7.myBench.reduce((sum, b) => sum + b.x, 0) / 7 + + // All should be centered + expect(avg3).toBeCloseTo(centerX, 0) + expect(avg5).toBeCloseTo(centerX, 0) + expect(avg7).toBeCloseTo(centerX, 0) + }) + + it('can combine benchSize with prize options', () => { + /** + * Test that bench and prize options work together. + * + * Custom rules might have both custom bench size and prize count, + * and both should be applied correctly. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + benchSize: 3, + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) +}) diff --git a/frontend/src/game/layout.ts b/frontend/src/game/layout.ts index 918857a..82905c3 100644 --- a/frontend/src/game/layout.ts +++ b/frontend/src/game/layout.ts @@ -71,6 +71,8 @@ export interface LayoutOptions { usePrizeCards?: boolean /** Number of prize cards when using classic mode. Defaults to 6. */ prizeCount?: number + /** Number of bench slots. Defaults to 5. */ + benchSize?: number } // ============================================================================= @@ -162,8 +164,9 @@ function calculateLandscapeLayout( ): BoardLayout { const usePrizeCards = options?.usePrizeCards ?? true const prizeCount = options?.prizeCount ?? PRIZE_SIZE + const benchSize = options?.benchSize ?? BENCH_SIZE const scale = getScaleFactor(width, height) - + // Scale card dimensions const cardW = Math.round(CARD_WIDTH_MEDIUM * scale) const cardH = Math.round(CARD_HEIGHT_MEDIUM * scale) @@ -171,7 +174,7 @@ function calculateLandscapeLayout( const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale) const padding = Math.round(ZONE_PADDING * scale) const edgePad = Math.round(EDGE_PADDING * scale) - + // Vertical layout divisions const centerY = height / 2 const playerY = centerY + (height * 0.18) // Player active zone @@ -179,11 +182,11 @@ function calculateLandscapeLayout( const benchOffsetY = cardH + padding // Bench below/above active (full card height + padding) const handY = height - edgePad - cardH / 2 // Hand at bottom const oppHandY = edgePad + cardH / 2 // Opponent hand at top - + // Horizontal layout - center of screen const centerX = width / 2 const benchSpacing = cardW + padding - const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing + const totalBenchWidth = (benchSize - 1) * benchSpacing // Deck/discard positions (right side) const deckX = width - edgePad - cardW / 2 - padding @@ -208,7 +211,7 @@ function calculateLandscapeLayout( rotation: 0, }, - myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + myBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX - totalBenchWidth / 2 + i * benchSpacing, y: playerY + benchOffsetY, width: cardW, @@ -270,7 +273,7 @@ function calculateLandscapeLayout( rotation: Math.PI, // Upside down }, - oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + oppBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX + totalBenchWidth / 2 - i * benchSpacing, // Reversed order y: oppY - benchOffsetY, // Above active for opponent width: cardW, @@ -348,8 +351,9 @@ function calculatePortraitLayout( ): BoardLayout { const usePrizeCards = options?.usePrizeCards ?? true const prizeCount = options?.prizeCount ?? PRIZE_SIZE + const benchSize = options?.benchSize ?? BENCH_SIZE const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait - + // Scale card dimensions - use smaller cards in portrait const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2) const cardH = Math.round(CARD_HEIGHT_SMALL * scale * 1.2) @@ -357,7 +361,7 @@ function calculatePortraitLayout( const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale * 0.9) const padding = Math.round(ZONE_PADDING * scale * 0.8) const edgePad = Math.round(EDGE_PADDING * scale * 0.8) - + // Vertical layout divisions const centerY = height / 2 const playerY = centerY + (height * 0.15) @@ -365,11 +369,11 @@ function calculatePortraitLayout( const benchOffsetY = cardH + padding // Full card height + padding const handY = height - edgePad - cardH / 2 const oppHandY = edgePad + cardH / 2 - + // Horizontal layout - tighter spacing for portrait const centerX = width / 2 - const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / BENCH_SIZE) - const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing + const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / benchSize) + const totalBenchWidth = (benchSize - 1) * benchSpacing // Deck/discard positions (right side, more compact) // Use reduced size (0.8) for these zones @@ -395,7 +399,7 @@ function calculatePortraitLayout( rotation: 0, }, - myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + myBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX - totalBenchWidth / 2 + i * benchSpacing, y: playerY + benchOffsetY, width: cardW * 0.9, // Slightly smaller bench cards @@ -456,7 +460,7 @@ function calculatePortraitLayout( rotation: Math.PI, }, - oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ + oppBench: Array.from({ length: benchSize }, (_, i) => ({ x: centerX + totalBenchWidth / 2 - i * benchSpacing, y: oppY - benchOffsetY, width: cardW * 0.9, diff --git a/frontend/src/game/sync/StateRenderer.ts b/frontend/src/game/sync/StateRenderer.ts index 302cb53..61d1221 100644 --- a/frontend/src/game/sync/StateRenderer.ts +++ b/frontend/src/game/sync/StateRenderer.ts @@ -132,6 +132,7 @@ export class StateRenderer { const layoutOptions: LayoutOptions = { usePrizeCards: state.rules_config?.prizes.use_prize_cards ?? false, prizeCount: state.rules_config?.prizes.count ?? 4, + benchSize: state.rules_config?.bench.max_size ?? 5, } // Calculate layout diff --git a/frontend/src/stores/game.ts b/frontend/src/stores/game.ts index d7877fd..6c4afdc 100644 --- a/frontend/src/stores/game.ts +++ b/frontend/src/stores/game.ts @@ -236,6 +236,11 @@ export const useGameStore = defineStore('game', () => { rulesConfig.value?.prizes.count ?? 4 ) + /** Maximum number of Pokemon on the bench */ + const benchSize = computed(() => + rulesConfig.value?.bench.max_size ?? 5 + ) + // --------------------------------------------------------------------------- // Computed - Card Lookup // --------------------------------------------------------------------------- @@ -361,6 +366,7 @@ export const useGameStore = defineStore('game', () => { rulesConfig, usePrizeCards, prizeCount, + benchSize, // Card lookup lookupCard, From 42e0116aecf74556c36d55a7bcf207eb498a6bf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:41:25 +0000 Subject: [PATCH 3/3] Add conditional energy deck zone based on RulesConfig Extends RulesConfig support in the frontend game board to conditionally render energy deck zones based on deck.energy_deck_enabled setting. When disabled (classic mode), energy zones are null and omitted from the layout, saving screen space. Changes: - Add energyDeckEnabled option to LayoutOptions interface - Update landscape/portrait layouts to conditionally generate energy zones - Make myEnergyZone/oppEnergyZone nullable in BoardLayout type - Update StateRenderer to conditionally create and update energy zones - Add energyDeckEnabled computed property to game store - Add 7 tests for conditional energy deck rendering https://claude.ai/code/session_01AAxKmpq2AGde327eX1nzUC --- frontend/src/game/layout.spec.ts | 134 ++++++++++++++++++++++++ frontend/src/game/layout.ts | 90 +++++++++------- frontend/src/game/sync/StateRenderer.ts | 50 +++++---- frontend/src/stores/game.ts | 6 ++ frontend/src/types/phaser.ts | 12 +-- 5 files changed, 230 insertions(+), 62 deletions(-) diff --git a/frontend/src/game/layout.spec.ts b/frontend/src/game/layout.spec.ts index 1338c34..5af82a1 100644 --- a/frontend/src/game/layout.spec.ts +++ b/frontend/src/game/layout.spec.ts @@ -822,3 +822,137 @@ describe('calculateLayout - bench size options', () => { expect(layout.oppPrizes).toHaveLength(4) }) }) + +// ============================================================================= +// Conditional Energy Deck Zone Tests (RulesConfig support) +// ============================================================================= + +describe('calculateLayout - energy deck options', () => { + it('returns energy zones when energyDeckEnabled is true (default)', () => { + /** + * Test that energy deck zones are generated by default. + * + * The Pokemon Pocket-style energy deck zone should be included + * when the energy deck feature is enabled (the default behavior). + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + }) + + it('returns null energy zones when energyDeckEnabled is false', () => { + /** + * Test that energy zones are omitted in classic mode. + * + * When using classic Pokemon TCG rules without an energy deck, + * the energy zones should be null to save screen space. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + }) + + it('energy zones have valid positions when enabled', () => { + /** + * Test that generated energy zones have valid positions. + * + * Each energy zone should have positive dimensions and valid coordinates. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: true, + }) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + + if (layout.myEnergyZone) { + expect(isValidZone(layout.myEnergyZone), 'Player energy zone should be valid').toBe(true) + } + if (layout.oppEnergyZone) { + expect(isValidZone(layout.oppEnergyZone), 'Opponent energy zone should be valid').toBe(true) + } + }) + + it('works correctly in portrait mode with energyDeckEnabled false', () => { + /** + * Test that portrait mode also respects energy deck settings. + * + * The portrait layout should also omit energy zones when using + * classic mode without an energy deck. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + energyDeckEnabled: false, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + }) + + it('works correctly in portrait mode with energyDeckEnabled true', () => { + /** + * Test that portrait mode generates energy zones when enabled. + * + * The portrait layout should include energy zones when the + * energy deck feature is enabled. + */ + const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, { + energyDeckEnabled: true, + }) + + expect(layout.myEnergyZone).not.toBeNull() + expect(layout.oppEnergyZone).not.toBeNull() + }) + + it('can combine energyDeckEnabled with other options', () => { + /** + * Test that energy deck option works with other layout options. + * + * Custom rules might combine energy deck settings with bench size + * and prize options, and all should be applied correctly. + */ + const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + benchSize: 3, + usePrizeCards: true, + prizeCount: 4, + }) + + expect(layout.myEnergyZone).toBeNull() + expect(layout.oppEnergyZone).toBeNull() + expect(layout.myBench).toHaveLength(3) + expect(layout.oppBench).toHaveLength(3) + expect(layout.myPrizes).toHaveLength(4) + expect(layout.oppPrizes).toHaveLength(4) + }) + + it('getAllZones excludes null energy zones', () => { + /** + * Test that getAllZones handles null energy zones correctly. + * + * When energy deck is disabled, getAllZones should not include + * energy zone entries in its output. + */ + const layoutWithEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: true, + }) + const layoutWithoutEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { + energyDeckEnabled: false, + }) + + const zonesWithEnergy = getAllZones(layoutWithEnergy) + const zonesWithoutEnergy = getAllZones(layoutWithoutEnergy) + + // Should have 2 fewer zones when energy deck is disabled + expect(zonesWithoutEnergy.length).toBe(zonesWithEnergy.length - 2) + + // Verify energy zone labels exist/don't exist + expect(zonesWithEnergy.find(z => z.label === 'myEnergyZone')).toBeDefined() + expect(zonesWithEnergy.find(z => z.label === 'oppEnergyZone')).toBeDefined() + expect(zonesWithoutEnergy.find(z => z.label === 'myEnergyZone')).toBeUndefined() + expect(zonesWithoutEnergy.find(z => z.label === 'oppEnergyZone')).toBeUndefined() + }) +}) diff --git a/frontend/src/game/layout.ts b/frontend/src/game/layout.ts index 82905c3..49bc869 100644 --- a/frontend/src/game/layout.ts +++ b/frontend/src/game/layout.ts @@ -73,6 +73,8 @@ export interface LayoutOptions { prizeCount?: number /** Number of bench slots. Defaults to 5. */ benchSize?: number + /** Whether to show energy deck zone (Pokemon Pocket style). Defaults to true. */ + energyDeckEnabled?: boolean } // ============================================================================= @@ -165,6 +167,7 @@ function calculateLandscapeLayout( const usePrizeCards = options?.usePrizeCards ?? true const prizeCount = options?.prizeCount ?? PRIZE_SIZE const benchSize = options?.benchSize ?? BENCH_SIZE + const energyDeckEnabled = options?.energyDeckEnabled ?? true const scale = getScaleFactor(width, height) // Scale card dimensions @@ -256,14 +259,16 @@ function calculateLandscapeLayout( ) : [], - myEnergyZone: { - x: centerX + energyOffsetX, - y: playerY, - width: cardW, - height: cardH, - rotation: 0, - }, - + myEnergyZone: energyDeckEnabled + ? { + x: centerX + energyOffsetX, + y: playerY, + width: cardW, + height: cardH, + rotation: 0, + } + : null, + // Opponent zones (top of screen, mirrored) oppActive: { x: centerX, @@ -318,15 +323,17 @@ function calculateLandscapeLayout( ) : [], - oppEnergyZone: { - x: centerX - energyOffsetX, - y: oppY, - width: cardW, - height: cardH, - rotation: Math.PI, - }, + oppEnergyZone: energyDeckEnabled + ? { + x: centerX - energyOffsetX, + y: oppY, + width: cardW, + height: cardH, + rotation: Math.PI, + } + : null, } - + return layout } @@ -352,6 +359,7 @@ function calculatePortraitLayout( const usePrizeCards = options?.usePrizeCards ?? true const prizeCount = options?.prizeCount ?? PRIZE_SIZE const benchSize = options?.benchSize ?? BENCH_SIZE + const energyDeckEnabled = options?.energyDeckEnabled ?? true const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait // Scale card dimensions - use smaller cards in portrait @@ -443,14 +451,16 @@ function calculatePortraitLayout( ) : [], - myEnergyZone: { - x: centerX + energyOffsetX, - y: playerY, - width: cardW * 0.8, - height: cardH * 0.8, - rotation: 0, - }, - + myEnergyZone: energyDeckEnabled + ? { + x: centerX + energyOffsetX, + y: playerY, + width: cardW * 0.8, + height: cardH * 0.8, + rotation: 0, + } + : null, + // Opponent zones (top, mirrored) oppActive: { x: centerX, @@ -504,15 +514,17 @@ function calculatePortraitLayout( ) : [], - oppEnergyZone: { - x: centerX - energyOffsetX, - y: oppY, - width: cardW * 0.8, - height: cardH * 0.8, - rotation: Math.PI, - }, + oppEnergyZone: energyDeckEnabled + ? { + x: centerX - energyOffsetX, + y: oppY, + width: cardW * 0.8, + height: cardH * 0.8, + rotation: Math.PI, + } + : null, } - + return layout } @@ -693,7 +705,7 @@ export function getAllZones( layout: BoardLayout ): Array<{ label: string; zone: ZonePosition }> { const zones: Array<{ label: string; zone: ZonePosition }> = [] - + // My zones zones.push({ label: 'myActive', zone: layout.myActive }) layout.myBench.forEach((z, i) => zones.push({ label: `myBench[${i}]`, zone: z })) @@ -701,8 +713,10 @@ export function getAllZones( zones.push({ label: 'myDeck', zone: layout.myDeck }) zones.push({ label: 'myDiscard', zone: layout.myDiscard }) layout.myPrizes.forEach((z, i) => zones.push({ label: `myPrizes[${i}]`, zone: z })) - zones.push({ label: 'myEnergyZone', zone: layout.myEnergyZone }) - + if (layout.myEnergyZone) { + zones.push({ label: 'myEnergyZone', zone: layout.myEnergyZone }) + } + // Opponent zones zones.push({ label: 'oppActive', zone: layout.oppActive }) layout.oppBench.forEach((z, i) => zones.push({ label: `oppBench[${i}]`, zone: z })) @@ -710,7 +724,9 @@ export function getAllZones( zones.push({ label: 'oppDeck', zone: layout.oppDeck }) zones.push({ label: 'oppDiscard', zone: layout.oppDiscard }) layout.oppPrizes.forEach((z, i) => zones.push({ label: `oppPrizes[${i}]`, zone: z })) - zones.push({ label: 'oppEnergyZone', zone: layout.oppEnergyZone }) - + if (layout.oppEnergyZone) { + zones.push({ label: 'oppEnergyZone', zone: layout.oppEnergyZone }) + } + return zones } diff --git a/frontend/src/game/sync/StateRenderer.ts b/frontend/src/game/sync/StateRenderer.ts index 61d1221..edc4d88 100644 --- a/frontend/src/game/sync/StateRenderer.ts +++ b/frontend/src/game/sync/StateRenderer.ts @@ -52,7 +52,7 @@ interface PlayerZones { deck: PileZone discard: PileZone prizes: PrizeZone | null // null when using points system instead of prize cards - energyZone: PileZone + energyZone: PileZone | null // null when energy deck is disabled (classic mode) } /** @@ -133,6 +133,7 @@ export class StateRenderer { usePrizeCards: state.rules_config?.prizes.use_prize_cards ?? false, prizeCount: state.rules_config?.prizes.count ?? 4, benchSize: state.rules_config?.bench.max_size ?? 5, + energyDeckEnabled: state.rules_config?.deck.energy_deck_enabled ?? true, } // Calculate layout @@ -265,16 +266,17 @@ export class StateRenderer { return } - // Check if we should create prize zones + // Check if we should create optional zones const usePrizeCards = state.rules_config?.prizes.use_prize_cards ?? false + const energyDeckEnabled = state.rules_config?.deck.energy_deck_enabled ?? true // Create main container this.container = this.scene.add.container(0, 0) // Create player zones this.zones = { - my: this.createPlayerZones(myState.player_id, false, usePrizeCards), - opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards), + my: this.createPlayerZones(myState.player_id, false, usePrizeCards, energyDeckEnabled), + opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled), } } @@ -284,12 +286,14 @@ export class StateRenderer { * @param playerId - The player's ID * @param isOpponent - Whether this is the opponent (affects layout orientation) * @param usePrizeCards - Whether to create prize zones (false for points system) + * @param energyDeckEnabled - Whether to create energy deck zone (false for classic mode) * @returns PlayerZones object */ private createPlayerZones( playerId: string, isOpponent: boolean, - usePrizeCards: boolean + usePrizeCards: boolean, + energyDeckEnabled: boolean ): PlayerZones { // Create zones at origin - positions updated in updateZonePositions() const zones: PlayerZones = { @@ -299,7 +303,7 @@ export class StateRenderer { deck: new PileZone(this.scene, 0, 0, playerId, 'deck'), discard: new PileZone(this.scene, 0, 0, playerId, 'discard'), prizes: usePrizeCards ? new PrizeZone(this.scene, 0, 0, playerId) : null, - energyZone: new PileZone(this.scene, 0, 0, playerId, 'deck'), // Energy deck uses pile zone + energyZone: energyDeckEnabled ? new PileZone(this.scene, 0, 0, playerId, 'deck') : null, } // Add all zones to container @@ -312,7 +316,9 @@ export class StateRenderer { if (zones.prizes) { this.container.add(zones.prizes) } - this.container.add(zones.energyZone) + if (zones.energyZone) { + this.container.add(zones.energyZone) + } } return zones @@ -330,7 +336,7 @@ export class StateRenderer { zones.deck.destroy() zones.discard.destroy() zones.prizes?.destroy() - zones.energyZone.destroy() + zones.energyZone?.destroy() } // =========================================================================== @@ -348,7 +354,9 @@ export class StateRenderer { this.zones.my.hand.applyLayout(this.layout.myHand) this.zones.my.deck.applyLayout(this.layout.myDeck) this.zones.my.discard.applyLayout(this.layout.myDiscard) - this.zones.my.energyZone.applyLayout(this.layout.myEnergyZone) + if (this.zones.my.energyZone && this.layout.myEnergyZone) { + this.zones.my.energyZone.applyLayout(this.layout.myEnergyZone) + } // Bench zone - use the center position and full width const benchLayout = this.layout.myBench @@ -379,7 +387,9 @@ export class StateRenderer { this.zones.opp.hand.applyLayout(this.layout.oppHand) this.zones.opp.deck.applyLayout(this.layout.oppDeck) this.zones.opp.discard.applyLayout(this.layout.oppDiscard) - this.zones.opp.energyZone.applyLayout(this.layout.oppEnergyZone) + if (this.zones.opp.energyZone && this.layout.oppEnergyZone) { + this.zones.opp.energyZone.applyLayout(this.layout.oppEnergyZone) + } // Opponent bench const oppBenchLayout = this.layout.oppBench @@ -471,15 +481,17 @@ export class StateRenderer { // Opponent just sees count zones.prizes?.setRemainingCount(playerState.prizes_count) - // Update energy zone - this.updateZone( - zones.energyZone, - playerState.energy_zone.cards, - registry, - playerId, - 'energy_zone', - true // always visible - ) + // Update energy zone (only if energy deck is enabled) + if (zones.energyZone) { + this.updateZone( + zones.energyZone, + playerState.energy_zone.cards, + registry, + playerId, + 'energy_zone', + true // always visible + ) + } } /** diff --git a/frontend/src/stores/game.ts b/frontend/src/stores/game.ts index 6c4afdc..e7e96d0 100644 --- a/frontend/src/stores/game.ts +++ b/frontend/src/stores/game.ts @@ -241,6 +241,11 @@ export const useGameStore = defineStore('game', () => { rulesConfig.value?.bench.max_size ?? 5 ) + /** Whether this game uses an energy deck zone (Pokemon Pocket style) */ + const energyDeckEnabled = computed(() => + rulesConfig.value?.deck.energy_deck_enabled ?? true + ) + // --------------------------------------------------------------------------- // Computed - Card Lookup // --------------------------------------------------------------------------- @@ -367,6 +372,7 @@ export const useGameStore = defineStore('game', () => { usePrizeCards, prizeCount, benchSize, + energyDeckEnabled, // Card lookup lookupCard, diff --git a/frontend/src/types/phaser.ts b/frontend/src/types/phaser.ts index 722eb6a..5778052 100644 --- a/frontend/src/types/phaser.ts +++ b/frontend/src/types/phaser.ts @@ -174,21 +174,21 @@ export interface ZonePosition { export interface BoardLayout { // My zones (bottom of screen) myActive: ZonePosition - myBench: ZonePosition[] // 5 slots + myBench: ZonePosition[] // configurable slots (default 5) myHand: ZonePosition myDeck: ZonePosition myDiscard: ZonePosition - myPrizes: ZonePosition[] // 6 slots - myEnergyZone: ZonePosition + myPrizes: ZonePosition[] // configurable slots (default 6), empty when using points + myEnergyZone: ZonePosition | null // null when energy deck is disabled // Opponent zones (top of screen, mirrored) oppActive: ZonePosition - oppBench: ZonePosition[] // 5 slots + oppBench: ZonePosition[] // configurable slots (default 5) oppHand: ZonePosition oppDeck: ZonePosition oppDiscard: ZonePosition - oppPrizes: ZonePosition[] // 6 slots - oppEnergyZone: ZonePosition + oppPrizes: ZonePosition[] // configurable slots (default 6), empty when using points + oppEnergyZone: ZonePosition | null // null when energy deck is disabled } /**