From cd9c87fceb79a9f3ea7ca19ee2a5252a89a03905 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 01:40:24 +0800 Subject: [PATCH 01/20] chore: update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 8077003..bee95a6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr benchmark-results playwright-report +.husky +.pnpm-store test-results dataset/private/ *.local From c98dcb4c9dde4408f4a5673d300c0d4cce1e989b Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 02:42:59 +0800 Subject: [PATCH 02/20] feat: add DatasetPage component and routing, implement dataset puzzle previews and filtering --- src/App.tsx | 2 + src/app/DatasetPage.test.tsx | 125 ++++++++ src/app/DatasetPage.tsx | 272 ++++++++++++++++++ src/app/EditorPage.tsx | 120 +------- src/app/WorkspacePage.tsx | 1 + src/app/workspace.css | 159 ++++++++++ src/features/dataset/publicDatasets.ts | 8 + .../puzzlePreview/PuzzlePreviewBoard.tsx | 163 +++++++++++ 8 files changed, 734 insertions(+), 116 deletions(-) create mode 100644 src/app/DatasetPage.test.tsx create mode 100644 src/app/DatasetPage.tsx create mode 100644 src/features/dataset/publicDatasets.ts create mode 100644 src/features/puzzlePreview/PuzzlePreviewBoard.tsx diff --git a/src/App.tsx b/src/App.tsx index 13b5330..6b41264 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { DatasetPage } from './app/DatasetPage' import { EditorPage } from './app/EditorPage' import { WorkspacePage } from './app/WorkspacePage' @@ -6,6 +7,7 @@ function App() { return ( } /> + } /> } /> } /> diff --git a/src/app/DatasetPage.test.tsx b/src/app/DatasetPage.test.tsx new file mode 100644 index 0000000..0db6186 --- /dev/null +++ b/src/app/DatasetPage.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import App from '../App' +import { createSlitherPuzzle } from '../domain/ir/slither' +import { useEditorStore } from '../features/editor/editorStore' +import { useSolverStore } from '../features/solver/solverStore' + +const SAMPLE_URL = 'https://puzz.link/p?slither/3/3/g0h' + +const renderDataset = () => + render( + + + , + ) + +const getCard = (name: string): HTMLElement => { + const heading = screen.getByRole('heading', { name }) + const card = heading.closest('article') + if (!card) { + throw new Error(`Dataset card "${name}" not found.`) + } + return card +} + +describe('DatasetPage', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + useSolverStore.getState().importFromUrl(SAMPLE_URL, 'slitherlink') + useEditorStore.getState().loadEditorPuzzle(createSlitherPuzzle(5, 5)) + }) + + it('renders dataset navigation, controls, and public manifest cards', () => { + renderDataset() + + const nav = screen.getByRole('navigation', { name: /workspace navigation/i }) + expect(screen.getByRole('heading', { name: /puzzlekit dataset/i })).toBeInTheDocument() + expect(within(nav).getByRole('link', { name: /dataset/i })).toHaveAttribute('aria-current', 'page') + expect(within(nav).getByRole('link', { name: /solver/i })).toHaveAttribute('href', '/') + expect(within(nav).getByRole('link', { name: /editor/i })).toHaveAttribute('href', '/editor') + expect(screen.getByRole('heading', { name: /dataset controls/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'slitherlink-10x10-0001' })).toBeInTheDocument() + expect(screen.getByLabelText(/slitherlink-10x10-0001 dataset preview/i)).toHaveClass( + 'dataset-preview-canvas', + ) + expect(screen.getByText(/showing 56 \/ 56 puzzles/i)).toBeInTheDocument() + }) + + it('filters by search text, size, and tag', () => { + renderDataset() + + fireEvent.change(screen.getByPlaceholderText(/name, tag, size, type, or url/i), { + target: { value: 'slitherlink-6x6-0001' }, + }) + + expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0001' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' })).not.toBeInTheDocument() + expect(screen.getByText(/showing 1 \/ 56 puzzles/i)).toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText(/name, tag, size, type, or url/i), { + target: { value: '' }, + }) + fireEvent.change(screen.getByLabelText(/^size$/i), { target: { value: '6 x 6' } }) + + expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0001' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0002' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' })).not.toBeInTheDocument() + expect(screen.getByText(/showing 2 \/ 56 puzzles/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /auto-imported/i })) + expect(screen.getByRole('button', { name: /auto-imported/i })).toHaveAttribute( + 'data-active', + 'true', + ) + expect(screen.getByText(/showing 2 \/ 56 puzzles/i)).toBeInTheDocument() + }) + + it('renders compact action links for each dataset puzzle', () => { + renderDataset() + + const card = getCard('slitherlink-10x10-0001') + const sourceLink = within(card).getByRole('link', { name: 'URL' }) + + expect(sourceLink).toHaveAttribute( + 'href', + 'https://puzz.link/p?slither/10/10/372d23djdh738adl72882dj18538ald838dhaj21d272c', + ) + expect(sourceLink).toHaveAttribute('target', '_blank') + expect(sourceLink).toHaveAttribute('rel', 'noreferrer') + expect(within(card).getByRole('link', { name: 'Solver' })).toHaveAttribute('href', '/') + expect(within(card).getByRole('link', { name: 'Editor' })).toHaveAttribute('href', '/editor') + }) + + it('loads a dataset puzzle into the solver', () => { + renderDataset() + + fireEvent.click(within(getCard('slitherlink-6x6-0001')).getByRole('link', { name: 'Solver' })) + + expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() + expect(useSolverStore.getState().pluginId).toBe('slitherlink') + expect(useSolverStore.getState().initialPuzzle.rows).toBe(6) + expect(useSolverStore.getState().initialPuzzle.cols).toBe(6) + expect(useSolverStore.getState().sourceUrl).toBe( + 'https://puzz.link/p?slither/6/6/1bg688cgc121186dgbg2b', + ) + expect(useSolverStore.getState().pointer).toBe(0) + }) + + it('loads a dataset puzzle into the editor', () => { + renderDataset() + + fireEvent.click(within(getCard('slitherlink-10x10-0001')).getByRole('link', { name: 'Editor' })) + + expect(screen.getByRole('heading', { name: /puzzlekit editor/i })).toBeInTheDocument() + expect(useEditorStore.getState().pluginId).toBe('slitherlink') + expect(useEditorStore.getState().puzzle.rows).toBe(10) + expect(useEditorStore.getState().puzzle.cols).toBe(10) + expect(useEditorStore.getState().sourceUrl).toBe( + 'https://puzz.link/p?slither/10/10/372d23djdh738adl72882dj18538ald838dhaj21d272c', + ) + expect(useEditorStore.getState().selectedPresetId).toBeNull() + }) +}) diff --git a/src/app/DatasetPage.tsx b/src/app/DatasetPage.tsx new file mode 100644 index 0000000..8f2fcde --- /dev/null +++ b/src/app/DatasetPage.tsx @@ -0,0 +1,272 @@ +import { useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import type { BenchmarkDatasetItem } from '../domain/benchmark/types' +import { puzzleRegistry } from '../domain/plugins/registry' +import { BoardLegendButton } from '../features/board/BoardLegendButton' +import { publicDatasetManifests } from '../features/dataset/publicDatasets' +import { useEditorStore } from '../features/editor/editorStore' +import { PuzzleInfoButton } from '../features/puzzleInfo/PuzzleInfoButton' +import { PuzzlePreviewBoard } from '../features/puzzlePreview/PuzzlePreviewBoard' +import { useSolverStore } from '../features/solver/solverStore' +import './workspace.css' + +type DatasetPuzzleCard = BenchmarkDatasetItem & { + datasetId: string + datasetTitle: string + description: string +} + +const DATASET_PREVIEW_SIZE = 136 + +const datasetCards: DatasetPuzzleCard[] = publicDatasetManifests.flatMap((manifest) => + manifest.items.map((item) => ({ + ...item, + datasetId: manifest.id, + datasetTitle: manifest.title, + description: `${manifest.title}: ${item.height} x ${item.width} ${item.puzzleType} puzzle.`, + })), +) + +const buildSizeLabel = (item: Pick): string => + `${item.height} x ${item.width}` + +const parseDatasetPuzzle = (item: BenchmarkDatasetItem) => { + const plugin = puzzleRegistry.get(item.puzzleType) + if (!plugin) { + throw new Error(`Plugin "${item.puzzleType}" not found.`) + } + return plugin.parse(item.sourceUrl) +} + +const DatasetPreview = ({ item }: { item: DatasetPuzzleCard }) => { + const puzzle = useMemo(() => { + try { + return parseDatasetPuzzle(item) + } catch { + return null + } + }, [item]) + + if (!puzzle) { + return {buildSizeLabel(item)} + } + + return ( + + ) +} + +export const DatasetPage = () => { + const navigate = useNavigate() + const loadSolverPuzzle = useSolverStore((state) => state.loadPuzzle) + const loadEditorPuzzle = useEditorStore((state) => state.loadEditorPuzzle) + const [pluginId, setPluginId] = useState('slitherlink') + const [query, setQuery] = useState('') + const [sizeFilter, setSizeFilter] = useState('all') + const [activeTag, setActiveTag] = useState(null) + const [actionError, setActionError] = useState('') + + const tags = useMemo( + () => Array.from(new Set(datasetCards.flatMap((item) => item.tags))).sort(), + [], + ) + const sizeOptions = useMemo( + () => Array.from(new Set(datasetCards.map(buildSizeLabel))).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), + [], + ) + const filteredItems = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + return datasetCards.filter((item) => { + const sizeLabel = buildSizeLabel(item) + const searchableText = [ + item.id, + item.puzzleType, + item.sourceUrl, + item.datasetId, + item.datasetTitle, + sizeLabel, + ...item.tags, + ] + .join(' ') + .toLowerCase() + const matchesPlugin = item.puzzleType === pluginId + const matchesQuery = normalizedQuery.length === 0 || searchableText.includes(normalizedQuery) + const matchesSize = sizeFilter === 'all' || sizeLabel === sizeFilter + const matchesTag = activeTag === null || item.tags.includes(activeTag) + return matchesPlugin && matchesQuery && matchesSize && matchesTag + }) + }, [activeTag, pluginId, query, sizeFilter]) + + const loadToSolver = (item: DatasetPuzzleCard) => { + try { + const puzzle = parseDatasetPuzzle(item) + loadSolverPuzzle(puzzle, { + pluginId: item.puzzleType, + sourceUrl: item.sourceUrl, + }) + setActionError('') + navigate('/') + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)) + } + } + + const loadToEditor = (item: DatasetPuzzleCard) => { + try { + const puzzle = parseDatasetPuzzle(item) + loadEditorPuzzle(puzzle, { + sourceUrl: item.sourceUrl, + presetId: null, + }) + setActionError('') + navigate('/editor') + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)) + } + } + + return ( +
+
+
+
+
+

PuzzleKit Dataset

+

Browse public Slitherlink puzzles and load them into the workspace.

+
+ +
+
+
+
+

Public Dataset

+ + Showing {filteredItems.length} / {datasetCards.length} puzzles + +
+
+ {actionError ?

{actionError}

: null} +
+ {filteredItems.map((item) => ( + + ))} +
+ {filteredItems.length === 0 ?

No dataset puzzles match the current filters.

: null} +
+
+
+
+
+

Dataset Controls

+
+
+ Puzzle Type +
+ + + +
+
+ + +
+ Tags +
+ + {tags.map((tag) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/src/app/EditorPage.tsx b/src/app/EditorPage.tsx index da5a3fd..8db9775 100644 --- a/src/app/EditorPage.tsx +++ b/src/app/EditorPage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { parseCellKey, parseEdgeKey } from '../domain/ir/keys' import { SLITHER_CUSTOM_GRID_MAX, SLITHER_CUSTOM_GRID_MIN, @@ -11,14 +10,11 @@ import { BoardLegendButton } from '../features/board/BoardLegendButton' import { SlitherlinkEditorBoard } from '../features/editor/SlitherlinkEditorBoard' import { useEditorStore } from '../features/editor/editorStore' import { puzzlePresets, type PuzzlePreset } from '../features/editor/presets' +import { PuzzlePreviewBoard } from '../features/puzzlePreview/PuzzlePreviewBoard' import { PuzzleInfoButton } from '../features/puzzleInfo/PuzzleInfoButton' import { useSolverStore } from '../features/solver/solverStore' import './workspace.css' -const PRESET_PREVIEW_WIDTH = 320 -const PRESET_PREVIEW_HEIGHT = 180 -const PRESET_PREVIEW_PADDING = 18 - const parsePresetPuzzle = (preset: PuzzlePreset): PuzzleIR | null => { if (preset.puzzle) { return preset.puzzle @@ -37,110 +33,9 @@ const parsePresetPuzzle = (preset: PuzzlePreset): PuzzleIR | null => { } } -const drawPresetPreview = (ctx: CanvasRenderingContext2D, puzzle: PuzzleIR): void => { - const boardWidth = PRESET_PREVIEW_WIDTH - PRESET_PREVIEW_PADDING * 2 - const boardHeight = PRESET_PREVIEW_HEIGHT - PRESET_PREVIEW_PADDING * 2 - const cellSize = Math.min(boardWidth / puzzle.cols, boardHeight / puzzle.rows) - const gridWidth = cellSize * puzzle.cols - const gridHeight = cellSize * puzzle.rows - const offsetX = (PRESET_PREVIEW_WIDTH - gridWidth) / 2 - const offsetY = (PRESET_PREVIEW_HEIGHT - gridHeight) / 2 - - ctx.clearRect(0, 0, PRESET_PREVIEW_WIDTH, PRESET_PREVIEW_HEIGHT) - ctx.fillStyle = '#ffffff' - ctx.fillRect(0, 0, PRESET_PREVIEW_WIDTH, PRESET_PREVIEW_HEIGHT) - - ctx.strokeStyle = '#cbd5e1' - ctx.lineWidth = 1 - for (let row = 0; row <= puzzle.rows; row += 1) { - const y = offsetY + row * cellSize - ctx.beginPath() - ctx.moveTo(offsetX, y) - ctx.lineTo(offsetX + gridWidth, y) - ctx.stroke() - } - for (let col = 0; col <= puzzle.cols; col += 1) { - const x = offsetX + col * cellSize - ctx.beginPath() - ctx.moveTo(x, offsetY) - ctx.lineTo(x, offsetY + gridHeight) - ctx.stroke() - } - - ctx.fillStyle = '#111827' - ctx.font = `700 ${Math.max(12, Math.min(22, cellSize * 0.5))}px Inter, sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - for (const [key, cell] of Object.entries(puzzle.cells)) { - if (cell.clue?.kind !== 'number') { - continue - } - const [row, col] = parseCellKey(key) - ctx.fillText( - String(cell.clue.value), - offsetX + col * cellSize + cellSize / 2, - offsetY + row * cellSize + cellSize / 2, - ) - } - - for (const [edge, state] of Object.entries(puzzle.edges)) { - const [v1, v2] = parseEdgeKey(edge) - const x1 = offsetX + v1[1] * cellSize - const y1 = offsetY + v1[0] * cellSize - const x2 = offsetX + v2[1] * cellSize - const y2 = offsetY + v2[0] * cellSize - - if (state.mark === 'line') { - ctx.strokeStyle = '#0284c7' - ctx.lineWidth = Math.max(2, cellSize * 0.08) - ctx.beginPath() - ctx.moveTo(x1, y1) - ctx.lineTo(x2, y2) - ctx.stroke() - } else if (state.mark === 'blank') { - const midX = (x1 + x2) / 2 - const midY = (y1 + y2) / 2 - const crossSize = Math.max(3, cellSize * 0.18) - ctx.strokeStyle = '#94a3b8' - ctx.lineWidth = Math.max(1.5, cellSize * 0.05) - ctx.beginPath() - ctx.moveTo(midX - crossSize, midY - crossSize) - ctx.lineTo(midX + crossSize, midY + crossSize) - ctx.moveTo(midX + crossSize, midY - crossSize) - ctx.lineTo(midX - crossSize, midY + crossSize) - ctx.stroke() - } - } - - ctx.fillStyle = '#111827' - const vertexRadius = Math.max(1.3, Math.min(2.2, cellSize * 0.08)) - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - ctx.beginPath() - ctx.arc(offsetX + col * cellSize, offsetY + row * cellSize, vertexRadius, 0, Math.PI * 2) - ctx.fill() - } - } -} - const PresetPreviewBoard = ({ preset }: { preset: PuzzlePreset }) => { - const canvasRef = useRef(null) const puzzle = useMemo(() => parsePresetPuzzle(preset), [preset]) - useEffect(() => { - if (preset.previewImageUrl || !puzzle) { - return - } - const canvas = canvasRef.current - const ctx = canvas?.getContext('2d') - if (!canvas || !ctx) { - return - } - canvas.width = PRESET_PREVIEW_WIDTH - canvas.height = PRESET_PREVIEW_HEIGHT - drawPresetPreview(ctx, puzzle) - }, [preset.previewImageUrl, puzzle]) - if (preset.previewImageUrl) { return } @@ -149,15 +44,7 @@ const PresetPreviewBoard = ({ preset }: { preset: PuzzlePreset }) => { return {preset.rows} × {preset.cols} } - return ( - - ) + return } type PresetLibraryDialogProps = { @@ -402,6 +289,7 @@ export const EditorPage = () => { diff --git a/src/app/workspace.css b/src/app/workspace.css index d41450f..bfd6cc4 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -1195,6 +1195,139 @@ button[data-active='true'] { color: #6b7280; } +.dataset-workspace-grid { + height: calc(100vh - 32px); + align-items: stretch; +} + +.dataset-workspace-grid .left-column, +.dataset-workspace-grid .right-column { + min-height: 0; +} + +.dataset-list-card { + display: flex; + min-height: 0; + overflow: hidden; + flex: 1 1 auto; + flex-direction: column; +} + +.dataset-list-header { + align-items: flex-start; +} + +.dataset-list-header small { + display: block; + margin-top: 4px; +} + +.dataset-action-error { + margin-top: 0; +} + +.dataset-card-list { + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.dataset-puzzle-card { + display: grid; + grid-template-columns: 136px minmax(0, 1fr); + gap: 10px; + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + padding: 8px; +} + +.dataset-preview { + display: grid; + width: 136px; + aspect-ratio: 1; + place-items: center; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: + linear-gradient(#e5e7eb 1px, transparent 1px), + linear-gradient(90deg, #e5e7eb 1px, transparent 1px), + #f8fafc; + background-size: 24px 24px; + color: #475569; + font-weight: 700; +} + +.dataset-preview-canvas { + display: block; + width: 100%; + height: 100%; +} + +.dataset-card-body { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: 5px; +} + +.dataset-card-body h3 { + overflow: hidden; + margin: 0; + color: #0f172a; + font-size: 0.92rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dataset-card-meta { + color: #6b7280; + font-size: 0.78rem; +} + +.dataset-tags { + min-height: 21px; +} + +.dataset-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 1px; +} + +.dataset-card-actions a { + border: 0; + background: transparent; + color: #0369a1; + font-size: 0.78rem; + font-weight: 700; + line-height: 1.2; + padding: 0; + text-decoration: none; +} + +.dataset-card-actions a:hover { + color: #0f172a; + text-decoration: underline; +} + +.dataset-card-actions .dataset-primary-link { + color: #0e7490; +} + +.dataset-filter-group { + margin-top: 10px; +} + details summary { cursor: pointer; color: #4b5563; @@ -1210,6 +1343,20 @@ details pre { .workspace-grid { grid-template-columns: 1fr; } + + .dataset-workspace-grid { + height: auto; + } + + .dataset-card-list { + max-height: min(62vh, 680px); + } +} + +@media (max-width: 860px) { + .dataset-card-list { + grid-template-columns: 1fr; + } } @media (max-width: 520px) { @@ -1284,4 +1431,16 @@ details pre { .preset-modal-close { width: 100%; } + + .dataset-puzzle-card { + grid-template-columns: 104px minmax(0, 1fr); + } + + .dataset-preview { + width: 104px; + } + + .dataset-card-actions { + gap: 7px; + } } diff --git a/src/features/dataset/publicDatasets.ts b/src/features/dataset/publicDatasets.ts new file mode 100644 index 0000000..d6238c7 --- /dev/null +++ b/src/features/dataset/publicDatasets.ts @@ -0,0 +1,8 @@ +import type { BenchmarkDatasetManifest } from '../../domain/benchmark/types' +import slitherlinkExampleRaw from '../../../dataset/public/slitherlink.example.json?raw' + +const parseManifest = (raw: string): BenchmarkDatasetManifest => JSON.parse(raw) as BenchmarkDatasetManifest + +export const publicDatasetManifests: BenchmarkDatasetManifest[] = [ + parseManifest(slitherlinkExampleRaw), +] diff --git a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx new file mode 100644 index 0000000..061e834 --- /dev/null +++ b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from 'react' +import { parseCellKey, parseEdgeKey } from '../../domain/ir/keys' +import type { PuzzleIR } from '../../domain/ir/types' + +const DEFAULT_PREVIEW_WIDTH = 320 +const DEFAULT_PREVIEW_HEIGHT = 180 +const DEFAULT_PREVIEW_PADDING = 18 +type PuzzlePreviewVariant = 'default' | 'compact' + +const drawPuzzlePreview = ( + ctx: CanvasRenderingContext2D, + puzzle: PuzzleIR, + options: { + width?: number + height?: number + padding?: number + variant?: PuzzlePreviewVariant + } = {}, +): void => { + const previewWidth = options.width ?? DEFAULT_PREVIEW_WIDTH + const previewHeight = options.height ?? DEFAULT_PREVIEW_HEIGHT + const padding = options.padding ?? DEFAULT_PREVIEW_PADDING + const variant = options.variant ?? 'default' + const isCompact = variant === 'compact' + const boardWidth = previewWidth - padding * 2 + const boardHeight = previewHeight - padding * 2 + const cellSize = Math.min(boardWidth / puzzle.cols, boardHeight / puzzle.rows) + const gridWidth = cellSize * puzzle.cols + const gridHeight = cellSize * puzzle.rows + const offsetX = (previewWidth - gridWidth) / 2 + const offsetY = (previewHeight - gridHeight) / 2 + + ctx.clearRect(0, 0, previewWidth, previewHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, previewWidth, previewHeight) + + ctx.strokeStyle = isCompact ? '#e2e8f0' : '#cbd5e1' + ctx.lineWidth = isCompact ? 0.7 : 1 + for (let row = 0; row <= puzzle.rows; row += 1) { + const y = offsetY + row * cellSize + ctx.beginPath() + ctx.moveTo(offsetX, y) + ctx.lineTo(offsetX + gridWidth, y) + ctx.stroke() + } + for (let col = 0; col <= puzzle.cols; col += 1) { + const x = offsetX + col * cellSize + ctx.beginPath() + ctx.moveTo(x, offsetY) + ctx.lineTo(x, offsetY + gridHeight) + ctx.stroke() + } + + const shouldDrawClues = !isCompact || cellSize >= 8 + if (shouldDrawClues) { + ctx.fillStyle = isCompact ? '#334155' : '#111827' + const clueFontSize = isCompact + ? Math.min(12, Math.max(5, cellSize * 0.52)) + : Math.max(12, Math.min(22, cellSize * 0.5)) + const clueFontWeight = isCompact ? 500 : 700 + ctx.font = `${clueFontWeight} ${clueFontSize}px Inter, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'number') { + continue + } + const [row, col] = parseCellKey(key) + ctx.fillText( + String(cell.clue.value), + offsetX + col * cellSize + cellSize / 2, + offsetY + row * cellSize + cellSize / 2, + ) + } + } + + for (const [edge, state] of Object.entries(puzzle.edges)) { + const [v1, v2] = parseEdgeKey(edge) + const x1 = offsetX + v1[1] * cellSize + const y1 = offsetY + v1[0] * cellSize + const x2 = offsetX + v2[1] * cellSize + const y2 = offsetY + v2[0] * cellSize + + if (state.mark === 'line') { + ctx.strokeStyle = '#0284c7' + ctx.lineWidth = isCompact ? Math.max(1.2, cellSize * 0.08) : Math.max(2, cellSize * 0.08) + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } else if (state.mark === 'blank') { + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + const crossSize = isCompact ? Math.max(1.8, cellSize * 0.16) : Math.max(3, cellSize * 0.18) + ctx.strokeStyle = '#94a3b8' + ctx.lineWidth = isCompact ? Math.max(1, cellSize * 0.05) : Math.max(1.5, cellSize * 0.05) + ctx.beginPath() + ctx.moveTo(midX - crossSize, midY - crossSize) + ctx.lineTo(midX + crossSize, midY + crossSize) + ctx.moveTo(midX + crossSize, midY - crossSize) + ctx.lineTo(midX - crossSize, midY + crossSize) + ctx.stroke() + } + } + + const shouldDrawVertices = !isCompact || cellSize >= 7 + if (shouldDrawVertices) { + ctx.fillStyle = isCompact ? '#475569' : '#111827' + const vertexRadius = isCompact + ? Math.max(0.7, Math.min(1.5, cellSize * 0.055)) + : Math.max(1.3, Math.min(2.2, cellSize * 0.08)) + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + ctx.beginPath() + ctx.arc(offsetX + col * cellSize, offsetY + row * cellSize, vertexRadius, 0, Math.PI * 2) + ctx.fill() + } + } + } +} + +type PuzzlePreviewBoardProps = { + puzzle: PuzzleIR + label: string + className?: string + width?: number + height?: number + padding?: number + variant?: PuzzlePreviewVariant +} + +export const PuzzlePreviewBoard = ({ + puzzle, + label, + className = 'preset-preview-canvas', + width = DEFAULT_PREVIEW_WIDTH, + height = DEFAULT_PREVIEW_HEIGHT, + padding = DEFAULT_PREVIEW_PADDING, + variant = 'default', +}: PuzzlePreviewBoardProps) => { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) { + return + } + canvas.width = width + canvas.height = height + drawPuzzlePreview(ctx, puzzle, { width, height, padding, variant }) + }, [height, padding, puzzle, variant, width]) + + return ( + + ) +} From 76012cc69736007408b06727f2e1322fb3c3d199 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 03:00:27 +0800 Subject: [PATCH 03/20] refactor: remove preset library functionality and related tests, update DatasetPage styles and structure --- src/app/DatasetPage.test.tsx | 1 - src/app/DatasetPage.tsx | 7 +- src/app/EditorPage.test.tsx | 101 +------ src/app/EditorPage.tsx | 250 +----------------- src/app/WorkspacePage.test.tsx | 26 +- src/app/workspace.css | 250 +++--------------- src/features/editor/editorStore.test.ts | 11 - src/features/editor/editorStore.ts | 41 +-- src/features/editor/presets.ts | 103 -------- .../puzzlePreview/PuzzlePreviewBoard.tsx | 2 +- src/features/solver/ControlPanel.tsx | 6 +- 11 files changed, 62 insertions(+), 736 deletions(-) delete mode 100644 src/features/editor/presets.ts diff --git a/src/app/DatasetPage.test.tsx b/src/app/DatasetPage.test.tsx index 0db6186..304e9d8 100644 --- a/src/app/DatasetPage.test.tsx +++ b/src/app/DatasetPage.test.tsx @@ -120,6 +120,5 @@ describe('DatasetPage', () => { expect(useEditorStore.getState().sourceUrl).toBe( 'https://puzz.link/p?slither/10/10/372d23djdh738adl72882dj18538ald838dhaj21d272c', ) - expect(useEditorStore.getState().selectedPresetId).toBeNull() }) }) diff --git a/src/app/DatasetPage.tsx b/src/app/DatasetPage.tsx index 8f2fcde..b28c498 100644 --- a/src/app/DatasetPage.tsx +++ b/src/app/DatasetPage.tsx @@ -124,7 +124,6 @@ export const DatasetPage = () => { const puzzle = parseDatasetPuzzle(item) loadEditorPuzzle(puzzle, { sourceUrl: item.sourceUrl, - presetId: null, }) setActionError('') navigate('/editor') @@ -171,7 +170,7 @@ export const DatasetPage = () => { {buildSizeLabel(item)} · {item.puzzleType} -
+
{item.tags.map((tag) => ( {tag} ))} @@ -204,7 +203,7 @@ export const DatasetPage = () => { ))}
- {filteredItems.length === 0 ?

No dataset puzzles match the current filters.

: null} + {filteredItems.length === 0 ?

No dataset puzzles match the current filters.

: null}
@@ -248,7 +247,7 @@ export const DatasetPage = () => {
Tags -
+
diff --git a/src/app/EditorPage.test.tsx b/src/app/EditorPage.test.tsx index 66e4a96..6523bc3 100644 --- a/src/app/EditorPage.test.tsx +++ b/src/app/EditorPage.test.tsx @@ -259,32 +259,6 @@ describe('EditorPage', () => { expect(useEditorStore.getState().puzzle.edges[crossedHorizontal]?.mark).toBe('unknown') }) - it('opens the preset library and filters presets by search and tag', () => { - render( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - - const dialog = screen.getByRole('dialog', { name: /load preset/i }) - expect(dialog.querySelector('.preset-grid-scroll')).not.toBeNull() - expect(within(dialog).getByText(/default slitherlink 1/i)).toBeInTheDocument() - expect(within(dialog).getByRole('button', { name: /default/i })).toBeInTheDocument() - expect(within(dialog).getAllByLabelText(/preset preview/i).length).toBeGreaterThan(0) - - fireEvent.click(within(dialog).getByRole('button', { name: /puzz\.link/i })) - expect(within(dialog).getByText(/default slitherlink 2/i)).toBeInTheDocument() - - fireEvent.change(within(dialog).getByLabelText(/search presets/i), { - // Unique fragment from default-slitherlink-2 sourceUrl (search is substring match over URL/name/etc.) - target: { value: '82232382' }, - }) - expect(within(dialog).getByText(/default slitherlink 2/i)).toBeInTheDocument() - expect(within(dialog).queryByText(/default slitherlink 1/i)).not.toBeInTheDocument() - }) - it('opens slitherlink rules from the editor puzzle type row', () => { render( @@ -315,78 +289,9 @@ describe('EditorPage', () => { ) expect(document.querySelector('.workspace-grid.editor-workspace-grid')).not.toBeNull() - }) - - it('loads a preset into the editor from the preset library', () => { - render( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - const card = screen.getByText(/default slitherlink 1/i).closest('article') - expect(card).not.toBeNull() - fireEvent.click(within(card as HTMLElement).getByRole('button', { name: /to edit/i })) - - expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() - expect(useEditorStore.getState().selectedPresetId).toBe('default-slitherlink-1') - expect(useEditorStore.getState().puzzle.rows).toBe(10) - expect(useEditorStore.getState().puzzle.cols).toBe(10) - }) - - it('loads a preset into the solver from the preset library', () => { - render( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - const card = screen.getByText(/default slitherlink 1/i).closest('article') - expect(card).not.toBeNull() - fireEvent.click(within(card as HTMLElement).getByRole('button', { name: /to solve/i })) - - expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() - expect(useSolverStore.getState().initialPuzzle.rows).toBe(10) - expect(useSolverStore.getState().initialPuzzle.cols).toBe(10) - }) - - it('opens preset URLs in a new tab', () => { - const open = vi.spyOn(window, 'open').mockImplementation(() => null) - - render( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - const card = screen.getByText(/default slitherlink 1/i).closest('article') - expect(card).not.toBeNull() - fireEvent.click(within(card as HTMLElement).getByRole('button', { name: 'URL' })) - - expect(open).toHaveBeenCalledWith( - 'https://puzz.link/p?slither/10/10/gdk8dh2ah738cgd60djagbdgcj25bdg817ah0dh8dk5', - '_blank', - 'noopener,noreferrer', - ) - }) - - it('closes the preset library with close controls and Escape', () => { - render( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - fireEvent.click(screen.getByRole('button', { name: /close preset library/i })) - expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: /load preset/i })) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /load preset/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /import url/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /solve it/i })).toBeInTheDocument() }) it('keeps wheel scrolling separate from editor board zoom', () => { diff --git a/src/app/EditorPage.tsx b/src/app/EditorPage.tsx index 8db9775..98dd2bb 100644 --- a/src/app/EditorPage.tsx +++ b/src/app/EditorPage.tsx @@ -1,199 +1,17 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { SLITHER_CUSTOM_GRID_MAX, SLITHER_CUSTOM_GRID_MIN, } from '../domain/ir/slither' -import type { PuzzleIR } from '../domain/ir/types' import { puzzleRegistry } from '../domain/plugins/registry' import { BoardLegendButton } from '../features/board/BoardLegendButton' import { SlitherlinkEditorBoard } from '../features/editor/SlitherlinkEditorBoard' import { useEditorStore } from '../features/editor/editorStore' -import { puzzlePresets, type PuzzlePreset } from '../features/editor/presets' -import { PuzzlePreviewBoard } from '../features/puzzlePreview/PuzzlePreviewBoard' import { PuzzleInfoButton } from '../features/puzzleInfo/PuzzleInfoButton' import { useSolverStore } from '../features/solver/solverStore' import './workspace.css' -const parsePresetPuzzle = (preset: PuzzlePreset): PuzzleIR | null => { - if (preset.puzzle) { - return preset.puzzle - } - if (!preset.sourceUrl) { - return null - } - const plugin = puzzleRegistry.get(preset.puzzleType) - if (!plugin) { - return null - } - try { - return plugin.parse(preset.sourceUrl) - } catch { - return null - } -} - -const PresetPreviewBoard = ({ preset }: { preset: PuzzlePreset }) => { - const puzzle = useMemo(() => parsePresetPuzzle(preset), [preset]) - - if (preset.previewImageUrl) { - return - } - - if (!puzzle) { - return {preset.rows} × {preset.cols} - } - - return -} - -type PresetLibraryDialogProps = { - presets: PuzzlePreset[] - selectedPresetId: string | null - onClose: () => void - onOpenUrl: (preset: PuzzlePreset) => void - onLoadToEdit: (preset: PuzzlePreset) => void - onLoadToSolve: (preset: PuzzlePreset) => void - actionError: string -} - -const PresetLibraryDialog = ({ - presets, - selectedPresetId, - onClose, - onOpenUrl, - onLoadToEdit, - onLoadToSolve, - actionError, -}: PresetLibraryDialogProps) => { - const [query, setQuery] = useState('') - const [activeTag, setActiveTag] = useState(null) - const tags = useMemo( - () => Array.from(new Set(presets.flatMap((preset) => preset.tags))).sort(), - [presets], - ) - const filteredPresets = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase() - return presets.filter((preset) => { - const searchableText = [ - preset.name, - preset.description, - preset.sourceUrl, - preset.puzzleType, - preset.rows, - preset.cols, - ...preset.tags, - ] - .filter((value) => value !== undefined) - .join(' ') - .toLowerCase() - const matchesQuery = normalizedQuery.length === 0 || searchableText.includes(normalizedQuery) - const matchesTag = activeTag === null || preset.tags.includes(activeTag) - return matchesQuery && matchesTag - }) - }, [activeTag, presets, query]) - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose() - } - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [onClose]) - - return ( -
-
event.stopPropagation()} - > -
-
-

Load Preset

- {/*

Select a puzzle to open, solve, or continue editing.

*/} -
- -
-
- -
- - {tags.map((tag) => ( - - ))} -
-
- {actionError ?

{actionError}

: null} -
-
- {filteredPresets.map((preset) => ( -
-
- -
-
-

{preset.name}

- - {preset.rows} × {preset.cols} · {preset.puzzleType} - -
- {preset.tags.map((tag) => ( - {tag} - ))} -
- {preset.description ? ( -

{preset.description}

- ) : null} -
-
- - - -
-
- ))} -
- {filteredPresets.length === 0 ?

No presets match the current filters.

: null} -
-
-
- ) -} - export const EditorPage = () => { const navigate = useNavigate() const { @@ -201,11 +19,9 @@ export const EditorPage = () => { puzzle, sourceUrl, importError, - selectedPresetId, setPluginId, createBlankSlither, importFromUrl, - loadPreset, setSlitherCellClue, setSlitherEdgeMark, } = useEditorStore() @@ -213,8 +29,6 @@ export const EditorPage = () => { const [localUrl, setLocalUrl] = useState(sourceUrl) const [rows, setRows] = useState(String(puzzle.rows)) const [cols, setCols] = useState(String(puzzle.cols)) - const [showPresetLibrary, setShowPresetLibrary] = useState(false) - const [presetActionError, setPresetActionError] = useState('') useEffect(() => { setRows(String(puzzle.rows)) @@ -233,51 +47,6 @@ export const EditorPage = () => { navigate('/') } - const openPresetUrl = (preset: PuzzlePreset) => { - if (!preset.sourceUrl) { - return - } - window.open(preset.sourceUrl, '_blank', 'noopener,noreferrer') - } - - const loadPresetToEdit = (preset: PuzzlePreset) => { - loadPreset(preset) - setRows(String(preset.rows)) - setCols(String(preset.cols)) - setLocalUrl(preset.sourceUrl ?? '') - setPresetActionError('') - setShowPresetLibrary(false) - navigate('/editor') - } - - const loadPresetToSolve = (preset: PuzzlePreset) => { - try { - if (preset.puzzle) { - loadPuzzle(preset.puzzle, { - pluginId: preset.puzzleType, - sourceUrl: preset.sourceUrl ?? '', - }) - } else if (preset.sourceUrl) { - const plugin = puzzleRegistry.get(preset.puzzleType) - if (!plugin) { - throw new Error(`Plugin "${preset.puzzleType}" not found.`) - } - const parsed = plugin.parse(preset.sourceUrl) - loadPuzzle(parsed, { - pluginId: preset.puzzleType, - sourceUrl: preset.sourceUrl, - }) - } else { - throw new Error(`Preset "${preset.name}" does not include puzzle data.`) - } - setPresetActionError('') - setShowPresetLibrary(false) - navigate('/') - } catch (error) { - setPresetActionError(error instanceof Error ? error.message : String(error)) - } - } - return (
@@ -370,9 +139,6 @@ export const EditorPage = () => { - @@ -381,20 +147,6 @@ export const EditorPage = () => {
- {showPresetLibrary ? ( - { - setPresetActionError('') - setShowPresetLibrary(false) - }} - onOpenUrl={openPresetUrl} - onLoadToEdit={loadPresetToEdit} - onLoadToSolve={loadPresetToSolve} - actionError={presetActionError} - /> - ) : null} ) } diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index 31a59f8..648cc80 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -179,7 +179,7 @@ describe('WorkspacePage', () => { useSolverStore.getState().setSolveChunkSize(100) renderWorkspace() - fireEvent.click(screen.getByRole('button', { name: /solve next 100 steps/i })) + fireEvent.click(screen.getByRole('button', { name: /next 100 steps/i })) expect(screen.getByRole('dialog', { name: /solving to end/i })).toBeInTheDocument() expect(screen.getByText(/step 0 \/ 100/i)).toBeInTheDocument() @@ -191,19 +191,19 @@ describe('WorkspacePage', () => { expect(screen.getByRole('dialog', { name: /solved/i })).toBeInTheDocument() expect(screen.getByText(/total time/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /next step/i })).toBeDisabled() - expect(screen.getByRole('button', { name: /solve next 100 steps/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /next 100 steps/i })).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: /close/i })) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /next step/i })).toBeDisabled() - expect(screen.getByRole('button', { name: /solve next 100 steps/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /next 100 steps/i })).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: /reset replay/i })) expect(screen.getByRole('button', { name: /next step/i })).not.toBeDisabled() - expect(screen.getByRole('button', { name: /solve next 100 steps/i })).not.toBeDisabled() + expect(screen.getByRole('button', { name: /next 100 steps/i })).not.toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: /solve next 100 steps/i })) + fireEvent.click(screen.getByRole('button', { name: /next 100 steps/i })) await waitFor(() => { expect(screen.queryByRole('dialog', { name: /solving to end/i })).not.toBeInTheDocument() }) @@ -211,7 +211,7 @@ describe('WorkspacePage', () => { fireEvent.click(screen.getByRole('button', { name: /reset replay/i })) expect(screen.getByRole('button', { name: /next step/i })).not.toBeDisabled() - expect(screen.getByRole('button', { name: /solve next 100 steps/i })).not.toBeDisabled() + expect(screen.getByRole('button', { name: /next 100 steps/i })).not.toBeDisabled() }) it('updates solve chunk controls and uses the chosen progress total', async () => { @@ -229,11 +229,11 @@ describe('WorkspacePage', () => { renderWorkspace() - expect(screen.getByRole('button', { name: /solve next 50 steps/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next 50 steps/i })).toBeInTheDocument() fireEvent.change(screen.getByLabelText(/step chunk/i), { target: { value: '25' } }) - expect(screen.getByRole('button', { name: /solve next 25 steps/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next 25 steps/i })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /solve next 25 steps/i })) + fireEvent.click(screen.getByRole('button', { name: /next 25 steps/i })) expect(screen.getByRole('dialog', { name: /solving to end/i })).toBeInTheDocument() expect(screen.getByText(/step 0 \/ 25/i)).toBeInTheDocument() @@ -266,12 +266,12 @@ describe('WorkspacePage', () => { expect(screen.getByText(/showing 3 \/ 3/i)).toBeInTheDocument() fireEvent.change(screen.getByLabelText(/step chunk/i), { target: { value: '2' } }) - fireEvent.click(screen.getByRole('button', { name: /^previous 2 steps$/i })) + fireEvent.click(screen.getByRole('button', { name: /^prev 2 steps$/i })) expect(screen.getByText(/showing 1 \/ 1/i)).toBeInTheDocument() expect(screen.getByLabelText(/replay timeline/i)).toHaveValue('1') - fireEvent.click(screen.getByRole('button', { name: /^previous 2 steps$/i })) + fireEvent.click(screen.getByRole('button', { name: /^prev 2 steps$/i })) expect(screen.getByText(/showing 0 \/ 0/i)).toBeInTheDocument() expect(screen.getByLabelText(/replay timeline/i)).toHaveValue('0') @@ -401,13 +401,13 @@ describe('WorkspacePage', () => { it('keeps replay and puzzle I/O controls in the intended compact order', () => { renderWorkspace() - const previousButton = screen.getByRole('button', { name: /previous step/i }) + const previousButton = screen.getByRole('button', { name: /prev step/i }) const nextButton = screen.getByRole('button', { name: /next step/i }) expect( previousButton.compareDocumentPosition(nextButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() - const previousChunkButton = screen.getByRole('button', { name: /previous 50 steps/i }) + const previousChunkButton = screen.getByRole('button', { name: /prev 50 steps/i }) const timeline = screen.getByLabelText(/replay timeline/i) expect( nextButton.compareDocumentPosition(previousChunkButton) & Node.DOCUMENT_POSITION_FOLLOWING, diff --git a/src/app/workspace.css b/src/app/workspace.css index bfd6cc4..6b98af4 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -53,7 +53,7 @@ .workspace-grid { display: grid; - grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); + grid-template-columns: minmax(0, 2.25fr) minmax(300px, 0.9fr); gap: 16px; min-width: 0; } @@ -1001,200 +1001,6 @@ button[data-active='true'] { font-weight: 700; } -.preset-card-meta, -.preset-description { - color: #6b7280; - font-size: 0.82rem; - line-height: 1.35; -} - -.preset-tags { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.preset-tags span { - border: 1px solid #cbd5e1; - border-radius: 999px; - padding: 2px 6px; - color: #4b5563; - font-size: 0.74rem; -} - -.preset-modal-backdrop { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 24px; - background: rgb(15 23 42 / 0.36); -} - -.preset-modal { - display: flex; - flex-direction: column; - gap: 14px; - width: min(1120px, 94vw); - height: min(900px, 94vh); - min-height: 0; - overflow: hidden; - border: 1px solid #cbd5e1; - border-radius: 12px; - background: #ffffff; - padding: 16px; - box-shadow: 0 24px 80px rgb(15 23 42 / 0.22); -} - -.preset-modal-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.preset-modal-header h2 { - margin: 0; - color: #0f172a; - font-size: 1.2rem; -} - -.preset-modal-header p { - margin: 4px 0 0; - color: #4b5563; -} - -.preset-modal-close { - flex: 0 0 auto; -} - -.preset-modal-tools { - display: grid; - grid-template-columns: minmax(220px, 360px) minmax(0, 1fr); - align-items: end; - gap: 12px; -} - -.preset-search-field input { - min-height: 38px; -} - -.preset-filter-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.preset-filter-row button { - min-height: 32px; -} - -.preset-filter-row button[data-active='true'] { - border-color: #22d3ee; - background: #e0f2fe; - color: #0f172a; - font-weight: 700; -} - -.preset-action-error { - flex: 0 0 auto; - margin: 0; -} - -.preset-grid-scroll { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - padding-right: 2px; -} - -.preset-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - gap: 12px; -} - -.preset-library-card { - display: flex; - min-width: 0; - flex-direction: column; - gap: 10px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #ffffff; - padding: 10px; -} - -.preset-library-card[data-active='true'] { - border-color: #22d3ee; - box-shadow: 0 0 0 2px #cffafe; -} - -.preset-preview { - display: grid; - width: 100%; - aspect-ratio: 16 / 9; - place-items: center; - overflow: hidden; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: - linear-gradient(#e5e7eb 1px, transparent 1px), - linear-gradient(90deg, #e5e7eb 1px, transparent 1px), - #f8fafc; - background-size: 24px 24px; - color: #475569; - font-weight: 700; -} - -.preset-preview img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.preset-preview-canvas { - display: block; - width: 100%; - height: 100%; -} - -.preset-card-body { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 6px; -} - -.preset-card-body h3 { - margin: 0; - color: #0f172a; - font-size: 1rem; -} - -.preset-card-body .preset-description { - margin: 0; -} - -.preset-card-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; -} - -.preset-card-actions button { - min-width: 0; - padding-right: 6px; - padding-left: 6px; -} - -.preset-empty { - margin: 0; - color: #6b7280; -} - .dataset-workspace-grid { height: calc(100vh - 32px); align-items: stretch; @@ -1293,9 +1099,20 @@ button[data-active='true'] { } .dataset-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; min-height: 21px; } +.dataset-tags span { + border: 1px solid #cbd5e1; + border-radius: 999px; + padding: 2px 6px; + color: #4b5563; + font-size: 0.74rem; +} + .dataset-card-actions { display: flex; flex-wrap: wrap; @@ -1328,6 +1145,28 @@ button[data-active='true'] { margin-top: 10px; } +.dataset-filter-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dataset-filter-row button { + min-height: 32px; +} + +.dataset-filter-row button[data-active='true'] { + border-color: #22d3ee; + background: #e0f2fe; + color: #0f172a; + font-weight: 700; +} + +.dataset-empty { + margin: 0; + color: #6b7280; +} + details summary { cursor: pointer; color: #4b5563; @@ -1411,27 +1250,6 @@ details pre { grid-template-columns: 1fr 1fr; } - .preset-modal-backdrop { - padding: 12px; - } - - .preset-modal { - width: 100%; - height: 94vh; - } - - .preset-modal-header { - flex-direction: column; - } - - .preset-modal-tools { - grid-template-columns: 1fr; - } - - .preset-modal-close { - width: 100%; - } - .dataset-puzzle-card { grid-template-columns: 104px minmax(0, 1fr); } diff --git a/src/features/editor/editorStore.test.ts b/src/features/editor/editorStore.test.ts index 0c8c788..340ae2f 100644 --- a/src/features/editor/editorStore.test.ts +++ b/src/features/editor/editorStore.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { cellKey, edgeKey } from '../../domain/ir/keys' import { createSlitherPuzzle } from '../../domain/ir/slither' -import { puzzlePresets } from './presets' import { useEditorStore } from './editorStore' const SAMPLE_URL = 'https://puzz.link/p?slither/3/3/g0h' @@ -41,14 +40,4 @@ describe('editor store', () => { expect(after.puzzle.cols).toBe(3) }) - it('loads typed presets with metadata and parsed puzzle data', () => { - const preset = puzzlePresets[0] - useEditorStore.getState().loadPreset(preset) - - const after = useEditorStore.getState() - expect(after.selectedPresetId).toBe(preset.id) - expect(after.puzzle.rows).toBe(preset.rows) - expect(after.puzzle.cols).toBe(preset.cols) - expect(preset.tags.length).toBeGreaterThan(0) - }) }) diff --git a/src/features/editor/editorStore.ts b/src/features/editor/editorStore.ts index e41c378..18fe390 100644 --- a/src/features/editor/editorStore.ts +++ b/src/features/editor/editorStore.ts @@ -8,7 +8,6 @@ import { } from '../../domain/ir/slither' import type { EdgeMark, NumberClueValue, PuzzleIR } from '../../domain/ir/types' import { puzzleRegistry } from '../../domain/plugins/registry' -import { puzzlePresets, type PuzzlePreset } from './presets' export type SlitherClueDraft = NumberClueValue | null @@ -17,12 +16,10 @@ type EditorStore = { puzzle: PuzzleIR sourceUrl: string importError?: string - selectedPresetId: string | null setPluginId: (pluginId: string) => void createBlankSlither: (rows: number, cols: number) => void - loadEditorPuzzle: (puzzle: PuzzleIR, options?: { sourceUrl?: string; presetId?: string | null }) => void + loadEditorPuzzle: (puzzle: PuzzleIR, options?: { sourceUrl?: string }) => void importFromUrl: (url: string) => void - loadPreset: (preset: PuzzlePreset) => void setSlitherCellClue: (key: string, value: SlitherClueDraft) => void setSlitherEdgeMark: (key: string, mark: EdgeMark) => void } @@ -41,7 +38,6 @@ export const useEditorStore = create((set, get) => ({ puzzle: defaultPuzzle, sourceUrl: '', importError: undefined, - selectedPresetId: null, setPluginId: (pluginId) => set({ pluginId, importError: undefined }), createBlankSlither: (rows, cols) => { const puzzle = createSlitherPuzzle(clampSlitherSize(rows), clampSlitherSize(cols)) @@ -50,7 +46,6 @@ export const useEditorStore = create((set, get) => ({ puzzle, sourceUrl: '', importError: undefined, - selectedPresetId: null, }) }, loadEditorPuzzle: (puzzle, options) => { @@ -59,7 +54,6 @@ export const useEditorStore = create((set, get) => ({ puzzle: clonePuzzle(puzzle), sourceUrl: options?.sourceUrl ?? '', importError: undefined, - selectedPresetId: options?.presetId ?? null, }) }, importFromUrl: (url) => { @@ -70,37 +64,12 @@ export const useEditorStore = create((set, get) => ({ } try { const puzzle = plugin.parse(url) - get().loadEditorPuzzle(puzzle, { sourceUrl: url, presetId: null }) + get().loadEditorPuzzle(puzzle, { sourceUrl: url }) } catch (error) { const message = error instanceof Error ? error.message : String(error) set({ sourceUrl: url, importError: message }) } }, - loadPreset: (preset) => { - if (preset.puzzle) { - get().loadEditorPuzzle(preset.puzzle, { - sourceUrl: preset.sourceUrl ?? '', - presetId: preset.id, - }) - return - } - if (!preset.sourceUrl) { - set({ importError: `Preset "${preset.name}" does not include puzzle data.` }) - return - } - const plugin = puzzleRegistry.get(preset.puzzleType) - if (!plugin) { - set({ importError: `Plugin "${preset.puzzleType}" not found.` }) - return - } - try { - const puzzle = plugin.parse(preset.sourceUrl) - get().loadEditorPuzzle(puzzle, { sourceUrl: preset.sourceUrl, presetId: preset.id }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - set({ importError: message, selectedPresetId: preset.id }) - } - }, setSlitherCellClue: (key, value) => { const { puzzle } = get() if (puzzle.puzzleType !== 'slitherlink') { @@ -135,7 +104,7 @@ export const useEditorStore = create((set, get) => ({ clue: { kind: 'number', value }, } } - set({ puzzle: next, selectedPresetId: null }) + set({ puzzle: next }) }, setSlitherEdgeMark: (key, mark) => { const { puzzle } = get() @@ -144,10 +113,8 @@ export const useEditorStore = create((set, get) => ({ } const next = clonePuzzle(puzzle) next.edges[key] = { ...next.edges[key], mark } - set({ puzzle: next, selectedPresetId: null }) + set({ puzzle: next }) }, })) -export const getInitialEditorPreset = (): PuzzlePreset | undefined => puzzlePresets[0] - export const getEditorCellKey = cellKey diff --git a/src/features/editor/presets.ts b/src/features/editor/presets.ts deleted file mode 100644 index c81244e..0000000 --- a/src/features/editor/presets.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { PuzzleKind, PuzzleIR } from '../../domain/ir/types' - -export type PuzzlePreset = { - id: string - name: string - puzzleType: PuzzleKind - rows: number - cols: number - tags: string[] - description?: string - previewImageUrl?: string - sourceUrl?: string - puzzle?: PuzzleIR -} - -export const puzzlePresets: PuzzlePreset[] = [ - { - id: 'default-slitherlink-1', - name: 'Default Slitherlink 1', - puzzleType: 'slitherlink', - rows: 10, - cols: 10, - tags: ['default', 'puzz.link'], - description: 'Default 10x10 Slitherlink preset.', - sourceUrl: 'https://puzz.link/p?slither/10/10/gdk8dh2ah738cgd60djagbdgcj25bdg817ah0dh8dk5', - }, - { - id: 'default-slitherlink-2', - name: 'Default Slitherlink 2', - puzzleType: 'slitherlink', - rows: 10, - cols: 10, - tags: ['default', 'puzz.link'], - description: 'Default 10x10 Slitherlink preset.', - sourceUrl: 'https://puzz.link/p?slither/10/10/82232382dg2dg27bh73201222121cbhchdhc22222222237ch72cg1bg383222283', - }, - { - id: 'default-slitherlink-3', - name: 'Default Slitherlink 3', - puzzleType: 'slitherlink', - rows: 10, - cols: 10, - tags: ['default', 'puzz.link'], - description: 'Default 10x10 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/10/10/l338111166b111611b111611bhd1111222cdh227222c227222c772222733dj', - }, - { - id: 'default-slitherlink-4', - name: 'Default Slitherlink 4', - puzzleType: 'slitherlink', - rows: 10, - cols: 18, - tags: ['default', 'puzz.link'], - description: 'Default 10x18 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/18/10/c82chcdgcbgd63c173ah6aibi81b71cdjcdcb123ddbcbjb37d16didi8dh161c36cdgcagdbh28bb', - }, - { - id: 'large-slitherlink-5', - name: 'Large Slitherlink 5', - puzzleType: 'slitherlink', - rows: 45, - cols: 31, - tags: ['default', 'puzz.link'], - description: 'Default 45x31 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/45/31/h33cg8dgbdgba6cddgadk30bk6djc21dgdddg328dk31di21ag7bgbcgcb8ddg6dg10ci32ck5bjd22dg23ddj8ck23di02bg8cgd7cddgdcg6cg22di13cjb02cgddbg22ccj8dk3388bgbdgcc6cadgcdg8cgck8dja32bgbddg22dcj01ai12dg6bgdcgcc6cb17bg13di11bk7cjc11bgahaj6dk12ai31cg8cgdchbagcdg6bg21ci31ck6aibcag22bdj7dk02bi10ddgcb7ccdgbag8bg13di8bjb22dgcdcg13dbj8ai20dg7dgcdgbd7bdcgd31ai21dk7djd22dgcddi6dk21ci21cg7cgccgdbhcdg8dg33di30ck7bjbhdg21bdj5ck21ci02ag81ca6bdcgcdg5cg23bi23djc12cgcdag22cbj8ckdg5dgccgdd7cdbgdcg8620bk7cjd21bgbddg22cbj20di23dg8cgcagdd7cag6cg21ci02ak8cjd11dg31ddj7dk12ai02ag8agd6ddagcdg6dg20ci31dk722dgcdag21cdj7dk30bkbagcd8bdagbcg8bg20b', - }, - { - id: 'Medium-slitherlink-6', - name: 'Medium Slitherlink 6', - puzzleType: 'slitherlink', - rows: 15, - cols: 25, - tags: ['default', 'puzz.link'], - description: 'Default 15x25 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/25/15/gdgbhbgdhagbg31d0c03c3b32bcibcidbi0aiccibdic33d2d03c1d22dgcgchcgbhdg8bicciadi0dabcacba1cidcibbi7bgbhdgdhbgcg11b1d23b1b23dcidcibdi0aidcidcib02a3c33d1d23dgbgbhagbhagc', - }, - { - id: 'Medium-slitherlink-7', - name: 'Medium Slitherlink 7', - puzzleType: 'slitherlink', - rows: 10, - cols: 18, - tags: ['default', 'puzz.link'], - description: 'Default 10x18 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/18/10/g1cg2bg31817c6d5bgc1c6b7dgb63abicdbj2ah261c263dh3cjadcib17cbg6b8d1cbg6d7d61612cg3cg1c', - }, - { - id: 'default-slitherlink-8', - name: 'Default Slitherlink 8', - puzzleType: 'slitherlink', - rows: 10, - cols: 18, - tags: ['default', 'puzz.link'], - description: 'Default 10x18 Slitherlink preset.', - sourceUrl: - 'https://puzz.link/p?slither/18/10/a27138bbg1cm6dj75733bi3ap5chdg677b8ah8d578dgbh6dp3di20678dj8bm0cgc62361bb', - }, -] diff --git a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx index 061e834..c3036be 100644 --- a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx +++ b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx @@ -132,7 +132,7 @@ type PuzzlePreviewBoardProps = { export const PuzzlePreviewBoard = ({ puzzle, label, - className = 'preset-preview-canvas', + className = 'puzzle-preview-canvas', width = DEFAULT_PREVIEW_WIDTH, height = DEFAULT_PREVIEW_HEIGHT, padding = DEFAULT_PREVIEW_PADDING, diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index c669558..5534af6 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -63,8 +63,8 @@ export const ControlPanel = () => { setShowImportErrorDialog(Boolean(importError)) }, [importError]) - const solveChunkLabel = `Solve Next ${solveChunkSize} ${solveChunkSize === 1 ? 'Step' : 'Steps'}` - const previousChunkLabel = `Previous ${solveChunkSize} ${solveChunkSize === 1 ? 'Step' : 'Steps'}` + const solveChunkLabel = `Next ${solveChunkSize} ${solveChunkSize === 1 ? 'Step' : 'Steps'}` + const previousChunkLabel = `Prev ${solveChunkSize} ${solveChunkSize === 1 ? 'Step' : 'Steps'}` const timelineStepForTooltip = timelinePreviewStep ?? pointer const timelineTooltipLeft = steps.length > 0 ? `${Math.min(100, Math.max(0, (timelineStepForTooltip / steps.length) * 100))}%` : '0%' @@ -140,7 +140,7 @@ export const ControlPanel = () => { Replay
+ + + ) +} From c36627100ad091e63c3aa5ca4cec50f093a8533d Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 03:36:30 +0800 Subject: [PATCH 05/20] chore: add comprehensive guide --- docs/ADDING_PUZZLE_FAMILY_EN.md | 155 ++++++++++++++++++++++++++++++++ docs/PROJECT_GUIDE_EN.md | 68 +++++++++----- 2 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 docs/ADDING_PUZZLE_FAMILY_EN.md diff --git a/docs/ADDING_PUZZLE_FAMILY_EN.md b/docs/ADDING_PUZZLE_FAMILY_EN.md new file mode 100644 index 0000000..37ec281 --- /dev/null +++ b/docs/ADDING_PUZZLE_FAMILY_EN.md @@ -0,0 +1,155 @@ +# Adding a Puzzle Family + +This guide is for developers and AI agents adding a new puzzle type such as +Nonogram or Masyu. Build a small vertical slice first: parse or create one +puzzle, render it, run a few explainable rules, and prove replay works. + +PuzzleKit is a reasoning engine with a UI. Keep puzzle logic in `domain/`, keep +rendering/orchestration in `features/` and `app/`, and connect them through +`PuzzleIR` plus `PuzzlePlugin`. + +--- + +## 1. Understand the Core Mechanisms + +Read these before writing code: + +- `src/domain/ir/types.ts` - shared `PuzzleIR`, cell/edge/sector/vertex state. +- `src/domain/ir/keys.ts` - stable keys for cells, edges, sectors, and vertices. +- `src/domain/rules/types.ts` - `Rule`, `RuleApplication`, `RuleStep`, and `RuleDiff`. +- `src/domain/rules/engine.ts` - applies rule diffs and rebuilds replay states. +- `src/features/solver/solverStore.ts` - loads puzzles, runs plugin rules, replays steps, and builds terminal reports. +- `src/features/editor/editorStore.ts` - current editor state model and Slitherlink editing pattern. +- `src/domain/plugins/types.ts` and `registry.ts` - plugin boundary for puzzle families. +- `src/domain/benchmark/*` and `dataset/public/*` - dataset and benchmark flow. + +Replay safety is the central contract. A rule must return explicit diffs; the +engine and solver store must be able to apply and undo those diffs without +hidden mutation. + +--- + +## 2. Define the Puzzle Boundary + +Start by deciding how the new puzzle maps into `PuzzleIR`: + +- Use `cells` for clue values, fills, shaded states, or symbols. +- Use `edges` for line-like or wall-like decisions. +- Use `sectors` only when the puzzle needs Slitherlink-style corner constraints. +- Use `vertices` only when vertex candidate sets are part of the reasoning model. +- Put puzzle-specific metadata in `metadata`, but prefer typed shared fields when they fit. + +Then add or update a plugin in `src/domain/plugins/`: + +- `id` and `displayName` +- `parse(input)` for supported input +- `encode(puzzle)` when export is available +- `getRules()` in deterministic execution order +- optional `help`, `legend`, and `getStats(puzzle)` for UI affordances + +Register the plugin in `src/domain/plugins/registry.ts`. Planned stubs are fine, +but do not make UI or docs imply a puzzle is implemented until it can parse, +render, and run at least a minimal rule path. + +--- + +## 3. Build the First Vertical Slice + +Recommended order: + +1. **IR factory and parser** + - Add a puzzle factory similar to `createSlitherPuzzle` if blank puzzles are needed. + - Add parser tests with small, readable fixtures. + - If full URL support is too large, add a minimal loader path first and document the limit. + +2. **Renderer** + - Add a puzzle-specific board renderer or make an existing renderer safely plugin-aware. + - Render actual puzzle state, not placeholder marketing UI. + - Keep dimensions stable so large boards and zoom do not shift layout. + +3. **Rules** + - Add a puzzle-specific rule folder under `src/domain/rules//`. + - Start with small deterministic rules that produce clear messages and explicit `RuleDiff`s. + - Keep rule order in one aggregation file, like Slitherlink's `rules.ts`. + +4. **Solver integration** + - Ensure `getRules()` returns the new ordered rules. + - Add replay tests proving `nextStep`, `prevStep`, and `goToStep` rebuild the same state. + - Add terminal/completion analysis when the puzzle has a meaningful solved/stalled report. + +5. **Editor and export** + - Add editor tools only after parsing/rendering/rules are stable. + - Keep editor state normalized as `PuzzleIR` so it can load directly into the solver. + - Add export only for formats that can round-trip reliably. + +6. **Dataset and benchmark** + - Add small public fixtures only when they are useful and stable. + - Use private datasets for local experiments. + - Benchmark reports should summarize status, step count, timing, and rule usage. + +--- + +## 4. UI Integration Checklist + +For a puzzle family to feel first-class, decide which of these it owns: + +- Solver board rendering and highlights. +- Editor board rendering and input tools. +- Puzzle type controls in Solver, Editor, and Dataset pages. +- `PuzzleInfoButton` content via plugin `help`. +- `BoardLegendButton` content via plugin `legend`. +- Board-title statistics via plugin `getStats`. +- Dataset preview rendering. +- Export controls and error messages. + +Prefer plugin-aware shared components when the behavior is generic. Prefer +puzzle-specific components when the interaction model is genuinely different +from Slitherlink. + +--- + +## 5. Suggested Roadmap + +**Milestone 1: Parse, render, sample puzzle** + +- A sample puzzle can load through the plugin. +- The board displays the puzzle accurately. +- Tests cover parser basics and rendering smoke behavior. + +**Milestone 2: Deterministic starter rules** + +- Add a small ordered rule set. +- Each rule returns explainable messages and explicit diffs. +- Replay tests prove forward/backward timeline behavior. + +**Milestone 3: Editor and export** + +- Add minimal editor tools for the puzzle's givens and user-editable state. +- Solver can load the editor puzzle without conversion hacks. +- Export round-trips when supported. + +**Milestone 4: Completion, datasets, UI polish** + +- Add solved/stalled analysis for terminal reports. +- Add curated public dataset entries and benchmark coverage. +- Add help, legend, and stats where they clarify the puzzle. + +**Milestone 5: Stronger inference** + +- Add advanced or branch-based inference only after deterministic rules are stable. +- Inject deterministic rule dependencies instead of self-referencing exported rule arrays. +- Keep branch reasoning conservative and test contradiction cases carefully. + +--- + +## 6. Implementation Cautions + +- Do not put puzzle-specific rules into shared solver orchestration. +- Do not mutate puzzle state inside a rule; return `RuleDiff`s. +- Do not change diff semantics without updating both engine and replay tests. +- Do not hide non-determinism behind rule ordering or object iteration. +- Do not overfit UI to Slitherlink if the next puzzle needs different primitives. +- Do not claim full support in docs, dropdowns, or datasets until parse/render/solve basics exist. + +The best first version is small, explainable, and replay-safe. Coverage can grow +incrementally once that spine is solid. diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index 637cece..679a151 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -58,6 +58,7 @@ Design rule: - UI should render and orchestrate. - Domain should decide logic. - The solver workspace and puzzle editor are separate product surfaces that exchange normalized `PuzzleIR`. +- Puzzle-specific behavior should enter through `PuzzlePlugin` or puzzle-specific feature modules, not shared solver orchestration. --- @@ -69,13 +70,32 @@ Design rule: 4. Rule engine runs ordered rules and returns one step at a time. 5. Each step stores rule metadata + explicit diffs. 6. Timeline store replays diffs forward/backward. -7. Board and explanation panel render current state + reasoning history. +7. Board, stats, and explanation panels render current state + reasoning history. This guarantees the same inference chain can be replayed and inspected later. --- -## 5. Benchmark and Dataset Flow +## 5. Plugin Contract + +Puzzle families are registered in `src/domain/plugins/registry.ts`. + +Each `PuzzlePlugin` owns the puzzle-family boundary: + +- `parse(input)` converts supported source input into normalized `PuzzleIR`. +- `encode(puzzle)` exports a puzzle back to a supported URL/string format. +- `getRules()` returns the ordered rule list used by the solver. +- `help` optionally powers the puzzle rules popout. +- `legend` optionally powers board legend examples. +- `getStats(puzzle)` optionally powers compact board-title puzzle stats via `PuzzleStatsInfoButton`. + +The current registry includes Slitherlink plus planned Masyu/Nonogram stubs. The +stubs are visible as future puzzle families but do not yet parse, render, edit, +or solve real puzzles. + +--- + +## 6. Benchmark and Dataset Flow Benchmarks evaluate solver behavior across JSON dataset manifests. They are for solver quality and rule-usage analysis, not for unit-test correctness. @@ -107,20 +127,23 @@ Report intent: - `steps` is intentionally an empty array for now to keep large reports small. - `ruleSteps[ruleId] = [stepNumbers...]` records where each rule fired. +The Dataset page browses public manifests, renders compact puzzle previews, and +can load a puzzle into either Solver or Editor. + --- -## 6. Slitherlink Rule Architecture (Current) +## 7. Slitherlink Rule Architecture (Current) The Slitherlink rules are now modularized under `src/domain/rules/slither/rules/`. -### 6.1 Aggregation entrypoint +### 7.1 Aggregation entrypoint - `src/domain/rules/slither/rules.ts` - Exports `deterministicSlitherRules` in a fixed order - Exports `slitherRules = deterministic + strong-inference` - Serves as the single place for execution-order control -### 6.2 Rule modules +### 7.2 Rule modules - `patterns.ts` - pattern-style clue rules (e.g. contiguous 3-run, diagonal adjacent 3) @@ -141,7 +164,7 @@ The Slitherlink rules are now modularized under `src/domain/rules/slither/rules/ - `shared.ts` - reusable helpers (geometry adjacency, clue/color utilities, mask helpers) -### 6.3 Branch inference decoupling +### 7.3 Branch inference decoupling Branch-based inference rules should not self-reference the exported `slitherRules` array. They receive deterministic rules via dependency @@ -153,7 +176,7 @@ This prevents circular coupling and keeps branch inference reusable/testable. --- -## 7. Sector Constraint Model (Critical) +## 8. Sector Constraint Model (Critical) Sector state is represented as a bitmask of allowed corner line counts `{0,1,2}`. @@ -166,7 +189,7 @@ Do not revert to old single-label sector semantics. --- -## 8. Replay and Determinism Contract +## 9. Replay and Determinism Contract Two files must stay behaviorally aligned: @@ -181,28 +204,32 @@ If these two paths diverge, timeline replay and solver state will drift. --- -## 9. Current Capability Snapshot +## 10. Current Capability Snapshot Implemented: -- Dedicated solver workspace for import, solving, replay, explanation, stats, and export +- Dedicated solver workspace for import, solving, replay, explanation, live stats, terminal reports, and export - Dedicated editor workspace for puzzle construction before loading into the solver +- Public Dataset page with filters, compact previews, and load-to-Solver/Editor actions - Slitherlink puzz.link parse/encode baseline - Slitherlink Penpa import baseline - Slitherlink editor tools for clues, pre-drawn line edges, crossed/blank edges, erasing, custom grid sizes, and built-in presets +- Plugin-powered rule help, board legend, and compact board-title puzzle stats +- Slitherlink board stats for numeric clue count and 0/1/2/3 clue distribution - Ordered rule execution with step metadata - Step replay (`Next`, `Previous`, `Solve to End`) - Explanation-oriented deduction trace - Sector mask inference/propagation pipeline - Strong-inference fallback for harder states +- Slitherlink completion analysis for solved/stalled terminal reports - Public/private benchmark manifest workflow - Compact benchmark reports with solve status, timing, rule usage, and rule step indices +- GitHub Pages release workflow for tagged builds Partially implemented / planned: -- More puzzle families (e.g. Masyu/Nonogram) +- Masyu and Nonogram plugin stubs only; real parsers, renderers, editors, rules, and completion checks are still planned - Puzzle-specific editor support for each puzzle family -- Dataset browsing as a product surface - Canvas interaction and rendering optimization for larger boards and richer editor states - Penpa adapter/export completeness - Better calibrated difficulty modeling @@ -211,16 +238,17 @@ Important expectation: difficult puzzles may stop at a stable but incomplete sta --- -## 10. AI Agent Quick Start +## 11. AI Agent Quick Start If you are an AI agent onboarding this repository, do this first: 1. Read `src/domain/rules/types.ts` and `src/domain/rules/engine.ts`. -2. Read `src/domain/rules/slither/rules.ts` to understand execution order. -3. Read `src/domain/rules/slither/rules/*.ts` by module category. -4. Verify replay contract in `src/features/solver/solverStore.ts`. -5. For benchmark work, read `src/domain/benchmark/runner.ts` and `scripts/benchmark-solve.ts`. -6. Use `src/domain/rules/slither/rules.test.ts` and `src/domain/benchmark/*.test.ts` as behavior references. +2. Read `src/domain/plugins/types.ts` and `src/domain/plugins/registry.ts`. +3. Read `src/features/solver/solverStore.ts` to verify replay and terminal-report behavior. +4. For Slitherlink work, read `src/domain/rules/slither/rules.ts` and the rule modules by category. +5. For editor/UI work, inspect the relevant `src/features/*` component and page tests first. +6. For benchmark work, read `src/domain/benchmark/runner.ts` and `scripts/benchmark-solve.ts`. +7. Use `src/domain/rules/slither/rules.test.ts`, page tests, and benchmark tests as behavior references. When editing: @@ -232,7 +260,7 @@ When editing: --- -## 11. Development Commands +## 12. Development Commands - `pnpm install` - install dependencies using the locked pnpm dependency graph - `pnpm dev` - local development @@ -242,7 +270,7 @@ When editing: - `pnpm build` - production build - `pnpm test:e2e` - Playwright end-to-end tests -## 12. Deployment and Release Flow +## 13. Deployment and Release Flow - Package management is standardized on pnpm 10.33.0 via the `packageManager` field in `package.json`. GitHub Actions installs that pnpm version before From 2c25e1f4c23c7e25ebf52efb792bb2b6ebda5507 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 12:06:39 +0800 Subject: [PATCH 06/20] feat: new Live Stat window --- src/app/WorkspacePage.test.tsx | 103 ++++++++ src/app/WorkspacePage.tsx | 14 +- src/app/workspace.css | 202 +++++++++++++++- src/domain/benchmark/runner.ts | 12 +- src/domain/difficulty/traceStats.test.ts | 151 ++++++++++++ src/domain/difficulty/traceStats.ts | 208 ++++++++++++++++ src/features/stats/StatsPanel.tsx | 291 ++++++++++++++++++++--- 7 files changed, 927 insertions(+), 54 deletions(-) create mode 100644 src/domain/difficulty/traceStats.test.ts create mode 100644 src/domain/difficulty/traceStats.ts diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index 6ceb965..e771485 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -283,6 +283,109 @@ describe('WorkspacePage', () => { expect(screen.getByText(/step 1 \/ 2/i)).toBeInTheDocument() }) + it('uses the live stats timeline to jump through the generated trace', () => { + renderWorkspace() + + fireEvent.click(screen.getByRole('button', { name: /next step/i })) + fireEvent.click(screen.getByRole('button', { name: /next step/i })) + + const statsTimeline = screen.getByLabelText(/trace timeline/i) + const liveStats = screen.getByLabelText(/live stats/i) + expect(statsTimeline).toHaveValue('2') + expect(within(liveStats).getByText(/board progress/i)).toBeInTheDocument() + expect(within(liveStats).getByText(/inference coverage/i)).toBeInTheDocument() + + fireEvent.change(statsTimeline, { target: { value: '1' } }) + + expect(statsTimeline).toHaveValue('1') + expect(screen.getByLabelText(/replay timeline/i)).toHaveValue('1') + expect(screen.getByText(/showing 1 \/ 1/i)).toBeInTheDocument() + }) + + it('shows the optimized live stats summary and charts', () => { + renderWorkspace() + + fireEvent.click(screen.getByRole('button', { name: /next step/i })) + + const liveStats = screen.getByLabelText(/live stats/i) + expect(within(liveStats).getByText(/current step/i)).toBeInTheDocument() + expect(within(liveStats).getByText(/unique rules applied/i)).toBeInTheDocument() + expect(within(liveStats).getByText(/total rule time/i)).toBeInTheDocument() + expect(within(liveStats).queryByText(/total diffs/i)).not.toBeInTheDocument() + expect(within(liveStats).queryByText(/rule applications/i)).not.toBeInTheDocument() + expect(within(liveStats).queryByText(/trace progress/i)).not.toBeInTheDocument() + + expect(within(liveStats).getByLabelText(/^board progress$/i)).toBeInTheDocument() + const coverageChart = within(liveStats).getByLabelText(/^inference coverage$/i) + expect(within(coverageChart).getByText(/edge/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/vertex/i)).toBeInTheDocument() + expect(within(coverageChart).queryByText(/sector/i)).not.toBeInTheDocument() + }) + + it('keeps future trace rules visible in live stats while browsing an earlier prefix', () => { + const steps: RuleStep[] = [ + { + id: 'step-1', + ruleId: 'rule-a', + ruleName: 'Rule A', + message: 'first', + diffs: [{ kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }], + affectedCells: [], + affectedEdges: [edgeKey([0, 0], [0, 1])], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 2, + }, + { + id: 'step-2', + ruleId: 'rule-b', + ruleName: 'Rule B', + message: 'second', + diffs: [{ kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }], + affectedCells: [cellKey(0, 0)], + affectedEdges: [], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 3, + }, + ] + const puzzle = createSlitherPuzzle(1, 1) + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'slitherlink', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + pointer: 1, + highlightedCells: [], + highlightedColorCells: [], + highlightedEdges: [], + solveProgress: null, + terminalReport: null, + isRunning: false, + })) + + renderWorkspace() + + const liveStats = screen.getByLabelText(/live stats/i) + expect(within(liveStats).getByText('Rule A')).toBeInTheDocument() + expect(within(liveStats).getByText('Rule B')).toBeInTheDocument() + + const ruleBRow = within(liveStats).getByText('Rule B').closest('tr') + expect(ruleBRow).not.toBeNull() + expect(within(ruleBRow as HTMLElement).getAllByText('0')).toHaveLength(1) + expect(within(ruleBRow as HTMLElement).getByText('-')).toBeInTheDocument() + }) + + it('shows a disabled live stats timeline before steps are generated', () => { + renderWorkspace() + + const statsTimeline = screen.getByLabelText(/trace timeline/i) + expect(statsTimeline).toBeDisabled() + expect(screen.getByText(/no generated steps yet/i)).toBeInTheDocument() + }) + it('rewinds by the configured step chunk and clamps at the start', () => { renderWorkspace() diff --git a/src/app/WorkspacePage.tsx b/src/app/WorkspacePage.tsx index 2cc6ae9..3e246cf 100644 --- a/src/app/WorkspacePage.tsx +++ b/src/app/WorkspacePage.tsx @@ -3,13 +3,14 @@ import { Link } from 'react-router-dom' import { CanvasBoard } from '../features/board/CanvasBoard' import { ExplanationPanel } from '../features/explanation/ExplanationPanel' import { ControlPanel } from '../features/solver/ControlPanel' -import { buildDifficultySnapshot, useSolverStore } from '../features/solver/solverStore' +import { useSolverStore } from '../features/solver/solverStore' import { StatsPanel } from '../features/stats/StatsPanel' import './workspace.css' export const WorkspacePage = () => { const { pluginId, + initialPuzzle, currentPuzzle, steps, pointer, @@ -18,9 +19,10 @@ export const WorkspacePage = () => { highlightedEdges, includeVertexNumbers, solveProgress, + goToStep, + isRunning, } = useSolverStore() const activeSteps = useMemo(() => steps.slice(0, pointer), [steps, pointer]) - const difficulty = useMemo(() => buildDifficultySnapshot(activeSteps), [activeSteps]) return (
@@ -47,7 +49,13 @@ export const WorkspacePage = () => { highlightedEdges={highlightedEdges} showVertexNumbers={includeVertexNumbers} /> - +
diff --git a/src/app/workspace.css b/src/app/workspace.css index b8f449b..142f3a1 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -1038,13 +1038,13 @@ button[data-active='true'] { font-size: 0.85rem; } -.stats-grid { +.stats-summary-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.stats-grid > div { +.stats-summary-grid > div { border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; @@ -1053,19 +1053,203 @@ button[data-active='true'] { gap: 4px; } -.stats-grid span { +.stats-summary-grid span { color: #6b7280; font-size: 0.85rem; } -.stats-grid strong { +.stats-summary-grid strong { color: #0f172a; } -.rule-usage { - margin: 8px 0 0; - padding-left: 18px; - color: #4b5563; +.stats-timeline-row { + margin-top: 12px; +} + +.stats-timeline-slider { + margin-top: 8px; +} + +.stats-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.trace-chart-card { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.trace-chart-header { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.trace-chart-header h3 { + margin: 0; + color: #0f172a; + font-size: 0.9rem; +} + +.trace-chart-header p { + margin: 2px 0 0; + color: #64748b; + font-size: 0.76rem; + line-height: 1.3; +} + +.trace-chart-header span { + flex: none; + color: #64748b; + font-size: 0.76rem; + font-variant-numeric: tabular-nums; +} + +.trace-line-chart { + display: block; + width: 100%; + height: auto; +} + +.chart-axis { + stroke: #94a3b8; + stroke-width: 1; +} + +.chart-grid-line { + stroke: #e2e8f0; + stroke-width: 1; +} + +.chart-current-line { + stroke: #0f172a; + stroke-dasharray: 4 4; + stroke-width: 1; + opacity: 0.5; +} + +.chart-axis-label { + fill: #64748b; + font-size: 10px; +} + +.trace-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; + color: #475569; + font-size: 0.76rem; +} + +.trace-chart-legend span { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; +} + +.trace-chart-legend i { + width: 8px; + height: 8px; + border-radius: 999px; +} + +.rule-usage-panel { + margin-top: 12px; +} + +.rule-usage-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.rule-usage-header h3 { + margin: 0; + color: #0f172a; + font-size: 0.92rem; +} + +.rule-usage-header span, +.rule-usage-empty { + color: #6b7280; + font-size: 0.8rem; +} + +.rule-usage-empty { + margin: 0; +} + +.rule-usage-table-wrap { + overflow-x: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.rule-usage-table { + width: 100%; + border-collapse: collapse; + color: #334155; + font-size: 0.8rem; +} + +.rule-usage-table th, +.rule-usage-table td { + padding: 7px 8px; + border-bottom: 1px solid #edf2f7; + text-align: left; + vertical-align: top; +} + +.rule-usage-table th { + background: #f8fafc; + color: #475569; + font-size: 0.74rem; + text-transform: uppercase; +} + +.rule-usage-table tr:last-child td { + border-bottom: 0; +} + +.rule-name, +.rule-id { + display: block; +} + +.rule-name { + color: #0f172a; + font-weight: 700; +} + +.rule-id { + margin-top: 2px; + color: #64748b; + font-size: 0.72rem; +} + +.rule-step-list { + max-width: 180px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: normal; +} + +@media (max-width: 900px) { + .stats-summary-grid, + .stats-chart-grid { + grid-template-columns: 1fr; + } } .editor-board-card { diff --git a/src/domain/benchmark/runner.ts b/src/domain/benchmark/runner.ts index 8364d86..cf61900 100644 --- a/src/domain/benchmark/runner.ts +++ b/src/domain/benchmark/runner.ts @@ -2,7 +2,7 @@ import type { PuzzleIR } from '../ir/types' import { puzzleRegistry } from '../plugins/registry' import { runNextRule } from '../rules/engine' import { analyzeSlitherCompletion } from '../rules/slither/completion' -import type { RuleStep } from '../rules/types' +import { addRuleUsage } from '../difficulty/traceStats' import type { BenchmarkDatasetItem, BenchmarkDatasetManifest, @@ -26,16 +26,6 @@ const normalizeLimit = ( return Math.max(1, Math.floor(value)) } -const addRuleUsage = ( - ruleUsage: Record, - ruleSteps: Record, - step: RuleStep, - stepNumber: number, -): void => { - ruleUsage[step.ruleId] = (ruleUsage[step.ruleId] ?? 0) + 1 - ruleSteps[step.ruleId] = [...(ruleSteps[step.ruleId] ?? []), stepNumber] -} - const getSlitherTerminal = (puzzleType: string, puzzle: PuzzleIR) => puzzleType === 'slitherlink' ? analyzeSlitherCompletion(puzzle) : null diff --git a/src/domain/difficulty/traceStats.test.ts b/src/domain/difficulty/traceStats.test.ts new file mode 100644 index 0000000..08558f4 --- /dev/null +++ b/src/domain/difficulty/traceStats.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, edgeKey, vertexKey } from '../ir/keys' +import { createSlitherPuzzle } from '../ir/slither' +import type { RuleStep } from '../rules/types' +import { buildRuleTraceStats, buildTraceChartStats } from './traceStats' + +const makeStep = ( + index: number, + ruleId: string, + ruleName: string, + durationMs: number, + diffs: RuleStep['diffs'], +): RuleStep => ({ + id: `step-${index}`, + ruleId, + ruleName, + message: `step ${index}`, + diffs, + affectedCells: [], + affectedEdges: diffs.flatMap((diff) => (diff.kind === 'edge' ? [diff.edgeKey] : [])), + affectedSectors: diffs.flatMap((diff) => (diff.kind === 'sector' ? [diff.sectorKey] : [])), + timestamp: index, + durationMs, +}) + +describe('buildRuleTraceStats', () => { + it('builds rule usage and rule step indices for the active prefix', () => { + const steps: RuleStep[] = [ + makeStep(1, 'rule-a', 'Rule A', 2, [ + { kind: 'edge', edgeKey: '0,0-0,1', from: 'unknown', to: 'line' }, + ]), + makeStep(2, 'rule-b', 'Rule B', 3, [ + { kind: 'cell', cellKey: '0,0', fromFill: null, toFill: 'green' }, + ]), + makeStep(3, 'rule-a', 'Rule A', 5, [ + { kind: 'sector', sectorKey: '0,0,nw', fromMask: 7, toMask: 2 }, + ]), + ] + + const stats = buildRuleTraceStats(steps, 3) + + expect(stats.ruleUsage).toEqual({ 'rule-a': 2, 'rule-b': 1 }) + expect(stats.ruleSteps).toEqual({ 'rule-a': [1, 3], 'rule-b': [2] }) + expect(stats.totalDurationMs).toBe(10) + expect(stats.diffCounts).toEqual({ edge: 1, sector: 1, cell: 1, vertex: 0 }) + }) + + it('keeps all full-trace rules visible when the active prefix has not used them yet', () => { + const steps: RuleStep[] = [ + makeStep(1, 'rule-a', 'Rule A', 1, []), + makeStep(2, 'rule-b', 'Rule B', 1, []), + ] + + const stats = buildRuleTraceStats(steps, 1) + + expect(stats.rules.map((rule) => rule.ruleId)).toEqual(['rule-a', 'rule-b']) + expect(stats.rules[0]).toMatchObject({ count: 1, steps: [1] }) + expect(stats.rules[1]).toMatchObject({ count: 0, steps: [] }) + expect(stats.uniqueRulesUsed).toBe(1) + }) + + it('clamps pointer and reports trace progress as generated-trace progress', () => { + const steps: RuleStep[] = [ + makeStep(1, 'rule-a', 'Rule A', 1, []), + makeStep(2, 'rule-b', 'Rule B', 1, []), + ] + + expect(buildRuleTraceStats(steps, -10).pointer).toBe(0) + expect(buildRuleTraceStats(steps, 99).pointer).toBe(2) + expect(buildRuleTraceStats(steps, 1).traceProgressRatio).toBe(0.5) + }) +}) + +describe('buildTraceChartStats', () => { + it('starts with a step zero chart point from the initial puzzle', () => { + const puzzle = createSlitherPuzzle(1, 1) + const stats = buildTraceChartStats(puzzle, [], 0) + + expect(stats.points).toHaveLength(1) + expect(stats.current.step).toBe(0) + expect(stats.current.boardProgressRatio).toBe(0) + expect(stats.totalEdges).toBe(4) + expect(stats.totalCells).toBe(1) + expect(stats.totalVertices).toBe(4) + }) + + it('tracks edge board progress and edge coverage as edge diffs are applied', () => { + const puzzle = createSlitherPuzzle(1, 1) + const topEdge = edgeKey([0, 0], [0, 1]) + const bottomEdge = edgeKey([1, 0], [1, 1]) + const steps: RuleStep[] = [ + makeStep(1, 'edge-rule', 'Edge Rule', 1, [ + { kind: 'edge', edgeKey: topEdge, from: 'unknown', to: 'line' }, + ]), + makeStep(2, 'edge-rule', 'Edge Rule', 1, [ + { kind: 'edge', edgeKey: bottomEdge, from: 'unknown', to: 'blank' }, + ]), + ] + + const stats = buildTraceChartStats(puzzle, steps, 2) + + expect(stats.points.map((point) => point.edgeCoverageRatio)).toEqual([0, 0.25, 0.5]) + expect(stats.current.boardProgressRatio).toBe(0.5) + }) + + it('tracks cell coverage from filled cells', () => { + const puzzle = createSlitherPuzzle(2, 2) + const steps: RuleStep[] = [ + makeStep(1, 'cell-rule', 'Cell Rule', 1, [ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ]), + ] + + const stats = buildTraceChartStats(puzzle, steps, 1) + + expect(stats.current.cellCoverageRatio).toBe(0.25) + }) + + it('tracks vertex coverage from narrowed vertex candidates', () => { + const puzzle = createSlitherPuzzle(1, 1) + const targetVertex = vertexKey(0, 0) + const initialCandidates = puzzle.vertices[targetVertex].candidateEdgeSets + const steps: RuleStep[] = [ + makeStep(1, 'vertex-rule', 'Vertex Rule', 1, [ + { + kind: 'vertex', + vertexKey: targetVertex, + fromCandidates: initialCandidates, + toCandidates: [initialCandidates[0]], + }, + ]), + ] + + const stats = buildTraceChartStats(puzzle, steps, 1) + + expect(stats.current.vertexCoverageRatio).toBe(0.25) + }) + + it('clamps pointer when selecting the current chart point', () => { + const puzzle = createSlitherPuzzle(1, 1) + const topEdge = edgeKey([0, 0], [0, 1]) + const steps: RuleStep[] = [ + makeStep(1, 'edge-rule', 'Edge Rule', 1, [ + { kind: 'edge', edgeKey: topEdge, from: 'unknown', to: 'line' }, + ]), + ] + + expect(buildTraceChartStats(puzzle, steps, 99).current.step).toBe(1) + expect(buildTraceChartStats(puzzle, steps, -10).current.step).toBe(0) + }) +}) diff --git a/src/domain/difficulty/traceStats.ts b/src/domain/difficulty/traceStats.ts new file mode 100644 index 0000000..f88a110 --- /dev/null +++ b/src/domain/difficulty/traceStats.ts @@ -0,0 +1,208 @@ +import type { PuzzleIR, VertexCandidate } from '../ir/types' +import type { RuleDiff, RuleStep } from '../rules/types' + +export type RuleTraceDiffCounts = Record + +export type RuleTraceSummary = { + ruleId: string + ruleName: string + count: number + percent: number + durationMs: number + steps: number[] +} + +export type RuleTraceStats = { + pointer: number + totalSteps: number + traceProgressRatio: number + totalRuleApplications: number + totalDurationMs: number + totalDiffs: number + uniqueRulesUsed: number + diffCounts: RuleTraceDiffCounts + ruleUsage: Record + ruleSteps: Record + rules: RuleTraceSummary[] +} + +export type TraceChartPoint = { + step: number + boardProgressRatio: number + edgeCoverageRatio: number + cellCoverageRatio: number + vertexCoverageRatio: number +} + +export type TraceChartStats = { + pointer: number + totalSteps: number + totalEdges: number + totalCells: number + totalVertices: number + current: TraceChartPoint + points: TraceChartPoint[] +} + +export const emptyDiffCounts = (): RuleTraceDiffCounts => ({ + edge: 0, + sector: 0, + cell: 0, + vertex: 0, +}) + +export const addRuleUsage = ( + ruleUsage: Record, + ruleSteps: Record, + step: RuleStep, + stepNumber: number, +): void => { + ruleUsage[step.ruleId] = (ruleUsage[step.ruleId] ?? 0) + 1 + ruleSteps[step.ruleId] = [...(ruleSteps[step.ruleId] ?? []), stepNumber] +} + +const clampPointer = (pointer: number, totalSteps: number): number => { + if (!Number.isFinite(pointer)) { + return 0 + } + return Math.min(totalSteps, Math.max(0, Math.floor(pointer))) +} + +export const buildRuleTraceStats = (steps: RuleStep[], pointer: number): RuleTraceStats => { + const currentPointer = clampPointer(pointer, steps.length) + const activeSteps = steps.slice(0, currentPointer) + const ruleOrder: string[] = [] + const ruleNames: Record = {} + const ruleUsage: Record = {} + const ruleSteps: Record = {} + const ruleDurations: Record = {} + const diffCounts = emptyDiffCounts() + let totalDurationMs = 0 + let totalDiffs = 0 + + for (const step of steps) { + if (ruleNames[step.ruleId] === undefined) { + ruleOrder.push(step.ruleId) + ruleNames[step.ruleId] = step.ruleName + } + } + + activeSteps.forEach((step, index) => { + const stepNumber = index + 1 + addRuleUsage(ruleUsage, ruleSteps, step, stepNumber) + const durationMs = step.durationMs ?? 0 + ruleDurations[step.ruleId] = (ruleDurations[step.ruleId] ?? 0) + durationMs + totalDurationMs += durationMs + + for (const diff of step.diffs) { + diffCounts[diff.kind] += 1 + totalDiffs += 1 + } + }) + + const rules = ruleOrder.map((ruleId) => { + const count = ruleUsage[ruleId] ?? 0 + return { + ruleId, + ruleName: ruleNames[ruleId] ?? ruleId, + count, + percent: currentPointer > 0 ? count / currentPointer : 0, + durationMs: ruleDurations[ruleId] ?? 0, + steps: ruleSteps[ruleId] ?? [], + } + }) + + return { + pointer: currentPointer, + totalSteps: steps.length, + traceProgressRatio: steps.length > 0 ? currentPointer / steps.length : 0, + totalRuleApplications: currentPointer, + totalDurationMs, + totalDiffs, + uniqueRulesUsed: Object.keys(ruleUsage).length, + diffCounts, + ruleUsage, + ruleSteps, + rules, + } +} + +const ratio = (count: number, total: number): number => (total <= 0 ? 0 : count / total) + +const vertexSignature = (candidates: VertexCandidate[] | undefined): string => + JSON.stringify( + (candidates ?? []) + .map((candidate) => [...candidate].sort()) + .sort((a, b) => a.length - b.length || a.join('|').localeCompare(b.join('|'))), + ) + +export const buildTraceChartStats = ( + initialPuzzle: PuzzleIR, + steps: RuleStep[], + pointer: number, +): TraceChartStats => { + const currentPointer = clampPointer(pointer, steps.length) + const totalEdges = Object.keys(initialPuzzle.edges).length + const totalCells = initialPuzzle.rows * initialPuzzle.cols + const totalVertices = Object.keys(initialPuzzle.vertices).length + + const edgeMarks: Record = {} + const cellFills: Record = {} + const initialVertexCandidateCounts: Record = {} + const initialVertexSignatures: Record = {} + const vertexCandidates: Record = {} + + for (const [key, edge] of Object.entries(initialPuzzle.edges)) { + edgeMarks[key] = edge?.mark ?? 'unknown' + } + for (const [key, cell] of Object.entries(initialPuzzle.cells)) { + cellFills[key] = cell.fill ?? null + } + for (const [key, vertex] of Object.entries(initialPuzzle.vertices)) { + const candidates = vertex?.candidateEdgeSets ?? [] + initialVertexCandidateCounts[key] = candidates.length + initialVertexSignatures[key] = vertexSignature(candidates) + vertexCandidates[key] = candidates.map((candidate) => [...candidate]) + } + + const makePoint = (step: number): TraceChartPoint => { + const decidedEdges = Object.values(edgeMarks).filter((mark) => mark !== 'unknown').length + const filledCells = Object.values(cellFills).filter((fill) => fill !== null).length + const narrowedVertices = Object.entries(vertexCandidates).filter(([key, candidates]) => { + const initialCount = initialVertexCandidateCounts[key] ?? 0 + return candidates.length < initialCount || vertexSignature(candidates) !== initialVertexSignatures[key] + }).length + + return { + step, + boardProgressRatio: ratio(decidedEdges, totalEdges), + edgeCoverageRatio: ratio(decidedEdges, totalEdges), + cellCoverageRatio: ratio(filledCells, totalCells), + vertexCoverageRatio: ratio(narrowedVertices, totalVertices), + } + } + + const points: TraceChartPoint[] = [makePoint(0)] + steps.forEach((step, index) => { + for (const diff of step.diffs) { + if (diff.kind === 'edge') { + edgeMarks[diff.edgeKey] = diff.to + } else if (diff.kind === 'cell') { + cellFills[diff.cellKey] = diff.toFill + } else if (diff.kind === 'vertex') { + vertexCandidates[diff.vertexKey] = diff.toCandidates.map((candidate) => [...candidate]) + } + } + points.push(makePoint(index + 1)) + }) + + return { + pointer: currentPointer, + totalSteps: steps.length, + totalEdges, + totalCells, + totalVertices, + current: points[currentPointer] ?? points[0], + points, + } +} diff --git a/src/features/stats/StatsPanel.tsx b/src/features/stats/StatsPanel.tsx index a3b332d..52771f8 100644 --- a/src/features/stats/StatsPanel.tsx +++ b/src/features/stats/StatsPanel.tsx @@ -1,53 +1,282 @@ -import type { DifficultySnapshot } from '../../domain/difficulty/types' +import { useMemo } from 'react' +import { + buildRuleTraceStats, + buildTraceChartStats, + type TraceChartPoint, +} from '../../domain/difficulty/traceStats' +import type { PuzzleIR } from '../../domain/ir/types' import type { RuleStep } from '../../domain/rules/types' type Props = { + initialPuzzle: PuzzleIR steps: RuleStep[] - difficulty: DifficultySnapshot + pointer: number + isRunning: boolean + onGoToStep: (targetPointer: number) => void } -export const StatsPanel = ({ steps, difficulty }: Props) => { - const totalChanges = steps.reduce( - (sum, step) => sum + step.diffs.filter((diff) => diff.kind === 'edge').length, - 0, +type ChartSeries = { + label: string + color: string + values: number[] +} + +const formatPercent = (ratio: number): string => `${(ratio * 100).toFixed(1)}%` + +const formatDuration = (durationMs: number): string => { + if (durationMs < 1000) { + return `${durationMs.toFixed(1)} ms` + } + return `${(durationMs / 1000).toFixed(2)} s` +} + +const formatStepList = (stepNumbers: number[]): string => { + if (stepNumbers.length === 0) { + return '-' + } + if (stepNumbers.length <= 8) { + return stepNumbers.join(', ') + } + return `${stepNumbers.slice(0, 8).join(', ')} +${stepNumbers.length - 8}` +} + +const clampRatio = (value: number): number => Math.min(1, Math.max(0, value)) + +const makePath = ( + values: number[], + width: number, + height: number, + padding: { top: number; right: number; bottom: number; left: number }, +): string => { + if (values.length === 0) { + return '' + } + const plotWidth = width - padding.left - padding.right + const plotHeight = height - padding.top - padding.bottom + const xFor = (index: number): number => + padding.left + (values.length <= 1 ? 0 : (index / (values.length - 1)) * plotWidth) + const yFor = (value: number): number => padding.top + (1 - clampRatio(value)) * plotHeight + return values.map((value, index) => `${index === 0 ? 'M' : 'L'} ${xFor(index)} ${yFor(value)}`).join(' ') +} + +const TraceLineChart = ({ + title, + description, + series, + currentIndex, +}: { + title: string + description: string + series: ChartSeries[] + currentIndex: number +}) => { + const width = 360 + const height = 190 + const padding = { top: 18, right: 18, bottom: 30, left: 36 } + const maxLength = Math.max(1, ...series.map((item) => item.values.length)) + const clampedIndex = Math.min(maxLength - 1, Math.max(0, currentIndex)) + const plotWidth = width - padding.left - padding.right + const plotHeight = height - padding.top - padding.bottom + const xFor = (index: number): number => + padding.left + (maxLength <= 1 ? 0 : (index / (maxLength - 1)) * plotWidth) + const yFor = (value: number): number => padding.top + (1 - clampRatio(value)) * plotHeight + + return ( +
+
+
+

{title}

+

{description}

+
+ Step {clampedIndex} +
+ + + + + + + 100% + + + 0% + + + 0 + + + {Math.max(0, maxLength - 1)} + + {series.map((item) => ( + + ))} + + {series.map((item) => { + const value = item.values[clampedIndex] ?? 0 + return ( + + ) + })} + +
+ {series.map((item) => ( + + + {item.label} {formatPercent(item.values[clampedIndex] ?? 0)} + + ))} +
+
+ ) +} + +const getBoardProgressSeries = (points: TraceChartPoint[]): ChartSeries[] => [ + { + label: 'Progress', + color: '#0891b2', + values: points.map((point) => point.boardProgressRatio), + }, +] + +const getCoverageSeries = (points: TraceChartPoint[]): ChartSeries[] => [ + { + label: 'Edge', + color: '#2563eb', + values: points.map((point) => point.edgeCoverageRatio), + }, + { + label: 'Cell', + color: '#16a34a', + values: points.map((point) => point.cellCoverageRatio), + }, + { + label: 'Vertex', + color: '#d97706', + values: points.map((point) => point.vertexCoverageRatio), + }, +] + +export const StatsPanel = ({ initialPuzzle, steps, pointer, isRunning, onGoToStep }: Props) => { + const stats = useMemo(() => buildRuleTraceStats(steps, pointer), [pointer, steps]) + const chartStats = useMemo( + () => buildTraceChartStats(initialPuzzle, steps, pointer), + [initialPuzzle, pointer, steps], ) + return ( -
+

Live Stats

-
+
- Total Steps - {steps.length} + Current Step + + {stats.pointer} / {stats.totalSteps} +
- Total Modifications - {totalChanges} + Total Rule Time + {formatDuration(stats.totalDurationMs)}
- Unique Techniques - {difficulty.uniqueRules} + Unique Rules Applied + {stats.uniqueRulesUsed}
-
- Difficulty Score (draft) - {difficulty.totalSteps + difficulty.totalEdgeChanges} +
+ +
+
+ + Step {chartStats.pointer} of {chartStats.totalSteps} +
+ onGoToStep(Number(event.target.value))} + /> +
+ +
+ + +
+ +
+
+

Rule Usage

+ {stats.rules.length} rules in generated trace
+ {stats.rules.length === 0 ? ( +

No generated steps yet.

+ ) : ( +
+ + + + + + + + + + + + {stats.rules.map((rule) => ( + + + + + + + + ))} + +
RuleCountShareTimeSteps
+ {rule.ruleName} + {rule.ruleId} + {rule.count}{formatPercent(rule.percent)}{formatDuration(rule.durationMs)}{formatStepList(rule.steps)}
+
+ )}
-
- Rule Usage -
    - {Object.entries(difficulty.ruleUsage).length === 0 ? ( -
  • None yet
  • - ) : ( - Object.entries(difficulty.ruleUsage).map(([rule, count]) => ( -
  • - {rule}: {count} -
  • - )) - )} -
-
) } From 4dcc282f40582a4474141e09bb6cebe801e34c72 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 12:18:32 +0800 Subject: [PATCH 07/20] feat: Live Stat enhance --- src/app/WorkspacePage.test.tsx | 3 + src/app/WorkspacePage.tsx | 5 +- src/domain/difficulty/traceStats.test.ts | 99 +++++++- src/domain/difficulty/traceStats.ts | 279 +++++++++++++++++++++++ src/features/solver/solverStore.test.ts | 43 ++++ src/features/solver/solverStore.ts | 17 +- src/features/stats/StatsPanel.tsx | 52 +++-- 7 files changed, 472 insertions(+), 26 deletions(-) diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index e771485..f910433 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { cellKey, edgeKey } from '../domain/ir/keys' import { createSlitherPuzzle } from '../domain/ir/slither' import type { EdgeMark, PuzzleIR } from '../domain/ir/types' +import { rebuildTraceStatsCache } from '../domain/difficulty/traceStats' import { DEFAULT_SOLVE_CHUNK_SIZE, useSolverStore } from '../features/solver/solverStore' import { WorkspacePage } from './WorkspacePage' import type { RuleStep } from '../domain/rules/types' @@ -357,6 +358,7 @@ describe('WorkspacePage', () => { initialPuzzle: puzzle, currentPuzzle: puzzle, steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), pointer: 1, highlightedCells: [], highlightedColorCells: [], @@ -509,6 +511,7 @@ describe('WorkspacePage', () => { useSolverStore.setState((state) => ({ ...state, steps, + traceStatsCache: rebuildTraceStatsCache(state.initialPuzzle, steps), pointer: steps.length, terminalReport: null, })) diff --git a/src/app/WorkspacePage.tsx b/src/app/WorkspacePage.tsx index 3e246cf..292781c 100644 --- a/src/app/WorkspacePage.tsx +++ b/src/app/WorkspacePage.tsx @@ -10,9 +10,9 @@ import './workspace.css' export const WorkspacePage = () => { const { pluginId, - initialPuzzle, currentPuzzle, steps, + traceStatsCache, pointer, highlightedCells, highlightedColorCells, @@ -50,8 +50,7 @@ export const WorkspacePage = () => { showVertexNumbers={includeVertexNumbers} /> { expect(buildTraceChartStats(puzzle, steps, -10).current.step).toBe(0) }) }) + +describe('incremental trace stats cache', () => { + it('initializes cache with a step zero chart point', () => { + const puzzle = createSlitherPuzzle(1, 1) + const cache = createTraceStatsCache(puzzle) + const view = buildTraceStatsView(cache, 0) + + expect(cache.points).toHaveLength(1) + expect(view.current.step).toBe(0) + expect(view.current.boardProgressRatio).toBe(0) + expect(view.totalEdges).toBe(4) + }) + + it('increments edge, cell, and vertex coverage from appended diffs', () => { + const puzzle = createSlitherPuzzle(2, 2) + const targetVertex = vertexKey(0, 0) + const initialCandidates = puzzle.vertices[targetVertex].candidateEdgeSets + const step = makeStep(1, 'mixed-rule', 'Mixed Rule', 4, [ + { kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }, + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'yellow' }, + { + kind: 'vertex', + vertexKey: targetVertex, + fromCandidates: initialCandidates, + toCandidates: [initialCandidates[0]], + }, + ]) + + const cache = appendTraceStatsStep(createTraceStatsCache(puzzle), step) + const view = buildTraceStatsView(cache, 1) + + expect(view.current.edgeCoverageRatio).toBe(1 / 12) + expect(view.current.cellCoverageRatio).toBe(0.25) + expect(view.current.vertexCoverageRatio).toBe(1 / 9) + expect(view.totalDurationMs).toBe(4) + expect(view.diffCounts).toMatchObject({ edge: 1, cell: 1, vertex: 1 }) + }) + + it('does not count an unchanged vertex candidate set as narrowed', () => { + const puzzle = createSlitherPuzzle(1, 1) + const targetVertex = vertexKey(0, 0) + const initialCandidates = puzzle.vertices[targetVertex].candidateEdgeSets + const step = makeStep(1, 'vertex-rule', 'Vertex Rule', 1, [ + { + kind: 'vertex', + vertexKey: targetVertex, + fromCandidates: initialCandidates, + toCandidates: initialCandidates, + }, + ]) + + const cache = appendTraceStatsStep(createTraceStatsCache(puzzle), step) + + expect(buildTraceStatsView(cache, 1).current.vertexCoverageRatio).toBe(0) + }) + + it('truncates a future branch and rebuilds prefix totals', () => { + const puzzle = createSlitherPuzzle(1, 1) + const first = makeStep(1, 'rule-a', 'Rule A', 2, [ + { kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }, + ]) + const second = makeStep(2, 'rule-b', 'Rule B', 3, [ + { kind: 'edge', edgeKey: edgeKey([1, 0], [1, 1]), from: 'unknown', to: 'blank' }, + ]) + const cache = rebuildTraceStatsCache(puzzle, [first, second]) + + const truncated = truncateTraceStatsCache(puzzle, cache, [first, second], 1) + const view = buildTraceStatsView(truncated, 1) + + expect(truncated.points).toHaveLength(2) + expect(view.totalDurationMs).toBe(2) + expect(view.rules.map((rule) => rule.ruleId)).toEqual(['rule-a']) + expect(view.current.edgeCoverageRatio).toBe(0.25) + }) + + it('keeps full generated rule rows visible while building an earlier pointer view', () => { + const puzzle = createSlitherPuzzle(1, 1) + const first = makeStep(1, 'rule-a', 'Rule A', 1, []) + const second = makeStep(2, 'rule-b', 'Rule B', 1, []) + const cache = rebuildTraceStatsCache(puzzle, [first, second]) + + const view = buildTraceStatsView(cache, 1) + + expect(view.rules.map((rule) => rule.ruleId)).toEqual(['rule-a', 'rule-b']) + expect(view.rules[0]).toMatchObject({ count: 1, steps: [1] }) + expect(view.rules[1]).toMatchObject({ count: 0, steps: [] }) + expect(buildTraceStatsView(cache, 99).pointer).toBe(2) + }) +}) diff --git a/src/domain/difficulty/traceStats.ts b/src/domain/difficulty/traceStats.ts index f88a110..d1d4d4b 100644 --- a/src/domain/difficulty/traceStats.ts +++ b/src/domain/difficulty/traceStats.ts @@ -44,6 +44,34 @@ export type TraceChartStats = { points: TraceChartPoint[] } +export type RuleTraceOccurrence = { + ruleId: string + ruleName: string + steps: number[] + durationPrefixMs: number[] +} + +export type TraceStatsCache = { + totalEdges: number + totalCells: number + totalVertices: number + points: TraceChartPoint[] + ruleOrder: string[] + ruleOccurrences: Record + totalDurationPrefixMs: number[] + totalDiffPrefixCounts: number[] + diffPrefixCounts: Record + edgeMarks: Record + cellFills: Record + vertexCandidateSignatures: Record + initialVertexCandidateCounts: Record + initialVertexCandidateSignatures: Record + narrowedVertexKeys: Record + decidedEdgeCount: number + filledCellCount: number + narrowedVertexCount: number +} + export const emptyDiffCounts = (): RuleTraceDiffCounts => ({ edge: 0, sector: 0, @@ -136,6 +164,257 @@ const vertexSignature = (candidates: VertexCandidate[] | undefined): string => .sort((a, b) => a.length - b.length || a.join('|').localeCompare(b.join('|'))), ) +const makeChartPoint = ( + step: number, + cache: Pick< + TraceStatsCache, + | 'decidedEdgeCount' + | 'filledCellCount' + | 'narrowedVertexCount' + | 'totalEdges' + | 'totalCells' + | 'totalVertices' + >, +): TraceChartPoint => ({ + step, + boardProgressRatio: ratio(cache.decidedEdgeCount, cache.totalEdges), + edgeCoverageRatio: ratio(cache.decidedEdgeCount, cache.totalEdges), + cellCoverageRatio: ratio(cache.filledCellCount, cache.totalCells), + vertexCoverageRatio: ratio(cache.narrowedVertexCount, cache.totalVertices), +}) + +const countInitialFilledCells = (puzzle: PuzzleIR): number => + Object.values(puzzle.cells).filter((cell) => cell.fill !== undefined && cell.fill !== null).length + +const countInitialDecidedEdges = (puzzle: PuzzleIR): number => + Object.values(puzzle.edges).filter((edge) => (edge?.mark ?? 'unknown') !== 'unknown').length + +const upperBound = (values: number[], target: number): number => { + let low = 0 + let high = values.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (values[mid] <= target) { + low = mid + 1 + } else { + high = mid + } + } + return low +} + +export const createTraceStatsCache = (initialPuzzle: PuzzleIR): TraceStatsCache => { + const totalEdges = Object.keys(initialPuzzle.edges).length + const totalCells = initialPuzzle.rows * initialPuzzle.cols + const totalVertices = Object.keys(initialPuzzle.vertices).length + const edgeMarks: Record = {} + const cellFills: Record = {} + const vertexCandidateSignatures: Record = {} + const initialVertexCandidateCounts: Record = {} + const initialVertexCandidateSignatures: Record = {} + const narrowedVertexKeys: Record = {} + + for (const [key, edge] of Object.entries(initialPuzzle.edges)) { + edgeMarks[key] = edge?.mark ?? 'unknown' + } + for (const [key, cell] of Object.entries(initialPuzzle.cells)) { + cellFills[key] = cell.fill ?? null + } + for (const [key, vertex] of Object.entries(initialPuzzle.vertices)) { + const candidates = vertex?.candidateEdgeSets ?? [] + const signature = vertexSignature(candidates) + vertexCandidateSignatures[key] = signature + initialVertexCandidateCounts[key] = candidates.length + initialVertexCandidateSignatures[key] = signature + narrowedVertexKeys[key] = false + } + + const cacheBase = { + totalEdges, + totalCells, + totalVertices, + edgeMarks, + cellFills, + vertexCandidateSignatures, + initialVertexCandidateCounts, + initialVertexCandidateSignatures, + narrowedVertexKeys, + decidedEdgeCount: countInitialDecidedEdges(initialPuzzle), + filledCellCount: countInitialFilledCells(initialPuzzle), + narrowedVertexCount: 0, + } + + return { + ...cacheBase, + points: [makeChartPoint(0, cacheBase)], + ruleOrder: [], + ruleOccurrences: {}, + totalDurationPrefixMs: [0], + totalDiffPrefixCounts: [0], + diffPrefixCounts: { + edge: [0], + sector: [0], + cell: [0], + vertex: [0], + }, + } +} + +export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): TraceStatsCache => { + const stepNumber = cache.points.length + const next: TraceStatsCache = { + ...cache, + } + + let edgeDiffs = 0 + let sectorDiffs = 0 + let cellDiffs = 0 + let vertexDiffs = 0 + + for (const diff of step.diffs) { + if (diff.kind === 'edge') { + edgeDiffs += 1 + const previous = next.edgeMarks[diff.edgeKey] ?? diff.from ?? 'unknown' + const previousDecided = previous !== 'unknown' + const nextDecided = diff.to !== 'unknown' + if (!previousDecided && nextDecided) { + next.decidedEdgeCount += 1 + } else if (previousDecided && !nextDecided) { + next.decidedEdgeCount -= 1 + } + next.edgeMarks[diff.edgeKey] = diff.to + } else if (diff.kind === 'cell') { + cellDiffs += 1 + const previous = next.cellFills[diff.cellKey] ?? null + const previousFilled = previous !== null + const nextFilled = diff.toFill !== null + if (!previousFilled && nextFilled) { + next.filledCellCount += 1 + } else if (previousFilled && !nextFilled) { + next.filledCellCount -= 1 + } + next.cellFills[diff.cellKey] = diff.toFill + } else if (diff.kind === 'vertex') { + vertexDiffs += 1 + const signature = vertexSignature(diff.toCandidates) + const initialCount = next.initialVertexCandidateCounts[diff.vertexKey] ?? 0 + const initialSignature = next.initialVertexCandidateSignatures[diff.vertexKey] ?? '[]' + const wasNarrowed = next.narrowedVertexKeys[diff.vertexKey] ?? false + const isNarrowed = diff.toCandidates.length < initialCount || signature !== initialSignature + if (!wasNarrowed && isNarrowed) { + next.narrowedVertexCount += 1 + } else if (wasNarrowed && !isNarrowed) { + next.narrowedVertexCount -= 1 + } + next.narrowedVertexKeys[diff.vertexKey] = isNarrowed + next.vertexCandidateSignatures[diff.vertexKey] = signature + } else { + sectorDiffs += 1 + } + } + + if (next.ruleOccurrences[step.ruleId] === undefined) { + next.ruleOrder.push(step.ruleId) + next.ruleOccurrences[step.ruleId] = { + ruleId: step.ruleId, + ruleName: step.ruleName, + steps: [], + durationPrefixMs: [0], + } + } + const occurrence = next.ruleOccurrences[step.ruleId] + occurrence.steps.push(stepNumber) + occurrence.durationPrefixMs.push( + occurrence.durationPrefixMs[occurrence.durationPrefixMs.length - 1] + (step.durationMs ?? 0), + ) + + next.totalDurationPrefixMs.push( + next.totalDurationPrefixMs[next.totalDurationPrefixMs.length - 1] + (step.durationMs ?? 0), + ) + next.totalDiffPrefixCounts.push( + next.totalDiffPrefixCounts[next.totalDiffPrefixCounts.length - 1] + step.diffs.length, + ) + next.diffPrefixCounts.edge.push(next.diffPrefixCounts.edge[next.diffPrefixCounts.edge.length - 1] + edgeDiffs) + next.diffPrefixCounts.sector.push(next.diffPrefixCounts.sector[next.diffPrefixCounts.sector.length - 1] + sectorDiffs) + next.diffPrefixCounts.cell.push(next.diffPrefixCounts.cell[next.diffPrefixCounts.cell.length - 1] + cellDiffs) + next.diffPrefixCounts.vertex.push(next.diffPrefixCounts.vertex[next.diffPrefixCounts.vertex.length - 1] + vertexDiffs) + next.points.push(makeChartPoint(stepNumber, next)) + + return { ...next } +} + +export const rebuildTraceStatsCache = ( + initialPuzzle: PuzzleIR, + steps: RuleStep[] = [], +): TraceStatsCache => steps.reduce(appendTraceStatsStep, createTraceStatsCache(initialPuzzle)) + +export const truncateTraceStatsCache = ( + initialPuzzle: PuzzleIR, + cache: TraceStatsCache, + steps: RuleStep[], + pointer: number, +): TraceStatsCache => { + const clampedPointer = clampPointer(pointer, steps.length) + if (clampedPointer === steps.length && cache.points.length === steps.length + 1) { + return cache + } + return rebuildTraceStatsCache(initialPuzzle, steps.slice(0, clampedPointer)) +} + +export const buildTraceStatsView = ( + cache: TraceStatsCache, + pointer: number, +): RuleTraceStats & TraceChartStats => { + const totalSteps = Math.max(0, cache.points.length - 1) + const currentPointer = clampPointer(pointer, totalSteps) + const ruleUsage: Record = {} + const ruleSteps: Record = {} + const rules = cache.ruleOrder.map((ruleId) => { + const occurrence = cache.ruleOccurrences[ruleId] + const count = upperBound(occurrence.steps, currentPointer) + const steps = occurrence.steps.slice(0, count) + const durationMs = occurrence.durationPrefixMs[count] ?? 0 + ruleUsage[ruleId] = count + ruleSteps[ruleId] = steps + return { + ruleId, + ruleName: occurrence.ruleName, + count, + percent: currentPointer > 0 ? count / currentPointer : 0, + durationMs, + steps, + } + }) + + const activeRuleUsage = Object.fromEntries( + Object.entries(ruleUsage).filter(([, count]) => count > 0), + ) + + return { + pointer: currentPointer, + totalSteps, + traceProgressRatio: totalSteps > 0 ? currentPointer / totalSteps : 0, + totalRuleApplications: currentPointer, + totalDurationMs: cache.totalDurationPrefixMs[currentPointer] ?? 0, + totalDiffs: cache.totalDiffPrefixCounts[currentPointer] ?? 0, + uniqueRulesUsed: Object.keys(activeRuleUsage).length, + diffCounts: { + edge: cache.diffPrefixCounts.edge[currentPointer] ?? 0, + sector: cache.diffPrefixCounts.sector[currentPointer] ?? 0, + cell: cache.diffPrefixCounts.cell[currentPointer] ?? 0, + vertex: cache.diffPrefixCounts.vertex[currentPointer] ?? 0, + }, + ruleUsage, + ruleSteps, + rules, + totalEdges: cache.totalEdges, + totalCells: cache.totalCells, + totalVertices: cache.totalVertices, + current: cache.points[currentPointer] ?? cache.points[0], + points: cache.points, + } +} + export const buildTraceChartStats = ( initialPuzzle: PuzzleIR, steps: RuleStep[], diff --git a/src/features/solver/solverStore.test.ts b/src/features/solver/solverStore.test.ts index 53089f6..8f1a084 100644 --- a/src/features/solver/solverStore.test.ts +++ b/src/features/solver/solverStore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { cellKey, edgeKey } from '../../domain/ir/keys' +import { buildTraceStatsView, rebuildTraceStatsCache } from '../../domain/difficulty/traceStats' import { semanticEquals } from '../../domain/ir/normalize' import { createSlitherPuzzle } from '../../domain/ir/slither' import type { EdgeMark, PuzzleIR } from '../../domain/ir/types' @@ -53,6 +54,7 @@ describe('solver timeline behavior', () => { const store = useSolverStore.getState() store.nextStep() expect(useSolverStore.getState().steps.length).toBe(1) + expect(useSolverStore.getState().traceStatsCache.points).toHaveLength(2) expect(useSolverStore.getState().pointer).toBe(1) store.prevStep() @@ -62,6 +64,7 @@ describe('solver timeline behavior', () => { store.nextStep() expect(useSolverStore.getState().pointer).toBe(1) expect(useSolverStore.getState().steps.length).toBe(1) + expect(useSolverStore.getState().traceStatsCache.points).toHaveLength(2) }) it('keeps prevStep state consistent with replayed prefix state', () => { @@ -113,6 +116,7 @@ describe('solver timeline behavior', () => { initialPuzzle, currentPuzzle: buildPuzzleFromSteps(initialPuzzle, steps, 2), steps, + traceStatsCache: rebuildTraceStatsCache(initialPuzzle, steps), pointer: 2, highlightedCells: steps[1].affectedCells, highlightedColorCells: [], @@ -153,6 +157,7 @@ describe('solver timeline behavior', () => { initialPuzzle, currentPuzzle: buildPuzzleFromSteps(initialPuzzle, steps, 1), steps, + traceStatsCache: rebuildTraceStatsCache(initialPuzzle, steps), pointer: 1, highlightedCells: steps[0].affectedCells, highlightedColorCells: [], @@ -184,6 +189,41 @@ describe('solver timeline behavior', () => { expect(useSolverStore.getState().pointer).toBe(0) useSolverStore.setState((state) => ({ ...state, isRunning: false })) }) + + it('keeps trace stats cache when moving through existing replay states', () => { + const initialPuzzle = createSlitherPuzzle(1, 1) + const topEdge = edgeKey([0, 0], [0, 1]) + const step: RuleStep = { + id: 'step-1', + ruleId: 'test-rule', + ruleName: 'Test Rule', + message: 'test', + diffs: [{ kind: 'edge', edgeKey: topEdge, from: 'unknown', to: 'line' }], + affectedCells: [cellKey(0, 0)], + affectedEdges: [topEdge], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 2, + } + useSolverStore.setState((state) => ({ + ...state, + initialPuzzle, + currentPuzzle: buildPuzzleFromSteps(initialPuzzle, [step], 1), + steps: [step], + traceStatsCache: rebuildTraceStatsCache(initialPuzzle, [step]), + pointer: 1, + isRunning: false, + })) + + useSolverStore.getState().goToStep(0) + + expect(useSolverStore.getState().traceStatsCache.points).toHaveLength(2) + expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 0).current.edgeCoverageRatio).toBe(0) + + useSolverStore.getState().goToStep(1) + + expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 1).current.edgeCoverageRatio).toBe(0.25) + }) }) describe('solve chunk sizing', () => { @@ -253,6 +293,7 @@ describe('solver puzzle loading', () => { useSolverStore.getState().loadPuzzle(puzzle, { pluginId: 'slitherlink' }) const after = useSolverStore.getState() expect(after.steps.length).toBe(0) + expect(after.traceStatsCache.points).toHaveLength(1) expect(after.pointer).toBe(0) expect(after.sourceUrl).toBe('') expect(after.currentPuzzle.rows).toBe(5) @@ -273,6 +314,7 @@ describe('solver puzzle loading', () => { const after = useSolverStore.getState() expect(after.pointer).toBe(0) expect(after.steps.length).toBe(0) + expect(after.traceStatsCache.points).toHaveLength(1) expect(after.terminalReport).toBeNull() expect(after.sourceUrl).toBe('editor') expect(after.currentPuzzle.cells[cellKey(0, 0)]?.clue).toEqual({ @@ -290,6 +332,7 @@ describe('solver puzzle loading', () => { expect(after.currentPuzzle.rows).toBe(3) expect(after.currentPuzzle.cols).toBe(3) expect(after.steps.length).toBe(0) + expect(after.traceStatsCache.points).toHaveLength(1) expect(after.sourceUrl).toBe(SAMPLE_URL) }) }) diff --git a/src/features/solver/solverStore.ts b/src/features/solver/solverStore.ts index 55e669e..2a62006 100644 --- a/src/features/solver/solverStore.ts +++ b/src/features/solver/solverStore.ts @@ -1,4 +1,11 @@ import { create } from 'zustand' +import { + appendTraceStatsStep, + createTraceStatsCache, + rebuildTraceStatsCache, + truncateTraceStatsCache, + type TraceStatsCache, +} from '../../domain/difficulty/traceStats' import type { DifficultySnapshot } from '../../domain/difficulty/types' import { clonePuzzle } from '../../domain/ir/normalize' import { createSlitherPuzzle } from '../../domain/ir/slither' @@ -32,6 +39,7 @@ type SolverStore = { initialPuzzle: PuzzleIR currentPuzzle: PuzzleIR steps: RuleStep[] + traceStatsCache: TraceStatsCache pointer: number highlightedCells: string[] highlightedColorCells: string[] @@ -131,6 +139,7 @@ const getSamplePuzzle = (): PuzzleIR => { } const initialPuzzle = getSamplePuzzle() +const initialTraceStatsCache = createTraceStatsCache(initialPuzzle) export const useSolverStore = create((set, get) => ({ pluginId: 'slitherlink', @@ -138,6 +147,7 @@ export const useSolverStore = create((set, get) => ({ initialPuzzle, currentPuzzle: clonePuzzle(initialPuzzle), steps: [], + traceStatsCache: initialTraceStatsCache, pointer: 0, highlightedCells: [], highlightedColorCells: [], @@ -159,6 +169,7 @@ export const useSolverStore = create((set, get) => ({ initialPuzzle: nextInitial, currentPuzzle: clonePuzzle(nextInitial), steps: [], + traceStatsCache: createTraceStatsCache(nextInitial), pointer: 0, highlightedCells: [], highlightedColorCells: [], @@ -183,7 +194,7 @@ export const useSolverStore = create((set, get) => ({ } }, nextStep: () => { - const { pluginId, currentPuzzle, steps, pointer, terminalReport } = get() + const { pluginId, currentPuzzle, steps, pointer, terminalReport, initialPuzzle, traceStatsCache } = get() if (terminalReport) { return } @@ -209,10 +220,13 @@ export const useSolverStore = create((set, get) => ({ } return } + const baseCache = truncateTraceStatsCache(initialPuzzle, traceStatsCache, steps, pointer) const nextSteps = [...activeSteps, step] + const nextTraceStatsCache = appendTraceStatsStep(baseCache, step) set({ currentPuzzle: nextPuzzle, steps: nextSteps, + traceStatsCache: nextTraceStatsCache, pointer: nextSteps.length, highlightedCells: step.affectedCells, highlightedColorCells: getStepColorCells(step), @@ -289,6 +303,7 @@ export const useSolverStore = create((set, get) => ({ set({ currentPuzzle: clonePuzzle(initialPuzzle), steps: [], + traceStatsCache: rebuildTraceStatsCache(initialPuzzle), pointer: 0, highlightedCells: [], highlightedColorCells: [], diff --git a/src/features/stats/StatsPanel.tsx b/src/features/stats/StatsPanel.tsx index 52771f8..1030e92 100644 --- a/src/features/stats/StatsPanel.tsx +++ b/src/features/stats/StatsPanel.tsx @@ -1,15 +1,12 @@ import { useMemo } from 'react' import { - buildRuleTraceStats, - buildTraceChartStats, + buildTraceStatsView, + type TraceStatsCache, type TraceChartPoint, } from '../../domain/difficulty/traceStats' -import type { PuzzleIR } from '../../domain/ir/types' -import type { RuleStep } from '../../domain/rules/types' type Props = { - initialPuzzle: PuzzleIR - steps: RuleStep[] + traceStatsCache: TraceStatsCache pointer: number isRunning: boolean onGoToStep: (targetPointer: number) => void @@ -42,6 +39,20 @@ const formatStepList = (stepNumbers: number[]): string => { const clampRatio = (value: number): number => Math.min(1, Math.max(0, value)) +const MAX_RENDERED_POINTS = 400 + +const sampleChartValues = (values: number[]): Array<{ index: number; value: number }> => { + if (values.length <= MAX_RENDERED_POINTS) { + return values.map((value, index) => ({ index, value })) + } + const lastIndex = values.length - 1 + const sampled = Array.from({ length: MAX_RENDERED_POINTS }, (_, index) => { + const sourceIndex = Math.round((index / (MAX_RENDERED_POINTS - 1)) * lastIndex) + return { index: sourceIndex, value: values[sourceIndex] } + }) + return sampled.filter((item, index, arr) => index === 0 || item.index !== arr[index - 1].index) +} + const makePath = ( values: number[], width: number, @@ -51,12 +62,15 @@ const makePath = ( if (values.length === 0) { return '' } + const sampled = sampleChartValues(values) const plotWidth = width - padding.left - padding.right const plotHeight = height - padding.top - padding.bottom const xFor = (index: number): number => padding.left + (values.length <= 1 ? 0 : (index / (values.length - 1)) * plotWidth) const yFor = (value: number): number => padding.top + (1 - clampRatio(value)) * plotHeight - return values.map((value, index) => `${index === 0 ? 'M' : 'L'} ${xFor(index)} ${yFor(value)}`).join(' ') + return sampled + .map(({ index, value }, sampledIndex) => `${sampledIndex === 0 ? 'M' : 'L'} ${xFor(index)} ${yFor(value)}`) + .join(' ') } const TraceLineChart = ({ @@ -178,12 +192,8 @@ const getCoverageSeries = (points: TraceChartPoint[]): ChartSeries[] => [ }, ] -export const StatsPanel = ({ initialPuzzle, steps, pointer, isRunning, onGoToStep }: Props) => { - const stats = useMemo(() => buildRuleTraceStats(steps, pointer), [pointer, steps]) - const chartStats = useMemo( - () => buildTraceChartStats(initialPuzzle, steps, pointer), - [initialPuzzle, pointer, steps], - ) +export const StatsPanel = ({ traceStatsCache, pointer, isRunning, onGoToStep }: Props) => { + const stats = useMemo(() => buildTraceStatsView(traceStatsCache, pointer), [pointer, traceStatsCache]) return (
@@ -210,17 +220,17 @@ export const StatsPanel = ({ initialPuzzle, steps, pointer, isRunning, onGoToSte
- Step {chartStats.pointer} of {chartStats.totalSteps} + Step {stats.pointer} of {stats.totalSteps}
onGoToStep(Number(event.target.value))} />
@@ -229,14 +239,14 @@ export const StatsPanel = ({ initialPuzzle, steps, pointer, isRunning, onGoToSte
From c6d1f088f322cf8a041dc5e4362b6d67fe34bbc8 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 14 May 2026 14:50:12 +0800 Subject: [PATCH 08/20] feat: enhance Live Stats with rule usage visualization and improved replay mechanics --- docs/ADDING_PUZZLE_FAMILY_EN.md | 23 +++- docs/PROJECT_GUIDE_EN.md | 18 ++- src/app/WorkspacePage.test.tsx | 70 +++++++++++- src/app/workspace.css | 84 ++++++++++++++ src/features/solver/solverStore.bench.ts | 40 +++++++ src/features/solver/solverStore.test.ts | 53 +++++++++ src/features/solver/solverStore.ts | 134 ++++++++++++++++++++-- src/features/stats/StatsPanel.tsx | 136 +++++++++++++++++------ 8 files changed, 507 insertions(+), 51 deletions(-) diff --git a/docs/ADDING_PUZZLE_FAMILY_EN.md b/docs/ADDING_PUZZLE_FAMILY_EN.md index 37ec281..9ec1f81 100644 --- a/docs/ADDING_PUZZLE_FAMILY_EN.md +++ b/docs/ADDING_PUZZLE_FAMILY_EN.md @@ -17,15 +17,17 @@ Read these before writing code: - `src/domain/ir/types.ts` - shared `PuzzleIR`, cell/edge/sector/vertex state. - `src/domain/ir/keys.ts` - stable keys for cells, edges, sectors, and vertices. - `src/domain/rules/types.ts` - `Rule`, `RuleApplication`, `RuleStep`, and `RuleDiff`. -- `src/domain/rules/engine.ts` - applies rule diffs and rebuilds replay states. -- `src/features/solver/solverStore.ts` - loads puzzles, runs plugin rules, replays steps, and builds terminal reports. +- `src/domain/rules/engine.ts` - applies and reverts rule diffs. +- `src/features/solver/solverStore.ts` - loads puzzles, runs plugin rules, replays steps with checkpoints, and builds terminal reports. - `src/features/editor/editorStore.ts` - current editor state model and Slitherlink editing pattern. - `src/domain/plugins/types.ts` and `registry.ts` - plugin boundary for puzzle families. - `src/domain/benchmark/*` and `dataset/public/*` - dataset and benchmark flow. Replay safety is the central contract. A rule must return explicit diffs; the engine and solver store must be able to apply and undo those diffs without -hidden mutation. +hidden mutation. The solver now uses incremental replay plus periodic +checkpoints, so every puzzle family must keep `RuleDiff` forward and reverse +semantics deterministic. --- @@ -74,7 +76,7 @@ Recommended order: 4. **Solver integration** - Ensure `getRules()` returns the new ordered rules. - - Add replay tests proving `nextStep`, `prevStep`, and `goToStep` rebuild the same state. + - Add replay tests proving `nextStep`, `prevStep`, small `goToStep` moves, and large checkpoint-backed `goToStep` jumps rebuild the same state. - Add terminal/completion analysis when the puzzle has a meaningful solved/stalled report. 5. **Editor and export** @@ -94,6 +96,7 @@ Recommended order: For a puzzle family to feel first-class, decide which of these it owns: - Solver board rendering and highlights. +- Live Stats coverage semantics for its chosen IR fields. - Editor board rendering and input tools. - Puzzle type controls in Solver, Editor, and Dataset pages. - `PuzzleInfoButton` content via plugin `help`. @@ -106,6 +109,12 @@ Prefer plugin-aware shared components when the behavior is generic. Prefer puzzle-specific components when the interaction model is genuinely different from Slitherlink. +Live Stats currently derives board progress and coverage from common IR fields +such as decided edges, filled cells, and narrowed vertices. If a new puzzle uses +different state primitives, either make those primitives fit the shared +coverage model or add a small plugin-aware adapter before presenting the stats +as meaningful. + --- ## 5. Suggested Roadmap @@ -120,7 +129,8 @@ from Slitherlink. - Add a small ordered rule set. - Each rule returns explainable messages and explicit diffs. -- Replay tests prove forward/backward timeline behavior. +- Replay tests prove forward/backward timeline behavior, including timeline jumps. +- Live Stats shows sane active-prefix counts for the generated trace. **Milestone 3: Editor and export** @@ -146,8 +156,9 @@ from Slitherlink. - Do not put puzzle-specific rules into shared solver orchestration. - Do not mutate puzzle state inside a rule; return `RuleDiff`s. -- Do not change diff semantics without updating both engine and replay tests. +- Do not change diff semantics without updating engine behavior, checkpoint replay, and replay tests. - Do not hide non-determinism behind rule ordering or object iteration. +- Do not rely on object identity or hidden mutation for replay or stats; caches may be reused across timeline browsing. - Do not overfit UI to Slitherlink if the next puzzle needs different primitives. - Do not claim full support in docs, dropdowns, or datasets until parse/render/solve basics exist. diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index 679a151..97a2645 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -69,8 +69,8 @@ Design rule: 3. The solver store loads the initial IR and resets replay state. 4. Rule engine runs ordered rules and returns one step at a time. 5. Each step stores rule metadata + explicit diffs. -6. Timeline store replays diffs forward/backward. -7. Board, stats, and explanation panels render current state + reasoning history. +6. Solver replay uses diffs forward/backward, with checkpoints for large timeline jumps. +7. Board, live stats, and explanation panels render current state + reasoning history. This guarantees the same inference chain can be replayed and inspected later. @@ -202,6 +202,17 @@ Both apply the same `RuleDiff` semantics, especially sector mask writes: If these two paths diverge, timeline replay and solver state will drift. +Recent replay updates: + +- `solverStore` keeps replay checkpoints so large `goToStep` jumps do not rebuild from step 0. +- Adjacent timeline movement uses forward/reverse diffs instead of full prefix replay. +- Live Stats uses the trace stats cache for step-prefix summaries, chart progress, and rule usage. +- Default Rule Usage is lightweight; full rule step lists render only when details are opened. + +When editing replay, preserve checkpoint correctness after reset, import, and branch truncation. +When editing Live Stats, keep chart series aligned with the trace cache object, not only nested +array references that may be mutated during cache append. + --- ## 10. Current Capability Snapshot @@ -217,8 +228,9 @@ Implemented: - Plugin-powered rule help, board legend, and compact board-title puzzle stats - Slitherlink board stats for numeric clue count and 0/1/2/3 clue distribution - Ordered rule execution with step metadata -- Step replay (`Next`, `Previous`, `Solve to End`) +- Step replay (`Next`, `Previous`, timeline jumps, `Solve to End`) with checkpoint-assisted large jumps - Explanation-oriented deduction trace +- Live Stats summary charts for board progress, coverage, and rule usage over the active replay prefix - Sector mask inference/propagation pipeline - Strong-inference fallback for harder states - Slitherlink completion analysis for solved/stalled terminal reports diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index f910433..392de91 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -303,6 +303,71 @@ describe('WorkspacePage', () => { expect(screen.getByText(/showing 1 \/ 1/i)).toBeInTheDocument() }) + it('updates live stats chart legends as steps are generated and replayed', () => { + const firstEdge = edgeKey([0, 0], [0, 1]) + const secondEdge = edgeKey([0, 1], [0, 2]) + const steps: RuleStep[] = [ + { + id: 'step-1', + ruleId: 'rule-a', + ruleName: 'Rule A', + message: 'first', + diffs: [{ kind: 'edge', edgeKey: firstEdge, from: 'unknown', to: 'line' }], + affectedCells: [], + affectedEdges: [firstEdge], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 1, + }, + { + id: 'step-2', + ruleId: 'rule-b', + ruleName: 'Rule B', + message: 'second', + diffs: [ + { kind: 'edge', edgeKey: secondEdge, from: 'unknown', to: 'blank' }, + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ], + affectedCells: [cellKey(0, 0)], + affectedEdges: [secondEdge], + affectedSectors: [], + timestamp: Date.now() + 1, + durationMs: 1, + }, + ] + const puzzle = createSlitherPuzzle(1, 2) + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'slitherlink', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), + pointer: 2, + highlightedCells: [], + highlightedColorCells: [], + highlightedEdges: [], + solveProgress: null, + terminalReport: null, + isRunning: false, + })) + + renderWorkspace() + + const liveStats = screen.getByLabelText(/live stats/i) + const progressChart = within(liveStats).getByLabelText(/^board progress$/i) + const coverageChart = within(liveStats).getByLabelText(/^inference coverage$/i) + expect(within(progressChart).getByText(/progress 28\.6%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/edge 28\.6%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell 50\.0%/i)).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/trace timeline/i), { target: { value: '1' } }) + + expect(within(progressChart).getByText(/progress 14\.3%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/edge 14\.3%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell 0\.0%/i)).toBeInTheDocument() + }) + it('shows the optimized live stats summary and charts', () => { renderWorkspace() @@ -372,8 +437,11 @@ describe('WorkspacePage', () => { const liveStats = screen.getByLabelText(/live stats/i) expect(within(liveStats).getByText('Rule A')).toBeInTheDocument() - expect(within(liveStats).getByText('Rule B')).toBeInTheDocument() + expect(within(liveStats).queryByText('Rule B')).not.toBeInTheDocument() + fireEvent.click(within(liveStats).getByRole('button', { name: /view details/i })) + + expect(within(liveStats).getByText('Rule B')).toBeInTheDocument() const ruleBRow = within(liveStats).getByText('Rule B').closest('tr') expect(ruleBRow).not.toBeNull() expect(within(ruleBRow as HTMLElement).getAllByText('0')).toHaveLength(1) diff --git a/src/app/workspace.css b/src/app/workspace.css index 142f3a1..14469d3 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -1186,11 +1186,78 @@ button[data-active='true'] { font-size: 0.8rem; } +.rule-usage-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + .rule-usage-empty { margin: 0; } +.rule-usage-bars { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-usage-bar-row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 2fr) minmax(116px, auto); + gap: 8px; + align-items: center; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px; + background: #ffffff; +} + +.rule-usage-bar-meta { + min-width: 0; +} + +.rule-usage-bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.rule-usage-bar-track span { + display: block; + height: 100%; + border-radius: inherit; + background: #0891b2; +} + +.rule-usage-bar-values { + display: grid; + grid-template-columns: repeat(3, auto); + gap: 8px; + justify-content: end; + color: #64748b; + font-size: 0.76rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.rule-usage-bar-values strong { + color: #0f172a; +} + +.rule-step-summary { + display: block; + margin-top: 2px; + color: #64748b; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + .rule-usage-table-wrap { + margin-top: 10px; overflow-x: auto; border: 1px solid #e5e7eb; border-radius: 8px; @@ -1250,6 +1317,23 @@ button[data-active='true'] { .stats-chart-grid { grid-template-columns: 1fr; } + + .rule-usage-header { + align-items: flex-start; + flex-direction: column; + } + + .rule-usage-actions { + justify-content: flex-start; + } + + .rule-usage-bar-row { + grid-template-columns: 1fr; + } + + .rule-usage-bar-values { + justify-content: start; + } } .editor-board-card { diff --git a/src/features/solver/solverStore.bench.ts b/src/features/solver/solverStore.bench.ts index 8bb9db7..9130fcb 100644 --- a/src/features/solver/solverStore.bench.ts +++ b/src/features/solver/solverStore.bench.ts @@ -3,6 +3,7 @@ import { decodeSlitherFromPuzzlink } from '../../domain/parsers/puzzlink' import { buildPuzzleFromSteps, rewindPuzzleByStep, runNextRule } from '../../domain/rules/engine' import { slitherRules } from '../../domain/rules/slither/rules' import type { RuleStep } from '../../domain/rules/types' +import { createSlitherPuzzle } from '../../domain/ir/slither' const SAMPLE_URL = 'https://puzz.link/p?slither/18/10/i61ch28cg16dg122cg63bi3ah1di2dcg0bgb1bc6c8bchd8b6cd1cbg2cgb3ci1dh3ci18dg132bg72bg82bh36dg' @@ -28,6 +29,31 @@ const pointer = steps.length const currentPuzzle = buildPuzzleFromSteps(initialPuzzle, steps, pointer) const stepToUndo = steps[pointer - 1] +const synthetic60 = createSlitherPuzzle(60, 60) +const syntheticEdgeKeys = Object.keys(synthetic60.edges) +const syntheticSteps: RuleStep[] = Array.from({ length: 2000 }, (_, index) => { + const edge = syntheticEdgeKeys[index] + return { + id: `synthetic-step-${index + 1}`, + ruleId: `synthetic-rule-${index % 24}`, + ruleName: `Synthetic Rule ${index % 24}`, + message: `synthetic step ${index + 1}`, + diffs: [ + { + kind: 'edge', + edgeKey: edge, + from: 'unknown', + to: index % 2 === 0 ? 'line' : 'blank', + }, + ], + affectedCells: [], + affectedEdges: [edge], + affectedSectors: [], + timestamp: index, + durationMs: 1, + } +}) + describe('solver prev-step benchmark', () => { bench('rebuild prefix from initial puzzle', () => { buildPuzzleFromSteps(initialPuzzle, steps, pointer - 1) @@ -37,3 +63,17 @@ describe('solver prev-step benchmark', () => { rewindPuzzleByStep(currentPuzzle, stepToUndo) }) }) + +describe('60x60 replay benchmark', () => { + bench('full rebuild to step 500', () => { + buildPuzzleFromSteps(synthetic60, syntheticSteps, 500) + }) + + bench('full rebuild to step 1000', () => { + buildPuzzleFromSteps(synthetic60, syntheticSteps, 1000) + }) + + bench('full rebuild to step 2000', () => { + buildPuzzleFromSteps(synthetic60, syntheticSteps, 2000) + }) +}) diff --git a/src/features/solver/solverStore.test.ts b/src/features/solver/solverStore.test.ts index 8f1a084..3ba520b 100644 --- a/src/features/solver/solverStore.test.ts +++ b/src/features/solver/solverStore.test.ts @@ -8,6 +8,7 @@ import { buildPuzzleFromSteps } from '../../domain/rules/engine' import { DEFAULT_SOLVE_CHUNK_SIZE, MAX_SOLVE_CHUNK_SIZE, + REPLAY_CHECKPOINT_INTERVAL, sumRuleStepDurationMs, useSolverStore, type TerminalSolveReport, @@ -29,6 +30,29 @@ const createSolvedLoopPuzzle = (): PuzzleIR => { return puzzle } +const makeEdgeSteps = (puzzle: PuzzleIR, count: number): RuleStep[] => + Object.keys(puzzle.edges) + .slice(0, count) + .map((edge, index) => ({ + id: `step-${index + 1}`, + ruleId: `test-rule-${index % 3}`, + ruleName: `Test Rule ${index % 3}`, + message: `step ${index + 1}`, + diffs: [ + { + kind: 'edge' as const, + edgeKey: edge, + from: 'unknown' as const, + to: index % 2 === 0 ? ('line' as const) : ('blank' as const), + }, + ], + affectedCells: [], + affectedEdges: [edge], + affectedSectors: [], + timestamp: Date.now() + index, + durationMs: 1, + })) + const mockTerminalReport: TerminalSolveReport = { status: 'stalled', stepCount: 0, @@ -224,6 +248,35 @@ describe('solver timeline behavior', () => { expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 1).current.edgeCoverageRatio).toBe(0.25) }) + + it('jumps across a large replay trace and matches a full rebuild', () => { + const initialPuzzle = createSlitherPuzzle(1, REPLAY_CHECKPOINT_INTERVAL * 3) + const steps = makeEdgeSteps(initialPuzzle, REPLAY_CHECKPOINT_INTERVAL * 2 + 7) + useSolverStore.setState((state) => ({ + ...state, + initialPuzzle, + currentPuzzle: buildPuzzleFromSteps(initialPuzzle, steps, steps.length), + steps, + traceStatsCache: rebuildTraceStatsCache(initialPuzzle, steps), + pointer: steps.length, + highlightedCells: [], + highlightedColorCells: [], + highlightedEdges: [], + terminalReport: mockTerminalReport, + isRunning: false, + })) + + useSolverStore.getState().goToStep(REPLAY_CHECKPOINT_INTERVAL + 3) + const middle = useSolverStore.getState() + expect(semanticEquals(middle.currentPuzzle, buildPuzzleFromSteps(initialPuzzle, steps, middle.pointer))).toBe(true) + expect(middle.pointer).toBe(REPLAY_CHECKPOINT_INTERVAL + 3) + expect(middle.terminalReport).toBeNull() + + useSolverStore.getState().goToStep(steps.length) + const end = useSolverStore.getState() + expect(semanticEquals(end.currentPuzzle, buildPuzzleFromSteps(initialPuzzle, steps, steps.length))).toBe(true) + }) + }) describe('solve chunk sizing', () => { diff --git a/src/features/solver/solverStore.ts b/src/features/solver/solverStore.ts index 2a62006..4527e12 100644 --- a/src/features/solver/solverStore.ts +++ b/src/features/solver/solverStore.ts @@ -11,7 +11,7 @@ import { clonePuzzle } from '../../domain/ir/normalize' import { createSlitherPuzzle } from '../../domain/ir/slither' import type { PuzzleIR } from '../../domain/ir/types' import { puzzleRegistry } from '../../domain/plugins/registry' -import { buildPuzzleFromSteps, rewindPuzzleByStep, runNextRule } from '../../domain/rules/engine' +import { applyRuleDiffs, rewindPuzzleByStep, runNextRule } from '../../domain/rules/engine' import { analyzeSlitherCompletion, type SlitherCompletionReport, @@ -21,6 +21,7 @@ import type { RuleStep } from '../../domain/rules/types' const SAMPLE_URL = 'https://puzz.link/p?slither/18/10/c82chcdgcbgd63c173ah6aibi81b71cdjcdcb123ddbcbjb37d16didi8dh161c36cdgcagdbh28bb' export const DEFAULT_SOLVE_CHUNK_SIZE = 50 export const MAX_SOLVE_CHUNK_SIZE = 1000 +export const REPLAY_CHECKPOINT_INTERVAL = 50 export type TerminalSolveReport = SlitherCompletionReport & { stepCount: number @@ -32,6 +33,11 @@ export type SolveProgress = { total: number } +type ReplayCheckpoint = { + pointer: number + puzzle: PuzzleIR +} + type SolverStore = { pluginId: string sourceUrl: string @@ -39,6 +45,7 @@ type SolverStore = { initialPuzzle: PuzzleIR currentPuzzle: PuzzleIR steps: RuleStep[] + replayCheckpoints: ReplayCheckpoint[] traceStatsCache: TraceStatsCache pointer: number highlightedCells: string[] @@ -67,8 +74,91 @@ export type LoadPuzzleOptions = { sourceUrl?: string } -const buildStateFromSteps = (initialPuzzle: PuzzleIR, steps: RuleStep[], pointer: number): PuzzleIR => { - return buildPuzzleFromSteps(initialPuzzle, steps, pointer) +const createInitialReplayCheckpoints = (initialPuzzle: PuzzleIR): ReplayCheckpoint[] => [ + { pointer: 0, puzzle: initialPuzzle }, +] + +const trimReplayCheckpoints = (checkpoints: ReplayCheckpoint[], pointer: number): ReplayCheckpoint[] => + checkpoints.filter((checkpoint) => checkpoint.pointer <= pointer) + +const getValidReplayCheckpoints = ( + initialPuzzle: PuzzleIR, + checkpoints: ReplayCheckpoint[] | undefined, +): ReplayCheckpoint[] => { + if (!checkpoints?.length || checkpoints[0]?.puzzle !== initialPuzzle) { + return createInitialReplayCheckpoints(initialPuzzle) + } + return checkpoints +} + +const addReplayCheckpoint = ( + checkpoints: ReplayCheckpoint[], + pointer: number, + puzzle: PuzzleIR, +): ReplayCheckpoint[] => { + if (pointer <= 0 || pointer % REPLAY_CHECKPOINT_INTERVAL !== 0) { + return checkpoints + } + if (checkpoints.some((checkpoint) => checkpoint.pointer === pointer)) { + return checkpoints + } + return [...checkpoints, { pointer, puzzle }] +} + +const applyStepsForward = ( + puzzle: PuzzleIR, + steps: RuleStep[], + fromPointer: number, + toPointer: number, +): PuzzleIR => { + let next = puzzle + for (let index = fromPointer; index < toPointer; index += 1) { + next = applyRuleDiffs(next, steps[index].diffs) + } + return next +} + +const rewindStepsBackward = ( + puzzle: PuzzleIR, + steps: RuleStep[], + fromPointer: number, + toPointer: number, +): PuzzleIR => { + let next = puzzle + for (let index = fromPointer - 1; index >= toPointer; index -= 1) { + next = rewindPuzzleByStep(next, steps[index]) + } + return next +} + +const buildStateFromReplayCache = ( + initialPuzzle: PuzzleIR, + currentPuzzle: PuzzleIR, + steps: RuleStep[], + currentPointer: number, + targetPointer: number, + checkpoints: ReplayCheckpoint[] | undefined, +): PuzzleIR => { + if (targetPointer === currentPointer) { + return currentPuzzle + } + + const currentDistance = Math.abs(targetPointer - currentPointer) + if (currentDistance <= REPLAY_CHECKPOINT_INTERVAL) { + return targetPointer > currentPointer + ? applyStepsForward(currentPuzzle, steps, currentPointer, targetPointer) + : rewindStepsBackward(currentPuzzle, steps, currentPointer, targetPointer) + } + + const availableCheckpoints = getValidReplayCheckpoints(initialPuzzle, checkpoints) + const checkpoint = availableCheckpoints + .filter((item) => item.pointer <= targetPointer) + .reduce( + (best, item) => (item.pointer > best.pointer ? item : best), + availableCheckpoints[0] ?? { pointer: 0, puzzle: initialPuzzle }, + ) + + return applyStepsForward(checkpoint.puzzle, steps, checkpoint.pointer, targetPointer) } const getActiveSteps = (steps: RuleStep[], pointer: number): RuleStep[] => steps.slice(0, pointer) @@ -140,6 +230,7 @@ const getSamplePuzzle = (): PuzzleIR => { const initialPuzzle = getSamplePuzzle() const initialTraceStatsCache = createTraceStatsCache(initialPuzzle) +const initialReplayCheckpoints = createInitialReplayCheckpoints(initialPuzzle) export const useSolverStore = create((set, get) => ({ pluginId: 'slitherlink', @@ -147,6 +238,7 @@ export const useSolverStore = create((set, get) => ({ initialPuzzle, currentPuzzle: clonePuzzle(initialPuzzle), steps: [], + replayCheckpoints: initialReplayCheckpoints, traceStatsCache: initialTraceStatsCache, pointer: 0, highlightedCells: [], @@ -169,6 +261,7 @@ export const useSolverStore = create((set, get) => ({ initialPuzzle: nextInitial, currentPuzzle: clonePuzzle(nextInitial), steps: [], + replayCheckpoints: createInitialReplayCheckpoints(nextInitial), traceStatsCache: createTraceStatsCache(nextInitial), pointer: 0, highlightedCells: [], @@ -194,7 +287,16 @@ export const useSolverStore = create((set, get) => ({ } }, nextStep: () => { - const { pluginId, currentPuzzle, steps, pointer, terminalReport, initialPuzzle, traceStatsCache } = get() + const { + pluginId, + currentPuzzle, + steps, + pointer, + terminalReport, + initialPuzzle, + traceStatsCache, + replayCheckpoints, + } = get() if (terminalReport) { return } @@ -221,11 +323,17 @@ export const useSolverStore = create((set, get) => ({ return } const baseCache = truncateTraceStatsCache(initialPuzzle, traceStatsCache, steps, pointer) + const validReplayCheckpoints = getValidReplayCheckpoints(initialPuzzle, replayCheckpoints) const nextSteps = [...activeSteps, step] const nextTraceStatsCache = appendTraceStatsStep(baseCache, step) set({ currentPuzzle: nextPuzzle, steps: nextSteps, + replayCheckpoints: addReplayCheckpoint( + trimReplayCheckpoints(validReplayCheckpoints, pointer), + nextSteps.length, + nextPuzzle, + ), traceStatsCache: nextTraceStatsCache, pointer: nextSteps.length, highlightedCells: step.affectedCells, @@ -235,15 +343,13 @@ export const useSolverStore = create((set, get) => ({ }) }, prevStep: () => { - const { initialPuzzle, currentPuzzle, steps, pointer } = get() + const { currentPuzzle, steps, pointer } = get() if (pointer === 0) { return } const stepToUndo = steps[pointer - 1] const nextPointer = pointer - 1 - const currentPuzzleAfterUndo = stepToUndo - ? rewindPuzzleByStep(currentPuzzle, stepToUndo) - : buildStateFromSteps(initialPuzzle, steps, nextPointer) + const currentPuzzleAfterUndo = rewindPuzzleByStep(currentPuzzle, stepToUndo) const currentStep = steps[nextPointer - 1] set({ currentPuzzle: currentPuzzleAfterUndo, @@ -255,14 +361,21 @@ export const useSolverStore = create((set, get) => ({ }) }, goToStep: (targetPointer) => { - const { initialPuzzle, steps, isRunning } = get() + const { initialPuzzle, currentPuzzle, steps, pointer, isRunning, replayCheckpoints } = get() if (isRunning) { return } const nextPointer = clampPointer(targetPointer, steps.length) const currentStep = steps[nextPointer - 1] set({ - currentPuzzle: buildStateFromSteps(initialPuzzle, steps, nextPointer), + currentPuzzle: buildStateFromReplayCache( + initialPuzzle, + currentPuzzle, + steps, + pointer, + nextPointer, + replayCheckpoints, + ), pointer: nextPointer, highlightedCells: currentStep?.affectedCells ?? [], highlightedColorCells: getStepColorCells(currentStep), @@ -303,6 +416,7 @@ export const useSolverStore = create((set, get) => ({ set({ currentPuzzle: clonePuzzle(initialPuzzle), steps: [], + replayCheckpoints: createInitialReplayCheckpoints(initialPuzzle), traceStatsCache: rebuildTraceStatsCache(initialPuzzle), pointer: 0, highlightedCells: [], diff --git a/src/features/stats/StatsPanel.tsx b/src/features/stats/StatsPanel.tsx index 1030e92..b45d62f 100644 --- a/src/features/stats/StatsPanel.tsx +++ b/src/features/stats/StatsPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { buildTraceStatsView, type TraceStatsCache, @@ -37,6 +37,17 @@ const formatStepList = (stepNumbers: number[]): string => { return `${stepNumbers.slice(0, 8).join(', ')} +${stepNumbers.length - 8}` } +const formatStepSummary = (stepNumbers: number[]): string => { + if (stepNumbers.length === 0) { + return 'No active steps' + } + const first = stepNumbers[0] + const last = stepNumbers[stepNumbers.length - 1] + return first === last + ? `Step ${first}` + : `${stepNumbers.length} hits, steps ${first}-${last}` +} + const clampRatio = (value: number): number => Math.min(1, Math.max(0, value)) const MAX_RENDERED_POINTS = 400 @@ -192,8 +203,56 @@ const getCoverageSeries = (points: TraceChartPoint[]): ChartSeries[] => [ }, ] +const MAX_RULE_BARS = 8 + +const RuleUsageBars = ({ rules }: { rules: ReturnType['rules'] }) => { + const activeRules = useMemo( + () => + rules + .filter((rule) => rule.count > 0) + .sort((a, b) => b.count - a.count || b.durationMs - a.durationMs || a.ruleName.localeCompare(b.ruleName)) + .slice(0, MAX_RULE_BARS), + [rules], + ) + const maxCount = Math.max(1, ...activeRules.map((rule) => rule.count)) + + if (activeRules.length === 0) { + return

No rules have fired in the active prefix.

+ } + + return ( +
+ {activeRules.map((rule) => { + const width = `${Math.max(4, (rule.count / maxCount) * 100)}%` + return ( +
+
+ {rule.ruleName} + {formatStepSummary(rule.steps)} +
+ +
+ {rule.count} + {formatPercent(rule.percent)} + {formatDuration(rule.durationMs)} +
+
+ ) + })} +
+ ) +} + export const StatsPanel = ({ traceStatsCache, pointer, isRunning, onGoToStep }: Props) => { const stats = useMemo(() => buildTraceStatsView(traceStatsCache, pointer), [pointer, traceStatsCache]) + const [showRuleDetails, setShowRuleDetails] = useState(false) + const boardProgressSeries = useMemo( + () => getBoardProgressSeries(traceStatsCache.points), + [traceStatsCache], + ) + const coverageSeries = useMemo(() => getCoverageSeries(traceStatsCache.points), [traceStatsCache]) return (
@@ -239,13 +298,13 @@ export const StatsPanel = ({ traceStatsCache, pointer, isRunning, onGoToStep }:
@@ -253,38 +312,53 @@ export const StatsPanel = ({ traceStatsCache, pointer, isRunning, onGoToStep }:

Rule Usage

- {stats.rules.length} rules in generated trace +
+ {stats.rules.length} rules in generated trace + +
{stats.rules.length === 0 ? (

No generated steps yet.

) : ( -
- - - - - - - - - - - - {stats.rules.map((rule) => ( - - - - - - - - ))} - -
RuleCountShareTimeSteps
- {rule.ruleName} - {rule.ruleId} - {rule.count}{formatPercent(rule.percent)}{formatDuration(rule.durationMs)}{formatStepList(rule.steps)}
-
+ <> + + {showRuleDetails ? ( +
+ + + + + + + + + + + + {stats.rules.map((rule) => ( + + + + + + + + ))} + +
RuleCountShareTimeSteps
+ {rule.ruleName} + {rule.ruleId} + {rule.count}{formatPercent(rule.percent)}{formatDuration(rule.durationMs)}{formatStepList(rule.steps)}
+
+ ) : null} + )}
From efb3a0dec4fc32660585c0ad7953098bfd5c8743 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 16 May 2026 12:30:46 +0800 Subject: [PATCH 09/20] feat: add Masyu puzzle support with import, rendering, and initial rule framework --- docs/MASYU_ASSIST_STRATEGIES_CN.md | 545 ++++++++++++++++++ docs/MASYU_CHANGELOG.md | 113 ++++ docs/PROJECT_GUIDE_EN.md | 392 ++++++------- docs/techniques/masyu.md | 46 ++ docs/techniques/slitherlink.md | 39 ++ src/app/WorkspacePage.tsx | 4 +- src/domain/difficulty/traceStats.test.ts | 19 +- src/domain/difficulty/traceStats.ts | 36 +- src/domain/ir/keys.ts | 33 ++ src/domain/ir/masyu.test.ts | 20 + src/domain/ir/masyu.ts | 29 + src/domain/ir/normalize.ts | 26 +- src/domain/ir/types.ts | 15 + src/domain/parsers/puzzlink/index.ts | 6 + .../parsers/puzzlink/masyuPuzzlink.test.ts | 39 ++ src/domain/parsers/puzzlink/masyuPuzzlink.ts | 113 ++++ src/domain/plugins/masyuPlugin.ts | 106 +++- src/domain/rules/engine.bench.ts | 8 + src/domain/rules/engine.test.ts | 21 + src/domain/rules/engine.ts | 14 + src/domain/rules/types.ts | 13 +- src/features/board/CanvasBoard.tsx | 200 ++++--- src/features/solver/ControlPanel.tsx | 2 +- src/features/solver/solverStore.ts | 10 +- 24 files changed, 1535 insertions(+), 314 deletions(-) create mode 100644 docs/MASYU_ASSIST_STRATEGIES_CN.md create mode 100644 docs/MASYU_CHANGELOG.md create mode 100644 docs/techniques/masyu.md create mode 100644 docs/techniques/slitherlink.md create mode 100644 src/domain/ir/masyu.test.ts create mode 100644 src/domain/ir/masyu.ts create mode 100644 src/domain/parsers/puzzlink/masyuPuzzlink.test.ts create mode 100644 src/domain/parsers/puzzlink/masyuPuzzlink.ts diff --git a/docs/MASYU_ASSIST_STRATEGIES_CN.md b/docs/MASYU_ASSIST_STRATEGIES_CN.md new file mode 100644 index 0000000..4c2edee --- /dev/null +++ b/docs/MASYU_ASSIST_STRATEGIES_CN.md @@ -0,0 +1,545 @@ +# Masyu Assist 求解策略调研 + +本文整理油猴脚本 `Puzzlink_Assistance.js` 中 `MasyuAssist()` 的求解框架与推理策略。目标不是复刻脚本实现细节,而是把作者编码进去的 Masyu 技巧拆成未来“分步可解释谜题求解器”可以吸收的规则。 + +本文主要依据: + +- `MasyuAssist()`:`Puzzlink_Assistance.js` 约 `13001-13132` +- 通用单环框架 `SingleLoopInCell()`:约 `1577-1907` +- 基础读写操作 `isLine`、`isCross`、`add_line`、`add_cross`、`add_inout`、`offset`:约 `550-812` + +## 1. 谜题与脚本中的基础模型 + +Masyu 的目标是在方格中心连出一条单一闭合回路。回路必须穿过所有珠子,并满足两类珠子的局部约束: + +- 白珠 `○`:线必须在白珠格中直行,并且至少在白珠前后相邻的一侧转弯。 +- 黑珠 `●`:线必须在黑珠格中转弯,并且从黑珠出来的两个方向都要继续直行至少一格。 + +脚本内部把棋盘对象分成几类: + +- `cell`:格子,Masyu 的白珠、黑珠都在格子上。 +- `border`:两个相邻格子之间的边,是回路线段或禁线的载体。 +- `cross`:格点,也就是四条 `border` 的交点。脚本用它保存内外染色辅助信息。 + +几个关键谓词与操作: + +- `isWhite(c)`:格子 `c` 是白珠,即 `c.qnum === CQNUM.wcir`。 +- `isBlack(c)`:格子 `c` 是黑珠,即 `c.qnum === CQNUM.bcir`。 +- `isLine(b)`:边 `b` 已经确定为回路线段。 +- `isCross(b)`:边 `b` 已经确定为不能走线。 +- `isPathable(b)`:边 `b` 存在且尚未被禁线。 +- `add_line(b)`:把边 `b` 标成回路线段。 +- `add_cross(b)`:把边 `b` 标成禁线。 +- `add_inout(cr, qsub)`:给格点 `cr` 标记内外状态,用来推断线段与禁线。 + +`offset(cell, dx, dy, d)` 是阅读这段代码的关键。它表示从某个对象出发,在相对坐标 `(dx, dy)` 上取另一个对象,并按方向 `d` 旋转。作者大量使用 `d = 0..3` 枚举四个旋转方向,于是同一条规则只写一次,就能覆盖上下左右和镜像等价情况。 + +常见相对坐标含义如下: + +- `offset(cell, .5, 0, d)`:从格子中心向方向 `d` 走半格,得到该侧的边。 +- `offset(cell, 1, 0, d)`:方向 `d` 上相邻一格。 +- `offset(cell, 1.5, 0, d)`:越过相邻格后,再往前的下一条边。 +- `offset(cell, 0, .5, d)`:与方向 `d` 垂直的一侧边。 +- `offset(cell, .5, .5, d)`:格子斜角上的格点。 + +## 2. 总体求解流程 + +`MasyuAssist()` 的结构很短,但包含两层推理: + +```javascript +function MasyuAssist() { + SingleLoopInCell({ + isPass: c => c.qnum !== CQNUM.none, + }); + // 定义 isBlack / isWhite / isPathable + forEachCell(cell => { + for (let d = 0; d < 4; d++) { + // Masyu 专用局部规则 + } + }); +} +``` + +第一层是通用单环框架:`SingleLoopInCell({ isPass: c => c.qnum !== CQNUM.none })`。这里的含义是“所有有珠子的格子都必须被回路经过”。它不关心白珠和黑珠的差异,只负责单环类谜题的基础拓扑约束。 + +第二层是 Masyu 专用规则:遍历每个格子,并对四个方向重复检查局部图形。它根据白珠直行、黑珠转弯、相邻珠子互相限制、已有线段和禁线、以及当前连通分量状态,继续添加确定线和禁线。 + +整个脚本外层由 `assist()` 固定点迭代驱动:只要某一轮有新推理,就继续执行规则,直到没有新变化、达到上限或单步模式中断。因此 `MasyuAssist()` 本身不是搜索,也不枚举假设;它只做确定性规则传播。 + +## 3. 通用单环框架:`SingleLoopInCell()` + +Masyu 继承了单环谜题的共同约束。作者先用通用函数处理这些基础逻辑,避免在 Masyu 规则里重复编码。 + +### 3.1 必经格与不可达边 + +触发条件:某格是 `isPass(cell)`,也就是 Masyu 中的任意珠子。 + +推出结论:这个格子最终必须有两条回路线段经过。若某个方向通向不可走的边或不可通行的相邻格,则该方向禁线。 + +直觉解释:所有珠子都必须在唯一回路上。回路进入一个普通格后必须离开,不能只进入不离开。 + +可内化规则名:`珠子必须被回路经过`、`不可通方向禁线`。 + +### 3.2 无分叉与无死端 + +触发条件: + +- 一个格子已经有两条线段。 +- 或一个格子可用出口不足。 +- 或一个必须经过的格子只有两个可能出口。 + +推出结论: + +- 已有两条线段的格子,其余方向全部禁线。 +- 若某格可用出口不超过一个,则它不可能被回路经过,相关方向禁线。 +- 若必须经过的格子只剩两个可用出口,则这两个出口都成线。 + +直觉解释:单条回路在每个经过的格子度数必须为 2,不能分叉成 3 条线,也不能形成度数为 1 的死端。 + +可内化规则名:`度数为二`、`无死端`、`双出口强制成线`。 + +### 3.3 防止提前闭合小环 + +触发条件:某条候选边会把同一条已存路径的两个端点连起来,但棋盘上仍有未经过的必经珠子,或当前线图还存在多个分量。 + +推出结论:这条候选边禁线。 + +直觉解释:Masyu 需要一条经过所有珠子的单一闭环。若提前形成一个局部小环,剩下的珠子就无法被同一条回路覆盖。 + +可内化规则名:`禁止提前闭环`、`同路径端点不可相连`。 + +### 3.4 连通性桥规则 + +触发条件:在当前候选图中,某条边是连接必须经过区域的桥;如果不选择它,会把已经存在或必须存在的线段分到不同连通块中。 + +推出结论:该桥边成线。 + +直觉解释:所有线段最终必须属于同一条环。某些位置虽然局部看不出黑白珠形状,但从全局连通性看是必须保留的通道。 + +可内化规则名:`单环连通桥成线`。 + +### 3.5 内外染色 + +脚本给 `cross` 标记 `CRQSUB.in` / `CRQSUB.out`,用格点内外关系辅助推断边: + +- 相邻两个格点内外相同:中间不能穿过回路线,边禁线。 +- 相邻两个格点内外不同:中间必须有回路线,边成线。 +- 若一条边已成线,则线两侧格点的内外状态相反。 +- 若一条边是禁线,则两侧格点的内外状态相同。 + +这相当于把闭合曲线的内外区域当作二染色问题。对于单一闭合曲线,穿过曲线时内外翻转,不穿过曲线时内外保持一致。 + +可内化规则名:`内外异色成线`、`内外同色禁线`、`线段翻转内外`、`禁线保持内外`。 + +## 4. Masyu 专用规则总览 + +`MasyuAssist()` 在通用单环之后,主要添加以下几类规则: + +- 从白珠的直行性质推出线段和禁线。 +- 从黑珠的转弯性质推出线段和禁线。 +- 利用黑白珠相邻、连续白珠、对称白珠等局部形状做模式推理。 +- 利用已有线段、禁线、边界、相邻珠子造成的“某方向不可能”来确定另一个方向。 +- 利用 `path` 和 `linegraph.components` 防止小环,或在全局连通性要求下强制连接。 + +下面按规则簇解释。 + +## 5. 白珠规则 + +### 5.1 白珠一侧已有线或一侧被堵时,强制直行 + +代码形态: + +```javascript +if (isWhite(cell) && (isLine(offset(cell, -.5, 0, d)) || !isPathable(offset(cell, 0, -.5, d)))) { + add_line(offset(cell, -.5, 0, d)); + add_line(offset(cell, +.5, 0, d)); + add_cross(offset(cell, 0, -.5, d)); + add_cross(offset(cell, 0, +.5, d)); +} +``` + +触发条件:当前格是白珠,并且某个直行方向的一侧已经有线;或者垂直方向某一侧已不可走。 + +推出结论:白珠必须沿当前轴线直行,两侧直行边成线;垂直两侧禁线。 + +直觉解释:白珠不能转弯,所以一旦知道它不能走某个垂直方向,或已经沿某个轴线进入,就能确定它只能在同一轴线上通过。 + +可内化规则名:`白珠被迫直行`。 + +### 5.2 白珠连续直行两格后,远端禁线 + +代码形态: + +```javascript +if (isWhite(cell) && isLine(offset(cell, -.5, 0, d)) && isLine(offset(cell, -1.5, 0, d))) { + add_cross(offset(cell, 1.5, 0, d)); +} +``` + +触发条件:白珠一侧已经连续出现两段直线。 + +推出结论:白珠另一侧再往外一格的延长边禁线。 + +直觉解释:白珠处必须直行,但它的前后相邻格至少一边要转弯。如果一侧已经没有在相邻格转弯,而是继续直行,那么另一侧必须承担转弯条件,不能也继续直行。 + +可内化规则名:`白珠至少一侧转弯`。 + +### 5.3 两端都不能承担白珠直行轴时,白珠改走垂直轴 + +代码中较长的白珠条件检查了当前白珠左右两侧是否都存在迫使当前轴线不可行的因素: + +- 远处已有线会让白珠相邻格无法转弯。 +- 相邻格也是白珠,会导致连续白珠互相要求直行。 +- 相邻方向上下两侧均不能走线,或被黑珠结构阻断。 + +触发条件:对白珠而言,当前轴线两端都不能作为合法直行穿过方向。 + +推出结论:当前轴线两侧禁线,垂直轴两侧成线。 + +直觉解释:白珠必须二选一直行。如果横向无法满足白珠规则,就只能纵向直行。 + +可内化规则名:`白珠轴线排除`、`白珠改走另一轴`。 + +### 5.4 白珠参与内外染色传播 + +代码开头: + +```javascript +if (isWhite(cell) && offset(cell, .5, .5, d).qsub !== CRQSUB.none) { + add_inout(offset(cell, -.5, -.5, d), offset(cell, .5, .5, d).qsub ^ 1); +} +``` + +触发条件:白珠某个斜角格点已经有内外标记。 + +推出结论:对白珠对角的另一个格点标记相反内外。 + +直觉解释:白珠处的线必须直行。对角格点之间的关系可以由直线穿过白珠附近的局部结构确定,作者用它加速单环内外染色传播。 + +可内化规则名:`白珠对角内外翻转`。 + +## 6. 黑珠规则 + +### 6.1 黑珠某方向不能作为出口时,反向出口成线 + +代码形态: + +```javascript +if (isBlack(cell) && (isLine(offset(cell, -.5, 0, d)) || !isPathable(offset(cell, .5, 0, d)) || + isLine(offset(cell, 1, -.5, d)) || isLine(offset(cell, 1, .5, d)) || + isBlack(offset(cell, 1, 0, d)) || !isPathable(offset(cell, 1.5, 0, d)))) { + add_cross(offset(cell, .5, 0, d)); + add_line(offset(cell, -.5, 0, d)); +} +``` + +触发条件:当前格是黑珠,并且方向 `d` 的出口不可能合法。原因可以是: + +- 反方向已经有线,黑珠不能直行穿过。 +- 当前方向边已不可走。 +- 当前方向相邻格的侧边已有线,会破坏“出黑珠后直行一格”。 +- 当前方向相邻格也是黑珠。 +- 当前方向再往前的延长边不可走。 + +推出结论:当前方向禁线,反方向成线。 + +直觉解释:黑珠必须转弯,并且从黑珠出来后要直走一格。若某一侧作为黑珠出口会立即违反这些要求,就排除该侧;黑珠度数还需要两个出口,因此相对方向往往被强制。 + +可内化规则名:`黑珠非法出口排除`、`黑珠反向延伸`。 + +### 6.2 黑珠已有出口时,继续向前延伸一格 + +代码形态: + +```javascript +if (isBlack(cell) && isLine(offset(cell, .5, 0, d))) { + add_line(offset(cell, 1.5, 0, d)); +} +``` + +触发条件:黑珠某侧边已经确定为线。 + +推出结论:同方向相邻格外侧的下一条边也成线。 + +直觉解释:黑珠要求离开黑珠后必须直行至少一格。已有出口就强制延伸。 + +可内化规则名:`黑珠出口延伸`。 + +### 6.3 黑珠面对连续两个白珠时,反方向成线 + +代码形态: + +```javascript +if (isBlack(cell) && isWhite(offset(cell, 2, 0, d)) && isWhite(offset(cell, 3, 0, d))) { + add_line(offset(cell, -.5, 0, d)); +} +``` + +触发条件:黑珠某方向隔一格后出现两个连续白珠。 + +推出结论:黑珠反方向边成线。 + +直觉解释:若黑珠向那一侧出线,它需要先直行一格,然后很快遇到连续白珠结构。连续白珠会强烈限制直行/转弯方式,使该侧难以承担黑珠出口。作者把这种局部冲突编码成强制反向。 + +可内化规则名:`黑珠避开连续白珠`。 + +### 6.4 黑珠被两枚斜向白珠夹逼时,指定方向成线 + +代码形态: + +```javascript +if (isBlack(cell) && isWhite(offset(cell, -1, -1, d)) && isWhite(offset(cell, 1, -1, d))) { + add_line(offset(cell, 0, .5, d)); +} +``` + +触发条件:黑珠某一侧的两个斜对角位置都是白珠。 + +推出结论:黑珠朝相反的垂直方向成线。 + +直觉解释:两个斜向白珠会限制黑珠向它们所在一侧转出的可能。黑珠必须转弯且出口要延伸,于是被迫选择离开白珠夹逼的一侧。 + +可内化规则名:`黑珠斜白夹逼`。 + +### 6.5 黑珠与两侧白珠的内外染色传播 + +代码开头有两条黑珠相关的 `add_inout` 规则: + +```javascript +if (isBlack(cell) && isWhite(offset(cell, -1, -1, d)) && isWhite(offset(cell, 1, 1, d)) && + offset(cell, .5, .5, d).qsub !== CRQSUB.none) { + add_inout(offset(cell, -.5, -.5, d), offset(cell, .5, .5, d).qsub); +} +if (isBlack(cell) && isWhite(offset(cell, -1, -1, d)) && isWhite(offset(cell, 1, 1, d)) && + offset(cell, -.5, .5, d).qsub !== CRQSUB.none) { + add_inout(offset(cell, .5, -.5, d), offset(cell, -.5, .5, d).qsub ^ 1); +} +``` + +触发条件:黑珠处于一对白珠的对角结构中,并且某个相关格点已有内外标记。 + +推出结论:给另一个格点标记相同或相反的内外状态。 + +直觉解释:这是把特定 Masyu 局部形状翻译成闭合曲线的内外关系。它不是直接画线,而是先传播辅助染色,再交给 `SingleLoopInCell()` 的内外规则转化为线段或禁线。 + +可内化规则名:`黑白对角内外传播`。 + +## 7. 黑白珠组合模式 + +### 7.1 两个黑珠夹一格且一侧被堵时,另一侧禁线 + +代码形态: + +```javascript +if (isBlack(offset(cell, -1, 0, d)) && isBlack(offset(cell, 1, 0, d)) && !isPathable(offset(cell, 0, -.5, d))) { + add_cross(offset(cell, 0, .5, d)); +} +``` + +触发条件:某个空位左右两侧都是黑珠,并且该空位一条垂直边不可走。 + +推出结论:另一条垂直边也禁线。 + +直觉解释:两侧黑珠都需要转弯和延伸。中间空位如果只剩一个垂直出口,会制造死端或迫使某个黑珠直行违规,因此另一侧也被排除。 + +可内化规则名:`双黑夹格垂直禁线`。 + +### 7.2 白珠与邻近白珠/黑珠造成的轴线排除 + +`MasyuAssist()` 中最长的白珠条件把多个局部原因合并在一起: + +- 某侧远端已有线。 +- 某侧相邻格是白珠。 +- 某侧上下两个出路都不是线的候选。 +- 某侧斜向存在黑珠,导致相邻格不能作为白珠转弯位置。 + +这些条件都用于判断“白珠沿当前轴线直行会失败”。当白珠左右两端都失败时,脚本强制它走垂直轴。 + +未来实现时不建议把它照搬成一个巨型规则。更适合拆成若干解释性子规则: + +- `白珠相邻白珠排除轴线` +- `白珠相邻转弯位受阻` +- `白珠远端直线冲突` +- `白珠双端排除后改轴` + +这样每一步都能给出清晰解释。 + +## 8. 连通性与防小环专项规则 + +除了通用单环框架内的防小环,`MasyuAssist()` 还手写了几个与当前线段分量有关的模式。它们都带有 `board.linegraph.components.length > 1`,表示当前线图还不止一个连通分量,或者至少不能把局部线段闭成最终唯一环。 + +### 8.1 角落形状遇到双白珠时强制连接 + +代码检查一个已经形成的 L 形路径: + +```javascript +[[0, .5], [0, 1.5], [.5, 0], [1.5, 0]].every(([dx, dy]) => isLine(offset(cell, dx, dy, d))) +``` + +这表示从某个局部角落伸出两段线,形成固定的拐角结构。随后根据远处的白珠或黑珠位置,脚本补上若干线段,避免该局部路径在不合法的位置断开或形成无法连接的分量。 + +典型推出包括: + +- 若远处两个位置是白珠,则给它们附近补出必须经过白珠的线。 +- 若远处是黑珠加白珠,则给黑珠出口方向补出延伸线。 +- 若远处是黑珠,且另一个方向有白珠,则给黑珠反向补出线。 + +直觉解释:这些是“已成形路径 + 珠子局部约束 + 不能提前闭环”的复合模式。单看每个珠子可能还不够,但已有 L 形线段把未来连接方式压缩到很少,因而可以确定后续线段。 + +可内化规则名:`L形路径连通补线`、`路径分量约束下的珠子补线`。 + +注意:源码中有一段黑珠加白珠的判断重复出现两次,效果相同。文档化和未来迁移时可以去重。 + +### 8.2 黑珠两端属于同一路径时,向外补线 + +代码形态: + +```javascript +if (isBlack(cell) && cell.path !== null && cell.path === offset(cell, 2, 0, d).path && + offset(cell, 1, 0, d).path === null && board.linegraph.components.length > 1) { + add_line(offset(cell, -.5, 0, d)); +} +``` + +触发条件:黑珠与其某方向隔一格的格子已经属于同一路径,中间格还不在路径上,并且当前还存在多个线段分量。 + +推出结论:黑珠反方向边成线。 + +直觉解释:如果黑珠朝该方向连接,可能把同一路径局部闭合或制造不符合黑珠转弯延伸的连接方式。为了保持整体单环连通,只能从另一侧扩展。 + +可内化规则名:`黑珠同路径避闭环`。 + +### 8.3 白珠两侧属于同一路径时,改走垂直方向 + +代码形态: + +```javascript +if (isWhite(cell) && offset(cell, -1, 0, d).path !== null && offset(cell, -1, 0, d).path === offset(cell, +1, 0, d).path && + board.linegraph.components.length > 1) { + add_line(offset(cell, 0, +.5, d)); + add_line(offset(cell, 0, -.5, d)); +} +``` + +触发条件:白珠左右两个相邻格已经属于同一路径,且当前线图还不是唯一最终环。 + +推出结论:白珠不能沿这条轴线连接两侧,而必须沿垂直方向直行。 + +直觉解释:如果白珠沿左右方向直行,会连接同一路径的两端,形成提前闭合的小环。白珠必须直行,所以只剩垂直轴。 + +可内化规则名:`白珠同路径改轴`。 + +## 9. 推理框架对可解释求解器的启发 + +如果要把这些策略内化为分步可解释求解器,建议不要按源码中的 `if` 顺序机械迁移,而是建立规则层级。 + +### 9.1 第一层:通用单环规则 + +这些规则与 Masyu 无关,未来可复用于 Simple Loop、Slitherlink 变体、Mid-loop 等回路谜题: + +- `度数为二` +- `无分叉` +- `无死端` +- `必须经过指定格` +- `同路径端点禁止提前相连` +- `连通桥成线` +- `内外同色禁线` +- `内外异色成线` + +这些规则应在解释中引用当前格子的度数、候选出口、路径分量或内外标记。 + +### 9.2 第二层:Masyu 基础珠子规则 + +这些是最适合作为首批 Masyu 规则实现的部分: + +- `白珠被迫直行`:白珠已有一侧线或垂直方向被堵时,确定直行轴。 +- `白珠至少一侧转弯`:白珠一侧连续直行后,另一侧不能继续直行。 +- `黑珠出口延伸`:黑珠已有出口时,下一段同方向线成线。 +- `黑珠非法出口排除`:某方向不能满足“转弯且延伸”时禁线。 +- `黑珠反向延伸`:某方向被排除后,结合黑珠度数推出反向边。 + +这些规则解释性强,测试样例也容易构造。 + +### 9.3 第三层:局部形状规则 + +这些规则更像人类解题技巧中的“形状库”: + +- `双黑夹格垂直禁线` +- `黑珠避开连续白珠` +- `黑珠斜白夹逼` +- `白珠相邻白珠排除轴线` +- `白珠远端直线冲突` +- `白珠双端排除后改轴` + +实现时建议每条规则有独立的触发说明,不要把源码中的多个 `||` 全部塞进同一个解释里。否则用户看到的解释会变得很长,也难以验证。 + +### 9.4 第四层:全局连通与路径分量规则 + +这些规则需要求解器显式维护线段分量: + +- `白珠同路径改轴` +- `黑珠同路径避闭环` +- `L形路径连通补线` +- `路径分量约束下的珠子补线` + +这类规则的解释应展示“若选择另一条边,会提前闭合小环”或“若不选择这条边,某个必经珠子/线段分量无法并入最终单环”。 + +## 10. 迁移到 PuzzleKit IR 时的建模建议 + +当前 PuzzleKit 的目标是分步、可回放、可解释。迁移这段脚本时,需要把 puzz.link 的隐式状态显式化。 + +建议建模: + +- 用 `cells` 保存 Masyu 珠子类型:空、白珠、黑珠。 +- 用 `edges` 保存线段状态:未知、线、禁线。 +- 用派生状态计算每个格子的线段度数和候选出口。 +- 用派生图计算当前线段分量,替代 `cell.path` 与 `board.linegraph.components`。 +- 如要迁移内外染色,可用 `vertices` 保存顶点内外候选或确定状态。 +- 每条规则返回明确 `RuleDiff`,例如把某条 edge 从 unknown 改为 line/cross,把某个 vertex 标记为 inside/outside。 + +规则执行顺序可以采用: + +1. 基础边状态清理:不可达边、边界、已有两线后的禁线。 +2. 珠子基础规则:白珠直行、黑珠转弯延伸。 +3. 单环分量规则:防小环、桥边。 +4. 形状规则:连续白珠、黑白组合、L 形路径。 +5. 内外染色规则:若实现顶点内外辅助层,则在每轮中穿插传播。 + +## 11. 局限与注意点 + +这段 `MasyuAssist()` 是一个实用型助手,不是完整的形式化求解器。 + +需要注意: + +- 它依赖 puzz.link/pzpr 的运行时对象,例如 `cell.path`、`board.linegraph.components`、`border.line`、`cross.qsub`。 +- 部分推理通过内外染色间接完成,不一定在 Masyu 专用函数中直接画线。 +- 某些模式是高度压缩的局部图形判断,解释时应拆成更小的可读规则。 +- 源码中存在重复判断,迁移时可以合并。 +- `add_inout()` 的推理会写入临时顶点标记,外层处理结束后可能清理;在可解释求解器中应决定这些辅助标记是否展示给用户。 + +## 12. 候选规则清单 + +下面是一份适合后续实现和测试的规则清单。 + +| 规则名 | 类型 | 触发条件 | 结论 | +| --- | --- | --- | --- | +| 珠子必须被回路经过 | 通用单环 | 格子是白珠或黑珠 | 该格最终度数为 2 | +| 度数为二 | 通用单环 | 某格已有两条线 | 其余边禁线 | +| 无死端 | 通用单环 | 某格可用出口不足 | 排除导致死端的边 | +| 双出口强制成线 | 通用单环 | 必经格只剩两个出口 | 两个出口成线 | +| 禁止提前闭环 | 通用单环 | 候选边连接同一路径且仍有未完成部分 | 候选边禁线 | +| 内外异色成线 | 通用单环 | 相邻顶点内外不同 | 中间边成线 | +| 内外同色禁线 | 通用单环 | 相邻顶点内外相同 | 中间边禁线 | +| 白珠被迫直行 | 白珠 | 一侧已有线或垂直方向被堵 | 直行轴成线,垂直轴禁线 | +| 白珠至少一侧转弯 | 白珠 | 白珠一侧已连续直行 | 另一侧远端禁线 | +| 白珠轴线排除 | 白珠 | 某轴两端都无法满足白珠约束 | 改走另一轴 | +| 黑珠非法出口排除 | 黑珠 | 某方向无法满足转弯和延伸 | 该方向禁线 | +| 黑珠出口延伸 | 黑珠 | 黑珠某侧已有线 | 同方向下一段成线 | +| 黑珠避开连续白珠 | 黑白组合 | 黑珠某方向面对连续两个白珠 | 反方向成线 | +| 黑珠斜白夹逼 | 黑白组合 | 黑珠一侧两个斜角都是白珠 | 远离夹逼方向成线 | +| 双黑夹格垂直禁线 | 黑黑组合 | 两黑夹一格且一侧垂直边被堵 | 另一侧垂直边禁线 | +| 白珠同路径改轴 | 连通性 | 白珠两侧属于同一路径 | 垂直轴成线 | +| 黑珠同路径避闭环 | 连通性 | 黑珠与远端同路径且中间未入路径 | 反方向成线 | +| L形路径连通补线 | 连通性/形状 | 已有 L 形路径配合远处珠子 | 补出避免断联或小环的线 | + +这些规则可以按“通用单环先行、Masyu 基础规则随后、复杂形状最后”的方式逐步实现。这样既能保持求解器行为稳定,也能让每一步解释接近人类解题语言。 diff --git a/docs/MASYU_CHANGELOG.md b/docs/MASYU_CHANGELOG.md new file mode 100644 index 0000000..2eaf621 --- /dev/null +++ b/docs/MASYU_CHANGELOG.md @@ -0,0 +1,113 @@ +# Masyu Implementation Changelog + +## 2026-05-16 Initial Import And Display Increment + +This update adds the first real Masyu support path to PuzzleKit Web. The goal of +this increment is intentionally narrow: import a Masyu `puzz.link` URL, preserve +the existing Slitherlink architecture, and render the imported board in the main +solver workspace. + +## Implemented + +- Added first-class Masyu IR fields: + - `PuzzleIR.lines`: canonical center-to-center loop decisions for Masyu. + - `PuzzleIR.tiles`: future vertex-centered coloring units. + - Pearl clues as `Clue { kind: "pearl"; color: "white" | "black" }`. +- Added Masyu key helpers: + - `lineKey`, `parseLineKey`, `getCellLineKeys`. + - `tileKey`, `parseTileKey`. +- Added `createMasyuPuzzle(rows, cols)`: + - Creates one unknown line for each orthogonally adjacent cell-center pair. + - Creates tiles at original grid vertex coordinates, `0..rows` and `0..cols`. + - Leaves Slitherlink-style `edges` and `sectors` empty. +- Added `decodeMasyuFromPuzzlink`: + - Accepts `masyu`, `mashu`, and `pearl`. + - Supports optional `v:` and `b` header segments. + - Decodes `number3` trits according to `docs/MASYU_ENCODE_METHOD.md`. + - Verified sample: + `https://puzz.link/p?mashu/5/5/001390360`. +- Updated `masyuPlugin`: + - Display name is now `Masyu`. + - Parser is wired to the new puzz.link decoder. + - Export intentionally throws: Masyu puzz.link export is not implemented yet. + - Rule/help text is present. + - Legend is a placeholder. + - Stats show board size and pearl distribution. +- Extended replay and stats plumbing: + - Added `LineDiff`. + - Rule engine can apply and revert line diffs. + - Rule steps may carry `affectedLines`. + - Trace stats treat Masyu line decisions as board progress. + - Slitherlink edge behavior remains unchanged. +- Added Masyu rendering in the solver board: + - Thin dashed inner grid. + - Thick solid outer border. + - Existing `R` / `C` coordinate labels. + - Centered white and black pearls. + - Center-to-center lines and crosses from `PuzzleIR.lines`. + +## Not Implemented Yet + +- Masyu solving rules. +- Masyu editor. +- Masyu dataset flow. +- Masyu-specific Live Stats labels. +- Masyu export back to puzz.link. +- Rule examples and rich legend diagrams. + +## Validation + +Use a modern local Node runtime. Debugging with local Node `v24.13.1` should be +fine. In this Codex environment, the bundled Node runtime was required because +the default shell Node was too old for the current `pnpm`. + +Commands run successfully: + +```bash +pnpm lint +pnpm build +pnpm test:run +``` + +Full test result at implementation time: + +- 16 test files passed. +- 278 tests passed. + +Focused tests added: + +- `src/domain/ir/masyu.test.ts` +- `src/domain/parsers/puzzlink/masyuPuzzlink.test.ts` +- Line diff coverage in `src/domain/rules/engine.test.ts` +- Masyu line progress coverage in `src/domain/difficulty/traceStats.test.ts` + +## Architecture Notes For Future Agents + +- `lines` is the canonical Masyu decision state. Do not reuse Slitherlink + `edges` for Masyu loop segments. +- `edges` remains Slitherlink-style vertex-to-vertex grid-edge state. +- `tiles` is reserved for future Masyu coloring over vertex-centered middle + cells. It is not currently rendered or inferred. +- Masyu line keys use cell coordinates, not vertex coordinates: + `lineKey([row, col], [neighborRow, neighborCol])`. +- `rows × cols` in the UI means the user-operated cell board size. +- Existing Slitherlink rules should not be generalized unless a Masyu feature + needs shared infrastructure. + +## Next Work Center + +The next development center should be deterministic Masyu solving rules. Use +`docs/MASYU_ASSIST_STRATEGIES_CN.md` as the strategy reference. That document +summarizes the Masyu assist framework and local rules from `Puzzlink_Assistance` +in a form suitable for PuzzleKit's explainable rule engine. + +Recommended next steps: + +- Add small Masyu rule helpers for directions, opposite directions, adjacent + cell-center lines, and pathable line checks. +- Implement generic single-loop-in-cell basics for Masyu: + degree 2, no dead ends, forced two exits, and no premature small loop. +- Then add pearl-specific rules: + black pearl turn-and-straight constraints. + white pearl straight-and-turn-nearby constraints. +- Keep each rule small, named, deterministic, and backed by focused tests. diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index 97a2645..ea8d925 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -1,117 +1,145 @@ -# PuzzleKit Web Project Guide (English) +# PuzzleKit Web Project Guide -## 1. Project Intent (Read This First) +## 1. Project Intent -PuzzleKit Web is a frontend-first, rule-based logic puzzle solver focused on **machine reasoning quality**, not maximum solve rate. +PuzzleKit Web is a frontend-first, rule-based logic puzzle solver focused on +machine reasoning quality rather than maximum solve rate. -Core intent: +Core principles: -- Emphasize explicit computer deduction over black-box search/SAT solving -- Produce step-by-step, replayable, explainable reasoning -- Accept that some puzzles may remain unsolved by current rule coverage -- Prioritize solver traceability and reasoning playback over rich interactive tooling +- Prefer explicit deduction over black-box search or SAT solving. +- Make every step replayable, inspectable, and explainable. +- Accept that some puzzles may stop at a stable incomplete state. +- Grow solver strength incrementally by adding deterministic, human-readable + inference rules. -In short: this project is a **logic reasoning engine with a UI**, not a UI-first puzzle editor. +In short: this project is a logic reasoning engine with a UI, not a UI-first +puzzle editor. ---- - -## 2. Product Philosophy and Non-Goals - -### 2.1 Philosophy - -- Every step should be understandable: what changed, why it changed, and which rule produced it -- The system should be deterministic and replay-safe -- Rule growth should happen incrementally by adding human-readable inference rules - -### 2.2 Explicit Non-Goals - -- No guarantee to solve every valid puzzle instance -- No requirement to optimize for shortest solution path -- No requirement to prioritize advanced user interaction over deduction transparency - ---- - -## 3. High-Level Architecture +## 2. Architecture Map ```text src/ app/ # page composition and top-level routing/layout domain/ # puzzle logic source of truth - benchmark/ # dataset manifest validation and solver benchmark runner + benchmark/ # dataset validation and solver benchmark runner + difficulty/ # trace statistics and difficulty snapshots + exporters/ # export adapters ir/ # puzzle IR schemas, key utilities, normalize/clone parsers/ # puzz.link/penpa adapters - rules/ # rule contracts, step engine, puzzle-specific rule sets plugins/ # plugin contracts and registry - exporters/ # export adapters - difficulty/ # difficulty snapshot and rule usage aggregation - features/ # solver controls, board rendering, editor tools, explanation, stats + rules/ # rule contracts, step engine, puzzle-specific rules + features/ # board rendering, solver controls, editor, stats, explanation test/ # test setup/runtime helpers dataset/ public/ # committed benchmark/dataset manifests private/ # local-only manifests, ignored by git scripts/ - benchmark-solve.ts # project-owned benchmark entrypoint + benchmark-solve.ts +docs/ + techniques/ # puzzle-specific solving technique notes ``` -Design rule: - -- UI should render and orchestrate. -- Domain should decide logic. -- The solver workspace and puzzle editor are separate product surfaces that exchange normalized `PuzzleIR`. -- Puzzle-specific behavior should enter through `PuzzlePlugin` or puzzle-specific feature modules, not shared solver orchestration. +Boundary rule: ---- +- UI renders and orchestrates. +- Domain code owns parsing, IR, rules, replay semantics, and exports. +- Solver and editor are separate product surfaces that exchange normalized + `PuzzleIR`. +- Puzzle-family behavior enters through `PuzzlePlugin`, puzzle-specific domain + modules, or explicit renderer branches. -## 4. End-to-End Data Flow +## 3. Data Flow -1. Parser converts URL/input into IR (`PuzzleIR`). -2. Optional editor tooling can create or modify initial puzzle IR before solving. -3. The solver store loads the initial IR and resets replay state. +1. Parser converts URL/input into `PuzzleIR`. +2. Optional editor tooling creates or modifies initial IR. +3. Solver store loads the initial IR and resets replay state. 4. Rule engine runs ordered rules and returns one step at a time. -5. Each step stores rule metadata + explicit diffs. -6. Solver replay uses diffs forward/backward, with checkpoints for large timeline jumps. -7. Board, live stats, and explanation panels render current state + reasoning history. +5. Each step stores rule metadata plus explicit diffs. +6. Replay applies or reverts diffs, with checkpoints for large timeline jumps. +7. Board, stats, and explanation panels render the active replay state. -This guarantees the same inference chain can be replayed and inspected later. +This contract is the heart of the app: solver output must remain deterministic, +replay-safe, and explainable. ---- - -## 5. Plugin Contract +## 4. Plugin Contract Puzzle families are registered in `src/domain/plugins/registry.ts`. -Each `PuzzlePlugin` owns the puzzle-family boundary: +Each `PuzzlePlugin` owns its family boundary: -- `parse(input)` converts supported source input into normalized `PuzzleIR`. -- `encode(puzzle)` exports a puzzle back to a supported URL/string format. +- `parse(input)` converts supported input into normalized `PuzzleIR`. +- `encode(puzzle)` exports a puzzle to a supported URL/string format. - `getRules()` returns the ordered rule list used by the solver. -- `help` optionally powers the puzzle rules popout. -- `legend` optionally powers board legend examples. -- `getStats(puzzle)` optionally powers compact board-title puzzle stats via `PuzzleStatsInfoButton`. +- `help` powers the puzzle rules popout. +- `legend` powers board legend examples. +- `getStats(puzzle)` powers compact board-title puzzle stats. + +Current families: + +- Slitherlink: parser, renderer, editor, rules, stats, completion analysis, and + export support are implemented. +- Masyu: puzz.link import, IR, renderer, stats, help, and replay plumbing are + implemented; solving rules and export are still planned. +- Nonogram: visible as a planned plugin stub. + +## 5. IR And Diff Conventions + +`PuzzleIR` is the shared normalized state between parser, rules, replay, board, +editor, and exporters. -The current registry includes Slitherlink plus planned Masyu/Nonogram stubs. The -stubs are visible as future puzzle families but do not yet parse, render, edit, -or solve real puzzles. +Important state buckets: ---- +- `cells`: cell clues and cell-local visual state. +- `edges`: Slitherlink-style vertex-to-vertex grid-edge decisions. +- `lines`: Masyu-style cell-center-to-cell-center line decisions. +- `sectors`: Slitherlink corner-sector constraints. +- `tiles`: future vertex-centered coloring units, currently introduced for + Masyu. +- `vertices`: vertex candidate state for Slitherlink inference. -## 6. Benchmark and Dataset Flow +`RuleDiff` is the replay contract. If a rule mutates a new IR bucket, add a diff +type and update both: + +- `src/domain/rules/engine.ts` +- `src/features/solver/solverStore.ts` + +Keep forward and reverse replay behavior aligned. Timeline replay must not +diverge from direct rule execution. + +## 6. Puzzle Techniques + +Do not put puzzle-specific solving techniques in this project guide. Use the +technique notes instead: + +- `docs/techniques/PUZZLE_TECHNIQUES_EN.md` + +That file points to the current Slitherlink rule modules, the Masyu changelog, +the Masyu URL encoding reference, and the Masyu strategy research document: + +- `docs/MASYU_CHANGELOG.md` +- `docs/MASYU_ENCODE_METHOD.md` +- `docs/MASYU_ASSIST_STRATEGIES_CN.md` + +## 7. Benchmark And Dataset Flow Benchmarks evaluate solver behavior across JSON dataset manifests. They are for solver quality and rule-usage analysis, not for unit-test correctness. Data locations: -- `dataset/public/**/*.json` is committed and should stay small/curated. +- `dataset/public/**/*.json` is committed and should stay small and curated. - `dataset/private/**/*.json` is local-only and ignored by git. - `benchmark-results/` is generated output and ignored by git. Run: -- `pnpm benchmark:solve` +```bash +pnpm benchmark:solve +``` -This command scans public/private manifests, runs each puzzle with the default -plugin rule order, and writes one report per manifest to +The benchmark runner scans public/private manifests, runs each puzzle with the +default plugin rule order, and writes reports to `benchmark-results/.report.json`. Current defaults: @@ -124,171 +152,97 @@ Report intent: - Per puzzle: status, step count, duration, terminal completion report, `ruleUsage`, and compact `ruleSteps`. -- `steps` is intentionally an empty array for now to keep large reports small. +- `steps` is intentionally empty for now to keep reports small. - `ruleSteps[ruleId] = [stepNumbers...]` records where each rule fired. -The Dataset page browses public manifests, renders compact puzzle previews, and -can load a puzzle into either Solver or Editor. - ---- - -## 7. Slitherlink Rule Architecture (Current) - -The Slitherlink rules are now modularized under `src/domain/rules/slither/rules/`. - -### 7.1 Aggregation entrypoint - -- `src/domain/rules/slither/rules.ts` - - Exports `deterministicSlitherRules` in a fixed order - - Exports `slitherRules = deterministic + strong-inference` - - Serves as the single place for execution-order control - -### 7.2 Rule modules - -- `patterns.ts` - - pattern-style clue rules (e.g. contiguous 3-run, diagonal adjacent 3) -- `core.ts` - - generic Slither constraints (cell count, vertex degree, premature loop prevention) -- `color.ts` - - cell color seeding and propagation rules -- `sectorInference.ts` - - corner-sector inference from local edge/vertex/cell evidence -- `sectorPropagation.ts` - - sector-to-sector and sector-to-edge propagation family -- `colorAssumptionInference.ts` - - conservative color-branch contradiction inference -- `sectorParityInference.ts` - - conservative sector-parity contradiction inference -- `strongInference.ts` - - conservative branch-based contradiction inference -- `shared.ts` - - reusable helpers (geometry adjacency, clue/color utilities, mask helpers) - -### 7.3 Branch inference decoupling - -Branch-based inference rules should not self-reference the exported -`slitherRules` array. They receive deterministic rules via dependency -injection, for example: - -- `createStrongInferenceRule(() => deterministicSlitherRules)` - -This prevents circular coupling and keeps branch inference reusable/testable. - ---- - -## 8. Sector Constraint Model (Critical) - -Sector state is represented as a bitmask of allowed corner line counts `{0,1,2}`. - -- IR source: `src/domain/ir/types.ts` -- Rule diff source: `src/domain/rules/types.ts` -- Sector diffs use `fromMask -> toMask` -- Rule semantics are narrowing by mask intersection, then propagating when masks become strict enough - -Do not revert to old single-label sector semantics. - ---- - -## 9. Replay and Determinism Contract - -Two files must stay behaviorally aligned: - -- `src/domain/rules/engine.ts` -- `src/features/solver/solverStore.ts` - -Both apply the same `RuleDiff` semantics, especially sector mask writes: - -- `puzzle.sectors[sectorKey].constraintsMask = diff.toMask` - -If these two paths diverge, timeline replay and solver state will drift. - -Recent replay updates: - -- `solverStore` keeps replay checkpoints so large `goToStep` jumps do not rebuild from step 0. -- Adjacent timeline movement uses forward/reverse diffs instead of full prefix replay. -- Live Stats uses the trace stats cache for step-prefix summaries, chart progress, and rule usage. -- Default Rule Usage is lightweight; full rule step lists render only when details are opened. - -When editing replay, preserve checkpoint correctness after reset, import, and branch truncation. -When editing Live Stats, keep chart series aligned with the trace cache object, not only nested -array references that may be mutated during cache append. - ---- - -## 10. Current Capability Snapshot +## 8. Current Capability Snapshot Implemented: -- Dedicated solver workspace for import, solving, replay, explanation, live stats, terminal reports, and export -- Dedicated editor workspace for puzzle construction before loading into the solver -- Public Dataset page with filters, compact previews, and load-to-Solver/Editor actions -- Slitherlink puzz.link parse/encode baseline -- Slitherlink Penpa import baseline -- Slitherlink editor tools for clues, pre-drawn line edges, crossed/blank edges, erasing, custom grid sizes, and built-in presets -- Plugin-powered rule help, board legend, and compact board-title puzzle stats -- Slitherlink board stats for numeric clue count and 0/1/2/3 clue distribution -- Ordered rule execution with step metadata -- Step replay (`Next`, `Previous`, timeline jumps, `Solve to End`) with checkpoint-assisted large jumps -- Explanation-oriented deduction trace -- Live Stats summary charts for board progress, coverage, and rule usage over the active replay prefix -- Sector mask inference/propagation pipeline -- Strong-inference fallback for harder states -- Slitherlink completion analysis for solved/stalled terminal reports -- Public/private benchmark manifest workflow -- Compact benchmark reports with solve status, timing, rule usage, and rule step indices -- GitHub Pages release workflow for tagged builds - -Partially implemented / planned: - -- Masyu and Nonogram plugin stubs only; real parsers, renderers, editors, rules, and completion checks are still planned -- Puzzle-specific editor support for each puzzle family -- Canvas interaction and rendering optimization for larger boards and richer editor states -- Penpa adapter/export completeness -- Better calibrated difficulty modeling - -Important expectation: difficult puzzles may stop at a stable but incomplete state if no rule applies. - ---- - -## 11. AI Agent Quick Start - -If you are an AI agent onboarding this repository, do this first: - -1. Read `src/domain/rules/types.ts` and `src/domain/rules/engine.ts`. -2. Read `src/domain/plugins/types.ts` and `src/domain/plugins/registry.ts`. -3. Read `src/features/solver/solverStore.ts` to verify replay and terminal-report behavior. -4. For Slitherlink work, read `src/domain/rules/slither/rules.ts` and the rule modules by category. -5. For editor/UI work, inspect the relevant `src/features/*` component and page tests first. -6. For benchmark work, read `src/domain/benchmark/runner.ts` and `scripts/benchmark-solve.ts`. -7. Use `src/domain/rules/slither/rules.test.ts`, page tests, and benchmark tests as behavior references. +- Solver workspace for import, solving, replay, explanation, live stats, + terminal reports, and export. +- Editor workspace for constructing Slitherlink puzzles before loading into the + solver. +- Public Dataset page with filters, previews, and load-to-Solver/Editor actions. +- Plugin-powered rule help, board legend, and compact board-title stats. +- Slitherlink puzz.link parse/encode and Penpa import. +- Slitherlink editor tools for clues, line edges, crosses, erasing, custom sizes, + and built-in presets. +- Slitherlink deterministic and branch-based inference pipeline. +- Slitherlink completion analysis. +- Masyu puzz.link import for `masyu`, `mashu`, and `pearl`. +- Masyu IR support through `lines`, `tiles`, and pearl clues. +- Masyu solver-board rendering for dashed grids, pearls, center lines, and + crosses. +- Replay support for both edge diffs and line diffs. +- Live Stats trace cache for step-prefix summaries, chart progress, and rule + usage. +- Public/private benchmark manifest workflow. +- GitHub Pages release workflow for tagged builds. + +Planned or partial: + +- Masyu deterministic solving rules. +- Masyu editor, dataset flow, completion analysis, and URL export. +- Nonogram parser, renderer, editor, and rules. +- Puzzle-specific Live Stats wording beyond the current shared labels. +- Penpa adapter/export completeness. +- Better calibrated difficulty modeling. + +## 9. AI Agent Quick Start + +If you are an AI agent onboarding this repository, read in this order: + +1. `src/domain/rules/types.ts` +2. `src/domain/rules/engine.ts` +3. `src/domain/ir/types.ts` +4. `src/domain/plugins/types.ts` +5. `src/domain/plugins/registry.ts` +6. `src/features/solver/solverStore.ts` +7. `docs/techniques/PUZZLE_TECHNIQUES_EN.md` for puzzle-specific rule work + +For targeted work: + +- Slitherlink rules: start at `src/domain/rules/slither/rules.ts`. +- Masyu import/display: start at `docs/MASYU_CHANGELOG.md`. +- Masyu future rules: start at `docs/MASYU_ASSIST_STRATEGIES_CN.md`. +- Editor/UI work: inspect the relevant `src/features/*` component and page test. +- Benchmark work: read `src/domain/benchmark/runner.ts` and + `scripts/benchmark-solve.ts`. When editing: - Keep changes domain-first and minimally scoped. -- Preserve diff/message explainability. -- Preserve ordered deterministic behavior unless intentionally changed. -- Add/adjust tests alongside rule changes. +- Preserve deterministic replay semantics. +- Preserve explainable rule messages and explicit diffs. +- Add or adjust tests alongside parser, IR, replay, or rule changes. - Do not commit private datasets or generated benchmark reports. ---- +## 10. Development Commands + +Use a modern Node runtime. Local Node `v24.13.1` is suitable for current +development. Older Node versions may fail before project scripts start because +the configured pnpm version requires a newer runtime. -## 12. Development Commands +Commands: -- `pnpm install` - install dependencies using the locked pnpm dependency graph -- `pnpm dev` - local development -- `pnpm benchmark:solve` - run all public/private benchmark manifests -- `pnpm lint` - linting -- `pnpm test:run` - unit/component tests -- `pnpm build` - production build -- `pnpm test:e2e` - Playwright end-to-end tests +```bash +pnpm install +pnpm dev +pnpm lint +pnpm test:run +pnpm build +pnpm benchmark:solve +pnpm test:e2e +``` -## 13. Deployment and Release Flow +## 11. Deployment And Release Flow -- Package management is standardized on pnpm 10.33.0 via the `packageManager` - field in `package.json`. GitHub Actions installs that pnpm version before - enabling `actions/setup-node` pnpm caching. -- CI runs on pushes and pull requests targeting `main`; it installs with - `pnpm install --frozen-lockfile`, then runs linting, unit tests, and build. -- GitHub Pages deployment is triggered by pushing a `v*` tag. The deployment - workflow runs the same checks and build, copies `dist/index.html` to +- Package management is standardized on pnpm 10.33.0 via `packageManager` in + `package.json`. +- CI runs on pushes and pull requests targeting `main`. +- CI installs with `pnpm install --frozen-lockfile`, then runs linting, unit + tests, and build. +- GitHub Pages deployment is triggered by pushing a `v*` tag. +- The deployment workflow builds `dist/`, copies `dist/index.html` to `dist/404.html` for SPA fallback, then publishes `dist/`. diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md new file mode 100644 index 0000000..0fedfce --- /dev/null +++ b/docs/techniques/masyu.md @@ -0,0 +1,46 @@ +# Masyu + +Current implementation location: + +- IR factory: `src/domain/ir/masyu.ts` +- puzz.link decoder: `src/domain/parsers/puzzlink/masyuPuzzlink.ts` +- Plugin: `src/domain/plugins/masyuPlugin.ts` +- Board rendering branch: `src/features/board/CanvasBoard.tsx` +- Change log: `docs/MASYU_CHANGELOG.md` +- Encoding reference: `docs/MASYU_ENCODE_METHOD.md` +- Strategy reference: `docs/MASYU_ASSIST_STRATEGIES_CN.md` + +Current model: + +- `PuzzleIR.lines` is the canonical center-to-center loop decision state. +- `PuzzleIR.edges` remains Slitherlink-style vertex-to-vertex grid-edge state. +- `PuzzleIR.tiles` is reserved for future vertex-centered coloring units. +- Pearl clues are stored on cells as + `Clue { kind: "pearl"; color: "white" | "black" }`. +- Masyu line keys use cell coordinates: + `lineKey([row, col], [neighborRow, neighborCol])`. + +Implemented so far: + +- Import from `puzz.link` Masyu-family URLs: `masyu`, `mashu`, and `pearl`. +- Render dashed inner grid, thick outer border, pearls, center lines, and + crosses. +- Replay and trace stats understand `LineDiff`. + +Not implemented yet: + +- Deterministic Masyu solving rules. +- Masyu editor, dataset flow, completion analysis, and URL export. +- Masyu-specific Live Stats labels and rich legend examples. + +Next rule-development direction: + +- Use `docs/MASYU_ASSIST_STRATEGIES_CN.md` as the main source for Masyu solving + strategies. +- Start with generic single-loop-in-cell rules: degree 2, no dead ends, forced + two exits, and premature loop prevention. +- Then add pearl-local rules: + - Black pearl turn-and-straight constraints. + - White pearl straight-and-turn-nearby constraints. +- Keep each rule deterministic, explainable, small, and covered by focused + tests. diff --git a/docs/techniques/slitherlink.md b/docs/techniques/slitherlink.md new file mode 100644 index 0000000..e444127 --- /dev/null +++ b/docs/techniques/slitherlink.md @@ -0,0 +1,39 @@ +# Slitherlink + +Current implementation location: + +- Rule aggregator: `src/domain/rules/slither/rules.ts` +- Rule modules: `src/domain/rules/slither/rules/` +- Completion analysis: `src/domain/rules/slither/completion.ts` +- Tests: `src/domain/rules/slither/rules.test.ts` + +Current rule organization: + +- `patterns.ts`: clue pattern rules, such as contiguous 3-runs and diagonal + adjacent 3s. +- `core.ts`: generic Slitherlink constraints, including clue edge counts, + vertex degree, and premature loop prevention. +- `color.ts`: cell color seeding and propagation. +- `sectorInference.ts`: corner-sector inference from local edge, vertex, and + cell evidence. +- `sectorPropagation.ts`: sector-to-sector and sector-to-edge propagation. +- `colorAssumptionInference.ts`: conservative color-branch contradiction + inference. +- `sectorParityInference.ts`: conservative sector-parity contradiction + inference. +- `strongInference.ts`: conservative branch-based contradiction inference. +- `shared.ts`: reusable geometry, clue, color, and mask helpers. + +Important Slitherlink model note: + +- Sector state is a bitmask of allowed corner line counts `{0,1,2}`. +- Sector diffs use `fromMask -> toMask`. +- Rule semantics narrow masks by intersection and then propagate strict masks. +- Do not revert sectors to old single-label semantics. + +Branch inference note: + +- Branch-based rules should not self-reference the exported `slitherRules` + array. +- Use dependency injection, for example + `createStrongInferenceRule(() => deterministicSlitherRules)`. diff --git a/src/app/WorkspacePage.tsx b/src/app/WorkspacePage.tsx index 292781c..1b0fc91 100644 --- a/src/app/WorkspacePage.tsx +++ b/src/app/WorkspacePage.tsx @@ -17,6 +17,7 @@ export const WorkspacePage = () => { highlightedCells, highlightedColorCells, highlightedEdges, + highlightedLines, includeVertexNumbers, solveProgress, goToStep, @@ -31,7 +32,7 @@ export const WorkspacePage = () => {

PuzzleKit Web

-

A Step-wise and Explainable Inference Solver for Slitherlink.

+

A Step-wise and Explainable Inference Solver for Logic Puzzles.

@@ -334,18 +392,20 @@ export const CanvasBoard = ({ Use the slider to zoom. Scroll to move around large grids. Highlight syncs with reasoning steps.

-
- Cell to edge mapping helper -
-          {Object.keys(puzzle.cells)
-            .slice(0, 5)
-            .map((key) => {
-              const [r, c] = parseCellKey(key)
-              return `${key} -> ${getCellEdgeKeys(r, c).join(' | ')}`
-            })
-            .join('\n')}
-        
-
+ {puzzle.puzzleType !== 'masyu' ? ( +
+ Cell to edge mapping helper +
+            {Object.keys(puzzle.cells)
+              .slice(0, 5)
+              .map((key) => {
+                const [r, c] = parseCellKey(key)
+                return `${key} -> ${getCellEdgeKeys(r, c).join(' | ')}`
+              })
+              .join('\n')}
+          
+
+ ) : null} ) } diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index 5534af6..782d3a8 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -94,7 +94,7 @@ export const ControlPanel = () => {