diff --git a/.gitignore b/.gitignore index 6051801..d02209e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ bower_components/ # Build outputs dist/ +# npm package tarballs +*.tgz +vimazing-vim-sudoku-*.tgz + # TypeScript *.tsbuildinfo diff --git a/.npmignore b/.npmignore index a2cf60a..35e71e4 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,12 @@ src/ +example/ node_modules/ tsconfig.json bun.lockb *.log - +.git +.gitignore +NPM_PUBLISHING_CHECKLIST.md +vim_sudoku_implementation.md +vim_sudoku_game_mechanics.md +vimazing-vim-sudoku-*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e15595e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to @vimazing/vim-sudoku will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-01-15 + +### Added +- Initial release of @vimazing/vim-sudoku +- VIM-style modal editing with navigation and edit modes +- Full VIM motions (h/j/k/l, counted moves, ^/$/0, repeat with .) +- Smart cell locking for pre-filled clues +- Real-time validation system (optional, disabled by default) +- Automatic win detection using sum-to-45 algorithm +- Time-based scoring with keystroke penalties +- Tokyo Night themed CSS with 3x3 box separators +- Configurable size multiplier via CSS variables +- Complete TypeScript support with type declarations +- Composable hook architecture (useGame, useScore, useBoardState, etc.) +- Tree-shakeable exports + +### Features +- `useGame(platformHook?, options?)` - Main game orchestration hook + - `validateMoves` option for real-time move validation (default: false) +- Automatic win detection when board is complete and valid +- Scoring formula: `max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)` +- VIM modal editing: navigation mode (default) and edit mode (R/C/I to enter) +- Visual feedback for all game states (navigation glow, edit mode pulse, locked cells) +- Support for counted motions (3j, 5l, etc.) +- Row navigation shortcuts (^, $, 0) +- Repeat last motion with `.` + +### Documentation +- Comprehensive README with usage examples +- VIM modal editing guide +- Cell state rules reference +- API documentation for all hooks +- Build and release instructions + +[0.1.0]: https://github.com/vimazing/vim-sudoku/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2b648fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 André Padez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NPM_PUBLISHING_CHECKLIST.md b/NPM_PUBLISHING_CHECKLIST.md new file mode 100644 index 0000000..3b0088a --- /dev/null +++ b/NPM_PUBLISHING_CHECKLIST.md @@ -0,0 +1,46 @@ +# 📋 npm Publishing Checklist for @vimazing/vim-sudoku + +## 🔴 Critical (Must Fix Before Publishing) + +- [ ] **Fix TypeScript build errors** - Package won't compile + - [ ] `Sudoku.ts`: operator type issues (lines 77, 93, 95) + - [ ] `useBoardState.ts`: remove unused `useCallback`, `useMemo` imports + - [ ] `types.ts`: remove non-existent `BoardString` export + - [ ] Remove unused variables: `idx`, `max_square` + +- [ ] **Add LICENSE file** - Required for MIT license in package.json + +- [ ] **Update package.json author** - Currently `you@example.com` + +## 🟡 Important (Should Fix) + +- [ ] **Update README.md** + - [ ] Remove "coming soon" for win detection (✅ implemented!) + - [ ] Document new `validateMoves` option in `useGame` + - [ ] Update scoring formula to include move penalty + +- [ ] **Fix tsconfig.json** - Conflicting `declaration` and `emitDeclarationOnly` flags + +- [ ] **Verify exports** - Check `src/index.ts` exports everything needed + +- [ ] **Add CHANGELOG.md** - Document version history and changes + +- [ ] **Add package.json keywords** - Improve npm discoverability (sudoku, vim, react, game, puzzle) + +- [ ] **Update .npmignore** - Verify `example/` is excluded + +## 🟢 Nice to Have + +- [ ] **Test locally** - Run `npm pack` and inspect tarball contents + +- [ ] **Add npm badges** - Version and downloads badges in README + +- [ ] **Add engines field** - Specify Node.js version requirements + +- [ ] **GitHub topics** - Add repository tags for discoverability + +--- + +**Total: 18 tasks** (6 critical, 6 important, 6 nice-to-have) + +**Status:** Ready to start working on critical issues 🚀 diff --git a/README.md b/README.md index dd055f8..d7d64b5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # @vimazing/vim-sudoku +[![npm version](https://img.shields.io/npm/v/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku) +[![npm downloads](https://img.shields.io/npm/dm/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku) +[![license](https://img.shields.io/npm/l/@vimazing/vim-sudoku.svg)](https://github.com/vimazing/vim-sudoku/blob/main/LICENSE) + +![VIM Sudoku Demo](./vim-sudoku.gif) + Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**. Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project. @@ -146,9 +152,14 @@ Jump to end: Press $ ## Core Hooks -### `useGame(platformHook?)` +### `useGame(platformHook?, options?)` -Main hook that orchestrates the entire sudoku game. Returns: +Main hook that orchestrates the entire sudoku game. + +**Options:** +- `validateMoves?: boolean` (default: `false`) - Enable real-time move validation with conflict detection. When `false`, allows harder gameplay without immediate feedback. + +**Returns:** ```ts { @@ -201,11 +212,17 @@ import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState"; ### Scoring -Time-based scoring formula: +Scoring formula with time and move penalties: ``` -score = max(0, 100000 - timeInSeconds × 10) +score = max(0, 100000 - timeInSeconds × 10 - extraMoves × 50) ``` +Where: +- `baseScore = 100000` +- `timePenalty = timeInSeconds × 10` +- `extraMoves = actualKeystrokes - minMovesRequired` +- `movePenalty = extraMoves × 50` + ### Game Over Conditions - ⏱️ **Time limit**: 60 seconds @@ -219,7 +236,13 @@ const KEYSTROKE_LIMIT = 100; ### Win Detection -Currently manual (solving via UI or `solveBoard()`). Automatic win detection coming soon! +Automatic win detection is fully implemented using an efficient sum-to-45 algorithm: +- Validates when board is complete (no empty cells) +- Checks all rows sum to 45 (1+2+...+9) +- Checks all columns sum to 45 +- Checks all 3x3 boxes sum to 45 + +Manual win trigger available via `solveBoard()` for testing. ### Board Format diff --git a/src/GameOverlay.tsx b/example/src/GameOverlay.tsx similarity index 56% rename from src/GameOverlay.tsx rename to example/src/GameOverlay.tsx index f03c57f..e0d6340 100644 --- a/src/GameOverlay.tsx +++ b/example/src/GameOverlay.tsx @@ -25,21 +25,24 @@ export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => { export const GameWaitingContent = () => { return (
-
-

Press New Puzzle to start

-
-

Navigation Mode (default):

+
+

Press New Puzzle to start

+
+

Navigation Mode (default):

h,j,k,l to move around

-

R or C to enter edit mode

+

R or C to enter edit mode

-

Edit Mode:

+

Edit Mode:

Type 1-9 to fill cell

-

Esc or Enter to exit edit mode

+

Esc or Enter to exit edit mode

-

+

Try vim motions: $, ^, 0, counted moves

@@ -53,13 +56,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) return (

🎉 Puzzle Solved!

Final Score:

-

{finalScore.toLocaleString()}

-

+

{finalScore.toLocaleString()}

+

max ( 0 @@ -82,8 +89,8 @@ export const GameOverContent = () => { className="text-[#ff757f] font-mono uppercase text-center select-none" style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }} > -

Game Over

-

Press i to play again

+

Game Over

+

Press i to play again

); }; diff --git a/src/MobileGameOverlay.tsx b/example/src/MobileGameOverlay.tsx similarity index 63% rename from src/MobileGameOverlay.tsx rename to example/src/MobileGameOverlay.tsx index 8d03524..10f6632 100644 --- a/src/MobileGameOverlay.tsx +++ b/example/src/MobileGameOverlay.tsx @@ -20,11 +20,14 @@ export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverla export const GameWaitingContent = () => { return (
-
-

Tap New Puzzle to start

+
+

Tap New Puzzle to start

VIM motions available
via on-screen keyboard

@@ -36,13 +39,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) return (

🎉 Solved!

Final Score:

-

{finalScore.toLocaleString()}

-

+

{finalScore.toLocaleString()}

+

max ( 0 @@ -62,8 +69,11 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) export const GameOverContent = () => { return (

Game Over
diff --git a/example/src/index.css b/example/src/index.css index f1d8c73..14599bf 100644 --- a/example/src/index.css +++ b/example/src/index.css @@ -1 +1,5 @@ @import "tailwindcss"; + +:root { + --sudoku-size-multiplier: 3; +} diff --git a/example/tailwind.config.ts b/example/tailwind.config.ts new file mode 100644 index 0000000..56cd7c4 --- /dev/null +++ b/example/tailwind.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'tailwindcss' + +export default { + content: [ + './src/**/*.{js,ts,jsx,tsx}', + '../src/**/*.{js,ts,jsx,tsx}', + ], +} satisfies Config diff --git a/package.json b/package.json index aa3ffa2..ea16710 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@vimazing/vim-sudoku", "version": "0.1.0", "description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.", - "author": "André Padez ", + "author": "André Padez ", "repository": { "type": "git", "url": "https://github.com/vimazing/vim-sudoku.git" @@ -12,7 +12,21 @@ }, "homepage": "https://github.com/vimazing/vim-sudoku#readme", "license": "MIT", + "keywords": [ + "sudoku", + "vim", + "react", + "hooks", + "game", + "puzzle", + "modal-editing", + "typescript", + "vimazing" + ], "type": "module", + "engines": { + "node": ">=18.0.0" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/lib/Sudoku.ts b/src/lib/Sudoku.ts index d6c6d00..8c1f7ee 100644 --- a/src/lib/Sudoku.ts +++ b/src/lib/Sudoku.ts @@ -51,11 +51,14 @@ function initialize() { // --------------------------------------------------------------------------- sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string { + let numericDifficulty: number; if (typeof difficulty === "string" || typeof difficulty === "undefined") { - difficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy; + numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy; + } else { + numericDifficulty = difficulty; } - difficulty = sudoku._force_range(difficulty, NR_SQUARES + 1, MIN_GIVENS); + numericDifficulty = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS); unique = unique ?? true; const blank_board = sudoku.BLANK_BOARD; @@ -74,7 +77,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string { } if ( - single_candidates.length >= difficulty && + single_candidates.length >= numericDifficulty && sudoku._strip_dups(single_candidates).length >= 8 ) { let board = ""; @@ -90,9 +93,9 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string { } } - if (givens_idxs.length > difficulty) { + if (givens_idxs.length > numericDifficulty) { givens_idxs = sudoku._shuffle(givens_idxs); - for (let i = 0; i < givens_idxs.length - difficulty; ++i) { + for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) { const target = givens_idxs[i]; board = board.substring(0, target) + @@ -105,7 +108,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string { } } - return sudoku.generate(difficulty); + return sudoku.generate(numericDifficulty); }; // --------------------------------------------------------------------------- @@ -187,12 +190,10 @@ sudoku._search = function (candidates: Record, reverse: boolean) reverse = reverse || false; let max_nr_candidates = 0; - let max_square = ""; for (const s of SQUARES) { const n = candidates[s].length; if (n > max_nr_candidates) { max_nr_candidates = n; - max_square = s; } } if (max_nr_candidates === 1) return candidates; diff --git a/src/sudoku.css b/src/sudoku.css index 50469ff..7aca8d4 100644 --- a/src/sudoku.css +++ b/src/sudoku.css @@ -1,28 +1,14 @@ /* ========================================================= 🔧 Controls (tweak these) ========================================================= */ -/* :root { */ -/* --cell-size: 32px; */ -/* --cell-font-size: 16px; */ -/* --grid-gap: 2px; */ -/* --box-separator: 3px; */ -/* } */ - :root { - --cell-size: 48px; - --cell-font-size: 32px; - --grid-gap: 4px; - --box-separator: 6px; + --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)); } - -/* :root { */ -/* --cell-size: 96px; */ -/* --cell-font-size: 48px; */ -/* --grid-gap: 6px; */ -/* --box-separator: 9px; */ -/* } */ - /* ========================================================= Container & Grid Base ========================================================= */ diff --git a/src/types.ts b/src/types.ts index 2370fc1..d09f67c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,6 @@ export type UseScoreType = ReturnType; export type { Difficulty, DifficultyName, - BoardString, } from "./lib/Sudoku"; export type GamePhase = "idle" | "playing"; diff --git a/src/useGame/useBoardState.ts b/src/useGame/useBoardState.ts index 8bf2e02..eecce25 100644 --- a/src/useGame/useBoardState.ts +++ b/src/useGame/useBoardState.ts @@ -1,9 +1,14 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState } from "react"; import { sudoku } from "../lib/Sudoku"; import type { UseCursorType } from "./useCursor"; -export function useBoardState(cursorManager: UseCursorType) { +type UseBoardStateOptions = { + validateMoves?: boolean; +}; + +export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) { const { cursor, setCursor } = cursorManager; + const { validateMoves = false } = options || {}; const [board, setBoard] = useState(sudoku.generate("medium")); const [initialBoard, setInitialBoard] = useState(board); const [solution, setSolution] = useState(null); @@ -26,8 +31,6 @@ export function useBoardState(cursorManager: UseCursorType) { const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => { if (digit === ".") return true; - const idx = row * 9 + col; - for (let c = 0; c < 9; c++) { const checkIdx = row * 9 + c; if (c !== col && testBoard[checkIdx] === digit) { @@ -131,7 +134,7 @@ export function useBoardState(cursorManager: UseCursorType) { }; const setDigit = (d: string) => { - const i = cursor.r * 9 + cursor.c; + const cellIndex = cursor.r * 9 + cursor.c; if (!canEditCell(cursor.r, cursor.c)) { window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); @@ -139,10 +142,10 @@ export function useBoardState(cursorManager: UseCursorType) { } const arr = board.split(""); - arr[i] = d; + arr[cellIndex] = d; const newBoard = arr.join(""); - if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) { + if (validateMoves && !isValidPlacement(newBoard, cursor.r, cursor.c, d)) { const conflicts = getConflicts(newBoard, cursor.r, cursor.c); window.dispatchEvent( new CustomEvent("sudoku-invalid-move", { @@ -153,11 +156,13 @@ export function useBoardState(cursorManager: UseCursorType) { } setBoard(newBoard); - window.dispatchEvent( - new CustomEvent("sudoku-valid-move", { - detail: { row: cursor.r, col: cursor.c, digit: d }, - }) - ); + if (validateMoves) { + window.dispatchEvent( + new CustomEvent("sudoku-valid-move", { + detail: { row: cursor.r, col: cursor.c, digit: d }, + }) + ); + } return true; }; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 6a72ef9..4150bb8 100644 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -6,14 +6,19 @@ import { useBoardState } from "./useBoardState"; import { useKeyBindings } from "./useKeyBindings"; import { useScore } from "../useScore"; -export function useGame(platformHook?: unknown) { +export type UseGameOptions = { + validateMoves?: boolean; +}; + +export function useGame(platformHook?: unknown, options?: UseGameOptions) { + const { validateMoves = false } = options || {}; const rendererManager = useRenderer(); const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager; const cursorManager = useCursor(rendererManager); const { cursor, moveCursor } = cursorManager; const gameManager = useGameStatus(rendererManager, cursorManager); const { gameStatus, setGameStatus, startGame, stopGame } = gameManager; - const boardManager = useBoardState(cursorManager); + const boardManager = useBoardState(cursorManager, { validateMoves }); const { board, initialBoard, diff --git a/tsconfig.json b/tsconfig.json index 14cd137..c98748a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,8 @@ "isolatedModules": true, "moduleDetection": "force", "noEmit": false, + "declaration": true, + "emitDeclarationOnly": false, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/vim-sudoku.gif b/vim-sudoku.gif new file mode 100644 index 0000000..bc787d7 Binary files /dev/null and b/vim-sudoku.gif differ