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 } // =============================================================================