diff --git a/src/islands/games/OnetGame.tsx b/src/islands/games/OnetGame.tsx new file mode 100644 index 0000000..b2a54c5 --- /dev/null +++ b/src/islands/games/OnetGame.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Maximize2, Minimize2, Lightbulb, Shuffle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { useExpand } from '@/hooks/useExpand'; +import { + DIFFICULTIES, createBoard, findPath, findHint, hasAnyMove, removePair, + shuffleBoard, tilesLeft, isSolved, pairScore, posEq, + type Grid, type Pos, type Difficulty, +} from '@/tools/games/onet.lib'; +import type { Lang } from '@/i18n/config'; + +/** 24 tiles chosen to stay distinct at small size (different colours + shapes). */ +const TILES = [ + '🍎', '🍊', 'πŸ‹', 'πŸ‡', 'πŸ“', 'πŸ‘', '🍍', 'πŸ₯', + '🐢', '🐱', '🐸', '🐡', '🐼', '🦊', '🐨', '🦁', + '🌸', '🌻', '🌡', 'πŸ€', '⭐', '⚑', 'πŸ””', '🎈', +]; + +const BEST_KEY = 'gwt-onet-best'; +const TIMED_SECONDS: Record = { easy: 180, normal: 300, hard: 420 }; + +const TR: Record> = { + en: { + intro: 'Match pairs of identical tiles that can be joined by a line with at most two turns β€” the line may also travel around the outside of the board. Clear every tile to win.', + relaxed: 'Relaxed', timed: 'Timed', hint: 'Hint', shuffle: 'Shuffle', newGame: 'New game', + left: 'Tiles left', time: 'Time', best: 'Best', score: 'Score', expand: 'Expand', exit: 'Exit', + won: 'Board cleared! πŸŽ‰', lost: 'Time’s up!', noMoves: 'No moves left β€” shuffling…', + easy: 'Easy', normal: 'Normal', hard: 'Hard', + hintUsed: 'Hints used', tapHint: 'Tap two matching tiles.', + }, + id: { + intro: 'Cocokkan pasangan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan β€” garisnya juga boleh lewat di luar papan. Habiskan semua ubin untuk menang.', + relaxed: 'Santai', timed: 'Berwaktu', hint: 'Petunjuk', shuffle: 'Acak', newGame: 'Main baru', + left: 'Sisa ubin', time: 'Waktu', best: 'Terbaik', score: 'Skor', expand: 'Perbesar', exit: 'Keluar', + won: 'Papan bersih! πŸŽ‰', lost: 'Waktu habis!', noMoves: 'Tidak ada langkah β€” mengacak…', + easy: 'Mudah', normal: 'Normal', hard: 'Sulit', + hintUsed: 'Petunjuk dipakai', tapHint: 'Ketuk dua ubin yang sama.', + }, +}; + +const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.max(0, s % 60)).padStart(2, '0')}`; + +const emptyGrid = (d: Difficulty): Grid => + Array.from({ length: d.rows }, () => Array(d.cols).fill(0)); + +export default function OnetGame({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const { ref: stageRef, expanded, enter, exit } = useExpand(); + + const [diff, setDiff] = useState(DIFFICULTIES[1]); + const [timed, setTimed] = useState(false); + // Start with a deterministic empty board: dealing a random one here would + // differ between the server render and the client, causing a hydration + // mismatch. The real board is dealt on mount. + const [grid, setGrid] = useState(() => emptyGrid(DIFFICULTIES[1])); + const [ready, setReady] = useState(false); + const [selected, setSelected] = useState(null); + const [path, setPath] = useState(null); + const [hintPair, setHintPair] = useState<[Pos, Pos] | null>(null); + const [score, setScore] = useState(0); + const [streak, setStreak] = useState(0); + const [hintsUsed, setHintsUsed] = useState(0); + const [elapsed, setElapsed] = useState(0); + const [status, setStatus] = useState<'playing' | 'won' | 'lost'>('playing'); + const [notice, setNotice] = useState(''); + const [best, setBest] = useState>({}); + + const pathTimer = useRef | null>(null); + const noticeTimer = useRef | null>(null); + + useEffect(() => { + try { setBest(JSON.parse(localStorage.getItem(BEST_KEY) ?? '{}')); } catch { /* blocked */ } + setGrid(createBoard(DIFFICULTIES[1])); + setReady(true); + return () => { + if (pathTimer.current) clearTimeout(pathTimer.current); + if (noticeTimer.current) clearTimeout(noticeTimer.current); + }; + }, []); + + const limit = TIMED_SECONDS[diff.id] ?? 300; + const remaining = Math.max(0, limit - elapsed); + + // Clock: counts up in relaxed mode, down in timed mode. + useEffect(() => { + if (status !== 'playing') return; + const id = setInterval(() => setElapsed((e) => e + 1), 1000); + return () => clearInterval(id); + }, [status]); + + useEffect(() => { + if (timed && status === 'playing' && remaining <= 0) setStatus('lost'); + }, [timed, remaining, status]); + + const saveBest = useCallback((secs: number) => { + setBest((prev) => { + const key = `${diff.id}${timed ? '-timed' : ''}`; + if (prev[key] !== undefined && prev[key] <= secs) return prev; + const next = { ...prev, [key]: secs }; + try { localStorage.setItem(BEST_KEY, JSON.stringify(next)); } catch { /* blocked */ } + return next; + }); + }, [diff.id, timed]); + + const start = useCallback((d: Difficulty, useTimer: boolean) => { + setReady(true); + if (pathTimer.current) clearTimeout(pathTimer.current); + setGrid(createBoard(d)); + setDiff(d); + setTimed(useTimer); + setSelected(null); + setPath(null); + setHintPair(null); + setScore(0); + setStreak(0); + setHintsUsed(0); + setElapsed(0); + setStatus('playing'); + setNotice(''); + }, []); + + const flashNotice = (msg: string) => { + setNotice(msg); + if (noticeTimer.current) clearTimeout(noticeTimer.current); + noticeTimer.current = setTimeout(() => setNotice(''), 1600); + }; + + /** After a removal: win, or reshuffle when the board has no legal move. */ + const settle = useCallback((next: Grid, secs: number) => { + if (isSolved(next)) { + setStatus('won'); + saveBest(secs); + return next; + } + if (!hasAnyMove(next)) { + flashNotice(t.noMoves); + return shuffleBoard(next); + } + return next; + }, [saveBest, t.noMoves]); + + const tap = (r: number, c: number) => { + if (status !== 'playing' || grid[r][c] === 0) return; + const here: Pos = { r, c }; + setHintPair(null); + + if (!selected) { setSelected(here); return; } + if (posEq(selected, here)) { setSelected(null); return; } + + // Tapping a different symbol just moves the selection. + if (grid[selected.r][selected.c] !== grid[r][c]) { setSelected(here); return; } + + const found = findPath(grid, selected, here); + if (!found) { setSelected(here); setStreak(0); return; } + + // Show the connecting line briefly, then clear the pair. + setPath(found); + const cleared = removePair(grid, selected, here); + setSelected(null); + const nextStreak = streak + 1; + setStreak(nextStreak); + setScore((s) => s + pairScore(nextStreak)); + if (pathTimer.current) clearTimeout(pathTimer.current); + pathTimer.current = setTimeout(() => { + setPath(null); + setGrid(settle(cleared, elapsed)); + }, 240); + }; + + const useHint = () => { + if (status !== 'playing') return; + const found = findHint(grid); + if (!found) { flashNotice(t.noMoves); setGrid(shuffleBoard(grid)); return; } + setHintPair(found); + setHintsUsed((n) => n + 1); + setScore((s) => Math.max(0, s - 5)); + }; + + const doShuffle = () => { + if (status !== 'playing') return; + setSelected(null); + setGrid(shuffleBoard(grid)); + setScore((s) => Math.max(0, s - 10)); + }; + + const isHinted = (r: number, c: number) => + !!hintPair && (posEq(hintPair[0], { r, c }) || posEq(hintPair[1], { r, c })); + + const bestKey = `${diff.id}${timed ? '-timed' : ''}`; + const bestTime = best[bestKey]; + + const seg = (active: boolean) => + `border-2 px-3 py-1 text-sm font-medium transition-all ${ + active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal' + }`; + + return ( +
+

{t.intro}

+ +
+
+ {DIFFICULTIES.map((d) => ( + + ))} + + +
+ +
+ + {t.left}: {tilesLeft(grid)} + + + {t.score}: {score} + + + {t.time}:{' '} + {fmt(timed ? remaining : elapsed)} + + {bestTime !== undefined && ( + + {t.best}: {fmt(bestTime)} + + )} +
+ + {timed && ( +
+
+
+ )} + +
+
+ {grid.map((row, r) => + row.map((v, c) => { + const sel = selected && posEq(selected, { r, c }); + return ( + + ); + }), + )} + + {/* Connecting line, drawn in cell units with a one-cell margin so + routes that leave the board are visible. */} + {path && ( + + `${p.c + 0.5},${p.r + 0.5}`).join(' ')} + fill="none" + stroke="rgb(74,222,128)" + strokeWidth={0.14} + strokeLinecap="round" + strokeLinejoin="round" + vectorEffect="non-scaling-stroke" + /> + + )} + + {ready && status !== 'playing' && ( +
+ {status === 'won' ? t.won : t.lost} + {t.score}: {score} Β· {t.time}: {fmt(timed ? limit - remaining : elapsed)} + +
+ )} +
+
+ +
+ + + + +
+ +

+ {notice || (hintsUsed > 0 ? `${t.hintUsed}: ${hintsUsed}` : t.tapHint)} +

+
+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 3ed5ada..67c8afe 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -865,6 +865,24 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game runs with no internet connection β€” fitting, for this one.' }, ], }, + 'onet': { + title: 'Onet Connect β€” Free Tile Matching Game Online', + description: 'Play Onet free in your browser: match pairs of identical tiles that a line can join with at most two turns. Three difficulties, relaxed or timed. No ads, works offline.', + intro: 'Onet Connect is the classic tile-matching puzzle: clear the board by pairing identical tiles that can be joined by a line with at most two turns. The line can also travel around the outside of the board, which is what makes edge tiles matchable. Pick a difficulty, play relaxed or against the clock, and use a hint or shuffle when you get stuck. Everything runs in your browser β€” no ads, no account.', + howTo: [ + 'Tap one tile, then tap a matching tile to connect them.', + 'The connecting line may bend at most twice, and may pass around the outside of the board.', + 'Use Hint to reveal a valid pair, or Shuffle to redeal when you are stuck.', + 'Clear every tile to win β€” try Timed mode for a countdown challenge.', + ], + faqs: [ + { q: 'What are the matching rules?', a: 'Two tiles must show the same picture and be joinable by a line that turns at most twice and passes only through empty space. The line may also route around the outside edge of the board.' }, + { q: 'What happens when there are no moves left?', a: 'The board reshuffles automatically so you can keep playing β€” the remaining tiles stay in their cells, only the pictures move.' }, + { q: 'Is there a timer?', a: 'Only if you want one. Relaxed mode has no clock; Timed mode gives you a countdown that depends on the difficulty. Your best time is saved on this device.' }, + { q: 'Does it work on mobile?', a: 'Yes β€” tap tiles to select them, and use Expand for a bigger fullscreen board, which helps a lot on the harder sizes.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' }, + ], + }, 'snake': { title: 'Snake Game β€” Play the Classic Snake Online Free', description: 'Play classic snake in your browser: swipe or use arrow keys, grab golden bonus food, and optionally wrap through the walls. Free, no ads, works offline.', @@ -3805,6 +3823,24 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game berjalan tanpa koneksi internet β€” cocok untuk game yang satu ini.' }, ], }, + 'onet': { + title: 'Onet Connect β€” Game Cocokkan Ubin Gratis Online', + description: 'Main Onet gratis di browser: cocokkan pasangan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan. Tiga tingkat kesulitan, santai atau berwaktu. Tanpa iklan, bekerja offline.', + intro: 'Onet Connect adalah puzzle cocokkan ubin klasik: bersihkan papan dengan memasangkan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan. Garisnya juga boleh lewat di luar papan β€” itulah yang membuat ubin di tepi tetap bisa dipasangkan. Pilih tingkat kesulitan, main santai atau berpacu dengan waktu, dan gunakan petunjuk atau acak saat buntu. Semuanya berjalan di browser Anda β€” tanpa iklan, tanpa akun.', + howTo: [ + 'Ketuk satu ubin, lalu ketuk ubin yang sama untuk menghubungkannya.', + 'Garis penghubung boleh berbelok maksimal dua kali, dan boleh lewat di luar papan.', + 'Gunakan Petunjuk untuk menampilkan pasangan yang valid, atau Acak saat buntu.', + 'Habiskan semua ubin untuk menang β€” coba mode Berwaktu untuk tantangan hitung mundur.', + ], + faqs: [ + { q: 'Apa aturan pencocokannya?', a: 'Dua ubin harus bergambar sama dan bisa dihubungkan garis yang berbelok maksimal dua kali serta hanya melewati ruang kosong. Garisnya juga boleh memutar lewat tepi luar papan.' }, + { q: 'Apa yang terjadi kalau tidak ada langkah tersisa?', a: 'Papan diacak ulang otomatis agar Anda bisa terus bermain β€” ubin tetap di selnya, hanya gambarnya yang berpindah.' }, + { q: 'Apakah ada timer?', a: 'Hanya jika Anda mau. Mode Santai tanpa jam; mode Berwaktu memberi hitung mundur sesuai tingkat kesulitan. Waktu terbaik Anda tersimpan di perangkat ini.' }, + { q: 'Apakah bisa di ponsel?', a: 'Ya β€” ketuk ubin untuk memilih, dan gunakan Perbesar untuk papan layar penuh yang lebih besar, sangat membantu di ukuran sulit.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' }, + ], + }, 'snake': { title: 'Game Ular β€” Main Snake Klasik Online Gratis', description: 'Main snake klasik di browser: geser atau pakai tombol panah, ambil makanan bonus emas, dan opsional tembus dinding. Gratis, tanpa iklan, bekerja offline.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 60931dd..f28d29e 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2 } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -861,6 +861,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/games/SnakeGame'), status: 'beta' }, + { + id: 'onet', + name: 'Onet Connect', + category: 'Games', + route: '/tools/onet', + keywords: ['onet', 'onet connect', 'tile match game', 'connect pairs', 'pikachu game', 'game onet', 'cocokkan ubin'], + icon: Link2, + summary: 'Match tile pairs joined by a line with at most two turns', + load: () => import('@/islands/games/OnetGame'), + status: 'beta' + }, { id: 'pdf-organize', name: 'Organize PDF', diff --git a/src/tools/games/onet.lib.test.ts b/src/tools/games/onet.lib.test.ts new file mode 100644 index 0000000..9f5c93d --- /dev/null +++ b/src/tools/games/onet.lib.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect } from 'vitest'; +import { + findPath, canConnect, findHint, hasAnyMove, removePair, shuffleBoard, + createBoard, tilesLeft, isSolved, simplifyPath, pairScore, posEq, + DIFFICULTIES, type Grid, +} from './onet.lib'; + +/** Build a grid from rows of digits; '.' is empty. */ +const g = (...rows: string[]): Grid => + rows.map((row) => [...row].map((ch) => (ch === '.' ? 0 : Number(ch)))); + +const seeded = (seed: number) => { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +describe('findPath β€” straight line (0 turns)', () => { + it('connects horizontal neighbours', () => { + const grid = g('11'); + expect(findPath(grid, { r: 0, c: 0 }, { r: 0, c: 1 })).not.toBeNull(); + }); + + it('connects along a clear row', () => { + const grid = g('1..1'); + const path = findPath(grid, { r: 0, c: 0 }, { r: 0, c: 3 }); + expect(path).not.toBeNull(); + expect(path).toHaveLength(2); // start + end, no turns + }); + + it('connects along a clear column', () => { + const grid = g('1', '.', '.', '1'); + expect(canConnect(grid, { r: 0, c: 0 }, { r: 3, c: 0 })).toBe(true); + }); + + it('still connects a blocked row by going around the outside', () => { + // The direct row is blocked by the 2, but the top margin gives a 2-turn route. + const grid = g('1.2.1'); + expect(canConnect(grid, { r: 0, c: 0 }, { r: 0, c: 4 })).toBe(true); + }); + + it('refuses a blocked row when the outside route needs too many turns', () => { + const grid = g( + '999', + '121', + '999', + ); + expect(findPath(grid, { r: 1, c: 0 }, { r: 1, c: 2 })).toBeNull(); + }); +}); + +describe('findPath β€” one and two turns', () => { + it('connects with a single turn (L shape)', () => { + const grid = g( + '1..', + '...', + '..1', + ); + const path = findPath(grid, { r: 0, c: 0 }, { r: 2, c: 2 }); + expect(path).not.toBeNull(); + expect(path!.length).toBeGreaterThanOrEqual(3); // has at least one corner + }); + + it('connects with two turns (Z / U shape around a wall)', () => { + const grid = g( + '1.2', + '..2', + '1.2', + ); + // Straight down column 0 is clear β†’ still connectable. + expect(canConnect(grid, { r: 0, c: 0 }, { r: 2, c: 0 })).toBe(true); + }); + + it('routes around a blocking wall using two turns', () => { + const grid = g( + '1929', + '.99.', + '1...', + ); + // (0,0) β†’ down β†’ right? Column 0 clear to (2,0): a straight shot. + expect(canConnect(grid, { r: 0, c: 0 }, { r: 2, c: 0 })).toBe(true); + }); + + it('rejects a path that would need three turns', () => { + // 1s are boxed in by 9s such that no ≀2-turn route exists. + const grid = g( + '9991', + '1.99', + '9999', + '9999', + ); + expect(canConnect(grid, { r: 0, c: 3 }, { r: 1, c: 0 })).toBe(false); + }); +}); + +describe('findPath β€” routing outside the board', () => { + it('connects two tiles on opposite edges by going around the outside', () => { + // Middle is packed; the only route is out through the margin. + const grid = g( + '1991', + '9999', + '9999', + ); + expect(canConnect(grid, { r: 0, c: 0 }, { r: 0, c: 3 })).toBe(true); + }); + + it('connects top-left and bottom-left corners around the left margin', () => { + const grid = g( + '19', + '99', + '19', + ); + expect(canConnect(grid, { r: 0, c: 0 }, { r: 2, c: 0 })).toBe(true); + }); + + it('refuses OPPOSITE corners of a completely full board (that needs three turns)', () => { + // Out one side, along, back in β€” the final approach costs a third turn. + const grid = g( + '199', + '999', + '991', + ); + expect(canConnect(grid, { r: 0, c: 0 }, { r: 2, c: 2 })).toBe(false); + }); + + it('still refuses when the interior AND the needed turns exceed the limit', () => { + // A single tile pair separated by a full board needing >2 turns. + const grid = g( + '9199', + '9999', + '9999', + '9919', + ); + expect(canConnect(grid, { r: 0, c: 1 }, { r: 3, c: 2 })).toBe(false); + }); +}); + +describe('findPath β€” validity guards', () => { + const grid = g('1.2', '...', '1.2'); + + it('refuses different symbols', () => { + expect(findPath(grid, { r: 0, c: 0 }, { r: 0, c: 2 })).toBeNull(); + }); + + it('refuses the same tile twice', () => { + expect(findPath(grid, { r: 0, c: 0 }, { r: 0, c: 0 })).toBeNull(); + }); + + it('refuses an empty cell', () => { + expect(findPath(grid, { r: 1, c: 1 }, { r: 0, c: 0 })).toBeNull(); + }); + + it('refuses out-of-bounds coordinates', () => { + expect(findPath(grid, { r: -1, c: 0 }, { r: 0, c: 0 })).toBeNull(); + expect(findPath(grid, { r: 0, c: 0 }, { r: 9, c: 9 })).toBeNull(); + }); + + it('returns a path that starts and ends on the chosen tiles', () => { + const path = findPath(grid, { r: 0, c: 0 }, { r: 2, c: 0 })!; + expect(posEq(path[0], { r: 0, c: 0 })).toBe(true); + expect(posEq(path[path.length - 1], { r: 2, c: 0 })).toBe(true); + }); + + it('never returns more than four points (≀2 turns)', () => { + const board = createBoard(DIFFICULTIES[0], seeded(7)); + const hint = findHint(board); + if (hint) { + const path = findPath(board, hint[0], hint[1])!; + expect(path.length).toBeLessThanOrEqual(4); + } + }); +}); + +describe('simplifyPath', () => { + it('collapses collinear points to the corners', () => { + const pts = [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 2 }]; + expect(simplifyPath(pts)).toEqual([{ r: 0, c: 0 }, { r: 0, c: 2 }, { r: 1, c: 2 }]); + }); + + it('leaves short paths alone', () => { + const pts = [{ r: 0, c: 0 }, { r: 0, c: 1 }]; + expect(simplifyPath(pts)).toEqual(pts); + }); +}); + +describe('board state', () => { + it('counts tiles and detects a solved board', () => { + expect(tilesLeft(g('1.1', '...'))).toBe(2); + expect(isSolved(g('...', '...'))).toBe(true); + expect(isSolved(g('1..'))).toBe(false); + }); + + it('removePair clears both cells without mutating the input', () => { + const grid = g('1.1'); + const next = removePair(grid, { r: 0, c: 0 }, { r: 0, c: 2 }); + expect(tilesLeft(next)).toBe(0); + expect(tilesLeft(grid)).toBe(2); // original untouched + }); +}); + +describe('hints and deadlock', () => { + it('finds a connectable pair', () => { + const hint = findHint(g('1.1')); + expect(hint).not.toBeNull(); + expect(hasAnyMove(g('1.1'))).toBe(true); + }); + + it('reports no move when the only pair is unreachable', () => { + // Every filler symbol is unique (so they can't pair with each other) and the + // two 1s sit in opposite corners of a full board, which needs three turns. + const grid = g( + '123', + '456', + '781', + ); + expect(findHint(grid)).toBeNull(); + expect(hasAnyMove(grid)).toBe(false); + }); + + it('an empty board has no move', () => { + expect(hasAnyMove(g('...', '...'))).toBe(false); + }); +}); + +describe('shuffleBoard', () => { + it('keeps the same tiles in the same cells and yields a playable board', () => { + const grid = g( + '1..2', + '.33.', + '2..1', + ); + const next = shuffleBoard(grid, seeded(3)); + expect(tilesLeft(next)).toBe(tilesLeft(grid)); + // Occupied cells are unchanged β€” only the symbols move. + for (let r = 0; r < grid.length; r++) + for (let c = 0; c < grid[r].length; c++) + expect(next[r][c] === 0).toBe(grid[r][c] === 0); + expect(hasAnyMove(next)).toBe(true); + }); + + it('leaves an empty board alone', () => { + const empty = g('..', '..'); + expect(shuffleBoard(empty, seeded(1))).toEqual(empty); + }); +}); + +describe('createBoard', () => { + it.each(DIFFICULTIES.map((d) => [d.id, d] as const))('%s deals a playable board', (_id, d) => { + const board = createBoard(d, seeded(11)); + expect(board).toHaveLength(d.rows); + expect(board[0]).toHaveLength(d.cols); + expect(tilesLeft(board)).toBe(d.rows * d.cols); + expect(hasAnyMove(board)).toBe(true); + }); + + it('places every symbol an even number of times', () => { + const board = createBoard(DIFFICULTIES[0], seeded(5)); + const counts = new Map(); + for (const row of board) for (const v of row) counts.set(v, (counts.get(v) ?? 0) + 1); + for (const [, n] of counts) expect(n % 2).toBe(0); + }); + + it('rejects an odd number of cells', () => { + expect(() => createBoard({ id: 'odd', cols: 3, rows: 3, symbols: 4 }, seeded(1))).toThrow(); + }); + + it('a full board can always be cleared pair by pair', () => { + // Play greedily with hints; a fair game must never strand the player + // without offering a shuffle, and shuffles must keep it playable. + let board = createBoard(DIFFICULTIES[0], seeded(21)); + const rng = seeded(99); + let guard = 0; + while (tilesLeft(board) > 0 && guard++ < 200) { + const hint = findHint(board); + if (!hint) { board = shuffleBoard(board, rng); continue; } + board = removePair(board, hint[0], hint[1]); + } + expect(isSolved(board)).toBe(true); + }); +}); + +describe('pairScore', () => { + it('rewards streaks but caps the bonus', () => { + expect(pairScore(1)).toBe(10); + expect(pairScore(2)).toBe(15); + expect(pairScore(100)).toBe(50); + }); +}); diff --git a/src/tools/games/onet.lib.ts b/src/tools/games/onet.lib.ts new file mode 100644 index 0000000..830093c --- /dev/null +++ b/src/tools/games/onet.lib.ts @@ -0,0 +1,228 @@ +/** + * Pure logic for the Onet / connect tile-matching game. + * + * Two identical tiles clear when a path joins them using at most three straight + * segments (i.e. at most two turns) through empty cells. The path may leave the + * board through a one-cell margin all around β€” that border route is what makes + * edge tiles matchable and is the classic source of "why won't these match?". + * + * Grids are `Cell[][]` indexed [row][col]; 0 means empty. + */ + +export type Cell = number; // 0 = empty, >0 = tile symbol id +export type Grid = Cell[][]; +export interface Pos { r: number; c: number } +export type Rng = () => number; + +export interface Difficulty { id: string; cols: number; rows: number; symbols: number } + +export const DIFFICULTIES: Difficulty[] = [ + { id: 'easy', cols: 6, rows: 8, symbols: 12 }, + { id: 'normal', cols: 8, rows: 10, symbols: 18 }, + { id: 'hard', cols: 10, rows: 12, symbols: 24 }, +]; + +export const posEq = (a: Pos, b: Pos): boolean => a.r === b.r && a.c === b.c; + +/** Tiles remaining on the board. */ +export function tilesLeft(grid: Grid): number { + let n = 0; + for (const row of grid) for (const v of row) if (v !== 0) n++; + return n; +} + +export const isSolved = (grid: Grid): boolean => tilesLeft(grid) === 0; + +/** + * Is (r, c) walkable? The board is padded by one cell on every side, so + * coordinates from -1..rows and -1..cols are valid; anything in the margin is + * always walkable, and inside cells are walkable only when empty. + */ +function walkable(grid: Grid, r: number, c: number): boolean { + const rows = grid.length; + const cols = grid[0].length; + if (r < -1 || c < -1 || r > rows || c > cols) return false; + if (r === -1 || c === -1 || r === rows || c === cols) return true; // margin + return grid[r][c] === 0; +} + +const DIRS: Pos[] = [{ r: -1, c: 0 }, { r: 1, c: 0 }, { r: 0, c: -1 }, { r: 0, c: 1 }]; + +/** + * Find a connecting path between two matching tiles, or null. + * Returns the corner points (start β†’ turns β†’ end) so the UI can draw the line. + */ +export function findPath(grid: Grid, a: Pos, b: Pos): Pos[] | null { + if (posEq(a, b)) return null; + const rows = grid.length; + const cols = grid[0].length; + const inside = (p: Pos) => p.r >= 0 && p.c >= 0 && p.r < rows && p.c < cols; + if (!inside(a) || !inside(b)) return null; + const symbol = grid[a.r][a.c]; + if (symbol === 0 || grid[b.r][b.c] !== symbol) return null; + + // BFS over (cell, incoming direction, turns used). Keeping the best (lowest) + // turn count per (cell, direction) is enough β€” a cheaper arrival is never worse. + const key = (r: number, c: number, d: number) => `${r},${c},${d}`; + const best = new Map(); + const prev = new Map(); + const queue: { r: number; c: number; d: number; turns: number }[] = []; + + for (let d = 0; d < DIRS.length; d++) { + const nr = a.r + DIRS[d].r; + const nc = a.c + DIRS[d].c; + // The destination itself is a tile (not empty), so allow stepping onto it. + if (!walkable(grid, nr, nc) && !(nr === b.r && nc === b.c)) continue; + const k = key(nr, nc, d); + best.set(k, 0); + prev.set(k, null); + queue.push({ r: nr, c: nc, d, turns: 0 }); + } + + let endKey: string | null = null; + for (let i = 0; i < queue.length; i++) { + const cur = queue[i]; + if (cur.r === b.r && cur.c === b.c) { endKey = key(cur.r, cur.c, cur.d); break; } + // Can't travel THROUGH a tile β€” only stop on the target. + if (!walkable(grid, cur.r, cur.c)) continue; + for (let d = 0; d < DIRS.length; d++) { + const turns = cur.turns + (d === cur.d ? 0 : 1); + if (turns > 2) continue; + const nr = cur.r + DIRS[d].r; + const nc = cur.c + DIRS[d].c; + const isTarget = nr === b.r && nc === b.c; + if (!walkable(grid, nr, nc) && !isTarget) continue; + const k = key(nr, nc, d); + const known = best.get(k); + if (known !== undefined && known <= turns) continue; + best.set(k, turns); + prev.set(k, key(cur.r, cur.c, cur.d)); + queue.push({ r: nr, c: nc, d, turns }); + } + } + + if (!endKey) return null; + + // Walk the chain back, then keep only the corner points. + const chain: Pos[] = []; + let k: string | null = endKey; + while (k) { + const [r, c] = k.split(',').map(Number); + chain.push({ r, c }); + k = prev.get(k) ?? null; + } + chain.push({ r: a.r, c: a.c }); + chain.reverse(); + return simplifyPath(chain); +} + +/** Drop collinear midpoints so only the start, turns and end remain. */ +export function simplifyPath(points: Pos[]): Pos[] { + if (points.length <= 2) return points; + const out: Pos[] = [points[0]]; + for (let i = 1; i < points.length - 1; i++) { + const a = points[i - 1]; + const b = points[i]; + const c = points[i + 1]; + const straight = (a.r === b.r && b.r === c.r) || (a.c === b.c && b.c === c.c); + if (!straight) out.push(b); + } + out.push(points[points.length - 1]); + return out; +} + +export const canConnect = (grid: Grid, a: Pos, b: Pos): boolean => findPath(grid, a, b) !== null; + +/** First connectable pair on the board, or null when the player is stuck. */ +export function findHint(grid: Grid): [Pos, Pos] | null { + const spots: Pos[] = []; + for (let r = 0; r < grid.length; r++) + for (let c = 0; c < grid[r].length; c++) + if (grid[r][c] !== 0) spots.push({ r, c }); + + for (let i = 0; i < spots.length; i++) { + for (let j = i + 1; j < spots.length; j++) { + if (grid[spots[i].r][spots[i].c] !== grid[spots[j].r][spots[j].c]) continue; + if (canConnect(grid, spots[i], spots[j])) return [spots[i], spots[j]]; + } + } + return null; +} + +export const hasAnyMove = (grid: Grid): boolean => findHint(grid) !== null; + +/** Remove a matched pair (returns a new grid). */ +export function removePair(grid: Grid, a: Pos, b: Pos): Grid { + const next = grid.map((row) => [...row]); + next[a.r][a.c] = 0; + next[b.r][b.c] = 0; + return next; +} + +function shuffled(items: T[], rng: Rng): T[] { + const out = [...items]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +/** Re-deal the remaining tiles into their existing cells. */ +function redeal(grid: Grid, rng: Rng): Grid { + const spots: Pos[] = []; + const values: number[] = []; + for (let r = 0; r < grid.length; r++) + for (let c = 0; c < grid[r].length; c++) + if (grid[r][c] !== 0) { spots.push({ r, c }); values.push(grid[r][c]); } + + const mixed = shuffled(values, rng); + const next = grid.map((row) => row.map(() => 0)); + spots.forEach((p, i) => { next[p.r][p.c] = mixed[i]; }); + return next; +} + +/** + * Shuffle the remaining tiles, retrying until the result has at least one legal + * move so the player is never handed a dead board. + */ +export function shuffleBoard(grid: Grid, rng: Rng = Math.random, maxTries = 40): Grid { + if (tilesLeft(grid) === 0) return grid; + let last = grid; + for (let i = 0; i < maxTries; i++) { + last = redeal(grid, rng); + if (hasAnyMove(last)) return last; + } + return last; +} + +/** + * Deal a new board. Cell count must be even; each symbol is placed in pairs, so + * the board always has an even number of every tile. Retries until the opening + * position has a legal move. + */ +export function createBoard(d: Difficulty, rng: Rng = Math.random, maxTries = 40): Grid { + const total = d.cols * d.rows; + if (total % 2 !== 0) throw new Error('Board must have an even number of cells'); + const values: number[] = []; + for (let i = 0; i < total / 2; i++) { + const symbol = (i % d.symbols) + 1; + values.push(symbol, symbol); + } + for (let i = 0; i < maxTries; i++) { + const mixed = shuffled(values, rng); + const grid: Grid = []; + for (let r = 0; r < d.rows; r++) grid.push(mixed.slice(r * d.cols, (r + 1) * d.cols)); + if (hasAnyMove(grid)) return grid; + } + // Extremely unlikely; the caller can still shuffle. + const mixed = shuffled(values, rng); + const grid: Grid = []; + for (let r = 0; r < d.rows; r++) grid.push(mixed.slice(r * d.cols, (r + 1) * d.cols)); + return grid; +} + +/** Score for clearing a pair: base points plus a small streak bonus. */ +export function pairScore(streak: number): number { + return 10 + Math.min(40, Math.max(0, streak - 1) * 5); +}