diff --git a/README.md b/README.md
index 802b8cd..270d69b 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ bun add @vimazing/vim-sudoku
```tsx
import { useGame } from "@vimazing/vim-sudoku";
-import "@vimazing/vim-sudoku/sudoku.css";
+import "@vimazing/vim-sudoku/game.css";
export function SudokuGame() {
const { containerRef, initGame, gameStatus, scoreManager } = useGame();
@@ -80,6 +80,8 @@ export function SudokuGame() {
}
```
+> **Note:** You must manually import the CSS file. The package exports styles but does not auto-import them, giving you control over when and how styles are loaded.
+
---
## VIM Modal Editing
diff --git a/bun.lock b/bun.lock
index 935fccc..a7cbb10 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,7 @@
"": {
"name": "@vimazing/vim-maze",
"devDependencies": {
+ "@types/bun": "^1.3.0",
"@types/node": "^24.7.2",
"@types/react": "^19.2.2",
"typescript": "~5.9.3",
@@ -14,10 +15,14 @@
},
},
"packages": {
+ "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
+
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+ "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 22acd24..b02fc63 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -1,70 +1,15 @@
-import { useEffect } from "react";
-import { useGame } from "../../src/useGame/useGame";
+import { useGame } from "../../src/useGame";
import { useKeyBindings } from './useKeyBindings';
-import { useMounted } from './useMounted';
-import { GameOverlay } from './GameOverlay';
-import "../../src/sudoku.css";
-
-function formatTime(ms: number): string {
- const seconds = Math.floor(ms / 1000);
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
- return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
-}
+import "../../src/game.css";
export default function App() {
- const mounted = useMounted();
- const {
- containerRef,
- initGame,
- solveBoard,
- scoreManager,
- boardManager,
- keyLog,
- gameStatus,
- } = useGame(useKeyBindings);
-
- const { timeValue, finalScore } = scoreManager;
-
- const stringed = boardManager.boardToString(true);
-
- useEffect(() => {
- console.log(stringed);
- }, [stringed]);
+ const { containerRef } = useGame({}, useKeyBindings);
return (
VIMazing Sudoku
-
- {/* Scoreboard */}
-
-
- Time: {formatTime(timeValue)}
-
-
- Keystrokes: {keyLog.length}
-
- {gameStatus === "game-won" && finalScore !== null && (
-
- Score: {finalScore.toLocaleString()}
-
- )}
-
-
-
-
-
-
);
diff --git a/example/src/GameOverlay.tsx b/example/src/GameOverlay.tsx
deleted file mode 100644
index e0d6340..0000000
--- a/example/src/GameOverlay.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import type { GameStatus, UseScoreType } from "../../src/types";
-import { useIsMobile } from "./useIsMobile";
-import { MobileGameOverlay } from "./MobileGameOverlay";
-
-type GameOverlayProps = {
- gameStatus: GameStatus;
- scoreManager: UseScoreType;
-};
-
-export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
- const isMobile = useIsMobile();
- if (isMobile) return
-
- if (gameStatus === "started") return null;
-
- return (
-
- {gameStatus === "waiting" && }
- {gameStatus === "game-over" && }
- {gameStatus === "game-won" && }
-
- );
-};
-
-export const GameWaitingContent = () => {
- return (
-
-
-
Press New Puzzle to start
-
-
Navigation Mode (default):
-
h,j,k,l to move around
-
R or C to enter edit mode
-
-
Edit Mode:
-
Type 1-9 to fill cell
-
Esc or Enter to exit edit mode
-
-
- Try vim motions: $, ^, 0, counted moves
-
-
-
-
- );
-};
-
-export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
- const finalScore = scoreManager.finalScore ?? 0;
-
- return (
-
-
π Puzzle Solved!
-
Final Score:
-
{finalScore.toLocaleString()}
-
- max
- (
- 0
- ,
- 100000
- -
- timeInSeconds
- *
- 10
- )
-
-
Click New Puzzle to play again
-
- );
-};
-
-export const GameOverContent = () => {
- return (
-
-
Game Over
-
Press i to play again
-
- );
-};
-
-export const GameOverlayContainer = ({
- children,
-}: {
- children: React.ReactNode;
-}) => {
- return (
-
- {children}
-
- );
-};
-
-
-
diff --git a/example/src/MobileGameOverlay.tsx b/example/src/MobileGameOverlay.tsx
deleted file mode 100644
index 10f6632..0000000
--- a/example/src/MobileGameOverlay.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import type { GameStatus, UseScoreType } from "../../src/types";
-
-type MobileGameOverlayProps = {
- gameStatus: GameStatus;
- scoreManager: UseScoreType;
-};
-
-export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverlayProps) => {
- if (gameStatus === "started") return null;
-
- return (
-
- {gameStatus === "waiting" && }
- {gameStatus === "game-over" && }
- {gameStatus === "game-won" && }
-
- );
-};
-
-export const GameWaitingContent = () => {
- return (
-
-
-
Tap New Puzzle to start
-
VIM motions available
via on-screen keyboard
-
-
- );
-};
-
-export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
- const finalScore = scoreManager.finalScore ?? 0;
-
- return (
-
-
π Solved!
-
Final Score:
-
{finalScore.toLocaleString()}
-
- max
- (
- 0
- ,
- 100000
- -
- time
- *
- 10
- )
-
-
Tap to play again
-
- );
-};
-
-export const GameOverContent = () => {
- return (
-
- Game Over
-
- );
-};
-
-export const GameOverlayContainer = ({
- children,
-}: {
- children: React.ReactNode;
-}) => {
- return (
-
- {children}
-
- );
-};
-
-
diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts
index 9290af4..63f5690 100644
--- a/example/src/useKeyBindings.ts
+++ b/example/src/useKeyBindings.ts
@@ -1,22 +1,21 @@
import { useEffect } from "react";
-import type { UseGameType } from "../../src/";
+import type { GameManager } from "../../src";
-export const useKeyBindings = (gameManager: UseGameType) => {
- const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
+export const useKeyBindings = (gameManager: GameManager) => {
+ const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog } = gameManager;
useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- console.log(e.key);
+ const handler = (ev: KeyboardEvent) => {
// start game
- if (e.code === "Space") {
+ if (ev.code === "Space") {
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
- clearLog();
- resetCount();
+ clearKeyLog();
+ renderBoard();
if (gameStatus === "game-over" || gameStatus === "game-won") {
- stopGame();
- setTimeout(() => initGame(), 0);
+ quitGame();
+ setTimeout(() => startGame(), 0);
} else {
- initGame();
+ startGame();
}
return;
}
@@ -25,23 +24,15 @@ export const useKeyBindings = (gameManager: UseGameType) => {
// ignore other keys while waiting
if (gameStatus === "waiting") return;
- // cancel count or stop game
- if (e.key === "Escape") {
- stopGame();
- resetCount();
- return;
- }
-
// quit
- if (e.key === "q" || e.key === "Q") {
- stopGame();
- resetCount();
+ if (ev.key === "q" || ev.key === "Q") {
+ quitGame();
return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
- }, [gameStatus, initGame, stopGame, clearLog, resetCount]);
+ }, [gameStatus]);
};
diff --git a/example/tsconfig.json b/example/tsconfig.json
index 8538eed..0f68697 100644
--- a/example/tsconfig.json
+++ b/example/tsconfig.json
@@ -31,8 +31,8 @@
/* === Path aliases (optional) === */
"baseUrl": ".",
"paths": {
- "@vimazing/vim-maze": ["../dist/index.d.ts"],
- "@vimazing/vim-maze/*": ["../dist/*"]
+ "@vimazing/vim-sudoku": ["../src/index.ts"],
+ "@vimazing/vim-sudoku/*": ["../src/*"]
}
},
"include": ["src", "vite.config.ts", "global.d.ts"],
diff --git a/example/vite.config.ts b/example/vite.config.ts
index f2794ab..03bb162 100644
--- a/example/vite.config.ts
+++ b/example/vite.config.ts
@@ -11,7 +11,7 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
- "@vimazing/vim-maze": libSource, // use source during dev
+ "@vimazing/vim-sudoku": libSource, // use source during dev
},
},
server: {
diff --git a/package-lock.json b/package-lock.json
index 763f377..cc2fadc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@vimazing/vim-maze",
- "version": "0.10.0",
+ "version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vimazing/vim-maze",
- "version": "0.10.0",
+ "version": "0.11.0",
"license": "MIT",
"devDependencies": {
"typescript": "~5.9.3"
diff --git a/package.json b/package.json
index f0193fb..8a67b42 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@vimazing/vim-sudoku",
- "version": "0.10.0",
+ "version": "0.11.0",
"description": "VIMazing VIM Sudoku engine β a lightweight, typed React hook set for sudoku games.",
"author": "AndrΓ© Padez ",
"repository": {
@@ -52,13 +52,14 @@
"dist"
],
"scripts": {
- "build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/sudoku.css', 'dist/sudoku.css');\"",
+ "build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/game.css', 'dist/game.css');\"",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"react": "^19.1.1"
},
"devDependencies": {
+ "@types/bun": "^1.3.0",
"@types/node": "^24.7.2",
"@types/react": "^19.2.2",
"typescript": "~5.9.3"
diff --git a/src/sudoku.css b/src/game.css
similarity index 96%
rename from src/sudoku.css
rename to src/game.css
index 7aca8d4..09454f5 100644
--- a/src/sudoku.css
+++ b/src/game.css
@@ -2,11 +2,10 @@
π§ Controls (tweak these)
========================================================= */
:root {
- --sudoku-size-multiplier: 3;
- --cell-size: calc(32px * var(--sudoku-size-multiplier));
- --cell-font-size: calc(16px * var(--sudoku-size-multiplier));
- --grid-gap: calc(2px * var(--sudoku-size-multiplier));
- --box-separator: calc(3px * var(--sudoku-size-multiplier));
+ --cell-size: 96px;
+ --cell-font-size: 48px;
+ --grid-gap: 6px;
+ --box-separator: 9px;
}
/* =========================================================
diff --git a/src/index.ts b/src/index.ts
index 630a69f..5eb33b9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,32 @@
-import './sudoku.css';
-export * from './types';
-export * from "./useGame";
+export { useGame } from './useGame';
+export type { GameManager, GameOptions } from './types';
+
+export { useBoard } from './useBoard';
+export { useCursor } from './useCursor';
+export { useGameStatus } from './useGameStatus';
+export { useScore } from './useScore';
+
+export type {
+ Coord,
+ SudokuBoard,
+ SudokuCell,
+ Difficulty,
+ BoardManager
+} from './useBoard/types';
+
+export type {
+ CursorManager,
+ CursorMode,
+ GameKeyManager,
+ KeyLogEntry
+} from './useCursor/types';
+
+export type {
+ GameStatus,
+ GameStatusManager
+} from './useGameStatus/types';
+
+export type {
+ ScoreManager,
+ TimerManager
+} from './useScore/types';
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index d09f67c..91f9be7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,20 +1,35 @@
-import { useGame } from "./useGame";
-import { useScore } from "./useScore";
+import type { RefObject } from 'react';
-export type UseGameType = ReturnType;
-export type UseScoreType = ReturnType;
+export type { Coord, SudokuBoard, SudokuCell, Difficulty } from './useBoard/types';
+export type { CursorManager, CursorMode, GameKeyManager, KeyLogEntry } from './useCursor/types';
+export type { GameStatus, GameStatusManager } from './useGameStatus/types';
+export type { ScoreManager, TimerManager } from './useScore/types';
-export type {
- Difficulty,
- DifficultyName,
-} from "./lib/Sudoku";
+export type GameOptions = {
+ difficulty?: Difficulty;
+};
-export type GamePhase = "idle" | "playing";
-export type PlayStatus = "started" | "game-over" | "game-won";
-export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
-
-export const isPlaying = (status: GameStatus): status is PlayStatus =>
- status === "started" || status === "game-over" || status === "game-won";
-
-export const getGamePhase = (status: GameStatus): GamePhase =>
- status === "waiting" ? "idle" : "playing";
+export type GameManager = {
+ containerRef: RefObject;
+ renderBoard: () => void;
+ gameStatus: GameStatus;
+ setGameStatus: (status: GameStatus) => void;
+ startGame: () => void;
+ togglePause: (pause?: boolean) => void;
+ quitGame: () => void;
+ cursor: CursorManager;
+ scoreManager: ScoreManager;
+ keyLog: KeyLogEntry[];
+ clearKeyLog: () => void;
+ getKeyLog: () => KeyLogEntry[];
+ board: {
+ generateBoard: (difficulty: Difficulty) => void;
+ solveBoard: () => boolean;
+ resetBoard: () => void;
+ getCell: (row: number, col: number) => any;
+ setCell: (row: number, col: number, value: string) => boolean;
+ canEditCell: (row: number, col: number) => boolean;
+ isComplete: () => boolean;
+ isFullyValid: () => boolean;
+ };
+};
\ No newline at end of file
diff --git a/src/useBoard/SudokuGenerator.ts b/src/useBoard/SudokuGenerator.ts
new file mode 100644
index 0000000..585150a
--- /dev/null
+++ b/src/useBoard/SudokuGenerator.ts
@@ -0,0 +1,316 @@
+export type DifficultyName = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
+export type Difficulty = DifficultyName | number;
+
+const DIGITS = '123456789';
+const ROWS = 'ABCDEFGHI';
+const COLS = DIGITS;
+let SQUARES: string[] = [];
+let UNITS: string[][] = [];
+let SQUARE_UNITS_MAP: Record = {};
+let SQUARE_PEERS_MAP: Record = {};
+
+const MIN_GIVENS = 17;
+const NR_SQUARES = 81;
+
+const DIFFICULTY: Record = {
+ easy: 62,
+ medium: 53,
+ hard: 44,
+ 'very-hard': 35,
+ insane: 26,
+ inhuman: 17,
+};
+
+const BLANK_CHAR = '.';
+const BLANK_BOARD = BLANK_CHAR.repeat(NR_SQUARES);
+
+function initialize() {
+ SQUARES = cross(ROWS, COLS);
+ UNITS = getAllUnits(ROWS, COLS);
+ SQUARE_UNITS_MAP = getSquareUnitsMap(SQUARES, UNITS);
+ SQUARE_PEERS_MAP = getSquarePeersMap(SQUARES, SQUARE_UNITS_MAP);
+}
+
+function cross(a: string, b: string) {
+ const res: string[] = [];
+ for (const ai of a) {
+ for (const bi of b) {
+ res.push(ai + bi);
+ }
+ }
+ return res;
+}
+
+function inStr(v: string, seq: string) {
+ return seq.indexOf(v) !== -1;
+}
+
+function shuffle(seq: T[]): T[] {
+ const arr = [...seq];
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ return arr;
+}
+
+function randRange(max: number, min = 0) {
+ return Math.floor(Math.random() * (max - min)) + min;
+}
+
+function stripDups(seq: T[]): T[] {
+ return Array.from(new Set(seq));
+}
+
+function forceRange(nr: number, max: number, min = 0) {
+ return Math.min(Math.max(nr || 0, min), max);
+}
+
+function getAllUnits(rows: string, cols: string) {
+ const units: string[][] = [];
+ for (const r of rows) units.push(cross(r, cols));
+ for (const c of cols) units.push(cross(rows, c));
+ const row_blocks = ['ABC', 'DEF', 'GHI'];
+ const col_blocks = ['123', '456', '789'];
+ for (const rb of row_blocks) {
+ for (const cb of col_blocks) {
+ units.push(cross(rb, cb));
+ }
+ }
+ return units;
+}
+
+function getSquareUnitsMap(squares: string[], units: string[][]) {
+ const map: Record = {};
+ for (const s of squares) {
+ const list: string[][] = [];
+ for (const u of units) {
+ if (u.includes(s)) list.push(u);
+ }
+ map[s] = list;
+ }
+ return map;
+}
+
+function getSquarePeersMap(squares: string[], units_map: Record) {
+ const map: Record = {};
+ for (const s of squares) {
+ const peers: string[] = [];
+ for (const unit of units_map[s]) {
+ for (const sq of unit) {
+ if (sq !== s && !peers.includes(sq)) peers.push(sq);
+ }
+ }
+ map[s] = peers;
+ }
+ return map;
+}
+
+function getSquareValsMap(board: string) {
+ const map: Record = {};
+ if (board.length !== SQUARES.length) throw 'Board length mismatch';
+ for (let i = 0; i < SQUARES.length; i++) {
+ map[SQUARES[i]] = board[i];
+ }
+ return map;
+}
+
+function validateBoard(board: string): true | string {
+ if (!board) return 'Empty board';
+ if (board.length !== NR_SQUARES) return 'Invalid board size';
+ for (let i = 0; i < board.length; i++) {
+ const c = board[i];
+ if (!inStr(c, DIGITS) && c !== BLANK_CHAR) {
+ return `Invalid char at ${i}: ${c}`;
+ }
+ }
+ return true;
+}
+
+function getCandidatesMap(board: string): Record | false {
+ const report = validateBoard(board);
+ if (report !== true) throw report;
+
+ const map: Record = {};
+ const squares_values = getSquareValsMap(board);
+
+ for (const s of SQUARES) map[s] = DIGITS;
+
+ for (const s in squares_values) {
+ const val = squares_values[s];
+ if (inStr(val, DIGITS)) {
+ const new_candidates = assign(map, s, val);
+ if (!new_candidates) return false;
+ }
+ }
+ return map;
+}
+
+function assign(candidates: Record, square: string, val: string) {
+ const others = candidates[square].replace(val, '');
+ for (const v of others) {
+ const next = eliminate(candidates, square, v);
+ if (!next) return false;
+ }
+ return candidates;
+}
+
+function eliminate(candidates: Record, square: string, val: string) {
+ if (!inStr(val, candidates[square])) return candidates;
+
+ candidates[square] = candidates[square].replace(val, '');
+ const n = candidates[square].length;
+
+ if (n === 1) {
+ const target_val = candidates[square];
+ for (const peer of SQUARE_PEERS_MAP[square]) {
+ const next = eliminate(candidates, peer, target_val);
+ if (!next) return false;
+ }
+ } else if (n === 0) return false;
+
+ for (const unit of SQUARE_UNITS_MAP[square]) {
+ const places: string[] = [];
+ for (const sq of unit) {
+ if (inStr(val, candidates[sq])) places.push(sq);
+ }
+ if (places.length === 0) return false;
+ else if (places.length === 1) {
+ const next = assign(candidates, places[0], val);
+ if (!next) return false;
+ }
+ }
+ return candidates;
+}
+
+function search(candidates: Record, reverse: boolean): any {
+ if (!candidates) return false;
+ reverse = reverse || false;
+
+ let max_nr_candidates = 0;
+ for (const s of SQUARES) {
+ const n = candidates[s].length;
+ if (n > max_nr_candidates) {
+ max_nr_candidates = n;
+ }
+ }
+ if (max_nr_candidates === 1) return candidates;
+
+ let min_nr_candidates = 10;
+ let min_square = '';
+ for (const s of SQUARES) {
+ const n = candidates[s].length;
+ if (n < min_nr_candidates && n > 1) {
+ min_nr_candidates = n;
+ min_square = s;
+ }
+ }
+
+ const min_candidates = candidates[min_square];
+ if (!reverse) {
+ for (const v of min_candidates) {
+ const copy = JSON.parse(JSON.stringify(candidates));
+ const next = search(assign(copy, min_square, v), reverse);
+ if (next) return next;
+ }
+ } else {
+ for (let i = min_candidates.length - 1; i >= 0; --i) {
+ const v = min_candidates[i];
+ const copy = JSON.parse(JSON.stringify(candidates));
+ const next = search(assign(copy, min_square, v), reverse);
+ if (next) return next;
+ }
+ }
+ return false;
+}
+
+export class SudokuGenerator {
+ static generate(difficulty: Difficulty, unique?: boolean): string {
+ let numericDifficulty: number;
+ if (typeof difficulty === 'string' || typeof difficulty === 'undefined') {
+ numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
+ } else {
+ numericDifficulty = difficulty;
+ }
+
+ numericDifficulty = forceRange(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
+ unique = unique ?? true;
+
+ const blank_board = BLANK_BOARD;
+ let candidates = getCandidatesMap(blank_board);
+
+ if (!candidates) return SudokuGenerator.generate(numericDifficulty);
+
+ const shuffled_squares = shuffle(SQUARES);
+
+ for (const square of shuffled_squares) {
+ const rand_candidate_idx = randRange(candidates[square].length);
+ const rand_candidate = candidates[square][rand_candidate_idx];
+ if (!assign(candidates, square, rand_candidate)) break;
+
+ const single_candidates: string[] = [];
+ for (const s of SQUARES) {
+ if (candidates[s].length === 1) single_candidates.push(candidates[s]);
+ }
+
+ if (
+ single_candidates.length >= numericDifficulty &&
+ stripDups(single_candidates).length >= 8
+ ) {
+ let board = '';
+ let givens_idxs: number[] = [];
+
+ for (let i = 0; i < SQUARES.length; i++) {
+ const s = SQUARES[i];
+ if (candidates[s].length === 1) {
+ board += candidates[s];
+ givens_idxs.push(i);
+ } else {
+ board += BLANK_CHAR;
+ }
+ }
+
+ if (givens_idxs.length > numericDifficulty) {
+ givens_idxs = shuffle(givens_idxs);
+ for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
+ const target = givens_idxs[i];
+ board =
+ board.substring(0, target) +
+ BLANK_CHAR +
+ board.substring(target + 1);
+ }
+ }
+
+ if (SudokuGenerator.solve(board)) return board;
+ }
+ }
+
+ return SudokuGenerator.generate(numericDifficulty);
+ }
+
+ static solve(board: string, reverse?: boolean): string | false {
+ const report = validateBoard(board);
+ if (report !== true) throw report;
+
+ let nr_givens = 0;
+ for (const ch of board) {
+ if (ch !== BLANK_CHAR && inStr(ch, DIGITS)) nr_givens++;
+ }
+ if (nr_givens < MIN_GIVENS) throw 'Too few givens';
+
+ reverse = reverse || false;
+ const candidates = getCandidatesMap(board);
+ if (!candidates) return false;
+
+ const result = search(candidates, reverse);
+
+ if (result) {
+ let solution = '';
+ for (const s of SQUARES) solution += result[s];
+ return solution;
+ }
+ return false;
+ }
+}
+
+initialize();
\ No newline at end of file
diff --git a/src/useBoard/SudokuRenderer.ts b/src/useBoard/SudokuRenderer.ts
new file mode 100644
index 0000000..e99b483
--- /dev/null
+++ b/src/useBoard/SudokuRenderer.ts
@@ -0,0 +1,110 @@
+import type { SudokuBoard, SudokuCell } from './types';
+
+export class SudokuRenderer {
+ static renderBoard(container: HTMLElement, board: SudokuBoard): void {
+ const sudokuDiv = document.createElement('div');
+ sudokuDiv.id = 'sudoku';
+ sudokuDiv.className = 'sudoku-board';
+
+ for (let row = 0; row < 9; row++) {
+ const rowDiv = document.createElement('div');
+ rowDiv.className = 'sudoku-row';
+
+ for (let col = 0; col < 9; col++) {
+ const cell = board[row][col];
+ const cellDiv = document.createElement('div');
+ cellDiv.className = 'sudoku-cell';
+ cellDiv.dataset.row = row.toString();
+ cellDiv.dataset.col = col.toString();
+ cellDiv.textContent = cell.value || '';
+
+ if (cell.isGiven) {
+ cellDiv.classList.add('given');
+ }
+
+ if (cell.hasConflict) {
+ cellDiv.classList.add('conflict');
+ }
+
+ if (col === 2 || col === 5) {
+ cellDiv.classList.add('border-right');
+ }
+
+ if (row === 2 || row === 5) {
+ cellDiv.classList.add('border-bottom');
+ }
+
+ rowDiv.appendChild(cellDiv);
+ }
+
+ sudokuDiv.appendChild(rowDiv);
+ }
+
+ container.innerHTML = '';
+ container.appendChild(sudokuDiv);
+ }
+
+ static renderEmptyBoard(container: HTMLElement): void {
+ const emptyBoard: SudokuBoard = Array(9).fill(null).map(() =>
+ Array(9).fill(null).map(() => ({
+ value: '',
+ isGiven: false,
+ hasConflict: false
+ }))
+ );
+
+ SudokuRenderer.renderBoard(container, emptyBoard);
+ }
+
+ static updateCell(container: HTMLElement, row: number, col: number, cell: SudokuCell): void {
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (!cellEl) return;
+
+ cellEl.textContent = cell.value || '';
+ cellEl.classList.toggle('given', cell.isGiven);
+ cellEl.classList.toggle('conflict', cell.hasConflict);
+ }
+
+ static highlightCursor(container: HTMLElement, row: number, col: number, editMode: boolean): void {
+ const cells = container.querySelectorAll('.sudoku-cell');
+ cells.forEach(cell => cell.classList.remove('cursor', 'edit-mode'));
+
+ const cursorCell = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (cursorCell) {
+ cursorCell.classList.add('cursor');
+ if (editMode) {
+ cursorCell.classList.add('edit-mode');
+ }
+ }
+ }
+
+ static flashCell(container: HTMLElement, row: number, col: number, className: string, duration: number = 300): void {
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (!cellEl) return;
+
+ cellEl.classList.add(className);
+ setTimeout(() => cellEl.classList.remove(className), duration);
+ }
+
+ static highlightConflicts(container: HTMLElement, conflictIndices: number[], duration: number = 2000): void {
+ conflictIndices.forEach((idx) => {
+ const row = Math.floor(idx / 9);
+ const col = idx % 9;
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('conflict');
+ }
+ });
+
+ setTimeout(() => {
+ conflictIndices.forEach((idx) => {
+ const row = Math.floor(idx / 9);
+ const col = idx % 9;
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.remove('conflict');
+ }
+ });
+ }, duration);
+ }
+}
\ No newline at end of file
diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts
new file mode 100644
index 0000000..89a59d7
--- /dev/null
+++ b/src/useBoard/index.ts
@@ -0,0 +1,4 @@
+export { useBoard } from './useBoard';
+export type { BoardManager, SudokuBoard, SudokuCell, Coord, Difficulty } from './types';
+export { SudokuGenerator } from './SudokuGenerator';
+export { SudokuRenderer } from './SudokuRenderer';
\ No newline at end of file
diff --git a/src/useBoard/types.ts b/src/useBoard/types.ts
new file mode 100644
index 0000000..b6d369a
--- /dev/null
+++ b/src/useBoard/types.ts
@@ -0,0 +1,31 @@
+import type { RefObject } from 'react';
+
+export type Coord = { row: number; col: number };
+
+export type SudokuCell = {
+ value: string;
+ isGiven: boolean;
+ hasConflict: boolean;
+};
+
+export type SudokuBoard = SudokuCell[][];
+
+export type Difficulty = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
+
+export type BoardManager = {
+ containerRef: RefObject;
+ boardRef: RefObject;
+ initialBoardRef: RefObject;
+ solutionRef: RefObject;
+ renderBoard: () => void;
+ generateBoard: (difficulty: Difficulty) => void;
+ getCell: (row: number, col: number) => SudokuCell | null;
+ setCell: (row: number, col: number, value: string) => boolean;
+ canEditCell: (row: number, col: number) => boolean;
+ isValidPlacement: (row: number, col: number, digit: string) => boolean;
+ getConflicts: (row: number, col: number) => number[];
+ isComplete: () => boolean;
+ isFullyValid: () => boolean;
+ solveBoard: () => boolean;
+ resetBoard: () => void;
+};
\ No newline at end of file
diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts
new file mode 100644
index 0000000..4f8006c
--- /dev/null
+++ b/src/useBoard/useBoard.ts
@@ -0,0 +1,240 @@
+import { useRef, useState, useCallback } from 'react';
+import type { RefObject } from 'react';
+import type { BoardManager, SudokuBoard, SudokuCell, Difficulty } from './types';
+import { SudokuGenerator } from './SudokuGenerator';
+import { SudokuRenderer } from './SudokuRenderer';
+
+export function useBoard(): BoardManager {
+ const containerRef = useRef(null);
+ const boardRef = useRef([]);
+ const initialBoardRef = useRef('');
+ const solutionRef = useRef(null);
+
+ const initializeBoard = useCallback((boardString: string): SudokuBoard => {
+ const board: SudokuBoard = [];
+ for (let row = 0; row < 9; row++) {
+ const boardRow: SudokuCell[] = [];
+ for (let col = 0; col < 9; col++) {
+ const index = row * 9 + col;
+ const value = boardString[index];
+ boardRow.push({
+ value: value === '.' ? '' : value,
+ isGiven: value !== '.',
+ hasConflict: false
+ });
+ }
+ board.push(boardRow);
+ }
+ return board;
+ }, []);
+
+ const getCell = useCallback((row: number, col: number): SudokuCell | null => {
+ const board = boardRef.current;
+ if (row < 0 || row >= 9 || col < 0 || col >= 9) return null;
+ return board[row]?.[col] || null;
+ }, []);
+
+ const canEditCell = useCallback((row: number, col: number): boolean => {
+ const cell = getCell(row, col);
+ return cell ? !cell.isGiven : false;
+ }, [getCell]);
+
+ const isValidPlacement = useCallback((boardString: string, row: number, col: number, digit: string): boolean => {
+ if (digit === '.') return true;
+
+ for (let c = 0; c < 9; c++) {
+ const checkIdx = row * 9 + c;
+ if (c !== col && boardString[checkIdx] === digit) {
+ return false;
+ }
+ }
+
+ for (let r = 0; r < 9; r++) {
+ const checkIdx = r * 9 + col;
+ if (r !== row && boardString[checkIdx] === digit) {
+ return false;
+ }
+ }
+
+ const boxRow = Math.floor(row / 3) * 3;
+ const boxCol = Math.floor(col / 3) * 3;
+ for (let r = boxRow; r < boxRow + 3; r++) {
+ for (let c = boxCol; c < boxCol + 3; c++) {
+ const checkIdx = r * 9 + c;
+ if ((r !== row || c !== col) && boardString[checkIdx] === digit) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }, []);
+
+ const getConflicts = useCallback((boardString: string, row: number, col: number): number[] => {
+ const conflicts: number[] = [];
+ const digit = boardString[row * 9 + col];
+
+ if (digit === '.') return conflicts;
+
+ for (let c = 0; c < 9; c++) {
+ if (c !== col && boardString[row * 9 + c] === digit) {
+ conflicts.push(row * 9 + c);
+ }
+ }
+
+ for (let r = 0; r < 9; r++) {
+ if (r !== row && boardString[r * 9 + col] === digit) {
+ conflicts.push(r * 9 + col);
+ }
+ }
+
+ const boxRow = Math.floor(row / 3) * 3;
+ const boxCol = Math.floor(col / 3) * 3;
+ for (let r = boxRow; r < boxRow + 3; r++) {
+ for (let c = boxCol; c < boxCol + 3; c++) {
+ const idx = r * 9 + c;
+ if ((r !== row || c !== col) && boardString[idx] === digit) {
+ if (!conflicts.includes(idx)) {
+ conflicts.push(idx);
+ }
+ }
+ }
+ }
+
+ return conflicts;
+ }, []);
+
+ const isFullyValid = useCallback((boardString: string): boolean => {
+ for (let r = 0; r < 9; r++) {
+ let rowSum = 0;
+ for (let c = 0; c < 9; c++) {
+ const digit = boardString[r * 9 + c];
+ if (digit === '.') return false;
+ rowSum += parseInt(digit, 10);
+ }
+ if (rowSum !== 45) return false;
+ }
+
+ for (let c = 0; c < 9; c++) {
+ let colSum = 0;
+ for (let r = 0; r < 9; r++) {
+ const digit = boardString[r * 9 + c];
+ colSum += parseInt(digit, 10);
+ }
+ if (colSum !== 45) return false;
+ }
+
+ for (let boxRow = 0; boxRow < 3; boxRow++) {
+ for (let boxCol = 0; boxCol < 3; boxCol++) {
+ let boxSum = 0;
+ for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
+ for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
+ const digit = boardString[r * 9 + c];
+ boxSum += parseInt(digit, 10);
+ }
+ }
+ if (boxSum !== 45) return false;
+ }
+ }
+
+ return true;
+ }, []);
+
+ const isComplete = useCallback((boardString: string): boolean => {
+ return !boardString.includes('.');
+ }, []);
+
+ const boardToString = useCallback((board: SudokuBoard): string => {
+ return board.map(row =>
+ row.map(cell => cell.value || '.').join('')
+ ).join('');
+ }, []);
+
+ const setCell = useCallback((row: number, col: number, value: string): boolean => {
+ if (!canEditCell(row, col)) return false;
+
+ const board = boardRef.current;
+ const boardString = boardToString(board);
+ const newBoardString = boardString.substring(0, row * 9 + col) + value + boardString.substring(row * 9 + col + 1);
+
+ if (!isValidPlacement(newBoardString, row, col, value)) {
+ return false;
+ }
+
+ board[row][col] = {
+ value: value === '.' ? '' : value,
+ isGiven: board[row][col].isGiven,
+ hasConflict: false
+ };
+
+ return true;
+ }, [canEditCell, isValidPlacement, boardToString]);
+
+ const generateBoard = useCallback((difficulty: Difficulty): void => {
+ const newBoardString = SudokuGenerator.generate(difficulty);
+ const solution = SudokuGenerator.solve(newBoardString);
+
+ initialBoardRef.current = newBoardString;
+ solutionRef.current = solution;
+ boardRef.current = initializeBoard(newBoardString);
+ }, [initializeBoard]);
+
+ const solveBoard = useCallback((): boolean => {
+ const solution = solutionRef.current;
+ if (!solution) return false;
+
+ boardRef.current = initializeBoard(solution);
+ return true;
+ }, [initializeBoard]);
+
+ const resetBoard = useCallback((): void => {
+ const initialBoard = initialBoardRef.current;
+ if (initialBoard) {
+ boardRef.current = initializeBoard(initialBoard);
+ }
+ }, [initializeBoard]);
+
+ const renderBoard = useCallback((): void => {
+ const container = containerRef.current;
+ const board = boardRef.current;
+
+ if (!container || board.length === 0) {
+ if (container) {
+ SudokuRenderer.renderEmptyBoard(container);
+ }
+ return;
+ }
+
+ SudokuRenderer.renderBoard(container, board);
+ }, []);
+
+ return {
+ containerRef,
+ boardRef,
+ initialBoardRef,
+ solutionRef,
+ renderBoard,
+ generateBoard,
+ getCell,
+ setCell,
+ canEditCell,
+ isValidPlacement: (row, col, digit) => {
+ const boardString = boardToString(boardRef.current);
+ return isValidPlacement(boardString, row, col, digit);
+ },
+ getConflicts: (row, col) => {
+ const boardString = boardToString(boardRef.current);
+ return getConflicts(boardString, row, col);
+ },
+ isComplete: () => {
+ const boardString = boardToString(boardRef.current);
+ return isComplete(boardString);
+ },
+ isFullyValid: () => {
+ const boardString = boardToString(boardRef.current);
+ return isFullyValid(boardString);
+ },
+ solveBoard,
+ resetBoard
+ };
+}
\ No newline at end of file
diff --git a/src/useCursor/index.ts b/src/useCursor/index.ts
new file mode 100644
index 0000000..2a6a4c6
--- /dev/null
+++ b/src/useCursor/index.ts
@@ -0,0 +1,12 @@
+export { useCursor } from './useCursor';
+export { useVimMotions } from './useVimMotions';
+export type {
+ CursorManager,
+ CursorMode,
+ Coord,
+ Motion,
+ VimMotionSystem,
+ GameKeyManager,
+ KeyLogEntry,
+ CursorRenderer
+} from './types';
\ No newline at end of file
diff --git a/src/useCursor/types.ts b/src/useCursor/types.ts
new file mode 100644
index 0000000..b4d3fcd
--- /dev/null
+++ b/src/useCursor/types.ts
@@ -0,0 +1,63 @@
+import type { RefObject } from 'react';
+import type { BoardManager } from '../useBoard/types';
+
+export type Coord = { row: number; col: number };
+
+export type CursorMode = 'normal' | 'edit';
+
+export type Motion = {
+ dr: number;
+ dc: number;
+ steps: number;
+};
+
+export type CursorManager = {
+ position: () => Coord;
+ mode: () => CursorMode;
+ move: (dRows: number, dCols: number, count: number) => void;
+ moveLeft: (count?: number) => void;
+ moveRight: (count?: number) => void;
+ moveUp: (count?: number) => void;
+ moveDown: (count?: number) => void;
+ moveToStart: () => void;
+ moveToEnd: () => void;
+ moveToTop: () => void;
+ moveToBottom: () => void;
+ repeatLastMotion: () => void;
+ resetCount: () => void;
+ getCount: () => string;
+ hasCount: () => boolean;
+ setCount: (digit: string) => void;
+ setLastKey: (key: string) => void;
+ getLastKey: () => string;
+ setMode: (mode: CursorMode) => void;
+ flashInvalid: () => void;
+ flashValid: () => void;
+ flashLocked: () => void;
+ highlightConflicts: (conflictIndices: number[]) => void;
+};
+
+export type VimMotionSystem = {
+ processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count' | 'digit' | 'erase' | 'mode', data: any } | null;
+ resetCount: () => void;
+ getCount: () => number;
+ hasCount: () => boolean;
+ setLastMotion: (motion: Motion) => void;
+ repeatLastMotion: () => Motion | null;
+};
+
+export type GameKeyManager = {
+ keyLog: KeyLogEntry[];
+ clearKeyLog: () => void;
+ getKeyLog: () => KeyLogEntry[];
+ editMode: CursorMode;
+ setEditMode: (mode: CursorMode) => void;
+};
+
+export type KeyLogEntry = { key: string; timestamp: number };
+
+export type CursorRenderer = {
+ highlightCursor: (row: number, col: number, editMode: boolean) => void;
+ flashCell: (row: number, col: number, className: string, duration?: number) => void;
+ highlightConflicts: (conflictIndices: number[], duration?: number) => void;
+};
\ No newline at end of file
diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts
new file mode 100644
index 0000000..451a72d
--- /dev/null
+++ b/src/useCursor/useCursor.ts
@@ -0,0 +1,275 @@
+import { useState, useCallback, useEffect } from 'react';
+import type { BoardManager } from '../useBoard/types';
+import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
+import { useVimMotions } from './useVimMotions';
+
+export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
+ const [position, setPosition] = useState({ row: 0, col: 0 });
+ const [mode, setMode] = useState('normal');
+ const [keyLog, setKeyLog] = useState([]);
+
+ const vimMotions = useVimMotions();
+
+ const addKeyToLog = useCallback((key: string) => {
+ setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
+ }, []);
+
+ const move = useCallback((dRows: number, dCols: number, count: number) => {
+ setPosition(prev => {
+ const newRow = prev.row + (dRows * count);
+ const newCol = prev.col + (dCols * count);
+
+ if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
+ flashInvalid();
+ return prev;
+ }
+
+ return { row: newRow, col: newCol };
+ });
+ }, []);
+
+ const moveLeft = useCallback((count = 1) => {
+ move(0, -1, count);
+ }, [move]);
+
+ const moveRight = useCallback((count = 1) => {
+ move(0, 1, count);
+ }, [move]);
+
+ const moveUp = useCallback((count = 1) => {
+ move(-1, 0, count);
+ }, [move]);
+
+ const moveDown = useCallback((count = 1) => {
+ move(1, 0, count);
+ }, [move]);
+
+ const moveToStart = useCallback(() => {
+ setPosition(prev => ({ ...prev, col: 0 }));
+ }, []);
+
+ const moveToEnd = useCallback(() => {
+ setPosition(prev => ({ ...prev, col: 8 }));
+ }, []);
+
+ const moveToTop = useCallback(() => {
+ setPosition({ row: 0, col: 0 });
+ }, []);
+
+ const moveToBottom = useCallback(() => {
+ setPosition({ row: 8, col: 0 });
+ }, []);
+
+ const repeatLastMotion = useCallback(() => {
+ const lastMotion = vimMotions.repeatLastMotion();
+ if (lastMotion) {
+ move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
+ }
+ }, [vimMotions, move]);
+
+ const resetCount = useCallback(() => {
+ vimMotions.resetCount();
+ }, [vimMotions]);
+
+ const getCount = useCallback(() => {
+ return vimMotions.getCount().toString();
+ }, [vimMotions]);
+
+ const hasCount = useCallback(() => {
+ return vimMotions.hasCount();
+ }, [vimMotions]);
+
+ const setCount = useCallback((digit: string) => {
+ vimMotions.processKey(digit);
+ }, [vimMotions]);
+
+ const setLastKey = useCallback((key: string) => {
+ addKeyToLog(key);
+ }, [addKeyToLog]);
+
+ const getLastKey = useCallback(() => {
+ return keyLog[keyLog.length - 1]?.key || '';
+ }, [keyLog]);
+
+ const flashInvalid = useCallback(() => {
+ const container = board.containerRef.current;
+ if (!container) return;
+
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('invalid-move');
+ setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
+ }
+ }, [board, position]);
+
+ const flashValid = useCallback(() => {
+ const container = board.containerRef.current;
+ if (!container) return;
+
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('valid-move');
+ setTimeout(() => cellEl.classList.remove('valid-move'), 300);
+ }
+ }, [board, position]);
+
+ const flashLocked = useCallback(() => {
+ const container = board.containerRef.current;
+ if (!container) return;
+
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('locked-cell');
+ setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
+ }
+ }, [board, position]);
+
+ const highlightConflicts = useCallback((conflictIndices: number[]) => {
+ const container = board.containerRef.current;
+ if (!container) return;
+
+ conflictIndices.forEach((idx) => {
+ const row = Math.floor(idx / 9);
+ const col = idx % 9;
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('conflict');
+ }
+ });
+
+ setTimeout(() => {
+ conflictIndices.forEach((idx) => {
+ const row = Math.floor(idx / 9);
+ const col = idx % 9;
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.remove('conflict');
+ }
+ });
+ }, 2000);
+ }, [board]);
+
+ const clearKeyLog = useCallback(() => {
+ setKeyLog([]);
+ }, []);
+
+ const getKeyLog = useCallback(() => {
+ return [...keyLog];
+ }, [keyLog]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const key = e.key;
+ const result = vimMotions.processKey(key);
+
+ if (!result) return;
+
+ addKeyToLog(key);
+
+ switch (result.type) {
+ case 'move':
+ move(result.data.dr, result.data.dc, result.data.steps);
+ break;
+
+ case 'anchor':
+ switch (result.data) {
+ case 'start':
+ moveToStart();
+ break;
+ case 'end':
+ moveToEnd();
+ break;
+ case 'top':
+ moveToTop();
+ break;
+ case 'bottom':
+ moveToBottom();
+ break;
+ }
+ break;
+
+ case 'repeat':
+ if (result.data) {
+ move(result.data.dr, result.data.dc, result.data.steps);
+ }
+ break;
+
+ case 'mode':
+ setMode(result.data);
+ break;
+
+ case 'digit':
+ if (mode === 'edit') {
+ const success = board.setCell(position.row, position.col, result.data);
+ if (success) {
+ flashValid();
+ } else if (!board.canEditCell(position.row, position.col)) {
+ flashLocked();
+ } else {
+ const conflicts = board.getConflicts(position.row, position.col);
+ if (conflicts.length > 0) {
+ highlightConflicts(conflicts);
+ }
+ flashInvalid();
+ }
+ }
+ break;
+
+ case 'erase':
+ if (mode === 'edit') {
+ const success = board.setCell(position.row, position.col, '.');
+ if (success) {
+ flashValid();
+ } else if (!board.canEditCell(position.row, position.col)) {
+ flashLocked();
+ } else {
+ flashInvalid();
+ }
+ }
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
+
+ const cursorManager: CursorManager = {
+ position: () => position,
+ mode: () => mode,
+ move,
+ moveLeft,
+ moveRight,
+ moveUp,
+ moveDown,
+ moveToStart,
+ moveToEnd,
+ moveToTop,
+ moveToBottom,
+ repeatLastMotion,
+ resetCount,
+ getCount,
+ hasCount,
+ setCount,
+ setLastKey,
+ getLastKey,
+ setMode,
+ flashInvalid,
+ flashValid,
+ flashLocked,
+ highlightConflicts
+ };
+
+ const keyManager: GameKeyManager = {
+ keyLog,
+ clearKeyLog,
+ getKeyLog,
+ editMode: mode,
+ setEditMode: setMode
+ };
+
+ return {
+ ...cursorManager,
+ keyManager
+ };
+}
\ No newline at end of file
diff --git a/src/useCursor/useVimMotions.ts b/src/useCursor/useVimMotions.ts
new file mode 100644
index 0000000..a79e26a
--- /dev/null
+++ b/src/useCursor/useVimMotions.ts
@@ -0,0 +1,118 @@
+import { useRef } from 'react';
+import type { VimMotionSystem, Motion } from './types';
+
+export function useVimMotions(): VimMotionSystem {
+ const countRef = useRef('');
+ const lastMotionRef = useRef(null);
+
+ const processKey = (key: string) => {
+ if (key >= '0' && key <= '9') {
+ if (key === '0' && countRef.current === '') {
+ return { type: 'anchor', data: 'start' };
+ }
+ countRef.current += key;
+ return { type: 'count', data: key };
+ }
+
+ const count = countRef.current ? parseInt(countRef.current, 10) : 1;
+
+ switch (key) {
+ case 'h':
+ const motionH = { dr: 0, dc: -1, steps: count };
+ lastMotionRef.current = motionH;
+ countRef.current = '';
+ return { type: 'move', data: motionH };
+
+ case 'j':
+ const motionJ = { dr: 1, dc: 0, steps: count };
+ lastMotionRef.current = motionJ;
+ countRef.current = '';
+ return { type: 'move', data: motionJ };
+
+ case 'k':
+ const motionK = { dr: -1, dc: 0, steps: count };
+ lastMotionRef.current = motionK;
+ countRef.current = '';
+ return { type: 'move', data: motionK };
+
+ case 'l':
+ const motionL = { dr: 0, dc: 1, steps: count };
+ lastMotionRef.current = motionL;
+ countRef.current = '';
+ return { type: 'move', data: motionL };
+
+ case '^':
+ countRef.current = '';
+ return { type: 'anchor', data: 'start' };
+
+ case '$':
+ countRef.current = '';
+ return { type: 'anchor', data: 'end' };
+
+ case 'g':
+ countRef.current = '';
+ return { type: 'anchor', data: 'top' };
+
+ case 'G':
+ countRef.current = '';
+ return { type: 'anchor', data: 'bottom' };
+
+ case '.':
+ const lastMotion = lastMotionRef.current;
+ if (lastMotion) {
+ return { type: 'repeat', data: lastMotion };
+ }
+ return null;
+
+ case 'i':
+ countRef.current = '';
+ return { type: 'mode', data: 'edit' };
+
+ case 'Escape':
+ countRef.current = '';
+ return { type: 'mode', data: 'normal' };
+
+ case 'x':
+ case 'Delete':
+ case 'Backspace':
+ countRef.current = '';
+ return { type: 'erase', data: null };
+
+ default:
+ if (key >= '1' && key <= '9') {
+ countRef.current = '';
+ return { type: 'digit', data: key };
+ }
+ return null;
+ }
+ };
+
+ const resetCount = () => {
+ countRef.current = '';
+ };
+
+ const getCount = () => {
+ return countRef.current ? parseInt(countRef.current, 10) : 1;
+ };
+
+ const hasCount = () => {
+ return countRef.current !== '';
+ };
+
+ const setLastMotion = (motion: Motion) => {
+ lastMotionRef.current = motion;
+ };
+
+ const repeatLastMotion = () => {
+ return lastMotionRef.current;
+ };
+
+ return {
+ processKey,
+ resetCount,
+ getCount,
+ hasCount,
+ setLastMotion,
+ repeatLastMotion
+ };
+}
\ No newline at end of file
diff --git a/src/useGame.ts b/src/useGame.ts
new file mode 100644
index 0000000..a3a1709
--- /dev/null
+++ b/src/useGame.ts
@@ -0,0 +1,101 @@
+import { useEffect } from 'react';
+import { useBoard } from './useBoard';
+import { useCursor } from './useCursor';
+import { useGameStatus } from './useGameStatus';
+import { useScore } from './useScore';
+import type { GameManager, GameOptions } from './types';
+
+export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
+ const board = useBoard();
+ const gameStatusManager = useGameStatus();
+ const { cursor, keyManager } = useCursor(board);
+ const scoreManager = useScore({
+ board,
+ gameStatusManager,
+ keystrokeCount: keyManager.keyLog.length
+ });
+
+ useEffect(() => {
+ board.generateBoard('medium');
+ }, [board]);
+
+ useEffect(() => {
+ const { gameStatus } = gameStatusManager;
+
+ if (gameStatus === 'waiting') {
+ board.renderBoard();
+ } else {
+ board.renderBoard();
+ const container = board.containerRef.current;
+ if (container) {
+ const pos = cursor.position();
+ const cellEl = container.querySelector(`.sudoku-cell[data-row="${pos.row}"][data-col="${pos.col}"]`) as HTMLElement;
+ if (cellEl) {
+ cellEl.classList.add('cursor');
+ if (cursor.mode() === 'edit') {
+ cellEl.classList.add('edit-mode');
+ }
+ }
+ }
+ }
+ }, [gameStatusManager.gameStatus, board, cursor]);
+
+ useEffect(() => {
+ const container = board.containerRef.current;
+ if (!container) return;
+
+ const handleCellClick = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ const cellEl = target.closest('.sudoku-cell') as HTMLElement;
+ if (!cellEl) return;
+
+ const row = parseInt(cellEl.dataset.row || '0');
+ const col = parseInt(cellEl.dataset.col || '0');
+
+ const currentPos = cursor.position();
+ if (currentPos.row !== row || currentPos.col !== col) {
+ cursor.move(row - currentPos.row, col - currentPos.col, 1);
+ }
+ };
+
+ container.addEventListener('click', handleCellClick);
+ return () => container.removeEventListener('click', handleCellClick);
+ }, [board, cursor]);
+
+ const startGame = () => {
+ board.generateBoard('medium');
+ gameStatusManager.startGame();
+ };
+
+ const togglePause = (pause?: boolean) => {
+ gameStatusManager.togglePause(pause);
+ };
+
+ const quitGame = () => {
+ gameStatusManager.quitGame();
+ };
+
+ const gameManager: GameManager = {
+ containerRef: board.containerRef,
+ renderBoard: board.renderBoard,
+ gameStatus: gameStatusManager.gameStatus,
+ setGameStatus: gameStatusManager.setGameStatus,
+ startGame,
+ togglePause,
+ quitGame,
+ cursor,
+ scoreManager,
+ keyLog: keyManager.keyLog,
+ clearKeyLog: keyManager.clearKeyLog,
+ getKeyLog: keyManager.getKeyLog,
+ board,
+ solveBoard: board.solveBoard,
+ resetBoard: board.resetBoard
+ };
+
+ if (typeof platformHook === 'function') {
+ platformHook(gameManager);
+ }
+
+ return gameManager;
+}
\ No newline at end of file
diff --git a/src/useGameStatus/index.ts b/src/useGameStatus/index.ts
new file mode 100644
index 0000000..c72f736
--- /dev/null
+++ b/src/useGameStatus/index.ts
@@ -0,0 +1,2 @@
+export { useGameStatus } from './useGameStatus';
+export type { GameStatus, GameStatusManager } from './types';
\ No newline at end of file
diff --git a/src/useGameStatus/types.ts b/src/useGameStatus/types.ts
new file mode 100644
index 0000000..d764da1
--- /dev/null
+++ b/src/useGameStatus/types.ts
@@ -0,0 +1,9 @@
+export type GameStatus = 'waiting' | 'started' | 'paused' | 'game-over' | 'game-won';
+
+export type GameStatusManager = {
+ gameStatus: GameStatus;
+ setGameStatus: (status: GameStatus) => void;
+ startGame: () => void;
+ togglePause: (pause?: boolean) => void;
+ quitGame: () => void;
+};
\ No newline at end of file
diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts
new file mode 100644
index 0000000..56ecde4
--- /dev/null
+++ b/src/useGameStatus/useGameStatus.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from 'react';
+import type { GameStatusManager, GameStatus } from './types';
+
+export function useGameStatus(): GameStatusManager {
+ const [gameStatus, setGameStatus] = useState('waiting');
+
+ const startGame = useCallback(() => {
+ setGameStatus('started');
+ }, []);
+
+ const togglePause = useCallback((pause?: boolean) => {
+ setGameStatus(current => {
+ if (current === 'started' || current === 'paused') {
+ return pause !== undefined ? (pause ? 'paused' : 'started') :
+ (current === 'started' ? 'paused' : 'started');
+ }
+ return current;
+ });
+ }, []);
+
+ const quitGame = useCallback(() => {
+ setGameStatus('waiting');
+ }, []);
+
+ return {
+ gameStatus,
+ setGameStatus,
+ startGame,
+ togglePause,
+ quitGame
+ };
+}
\ No newline at end of file
diff --git a/src/useScore/index.ts b/src/useScore/index.ts
index 525ce75..1cd87d9 100644
--- a/src/useScore/index.ts
+++ b/src/useScore/index.ts
@@ -1 +1,3 @@
-export * from './useScore';
+export { useScore } from './useScore';
+export { useTimer } from './useTimer';
+export type { ScoreManager, TimerManager, UseScoreProps } from './types';
\ No newline at end of file
diff --git a/src/useScore/types.ts b/src/useScore/types.ts
new file mode 100644
index 0000000..128174b
--- /dev/null
+++ b/src/useScore/types.ts
@@ -0,0 +1,31 @@
+import type { BoardManager } from '../useBoard/types';
+import type { GameStatusManager } from '../useGameStatus/types';
+
+export type TimerManager = {
+ timeValue: number;
+ startTimer: () => void;
+ stopTimer: () => void;
+ resetTimer: () => void;
+};
+
+export type ScoreManager = {
+ timeValue: number;
+ keystrokes: number;
+ mistakes: number;
+ hints: number;
+ efficiency: number;
+ finalScore: number | null;
+ startTimer: () => void;
+ stopTimer: () => void;
+ resetTimer: () => void;
+ addMistake: () => void;
+ addHint: () => void;
+ calculateEfficiency: () => number;
+ calculateFinalScore: () => number | null;
+};
+
+export type UseScoreProps = {
+ board: BoardManager;
+ gameStatusManager: GameStatusManager;
+ keystrokeCount: number;
+};
\ No newline at end of file
diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts
index a85eae0..eb4179e 100644
--- a/src/useScore/useScore.ts
+++ b/src/useScore/useScore.ts
@@ -1,107 +1,96 @@
-import { useEffect, useState } from "react";
-import { useTimer } from "./hooks/useTimer";
-import { useScoreTime } from "./useScoreTime";
-import type { GameStatus } from "../types";
+import { useState, useEffect, useCallback } from 'react';
+import type { BoardManager } from '../useBoard/types';
+import type { GameStatusManager } from '../useGameStatus/types';
+import type { ScoreManager, UseScoreProps } from './types';
+import { useTimer } from './useTimer';
-const TIME_LIMIT_MS = Infinity; // No time limit by default
-const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
-
-type GameScoreContext = {
- gameStatus: GameStatus;
- setGameStatus: (status: GameStatus) => void;
-};
-
-type UseScoreParams = {
- gameContext: GameScoreContext;
- keystrokeCount: number;
- board: string;
- isComplete: (board: string) => boolean;
- isFullyValid: (board: string) => boolean;
- initialBoard: string;
-};
-
-export function useScore({
- gameContext,
- keystrokeCount,
- board,
- isComplete,
- isFullyValid,
- initialBoard
-}: UseScoreParams) {
+export function useScore({ board, gameStatusManager, keystrokeCount }: UseScoreProps): ScoreManager {
const timer = useTimer();
- const { gameStatus, setGameStatus } = gameContext;
- const { timeValue, startTimer, stopTimer, resetTimer } = timer;
+ const [mistakes, setMistakes] = useState(0);
+ const [hints, setHints] = useState(0);
- const [finalScore, setFinalScore] = useState(null);
+ const addMistake = useCallback(() => {
+ setMistakes(prev => prev + 1);
+ }, []);
- const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
- const actualMoves = keystrokeCount;
- const efficiency = minMovesRequired > 0
- ? Math.round((actualMoves / minMovesRequired) * 100)
- : 0;
+ const addHint = useCallback(() => {
+ setHints(prev => prev + 1);
+ }, []);
- useScoreTime({ gameStatus, timer });
+ const calculateEfficiency = useCallback((): number => {
+ const boardString = board.boardRef.current.map(row =>
+ row.map(cell => cell.value || '.').join('')
+ ).join('');
+
+ const filledCells = boardString.replace(/\./g, '').length;
+ const totalCells = 81;
+ const givenCells = board.initialBoardRef.current.replace(/\./g, '').length;
+ const userFilledCells = filledCells - givenCells;
+
+ if (userFilledCells === 0) return 100;
+
+ const optimalMoves = userFilledCells;
+ const actualMoves = keystrokeCount;
+
+ const efficiency = Math.round((optimalMoves / actualMoves) * 100);
+ return Math.max(0, Math.min(100, efficiency));
+ }, [board, keystrokeCount]);
+
+ const calculateFinalScore = useCallback((): number | null => {
+ const { gameStatus } = gameStatusManager;
+
+ if (gameStatus !== 'game-won') return null;
+
+ const efficiency = calculateEfficiency();
+ const timeInSeconds = timer.timeValue / 1000;
+ const timeBonus = Math.max(0, 3600 - timeInSeconds) * 10;
+ const mistakePenalty = mistakes * 100;
+ const hintPenalty = hints * 200;
+
+ const baseScore = 10000;
+ const efficiencyBonus = efficiency * 50;
+
+ const finalScore = baseScore + efficiencyBonus + timeBonus - mistakePenalty - hintPenalty;
+ return Math.max(0, Math.round(finalScore));
+ }, [gameStatusManager, calculateEfficiency, timer.timeValue, mistakes, hints]);
useEffect(() => {
- if (gameStatus !== "started") return;
+ const { gameStatus } = gameStatusManager;
- if (isComplete(board) && isFullyValid(board)) {
- setGameStatus("game-won");
- return;
+ if (gameStatus === 'started') {
+ timer.startTimer();
+ } else if (gameStatus === 'paused' || gameStatus === 'game-over' || gameStatus === 'game-won') {
+ timer.stopTimer();
}
- if (timeValue >= TIME_LIMIT_MS) {
- setGameStatus("game-over");
- return;
+ if (gameStatus === 'waiting') {
+ timer.resetTimer();
+ setMistakes(0);
+ setHints(0);
}
-
- if (keystrokeCount >= KEYSTROKE_LIMIT) {
- setGameStatus("game-over");
- return;
- }
-
- // TODO: Enable efficiency-based game-over in future
- // if (efficiency > 200) {
- // setGameStatus("game-over");
- // return;
- // }
- }, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
+ }, [gameStatusManager.gameStatus, timer]);
useEffect(() => {
- const handleWin = () => {
- if (gameStatus === "started") {
- setGameStatus("game-won");
- }
- };
- window.addEventListener("sudoku-game-won", handleWin);
- return () => window.removeEventListener("sudoku-game-won", handleWin);
- }, [gameStatus, setGameStatus]);
+ const { gameStatus, setGameStatus } = gameStatusManager;
- useEffect(() => {
- if (gameStatus !== "game-won") return;
-
- const timeInSeconds = timeValue / 1000;
- const baseScore = 100000;
- const timePenalty = Math.floor(timeInSeconds * 10);
- const extraMoves = Math.max(0, actualMoves - minMovesRequired);
- const movePenalty = extraMoves * 50;
- const score = Math.max(0, baseScore - timePenalty - movePenalty);
-
- setFinalScore(score);
- }, [gameStatus, timeValue, actualMoves, minMovesRequired]);
+ if (gameStatus === 'started' && board.isComplete() && board.isFullyValid()) {
+ setGameStatus('game-won');
+ }
+ }, [board, gameStatusManager]);
return {
- timeValue,
- startTimer,
- stopTimer,
- resetTimer,
- finalScore,
- timeLimit: TIME_LIMIT_MS,
- keystrokeLimit: KEYSTROKE_LIMIT,
- minMovesRequired,
- actualMoves,
- efficiency,
+ timeValue: timer.timeValue,
+ keystrokes: keystrokeCount,
+ mistakes,
+ hints,
+ efficiency: calculateEfficiency(),
+ finalScore: calculateFinalScore(),
+ startTimer: timer.startTimer,
+ stopTimer: timer.stopTimer,
+ resetTimer: timer.resetTimer,
+ addMistake,
+ addHint,
+ calculateEfficiency,
+ calculateFinalScore
};
-}
-
-export type UseScoreType = ReturnType;
+}
\ No newline at end of file
diff --git a/src/useScore/useTimer.ts b/src/useScore/useTimer.ts
new file mode 100644
index 0000000..0412bbf
--- /dev/null
+++ b/src/useScore/useTimer.ts
@@ -0,0 +1,45 @@
+import { useState, useEffect, useRef } from 'react';
+import type { TimerManager } from './types';
+
+export function useTimer(): TimerManager {
+ const [timeValue, setTimeValue] = useState(0);
+ const intervalRef = useRef(null);
+ const startTimeRef = useRef(0);
+
+ const startTimer = () => {
+ if (intervalRef.current) return;
+
+ startTimeRef.current = Date.now() - timeValue;
+ intervalRef.current = setInterval(() => {
+ setTimeValue(Date.now() - startTimeRef.current);
+ }, 100);
+ };
+
+ const stopTimer = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ const resetTimer = () => {
+ stopTimer();
+ setTimeValue(0);
+ startTimeRef.current = 0;
+ };
+
+ useEffect(() => {
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ };
+ }, []);
+
+ return {
+ timeValue,
+ startTimer,
+ stopTimer,
+ resetTimer
+ };
+}
\ No newline at end of file
diff --git a/src_old/game.css b/src_old/game.css
new file mode 100644
index 0000000..09454f5
--- /dev/null
+++ b/src_old/game.css
@@ -0,0 +1,256 @@
+/* =========================================================
+ π§ Controls (tweak these)
+ ========================================================= */
+:root {
+ --cell-size: 96px;
+ --cell-font-size: 48px;
+ --grid-gap: 6px;
+ --box-separator: 9px;
+}
+
+/* =========================================================
+ Container & Grid Base
+ ========================================================= */
+#sudoku_container {
+ position: relative;
+ margin: 1em auto;
+ width: fit-content;
+}
+
+#sudoku {
+ position: relative;
+ background-color: var(--card, #1e2030);
+ border-radius: 0.5rem;
+ box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
+ padding: 0.5rem;
+ width: fit-content;
+}
+
+/* =========================================================
+ Grid Layout
+ ========================================================= */
+#sudoku .sudoku-grid {
+ display: grid;
+ grid-template-columns:
+ var(--cell-size) var(--cell-size) var(--cell-size)
+ var(--cell-size) var(--cell-size) var(--cell-size)
+ var(--cell-size) var(--cell-size) var(--cell-size);
+ grid-template-rows:
+ var(--cell-size) var(--cell-size) var(--cell-size)
+ var(--cell-size) var(--cell-size) var(--cell-size)
+ var(--cell-size) var(--cell-size) var(--cell-size);
+ column-gap: var(--grid-gap);
+ row-gap: var(--grid-gap);
+ user-select: none;
+}
+
+/* =========================================================
+ Cell Base Styles
+ ========================================================= */
+#sudoku .sudoku-cell {
+ width: var(--cell-size);
+ height: var(--cell-size);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ font-family: "Fira Code", monospace;
+ font-size: var(--cell-font-size);
+ background: var(--muted, #2f334d);
+ color: var(--foreground, #c8d3f5);
+ transition: all 0.15s ease;
+ box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
+}
+
+/* =========================================================
+ 3x3 Box Separators
+ ========================================================= */
+/* Right borders (end of each 3x3 box horizontally) */
+#sudoku .sudoku-cell[data-c="2"],
+#sudoku .sudoku-cell[data-c="5"],
+#sudoku .sudoku-cell[data-c="8"] {
+ border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
+}
+
+/* Left borders (start of each 3x3 box horizontally) */
+#sudoku .sudoku-cell[data-c="0"],
+#sudoku .sudoku-cell[data-c="3"],
+#sudoku .sudoku-cell[data-c="6"] {
+ border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
+}
+
+/* Bottom borders (end of each 3x3 box vertically) */
+#sudoku .sudoku-cell[data-r="2"],
+#sudoku .sudoku-cell[data-r="5"],
+#sudoku .sudoku-cell[data-r="8"] {
+ border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
+}
+
+/* Top borders (start of each 3x3 box vertically) */
+#sudoku .sudoku-cell[data-r="0"],
+#sudoku .sudoku-cell[data-r="3"],
+#sudoku .sudoku-cell[data-r="6"] {
+ border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
+}
+
+/* =========================================================
+ User-Entered Cells
+ ========================================================= */
+#sudoku .sudoku-cell.user-entered {
+ color: var(--primary, #82aaff);
+ font-weight: 600;
+}
+
+/* =========================================================
+ Active Cell Highlight (Navigation Mode)
+ ========================================================= */
+#sudoku .sudoku-cell.active {
+ outline: 2px solid var(--primary, #82aaff);
+ outline-offset: -2px;
+ background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
+ box-shadow:
+ 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
+ 0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
+ inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
+}
+
+/* =========================================================
+ Editing Cell Highlight (Edit Mode)
+ ========================================================= */
+#sudoku .sudoku-cell.editing {
+ outline: 2px solid var(--accent, #ffc777);
+ outline-offset: -2px;
+ background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
+ box-shadow:
+ 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
+ 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
+ animation: editing-cell-glow 1.5s ease-in-out infinite;
+}
+
+@keyframes editing-cell-glow {
+ 0%, 100% {
+ box-shadow:
+ 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
+ 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
+ }
+ 50% {
+ box-shadow:
+ 0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
+ 0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
+ inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
+ }
+}
+
+/* =========================================================
+ Visual Feedback Effects
+ ========================================================= */
+/* Invalid move effect */
+#sudoku .sudoku-cell.invalid-move {
+ animation: sudoku-invalid-pulse 180ms ease-out;
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
+}
+
+@keyframes sudoku-invalid-pulse {
+ 0% {
+ transform: scale(1.05);
+ box-shadow:
+ 0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
+ 0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
+ }
+ 100% {
+ transform: none;
+ box-shadow: none;
+ }
+}
+
+/* Locked cell effect (pre-filled clues) */
+#sudoku .sudoku-cell.locked-cell {
+ animation: sudoku-locked-pulse 300ms ease-out;
+}
+
+@keyframes sudoku-locked-pulse {
+ 0% {
+ transform: scale(1.08);
+ background: rgba(255, 85, 85, 0.3);
+ box-shadow:
+ 0 0 20px rgba(255, 85, 85, 0.9),
+ 0 0 32px rgba(255, 85, 85, 0.6),
+ inset 0 0 16px rgba(255, 85, 85, 0.4);
+ }
+ 50% {
+ transform: scale(1.05);
+ background: rgba(255, 85, 85, 0.2);
+ box-shadow:
+ 0 0 16px rgba(255, 85, 85, 0.8),
+ 0 0 28px rgba(255, 85, 85, 0.5),
+ inset 0 0 12px rgba(255, 85, 85, 0.3);
+ }
+ 100% {
+ transform: none;
+ background: transparent;
+ box-shadow: none;
+ }
+}
+
+/* Trail effect for moves */
+#sudoku .sudoku-cell.trail {
+ box-shadow:
+ inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
+ 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
+ animation: sudoku-trail-fade 220ms ease-out;
+}
+
+@keyframes sudoku-trail-fade {
+ from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
+ to { background-color: transparent; }
+}
+
+/* Valid move flash */
+#sudoku .sudoku-cell.valid-move {
+ animation: sudoku-valid-flash 300ms ease-out;
+}
+
+@keyframes sudoku-valid-flash {
+ 0% {
+ background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent);
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent),
+ 0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent);
+ }
+ 100% {
+ background: transparent;
+ box-shadow: none;
+ }
+}
+
+/* Conflict highlighting */
+#sudoku .sudoku-cell.conflict {
+ outline: 2px solid var(--destructive, #c53b53);
+ outline-offset: -2px;
+ background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d));
+ box-shadow:
+ 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
+ animation: sudoku-conflict-pulse 1s ease-in-out infinite;
+}
+
+@keyframes sudoku-conflict-pulse {
+ 0%, 100% {
+ box-shadow:
+ 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
+ }
+ 50% {
+ box-shadow:
+ 0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent);
+ }
+}
diff --git a/src_old/index.ts b/src_old/index.ts
new file mode 100644
index 0000000..891c6f4
--- /dev/null
+++ b/src_old/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from "./useGame";
diff --git a/src/lib/Sudoku.ts b/src_old/lib/Sudoku.ts
similarity index 100%
rename from src/lib/Sudoku.ts
rename to src_old/lib/Sudoku.ts
diff --git a/src_old/sudoku/index.ts b/src_old/sudoku/index.ts
new file mode 100644
index 0000000..947c3b1
--- /dev/null
+++ b/src_old/sudoku/index.ts
@@ -0,0 +1,2 @@
+export * from './sudoku';
+export * from './validate-board';
diff --git a/src_old/sudoku/make-puzzle.ts b/src_old/sudoku/make-puzzle.ts
new file mode 100644
index 0000000..63a4248
--- /dev/null
+++ b/src_old/sudoku/make-puzzle.ts
@@ -0,0 +1,45 @@
+/**
+ * Create a playable Sudoku puzzle by removing random digits
+ * from a fully solved board string.
+ *
+ * Difficulty only determines how many cells are removed.
+ * - "easy": removes ~40 cells
+ * - "medium": removes ~50 cells
+ * - "hard": removes ~60 cells
+ *
+ * The returned string uses '.' for blanks.
+ */
+export function makePuzzle(
+ solution: string,
+ difficulty: "easy" | "medium" | "hard" = "medium"
+): string {
+ if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
+ throw new Error("Invalid solved board: must be 81 digits 1β9");
+ }
+
+ // --- removal count based on difficulty ---
+ const removeCount = {
+ easy: 40,
+ medium: 50,
+ hard: 60,
+ }[difficulty];
+
+ // --- create a mutable copy ---
+ const board = solution.split("");
+
+ // --- build a shuffled list of indices 0β80 ---
+ const indices = Array.from({ length: 81 }, (_, i) => i);
+ for (let i = indices.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [indices[i], indices[j]] = [indices[j], indices[i]];
+ }
+
+ // --- remove the desired number of cells ---
+ for (let i = 0; i < removeCount; i++) {
+ const idx = indices[i];
+ board[idx] = ".";
+ }
+
+ return board.join("");
+}
+
diff --git a/src_old/sudoku/sudoku.test.ts b/src_old/sudoku/sudoku.test.ts
new file mode 100644
index 0000000..4c86286
--- /dev/null
+++ b/src_old/sudoku/sudoku.test.ts
@@ -0,0 +1,57 @@
+import { describe, test, expect } from "bun:test";
+import { generateSolvedBoard } from "./sudoku";
+import { assertValidFullBoard } from "./validate-board";
+
+
+const valids = [
+ "534678912672195348198342567859761423426853791713924856961537284287419635345286179",
+ "456789123123456789789123456567891234234567891891234567678912345345678912912345678",
+ "234567891567891234891234567345678912678912345912345678456789123789123456123456789",
+ "987654321654321987321987654876543219543219876219876543765432198432198765198765432"
+];
+
+const invalids = [
+ "534678912672195348198342567859761423426853791713924856961537284287419635345286171",
+ "534678911672195348198342567859761423426853791713924856961537284287419635345286179",
+ "534678912572195348198342567859761423426853791713924856961537284287419635345286179",
+ "534678912672195348198342567859761423426863791713924856961537284287419635345286179",
+];
+
+describe("Testing the Asserter with static boards", () => {
+ test("valid boards", () => {
+ for (let board of valids) {
+ expect(() => assertValidFullBoard(board)).not.toThrow();
+ }
+ })
+
+ test("invalid boards", () => {
+ for (let board of invalids) {
+ expect(() => assertValidFullBoard(board)).toThrow()
+ }
+ })
+});
+
+describe("Testing the generateSolvedBoard", () => {
+ test("generates 10 valid boards", () => {
+ for (let i = 0; i < 10; i++) {
+ const generated = generateSolvedBoard();
+ assertValidFullBoard(generated);
+ };
+ });
+ test("generates 100 valid boards", () => {
+ for (let i = 0; i < 100; i++) {
+ const generated = generateSolvedBoard();
+ assertValidFullBoard(generated);
+ };
+ });
+
+ test("generates 1000 valid boards", () => {
+ for (let i = 0; i < 1000; i++) {
+ const generated = generateSolvedBoard();
+ assertValidFullBoard(generated);
+ };
+ });
+});
+
+
+
diff --git a/src_old/sudoku/sudoku.ts b/src_old/sudoku/sudoku.ts
new file mode 100644
index 0000000..cf776b3
--- /dev/null
+++ b/src_old/sudoku/sudoku.ts
@@ -0,0 +1,54 @@
+/**
+ * Generate a fully valid Sudoku solution using a base pattern and structural shuffles.
+ * Returns a string of 81 digits (row-major order).
+ */
+export function generateSolvedBoard(): string {
+ const size = 9;
+ const box = 3;
+
+ // --- 1οΈβ£ Base pattern function ---
+ const pattern = (r: number, c: number) =>
+ (box * (r % box) + Math.floor(r / box) + c) % size;
+
+ // --- 2οΈβ£ Create base board ---
+ const baseBoard: number[][] = Array.from({ length: size }, (_, r) =>
+ Array.from({ length: size }, (_, c) => pattern(r, c) + 1)
+ );
+
+ // --- 3οΈβ£ Helper to shuffle an array in place ---
+ const shuffle = (arr: T[]): T[] => {
+ const copy = [...arr];
+ for (let i = copy.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [copy[i], copy[j]] = [copy[j], copy[i]];
+ }
+ return copy;
+ };
+
+ // --- 4οΈβ£ Build shuffled row/col indices preserving 3Γ3 structure ---
+ const rows = shuffle([0, 1, 2])
+ .concat(shuffle([3, 4, 5]))
+ .concat(shuffle([6, 7, 8]));
+ const cols = shuffle([0, 1, 2])
+ .concat(shuffle([3, 4, 5]))
+ .concat(shuffle([6, 7, 8]));
+
+ // --- 5οΈβ£ Shuffle entire bands and stacks (groups of 3) ---
+ const rowBands = shuffle([0, 1, 2]);
+ const colStacks = shuffle([0, 1, 2]);
+
+ const shuffledRows = rowBands.flatMap(band => rows.slice(band * 3, band * 3 + 3));
+ const shuffledCols = colStacks.flatMap(stack => cols.slice(stack * 3, stack * 3 + 3));
+
+ // --- 6οΈβ£ Digit permutation (1β9) ---
+ const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
+
+ // --- 7οΈβ£ Build the final shuffled board ---
+ const board: number[][] = shuffledRows.map(r =>
+ shuffledCols.map(c => digits[baseBoard[r][c] - 1])
+ );
+
+ // --- 8οΈβ£ Flatten to 81-char string ---
+ return board.flat().join('');
+}
+
diff --git a/src_old/sudoku/validate-board.ts b/src_old/sudoku/validate-board.ts
new file mode 100644
index 0000000..3d638ce
--- /dev/null
+++ b/src_old/sudoku/validate-board.ts
@@ -0,0 +1,104 @@
+/**
+ * Validate a fully filled Sudoku board (81-char string)
+ * Returns true if:
+ * - 81 characters long
+ * - all characters are digits 1β9
+ * - all rows, columns, and 3Γ3 boxes sum to 45
+ */
+export function validateFullBoard(board: string): boolean {
+ // --- Basic sanity ---
+ if (board.length !== 81) return false;
+ if (!/^[1-9]+$/.test(board)) return false;
+
+ const getCell = (r: number, c: number) => Number(board[r * 9 + c]);
+
+ // --- Validate rows ---
+ for (let r = 0; r < 9; r++) {
+ const sum = Array.from({ length: 9 }, (_, c) => getCell(r, c))
+ .reduce((a, b) => a + b, 0);
+ if (sum !== 45) return false;
+ }
+
+ // --- Validate columns ---
+ for (let c = 0; c < 9; c++) {
+ const sum = Array.from({ length: 9 }, (_, r) => getCell(r, c))
+ .reduce((a, b) => a + b, 0);
+ if (sum !== 45) return false;
+ }
+
+ // --- Validate 3Γ3 boxes ---
+ for (let br = 0; br < 3; br++) {
+ for (let bc = 0; bc < 3; bc++) {
+ const sum = Array.from({ length: 9 }, (_, i) => {
+ const r = br * 3 + Math.floor(i / 3);
+ const c = bc * 3 + (i % 3);
+ return getCell(r, c);
+ }).reduce((a, b) => a + b, 0);
+ if (sum !== 45) return false;
+ }
+ }
+
+ return true;
+}
+/**
+ * π Assert that a Sudoku board string represents a valid, fully filled grid.
+ *
+ * Format:
+ * - 81 characters
+ * - Only digits 1β9
+ * - Each row, column, and 3Γ3 box contains unique digits that sum to 45
+ *
+ * Throws descriptive errors on failure.
+ */
+
+export function assertValidFullBoard(board: string): void {
+ // --- Basic integrity checks ---
+ console.assert(board.length === 81, `β Board length must be 81, got ${board.length}`);
+ if (board.length !== 81) throw new Error(`Invalid board length: ${board.length}`);
+
+ // All characters must be digits 1β9
+ console.assert(/^[1-9]+$/.test(board), "β Board must contain only digits 1β9 (no 0s or dots)");
+ if (!/^[1-9]+$/.test(board)) throw new Error("Board contains invalid characters");
+
+ const get = (r: number, c: number) => Number(board[r * 9 + c]);
+
+ // --- Helper to assert uniqueness and sum ---
+ const assertGroup = (label: string, cells: number[]) => {
+ const sum = cells.reduce((a, b) => a + b, 0);
+ const unique = new Set(cells);
+ console.assert(sum === 45, `β ${label} sum=${sum} (should be 45)`);
+ console.assert(unique.size === 9, `β ${label} has duplicates: [${cells.join(",")}]`);
+
+ if (sum !== 45) throw new Error(`${label} does not sum to 45`);
+ if (unique.size !== 9) throw new Error(`${label} contains duplicates`);
+ };
+
+ // --- Validate rows ---
+ for (let r = 0; r < 9; r++) {
+ const row = Array.from({ length: 9 }, (_, c) => get(r, c));
+ assertGroup(`Row ${r + 1}`, row);
+ }
+
+ // --- Validate columns ---
+ for (let c = 0; c < 9; c++) {
+ const col = Array.from({ length: 9 }, (_, r) => get(r, c));
+ assertGroup(`Column ${c + 1}`, col);
+ }
+
+ // --- Validate 3Γ3 boxes ---
+ for (let br = 0; br < 3; br++) {
+ for (let bc = 0; bc < 3; bc++) {
+ const box: number[] = [];
+ for (let r = 0; r < 3; r++) {
+ for (let c = 0; c < 3; c++) {
+ box.push(get(br * 3 + r, bc * 3 + c));
+ }
+ }
+ const idx = br * 3 + bc + 1;
+ assertGroup(`Box ${idx}`, box);
+ }
+ }
+}
+
+
+
diff --git a/src_old/types.ts b/src_old/types.ts
new file mode 100644
index 0000000..d09f67c
--- /dev/null
+++ b/src_old/types.ts
@@ -0,0 +1,20 @@
+import { useGame } from "./useGame";
+import { useScore } from "./useScore";
+
+export type UseGameType = ReturnType;
+export type UseScoreType = ReturnType;
+
+export type {
+ Difficulty,
+ DifficultyName,
+} from "./lib/Sudoku";
+
+export type GamePhase = "idle" | "playing";
+export type PlayStatus = "started" | "game-over" | "game-won";
+export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
+
+export const isPlaying = (status: GameStatus): status is PlayStatus =>
+ status === "started" || status === "game-over" || status === "game-won";
+
+export const getGamePhase = (status: GameStatus): GamePhase =>
+ status === "waiting" ? "idle" : "playing";
diff --git a/src/useGame/index.ts b/src_old/useGame/index.ts
similarity index 100%
rename from src/useGame/index.ts
rename to src_old/useGame/index.ts
diff --git a/src/useGame/useBoardState.ts b/src_old/useGame/useBoardState.ts
similarity index 100%
rename from src/useGame/useBoardState.ts
rename to src_old/useGame/useBoardState.ts
diff --git a/src/useGame/useCursor.ts b/src_old/useGame/useCursor.ts
similarity index 100%
rename from src/useGame/useCursor.ts
rename to src_old/useGame/useCursor.ts
diff --git a/src/useGame/useGame.ts b/src_old/useGame/useGame.ts
similarity index 100%
rename from src/useGame/useGame.ts
rename to src_old/useGame/useGame.ts
diff --git a/src/useGame/useGameStatus.ts b/src_old/useGame/useGameStatus.ts
similarity index 100%
rename from src/useGame/useGameStatus.ts
rename to src_old/useGame/useGameStatus.ts
diff --git a/src/useGame/useKeyBindings.ts b/src_old/useGame/useKeyBindings.ts
similarity index 100%
rename from src/useGame/useKeyBindings.ts
rename to src_old/useGame/useKeyBindings.ts
diff --git a/src/useGame/useSudokuRenderer.ts b/src_old/useGame/useSudokuRenderer.ts
similarity index 100%
rename from src/useGame/useSudokuRenderer.ts
rename to src_old/useGame/useSudokuRenderer.ts
diff --git a/src/useScore/hooks/useTimer.ts b/src_old/useScore/hooks/useTimer.ts
similarity index 100%
rename from src/useScore/hooks/useTimer.ts
rename to src_old/useScore/hooks/useTimer.ts
diff --git a/src_old/useScore/index.ts b/src_old/useScore/index.ts
new file mode 100644
index 0000000..525ce75
--- /dev/null
+++ b/src_old/useScore/index.ts
@@ -0,0 +1 @@
+export * from './useScore';
diff --git a/src_old/useScore/useScore.ts b/src_old/useScore/useScore.ts
new file mode 100644
index 0000000..a85eae0
--- /dev/null
+++ b/src_old/useScore/useScore.ts
@@ -0,0 +1,107 @@
+import { useEffect, useState } from "react";
+import { useTimer } from "./hooks/useTimer";
+import { useScoreTime } from "./useScoreTime";
+import type { GameStatus } from "../types";
+
+const TIME_LIMIT_MS = Infinity; // No time limit by default
+const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
+
+type GameScoreContext = {
+ gameStatus: GameStatus;
+ setGameStatus: (status: GameStatus) => void;
+};
+
+type UseScoreParams = {
+ gameContext: GameScoreContext;
+ keystrokeCount: number;
+ board: string;
+ isComplete: (board: string) => boolean;
+ isFullyValid: (board: string) => boolean;
+ initialBoard: string;
+};
+
+export function useScore({
+ gameContext,
+ keystrokeCount,
+ board,
+ isComplete,
+ isFullyValid,
+ initialBoard
+}: UseScoreParams) {
+ const timer = useTimer();
+ const { gameStatus, setGameStatus } = gameContext;
+ const { timeValue, startTimer, stopTimer, resetTimer } = timer;
+
+ const [finalScore, setFinalScore] = useState(null);
+
+ const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
+ const actualMoves = keystrokeCount;
+ const efficiency = minMovesRequired > 0
+ ? Math.round((actualMoves / minMovesRequired) * 100)
+ : 0;
+
+ useScoreTime({ gameStatus, timer });
+
+ useEffect(() => {
+ if (gameStatus !== "started") return;
+
+ if (isComplete(board) && isFullyValid(board)) {
+ setGameStatus("game-won");
+ return;
+ }
+
+ if (timeValue >= TIME_LIMIT_MS) {
+ setGameStatus("game-over");
+ return;
+ }
+
+ if (keystrokeCount >= KEYSTROKE_LIMIT) {
+ setGameStatus("game-over");
+ return;
+ }
+
+ // TODO: Enable efficiency-based game-over in future
+ // if (efficiency > 200) {
+ // setGameStatus("game-over");
+ // return;
+ // }
+ }, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
+
+ useEffect(() => {
+ const handleWin = () => {
+ if (gameStatus === "started") {
+ setGameStatus("game-won");
+ }
+ };
+ window.addEventListener("sudoku-game-won", handleWin);
+ return () => window.removeEventListener("sudoku-game-won", handleWin);
+ }, [gameStatus, setGameStatus]);
+
+ useEffect(() => {
+ if (gameStatus !== "game-won") return;
+
+ const timeInSeconds = timeValue / 1000;
+ const baseScore = 100000;
+ const timePenalty = Math.floor(timeInSeconds * 10);
+ const extraMoves = Math.max(0, actualMoves - minMovesRequired);
+ const movePenalty = extraMoves * 50;
+ const score = Math.max(0, baseScore - timePenalty - movePenalty);
+
+ setFinalScore(score);
+ }, [gameStatus, timeValue, actualMoves, minMovesRequired]);
+
+ return {
+ timeValue,
+ startTimer,
+ stopTimer,
+ resetTimer,
+ finalScore,
+ timeLimit: TIME_LIMIT_MS,
+ keystrokeLimit: KEYSTROKE_LIMIT,
+ minMovesRequired,
+ actualMoves,
+ efficiency,
+ };
+}
+
+export type UseScoreType = ReturnType;
diff --git a/src/useScore/useScoreTime.ts b/src_old/useScore/useScoreTime.ts
similarity index 100%
rename from src/useScore/useScoreTime.ts
rename to src_old/useScore/useScoreTime.ts