Merge pull request #1 from calcorum/claude/honor-rules-config-3ZRDG

Honor RulesConfig for prize cards vs points in frontend game board
This commit is contained in:
Cal Corum 2026-02-02 10:53:10 -06:00 committed by GitHub
commit 8a416c8ace
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 668 additions and 141 deletions

View File

@ -37,6 +37,7 @@ from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.config import RulesConfig
from app.core.enums import GameEndReason, TurnPhase from app.core.enums import GameEndReason, TurnPhase
from app.core.models.card import CardDefinition, CardInstance 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). stadium_owner_id: Player who played the current stadium (public).
forced_action: Current forced action, if any. forced_action: Current forced action, if any.
card_registry: Card definitions (needed to display cards). 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 game_id: str
@ -154,6 +156,9 @@ class VisibleGameState(BaseModel):
# Card definitions for display # Card definitions for display
card_registry: dict[str, CardDefinition] = Field(default_factory=dict) 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( def _create_visible_zone(
cards: list[CardInstance], 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_type=forced_action_type,
forced_action_reason=forced_action_reason, forced_action_reason=forced_action_reason,
card_registry=game.card_registry, 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_type=forced_action_type,
forced_action_reason=forced_action_reason, forced_action_reason=forced_action_reason,
card_registry=game.card_registry, card_registry=game.card_registry,
rules_config=game.rules,
) )

View File

@ -582,3 +582,377 @@ describe('getAllZones', () => {
expect(allZones.find(z => z.label === 'oppBench[0]')).toBeDefined() 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)
}
})
})
// =============================================================================
// 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)
})
})
// =============================================================================
// 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()
})
})

View File

@ -53,12 +53,30 @@ export const EDGE_PADDING = 16
/** Number of bench slots */ /** Number of bench slots */
export const BENCH_SIZE = 5 export const BENCH_SIZE = 5
/** Number of prize slots */ /** Default number of prize slots (for classic Pokemon TCG mode) */
export const PRIZE_SIZE = 6 export const PRIZE_SIZE = 6
/** Portrait mode threshold - below this aspect ratio we use portrait layout */ /** Portrait mode threshold - below this aspect ratio we use portrait layout */
export const PORTRAIT_THRESHOLD = 0.9 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
/** Number of bench slots. Defaults to 5. */
benchSize?: number
/** Whether to show energy deck zone (Pokemon Pocket style). Defaults to true. */
energyDeckEnabled?: boolean
}
// ============================================================================= // =============================================================================
// Orientation Detection // Orientation Detection
// ============================================================================= // =============================================================================
@ -103,19 +121,28 @@ export function getScaleFactor(width: number, height: number): number {
* *
* @param width - Canvas width in pixels * @param width - Canvas width in pixels
* @param height - Canvas height 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 * @returns Complete board layout with all zone positions
* *
* @example * @example
* ```ts * ```ts
* const layout = calculateLayout(1920, 1080) * const layout = calculateLayout(1920, 1080)
* console.log(layout.myActive) // { x: 960, y: 700, width: 120, height: 168 } * 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)) { if (isPortrait(width, height)) {
return calculatePortraitLayout(width, height) return calculatePortraitLayout(width, height, options)
} }
return calculateLandscapeLayout(width, height) return calculateLandscapeLayout(width, height, options)
} }
/** /**
@ -129,11 +156,20 @@ export function calculateLayout(width: number, height: number): BoardLayout {
* *
* @param width - Canvas width * @param width - Canvas width
* @param height - Canvas height * @param height - Canvas height
* @param options - Optional layout customization
* @returns Board layout for landscape orientation * @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 benchSize = options?.benchSize ?? BENCH_SIZE
const energyDeckEnabled = options?.energyDeckEnabled ?? true
const scale = getScaleFactor(width, height) const scale = getScaleFactor(width, height)
// Scale card dimensions // Scale card dimensions
const cardW = Math.round(CARD_WIDTH_MEDIUM * scale) const cardW = Math.round(CARD_WIDTH_MEDIUM * scale)
const cardH = Math.round(CARD_HEIGHT_MEDIUM * scale) const cardH = Math.round(CARD_HEIGHT_MEDIUM * scale)
@ -141,7 +177,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale) const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale)
const padding = Math.round(ZONE_PADDING * scale) const padding = Math.round(ZONE_PADDING * scale)
const edgePad = Math.round(EDGE_PADDING * scale) const edgePad = Math.round(EDGE_PADDING * scale)
// Vertical layout divisions // Vertical layout divisions
const centerY = height / 2 const centerY = height / 2
const playerY = centerY + (height * 0.18) // Player active zone const playerY = centerY + (height * 0.18) // Player active zone
@ -149,11 +185,11 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
const benchOffsetY = cardH + padding // Bench below/above active (full card height + padding) const benchOffsetY = cardH + padding // Bench below/above active (full card height + padding)
const handY = height - edgePad - cardH / 2 // Hand at bottom const handY = height - edgePad - cardH / 2 // Hand at bottom
const oppHandY = edgePad + cardH / 2 // Opponent hand at top const oppHandY = edgePad + cardH / 2 // Opponent hand at top
// Horizontal layout - center of screen // Horizontal layout - center of screen
const centerX = width / 2 const centerX = width / 2
const benchSpacing = cardW + padding const benchSpacing = cardW + padding
const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing const totalBenchWidth = (benchSize - 1) * benchSpacing
// Deck/discard positions (right side) // Deck/discard positions (right side)
const deckX = width - edgePad - cardW / 2 - padding const deckX = width - edgePad - cardW / 2 - padding
@ -178,7 +214,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
rotation: 0, rotation: 0,
}, },
myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ myBench: Array.from({ length: benchSize }, (_, i) => ({
x: centerX - totalBenchWidth / 2 + i * benchSpacing, x: centerX - totalBenchWidth / 2 + i * benchSpacing,
y: playerY + benchOffsetY, y: playerY + benchOffsetY,
width: cardW, width: cardW,
@ -210,24 +246,29 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
rotation: 0, rotation: 0,
}, },
myPrizes: calculatePrizePositions( myPrizes: usePrizeCards
prizesStartX, ? calculatePrizePositions(
playerY - cardH / 2, prizesStartX,
smallCardW, playerY - cardH / 2,
smallCardH, smallCardW,
prizesColSpacing, smallCardH,
prizesRowSpacing, prizesColSpacing,
false // Not flipped for player prizesRowSpacing,
), false, // Not flipped for player
prizeCount
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) // Opponent zones (top of screen, mirrored)
oppActive: { oppActive: {
x: centerX, x: centerX,
@ -237,7 +278,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
rotation: Math.PI, // Upside down 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 x: centerX + totalBenchWidth / 2 - i * benchSpacing, // Reversed order
y: oppY - benchOffsetY, // Above active for opponent y: oppY - benchOffsetY, // Above active for opponent
width: cardW, width: cardW,
@ -269,25 +310,30 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
rotation: Math.PI, rotation: Math.PI,
}, },
oppPrizes: calculatePrizePositions( oppPrizes: usePrizeCards
width - prizesStartX - prizesColSpacing, // Right side for opponent ? calculatePrizePositions(
oppY + cardH / 2, width - prizesStartX - prizesColSpacing, // Right side for opponent
smallCardW, oppY + cardH / 2,
smallCardH, smallCardW,
-prizesColSpacing, // Reversed column spacing smallCardH,
-prizesRowSpacing, // Reversed row spacing -prizesColSpacing, // Reversed column spacing
true // Flipped for opponent -prizesRowSpacing, // Reversed row spacing
), true, // Flipped for opponent
prizeCount
)
: [],
oppEnergyZone: { oppEnergyZone: energyDeckEnabled
x: centerX - energyOffsetX, ? {
y: oppY, x: centerX - energyOffsetX,
width: cardW, y: oppY,
height: cardH, width: cardW,
rotation: Math.PI, height: cardH,
}, rotation: Math.PI,
}
: null,
} }
return layout return layout
} }
@ -302,11 +348,20 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
* *
* @param width - Canvas width * @param width - Canvas width
* @param height - Canvas height * @param height - Canvas height
* @param options - Optional layout customization
* @returns Board layout for portrait orientation * @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 benchSize = options?.benchSize ?? BENCH_SIZE
const energyDeckEnabled = options?.energyDeckEnabled ?? true
const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait const scale = getScaleFactor(width, height) * 0.85 // Slightly smaller for portrait
// Scale card dimensions - use smaller cards in portrait // Scale card dimensions - use smaller cards in portrait
const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2) const cardW = Math.round(CARD_WIDTH_SMALL * scale * 1.2)
const cardH = Math.round(CARD_HEIGHT_SMALL * scale * 1.2) const cardH = Math.round(CARD_HEIGHT_SMALL * scale * 1.2)
@ -314,7 +369,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale * 0.9) const smallCardH = Math.round(CARD_HEIGHT_SMALL * scale * 0.9)
const padding = Math.round(ZONE_PADDING * scale * 0.8) const padding = Math.round(ZONE_PADDING * scale * 0.8)
const edgePad = Math.round(EDGE_PADDING * scale * 0.8) const edgePad = Math.round(EDGE_PADDING * scale * 0.8)
// Vertical layout divisions // Vertical layout divisions
const centerY = height / 2 const centerY = height / 2
const playerY = centerY + (height * 0.15) const playerY = centerY + (height * 0.15)
@ -322,11 +377,11 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
const benchOffsetY = cardH + padding // Full card height + padding const benchOffsetY = cardH + padding // Full card height + padding
const handY = height - edgePad - cardH / 2 const handY = height - edgePad - cardH / 2
const oppHandY = edgePad + cardH / 2 const oppHandY = edgePad + cardH / 2
// Horizontal layout - tighter spacing for portrait // Horizontal layout - tighter spacing for portrait
const centerX = width / 2 const centerX = width / 2
const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / BENCH_SIZE) const benchSpacing = Math.min(cardW + padding, (width - edgePad * 2) / benchSize)
const totalBenchWidth = (BENCH_SIZE - 1) * benchSpacing const totalBenchWidth = (benchSize - 1) * benchSpacing
// Deck/discard positions (right side, more compact) // Deck/discard positions (right side, more compact)
// Use reduced size (0.8) for these zones // Use reduced size (0.8) for these zones
@ -352,7 +407,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
rotation: 0, rotation: 0,
}, },
myBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ myBench: Array.from({ length: benchSize }, (_, i) => ({
x: centerX - totalBenchWidth / 2 + i * benchSpacing, x: centerX - totalBenchWidth / 2 + i * benchSpacing,
y: playerY + benchOffsetY, y: playerY + benchOffsetY,
width: cardW * 0.9, // Slightly smaller bench cards width: cardW * 0.9, // Slightly smaller bench cards
@ -384,23 +439,28 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
rotation: 0, rotation: 0,
}, },
myPrizes: calculatePortraitPrizePositions( myPrizes: usePrizeCards
prizesX, ? calculatePortraitPrizePositions(
playerY, prizesX,
smallCardW, playerY,
smallCardH, smallCardW,
prizesRowSpacing, smallCardH,
false prizesRowSpacing,
), false,
prizeCount
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) // Opponent zones (top, mirrored)
oppActive: { oppActive: {
x: centerX, x: centerX,
@ -410,7 +470,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
rotation: Math.PI, rotation: Math.PI,
}, },
oppBench: Array.from({ length: BENCH_SIZE }, (_, i) => ({ oppBench: Array.from({ length: benchSize }, (_, i) => ({
x: centerX + totalBenchWidth / 2 - i * benchSpacing, x: centerX + totalBenchWidth / 2 - i * benchSpacing,
y: oppY - benchOffsetY, y: oppY - benchOffsetY,
width: cardW * 0.9, width: cardW * 0.9,
@ -442,29 +502,34 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
rotation: Math.PI, rotation: Math.PI,
}, },
oppPrizes: calculatePortraitPrizePositions( oppPrizes: usePrizeCards
width - prizesX, ? calculatePortraitPrizePositions(
oppY, width - prizesX,
smallCardW, oppY,
smallCardH, smallCardW,
-prizesRowSpacing, smallCardH,
true -prizesRowSpacing,
), true,
prizeCount
)
: [],
oppEnergyZone: { oppEnergyZone: energyDeckEnabled
x: centerX - energyOffsetX, ? {
y: oppY, x: centerX - energyOffsetX,
width: cardW * 0.8, y: oppY,
height: cardH * 0.8, width: cardW * 0.8,
rotation: Math.PI, height: cardH * 0.8,
}, rotation: Math.PI,
}
: null,
} }
return layout return layout
} }
/** /**
* Calculate prize card positions in a 2x3 grid. * Calculate prize card positions in a 2xN grid.
* *
* @param startX - X position of first column * @param startX - X position of first column
* @param startY - Y position of first row center * @param startY - Y position of first row center
@ -473,7 +538,8 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
* @param colSpacing - Horizontal spacing between columns * @param colSpacing - Horizontal spacing between columns
* @param rowSpacing - Vertical spacing between rows * @param rowSpacing - Vertical spacing between rows
* @param flipped - Whether cards are upside down (opponent) * @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( function calculatePrizePositions(
startX: number, startX: number,
@ -482,13 +548,17 @@ function calculatePrizePositions(
cardH: number, cardH: number,
colSpacing: number, colSpacing: number,
rowSpacing: number, rowSpacing: number,
flipped: boolean flipped: boolean,
count: number = PRIZE_SIZE
): ZonePosition[] { ): ZonePosition[] {
const prizes: ZonePosition[] = [] const prizes: ZonePosition[] = []
const cols = 2
for (let row = 0; row < 3; row++) { const rows = Math.ceil(count / cols)
for (let col = 0; col < 2; col++) {
const index = row * 2 + col 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({ prizes.push({
x: startX + col * colSpacing, x: startX + col * colSpacing,
y: startY + row * rowSpacing, y: startY + row * rowSpacing,
@ -498,7 +568,7 @@ function calculatePrizePositions(
}) })
} }
} }
return prizes return prizes
} }
@ -514,7 +584,8 @@ function calculatePrizePositions(
* @param cardH - Card height * @param cardH - Card height
* @param rowSpacing - Vertical spacing (can be negative for overlap) * @param rowSpacing - Vertical spacing (can be negative for overlap)
* @param flipped - Whether cards are upside down * @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( function calculatePortraitPrizePositions(
x: number, x: number,
@ -522,12 +593,14 @@ function calculatePortraitPrizePositions(
cardW: number, cardW: number,
cardH: number, cardH: number,
rowSpacing: number, rowSpacing: number,
flipped: boolean flipped: boolean,
count: number = PRIZE_SIZE
): ZonePosition[] { ): ZonePosition[] {
const prizes: ZonePosition[] = [] const prizes: ZonePosition[] = []
const startY = centerY - (2.5 * Math.abs(rowSpacing)) // Center the stack // Center the stack based on count
const startY = centerY - ((count - 1) / 2 * Math.abs(rowSpacing))
for (let i = 0; i < PRIZE_SIZE; i++) {
for (let i = 0; i < count; i++) {
prizes.push({ prizes.push({
x, x,
y: startY + i * Math.abs(rowSpacing), y: startY + i * Math.abs(rowSpacing),
@ -536,7 +609,7 @@ function calculatePortraitPrizePositions(
rotation: flipped ? Math.PI : 0, rotation: flipped ? Math.PI : 0,
}) })
} }
return prizes return prizes
} }
@ -632,7 +705,7 @@ export function getAllZones(
layout: BoardLayout layout: BoardLayout
): Array<{ label: string; zone: ZonePosition }> { ): Array<{ label: string; zone: ZonePosition }> {
const zones: Array<{ label: string; zone: ZonePosition }> = [] const zones: Array<{ label: string; zone: ZonePosition }> = []
// My zones // My zones
zones.push({ label: 'myActive', zone: layout.myActive }) zones.push({ label: 'myActive', zone: layout.myActive })
layout.myBench.forEach((z, i) => zones.push({ label: `myBench[${i}]`, zone: z })) layout.myBench.forEach((z, i) => zones.push({ label: `myBench[${i}]`, zone: z }))
@ -640,8 +713,10 @@ export function getAllZones(
zones.push({ label: 'myDeck', zone: layout.myDeck }) zones.push({ label: 'myDeck', zone: layout.myDeck })
zones.push({ label: 'myDiscard', zone: layout.myDiscard }) zones.push({ label: 'myDiscard', zone: layout.myDiscard })
layout.myPrizes.forEach((z, i) => zones.push({ label: `myPrizes[${i}]`, zone: z })) 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 // Opponent zones
zones.push({ label: 'oppActive', zone: layout.oppActive }) zones.push({ label: 'oppActive', zone: layout.oppActive })
layout.oppBench.forEach((z, i) => zones.push({ label: `oppBench[${i}]`, zone: z })) layout.oppBench.forEach((z, i) => zones.push({ label: `oppBench[${i}]`, zone: z }))
@ -649,7 +724,9 @@ export function getAllZones(
zones.push({ label: 'oppDeck', zone: layout.oppDeck }) zones.push({ label: 'oppDeck', zone: layout.oppDeck })
zones.push({ label: 'oppDiscard', zone: layout.oppDiscard }) zones.push({ label: 'oppDiscard', zone: layout.oppDiscard })
layout.oppPrizes.forEach((z, i) => zones.push({ label: `oppPrizes[${i}]`, zone: z })) 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 return zones
} }

View File

@ -29,7 +29,7 @@ import type {
} from '@/types/game' } from '@/types/game'
import type { BoardLayout } from '@/types/phaser' import type { BoardLayout } from '@/types/phaser'
import { getMyPlayerState, getOpponentState } from '@/types/game' import { getMyPlayerState, getOpponentState } from '@/types/game'
import { calculateLayout } from '../layout' import { calculateLayout, type LayoutOptions } from '../layout'
import { Card } from '../objects/Card' import { Card } from '../objects/Card'
import { ActiveZone } from '../objects/ActiveZone' import { ActiveZone } from '../objects/ActiveZone'
import { BenchZone } from '../objects/BenchZone' import { BenchZone } from '../objects/BenchZone'
@ -51,8 +51,8 @@ interface PlayerZones {
hand: HandZone hand: HandZone
deck: PileZone deck: PileZone
discard: PileZone discard: PileZone
prizes: PrizeZone 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)
} }
/** /**
@ -128,8 +128,16 @@ export class StateRenderer {
// Get canvas dimensions // Get canvas dimensions
const { width, height } = this.scene.cameras.main 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,
benchSize: state.rules_config?.bench.max_size ?? 5,
energyDeckEnabled: state.rules_config?.deck.energy_deck_enabled ?? true,
}
// Calculate layout // Calculate layout
this.layout = calculateLayout(width, height) this.layout = calculateLayout(width, height, layoutOptions)
// Create zones if needed // Create zones if needed
if (!this.zones) { if (!this.zones) {
@ -258,13 +266,17 @@ export class StateRenderer {
return return
} }
// 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 // Create main container
this.container = this.scene.add.container(0, 0) this.container = this.scene.add.container(0, 0)
// Create player zones // Create player zones
this.zones = { this.zones = {
my: this.createPlayerZones(myState.player_id, false), my: this.createPlayerZones(myState.player_id, false, usePrizeCards, energyDeckEnabled),
opp: this.createPlayerZones(oppState.player_id, true), opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled),
} }
} }
@ -273,9 +285,16 @@ export class StateRenderer {
* *
* @param playerId - The player's ID * @param playerId - The player's ID
* @param isOpponent - Whether this is the opponent (affects layout orientation) * @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 * @returns PlayerZones object
*/ */
private createPlayerZones(playerId: string, isOpponent: boolean): PlayerZones { private createPlayerZones(
playerId: string,
isOpponent: boolean,
usePrizeCards: boolean,
energyDeckEnabled: boolean
): PlayerZones {
// Create zones at origin - positions updated in updateZonePositions() // Create zones at origin - positions updated in updateZonePositions()
const zones: PlayerZones = { const zones: PlayerZones = {
active: new ActiveZone(this.scene, 0, 0, playerId), active: new ActiveZone(this.scene, 0, 0, playerId),
@ -283,8 +302,8 @@ export class StateRenderer {
hand: new HandZone(this.scene, 0, 0, playerId), hand: new HandZone(this.scene, 0, 0, playerId),
deck: new PileZone(this.scene, 0, 0, playerId, 'deck'), deck: new PileZone(this.scene, 0, 0, playerId, 'deck'),
discard: new PileZone(this.scene, 0, 0, playerId, 'discard'), 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 energyZone: energyDeckEnabled ? new PileZone(this.scene, 0, 0, playerId, 'deck') : null,
} }
// Add all zones to container // Add all zones to container
@ -294,8 +313,12 @@ export class StateRenderer {
this.container.add(zones.hand) this.container.add(zones.hand)
this.container.add(zones.deck) this.container.add(zones.deck)
this.container.add(zones.discard) this.container.add(zones.discard)
this.container.add(zones.prizes) if (zones.prizes) {
this.container.add(zones.energyZone) this.container.add(zones.prizes)
}
if (zones.energyZone) {
this.container.add(zones.energyZone)
}
} }
return zones return zones
@ -312,8 +335,8 @@ export class StateRenderer {
zones.hand.destroy() zones.hand.destroy()
zones.deck.destroy() zones.deck.destroy()
zones.discard.destroy() zones.discard.destroy()
zones.prizes.destroy() zones.prizes?.destroy()
zones.energyZone.destroy() zones.energyZone?.destroy()
} }
// =========================================================================== // ===========================================================================
@ -331,7 +354,9 @@ export class StateRenderer {
this.zones.my.hand.applyLayout(this.layout.myHand) this.zones.my.hand.applyLayout(this.layout.myHand)
this.zones.my.deck.applyLayout(this.layout.myDeck) this.zones.my.deck.applyLayout(this.layout.myDeck)
this.zones.my.discard.applyLayout(this.layout.myDiscard) 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 // Bench zone - use the center position and full width
const benchLayout = this.layout.myBench const benchLayout = this.layout.myBench
@ -344,8 +369,8 @@ export class StateRenderer {
this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height) this.zones.my.bench.setZoneDimensions(totalWidth, benchLayout[0].height)
} }
// Prizes - use the first prize position for the zone // Prizes - use the first prize position for the zone (only if using prize cards)
if (this.layout.myPrizes.length > 0) { if (this.zones.my.prizes && this.layout.myPrizes.length > 0) {
const prizePositions = this.layout.myPrizes const prizePositions = this.layout.myPrizes
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2))
@ -362,7 +387,9 @@ export class StateRenderer {
this.zones.opp.hand.applyLayout(this.layout.oppHand) this.zones.opp.hand.applyLayout(this.layout.oppHand)
this.zones.opp.deck.applyLayout(this.layout.oppDeck) this.zones.opp.deck.applyLayout(this.layout.oppDeck)
this.zones.opp.discard.applyLayout(this.layout.oppDiscard) 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 // Opponent bench
const oppBenchLayout = this.layout.oppBench const oppBenchLayout = this.layout.oppBench
@ -375,8 +402,8 @@ export class StateRenderer {
this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height) this.zones.opp.bench.setZoneDimensions(totalWidth, oppBenchLayout[0].height)
} }
// Opponent prizes // Opponent prizes (only if using prize cards)
if (this.layout.oppPrizes.length > 0) { if (this.zones.opp.prizes && this.layout.oppPrizes.length > 0) {
const prizePositions = this.layout.oppPrizes const prizePositions = this.layout.oppPrizes
const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2)) const minX = Math.min(...prizePositions.map(p => p.x - p.width / 2))
const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2)) const maxX = Math.max(...prizePositions.map(p => p.x + p.width / 2))
@ -449,20 +476,22 @@ export class StateRenderer {
true // always visible true // always visible
) )
// Update prizes // Update prizes (only if using prize cards)
// Viewer sees their own prize count (cards still hidden) // Viewer sees their own prize count (cards still hidden)
// Opponent just sees count // Opponent just sees count
zones.prizes.setRemainingCount(playerState.prizes_count) zones.prizes?.setRemainingCount(playerState.prizes_count)
// Update energy zone // Update energy zone (only if energy deck is enabled)
this.updateZone( if (zones.energyZone) {
zones.energyZone, this.updateZone(
playerState.energy_zone.cards, zones.energyZone,
registry, playerState.energy_zone.cards,
playerId, registry,
'energy_zone', playerId,
true // always visible 'energy_zone',
) true // always visible
)
}
} }
/** /**

View File

@ -14,6 +14,7 @@ import type {
CardDefinition, CardDefinition,
TurnPhase, TurnPhase,
Action, Action,
RulesConfig,
} from '@/types' } from '@/types'
import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } from '@/types' import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } from '@/types'
@ -216,6 +217,35 @@ export const useGameStore = defineStore('game', () => {
forcedAction.value?.player === gameState.value?.viewer_id forcedAction.value?.player === gameState.value?.viewer_id
) )
// ---------------------------------------------------------------------------
// Computed - Rules Config
// ---------------------------------------------------------------------------
/** Current game rules configuration */
const rulesConfig = computed<RulesConfig | null>(() =>
gameState.value?.rules_config ?? null
)
/** Whether this game uses classic prize cards (vs points system) */
const usePrizeCards = computed<boolean>(() =>
rulesConfig.value?.prizes.use_prize_cards ?? false
)
/** Number of prizes/points needed to win */
const prizeCount = computed<number>(() =>
rulesConfig.value?.prizes.count ?? 4
)
/** Maximum number of Pokemon on the bench */
const benchSize = computed<number>(() =>
rulesConfig.value?.bench.max_size ?? 5
)
/** Whether this game uses an energy deck zone (Pokemon Pocket style) */
const energyDeckEnabled = computed<boolean>(() =>
rulesConfig.value?.deck.energy_deck_enabled ?? true
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Computed - Card Lookup // Computed - Card Lookup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -337,6 +367,13 @@ export const useGameStore = defineStore('game', () => {
hasForcedAction, hasForcedAction,
isConnected, isConnected,
// Rules config
rulesConfig,
usePrizeCards,
prizeCount,
benchSize,
energyDeckEnabled,
// Card lookup // Card lookup
lookupCard, lookupCard,

View File

@ -6,7 +6,7 @@
* and sends this structure via WebSocket. * and sends this structure via WebSocket.
*/ */
import type { ModifierMode } from './rules' import type { ModifierMode, RulesConfig } from './rules'
// ============================================================================= // =============================================================================
// Enums and Constants // Enums and Constants
@ -407,6 +407,9 @@ export interface VisibleGameState {
/** Card definitions for display (definition_id -> CardDefinition) */ /** Card definitions for display (definition_id -> CardDefinition) */
card_registry: Record<string, CardDefinition> card_registry: Record<string, CardDefinition>
/** Rules configuration for UI rendering decisions (e.g., prize cards vs points) */
rules_config?: RulesConfig
} }
// ============================================================================= // =============================================================================

View File

@ -174,21 +174,21 @@ export interface ZonePosition {
export interface BoardLayout { export interface BoardLayout {
// My zones (bottom of screen) // My zones (bottom of screen)
myActive: ZonePosition myActive: ZonePosition
myBench: ZonePosition[] // 5 slots myBench: ZonePosition[] // configurable slots (default 5)
myHand: ZonePosition myHand: ZonePosition
myDeck: ZonePosition myDeck: ZonePosition
myDiscard: ZonePosition myDiscard: ZonePosition
myPrizes: ZonePosition[] // 6 slots myPrizes: ZonePosition[] // configurable slots (default 6), empty when using points
myEnergyZone: ZonePosition myEnergyZone: ZonePosition | null // null when energy deck is disabled
// Opponent zones (top of screen, mirrored) // Opponent zones (top of screen, mirrored)
oppActive: ZonePosition oppActive: ZonePosition
oppBench: ZonePosition[] // 5 slots oppBench: ZonePosition[] // configurable slots (default 5)
oppHand: ZonePosition oppHand: ZonePosition
oppDeck: ZonePosition oppDeck: ZonePosition
oppDiscard: ZonePosition oppDiscard: ZonePosition
oppPrizes: ZonePosition[] // 6 slots oppPrizes: ZonePosition[] // configurable slots (default 6), empty when using points
oppEnergyZone: ZonePosition oppEnergyZone: ZonePosition | null // null when energy deck is disabled
} }
/** /**