diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9bba293c..9ecc7451 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -48,6 +48,7 @@ const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEd const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas")); const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground")); +const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); import LogsPage from "./components/logging/LogsPage"; const queryClient = new QueryClient(); @@ -143,6 +144,9 @@ const AppWrapper = () => { {/* Logs */} } /> + {/* Apps */} + Loading...}>} /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} Loading...}>} /> diff --git a/packages/ui/src/apps/tetris/LearningLog.tsx b/packages/ui/src/apps/tetris/LearningLog.tsx new file mode 100644 index 00000000..c445add0 --- /dev/null +++ b/packages/ui/src/apps/tetris/LearningLog.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { AIWeights } from './aiPlayer'; + +interface WeightChange { + timestamp: number; + gameNumber: number; + score: number; + lines: number; + oldWeights: AIWeights; + newWeights: AIWeights; +} + +interface LearningLogProps { + changes: WeightChange[]; +} + +export const LearningLog: React.FC = ({ changes }) => { + if (changes.length === 0) { + return ( +
+

Learning Log

+

No weight changes yet. Play some games in Neural mode!

+
+ ); + } + + const calculateWeightDelta = (oldVal: number, newVal: number): { delta: number; percent: number } => { + const delta = newVal - oldVal; + const percent = oldVal !== 0 ? (delta / oldVal) * 100 : 0; + return { delta, percent }; + }; + + const getSignificantChanges = (change: WeightChange) => { + const keys = Object.keys(change.oldWeights) as (keyof AIWeights)[]; + const significant = keys + .map(key => { + const { delta, percent } = calculateWeightDelta(change.oldWeights[key], change.newWeights[key]); + return { key, delta, percent, newValue: change.newWeights[key] }; + }) + .filter(item => Math.abs(item.percent) > 5) // Only show changes > 5% + .sort((a, b) => Math.abs(b.percent) - Math.abs(a.percent)) + .slice(0, 3); // Top 3 changes + + return significant; + }; + + return ( +
+

Learning Log

+
+ {changes.slice().reverse().map((change, idx) => { + const significantChanges = getSignificantChanges(change); + const timeAgo = new Date(change.timestamp).toLocaleTimeString(); + + return ( +
+
+
+ Game #{change.gameNumber} + {timeAgo} +
+
+ Score: {change.score} | + Lines: {change.lines} +
+
+ + {significantChanges.length > 0 ? ( +
+

Top weight changes:

+ {significantChanges.map((item, i) => ( +
+ {item.key}: +
+ {item.newValue.toFixed(0)} + 0 ? 'text-green-400' : 'text-red-400'}`}> + {item.percent > 0 ? '+' : ''}{item.percent.toFixed(1)}% + +
+
+ ))} +
+ ) : ( +

Minor adjustments (<5% change)

+ )} +
+ ); + })} +
+
+ ); +}; diff --git a/packages/ui/src/apps/tetris/NeuralNetworkVisualizer.tsx b/packages/ui/src/apps/tetris/NeuralNetworkVisualizer.tsx new file mode 100644 index 00000000..98b1d801 --- /dev/null +++ b/packages/ui/src/apps/tetris/NeuralNetworkVisualizer.tsx @@ -0,0 +1,289 @@ +import React, { useRef, useEffect } from 'react'; +import { NeuralNetwork } from './neuralNetwork'; + +interface NeuralNetworkVisualizerProps { + network: NeuralNetwork; + currentInput?: number[]; + currentOutput?: number[]; +} + +export const NeuralNetworkVisualizer: React.FC = ({ + network, + currentInput, + currentOutput, +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [hoveredNeuron, setHoveredNeuron] = React.useState<{ + layer: number; + index: number; + x: number; + y: number; + name: string; + value: number; + } | null>(null); + + // Neuron names for input and output layers + const inputNames = [ + 'Lines Cleared', 'Contact', 'Holes', 'Holes Created', + 'Overhangs', 'Overhangs Created', 'Overhangs Filled', + 'Height Added', 'Wells', 'Well Depth²', + 'Bumpiness', 'Max Height', 'Avg Height' + ]; + + const outputNames = [ + 'lineCleared', 'contact', 'holes', 'holesCreated', + 'overhangs', 'overhangsCreated', 'overhangsFilled', + 'heightAdded', 'wells', 'wellDepthSquared', + 'bumpiness', 'maxHeight', 'avgHeight' + ]; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size + const width = canvas.width; + const height = canvas.height; + + // Clear canvas + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, width, height); + + // Get layer sizes + const layerSizes = [ + network.config.inputSize, + ...network.config.hiddenLayers, + network.config.outputSize, + ]; + + const numLayers = layerSizes.length; + const layerSpacing = width / (numLayers + 1); + const maxNeurons = Math.max(...layerSizes); + + // Calculate neuron positions + const neuronPositions: { x: number; y: number }[][] = []; + layerSizes.forEach((size, layerIdx) => { + const positions: { x: number; y: number }[] = []; + const x = layerSpacing * (layerIdx + 1); + const neuronSpacing = height / (size + 1); + + for (let i = 0; i < size; i++) { + const y = neuronSpacing * (i + 1); + positions.push({ x, y }); + } + neuronPositions.push(positions); + }); + + // Draw connections (weights) + for (let layer = 0; layer < numLayers - 1; layer++) { + const fromLayer = neuronPositions[layer]; + const toLayer = neuronPositions[layer + 1]; + const weights = network.weights[layer]; + + for (let i = 0; i < toLayer.length; i++) { + for (let j = 0; j < fromLayer.length; j++) { + const weight = weights.data[i][j]; + const from = fromLayer[j]; + const to = toLayer[i]; + + // Color based on weight value (red = negative, blue = positive) + const absWeight = Math.abs(weight); + const alpha = Math.min(absWeight * 0.5, 0.8); + const color = weight > 0 + ? `rgba(59, 130, 246, ${alpha})` // Blue for positive + : `rgba(239, 68, 68, ${alpha})`; // Red for negative + + ctx.strokeStyle = color; + ctx.lineWidth = Math.min(absWeight * 2, 3); + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + } + } + } + + // Draw neurons + layerSizes.forEach((size, layerIdx) => { + const positions = neuronPositions[layerIdx]; + + positions.forEach((pos, neuronIdx) => { + // Get activation value if available + let activation = 0.5; // Default neutral + if (layerIdx === 0 && currentInput) { + activation = currentInput[neuronIdx] || 0; + } else if (layerIdx === numLayers - 1 && currentOutput) { + activation = currentOutput[neuronIdx] || 0; + } + + // Draw neuron circle + const radius = 8; + + // Glow effect based on activation + const gradient = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, radius * 2); + const glowColor = activation > 0.5 + ? `rgba(34, 197, 94, ${activation})` // Green for high activation + : `rgba(148, 163, 184, ${activation * 0.5})`; // Gray for low activation + + gradient.addColorStop(0, glowColor); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, radius * 2, 0, Math.PI * 2); + ctx.fill(); + + // Draw neuron body + ctx.fillStyle = activation > 0.5 + ? `rgba(34, 197, 94, ${0.3 + activation * 0.7})` + : `rgba(100, 116, 139, ${0.3 + activation * 0.4})`; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); + ctx.fill(); + + // Draw neuron border + ctx.strokeStyle = activation > 0.5 + ? '#22c55e' + : '#64748b'; + ctx.lineWidth = 2; + ctx.stroke(); + }); + }); + + // Draw layer labels + ctx.fillStyle = '#94a3b8'; + ctx.font = '12px monospace'; + ctx.textAlign = 'center'; + + const labels = ['Input', ...network.config.hiddenLayers.map((_, i) => `Hidden ${i + 1}`), 'Output']; + labels.forEach((label, idx) => { + const x = layerSpacing * (idx + 1); + ctx.fillText(label, x, 20); + ctx.fillText(`(${layerSizes[idx]})`, x, 35); + }); + + // Draw legend + ctx.textAlign = 'left'; + ctx.font = '11px monospace'; + + // Positive weights + ctx.fillStyle = 'rgba(59, 130, 246, 0.8)'; + ctx.fillRect(10, height - 60, 15, 3); + ctx.fillStyle = '#94a3b8'; + ctx.fillText('Positive weight', 30, height - 55); + + // Negative weights + ctx.fillStyle = 'rgba(239, 68, 68, 0.8)'; + ctx.fillRect(10, height - 40, 15, 3); + ctx.fillStyle = '#94a3b8'; + ctx.fillText('Negative weight', 30, height - 35); + + // Active neuron + ctx.fillStyle = 'rgba(34, 197, 94, 0.8)'; + ctx.beginPath(); + ctx.arc(17, height - 20, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#94a3b8'; + ctx.fillText('Active neuron', 30, height - 15); + + }, [network, currentInput, currentOutput]); + + // Handle mouse move for hover detection + const handleMouseMove = (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const mouseX = (e.clientX - rect.left) * scaleX; + const mouseY = (e.clientY - rect.top) * scaleY; + + // Calculate neuron positions (same logic as drawing) + const layerSizes = [ + network.config.inputSize, + ...network.config.hiddenLayers, + network.config.outputSize, + ]; + const numLayers = layerSizes.length; + const layerSpacing = canvas.width / (numLayers + 1); + + let found = false; + layerSizes.forEach((size, layerIdx) => { + const x = layerSpacing * (layerIdx + 1); + const neuronSpacing = canvas.height / (size + 1); + + for (let i = 0; i < size; i++) { + const y = neuronSpacing * (i + 1); + const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2); + + if (distance < 12) { // Hover radius + // Only show tooltips for input and output layers + if (layerIdx === 0 || layerIdx === numLayers - 1) { + const isInput = layerIdx === 0; + const name = isInput ? inputNames[i] : outputNames[i]; + const value = isInput + ? (currentInput?.[i] ?? 0) + : (currentOutput?.[i] ?? 0); + + setHoveredNeuron({ + layer: layerIdx, + index: i, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + name, + value, + }); + found = true; + } + } + } + }); + + if (!found) { + setHoveredNeuron(null); + } + }; + + return ( +
+

Neural Network Visualization

+
+ setHoveredNeuron(null)} + /> + + {/* Hover Tooltip */} + {hoveredNeuron && ( +
+
{hoveredNeuron.name}
+
+ Value: {hoveredNeuron.value.toFixed(3)} +
+
+ )} +
+
+

• Network learns optimal weights from game performance

+

• Brighter neurons = higher activation

+

• Line thickness = weight strength

+

Hover over neurons to see names and values

+
+
+ ); +}; diff --git a/packages/ui/src/apps/tetris/PerformanceChart.tsx b/packages/ui/src/apps/tetris/PerformanceChart.tsx new file mode 100644 index 00000000..421d3b64 --- /dev/null +++ b/packages/ui/src/apps/tetris/PerformanceChart.tsx @@ -0,0 +1,153 @@ +import React from 'react'; + +interface GameResult { + score: number; + lines: number; + level: number; + timestamp: number; +} + +interface PerformanceChartProps { + history: GameResult[]; +} + +export const PerformanceChart: React.FC = ({ history }) => { + if (history.length === 0) { + return ( +
+

Performance Trend

+

No games played yet. Start playing to see improvement!

+
+ ); + } + + // Take last 20 games for visualization + const recentGames = history.slice(-20); + const maxScore = Math.max(...recentGames.map(g => g.score), 100); + const maxLevel = Math.max(...recentGames.map(g => g.level), 5); + + // Calculate statistics + const avgScore = recentGames.reduce((sum, g) => sum + g.score, 0) / recentGames.length; + const avgLevel = recentGames.reduce((sum, g) => sum + g.level, 0) / recentGames.length; + const bestGame = recentGames.reduce((best, game) => + game.score > best.score ? game : best + , recentGames[0]); + + // Calculate trend (simple moving average) + const trendWindow = 5; + const trends = recentGames.map((_, idx) => { + const start = Math.max(0, idx - trendWindow + 1); + const window = recentGames.slice(start, idx + 1); + return window.reduce((sum, g) => sum + g.score, 0) / window.length; + }); + + const isImproving = trends.length > 1 && trends[trends.length - 1] > trends[0]; + + return ( +
+

Performance Trend

+ + {/* Stats Summary */} +
+
+
Avg Score
+
{Math.round(avgScore)}
+
+
+
Best Score
+
{bestGame.score}
+
+
+
Avg Level
+
{avgLevel.toFixed(1)}
+
+
+ + {/* Trend Indicator */} +
+ Trend: + + {isImproving ? '📈 Improving' : '📊 Stable'} + +
+ + {/* Chart */} +
+ {/* Y-axis labels */} +
+ {maxScore >= 1000 ? `${(maxScore / 1000).toFixed(0)}k` : maxScore} + {maxScore >= 2000 ? `${(maxScore / 2000).toFixed(0)}k` : Math.round(maxScore / 2)} + 0 +
+ + {/* Chart area */} +
+ {/* Grid lines */} +
+ {[0, 1, 2, 3, 4].map(i => ( +
+ ))} +
+ + {/* Score bars */} +
+ {recentGames.map((game, idx) => { + const heightPercent = Math.max((game.score / maxScore) * 100, 2); // Minimum 2% height + const trendHeight = (trends[idx] / maxScore) * 100; + + return ( +
+ {/* Trend line marker */} + {trendHeight > 0 && ( +
+ )} + + {/* Score bar */} +
+ {/* Tooltip on hover */} +
+
Game #{history.length - recentGames.length + idx + 1}
+
Score: {game.score}
+
Level: {game.level}
+
Lines: {game.lines}
+
+
+
+ ); + })} +
+
+ + {/* X-axis label */} +
+ Last {recentGames.length} Games +
+
+ + {/* Legend */} +
+
+
+ Score +
+
+
+ Trend (avg) +
+
+
+ Best score +
+
+
+ ); +}; diff --git a/packages/ui/src/apps/tetris/Tetris.tsx b/packages/ui/src/apps/tetris/Tetris.tsx new file mode 100644 index 00000000..549d85ef --- /dev/null +++ b/packages/ui/src/apps/tetris/Tetris.tsx @@ -0,0 +1,1079 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { GameState, Position, BOARD_WIDTH, BOARD_HEIGHT } from './types'; +import { + createEmptyBoard, + getRandomTetromino, + rotateTetromino, + checkCollision, + mergePieceToBoard, + clearLines, + calculateScore, + getColorForCell, + getStartPosition, + getDropSpeed, +} from './gameLogic'; +import { findBestMove, AIWeights, DEFAULT_WEIGHTS } from './aiPlayer'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { HelpCircle, X } from 'lucide-react'; +import { getNeuralNetwork, saveNeuralNetwork, trainOnHistory, getNeuralWeights, calculateReward, extractBoardFeatures, weightsToOutput } from './neuralAI'; +import { NeuralNetworkVisualizer } from './NeuralNetworkVisualizer'; +import { LearningLog } from './LearningLog'; +import { PerformanceChart } from './PerformanceChart'; + +// Game history tracking +interface GameResult { + score: number; + lines: number; + level: number; + weights: AIWeights; + timestamp: number; +} + +interface WeightChange { + timestamp: number; + gameNumber: number; + score: number; + lines: number; + oldWeights: AIWeights; + newWeights: AIWeights; +} + +const saveGameResult = (score: number, lines: number, level: number, weights: AIWeights) => { + const history: GameResult[] = JSON.parse(localStorage.getItem('tetris-game-history') || '[]'); + history.push({ + score, + lines, + level, + weights, + timestamp: Date.now(), + }); + // Keep only last 100 games for training + if (history.length > 100) { + history.shift(); + } + localStorage.setItem('tetris-game-history', JSON.stringify(history)); + + // Train neural network on new data + const network = getNeuralNetwork(); + trainOnHistory(network); +}; + + + +const Tetris: React.FC = () => { + const [gameState, setGameState] = useState({ + board: createEmptyBoard(), + currentPiece: null, + currentPosition: getStartPosition(), + nextPiece: getRandomTetromino(), + score: 0, + level: 1, + lines: 0, + gameOver: false, + isPaused: false, + isAutoPlay: true, + }); + + const [gameStarted, setGameStarted] = useState(false); + const [debugInfo, setDebugInfo] = useState({ + linesCleared: 0, + lineScore: 0, + contacts: 0, + contactScore: 0, + holes: 0, + holesPenalty: 0, + holesCreated: 0, + holesCreatedPenalty: 0, + overhangs: 0, + overhangPenalty: 0, + overhangsCreated: 0, + overhangsCreatedPenalty: 0, + overhangsFilled: 0, + overhangsFilledBonus: 0, + heightAdded: 0, + heightAddedPenalty: 0, + wells: 0, + wellDepth: 0, + wellsPenalty: 0, + bumpiness: 0, + bumpinessPenalty: 0, + maxHeight: 0, + maxHeightPenalty: 0, + avgHeight: '0.0', + avgHeightPenalty: '0', + totalScore: 0, + }); + const [clearedLines, setClearedLines] = useState([]); + const [aiWeights, setAiWeights] = useState(() => { + const stored = localStorage.getItem('tetris-ai-weights'); + return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS; + }); + const [aiMode, setAiMode] = useState<'standard' | 'neural'>('neural'); + const [neuralNetwork] = useState(() => getNeuralNetwork()); + const [currentNNInput, setCurrentNNInput] = useState(); + const [currentNNOutput, setCurrentNNOutput] = useState(); + const [isHelpOpen, setIsHelpOpen] = useState(false); + const [weightChanges, setWeightChanges] = useState(() => { + const stored = localStorage.getItem('tetris-weight-changes'); + return stored ? JSON.parse(stored) : []; + }); + const [gameCounter, setGameCounter] = useState(() => { + const stored = localStorage.getItem('tetris-game-counter'); + return stored ? parseInt(stored) : 0; + }); + const [gameHistory, setGameHistory] = useState(() => { + const stored = localStorage.getItem('tetris-game-history'); + return stored ? JSON.parse(stored) : []; + }); + const gameLoopRef = useRef(null); + const lastDropTimeRef = useRef(0); + const aiMoveRef = useRef(null); + const lastProcessedPieceRef = useRef(null); // Track which piece we've processed + const aiStateRef = useRef<'idle' | 'calculating' | 'executing' | 'dropping'>('idle'); + const aiStateStartTimeRef = useRef(0); + const inactivityTimerRef = useRef(null); + + + const startGame = useCallback(() => { + // Save previous game result if it was a completed game + if (gameStarted && gameState.gameOver) { + saveGameResult(gameState.score, gameState.lines, gameState.level, aiWeights); + + // Update game history state + const newResult: GameResult = { + score: gameState.score, + lines: gameState.lines, + level: gameState.level, + weights: aiWeights, + timestamp: Date.now(), + }; + setGameHistory(prev => { + const updated = [...prev, newResult]; + // Keep only last 100 games + return updated.length > 100 ? updated.slice(-100) : updated; + }); + } + + // Load weights based on AI mode + const oldWeights = aiWeights; + const newWeights = aiMode === 'neural' ? getNeuralWeights() : aiWeights; + + if (aiMode === 'neural') { + // Track weight changes + const newGameNumber = gameCounter + 1; + setGameCounter(newGameNumber); + localStorage.setItem('tetris-game-counter', newGameNumber.toString()); + + // Log the weight change + const change: WeightChange = { + timestamp: Date.now(), + gameNumber: newGameNumber, + score: gameState.score, + lines: gameState.lines, + oldWeights, + newWeights, + }; + + const updatedChanges = [...weightChanges, change]; + setWeightChanges(updatedChanges); + localStorage.setItem('tetris-weight-changes', JSON.stringify(updatedChanges)); + + setAiWeights(newWeights); + console.log(`🧠 Neural Network: Game #${newGameNumber} - Weights updated`); + } + + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + aiStateRef.current = 'idle'; + setClearedLines([]); + setDebugInfo({ + linesCleared: 0, + lineScore: 0, + contacts: 0, + contactScore: 0, + holes: 0, + holesPenalty: 0, + holesCreated: 0, + holesCreatedPenalty: 0, + overhangs: 0, + overhangPenalty: 0, + overhangsCreated: 0, + overhangsCreatedPenalty: 0, + overhangsFilled: 0, + overhangsFilledBonus: 0, + heightAdded: 0, + heightAddedPenalty: 0, + wells: 0, + wellDepth: 0, + wellsPenalty: 0, + bumpiness: 0, + bumpinessPenalty: 0, + maxHeight: 0, + maxHeightPenalty: 0, + avgHeight: '0.0', + avgHeightPenalty: '0', + totalScore: 0, + }); + setGameState({ + board: createEmptyBoard(), + currentPiece: getRandomTetromino(), + currentPosition: getStartPosition(), + nextPiece: getRandomTetromino(), + score: 0, + level: 10, // Start at level 10 for autoplay challenge + lines: 0, + gameOver: false, + isPaused: false, + isAutoPlay: true, + }); + setGameStarted(true); + }, [gameStarted, gameState.gameOver, gameState.score, gameState.lines, gameState.level, aiWeights, aiMode]); + + const movePiece = useCallback((dx: number, dy: number) => { + setGameState((prev) => { + if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev; + + const newPosition: Position = { + x: prev.currentPosition.x + dx, + y: prev.currentPosition.y + dy, + }; + + if (!checkCollision(prev.board, prev.currentPiece, newPosition)) { + return { ...prev, currentPosition: newPosition }; + } + + return prev; + }); + }, []); + + const rotate = useCallback(() => { + setGameState((prev) => { + if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev; + + const rotated = rotateTetromino(prev.currentPiece); + if (!checkCollision(prev.board, rotated, prev.currentPosition)) { + return { ...prev, currentPiece: rotated }; + } + + return prev; + }); + }, []); + + const hardDrop = useCallback(() => { + setGameState((prev) => { + if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev; + + let newPosition = { ...prev.currentPosition }; + while (!checkCollision(prev.board, prev.currentPiece, { x: newPosition.x, y: newPosition.y + 1 })) { + newPosition.y++; + } + + return { ...prev, currentPosition: newPosition }; + }); + }, []); + + const lockPiece = useCallback(() => { + setGameState((prev) => { + if (!prev.currentPiece) return prev; + + const newBoard = mergePieceToBoard(prev.board, prev.currentPiece, prev.currentPosition); + const { newBoard: clearedBoard, linesCleared } = clearLines(newBoard); + + // Find which lines were cleared for animation + const linesToClear: number[] = []; + for (let y = 0; y < BOARD_HEIGHT; y++) { + if (newBoard[y].every(cell => cell !== 0)) { + linesToClear.push(y); + } + } + + // Show animation if lines were cleared + if (linesToClear.length > 0) { + setClearedLines(linesToClear); + setTimeout(() => setClearedLines([]), 300); + } + + const newScore = prev.score + calculateScore(linesCleared, prev.level); + const newLines = prev.lines + linesCleared; + const newLevel = Math.floor(newLines / 10); + + if (clearedBoard[0].some(cell => cell !== 0)) { + return { ...prev, gameOver: true }; + } + + return { + ...prev, + board: clearedBoard, + currentPiece: prev.nextPiece, + currentPosition: getStartPosition(), + nextPiece: getRandomTetromino(), + score: newScore, + level: newLevel, + lines: newLines, + }; + }); + }, []); + + const drop = useCallback(() => { + setGameState((prev) => { + if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev; + + const newPosition: Position = { + x: prev.currentPosition.x, + y: prev.currentPosition.y + 1, + }; + + if (!checkCollision(prev.board, prev.currentPiece, newPosition)) { + return { ...prev, currentPosition: newPosition }; + } + + return prev; + }); + + // Check if piece should lock after state update + setGameState((prev) => { + if (!prev.currentPiece) return prev; + + const nextPosition: Position = { + x: prev.currentPosition.x, + y: prev.currentPosition.y + 1, + }; + + if (checkCollision(prev.board, prev.currentPiece, nextPosition)) { + // Piece has landed, lock it + const newBoard = mergePieceToBoard(prev.board, prev.currentPiece, prev.currentPosition); + const { newBoard: clearedBoard, linesCleared } = clearLines(newBoard); + + // Find which lines were cleared for animation + const linesToClear: number[] = []; + for (let y = 0; y < BOARD_HEIGHT; y++) { + if (newBoard[y].every(cell => cell !== 0)) { + linesToClear.push(y); + } + } + + // Show animation if lines were cleared + if (linesToClear.length > 0) { + setClearedLines(linesToClear); + setTimeout(() => setClearedLines([]), 300); + } + + const newScore = prev.score + calculateScore(linesCleared, prev.level); + const newLines = prev.lines + linesCleared; + const newLevel = Math.floor(newLines / 10); + + if (clearedBoard[0].some(cell => cell !== 0)) { + return { ...prev, gameOver: true }; + } + + return { + ...prev, + board: clearedBoard, + currentPiece: prev.nextPiece, + currentPosition: getStartPosition(), + nextPiece: getRandomTetromino(), + score: newScore, + level: newLevel, + lines: newLines, + }; + } + + return prev; + }); + }, []); + + // Auto-start game on mount + useEffect(() => { + const timer = setTimeout(() => { + if (!gameStarted) { + startGame(); + } + }, 1000); + return () => clearTimeout(timer); + }, [gameStarted, startGame]); + + // Reset AI state when lines are cleared (board changes significantly) + useEffect(() => { + if (gameState.isAutoPlay && clearedLines.length > 0) { + console.log('Lines cleared, resetting AI state'); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + aiStateRef.current = 'idle'; + } + }, [clearedLines, gameState.isAutoPlay]); + + // AI logic with state machine + useEffect(() => { + if (!gameState.isAutoPlay || gameState.gameOver || gameState.isPaused || !gameState.currentPiece) { + return; + } + + // Watchdog: if AI has been in non-idle state for > 2 seconds, force reset + if (aiStateRef.current !== 'idle' && Date.now() - aiStateStartTimeRef.current > 2000) { + console.error('AI WATCHDOG: Stuck in state', aiStateRef.current, 'for > 2s, forcing reset'); + hardDrop(); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + aiStateRef.current = 'idle'; + return; + } + + // State: IDLE - waiting for new piece + if (aiStateRef.current === 'idle' && gameState.currentPosition.y <= 1) { + const pieceId = `${gameState.currentPiece.type}-${gameState.currentPosition.x}`; + + if (lastProcessedPieceRef.current !== pieceId) { + console.log('AI STATE: idle -> calculating', pieceId); + aiStateRef.current = 'calculating'; + aiStateStartTimeRef.current = Date.now(); + lastProcessedPieceRef.current = pieceId; + aiMoveRef.current = null; + + + const bestMove = findBestMove(gameState.board, gameState.currentPiece, aiWeights); + if (bestMove) { + aiMoveRef.current = { + ...bestMove, + rotationsNeeded: bestMove.rotation, + rotationsDone: 0, + pieceType: gameState.currentPiece.type, + movesDone: 0, + }; + setDebugInfo(bestMove.breakdown); + console.log('AI calculated move:', bestMove, 'transitioning to executing'); + aiStateRef.current = 'executing'; + } else { + console.warn('AI could not find a move! Forcing drop'); + aiStateRef.current = 'dropping'; + setTimeout(() => { + hardDrop(); + aiStateRef.current = 'idle'; + }, 50); + } + // Don't return here - let it fall through to executing state + } + } + + // State: EXECUTING - rotating and moving piece + if (aiStateRef.current === 'executing' && aiMoveRef.current) { + const aiMove = aiMoveRef.current; + + // Handle rotation + if (aiMove.rotationsDone < aiMove.rotationsNeeded) { + setTimeout(() => { + rotate(); + if (aiMoveRef.current) { + aiMoveRef.current.rotationsDone++; + } + }, 20); + return; + } + + // Handle horizontal movement + const targetX = aiMove.position.x; + const currentX = gameState.currentPosition.x; + + if (currentX < targetX) { + aiMove.movesDone = (aiMove.movesDone || 0) + 1; + if (aiMove.movesDone > 20) { + console.warn('AI stuck moving right, forcing drop'); + aiStateRef.current = 'dropping'; + setTimeout(() => { + hardDrop(); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + aiStateRef.current = 'idle'; + }, 50); + return; + } + setTimeout(() => movePiece(1, 0), 5); + return; + } else if (currentX > targetX) { + aiMove.movesDone = (aiMove.movesDone || 0) + 1; + if (aiMove.movesDone > 20) { + console.warn('AI stuck moving left, forcing drop'); + aiStateRef.current = 'dropping'; + setTimeout(() => { + hardDrop(); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + aiStateRef.current = 'idle'; + }, 50); + return; + } + setTimeout(() => movePiece(-1, 0), 5); + return; + } else { + // At target position, transition to dropping + console.log('AI STATE: executing -> dropping'); + aiStateRef.current = 'dropping'; + setTimeout(() => { + hardDrop(); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; // Clear so AI can process next piece + aiStateRef.current = 'idle'; + console.log('AI STATE: dropping -> idle'); + }, 50); + } + } + }, [gameState.isAutoPlay, gameState.currentPiece, gameState.currentPosition, gameState.gameOver, gameState.isPaused, gameState.board, aiWeights, movePiece, rotate, hardDrop]); + + // Auto-restart after game over + useEffect(() => { + if (gameState.gameOver && gameState.isAutoPlay) { + const timer = setTimeout(() => { + startGame(); + }, 2000); + return () => clearTimeout(timer); + } + }, [gameState.gameOver, gameState.isAutoPlay, startGame]); + + // Game loop + useEffect(() => { + if (!gameStarted || gameState.gameOver || gameState.isPaused) return; + + const speed = getDropSpeed(gameState.level); + const interval = setInterval(() => { + // Don't drop if AI is still rotating (horizontal movement is fast enough) + if (gameState.isAutoPlay && aiMoveRef.current) { + const aiMove = aiMoveRef.current; + const isRotating = aiMove.rotationsDone < aiMove.rotationsNeeded; + + if (isRotating) { + return; // Skip this drop while rotating + } + } + + drop(); + }, speed); + + return () => clearInterval(interval); + }, [gameStarted, gameState.gameOver, gameState.isPaused, gameState.level, gameState.isAutoPlay, drop]); + + // Keyboard controls + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore keyboard events when focused on input fields (weight tuning) + if (e.target instanceof HTMLInputElement) { + return; + } + + if (!gameStarted) { + startGame(); + return; + } + + if (gameState.gameOver) return; + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level })); + // Reset inactivity timer + if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current); + inactivityTimerRef.current = window.setTimeout(() => { + setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 })); + }, 10000); + movePiece(-1, 0); + break; + case 'ArrowRight': + e.preventDefault(); + setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level })); + // Reset inactivity timer + if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current); + inactivityTimerRef.current = window.setTimeout(() => { + setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 })); + }, 10000); + movePiece(1, 0); + break; + case 'ArrowDown': + case 'ArrowUp': + e.preventDefault(); + setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level })); + // Reset inactivity timer + if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current); + inactivityTimerRef.current = window.setTimeout(() => { + setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 })); + }, 10000); + rotate(); + break; + case ' ': + e.preventDefault(); + setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level })); + // Reset inactivity timer + if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current); + inactivityTimerRef.current = window.setTimeout(() => { + setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 })); + }, 10000); + hardDrop(); + break; + case 'p': + case 'P': + e.preventDefault(); + setGameState((prev) => ({ ...prev, isPaused: !prev.isPaused })); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [gameStarted, gameState.gameOver, startGame, movePiece, drop, rotate]); + + // Update neural network visualization + useEffect(() => { + if (aiMode === 'neural' && debugInfo) { + const input = extractBoardFeatures(debugInfo); + const output = weightsToOutput(aiWeights); + setCurrentNNInput(input); + setCurrentNNOutput(output); + } + }, [aiMode, debugInfo, aiWeights]); + + const renderBoard = () => { + // Calculate ghost piece position + let ghostY = gameState.currentPosition.y; + if (gameState.currentPiece) { + while (!checkCollision(gameState.board, gameState.currentPiece, { x: gameState.currentPosition.x, y: ghostY + 1 })) { + ghostY++; + } + } + + const displayBoard = gameState.currentPiece + ? mergePieceToBoard(gameState.board, gameState.currentPiece, gameState.currentPosition) + : gameState.board; + + return displayBoard.map((row, y) => ( +
+ {row.map((cell, x) => { + const isClearing = clearedLines.includes(y); + + // Check if this cell is part of the ghost piece + let isGhost = false; + if (!gameState.isAutoPlay && gameState.currentPiece && ghostY !== gameState.currentPosition.y) { + for (let py = 0; py < gameState.currentPiece.shape.length; py++) { + for (let px = 0; px < gameState.currentPiece.shape[py].length; px++) { + if (gameState.currentPiece.shape[py][px]) { + const ghostX = gameState.currentPosition.x + px; + const ghostYPos = ghostY + py; + if (ghostX === x && ghostYPos === y && !cell) { + isGhost = true; + } + } + } + } + } + + return ( +
+ ); + })} +
+ )); + }; + + const renderNextPiece = () => { + if (!gameState.nextPiece) return null; + + // Create a 4x4 grid and center the piece + const grid: number[][] = Array(4).fill(0).map(() => Array(4).fill(0)); + const piece = gameState.nextPiece; + + // Calculate offset to center the piece in the 4x4 grid + const offsetY = Math.floor((4 - piece.shape.length) / 2); + const offsetX = Math.floor((4 - piece.shape[0].length) / 2); + + // Place piece in the center of the grid + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + if (piece.shape[y][x]) { + grid[y + offsetY][x + offsetX] = 1; + } + } + } + + return ( +
+ {grid.map((row, y) => ( +
+ {row.map((cell, x) => ( +
+ ))} +
+ ))} +
+ ); + }; + + + return ( +
+
+ {/* Top Row: Game + Controls */} +
+ {/* Game Board - Left Side */} +
+
+
+

+ Tetris +

+ {!gameStarted && ( +

Press any arrow key to start!

+ )} +
+ +
+ {renderBoard()} +
+ + {gameState.gameOver && ( +
+

Game Over!

+ +
+ )} +
+ + {/* Stats and Next Piece - Below Game */} +
+ {/* Stats */} +
+

Stats

+
+
+ Score: + {gameState.score} +
+
+ Lines: + {gameState.lines} +
+
+ Level: + {gameState.level} +
+
+
+ + {/* Next Piece */} +
+

Next

+ {renderNextPiece()} +
+
+ + {gameStarted && ( + + )} +
+ + {/* Right Column: Controls + Weights */} +
+ + {/* AI Weights Panel with Inline Editing */} + {debugInfo && ( +
+
+
+

AI Weights

+ +
+ +
+
+ {/* Editable Weight Rows */} + {[ + { key: 'lineCleared', label: 'Lines Cleared', color: 'text-green-400', sign: '+' }, + { key: 'contact', label: 'Contact', color: 'text-blue-400', sign: '+' }, + { key: 'holes', label: 'Holes', color: 'text-red-400', sign: '-' }, + { key: 'holesCreated', label: 'Holes Created', color: 'text-red-600', sign: '-' }, + { key: 'overhangs', label: 'Overhangs', color: 'text-pink-400', sign: '-' }, + { key: 'overhangsCreated', label: 'Overhangs Created', color: 'text-pink-600', sign: '-' }, + { key: 'overhangsFilled', label: 'Overhangs Filled', color: 'text-emerald-400', sign: '+' }, + { key: 'heightAdded', label: 'Height Added', color: 'text-amber-400', sign: '-' }, + { key: 'wells', label: 'Wells', color: 'text-rose-400', sign: '-' }, + { key: 'wellDepthSquared', label: 'Well Depth²', color: 'text-rose-500', sign: '-' }, + { key: 'bumpiness', label: 'Bumpiness', color: 'text-violet-400', sign: '-' }, + { key: 'maxHeight', label: 'Max Height', color: 'text-orange-400', sign: '-' }, + { key: 'avgHeight', label: 'Avg Height', color: 'text-yellow-400', sign: '-' }, + ].map(({ key, label, color, sign }) => ( +
+ + {sign} + {' '}{label}: + + { + const newWeights = { ...aiWeights, [key]: parseFloat(e.target.value) || 0 }; + setAiWeights(newWeights); + localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights)); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + }} + onKeyDown={(e) => { + const step = key === 'lineCleared' ? 100 : key.includes('Height') ? 5 : 10; + if (e.key === 'ArrowUp') { + e.preventDefault(); + const newWeights = { ...aiWeights, [key]: aiWeights[key as keyof AIWeights] + step }; + setAiWeights(newWeights); + localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights)); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + const newWeights = { ...aiWeights, [key]: Math.max(0, aiWeights[key as keyof AIWeights] - step) }; + setAiWeights(newWeights); + localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights)); + aiMoveRef.current = null; + lastProcessedPieceRef.current = null; + } + }} + className={`${color} text - right bg - transparent border - b border - purple - 500 / 30 focus: border - purple - 500 focus: outline - none w - 16 px - 1`} + /> +
+ ))} +
+ Total Score: + {debugInfo.totalScore} +
+
+
+ )} + + {/* Controls */} +
+

Controls

+
+
+ { + setGameState((prev) => ({ ...prev, isAutoPlay: checked })); + aiMoveRef.current = null; + }} + disabled={!gameStarted || gameState.gameOver} + /> + +
+ +
+

AI Mode:

+
+ + +
+

+ {aiMode === 'neural' ? '🧠 Learning from past games' : '⚙️ Using manual weights'} +

+
+ +
+

Keyboard:

+
    +
  • ← → Move (stops auto)
  • +
  • ↑ ↓ Rotate (stops auto)
  • +
  • Space: Hard Drop (stops auto)
  • +
  • P: Pause
  • +
+
+
+
+ + + {gameStarted && ( + + )} + + {/* Learning Log - Neural Mode Only */} + {aiMode === 'neural' && ( + + )} +
+
+ + {/* Help Popup */} + {isHelpOpen && ( +
+
+
+

AI Weights Guide

+ +
+
+

+ These weights control how the AI evaluates each possible move. Higher values make the AI prioritize (for bonuses) or avoid (for penalties) that factor more strongly. +

+ +
+
+

+ Lines Cleared (Bonus)

+

Rewards clearing lines. Higher values make the AI prioritize moves that clear multiple lines at once (Tetris = 4 lines).

+
+ +
+

+ Contact (Bonus)

+

Rewards placing pieces adjacent to existing blocks. Encourages compact, connected structures rather than isolated pieces.

+
+ +
+

- Holes (Penalty)

+

Penalizes empty cells with blocks above them. Holes are hard to fill and lead to game over. Higher values make the AI avoid creating holes.

+
+ +
+

- Holes Created (Penalty)

+

Extra penalty for moves that create NEW holes. This is worse than existing holes because it represents a mistake the AI is about to make.

+
+ +
+

- Overhangs (Penalty)

+

Penalizes blocks that stick out horizontally with empty space beneath. Overhangs create awkward shapes and future holes.

+
+ +
+

- Overhangs Created (Penalty)

+

Extra penalty for creating NEW overhangs. Prevents the AI from making moves that create problematic structures.

+
+ +
+

+ Overhangs Filled (Bonus)

+

Rewards filling in existing overhangs. Encourages the AI to fix problematic structures when possible.

+
+ +
+

- Height Added (Penalty)

+

Penalizes moves that increase the stack height. Keeps the board low to avoid game over and maintain flexibility.

+
+ +
+

- Wells (Penalty)

+

Penalizes deep vertical gaps (wells). While useful for I-pieces, deep wells limit placement options and can trap pieces.

+
+ +
+

- Well Depth² (Penalty)

+

Squared penalty for well depth. Makes very deep wells exponentially worse. A 4-deep well is penalized 16x more than a 1-deep well.

+
+ +
+

- Bumpiness (Penalty)

+

Penalizes uneven column heights. Smooth, flat surfaces are easier to work with and reduce the chance of creating holes.

+
+ +
+

- Max Height (Penalty)

+

Penalizes the tallest column. Prevents any single column from getting too high, which would trigger game over.

+
+ +
+

- Avg Height (Penalty)

+

Penalizes the average height across all columns. Encourages keeping the overall board low and clearing lines regularly.

+
+
+ +
+

💡 Tuning Tips

+
    +
  • Start with default values and adjust one weight at a time
  • +
  • Use arrow keys (↑↓) to fine-tune values while watching the AI play
  • +
  • Higher penalty values make the AI avoid that factor more aggressively
  • +
  • Balance is key - extreme values can cause unexpected behavior
  • +
+
+
+
+
+ )} + + + {/* Neural Network Visualization - Full Width Below */} + {aiMode === 'neural' && ( +
+
+ + +
+
+ )} +
+
+ ); +}; + +export default Tetris; diff --git a/packages/ui/src/apps/tetris/WeightsTuner.tsx b/packages/ui/src/apps/tetris/WeightsTuner.tsx new file mode 100644 index 00000000..7959d627 --- /dev/null +++ b/packages/ui/src/apps/tetris/WeightsTuner.tsx @@ -0,0 +1,177 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { AIWeights, DEFAULT_WEIGHTS } from './aiPlayer'; +import { Button } from '@/components/ui/button'; +import { X, RotateCcw } from 'lucide-react'; + +const STORAGE_KEY = 'tetris-ai-weights'; + +interface WeightsTunerProps { + isOpen: boolean; + onClose: () => void; + onWeightsChange: (weights: AIWeights) => void; +} + +export const WeightsTuner: React.FC = ({ isOpen, onClose, onWeightsChange }) => { + const [weights, setWeights] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS; + }); + + const popupRef = useRef(null); + + useEffect(() => { + if (isOpen) { + // Focus first input when opened + const firstInput = popupRef.current?.querySelector('input'); + firstInput?.focus(); + } + }, [isOpen]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const updateWeight = (key: keyof AIWeights, value: number) => { + const newWeights = { ...weights, [key]: value }; + setWeights(newWeights); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newWeights)); + onWeightsChange(newWeights); + }; + + const resetToDefaults = () => { + setWeights(DEFAULT_WEIGHTS); + localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_WEIGHTS)); + onWeightsChange(DEFAULT_WEIGHTS); + }; + + if (!isOpen) return null; + + const weightFields: { key: keyof AIWeights; label: string; step: number }[] = [ + { key: 'lineCleared', label: 'Lines Cleared', step: 100 }, + { key: 'contact', label: 'Contact', step: 10 }, + { key: 'holes', label: 'Holes (penalty)', step: 10 }, + { key: 'holesCreated', label: 'Holes Created (penalty)', step: 10 }, + { key: 'overhangs', label: 'Overhangs (penalty)', step: 10 }, + { key: 'overhangsCreated', label: 'Overhangs Created (penalty)', step: 10 }, + { key: 'overhangsFilled', label: 'Overhangs Filled (bonus)', step: 10 }, + { key: 'heightAdded', label: 'Height Added (penalty)', step: 10 }, + { key: 'wells', label: 'Wells (penalty)', step: 10 }, + { key: 'wellDepthSquared', label: 'Well Depth² (penalty)', step: 10 }, + { key: 'bumpiness', label: 'Bumpiness (penalty)', step: 5 }, + { key: 'maxHeight', label: 'Max Height (penalty)', step: 5 }, + { key: 'avgHeight', label: 'Avg Height (penalty)', step: 5 }, + ]; + + return ( +
+
+ {/* Header */} +
+

+ AI Weights Tuner +

+
+ + +
+
+ + {/* Instructions */} +
+

+ ↑↓ + Adjust values + Tab + Navigate fields + Esc + Close +

+
+ + {/* Weight Fields */} +
+ {weightFields.map(({ key, label, step }) => ( +
+ + updateWeight(key, parseFloat(e.target.value) || 0)} + step={step} + className="bg-gray-900/50 border border-purple-500/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20" + onKeyDown={(e) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + updateWeight(key, weights[key] + step); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + updateWeight(key, weights[key] - step); + } + }} + /> +
+ ))} +
+ + {/* Footer */} +
+

+ Weights are automatically saved to localStorage +

+
+
+
+ ); +}; + +// Hook to load weights from localStorage +export const useAIWeights = (): AIWeights => { + const [weights, setWeights] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS; + }); + + useEffect(() => { + const handleStorageChange = () => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setWeights(JSON.parse(stored)); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + + return weights; +}; diff --git a/packages/ui/src/apps/tetris/aiPlayer.ts b/packages/ui/src/apps/tetris/aiPlayer.ts new file mode 100644 index 00000000..6c52c975 --- /dev/null +++ b/packages/ui/src/apps/tetris/aiPlayer.ts @@ -0,0 +1,328 @@ +import { Tetromino, Position, Move, BOARD_WIDTH, BOARD_HEIGHT } from './types'; +import { checkCollision, mergePieceToBoard, rotateTetromino, clearLines } from './gameLogic'; + +export interface AIWeights { + lineCleared: number; + contact: number; + holes: number; + holesCreated: number; + overhangs: number; + overhangsCreated: number; + overhangsFilled: number; + heightAdded: number; + wells: number; + wellDepthSquared: number; + bumpiness: number; + maxHeight: number; + avgHeight: number; +} + +export const DEFAULT_WEIGHTS: AIWeights = { + lineCleared: 10000, + contact: 100, + holes: 500, + holesCreated: 800, + overhangs: 500, + overhangsCreated: 1000, + overhangsFilled: 200, + heightAdded: 800, + wells: 100, + wellDepthSquared: 100, + bumpiness: 50, + maxHeight: 50, + avgHeight: 20, +}; + +function countContacts(board: number[][], piece: Tetromino, position: Position): number { + let contacts = 0; + + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + if (piece.shape[y][x]) { + const boardY = position.y + y; + const boardX = position.x + x; + + // Check below + if (boardY + 1 >= BOARD_HEIGHT || (boardY + 1 >= 0 && board[boardY + 1]?.[boardX])) { + contacts++; + } + // Check left + if (boardX - 1 < 0 || (boardY >= 0 && board[boardY]?.[boardX - 1])) { + contacts++; + } + // Check right + if (boardX + 1 >= BOARD_WIDTH || (boardY >= 0 && board[boardY]?.[boardX + 1])) { + contacts++; + } + } + } + } + + return contacts; +} + +function countCompletedLines(board: number[][]): number { + let completedLines = 0; + for (let y = 0; y < BOARD_HEIGHT; y++) { + if (board[y].every(cell => cell !== 0)) { + completedLines++; + } + } + return completedLines; +} + +function countHoles(board: number[][]): number { + let holes = 0; + + for (let x = 0; x < BOARD_WIDTH; x++) { + let blockFound = false; + for (let y = 0; y < BOARD_HEIGHT; y++) { + if (board[y][x] !== 0) { + blockFound = true; + } else if (blockFound) { + holes++; + } + } + } + + return holes; +} + +function getColumnHeights(board: number[][]): number[] { + const heights: number[] = []; + + for (let x = 0; x < BOARD_WIDTH; x++) { + let height = 0; + for (let y = 0; y < BOARD_HEIGHT; y++) { + if (board[y][x] !== 0) { + height = BOARD_HEIGHT - y; + break; + } + } + heights.push(height); + } + + return heights; +} + +function countWells(board: number[][]): { wells: number; wellDepth: number } { + const heights = getColumnHeights(board); + let totalWells = 0; + let maxWellDepth = 0; + + for (let x = 0; x < BOARD_WIDTH; x++) { + const currentHeight = heights[x]; + const leftHeight = x > 0 ? heights[x - 1] : 0; + const rightHeight = x < BOARD_WIDTH - 1 ? heights[x + 1] : 0; + + // A well is when both neighbors are higher + const wellDepth = Math.min(leftHeight, rightHeight) - currentHeight; + if (wellDepth > 0) { + totalWells++; + maxWellDepth = Math.max(maxWellDepth, wellDepth); + } + } + + return { wells: totalWells, wellDepth: maxWellDepth }; +} + +function countBumpiness(board: number[][]): number { + const heights = getColumnHeights(board); + let bumpiness = 0; + + for (let x = 0; x < BOARD_WIDTH - 1; x++) { + bumpiness += Math.abs(heights[x] - heights[x + 1]); + } + + return bumpiness; +} + +function countOverhangs(board: number[][]): number { + let overhangs = 0; + + for (let y = 0; y < BOARD_HEIGHT - 1; y++) { + for (let x = 0; x < BOARD_WIDTH; x++) { + // If there's a block with empty space below it + if (board[y][x] !== 0 && board[y + 1][x] === 0) { + // Check if there's a block further below (overhang) + for (let checkY = y + 2; checkY < BOARD_HEIGHT; checkY++) { + if (board[checkY][x] !== 0) { + overhangs++; + break; + } + } + } + } + } + + return overhangs; +} + +function countOverhangsFilled(boardBefore: number[][], boardAfter: number[][], piece: Tetromino, position: Position): number { + let filled = 0; + + // Check each cell of the placed piece + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + if (piece.shape[y][x]) { + const boardY = position.y + y; + const boardX = position.x + x; + + if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) { + // Check if there's a block above this position (we're filling under it) + for (let checkY = boardY - 1; checkY >= 0; checkY--) { + if (boardBefore[checkY][boardX] !== 0) { + filled++; + break; + } + } + } + } + } + } + + return filled; +} + +function evaluatePosition(board: number[][], piece: Tetromino, position: Position, weights: AIWeights): { score: number; breakdown: any } { + // Simulate placing the piece + const testBoard = mergePieceToBoard(board, piece, position); + const { newBoard, linesCleared } = clearLines(testBoard); + + // Count metrics + const contacts = countContacts(board, piece, position); + const holesBefore = countHoles(board); + const holesAfter = countHoles(newBoard); + const holesCreated = Math.max(0, holesAfter - holesBefore); + const overhangsBefore = countOverhangs(board); + const overhangsAfter = countOverhangs(newBoard); + const overhangsCreated = Math.max(0, overhangsAfter - overhangsBefore); + const overhangsFilled = countOverhangsFilled(board, testBoard, piece, position); + + const heightsBefore = getColumnHeights(board); + const heightsAfter = getColumnHeights(newBoard); + const maxHeightBefore = Math.max(...heightsBefore); + const maxHeightAfter = Math.max(...heightsAfter); + const heightAdded = Math.max(0, maxHeightAfter - maxHeightBefore); + + const { wells, wellDepth } = countWells(newBoard); + const bumpiness = countBumpiness(newBoard); + const avgHeight = heightsAfter.reduce((a, b) => a + b, 0) / heightsAfter.length; + + // Calculate score components using configurable weights + const lineScore = linesCleared * weights.lineCleared; + const contactScore = contacts * weights.contact; + const holesPenalty = holesAfter * weights.holes; + const holesCreatedPenalty = holesCreated * weights.holesCreated; + const overhangPenalty = overhangsAfter * weights.overhangs; + const overhangsCreatedPenalty = overhangsCreated * weights.overhangsCreated; + const overhangsFilledBonus = overhangsFilled * weights.overhangsFilled; + + + // Height added penalty - scales with current stack height + // The taller the stack, the worse it is to add more height + const heightMultiplier = 1 + (maxHeightBefore / BOARD_HEIGHT); // 1x at height 0, 2x at max height + const contactRatio = Math.min(contacts / 8, 1); // Normalize to 0-1 + const heightAddedPenalty = heightAdded * weights.heightAdded * heightMultiplier * (1 - contactRatio * 0.7); // Up to 70% reduction + + // Wells penalty - exponential with depth to heavily penalize deep gaps + const wellsPenalty = wells * weights.wells + (wellDepth * wellDepth * weights.wellDepthSquared); + + // Bumpiness penalty - encourage smooth surface + const bumpinessPenalty = bumpiness * weights.bumpiness; + + const maxHeightPenalty = maxHeightAfter * weights.maxHeight; + const avgHeightPenalty = avgHeight * weights.avgHeight; + + const totalScore = lineScore + contactScore - holesPenalty - holesCreatedPenalty - overhangPenalty - overhangsCreatedPenalty + overhangsFilledBonus - heightAddedPenalty - wellsPenalty - bumpinessPenalty - maxHeightPenalty - avgHeightPenalty; + + return { + score: totalScore, + breakdown: { + linesCleared, + lineScore, + contacts, + contactScore, + holes: holesAfter, + holesPenalty, + holesCreated, + holesCreatedPenalty, + overhangs: overhangsAfter, + overhangPenalty, + overhangsCreated, + overhangsCreatedPenalty, + overhangsFilled, + overhangsFilledBonus, + heightAdded, + heightAddedPenalty: Math.round(heightAddedPenalty), + wells, + wellDepth, + wellsPenalty, + bumpiness, + bumpinessPenalty, + maxHeight: maxHeightAfter, + maxHeightPenalty, + avgHeight: avgHeight.toFixed(1), + avgHeightPenalty: avgHeightPenalty.toFixed(0), + totalScore, + } + }; +} + + +export function findBestMove(board: number[][], piece: Tetromino, weights: AIWeights = DEFAULT_WEIGHTS): Move | null { + let bestMove: Move | null = null; + let bestScore = -Infinity; + + // Try all 4 rotations + let currentPiece = piece; + for (let rotation = 0; rotation < 4; rotation++) { + // Try all horizontal positions + for (let x = -3; x <= BOARD_WIDTH; x++) { + const position: Position = { x, y: 0 }; + + // Drop piece down until collision + let dropPosition = { ...position }; + while (!checkCollision(board, currentPiece, { x: dropPosition.x, y: dropPosition.y + 1 })) { + dropPosition.y++; + } + + // Check if this position is valid + if (!checkCollision(board, currentPiece, dropPosition)) { + // Evaluate this position + const evaluation = evaluatePosition(board, currentPiece, dropPosition, weights); + + if (evaluation.score > bestScore) { + bestScore = evaluation.score; + bestMove = { + position: dropPosition, + rotation, + score: evaluation.score, + breakdown: evaluation.breakdown, + }; + } + } + } + + // Rotate for next iteration + currentPiece = rotateTetromino(currentPiece); + } + + return bestMove; +} + + +export function getRotationCount(piece: Tetromino, targetRotation: number): number { + let currentPiece = piece; + let count = 0; + + for (let i = 0; i < 4; i++) { + if (i === targetRotation) { + return count; + } + currentPiece = rotateTetromino(currentPiece); + count++; + } + + return 0; +} diff --git a/packages/ui/src/apps/tetris/gameLogic.ts b/packages/ui/src/apps/tetris/gameLogic.ts new file mode 100644 index 00000000..a1c90805 --- /dev/null +++ b/packages/ui/src/apps/tetris/gameLogic.ts @@ -0,0 +1,189 @@ +import { Tetromino, TetrominoType, Position, BOARD_WIDTH, BOARD_HEIGHT } from './types'; + +const TETROMINO_SHAPES: Record = { + I: [ + [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0]], + [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0]], + [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]], + ], + O: [ + [[1, 1], [1, 1]], + ], + T: [ + [[0, 1, 0], [1, 1, 1], [0, 0, 0]], + [[0, 1, 0], [0, 1, 1], [0, 1, 0]], + [[0, 0, 0], [1, 1, 1], [0, 1, 0]], + [[0, 1, 0], [1, 1, 0], [0, 1, 0]], + ], + S: [ + [[0, 1, 1], [1, 1, 0], [0, 0, 0]], + [[0, 1, 0], [0, 1, 1], [0, 0, 1]], + [[0, 0, 0], [0, 1, 1], [1, 1, 0]], + [[1, 0, 0], [1, 1, 0], [0, 1, 0]], + ], + Z: [ + [[1, 1, 0], [0, 1, 1], [0, 0, 0]], + [[0, 0, 1], [0, 1, 1], [0, 1, 0]], + [[0, 0, 0], [1, 1, 0], [0, 1, 1]], + [[0, 1, 0], [1, 1, 0], [1, 0, 0]], + ], + J: [ + [[1, 0, 0], [1, 1, 1], [0, 0, 0]], + [[0, 1, 1], [0, 1, 0], [0, 1, 0]], + [[0, 0, 0], [1, 1, 1], [0, 0, 1]], + [[0, 1, 0], [0, 1, 0], [1, 1, 0]], + ], + L: [ + [[0, 0, 1], [1, 1, 1], [0, 0, 0]], + [[0, 1, 0], [0, 1, 0], [0, 1, 1]], + [[0, 0, 0], [1, 1, 1], [1, 0, 0]], + [[1, 1, 0], [0, 1, 0], [0, 1, 0]], + ], +}; + +const TETROMINO_COLORS: Record = { + I: '#00f0f0', + O: '#f0f000', + T: '#a000f0', + S: '#00f000', + Z: '#f00000', + J: '#0000f0', + L: '#f0a000', +}; + +export function createEmptyBoard(): number[][] { + return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0)); +} + +export function createTetromino(type: TetrominoType, rotation: number = 0): Tetromino { + return { + type, + shape: TETROMINO_SHAPES[type][rotation % TETROMINO_SHAPES[type].length], + color: TETROMINO_COLORS[type], + }; +} + +export function getRandomTetromino(): Tetromino { + const types: TetrominoType[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']; + const randomType = types[Math.floor(Math.random() * types.length)]; + return createTetromino(randomType); +} + +export function rotateTetromino(piece: Tetromino): Tetromino { + const rotations = TETROMINO_SHAPES[piece.type]; + const currentIndex = rotations.findIndex( + (shape) => JSON.stringify(shape) === JSON.stringify(piece.shape) + ); + const nextIndex = (currentIndex + 1) % rotations.length; + return { + ...piece, + shape: rotations[nextIndex], + }; +} + +export function checkCollision( + board: number[][], + piece: Tetromino, + position: Position +): boolean { + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + if (piece.shape[y][x]) { + const newX = position.x + x; + const newY = position.y + y; + + if ( + newX < 0 || + newX >= BOARD_WIDTH || + newY >= BOARD_HEIGHT || + (newY >= 0 && board[newY][newX]) + ) { + return true; + } + } + } + } + return false; +} + +export function mergePieceToBoard( + board: number[][], + piece: Tetromino, + position: Position +): number[][] { + const newBoard = board.map(row => [...row]); + + for (let y = 0; y < piece.shape.length; y++) { + for (let x = 0; x < piece.shape[y].length; x++) { + if (piece.shape[y][x]) { + const boardY = position.y + y; + const boardX = position.x + x; + if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) { + // Store color index (1-7) based on piece type + const colorIndex = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'].indexOf(piece.type) + 1; + newBoard[boardY][boardX] = colorIndex; + } + } + } + } + + return newBoard; +} + +export function clearLines(board: number[][]): { newBoard: number[][], linesCleared: number } { + let linesCleared = 0; + const newBoard = board.filter(row => { + if (row.every(cell => cell !== 0)) { + linesCleared++; + return false; + } + return true; + }); + + while (newBoard.length < BOARD_HEIGHT) { + newBoard.unshift(Array(BOARD_WIDTH).fill(0)); + } + + return { newBoard, linesCleared }; +} + +// NES Tetris speed curve (frames per drop) +const FRAMES_PER_DROP: Record = { + 0: 48, 1: 43, 2: 38, 3: 33, 4: 28, 5: 23, 6: 18, 7: 13, 8: 8, 9: 6, + 10: 5, 11: 5, 12: 5, 13: 4, 14: 4, 15: 4, 16: 3, 17: 3, 18: 3, + 19: 2, 20: 2, 21: 2, 22: 2, 23: 2, 24: 2, 25: 2, 26: 2, 27: 2, 28: 2, + 29: 1, // Kill screen +}; + +export function getFramesPerDrop(level: number): number { + if (level >= 29) return 1; + return FRAMES_PER_DROP[level] || 1; +} + +export function getDropSpeed(level: number): number { + // Convert frames to milliseconds (assuming 60 FPS) + const frames = getFramesPerDrop(level); + return Math.max((frames * 1000) / 60, 50); // Minimum 50ms +} + +export function calculateScore(linesCleared: number, level: number): number { + // NES Tetris scoring formula + const baseScores: Record = { + 0: 0, + 1: 40, // Single + 2: 100, // Double + 3: 300, // Triple + 4: 1200, // Tetris + }; + return (baseScores[linesCleared] || 0) * (level + 1); +} + +export function getColorForCell(cellValue: number): string { + const colors = ['transparent', '#00f0f0', '#f0f000', '#a000f0', '#00f000', '#f00000', '#0000f0', '#f0a000']; + return colors[cellValue] || 'transparent'; +} + +export function getStartPosition(): Position { + return { x: Math.floor(BOARD_WIDTH / 2) - 2, y: 0 }; +} diff --git a/packages/ui/src/apps/tetris/neuralAI.ts b/packages/ui/src/apps/tetris/neuralAI.ts new file mode 100644 index 00000000..1cef4dfc --- /dev/null +++ b/packages/ui/src/apps/tetris/neuralAI.ts @@ -0,0 +1,145 @@ +// Neural Network AI Player for Tetris +import { NeuralNetwork, TrainingExample } from './neuralNetwork'; +import { AIWeights } from './aiPlayer'; + +const NETWORK_STORAGE_KEY = 'tetris-neural-network'; + +// Network configuration +const NETWORK_CONFIG = { + inputSize: 13, // 13 board features (same as weight count) + hiddenLayers: [20, 15], // Two hidden layers + outputSize: 13, // 13 weight outputs + learningRate: 0.01, +}; + +// Extract features from board state for neural network input +export const extractBoardFeatures = (debugInfo: any): number[] => { + if (!debugInfo) { + return Array(13).fill(0); + } + + // Normalize features to 0-1 range for better training + return [ + Math.min(debugInfo.linesCleared / 4, 1), // 0-4 lines max + Math.min(debugInfo.contacts / 10, 1), // 0-10 contacts typical + Math.min(debugInfo.holes / 20, 1), // 0-20 holes + Math.min(debugInfo.holesCreated / 5, 1), // 0-5 new holes + Math.min(debugInfo.overhangs / 20, 1), // 0-20 overhangs + Math.min(debugInfo.overhangsCreated / 5, 1), // 0-5 new overhangs + Math.min(debugInfo.overhangsFilled / 5, 1), // 0-5 filled + Math.min(debugInfo.heightAdded / 10, 1), // 0-10 height added + Math.min(debugInfo.wells / 10, 1), // 0-10 wells + Math.min(debugInfo.wellDepth / 10, 1), // 0-10 depth + Math.min(debugInfo.bumpiness / 20, 1), // 0-20 bumpiness + Math.min(debugInfo.maxHeight / 20, 1), // 0-20 max height + Math.min(parseFloat(debugInfo.avgHeight) / 10, 1), // 0-10 avg height + ]; +}; + +// Convert neural network output to weights +export const outputToWeights = (output: number[]): AIWeights => { + // Scale outputs to reasonable weight ranges + return { + lineCleared: output[0] * 20000, // 0-20000 + contact: output[1] * 500, // 0-500 + holes: output[2] * 2000, // 0-2000 + holesCreated: output[3] * 2000, // 0-2000 + overhangs: output[4] * 2000, // 0-2000 + overhangsCreated: output[5] * 3000, // 0-3000 + overhangsFilled: output[6] * 1000, // 0-1000 + heightAdded: output[7] * 2000, // 0-2000 + wells: output[8] * 500, // 0-500 + wellDepthSquared: output[9] * 500, // 0-500 + bumpiness: output[10] * 200, // 0-200 + maxHeight: output[11] * 200, // 0-200 + avgHeight: output[12] * 100, // 0-100 + }; +}; + +// Convert weights to neural network target output +export const weightsToOutput = (weights: AIWeights): number[] => { + return [ + Math.min(weights.lineCleared / 20000, 1), + Math.min(weights.contact / 500, 1), + Math.min(weights.holes / 2000, 1), + Math.min(weights.holesCreated / 2000, 1), + Math.min(weights.overhangs / 2000, 1), + Math.min(weights.overhangsCreated / 3000, 1), + Math.min(weights.overhangsFilled / 1000, 1), + Math.min(weights.heightAdded / 2000, 1), + Math.min(weights.wells / 500, 1), + Math.min(weights.wellDepthSquared / 500, 1), + Math.min(weights.bumpiness / 200, 1), + Math.min(weights.maxHeight / 200, 1), + Math.min(weights.avgHeight / 100, 1), + ]; +}; + +// Calculate reward based on game performance +export const calculateReward = (score: number, lines: number, level: number): number => { + // Reward function: prioritize score and lines cleared + // Normalize to 0-1 range, with exponential scaling for high scores + const scoreReward = Math.min(score / 100000, 1) ** 0.5; // Square root for smoother scaling + const linesReward = Math.min(lines / 100, 1); + const levelReward = Math.min(level / 10, 1); + + // Weighted combination + return scoreReward * 0.6 + linesReward * 0.3 + levelReward * 0.1; +}; + +// Get or create neural network +export const getNeuralNetwork = (): NeuralNetwork => { + const stored = localStorage.getItem(NETWORK_STORAGE_KEY); + if (stored) { + try { + return NeuralNetwork.fromJSON(stored); + } catch (e) { + console.warn('Failed to load neural network, creating new one'); + } + } + return new NeuralNetwork(NETWORK_CONFIG); +}; + +// Save neural network +export const saveNeuralNetwork = (network: NeuralNetwork): void => { + localStorage.setItem(NETWORK_STORAGE_KEY, network.toJSON()); +}; + +// Train network on game history +export const trainOnHistory = (network: NeuralNetwork): void => { + const history = JSON.parse(localStorage.getItem('tetris-game-history') || '[]'); + + if (history.length === 0) return; + + const examples: TrainingExample[] = history.map((game: any) => { + // We don't have board features from history, so we'll use a simplified approach + // In a real implementation, you'd store board states during gameplay + const reward = calculateReward(game.score, game.lines, game.level); + + // Use dummy input (in real version, store actual board states) + const input = Array(13).fill(0.5); + const expectedOutput = weightsToOutput(game.weights); + + return { input, expectedOutput, reward }; + }); + + // Train in batches + const batchSize = 10; + for (let i = 0; i < examples.length; i += batchSize) { + const batch = examples.slice(i, i + batchSize); + network.train(batch); + } + + saveNeuralNetwork(network); +}; + +// Get weights from neural network prediction +export const getNeuralWeights = (boardFeatures?: number[]): AIWeights => { + const network = getNeuralNetwork(); + + // If no features provided, use neutral input + const input = boardFeatures || Array(13).fill(0.5); + const output = network.predict(input); + + return outputToWeights(output); +}; diff --git a/packages/ui/src/apps/tetris/neuralNetwork.ts b/packages/ui/src/apps/tetris/neuralNetwork.ts new file mode 100644 index 00000000..17240dee --- /dev/null +++ b/packages/ui/src/apps/tetris/neuralNetwork.ts @@ -0,0 +1,281 @@ +// Neural Network for Tetris AI +// Uses a simple feedforward network with backpropagation + +export interface NeuralNetworkConfig { + inputSize: number; + hiddenLayers: number[]; + outputSize: number; + learningRate: number; +} + +export interface TrainingExample { + input: number[]; + expectedOutput: number[]; + reward: number; +} + +class Matrix { + rows: number; + cols: number; + data: number[][]; + + constructor(rows: number, cols: number) { + this.rows = rows; + this.cols = cols; + this.data = Array(rows).fill(0).map(() => Array(cols).fill(0)); + } + + static fromArray(arr: number[]): Matrix { + const m = new Matrix(arr.length, 1); + for (let i = 0; i < arr.length; i++) { + m.data[i][0] = arr[i]; + } + return m; + } + + toArray(): number[] { + const arr: number[] = []; + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + arr.push(this.data[i][j]); + } + } + return arr; + } + + randomize(): void { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.data[i][j] = Math.random() * 2 - 1; // -1 to 1 + } + } + } + + static multiply(a: Matrix, b: Matrix): Matrix { + if (a.cols !== b.rows) { + throw new Error('Matrix dimensions do not match for multiplication'); + } + const result = new Matrix(a.rows, b.cols); + for (let i = 0; i < result.rows; i++) { + for (let j = 0; j < result.cols; j++) { + let sum = 0; + for (let k = 0; k < a.cols; k++) { + sum += a.data[i][k] * b.data[k][j]; + } + result.data[i][j] = sum; + } + } + return result; + } + + add(n: Matrix | number): void { + if (n instanceof Matrix) { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.data[i][j] += n.data[i][j]; + } + } + } else { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.data[i][j] += n; + } + } + } + } + + static subtract(a: Matrix, b: Matrix): Matrix { + const result = new Matrix(a.rows, a.cols); + for (let i = 0; i < a.rows; i++) { + for (let j = 0; j < a.cols; j++) { + result.data[i][j] = a.data[i][j] - b.data[i][j]; + } + } + return result; + } + + static transpose(m: Matrix): Matrix { + const result = new Matrix(m.cols, m.rows); + for (let i = 0; i < m.rows; i++) { + for (let j = 0; j < m.cols; j++) { + result.data[j][i] = m.data[i][j]; + } + } + return result; + } + + map(func: (val: number) => number): Matrix { + const result = new Matrix(this.rows, this.cols); + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + result.data[i][j] = func(this.data[i][j]); + } + } + return result; + } + + static hadamard(a: Matrix, b: Matrix): Matrix { + const result = new Matrix(a.rows, a.cols); + for (let i = 0; i < a.rows; i++) { + for (let j = 0; j < a.cols; j++) { + result.data[i][j] = a.data[i][j] * b.data[i][j]; + } + } + return result; + } + + scale(n: number): void { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.data[i][j] *= n; + } + } + } + + copy(): Matrix { + const result = new Matrix(this.rows, this.cols); + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + result.data[i][j] = this.data[i][j]; + } + } + return result; + } +} + +// Activation functions +const sigmoid = (x: number): number => 1 / (1 + Math.exp(-x)); +const sigmoidDerivative = (y: number): number => y * (1 - y); +const relu = (x: number): number => Math.max(0, x); +const reluDerivative = (y: number): number => y > 0 ? 1 : 0; + +export class NeuralNetwork { + config: NeuralNetworkConfig; + weights: Matrix[]; + biases: Matrix[]; + + constructor(config: NeuralNetworkConfig) { + this.config = config; + this.weights = []; + this.biases = []; + + // Initialize weights and biases for each layer + const layerSizes = [config.inputSize, ...config.hiddenLayers, config.outputSize]; + + for (let i = 0; i < layerSizes.length - 1; i++) { + const w = new Matrix(layerSizes[i + 1], layerSizes[i]); + w.randomize(); + this.weights.push(w); + + const b = new Matrix(layerSizes[i + 1], 1); + b.randomize(); + this.biases.push(b); + } + } + + // Forward propagation + predict(inputArray: number[]): number[] { + let current = Matrix.fromArray(inputArray); + + for (let i = 0; i < this.weights.length; i++) { + current = Matrix.multiply(this.weights[i], current); + current.add(this.biases[i]); + + // Use ReLU for hidden layers, sigmoid for output + if (i < this.weights.length - 1) { + current = current.map(relu); + } else { + current = current.map(sigmoid); + } + } + + return current.toArray(); + } + + // Backpropagation training + train(examples: TrainingExample[]): void { + for (const example of examples) { + // Forward pass - store all activations + const activations: Matrix[] = [Matrix.fromArray(example.input)]; + const zValues: Matrix[] = []; + + for (let i = 0; i < this.weights.length; i++) { + const z = Matrix.multiply(this.weights[i], activations[activations.length - 1]); + z.add(this.biases[i]); + zValues.push(z); + + let a: Matrix; + if (i < this.weights.length - 1) { + a = z.map(relu); + } else { + a = z.map(sigmoid); + } + activations.push(a); + } + + // Backward pass + const target = Matrix.fromArray(example.expectedOutput); + const weightGradients: Matrix[] = []; + const biasGradients: Matrix[] = []; + + // Output layer error (scaled by reward) + let delta = Matrix.subtract(activations[activations.length - 1], target); + delta.scale(example.reward); // Reward modulation + + const outputDerivative = activations[activations.length - 1].map(sigmoidDerivative); + delta = Matrix.hadamard(delta, outputDerivative); + + // Calculate gradients for each layer (backwards) + for (let i = this.weights.length - 1; i >= 0; i--) { + const weightGrad = Matrix.multiply(delta, Matrix.transpose(activations[i])); + weightGradients.unshift(weightGrad); + biasGradients.unshift(delta.copy()); + + if (i > 0) { + // Propagate error to previous layer + delta = Matrix.multiply(Matrix.transpose(this.weights[i]), delta); + const derivative = zValues[i - 1].map(reluDerivative); + delta = Matrix.hadamard(delta, derivative); + } + } + + // Update weights and biases + for (let i = 0; i < this.weights.length; i++) { + weightGradients[i].scale(this.config.learningRate); + this.weights[i].add(weightGradients[i].map(x => -x)); + + biasGradients[i].scale(this.config.learningRate); + this.biases[i].add(biasGradients[i].map(x => -x)); + } + } + } + + // Serialize to JSON for storage + toJSON(): string { + return JSON.stringify({ + config: this.config, + weights: this.weights.map(w => w.data), + biases: this.biases.map(b => b.data), + }); + } + + // Deserialize from JSON + static fromJSON(json: string): NeuralNetwork { + const data = JSON.parse(json); + const nn = new NeuralNetwork(data.config); + + nn.weights = data.weights.map((w: number[][]) => { + const matrix = new Matrix(w.length, w[0].length); + matrix.data = w; + return matrix; + }); + + nn.biases = data.biases.map((b: number[][]) => { + const matrix = new Matrix(b.length, b[0].length); + matrix.data = b; + return matrix; + }); + + return nn; + } +} diff --git a/packages/ui/src/apps/tetris/types.ts b/packages/ui/src/apps/tetris/types.ts new file mode 100644 index 00000000..9c349faa --- /dev/null +++ b/packages/ui/src/apps/tetris/types.ts @@ -0,0 +1,36 @@ +export type TetrominoType = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'; + +export interface Position { + x: number; + y: number; +} + +export interface Tetromino { + type: TetrominoType; + shape: number[][]; + color: string; +} + +export interface GameState { + board: number[][]; + currentPiece: Tetromino | null; + currentPosition: Position; + nextPiece: Tetromino | null; + score: number; + level: number; + lines: number; + gameOver: boolean; + isPaused: boolean; + isAutoPlay: boolean; +} + +export interface Move { + position: Position; + rotation: number; + score: number; + breakdown?: any; +} + +export const BOARD_WIDTH = 10; +export const BOARD_HEIGHT = 20; +export const CELL_SIZE = 30; diff --git a/packages/ui/src/components/PageActions.tsx b/packages/ui/src/components/PageActions.tsx index 6cee4793..9d20fe3a 100644 --- a/packages/ui/src/components/PageActions.tsx +++ b/packages/ui/src/components/PageActions.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree } from "lucide-react"; +import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -409,6 +409,18 @@ draft: ${!page.visible} } }; + const handleDumpJson = async () => { + try { + const pageJson = JSON.stringify(page, null, 2); + console.log('Page JSON:', pageJson); + await navigator.clipboard.writeText(pageJson); + toast.success("Page JSON dumped to console and clipboard"); + } catch (e) { + console.error("Failed to dump JSON", e); + toast.error("Failed to dump JSON"); + } + }; + return (
{/* Share Menu */} @@ -565,6 +577,20 @@ draft: ${!page.visible} parentId={page.id} /> + {/* Dev Mode: Dump JSON */} + {import.meta.env.DEV && ( + + )} + {onDelete && (