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 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,
)

View File

@ -582,3 +582,377 @@ 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)
}
})
})
// =============================================================================
// 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 */
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
/** 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
// =============================================================================
@ -103,19 +121,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 +156,18 @@ 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 benchSize = options?.benchSize ?? BENCH_SIZE
const energyDeckEnabled = options?.energyDeckEnabled ?? true
const scale = getScaleFactor(width, height)
// Scale card dimensions
@ -153,7 +189,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
// 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
@ -178,7 +214,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
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,
@ -210,23 +246,28 @@ 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,
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: {
@ -237,7 +278,7 @@ function calculateLandscapeLayout(width: number, height: number): BoardLayout {
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,
@ -269,23 +310,28 @@ 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,
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
@ -302,9 +348,18 @@ 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 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
@ -325,8 +380,8 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
// 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
@ -352,7 +407,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
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
@ -384,22 +439,27 @@ 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,
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: {
@ -410,7 +470,7 @@ function calculatePortraitLayout(width: number, height: number): BoardLayout {
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,
@ -442,29 +502,34 @@ 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,
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
}
/**
* 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 +538,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 +548,17 @@ function calculatePrizePositions(
cardH: number,
colSpacing: number,
rowSpacing: number,
flipped: boolean
flipped: boolean,
count: number = PRIZE_SIZE
): ZonePosition[] {
const prizes: ZonePosition[] = []
const cols = 2
const rows = Math.ceil(count / cols)
for (let row = 0; row < 3; row++) {
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({
x: startX + col * colSpacing,
y: startY + row * rowSpacing,
@ -514,7 +584,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 +593,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
// 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({
x,
y: startY + i * Math.abs(rowSpacing),
@ -640,7 +713,9 @@ 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 })
@ -649,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
}

View File

@ -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,8 +51,8 @@ interface PlayerZones {
hand: HandZone
deck: PileZone
discard: PileZone
prizes: PrizeZone
energyZone: PileZone
prizes: PrizeZone | null // null when using points system instead of prize cards
energyZone: PileZone | null // null when energy deck is disabled (classic mode)
}
/**
@ -128,8 +128,16 @@ 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,
benchSize: state.rules_config?.bench.max_size ?? 5,
energyDeckEnabled: state.rules_config?.deck.energy_deck_enabled ?? true,
}
// Calculate layout
this.layout = calculateLayout(width, height)
this.layout = calculateLayout(width, height, layoutOptions)
// Create zones if needed
if (!this.zones) {
@ -258,13 +266,17 @@ export class StateRenderer {
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
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, energyDeckEnabled),
opp: this.createPlayerZones(oppState.player_id, true, usePrizeCards, energyDeckEnabled),
}
}
@ -273,9 +285,16 @@ 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): PlayerZones {
private createPlayerZones(
playerId: string,
isOpponent: boolean,
usePrizeCards: boolean,
energyDeckEnabled: boolean
): PlayerZones {
// Create zones at origin - positions updated in updateZonePositions()
const zones: PlayerZones = {
active: new ActiveZone(this.scene, 0, 0, playerId),
@ -283,8 +302,8 @@ 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),
energyZone: new PileZone(this.scene, 0, 0, playerId, 'deck'), // Energy deck uses pile zone
prizes: usePrizeCards ? new PrizeZone(this.scene, 0, 0, playerId) : null,
energyZone: energyDeckEnabled ? new PileZone(this.scene, 0, 0, playerId, 'deck') : null,
}
// Add all zones to container
@ -294,8 +313,12 @@ export class StateRenderer {
this.container.add(zones.hand)
this.container.add(zones.deck)
this.container.add(zones.discard)
this.container.add(zones.prizes)
this.container.add(zones.energyZone)
if (zones.prizes) {
this.container.add(zones.prizes)
}
if (zones.energyZone) {
this.container.add(zones.energyZone)
}
}
return zones
@ -312,8 +335,8 @@ export class StateRenderer {
zones.hand.destroy()
zones.deck.destroy()
zones.discard.destroy()
zones.prizes.destroy()
zones.energyZone.destroy()
zones.prizes?.destroy()
zones.energyZone?.destroy()
}
// ===========================================================================
@ -331,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
@ -344,8 +369,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))
@ -362,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
@ -375,8 +402,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,20 +476,22 @@ 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(
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
)
}
}
/**

View File

@ -14,6 +14,7 @@ import type {
CardDefinition,
TurnPhase,
Action,
RulesConfig,
} 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
)
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@ -337,6 +367,13 @@ export const useGameStore = defineStore('game', () => {
hasForcedAction,
isConnected,
// Rules config
rulesConfig,
usePrizeCards,
prizeCount,
benchSize,
energyDeckEnabled,
// Card lookup
lookupCard,

View File

@ -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<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 {
// 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
}
/**