feat(arcade): update matching game and manifest schema

Updates to game break configuration and manifest validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-13 10:49:13 -06:00
parent b197d8f035
commit 4f812d4ad0
7 changed files with 1238 additions and 12 deletions

View File

@ -483,7 +483,13 @@ function InteractiveMatchingGame({ theme = 'light' }: { theme?: 'light' | 'dark'
</div>
{/* Timer simulation */}
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.25rem' })}>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<span className={css({ fontSize: '0.625rem', color: 'gray.400' })}>
Timer: {Math.floor(elapsedSeconds / 60)}:
{(elapsedSeconds % 60).toString().padStart(2, '0')} elapsed
@ -611,7 +617,13 @@ export const Documentation: Story = {
layout issues.
</p>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Full Page Context Stories
</h2>
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
@ -637,7 +649,13 @@ export const Documentation: Story = {
</li>
</ul>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Isolated Stories
</h2>
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
@ -645,7 +663,13 @@ export const Documentation: Story = {
Compare these with Full Context stories to debug layout issues caused by nav positioning.
</p>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Timer States
</h2>
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
@ -672,7 +696,13 @@ export const Documentation: Story = {
</li>
</ul>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Architecture
</h2>
<ul
@ -700,7 +730,13 @@ export const Documentation: Story = {
</li>
</ul>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Game Phases
</h2>
<ul

View File

@ -418,6 +418,15 @@ export function MatchingProvider({ children }: { children: ReactNode }) {
return false
}
// Can't flip if session state hasn't been received yet (currentPlayer not set)
// This prevents race condition where user clicks before server state arrives
if (!state.currentPlayer) {
console.log(
'[RoomProvider][canFlipCard] Blocked: waiting for session state (currentPlayer empty)'
)
return false
}
const card = gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched')

File diff suppressed because it is too large Load Diff

View File

@ -405,7 +405,9 @@ export function SetupPhase() {
>
<span>🧮</span>
<span
className={css({ fontSize: isCompact ? '16px' : { base: '16px', md: '20px' } })}
className={css({
fontSize: isCompact ? '16px' : { base: '16px', md: '20px' },
})}
>
</span>
@ -464,7 +466,9 @@ export function SetupPhase() {
>
<span>🤝</span>
<span
className={css({ fontSize: isCompact ? '16px' : { base: '16px', md: '20px' } })}
className={css({
fontSize: isCompact ? '16px' : { base: '16px', md: '20px' },
})}
>
</span>
@ -599,7 +603,10 @@ export function SetupPhase() {
<>
<div
data-element="button-level"
className={css({ fontSize: '14px', fontWeight: 'bold' })}
className={css({
fontSize: '14px',
fontWeight: 'bold',
})}
>
{difficultyInfo[difficulty].label}
</div>

View File

@ -30,6 +30,12 @@ export interface MatchingConfig extends GameConfig {
gameType: GameType
difficulty: Difficulty
turnTimer: number
/**
* Skip the setup phase and start directly in playing phase.
* When true, getInitialState() will generate cards immediately
* and set gamePhase to 'playing' instead of 'setup'.
*/
skipSetupPhase?: boolean
}
// ============================================================================

View File

@ -37,9 +37,21 @@ const manifest: GameManifest = {
minDurationMinutes: 1,
maxDurationMinutes: 5,
difficultyPresets: {
easy: { selectedCount: 5, displayTime: 2.5, selectedDifficulty: 'beginner' },
medium: { selectedCount: 5, displayTime: 2.0, selectedDifficulty: 'easy' },
hard: { selectedCount: 8, displayTime: 1.5, selectedDifficulty: 'medium' },
easy: {
selectedCount: 5,
displayTime: 2.5,
selectedDifficulty: 'beginner',
},
medium: {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
},
hard: {
selectedCount: 8,
displayTime: 1.5,
selectedDifficulty: 'medium',
},
},
},
}

View File

@ -49,6 +49,49 @@ export const PracticeBreakConfigSchema = z
})
.describe('Configuration for practice break behavior')
/**
* Scoreboard category for cross-game comparison
*/
export const ScoreboardCategorySchema = z.enum([
'puzzle',
'memory',
'speed',
'strategy',
'geography',
])
/**
* Schema for game results configuration.
* Defines how a game reports results for display and scoreboard tracking.
*/
export const GameResultsConfigSchema = z
.object({
/**
* Whether this game supports results reporting.
* Games that support this should implement getResultsReport() in their validator.
*/
supportsResults: z.boolean(),
/**
* How long to show results screen (ms).
* Default is 5000ms (5 seconds).
*/
resultsDisplayDurationMs: z.number().min(1000).optional().default(5000),
/**
* Custom component name for results display.
* If not specified, the default GameBreakResultsScreen is used.
*/
customResultsComponent: z.string().optional(),
/**
* Category for universal scoreboard.
* Used for grouping and comparing scores across games.
*/
scoreboardCategory: ScoreboardCategorySchema.optional(),
})
.describe('Configuration for game results reporting')
/**
* Schema for game manifest (game.yaml)
*/
@ -84,12 +127,17 @@ export const GameManifestSchema = z.object({
'Configuration for practice break behavior including suggested defaults, ' +
'locked fields, duration constraints, and difficulty presets.'
),
resultsConfig: GameResultsConfigSchema.optional().describe(
'Configuration for game results reporting including display duration, ' +
'scoreboard category, and custom component options.'
),
})
/**
* Inferred TypeScript types from schemas
*/
export type PracticeBreakConfig = z.infer<typeof PracticeBreakConfigSchema>
export type GameResultsConfig = z.infer<typeof GameResultsConfigSchema>
export type GameManifest = z.infer<typeof GameManifestSchema>
/**