From afdd5da2708fffc7f8373870c95cdf500e5705ac Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Mon, 4 May 2026 14:58:35 +0800 Subject: [PATCH 01/13] chore: fix strong inference time walls --- src/domain/rules/slither/rules.test.ts | 8 ++++++-- .../rules/slither/rules/strongInference.ts | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/domain/rules/slither/rules.test.ts b/src/domain/rules/slither/rules.test.ts index 6f3160e..a3b016b 100644 --- a/src/domain/rules/slither/rules.test.ts +++ b/src/domain/rules/slither/rules.test.ts @@ -1907,6 +1907,10 @@ describe('slither strong inference rule', () => { if (!strongRule) { throw new Error('Expected strong-inference rule') } + const unboundedStrongRule = createStrongInferenceRule( + () => slitherRules.filter((rule) => rule.id !== 'color-assumption-inference' && rule.id !== 'strong-inference'), + { maxMs: Number.POSITIVE_INFINITY }, + ) it('places color assumption inference before strong inference', () => { const colorAssumptionIdx = slitherRules.findIndex((rule) => rule.id === 'color-assumption-inference') @@ -2095,8 +2099,8 @@ describe('slither strong inference rule', () => { current = nextPuzzle } - expect(() => strongRule.apply(current)).not.toThrow() - const result = strongRule.apply(current) + expect(() => unboundedStrongRule.apply(current)).not.toThrow() + const result = unboundedStrongRule.apply(current) expect(result === null || result.diffs.length > 0).toBe(true) }) diff --git a/src/domain/rules/slither/rules/strongInference.ts b/src/domain/rules/slither/rules/strongInference.ts index 201a4e8..d01709e 100644 --- a/src/domain/rules/slither/rules/strongInference.ts +++ b/src/domain/rules/slither/rules/strongInference.ts @@ -15,6 +15,12 @@ const STRONG_MAX_CANDIDATES = 200 const STRONG_MAX_TRIAL_STEPS = 120 const STRONG_MAX_MS = 2000 +type StrongInferenceOptions = { + maxCandidates?: number + maxTrialSteps?: number + maxMs?: number +} + type StrongCandidate = | { kind: 'sector-only-one' @@ -206,17 +212,20 @@ const summarizeFixedDiffs = (diffs: RuleApplication['diffs']): string => { return `fixed ${edgeDiffs.length} edges (${preview}, ...)` } -export const createStrongInferenceRule = (getDeterministicRules: () => Rule[]): Rule => ({ +export const createStrongInferenceRule = ( + getDeterministicRules: () => Rule[], + options: StrongInferenceOptions = {}, +): Rule => ({ id: 'strong-inference', name: 'Strong Inference (Conservative)', apply: (puzzle: PuzzleIR): RuleApplication | null => { const deterministicRules = getDeterministicRules() - const candidates = collectStrongCandidates(puzzle, STRONG_MAX_CANDIDATES) + const candidates = collectStrongCandidates(puzzle, options.maxCandidates ?? STRONG_MAX_CANDIDATES) if (candidates.length === 0) { return null } - const deadlineMs = Date.now() + STRONG_MAX_MS + const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) for (const candidate of candidates) { if (Date.now() > deadlineMs) { break @@ -247,10 +256,10 @@ export const createStrongInferenceRule = (getDeterministicRules: () => Rule[]): } const branchAResult = branchAInfo.setupOk - ? runTrialUntilFixpoint(branchA, deterministicRules, STRONG_MAX_TRIAL_STEPS, deadlineMs) + ? runTrialUntilFixpoint(branchA, deterministicRules, options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, deadlineMs) : { contradiction: true, timedOut: false, exhausted: false, puzzle: branchA } const branchBResult = branchBInfo.setupOk - ? runTrialUntilFixpoint(branchB, deterministicRules, STRONG_MAX_TRIAL_STEPS, deadlineMs) + ? runTrialUntilFixpoint(branchB, deterministicRules, options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, deadlineMs) : { contradiction: true, timedOut: false, exhausted: false, puzzle: branchB } if (branchAResult.timedOut || branchBResult.timedOut) { From c3e27f8734b341182812fbba9e841a1f62e9e944 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Tue, 5 May 2026 18:48:48 +0800 Subject: [PATCH 02/13] feat: update UI --- docs/PROJECT_GUIDE_EN.md | 25 +- playwright.config.ts | 2 +- src/App.tsx | 2 + src/app/EditorPage.test.tsx | 94 +++++++ src/app/EditorPage.tsx | 218 ++++++++++++++ src/app/WorkspacePage.test.tsx | 20 +- src/app/WorkspacePage.tsx | 88 ++---- src/app/workspace.css | 132 +++++++++ src/features/board/CanvasBoard.tsx | 53 +--- .../editor/SlitherlinkEditorBoard.tsx | 266 ++++++++++++++++++ src/features/editor/editorStore.test.ts | 57 ++++ src/features/editor/editorStore.ts | 188 +++++++++++++ src/features/editor/presets.ts | 47 ++++ src/features/solver/ControlPanel.tsx | 92 +----- src/features/solver/solverStore.test.ts | 37 ++- src/features/solver/solverStore.ts | 94 +------ src/test/setup.ts | 2 + tests/e2e/workspace.spec.ts | 12 +- 18 files changed, 1107 insertions(+), 322 deletions(-) create mode 100644 src/app/EditorPage.test.tsx create mode 100644 src/app/EditorPage.tsx create mode 100644 src/features/editor/SlitherlinkEditorBoard.tsx create mode 100644 src/features/editor/editorStore.test.ts create mode 100644 src/features/editor/editorStore.ts create mode 100644 src/features/editor/presets.ts diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index e80a98b..c8939cb 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -43,7 +43,7 @@ src/ plugins/ # plugin contracts and registry exporters/ # export adapters difficulty/ # difficulty snapshot and rule usage aggregation - features/ # board, controls, replay, explanation, stats + features/ # solver controls, board rendering, editor tools, explanation, stats test/ # test setup/runtime helpers ``` @@ -51,16 +51,19 @@ 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`. --- ## 4. End-to-End Data Flow 1. Parser converts URL/input into IR (`PuzzleIR`). -2. Rule engine runs ordered rules and returns one step at a time. -3. Each step stores rule metadata + explicit diffs. -4. Timeline store replays diffs forward/backward. -5. Board and explanation panel render current state + reasoning history. +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. +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. This guarantees the same inference chain can be replayed and inspected later. @@ -137,7 +140,11 @@ If these two paths diverge, timeline replay and solver state will drift. Implemented: -- Slitherlink puzz.link parse/encode baseline (URL input currently targets puzz.link; penpa-style URL support is planned) +- Dedicated solver workspace for import, solving, replay, explanation, stats, and export +- Dedicated editor workspace for puzzle construction before loading into the solver +- 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 - Ordered rule execution with step metadata - Step replay (`Next`, `Previous`, `Solve to End`) - Explanation-oriented deduction trace @@ -146,9 +153,10 @@ Implemented: Partially implemented / planned: -- Penpa adapter completeness - More puzzle families (e.g. Masyu/Nonogram) -- Richer puzzle-specific interaction tools +- 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. @@ -181,4 +189,3 @@ When editing: - `npm run test:run` - unit/component tests - `npm run build` - production build - `npm run test:e2e` - Playwright end-to-end tests - diff --git a/playwright.config.ts b/playwright.config.ts index 212f475..e38d3e6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'npm run dev -- --host 127.0.0.1 --port 4173', + command: `"${process.execPath}" ./node_modules/vite/bin/vite.js --host 127.0.0.1 --port 4173`, port: 4173, reuseExistingServer: true, }, diff --git a/src/App.tsx b/src/App.tsx index 3c06e29..13b5330 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { EditorPage } from './app/EditorPage' import { WorkspacePage } from './app/WorkspacePage' function App() { return ( } /> + } /> } /> ) diff --git a/src/app/EditorPage.test.tsx b/src/app/EditorPage.test.tsx new file mode 100644 index 0000000..ea5c194 --- /dev/null +++ b/src/app/EditorPage.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import App from '../App' +import { cellKey, edgeKey } from '../domain/ir/keys' +import { createSlitherPuzzle } from '../domain/ir/slither' +import { useEditorStore } from '../features/editor/editorStore' +import { useSolverStore } from '../features/solver/solverStore' + +const mockCanvasRect = (canvas: HTMLCanvasElement) => { + Object.defineProperty(canvas, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: canvas.width, + bottom: canvas.height, + width: canvas.width, + height: canvas.height, + toJSON: () => ({}), + }), + }) +} + +const clickCanvas = (canvas: HTMLCanvasElement, x: number, y: number) => { + fireEvent.mouseDown(canvas, { clientX: x, clientY: y }) + fireEvent.mouseUp(canvas, { clientX: x, clientY: y }) +} + +describe('EditorPage', () => { + afterEach(() => { + cleanup() + useEditorStore.getState().loadEditorPuzzle(createSlitherPuzzle(5, 5)) + }) + + it('edits clues and edge marks, then hands the puzzle to the solver', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + + fireEvent.click(screen.getByRole('button', { name: '3' })) + clickCanvas(canvas, 75, 75) + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 3, + }) + + fireEvent.click(screen.getByRole('button', { name: /^line$/i })) + clickCanvas(canvas, 75, 48) + const topEdge = edgeKey([0, 0], [0, 1]) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('line') + + fireEvent.click(screen.getByRole('button', { name: /^cross$/i })) + clickCanvas(canvas, 48, 75) + const leftEdge = edgeKey([0, 0], [1, 0]) + expect(useEditorStore.getState().puzzle.edges[leftEdge]?.mark).toBe('blank') + + fireEvent.click(screen.getByRole('button', { name: /^eraser$/i })) + clickCanvas(canvas, 75, 48) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('unknown') + + fireEvent.click(screen.getByRole('button', { name: /solve it/i })) + + expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() + expect(useSolverStore.getState().initialPuzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 3, + }) + expect(useSolverStore.getState().initialPuzzle.edges[leftEdge]?.mark).toBe('blank') + expect(useSolverStore.getState().pointer).toBe(0) + }) + + it('loads a preset and exposes preset metadata', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /starter loop/i })) + + expect(screen.getByText(/small/i)).toBeInTheDocument() + expect(useEditorStore.getState().selectedPresetId).toBe('slitherlink-small-starter') + expect(useEditorStore.getState().puzzle.rows).toBe(3) + expect(useEditorStore.getState().puzzle.cols).toBe(3) + }) +}) diff --git a/src/app/EditorPage.tsx b/src/app/EditorPage.tsx new file mode 100644 index 0000000..f339619 --- /dev/null +++ b/src/app/EditorPage.tsx @@ -0,0 +1,218 @@ +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 { puzzleRegistry } from '../domain/plugins/registry' +import { SlitherlinkEditorBoard } from '../features/editor/SlitherlinkEditorBoard' +import { useEditorStore, type SlitherClueDraft } from '../features/editor/editorStore' +import { puzzlePresets } from '../features/editor/presets' +import { useSolverStore } from '../features/solver/solverStore' +import './workspace.css' + +const clueValues: SlitherClueDraft[] = [0, 1, 2, 3, '?', null] + +export const EditorPage = () => { + const navigate = useNavigate() + const { + pluginId, + puzzle, + sourceUrl, + importError, + tool, + clueValue, + selectedPresetId, + setPluginId, + setTool, + setClueValue, + createBlankSlither, + importFromUrl, + loadPreset, + applyCellTool, + applyEdgeTool, + } = useEditorStore() + const loadPuzzle = useSolverStore((state) => state.loadPuzzle) + const [localUrl, setLocalUrl] = useState(sourceUrl) + const [rows, setRows] = useState(String(puzzle.rows)) + const [cols, setCols] = useState(String(puzzle.cols)) + + useEffect(() => { + setRows(String(puzzle.rows)) + setCols(String(puzzle.cols)) + }, [puzzle.rows, puzzle.cols]) + + useEffect(() => { + setLocalUrl(sourceUrl) + }, [sourceUrl]) + + const solveCurrentPuzzle = () => { + loadPuzzle(puzzle, { + pluginId: puzzle.puzzleType, + sourceUrl, + }) + navigate('/') + } + + return ( +
+
+
+
+
+

PuzzleKit Editor

+

Create a puzzle, then hand it to the explainable solver.

+
+ +
+ +
+
+
+
+

Puzzle Builder

+
+ +
+ Grid +
+ + + +
+
+
+ Tools +
+ + + + +
+
+ {clueValues.map((value) => ( + + ))} +
+
+