From 7885b272a4805fb29d5eacbf379f95527451f571 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:22:44 +0000 Subject: [PATCH] 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 } // =============================================================================