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 diff --git a/README.md b/README.md index 4db34b3..fb679c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PuzzleKit Web -[PuzzleKit Web](https://smilingwayne.github.io/puzzlekit-web/) provides step-wise and explainable inference flow for logical puzzles (only slitherlink for now). The core goal is not just to output a final answer, but to make each deduction step explicit: what changed, why it changed, and which rule produced this change. +[PuzzleKit Web](https://smilingwayne.github.io/puzzlekit-web/) provides step-wise and explainable inference flow for logical puzzles, now supporting only slitherlink (with better perf) and Masyu (with poor perf). The core goal is not just to output a final answer, but to make each deduction step explicit: what changed, why it changed, and which rule produced this change. Current focus: diff --git a/docs/ADDING_PUZZLE_FAMILY_EN.md b/docs/ADDING_PUZZLE_FAMILY_EN.md new file mode 100644 index 0000000..9ec1f81 --- /dev/null +++ b/docs/ADDING_PUZZLE_FAMILY_EN.md @@ -0,0 +1,166 @@ +# 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 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. The solver now uses incremental replay plus periodic +checkpoints, so every puzzle family must keep `RuleDiff` forward and reverse +semantics deterministic. + +--- + +## 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`, 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** + - 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. +- 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`. +- `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. + +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 + +**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, including timeline jumps. +- Live Stats shows sane active-prefix counts for the generated trace. + +**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 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. + +The best first version is small, explainable, and replay-safe. Coverage can grow +incrementally once that spine is solid. diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md new file mode 100644 index 0000000..17a1f90 --- /dev/null +++ b/docs/MASYU_AGENT_BRIEF.md @@ -0,0 +1,148 @@ +# Masyu Agent Brief + +This is the lightweight starting point for AI agents working on Masyu in PuzzleKit Web. Read this first. Only open the longer docs if the task needs them. + +## Read-On-Demand Route + +- Implement a Masyu rule: read this brief, then inspect the relevant `src/domain/rules/masyu/*` files. +- Research Puzzlink Assistance strategy: also read `docs/MASYU_ASSIST_STRATEGIES_CN.md`. +- Design a new rule family: also read `docs/MASYU_RULE_ABSTRACTIONS.md`. +- Check historical context: also read `docs/MASYU_CHANGELOG.md`. +- Change plugin, IR, replay, stats, or app-wide architecture: also read `docs/PROJECT_GUIDE_EN.md`. + +## Current State + +Masyu is implemented as a first-class puzzle family with import, rendering, replay-safe rules, completion analysis, and tile-color topology support. + +Canonical model: + +- `PuzzleIR.cells`: pearl clues, stored as `{ kind: "pearl"; color: "white" | "black" }`. +- `PuzzleIR.lines`: canonical Masyu loop decisions. These are center-to-center line segments between orthogonally adjacent cells. +- `PuzzleIR.tiles`: vertex-centered region-color units for Masyu inside/outside reasoning. +- `PuzzleIR.edges`: Slitherlink edge state. Do not use it as Masyu loop state. + +Important coordinate convention: + +- Cell keys are `row,col`, zero-based. +- Masyu line keys connect cells: `lineKey([r, c], [nr, nc])`. +- Masyu tile keys are grid vertices: `tileKey(row, col)` where `row = 0..rows`, `col = 0..cols`. + +## Current Rule Stack + +Registered rule order: + +1. `White Circle Rule` +2. `Black Circle Rule` +3. `Black Facing Consecutive Whites` +4. `Black Diagonal White Pinch` +5. `Consecutive White Pearls Straight` +6. `Double Black Squeeze` +7. `Masyu Tile Color Propagation` +8. `Masyu Color-Pearl Propagation` +9. `Masyu Color-Line Propagation` +10. `Masyu Tile Connectivity Cut Coloring` +11. `Masyu Candidate Bridge Line` +12. `Prevent Premature Loop` +13. `Black Pearl Candidate Pruning` +14. `Pearl Completion` +15. `Cell Completion` +16. `Black Pearl Strong Inference` + +Implemented rule areas: + +- Pearl-local rules for white straight-through and black turn/extension behavior. +- Local pattern rules derived from common Masyu situations. +- Premature loop prevention over `PuzzleIR.lines`. +- Black pearl candidate pruning with shallow feasibility checks. +- Black pearl strong inference with bounded trial propagation that crosses out an exit when that exit's two-step assumption leads to a hard contradiction. +- Completion rules for pearl and non-pearl cells. +- Tile color propagation: + - boundary tiles are `yellow` / outside; + - known `blank` lines imply same-color adjacent tiles; + - known `line` lines imply opposite-color adjacent tiles; + - white pearl diagonal tiles imply opposite colors; + - same-color adjacent tiles imply a `blank` Masyu line; + - opposite-color adjacent tiles imply a `line` Masyu line; + - tile connectivity cuts color articulation regions needed to connect known inside/outside regions; + - regions unreachable from outside/yellow through non-line passages become inside/green; + - tile fills are replay-safe via `TileDiff`; + - Masyu tile colors render on the board as full-size vertex-centered tiles. + +## Architecture Hotspots + +Use these files first: + +- Masyu rule registration: `src/domain/rules/masyu/rules.ts` +- Masyu geometry helpers: `src/domain/rules/masyu/rules/shared.ts` +- Pearl rules: `src/domain/rules/masyu/rules/pearls.ts` +- Pattern rules: `src/domain/rules/masyu/rules/patterns.ts` +- Loop rules: `src/domain/rules/masyu/rules/loop.ts` +- Tile color rules: `src/domain/rules/masyu/rules/color.ts` +- Tile connectivity rules: `src/domain/rules/masyu/rules/connectivity.ts` +- Candidate bridge rules: `src/domain/rules/masyu/rules/bridges.ts` +- Lookahead helpers: `src/domain/rules/masyu/rules/lookahead*.ts` +- Tests: `src/domain/rules/masyu/rules.test.ts` + +Replay and rendering plumbing: + +- Rule diffs: `src/domain/rules/types.ts` +- Diff application: `src/domain/rules/engine.ts` +- Board rendering: `src/features/board/CanvasBoard.tsx` +- Solver timeline/highlights: `src/features/solver/solverStore.ts` + +## Current Development Direction + +Near-term Masyu work should focus on making tile color useful beyond connectivity coloring: + +1. Add pearl-local color implications: + - migrate selected Puzzlink in/out tricks only when they can be explained as small Masyu tile parity rules. + +2. Keep rule granularity small: + - one reasoning idea per rule; + - explicit diffs; + - concise explanation message; + - focused fixture tests. + +## How To Start A Task + +Default workflow: + +1. Read this brief. +2. Inspect the exact rule/helper files touched by the task. +3. Search existing tests before writing a new rule. +4. Prefer extending local Masyu helpers over copying Slither code directly. +5. Run focused tests first, then build. + +Useful commands: + +```bash +pnpm test:run src/domain/rules/masyu/rules.test.ts +pnpm test:run src/domain/rules/engine.test.ts src/features/solver/solverStore.test.ts +pnpm build +``` + +## When To Read More + +Read `docs/MASYU_RULE_ABSTRACTIONS.md` when designing a new rule family or checking intended rule taxonomy. + +Read `docs/MASYU_ASSIST_STRATEGIES_CN.md` only when tracing a deduction back to Puzzlink Assistance. It is research/provenance, not the implementation source of truth. + +Read `docs/MASYU_CHANGELOG.md` only when historical context matters. + +Read `docs/PROJECT_GUIDE_EN.md` when changing plugin contracts, IR conventions, replay, stats, or app-wide architecture. + +## Maintenance Rules + +- Update this brief whenever rule order, canonical state, or next development direction changes. +- Keep this brief current, not historical. Move history to `docs/MASYU_CHANGELOG.md`. +- Keep this brief short enough that it can be pasted into an AI context without drowning the actual task. +- Prefer links and routing over duplicating long explanations. + +## Guardrails + +- Do not use `PuzzleIR.edges` for Masyu loop deductions. +- Do not mutate `PuzzleIR` inside rule inspection. +- Do not batch unrelated reasoning into one rule. +- Do not overwrite already-decided line/tile state with the opposite value. +- Do not make long Puzzlink-style monolithic rules; keep steps explainable. +- If a doc disagrees with current code, trust current code and update this brief. diff --git a/docs/MASYU_ASSIST_STRATEGIES_CN.md b/docs/MASYU_ASSIST_STRATEGIES_CN.md new file mode 100644 index 0000000..4ec54d6 --- /dev/null +++ b/docs/MASYU_ASSIST_STRATEGIES_CN.md @@ -0,0 +1,553 @@ +# Puzzlink Assistance 中的 Masyu 求解策略调研 + +本文调研油猴脚本 `Puzzlink_Assistance.js` 里作者对 Masyu 的自动辅助策略,重点比较 `SingleLoopInCell` 与 `SingleLoopInBorder` 这两个通用单环推理框架。 + +结论先行: + +- `SingleLoopInCell` 适合 Masyu 这类“环经过格子中心”的谜题。它把格子当作图的顶点,把相邻格子之间的 border 当作边,目标是形成一条经过指定格子的单一回路。 +- `SingleLoopInBorder` 适合 Slitherlink 这类“环画在格子边界上”的谜题。它把格点 cross 当作图的顶点,把 border 当作边,同时用格子染色表示环的内外侧。 +- 二者共同解决的是单环基础约束:不能分叉、不能死端、不能提前形成小环、所有线段最终必须属于同一个连通分量。 +- Masyu 的珠子规则不是全部塞进通用单环框架,而是在 `SingleLoopInCell({ isPass: c => c.qnum !== CQNUM.none })` 之后追加一组白珠、黑珠和局部图形模式。 + +## 脚本中的基础状态 + +脚本基于 puzz.link / pzpr 的对象模型工作。几个状态很关键: + +- `border.line === 1`:这条边已经确定为线,脚本用 `isLine(b)` 判断,用 `add_line(b)` 写入。 +- `border.qsub === BQSUB.cross`:这条边已经确定不能走线,脚本用 `isCross(b)` 判断,用 `add_cross(b)` 写入。 +- `cell.lcnt`:一个格子周围已经接入的线数。对 Masyu 的格心路径来说,它就是该格子的当前度数。 +- `cross.qsub === CRQSUB.in / out`:交叉点上的隐式内外侧标记。`SingleLoopInCell` 会把这些标记用作“线两侧”的拓扑约束。 +- `cell.qsub === CQSUB.green / yellow`:格子背景色。`SingleLoopInBorder` 用它区分 Slitherlink 环内外。 +- `cell.path`、`cross.path`、`board.linegraph.components.length`:puzz.link 已经维护好的线段连通分量信息。脚本用它判断“如果现在连上这条边,是否会提前闭合一个小环”。 + +所有规则都不是一次性求完整解,而是在 `assist()` 的循环中反复调用。每次 `add_line`、`add_cross`、`add_inout` 等写入一个确定状态后,`stepcheck()` 增加推理步数;循环继续运行,直到没有新结论、达到步数上限或超时。 + +## `SingleLoopInCell`:格心路径模型 + +`MasyuAssist()` 的第一步是: + +```js +SingleLoopInCell({ + isPass: c => c.qnum !== CQNUM.none, +}); +``` + +这里的 `isPass` 表示“这个格子最终必须被环经过”。对 Masyu 来说,所有白珠、黑珠格都必须被环经过,普通空格则可以经过也可以不经过。 + +### 图模型 + +`SingleLoopInCell` 把棋盘看作如下图: + +```text +cell --border-- cell --border-- cell +``` + +- 图的顶点是格子中心。 +- 图的边是两个相邻格子之间的 border。 +- 一条 `line` 表示环从一个格子中心走到相邻格子中心。 +- 一个 `cross` 表示这两个格子中心不能直接相连。 + +这与 Masyu 规则天然一致:Masyu 的线是穿过格子中心的正交路径,珠子对“路径在该格怎么进出、下一格怎么走”施加约束。 + +### 可配置参数的意义 + +`SingleLoopInCell` 是通用函数,不只服务 Masyu。它的参数给不同谜题留了接口: + +- `isPassable(cell)`:这个格子是否允许被路径经过。默认所有格子都可经过。 +- `isPathable(border)`:这条边是否还可能成为路径。默认只要不是 `cross` 就可走。 +- `isPass(cell)`:这个格子是否已经或必须被路径经过。Masyu 传入“有珠子的格子”。 +- `isPath(border)`:这条边当前是否是路径。默认是 `isLine`。 +- `add_notpass(cell)` / `add_pass(cell)`:推出格子不经过或经过时的写入动作。Masyu 没有显式写格子状态,所以基本不用。 +- `add_notpath(border)` / `add_path(border)`:推出边不能走或必须走时的写入动作。默认分别是 `add_cross` 和 `add_line`。 +- `Directed`:用于需要方向箭头的单环谜题。Masyu 不使用。 +- `hasCross`:用于允许路径在格内交叉的变体。Masyu 不使用。 + +### 推理流程 + +`SingleLoopInCell` 大致按以下层次工作。 + +### 1. 内外侧传播 + +在没有冰块、没有交叉路径的普通情形下,函数先在 `cross` 上维护 `CRQSUB.in / out`。直观上,格心路径的一条线会把相邻两个 cross 分在环的两侧;一条禁止线的边则不会穿过环,两侧保持同侧。 + +核心关系是: + +- 如果两个相邻 cross 同为 `in` 或同为 `out`,它们之间的 border 不能是线,应当为 `cross`。 +- 如果两个相邻 cross 一内一外,它们之间的 border 必须是线。 +- 如果 border 已经是 `cross`,两端 cross 的内外侧应相同。 +- 如果 border 已经是 `line`,两端 cross 的内外侧应相反。 + +代码最后用 DFS 从已经有 `in/out` 的 cross 出发传播这些关系: + +```js +if (cr.qsub === ncr.qsub) { add_cross(b); } +if (cr.qsub !== ncr.qsub) { add_line(b); } +if (isntLine(b)) { add_inout(ncr, cr.qsub); } +if (isLine(b)) { add_inout(ncr, cr.qsub ^ CRQSUB.inout); } +``` + +这是一种很强的拓扑推理:它不直接看珠子,而是把“单环把平面分成内外”这个事实编码到 cross 的二染色里。 + +### 2. 不能提前闭合小环 + +函数会遍历每个已有线段分量的端点。若某条候选边会连接同一个 `path` 分量的两个端点,并且棋盘上还有其他线段分量或还有必须经过但尚未经过的格子,那么这条边会形成一个提前闭合的小环,必须打叉。 + +对 Masyu 来说,这条规则非常重要。因为 Masyu 最终只能有一个环,不能先在局部珠子附近形成一个已经闭合的圈,然后让其他珠子留在圈外。 + +可以理解为: + +```text +如果 A 和 B 已经属于同一条未完成路径, +且 A--B 这条候选边会闭合它, +但还有其他必须纳入的内容, +那么 A--B = cross。 +``` + +### 3. 度数约束:不能分叉,不能死端 + +对每个格子,函数统计: + +- `linecnt`:四周已有多少条线接入。 +- `emptycnt`:四周还有多少条可走候选边。 + +随后推出: + +- 如果某格已有 2 条线接入,它在单环中度数已经满了,其他方向全部 `cross`。 +- 如果某格可走方向小于等于 1,那么它不可能成为环上的普通点,周围候选边全部 `cross`。 +- 如果某格必须经过,或者已经有 1 条线接入,而可走方向只剩 2 条,则这两条可走边必须都成为 `line`。 + +这就是单环题最基础的“每个经过点度数为 2”。 + +### 4. 连通分量阻断 + +如果一个格子尚未接线,但它周围所有可走邻居都已经属于同一个路径分量,那么从这个格子进入再出去会把同一分量提前闭合。函数会把相关候选边打叉。 + +相反,如果某个必须经过的格子周围候选边被分成两个路径分量,并且每个分量只能通过某些边进入这个格子,那么这些边会被临时标为 `lineaux`,再进一步推出必须走的边。这部分代码相当于一种“弹回 / bouncing”推理:为了避免把两个入口选择错成同一分量,必须让路径穿过这个格子把两个分量连起来。 + +### 5. 可选方向推理 + +`Directed` 分支会在边上记录箭头,用于带方向的环题。它可以推出: + +- 一个度数为 2 的格子,如果一条线方向已知,另一条线方向也能确定。 +- 两个只有一个端口的路径端点如果具有相同的入/出方向,则不能相连。 +- 根据环的整体顺逆时针方向,把箭头和 `in/out` 关系互相传播。 + +Masyu 没有传 `Directed`,所以这些不是 Masyu 当前使用的核心策略,但它说明作者把 `SingleLoopInCell` 做成了较通用的“格心单环引擎”。 + +## `SingleLoopInBorder`:边界路径模型 + +`SlitherlinkAssist()` 的第一步是: + +```js +SingleLoopInBorder(); +``` + +Slitherlink 的线不穿过格子中心,而是画在格子边界上。因此它的自然图模型与 Masyu 不同: + +```text +cross --border-- cross + | cell | +cross --border-- cross +``` + +- 图的顶点是格点 cross。 +- 图的边是 border。 +- 数字格约束的是围绕该 cell 的四条 border 有几条是线。 +- 格子本身不在路径上,而是在环的内侧或外侧。 + +### 1. 格子内外染色 + +`SingleLoopInBorder` 先用 `CellConnected` 对格子做两次连通性搜索: + +- 绿色 `green` 表示一侧区域。 +- 黄色 `yellow` 表示另一侧区域,且棋盘外部视为黄色。 +- `line` 是内外区域之间的边界,不可穿过。 +- `cross` 表示这条边不是环,两个相邻格子在同一区域,可以连通。 + +于是有两条核心规则: + +- 相邻两格颜色不同,则它们之间必须有线。 +- 相邻两格颜色相同,则它们之间必须不是线,即 `cross`。 + +反过来也成立: + +- 若一条边是 `line`,两侧格子颜色应不同。 +- 若一条边是 `cross`,两侧格子颜色应相同。 + +这与 `SingleLoopInCell` 的 cross 内外侧传播很像,但作用对象不同。Masyu 的 `in/out` 标在格点 cross 上;Slitherlink 的 `green/yellow` 标在格子 cell 上。 + +### 2. NoCheckerCell:排除棋盘格状矛盾 + +`SingleLoopInBorder` 调用了 `NoCheckerCell`,目标是避免内外染色出现棋盘格式的局部矛盾。直观上,如果四个格子围绕一个交点交替内外,那么交点附近会要求四条边全部成为边界,导致顶点度数不符合单环规则。该规则把“内外区域不能以不合法方式交错”的拓扑事实变成染色推理。 + +### 3. 顶点候选集:cross 上只允许 0 条或 2 条线 + +Slitherlink 的每个 cross 是路径顶点。单环经过一个顶点时,必须恰好有 2 条 incident border 是线;如果不经过,则 0 条是线。 + +`SingleLoopInBorder` 用 `cross.qsub` 存一个 JSON 候选集: + +- 初始候选包含 `[]`,表示这个 cross 不被路径经过。 +- 还包含所有从四条 incident border 中任选两条的组合,表示路径从其中两条边进出。 +- 已经打叉的边会从候选集中删除。 +- 已经画线的边必须出现在候选中。 +- 如果所有候选都包含某条边,则该边必为 `line`。 +- 如果所有候选都不包含某条边,则该边必为 `cross`。 + +这相当于一个很小的局部约束传播器。它比单纯的“已有两条线就打叉、已有三条叉就补线”更强,因为它能把数字格和顶点候选联动起来。 + +### 4. 顶点度数的直接推理 + +除了候选集,函数还做直接计数: + +- 如果某 cross 已有 2 条线,则其他 incident border 都必须 `cross`。 +- 如果某 cross 已有 3 条 `cross`,则剩下那条也必须 `cross`,因为不能形成度数 1 的死端。 +- 如果某 cross 已有 1 条线且已有 2 条 `cross`,则剩下那条必须 `line`,补足度数 2。 + +### 5. 禁止提前闭环 + +对每条尚未决定的 border,如果它两端 cross 已经在同一个 `path` 分量中,并且棋盘上还有其他线段分量,那么画这条线会提前闭合小环。函数把它打叉。 + +这与 `SingleLoopInCell` 的提前闭环规则同源,只是判断对象从 cell path 改成 cross path。 + +## 二者的相同点与差异 + +### 相同点 + +- 都是“单一回路”推理引擎,不负责猜测,专门推出局部必然结论。 +- 都把未知边逐步决定为 `line` 或 `cross`。 +- 都维护度数约束:路径上的节点度数必须为 2,不能出现度数 1 的死端,也不能出现度数超过 2 的分叉。 +- 都利用 puzz.link 的 `linegraph.components` / `path` 信息禁止提前形成小环。 +- 都引入内外侧思想:单环会把平面分成内外两侧,颜色或 `in/out` 可继续反推线与叉。 +- 都作为题型专用规则的底座。Masyu 在其上加珠子规则,Slitherlink 在其上加数字格规则。 + +### 差异 + +| 维度 | `SingleLoopInCell` | `SingleLoopInBorder` | +| --- | --- | --- | +| 典型题型 | Masyu | Slitherlink | +| 路径位置 | 穿过格子中心 | 画在格子边界 | +| 图顶点 | cell | cross | +| 图边 | 相邻 cell 之间的 border | 相邻 cross 之间的 border | +| 约束核心 | 哪些格子必须被经过,经过格度数为 2 | 每个顶点度数为 0 或 2,每个数字格周围线数匹配数字 | +| 内外标记 | `cross.qsub = in/out` | `cell.qsub = green/yellow` | +| 候选传播 | 主要依靠格子度数、连通分量、内外侧传播 | 额外在每个 cross 上保存 incident edge 组合候选 | +| 提前闭环判断 | 连接同一 cell path 会不会闭合小环 | 连接同一 cross path 会不会闭合小环 | +| 题型局部规则 | 白珠直行并邻格转弯,黑珠转弯并两侧直行 | 数字格周围线数,常见 2/3/3-3 模式,区域染色 | + +一个简化理解是:`SingleLoopInCell` 解的是“哪些格子连成环”;`SingleLoopInBorder` 解的是“哪些边界围成环”。两者的 border 都是最终画线的对象,但图论意义不同。 + +## MasyuAssist 的专用策略 + +`MasyuAssist()` 在通用单环推理后,定义: + +```js +let isBlack = c => !c.isnull && c.qnum === CQNUM.bcir; +let isWhite = c => !c.isnull && c.qnum === CQNUM.wcir; +let isPathable = b => !b.isnull && !isCross(b); +``` + +然后对每个 cell、每个方向 `d` 套用一组旋转对称的局部规则。下面按策略含义解释。 + +### 1. 白珠与内外侧的关系 + +白珠规则:线必须直行通过白珠,并且至少在白珠相邻的一侧转弯。 + +代码中: + +```js +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); +} +``` + +这是在利用白珠的“直行”性质传播对角 cross 的内外侧。白珠直行通过时,对角上的两侧关系可以被确定:一个角的 `in/out` 已知,另一个对角也能推出。它不直接画线,但会让后续内外侧传播推出 `line` 或 `cross`。 + +### 2. 黑珠夹在两个白珠对角之间时的内外侧关系 + +黑珠规则:线必须在黑珠处转弯,并且离开黑珠后的下一段仍要直行。 + +代码检查黑珠两侧对角位置有白珠: + +```js +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); +} +``` + +以及另一个镜像关系。含义是:黑珠必须转弯,附近两个白珠又会要求直行并在邻格转弯,这个组合会固定某些角点的内外侧同异关系。作者把它作为拓扑传播规则,而不是显式枚举所有线型。 + +### 3. 两个黑珠中间一侧被堵:另一侧不能走 + +图形注释: + +```text + +×+ +×+ +● ● -> ● ● + + + +×+ +``` + +若一个空位左右各有黑珠,并且上方边已经不可走,那么下方边也必须不可走。 + +原因是黑珠如果朝中间延伸,会要求离开黑珠后继续直行;两个黑珠同时受限时,中间路径会造成无法满足的进出方式。作者用这个模式提前打叉,减少后续搜索。 + +### 4. 白珠已有一侧线,或垂直方向被堵:强制横向直行 + +图形注释: + +```text ++ + +×+ +━○ -> ━○━ ++ + +×+ +``` + +如果白珠左侧已有线,那么它必须从右侧出去,且上下不能走。或者如果上方不可走,白珠不能选择竖直轴,只能选择水平轴。于是: + +- 左右两侧加线。 +- 上下两侧打叉。 + +这是最基础的白珠补全:白珠必须直行,不能转弯经过。 + +### 5. 白珠一侧已经连续直行两段:另一侧不能继续直行太远 + +图形注释: + +```text ++ + + + + + + + +━━━○━╸ -> ━━━○━╸× ++ + + + + + + + +``` + +白珠要求“至少一侧相邻格要转弯”。如果白珠左侧已经出现连续两段直线,说明左侧相邻格没有转弯,那么右侧相邻格必须承担转弯义务,不能继续向右直行第二段。因此右侧远端边打叉。 + +这是白珠规则中容易漏掉的二阶约束:不仅白珠本身直行,白珠两侧的下一格还要至少一边发生转弯。 + +### 6. 连续白珠或两侧都无法承担直行轴:强制改用垂直轴 + +图形注释: + +```text ++ + + + + +┃+ + +━╸ ○ ○ -> ━╸×○×○ ++ + + + + +┃+ + +``` + +代码判断白珠左右两侧是否都不适合作为直行通过方向。触发条件包括: + +- 某侧已经有远端直线,使该侧无法满足白珠邻格转弯要求。 +- 某侧相邻格也是白珠,连续白珠会互相限制直行轴。 +- 某侧上下转弯候选都不可用,或被黑珠局部规则堵住。 + +当左右轴无法满足白珠要求时,白珠只能使用上下轴: + +- 左右打叉。 +- 上下加线。 + +这个规则体现了作者的模式化思路:不是只看白珠当前四边,而是看相邻一格、两格范围内的“这条轴能否合法完成白珠条件”。 + +### 7. 黑珠一侧不能作为出口:强制从另一侧进入,并堵掉坏方向 + +图形注释把多个情形合并: + +```text ++ + + : + + + : + +┃+ : + + + : + + + + + + +━● : ●× : ● ╹ : ● ● : ● × -> ━●× ++ + + ; + + + ; + + + ; + + + ; + + + + + + +``` + +对黑珠来说,若向右作为出口不合法,则左侧必须是线,右侧必须打叉。右侧不合法的原因包括: + +- 黑珠右侧边已经不可走。 +- 从黑珠向右走后,下一格不能继续直行。 +- 右侧相邻位置有黑珠,会冲突。 +- 右侧第二段不可走。 +- 某些相邻已有线会迫使黑珠不能按规则转弯后直行。 + +这对应黑珠基本规则:黑珠必须转弯,且离开黑珠的两个方向都要至少延伸一格。若某个方向不能满足“离开后继续直行”,该方向不能作为黑珠出口。 + +### 8. 黑珠已有一侧线:同方向第二段强制为线 + +图形注释: + +```text ++ + + + + + + ●━╸ -> ●━━━ ++ + + + + + +``` + +如果黑珠右侧已有线,那么这条线离开黑珠后必须继续直行一段,所以右侧第二段强制为线。 + +这正是黑珠“转弯后两边必须直行一格”的补全规则。 + +### 9. 黑珠面向两个连续白珠:反方向强制为线 + +图形注释: + +```text ++ + + + + + + + + + + ● ○ ○ -> ━● ○ ○ ++ + + + + + + + + + +``` + +如果黑珠某方向上隔一个空格后出现两个连续白珠,那么黑珠不能朝那边延伸。因为连续白珠会要求直行与邻格转弯,和黑珠离开后继续直行的需求冲突。于是黑珠必须选择反方向的一条线。 + +这个模式已经在当前项目的 `createBlackFacingConsecutiveWhitesRule()` 中有对应实现。 + +### 10. 黑珠被两个斜向白珠夹住:朝另一侧强制为线 + +图形注释: + +```text ++ + + + + + + + + ○ ○ ○ ○ ++ + + + -> + + + + + ● ● ++ + + + + +┃+ + +``` + +如果黑珠某侧的两个斜对角都是白珠,那么黑珠朝这一侧转弯/延伸会让两个白珠的转弯义务变得不可满足。于是黑珠必须朝相反方向延伸。 + +这个模式对应当前项目里的 `createBlackDiagonalWhitePinchRule()`。 + +### 11. 已有路径形状配合白珠/黑珠避免小环 + +后面几段以四条已有线构成一个拐角路径为前提: + +```js +[[0, .5], [0, 1.5], [.5, 0], [1.5, 0]].every(([dx, dy]) => isLine(offset(cell, dx, dy, d))) +``` + +这表示局部已经有一个 `┏` 形或类似的路径框架。若附近出现特定白珠或黑珠,且当前线图还有多个连通分量,作者会强制补出某些线,避免该路径框架闭合成局部小环或让珠子规则无解。 + +这些规则更像“模式库”: + +- 拐角路径旁有两个白珠时,补出穿过白珠的直线与转弯结构。 +- 拐角路径旁有黑珠与白珠时,补出黑珠离开后的直线延伸。 +- 若不补这些线,就会导致已有路径分量只能错误闭合,或违反白/黑珠的局部形态。 + +这些规则的解释性要求比较高。迁移时不建议只照搬坐标模式,而应把它们转写成“假设不走这条边会造成提前闭环或珠子不可满足”的小型反证规则。 + +### 12. 基于连通性的黑珠强制 + +代码: + +```js +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)); +} +``` + +含义是:黑珠与同方向隔两格的 cell 已经属于同一条路径分量,而中间格还不在路径上。如果黑珠再朝这个方向走,可能会把同一分量过早接回,且黑珠还需要转弯后直行。因此反方向边被强制为线。 + +它把黑珠局部规则和全局单环连通性结合起来。 + +### 13. 基于连通性的白珠强制 + +代码: + +```js +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)); +} +``` + +如果白珠左右两侧已经属于同一路径分量,那么让白珠横向直行会提前闭合该分量。因此白珠必须改走垂直方向,上下两边强制为线。 + +这是很典型的“白珠必须直行,但直行轴不能造成小环,所以选择另一条轴”的解释型规则。 + +## SlitherlinkAssist 在 `SingleLoopInBorder` 上追加的策略 + +为了对比,Slitherlink 的题型规则主要围绕数字格展开: + +- 若某数字格周围已有线数等于数字,其余边全部打叉。 +- 若某数字格周围未打叉的边数等于数字,所有未打叉边全部画线。 +- 利用 cross 上的候选集与数字格组合过滤,进一步收缩顶点候选。 +- 对常见局部形状加入模式,例如相邻 `3-3`、`2-3` 在特定边已叉时强制画线和打叉。 +- 基于内外染色,数字格周围相邻格的颜色数量可以反推该格是内侧还是外侧,也可以反推边是线还是叉。 + +因此 Slitherlink 的求解重心是“边界数量 + 顶点度数 + 内外区域”;Masyu 的求解重心是“格心路径 + 珠子局部几何 + 单环连通性”。 + +## 对可解释分步求解器的迁移建议 + +### 1. 把通用单环层和题型规则层拆开 + +建议将 Masyu 求解器分成两层: + +- 通用格心单环层:负责度数、死端、提前闭环、连通分量、必须经过格子的基础约束。 +- Masyu 珠子层:负责白珠直行并邻格转弯、黑珠转弯并两侧延伸,以及由珠子组合产生的模式。 + +这样每一步解释可以清楚地区分: + +- “这是所有单环题都成立的拓扑理由。” +- “这是 Masyu 珠子规则导致的局部必然。” + +### 2. 用 IR 中的 line/cell 图显式表达 `SingleLoopInCell` + +当前项目已经在 Masyu completion 和 rules 中把 line 视为相邻 cell 的连接。后续可以继续沿用: + +- 顶点:`cellKey(row, col)`。 +- 边:`lineKey([row1, col1], [row2, col2])`。 +- 度数:某 cell incident line 数。 +- 连通性:并查集维护 line 连接的 cell 分量。 +- 提前闭环:候选 line 两端已在同一分量,且还有其他 line 分量或未满足 pearl 时,候选 line 为 blank。 + +这与油猴脚本里的 `cell.path` / `linegraph.components` 思路一致,但在项目 IR 中更容易生成解释文本。 + +### 3. 每条规则都输出“局部事实 + 结论” + +例如白珠规则可以解释成: + +```text +白珠 (R3, C4) 必须直行通过。左侧已有线,因此右侧也必须为线,上下两侧不能走。 +``` + +黑珠规则可以解释成: + +```text +黑珠 (R5, C2) 已经向东连线。黑珠离开后必须继续直行一格,因此东侧第二段也必须为线。 +``` + +提前闭环规则可以解释成: + +```text +若画上 (R2, C2)-(R2, C3),会闭合同一个路径分量;但仍有其他线段或未满足珠子在环外,所以该线必须为空。 +``` + +这种解释比“匹配了某某模式”更适合分步可解释求解器。 + +### 4. 谨慎迁移坐标模式库 + +`MasyuAssist()` 中一些图形注释规则很强,但它们是以旋转坐标和已有线形写死的模式。迁移时建议分三类: + +- 基础珠子规则:优先迁移,解释清楚,风险低。 +- 珠子组合规则:例如黑珠面对连续白珠、黑珠斜向被白珠夹住,适合迁移为命名模式。 +- 复杂路径框架规则:应先写测试,最好用小型 lookahead 或反证表达,不要只照搬坐标。 + +### 5. 保留内外侧推理作为增强层 + +`SingleLoopInCell` 的 cross `in/out` 推理非常有价值,但对解释系统来说需要谨慎包装。它本质上是平面拓扑二染色: + +- 非线边两侧同色。 +- 线边两侧异色。 +- 同色推出非线。 +- 异色推出线。 + +若未来要实现,应把它作为“环内外侧标记”或“拓扑染色”规则,并在 UI 中可视化内外侧,否则用户可能难以理解为什么一个远处角点颜色能推出某条线。 + +## 最适合优先内化的策略清单 + +优先级从高到低: + +1. 格心单环基础:经过点度数为 2、不能死端、不能分叉。 +2. Masyu 基础珠子:白珠直行并至少一侧邻格转弯;黑珠转弯并两侧延伸一格。 +3. 提前闭环禁止:候选线连接同一分量且仍有其他内容未纳入时打叉。 +4. 白珠轴选择:某一轴被堵或无法满足邻格转弯时,强制另一轴。 +5. 黑珠出口可用性:某方向无法延伸一格时,禁止该方向。 +6. 黑珠面对连续白珠、黑珠被斜向白珠夹住等命名模式。 +7. 内外侧拓扑染色。 +8. 复杂路径框架 + 珠子组合的反证规则。 + +总体来看,作者的 Masyu 求解不是单纯堆局部珠子口诀,而是“通用单环拓扑引擎 + Masyu 珠子几何模式 + 少量连通性反证”的组合。这一点很适合迁移到分步可解释求解器:先让通用单环层稳定地产生基础结论,再逐步加入可命名、可测试、可解释的珠子策略。 diff --git a/docs/MASYU_CHANGELOG.md b/docs/MASYU_CHANGELOG.md new file mode 100644 index 0000000..2aff625 --- /dev/null +++ b/docs/MASYU_CHANGELOG.md @@ -0,0 +1,214 @@ +# Masyu Implementation Changelog + +## 2026-05-17 Deterministic Rule Increment + +This update adds the first replay-safe Masyu solving rules. The goal is still +incremental: keep each rule local, deterministic, explainable, and backed by +small fixtures before moving toward graph or coloring techniques. + +## Implemented + +- Added Masyu rule helpers in `src/domain/rules/masyu/rules/shared.ts`: + - Cardinal directions, opposite/turn checks, and direction offsets. + - Directional center-line lookup from a cell. + - Two-step line lookup for pearl extension logic. + - Line-decision collection helpers that avoid overwriting decided marks. +- Added pearl-local rules in `src/domain/rules/masyu/rules/pearls.ts`: + - `White Circle Rule`: white pearls go straight through the pearl, reject + blocked axes, blank perpendicular turn exits, and now enforce the adjacent + turn requirement when one side already runs straight for two segments. + - `Black Circle Rule`: black pearls turn, extend any known exit straight one + more cell, reject impossible exits, and blank the opposite side of a known + exit on the same axis. +- Added generic center-line completion in + `src/domain/rules/masyu/rules/completion.ts`: + - `Pearl Completion`: pearl cells get their own completion pass, separate + from ordinary cells. White pearls only complete straight-through exits; + black pearls only complete turn exits and try to extend confirmed exits one + more cell. + - Non-pearl cells with degree 2 blank every other candidate. + - Non-pearl cells with one known line and one remaining candidate force that + candidate as a line. + - Non-pearl dead-end candidates are blanked. +- Added graph and candidate look-ahead rules: + - `Prevent Premature Loop`: center-line loop components reject unknown lines + that would close a smaller loop while other confirmed lines remain outside. + - `Black Pearl Candidate Pruning`: enumerates the four black-pearl turns, + applies a shallow non-recursive feasibility check, and keeps only compatible + candidates before forcing shared exits, shared extensions, and excluded + adjacent exits. This rule is intentionally single-target per step, and its + white-pearl look-ahead validates an actual adjacent-turn overlay before + rejecting a candidate. + - `White Circle Rule` line forcing now checks both endpoints before adding a + line, so it will not push a neighboring cell above degree 2. +- Added local pattern rules in `src/domain/rules/masyu/rules/patterns.ts`: + - `Black Facing Consecutive Whites`: a black pearl facing two consecutive + white pearls two and three cells away is forced to leave the opposite way. + - `Black Diagonal White Pinch`: two diagonal white pearls on one side of a + black pearl force the black pearl away from that side. + - `Consecutive White Pearls Straight`: a run of three or more adjacent white + pearls is forced to pass perpendicular to the run. + - `Double Black Squeeze`: two black pearls with one middle cell between them + force the opposite perpendicular exit blank when the other perpendicular + exit is already blank. +- Registered the current Masyu rule order: + 1. `White Circle Rule` + 2. `Black Circle Rule` + 3. `Black Facing Consecutive Whites` + 4. `Black Diagonal White Pinch` + 5. `Consecutive White Pearls Straight` + 6. `Double Black Squeeze` + 7. `Prevent Premature Loop` + 8. `Black Pearl Candidate Pruning` + 9. `Pearl Completion` + 10. `Cell Completion` + +## Validation + +Focused tests live in `src/domain/rules/masyu/rules.test.ts` and cover: + +- White pearl straight-through, blocked-axis, and adjacent-turn deductions. +- Black pearl turn, extension, and impossible-exit deductions. +- Black-pearl local patterns. +- Consecutive white-pearl run patterns. +- Pearl-specific completion and double-black squeeze completion. +- Premature-loop prevention and black-pearl candidate pruning. +- Regression coverage for + `https://puzz.link/p?mashu/10/6/0000b6103260i0902216`, ensuring candidate + pruning does not batch multiple black pearls into one unsafe step. +- Regression coverage for a larger `mashu/49/39` puzzle where white-pearl + straight forcing previously created a degree-3 neighbor. +- Registration order and line-diff application on the sample Masyu puzzle. + +Commands run successfully: + +```bash +pnpm test:run src/domain/rules/masyu/rules.test.ts +pnpm lint +``` + +Focused result at implementation time: + +- 1 test file passed. +- 56 Masyu rule tests passed. + +## Notes For Future Agents + +- Keep using `PuzzleIR.lines` as the canonical Masyu loop state. +- New Masyu rules should continue to return explicit `LineDiff`s only unless a + future feature deliberately introduces replay support for another state field. +- Avoid contradiction masking: if a target line is already decided as the + opposite mark, skip the inference and leave invalidity reporting to a later + completion/analysis layer. +- The next useful rule families are graph-level single-loop constraints + (`premature loop prevention`, `candidate bridge`) and more white-pearl axis + elimination, before any Masyu coloring work. + +## 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 stronger deterministic Masyu solving +rules. Use `docs/MASYU_RULE_ABSTRACTIONS.md` as the implementation-oriented +taxonomy and `docs/MASYU_ASSIST_STRATEGIES_CN.md` as provenance for the original +strategy source. + +Recommended next steps: + +- Add premature-loop prevention over Masyu `lines`. +- Add candidate-graph bridge inference over non-blank center-line candidates. +- Continue expanding local white-pearl axis elimination and optional pattern + rules with focused fixtures. +- Keep each rule small, named, deterministic, and backed by focused tests. diff --git a/docs/MASYU_RULE_ABSTRACTIONS.md b/docs/MASYU_RULE_ABSTRACTIONS.md new file mode 100644 index 0000000..114f07b --- /dev/null +++ b/docs/MASYU_RULE_ABSTRACTIONS.md @@ -0,0 +1,743 @@ +# Masyu Rule Abstractions + +This document is an implementation-oriented rule taxonomy for adding deterministic Masyu solving to PuzzleKit. It abstracts the current Puzzlink Assistance strategy notes into rules that do not depend on that userscript's object model. + +The target reader is a developer or AI agent implementing `src/domain/rules/masyu/*` rules that return replay-safe `RuleDiff`s. Use `docs/MASYU_ASSIST_STRATEGIES_CN.md` only as provenance for the original strategy source; use this document as the implementation spec. + +## Current PuzzleKit Model + +Masyu currently uses a center-line model: + +- `PuzzleIR.cells`: stores pearl clues as `{ kind: "pearl", color: "white" | "black" }`. +- `PuzzleIR.lines`: stores center-to-center loop decisions using `LineState.mark`. +- `LineMark`: `unknown`, `line`, or `blank`. +- `PuzzleIR.tiles`: reserved for future region/corner coloring units. +- `PuzzleIR.edges`: remains available for Slitherlink-style vertex-to-vertex edges, but should not be the canonical Masyu loop state. + +Implementation should prefer small helpers that hide geometry details: + +- `getMasyuNeighborCells(puzzle, cellKey)`: orthogonal in-bounds neighbor cell keys. +- `getMasyuIncidentLineKeys(puzzle, cellKey)`: up to four center-line keys incident to a cell. +- `getMasyuDirectionalLine(puzzle, cellKey, direction, distance)`: the line at a directional offset, for pearl-local rules. +- `getMasyuCellDegree(puzzle, cellKey)`: count incident lines marked `line`. +- `getMasyuUnknownExits(puzzle, cellKey)`: incident lines still marked `unknown`. +- `buildMasyuLineComponents(puzzle)`: connected components of cells joined by `line` marks. +- `buildMasyuCandidateGraph(puzzle)`: graph of cells connected by lines that are not `blank`. + +Rules should produce explicit diffs: + +- Use `LineDiff` for loop decisions. +- Use `CellDiff` only if a future rule stores visible cell color/fill state. +- If Masyu coloring is added as a first-class hidden or visible state, prefer a dedicated IR field only after deciding how replay and rendering should expose it. Until then, treat coloring rules in this document as design guidance. + +## Rule Design Principles + +Each rule should be deterministic, local where possible, and replay-safe. + +- Do not mutate `PuzzleIR` while inspecting it. +- Collect all compatible updates in local maps, then return diffs. +- If a rule can infer both `line` and `blank`, reject or skip contradictory updates instead of masking them. +- Report a concise message with the first clear example and a total count. +- Keep each rule narrow enough that a failing test points to one reasoning idea. + +Recommended rule card fields: + +- Intent: what the rule proves. +- Input state: what facts it reads. +- Algorithm: implementation steps. +- Output diffs: what it can write. +- Explanation message: user-facing wording pattern. +- Tests: focused fixtures that prove the rule and replay. + +## Rule Family 1: Generic Single Loop In Cell + +These rules are puzzle-generic for loops that pass through cell centers. They should be implemented before pearl-specific rules because black and white pearl deductions depend on them. + +### Rule: Pearl Pass-Through Degree + +Intent: every pearl cell must have loop degree 2. + +Input state: + +- Pearl cells from `PuzzleIR.cells`. +- Incident center lines from `PuzzleIR.lines`. + +Algorithm: + +1. For each pearl cell, count incident `line`, `blank`, and `unknown` lines. +2. If two incident lines are already `line`, mark all remaining unknown incident lines `blank`. +3. If exactly two incident lines are not `blank`, mark both as `line`. +4. If fewer than two incident lines are available, return no inference; completion analysis should report invalidity later. + +Output diffs: `LineDiff` from `unknown` to `line` or `blank`. + +Explanation message: `Pearl (Rr, Cc) must have degree 2, so the only two available exits are lines.` + +Tests: + +- Pearl with two unknown exits and two blanks. +- Pearl with two lines and two unknown exits. +- Border/corner pearl with only two possible exits. + +### Rule: Center-Line Degree + +Intent: the loop has degree 0 or 2 at every cell center, and degree 2 at pearl cells. + +Input state: + +- All cells, not only pearls. +- Incident center lines. + +Algorithm: + +1. For each cell, count incident `line` and `unknown` lines. +2. If `lineCount === 2`, mark remaining unknown incident lines `blank`. +3. If `lineCount === 1 && unknownCount === 1`, mark the only unknown line `line`. +4. If `lineCount === 0 && unknownCount === 1` for a non-pearl cell, mark the only unknown line `blank`; using it would create a dead end. +5. For pearl cells, delegate forced two-exit logic to `Pearl Pass-Through Degree` or share a helper. + +Output diffs: `LineDiff`. + +Explanation message: `Cell (Rr, Cc) already has two loop lines, so every other exit is blank.` + +Tests: + +- A path entering a cell with only one unknown continuation. +- A completed degree-2 cell. +- A non-pearl dead-end candidate. + +### Rule: Premature Loop Prevention + +Intent: do not close a smaller loop before all required pearls are in the final loop. + +Input state: + +- Current `line` graph on cell centers. +- Unknown center lines. +- Pearl cells that still need to be included. + +Algorithm: + +1. Build a union-find over cell centers connected by existing `line` marks. +2. For each unknown line between cells `a` and `b`, check whether `a` and `b` are already in the same component. +3. If they are in the same component, adding this line closes a cycle. +4. Mark the line `blank` unless this closure would be the final valid loop containing all required pearl cells and no unresolved line component remains. A first implementation can conservatively blank same-component closures whenever any pearl has degree less than 2 or there is more than one active line component. + +Output diffs: `LineDiff` to `blank`. + +Explanation message: `Line (Rr, Cc)-(Rr2, Cc2) would close the current path before all pearls are connected, so it is blank.` + +Tests: + +- A nearly closed small loop with an outside pearl. +- A same-component candidate when multiple active components exist. +- A fully solved final loop should not be processed by this rule as a new inference. + +Implementation analogy: this is the center-line counterpart of Slitherlink's `createPreventPrematureLoopRule()`, but vertices become cell centers and `edges` become `lines`. + +### Rule: Candidate-Graph Bridge Line + +Intent: if all required loop material can stay connected only through a candidate line, that line must be used. + +Input state: + +- Candidate graph where every non-blank line is an edge. +- Source nodes: pearl cells and cells already incident to a line. +- Current known line components. + +Algorithm: + +1. Build the candidate graph from all cell centers and lines whose mark is not `blank`. +2. Treat existing line components and pearl cells as required sources. +3. Run Tarjan low-link analysis to find bridges or articulation structures that separate required sources. +4. If a candidate line is the only connection between two required-source sides, mark it `line`. +5. Keep the first version conservative: only infer when a single unknown line is the bridge between two source-containing components. + +Output diffs: `LineDiff` to `line`. + +Explanation message: `Line (Rr, Cc)-(Rr2, Cc2) is the only candidate connection between required loop regions, so it is a line.` + +Tests: + +- Two pearl groups connected by a one-cell-wide corridor. +- Existing line endpoint that can reach the rest of the puzzle through only one unknown line. +- No inference when two independent candidate corridors exist. + +Implementation analogy: this rule uses the same low-link idea as Slitherlink color connectivity cut coloring, but the graph is the Masyu candidate line graph rather than a colored-cell region graph. + +## Rule Family 2: Pearl-Local Rules + +These rules encode the Masyu clue semantics directly. They should work from directional axes rather than from copied pattern diagrams. + +Use directions `N`, `E`, `S`, `W`. An axis is an opposite pair, such as `E-W` or `N-S`. + +### Rule: Black Pearl Turn + +Intent: a black pearl must turn at the pearl cell. + +Input state: + +- Black pearl cell. +- Incident lines and blanks. +- Two axis groups: north-south and east-west. + +Algorithm: + +1. For each axis group, count incident `line` and non-blank candidate exits. +2. A valid black pearl uses exactly one exit from each axis group. +3. If one direction in an axis group is already `line`, mark the opposite direction in that axis group `blank`. +4. If an axis group has exactly one non-blank candidate and no line yet, mark that candidate `line`. +5. If an axis group has zero candidates, return no inference; completion analysis should report the contradiction. + +Output diffs: `LineDiff` to `blank` or `line`. + +Explanation message: `Black pearl (Rr, Cc) must use one vertical and one horizontal exit, so the only remaining vertical candidate is a line.` + +Tests: + +- Black pearl with an east line forces west blank. +- Black pearl with only one vertical candidate forces that vertical candidate to line. +- Black pearl with no vertical candidates produces no diff and is left for completion analysis. + +### Rule: Black Pearl Straight Extension + +Intent: after leaving a black pearl, the loop must continue straight for at least one more cell. + +Input state: + +- Black pearl cell. +- A known incident line in direction `d`. +- The next line in direction `d` from the neighboring cell. + +Algorithm: + +1. For each black pearl and direction `d`, if the incident line in `d` is `line`, find the next forward line beyond the adjacent cell. +2. If that next line is unknown, mark it `line`. +3. If that next line is already `blank`, do not infer here; invalidity belongs to completion analysis. + +Output diffs: `LineDiff` to `line`. + +Explanation message: `Black pearl (Rr, Cc) exits east, so the line must continue straight through the next cell.` + +Tests: + +- Each direction extension. +- Extension at board boundary should produce no diff. +- Existing extension line should be ignored. + +### Rule: Black Pearl Impossible Exit + +Intent: remove any black-pearl exit direction that cannot satisfy the turn-and-extension constraint. + +Input state: + +- Black pearl cell. +- Candidate incident direction `d`. +- Neighbor cell and forward extension line in direction `d`. +- Side lines through the neighbor cell. +- Nearby pearl clues. + +Algorithm: + +For each candidate direction `d`, mark the incident line `blank` if any of these are true: + +- The incident line is already unavailable. +- The forward extension line beyond the neighbor is unavailable. +- The neighbor already has a perpendicular line that would prevent straight extension. +- The neighbor is another black pearl, making the required straight continuation incompatible with that pearl's turn. +- Taking direction `d` would force the black pearl to go straight through the pearl cell. + +After blanking an impossible exit, ordinary degree rules can force the remaining exits. + +Output diffs: `LineDiff` to `blank`. + +Explanation message: `Black pearl (Rr, Cc) cannot exit east because the required straight extension is blocked.` + +Tests: + +- Blocked forward extension. +- Neighboring black pearl. +- Perpendicular line at the required extension cell. + +### Rule: White Pearl Straight Through + +Intent: a white pearl must go straight through the pearl cell. + +Input state: + +- White pearl cell. +- Incident lines and blanks. + +Algorithm: + +1. If an incident line on one side of an axis is `line`, mark the opposite side of that axis `line`. +2. Mark both perpendicular incident lines `blank`. +3. If both sides of one axis are unavailable, mark both sides of the other axis `line`. +4. If exactly one axis remains possible, force it. + +Output diffs: `LineDiff` to `line` and `blank`. + +Explanation message: `White pearl (Rr, Cc) must go straight, so the opposite exit is also a line and perpendicular exits are blank.` + +Tests: + +- Known east line forces west line and north/south blank. +- North/south blocked forces east-west line. +- One remaining possible axis. + +### Rule: White Pearl Adjacent Turn Requirement + +Intent: a white pearl must turn in at least one adjacent cell immediately before or after the pearl. + +Input state: + +- White pearl cell. +- A chosen or implied straight axis. +- Lines one and two steps away along each side of that axis. + +Algorithm: + +1. If one side of the white pearl already continues straight through the adjacent cell, then the opposite side must provide the required adjacent turn. +2. Concretely, if the line entering the pearl from west and the line west of the neighboring west cell are both `line`, mark the far east continuation line `blank`. +3. Apply symmetrically for all axes and directions. + +Output diffs: `LineDiff` to `blank`. + +Explanation message: `White pearl (Rr, Cc) already continues straight on one side, so the other adjacent cell must turn.` + +Tests: + +- Two consecutive line segments on one side of a white pearl. +- Symmetry across all four directions. + +### Rule: White Pearl Axis Elimination + +Intent: eliminate a white-pearl straight axis when either side cannot provide a valid adjacent turn condition. + +Input state: + +- White pearl cell. +- Candidate axis. +- Neighbor cells on both sides of the axis. +- Local line/blank states around those neighbors. +- Nearby pearl clues that constrain those neighbors. + +Algorithm: + +1. For each side of a candidate axis, determine whether that side can still satisfy the white pearl's adjacent-turn requirement. +2. A side cannot satisfy the requirement if the adjacent cell is forced to continue straight, is blocked from turning, or is itself a pearl whose constraints conflict with the required turn. +3. If both sides of an axis cannot satisfy the requirement, mark the two incident lines of that axis `blank` and force the perpendicular axis through `White Pearl Straight Through`. +4. Keep individual blockers as separate helper predicates so explanations stay short. + +Output diffs: `LineDiff` to `blank` and possibly `line`. + +Explanation message: `White pearl (Rr, Cc) cannot use the east-west axis because neither adjacent side can turn, so it must use the north-south axis.` + +Tests: + +- Adjacent white pearl blocks an axis. +- Existing far straight line blocks the adjacent-turn requirement. +- Both adjacent turn positions blocked. + +## Rule Family 3: Local Pattern Rules + +These are deterministic pattern rules derived from common Masyu shapes. Implement them after the core pearl-local rules, and keep each one optional and independently tested. + +### Rule: Double-Black Squeeze + +Intent: two black pearls on opposite sides of a cell can eliminate a perpendicular singleton exit. + +Input state: + +- A middle cell. +- Two black pearls on opposite sides along an axis. +- One perpendicular incident line from the middle cell already blank. + +Algorithm: + +1. For each cell and axis, check whether the two opposite neighbor cells are black pearls. +2. If one perpendicular line from the middle cell is `blank`, mark the other perpendicular line `blank`. +3. Rely on degree and black-pearl extension rules to handle any forced axis lines afterward. + +Output diffs: `LineDiff` to `blank`. + +Explanation message: `The cell between two black pearls cannot use a single perpendicular exit, so the opposite perpendicular exit is blank.` + +Tests: + +- Horizontal black pair with north blocked implies south blank. +- Vertical black pair with east blocked implies west blank. + +### Rule: Black Facing Consecutive Whites + +Intent: a black pearl may be forced away from a direction that leads into two consecutive white pearls. + +Input state: + +- Black pearl. +- Two white pearls at offsets two and three cells in direction `d`. + +Algorithm: + +1. For each black pearl and direction `d`, inspect the two cells at distance 2 and 3. +2. If both are white pearls, mark the opposite incident line from the black pearl `line`. +3. Let black pearl turn and extension rules clean up the remaining exits. + +Output diffs: `LineDiff` to `line`. + +Explanation message: `Black pearl (Rr, Cc) cannot satisfy its exit toward two consecutive white pearls, so it must extend the opposite way.` + +Tests: + +- Horizontal and vertical examples. +- No inference when only one white pearl exists. + +### Rule: Black Diagonal-White Pinch + +Intent: two diagonal white pearls on the same side of a black pearl force the black pearl away from that side. + +Input state: + +- Black pearl. +- Two white pearls at the diagonal cells on one side. + +Algorithm: + +1. For each black pearl and side `s`, inspect the two diagonal cells in front-left and front-right of that side. +2. If both are white pearls, mark the incident line opposite side `s` as `line`. +3. Use normal black-pearl rules to infer the turn partner and extension. + +Output diffs: `LineDiff` to `line`. + +Explanation message: `Two diagonal white pearls pinch black pearl (Rr, Cc), forcing it to leave away from them.` + +Tests: + +- All four orientations. +- No inference when one diagonal is empty or black. + +### Rule: White Neighbor Axis Exclusion + +Intent: neighboring white pearls and blocked turn cells can eliminate a white pearl axis. + +Input state: + +- White pearl. +- Candidate straight axis. +- Adjacent cells along that axis. +- Pearl clues and line states around those adjacent cells. + +Algorithm: + +1. For each candidate axis, evaluate both adjacent side cells. +2. Mark a side as axis-hostile when it is a white pearl in a position that would require incompatible straight-through behavior, or when both turn exits around that side are blocked. +3. If both sides are axis-hostile, blank the candidate axis and force the perpendicular axis. + +Output diffs: `LineDiff`. + +Explanation message: `White pearl (Rr, Cc) cannot use the east-west axis because both neighboring turn positions are unavailable.` + +Tests: + +- Consecutive white pearls on both sides. +- Turn exits blocked by blanks. +- Mixed blockers on the two sides. + +### Rule: L-Path Pearl Continuation + +Intent: an existing L-shaped partial path plus nearby pearl constraints can force continuation lines. + +Input state: + +- A local L shape made of four known center lines around a corner. +- Nearby white or black pearls at fixed offsets. +- More than one active line component, or an otherwise unresolved loop. + +Algorithm: + +1. Detect a local L-shaped path segment using center-line geometry. +2. Check for specific pearl configurations around the open ends: + - two white pearls near the diagonal continuation, + - a black pearl near one end and a white pearl near the other, + - a black pearl that must extend away from the L shape. +3. Add the continuation lines that are forced by pearl semantics and by avoiding a premature local loop. +4. Implement each pearl configuration as a named subrule rather than one large pattern. + +Output diffs: `LineDiff` to `line`. + +Explanation message: `The L-shaped path around (Rr, Cc) and nearby pearls force this continuation to keep the loop connected.` + +Tests: + +- One fixture per subrule. +- Regression test ensuring the rule does not fire after the loop is already complete. + +## Rule Family 4: Masyu Coloring + +Masyu can use a coloring strategy analogous to Slitherlink, but the colored objects differ. + +In Slitherlink, the loop runs on grid edges, so coloring cells as inside/outside works directly: same-colored adjacent cells imply a blank edge; opposite-colored adjacent cells imply a line edge. + +In Masyu, the loop runs between cell centers. The natural colored objects are the small regions around grid corners, not the puzzle cells themselves. This project already reserves `PuzzleIR.tiles` for future vertex-centered coloring units. A Masyu coloring implementation should decide whether `tiles` represent these corner regions. + +### Coloring State + +Use two colors: + +- `inside` +- `outside` + +For implementation parity with Slitherlink, these could be stored as tile fills such as `green` and `yellow`, but the semantic names in rule code should remain `inside` and `outside`. + +Adjacency relation: + +- Two neighboring corner regions separated by no loop crossing have the same color. +- Two neighboring corner regions separated by a Masyu center line have opposite colors. + +Line relation: + +- If adjacent regions are same color, the separating center-line candidate is `blank`. +- If adjacent regions are opposite colors, the separating center-line candidate is `line`. +- If a center line is `line`, adjacent regions become opposite colors. +- If a center line is `blank`, adjacent regions become same color. + +This is the Masyu counterpart of `createColorEdgePropagationRule()`. + +### Rule: Masyu Outside Seeding + +Intent: seed the unbounded exterior region as outside. + +Input state: + +- Tile/corner-region graph. +- Border-adjacent regions. + +Algorithm: + +1. Identify corner regions connected to the board exterior without crossing a known line. +2. Mark them `outside`. +3. Propagate through known blank separations if those are represented. + +Output diffs: future tile/color diffs, or no implementation until the IR supports them. + +Explanation message: `The exterior region is outside, so connected border regions are outside.` + +Implementation analogy: Slitherlink `createColorOutsideSeedingRule()`. + +### Rule: Masyu Line-Color Propagation + +Intent: known line and blank marks propagate region color parity. + +Input state: + +- Known line marks. +- Known region colors. +- Region adjacency separated by each center-line candidate. + +Algorithm: + +1. For each known line, require opposite colors on its adjacent regions. +2. For each known blank, require same colors on its adjacent regions. +3. Use a parity union-find if many implications are processed together. + +Output diffs: future tile/color diffs. + +Explanation message: `This line is part of the loop, so the regions on its sides have opposite colors.` + +Implementation analogy: the second half of `createColorEdgePropagationRule()`. + +### Rule: Masyu Color-Line Propagation + +Intent: known region colors decide line marks. + +Input state: + +- Adjacent region colors. +- Unknown center-line candidate separating them. + +Algorithm: + +1. If two adjacent regions have the same color, mark the separating line `blank`. +2. If two adjacent regions have opposite colors, mark the separating line `line`. + +Output diffs: `LineDiff`. + +Explanation message: `The regions on both sides have opposite colors, so the separating Masyu line is part of the loop.` + +Implementation analogy: the first half of `createColorEdgePropagationRule()`. + +### Rule: Pearl-Local Color Implications + +Intent: pearl semantics can imply region color parity even before line marks are known. + +Input state: + +- Pearl type. +- Local region colors around the pearl. +- Candidate pearl axes. + +Algorithm: + +1. White pearl straight-through behavior implies consistent parity between diagonal corner regions around its straight axis. +2. Black pearl turn behavior implies parity relations across the turn quadrant and straight-extension cells. +3. Encode each implication as a small local color rule only when the geometry is unambiguous. + +Output diffs: future tile/color diffs, or `LineDiff` if colors decide lines immediately. + +Explanation message: `White pearl (Rr, Cc) must go straight, so these two corner regions have opposite parity.` + +Note: these are the algorithmic version of the original assist script's pearl-specific `in/out` propagation. Implement them after basic coloring works. + +### Rule: Masyu Connectivity Cut Coloring + +Intent: Tarjan cut analysis can force unknown regions to become inside or outside when they are required to preserve color-region connectivity. + +Input state: + +- Region graph where known lines are barriers and known blanks are passable connections. +- Known inside or outside source regions. +- Optional exterior source for outside. + +Algorithm: + +1. Compress already-connected same-color regions with union-find. +2. Build a candidate connectivity graph between color components through non-barrier adjacencies. +3. For a target color, treat known target-colored components as sources. +4. Run Tarjan DFS with `discovery` and `low` values. +5. If a component is an articulation point separating target-color sources, color it with the target color. +6. If a component is unreachable from any target source, color it with the opposite color. +7. Run once for inside and once for outside. + +Output diffs: future tile/color diffs. + +Explanation message: `Region connectivity forces this component to be inside because it is a cut between inside sources.` + +Implementation analogy: this is the direct Masyu-region version of Slitherlink's `createColorConnectivityCutColoringRule()` and `findConnectivityColorUpdates()`. + +## Suggested Rule Order + +Start with rules that use only current IR fields: + +1. `masyu-pearl-pass-through-degree` +2. `masyu-center-line-degree` +3. `masyu-white-straight-through` +4. `masyu-black-turn` +5. `masyu-black-straight-extension` +6. `masyu-black-impossible-exit` +7. `masyu-white-adjacent-turn` +8. `masyu-white-axis-elimination` +9. `masyu-premature-loop-prevention` +10. `masyu-candidate-graph-bridge-line` +11. Selected local pattern rules + +Add coloring after the IR has an agreed representation for Masyu regions: + +1. `masyu-outside-seeding` +2. `masyu-line-color-propagation` +3. `masyu-color-line-propagation` +4. `masyu-pearl-local-color-implications` +5. `masyu-connectivity-cut-coloring` + +Keep branch-based or contradiction-based inference out of the first implementation pass. Masyu should first reach parity with deterministic local and graph rules. + +## Implementation Milestones + +Milestone 1: geometry helpers. + +- Add Masyu line-direction helpers. +- Add degree and candidate-exit helpers. +- Add line component builder. +- Add formatter helpers for cells and center lines. + +Milestone 2: generic loop rules. + +- Implement degree rules and premature loop prevention. +- Add focused unit tests for each. +- Register rules through `masyuPlugin.getRules()`. + +Milestone 3: pearl-local rules. + +- Implement black and white pearl rules as separate files or separate factory functions. +- Use small fixtures for each direction and axis. +- Keep explanation messages specific. + +Milestone 4: graph connectivity. + +- Implement candidate-graph bridge/cut inference. +- Use Tarjan low-link helpers if they can be shared cleanly with Slitherlink; otherwise keep a Masyu-local graph helper. + +Milestone 5: optional coloring. + +- Decide whether `PuzzleIR.tiles` should store Masyu region colors. +- Implement color propagation and cut coloring only after replay and rendering semantics are clear. + +## Test Strategy + +Every rule should have tests that cover: + +- One minimal positive fixture. +- One non-firing fixture with a near miss. +- Replay safety: apply the rule, undo via engine replay, and reapply deterministically where existing test harnesses support this. +- Directional symmetry for local rules. +- Conflict avoidance: a rule must not emit a diff from a non-unknown line to a different mark. + +For graph rules, include: + +- A single forced bridge. +- Two alternative corridors where no bridge is forced. +- A same-component premature loop candidate. +- A final-loop-like position that should not be incorrectly blanked. + +For coloring rules, include: + +- Same color implies blank. +- Opposite color implies line. +- Line implies opposite color. +- Blank implies same color. +- Tarjan cut component between two target-color sources. +- Unreachable component becomes the opposite color. + +## Mapping From Strategy Notes To Rule Names + +Use these names when migrating from the older strategy document: + +- `珠子必须被回路经过`: `masyu-pearl-pass-through-degree` +- `度数为二`: `masyu-center-line-degree` +- `无死端`: `masyu-center-line-degree` +- `双出口强制成线`: `masyu-pearl-pass-through-degree` +- `禁止提前闭环`: `masyu-premature-loop-prevention` +- `单环连通桥成线`: `masyu-candidate-graph-bridge-line` +- `白珠被迫直行`: `masyu-white-straight-through` +- `白珠至少一侧转弯`: `masyu-white-adjacent-turn` +- `白珠轴线排除`: `masyu-white-axis-elimination` +- `黑珠非法出口排除`: `masyu-black-impossible-exit` +- `黑珠出口延伸`: `masyu-black-straight-extension` +- `黑珠避开连续白珠`: `masyu-black-facing-consecutive-whites` +- `黑珠斜白夹逼`: `masyu-black-diagonal-white-pinch` +- `双黑夹格垂直禁线`: `masyu-double-black-squeeze` +- `白珠同路径改轴`: `masyu-white-same-component-axis-elimination` +- `黑珠同路径避闭环`: `masyu-black-same-component-exit-elimination` +- `L形路径连通补线`: `masyu-l-path-pearl-continuation` +- `内外异色成线`: `masyu-color-line-propagation` +- `内外同色禁线`: `masyu-color-line-propagation` +- `线段翻转内外`: `masyu-line-color-propagation` +- `禁线保持内外`: `masyu-line-color-propagation` + +## Notes On Reusing Slitherlink Infrastructure + +The Slitherlink color rules are useful templates, not drop-in Masyu rules. + +Reusable ideas: + +- Parity union-find for color constraints. +- Low-link Tarjan traversal for articulation/cut coloring. +- Rule factories that collect local decisions before returning diffs. +- First-example explanation messages with aggregate counts. + +Do not reuse directly without adapting geometry: + +- Slitherlink `edges` are vertex-to-vertex loop edges; Masyu loop decisions are `lines` between cell centers. +- Slitherlink cell colors represent regions separated by grid edges; Masyu region colors should represent corner/tile regions separated by center lines. +- Slitherlink vertex degree is not the same as Masyu cell-center degree. + +The desired end state is conceptual reuse with Masyu-specific helpers, not shared code that hides different geometry behind misleading names. diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index 637cece..ce90f61 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -1,254 +1,246 @@ -# 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`. +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. Timeline store replays diffs forward/backward. -7. Board and explanation panel 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. ---- +## 4. Plugin Contract -## 5. Benchmark and Dataset Flow +Puzzle families are registered in `src/domain/plugins/registry.ts`. -Benchmarks evaluate solver behavior across JSON dataset manifests. They are for -solver quality and rule-usage analysis, not for unit-test correctness. +Each `PuzzlePlugin` owns its family boundary: -Data locations: +- `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` powers the puzzle rules popout. +- `legend` powers board legend examples. +- `getStats(puzzle)` powers compact board-title puzzle stats. -- `dataset/public/**/*.json` is committed and should stay small/curated. -- `dataset/private/**/*.json` is local-only and ignored by git. -- `benchmark-results/` is generated output and ignored by git. +Current families: -Run: - -- `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 -`benchmark-results/.report.json`. - -Current defaults: - -- `maxSteps = 2000` -- `timeoutMs = 60000` -- `ruleProfile = "default"` - -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. -- `ruleSteps[ruleId] = [stepNumbers...]` records where each rule fired. +- 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 -## 6. Slitherlink Rule Architecture (Current) +`PuzzleIR` is the shared normalized state between parser, rules, replay, board, +editor, and exporters. -The Slitherlink rules are now modularized under `src/domain/rules/slither/rules/`. +Important state buckets: -### 6.1 Aggregation entrypoint +- `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. -- `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 +`RuleDiff` is the replay contract. If a rule mutates a new IR bucket, add a diff +type and update both: -### 6.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) +- `src/domain/rules/engine.ts` +- `src/features/solver/solverStore.ts` -### 6.3 Branch inference decoupling +Keep forward and reverse replay behavior aligned. Timeline replay must not +diverge from direct rule execution. -Branch-based inference rules should not self-reference the exported -`slitherRules` array. They receive deterministic rules via dependency -injection, for example: +## 6. Puzzle Techniques -- `createStrongInferenceRule(() => deterministicSlitherRules)` +Do not put puzzle-specific solving techniques in this project guide. Use the +technique notes instead: -This prevents circular coupling and keeps branch inference reusable/testable. +- `docs/techniques/masyu.md` +- `docs/techniques/slitherlink.md` ---- +That file points to the current Slitherlink rule modules, the Masyu changelog, +the Masyu URL encoding reference, and the Masyu strategy research document: -## 7. Sector Constraint Model (Critical) -Sector state is represented as a bitmask of allowed corner line counts `{0,1,2}`. +## 7. Benchmark And Dataset Flow -- 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 +Benchmarks evaluate solver behavior across JSON dataset manifests. They are for +solver quality and rule-usage analysis, not for unit-test correctness. -Do not revert to old single-label sector semantics. +Data locations: ---- +- `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. -## 8. Replay and Determinism Contract +Run: -Two files must stay behaviorally aligned: +```bash +pnpm benchmark:solve +``` -- `src/domain/rules/engine.ts` -- `src/features/solver/solverStore.ts` +The benchmark runner scans public/private manifests, runs each puzzle with the +default plugin rule order, and writes reports to +`benchmark-results/.report.json`. -Both apply the same `RuleDiff` semantics, especially sector mask writes: +Current defaults: -- `puzzle.sectors[sectorKey].constraintsMask = diff.toMask` +- `maxSteps = 2000` +- `timeoutMs = 60000` +- `ruleProfile = "default"` -If these two paths diverge, timeline replay and solver state will drift. +Report intent: ---- +- Per puzzle: status, step count, duration, terminal completion report, + `ruleUsage`, and compact `ruleSteps`. +- `steps` is intentionally empty for now to keep reports small. +- `ruleSteps[ruleId] = [stepNumbers...]` records where each rule fired. -## 9. Current Capability Snapshot +## 8. Current Capability Snapshot Implemented: -- 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 -- Sector mask inference/propagation pipeline -- Strong-inference fallback for harder states -- Public/private benchmark manifest workflow -- Compact benchmark reports with solve status, timing, rule usage, and rule step indices - -Partially implemented / planned: - -- More puzzle families (e.g. Masyu/Nonogram) -- 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 - -Important expectation: difficult puzzles may stop at a stable but incomplete state if no rule applies. - ---- - -## 10. 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. +- 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. -## 11. 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 +``` -## 12. 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..b7e72ec --- /dev/null +++ b/docs/techniques/masyu.md @@ -0,0 +1,37 @@ +# Masyu + +Masyu is a loop puzzle with black and white pearls. The final answer is one continuous loop that passes through every pearl. + +This page is a short user-facing technique note. AI agents and developers should start from `docs/MASYU_AGENT_BRIEF.md` instead. + +## Core Rules + +- The loop travels between cell centers and never branches. +- Every pearl is visited by the loop. +- A white pearl is passed through straight, and the loop must turn in at least one adjacent cell before or after it. +- A black pearl is turned on, and the loop must go straight for at least one cell before and after the turn. +- The final loop must be a single loop, not several disconnected loops or an early small loop. + +## PuzzleKit Model + +PuzzleKit represents Masyu with a center-line model: + +- `PuzzleIR.lines` stores the Masyu loop decisions. +- `PuzzleIR.cells` stores pearl clues. +- `PuzzleIR.tiles` stores vertex-centered inside/outside colors used by Masyu coloring rules. +- `PuzzleIR.edges` is Slitherlink state and is not the Masyu loop model. + +## Current Support + +- Import from Masyu-family `puzz.link` URLs. +- Render pearls, center-to-center loop segments, crosses, and tile colors. +- Replay deterministic solving steps with explanations. +- Analyze completion for a single valid loop and satisfied pearl constraints. +- Apply local pearl rules, selected local patterns, premature-loop prevention, candidate pruning, completion rules, and Masyu tile-color propagation. + +## Developer Pointers + +- Start here for Masyu development: `docs/MASYU_AGENT_BRIEF.md` +- Current rule taxonomy: `docs/MASYU_RULE_ABSTRACTIONS.md` +- Original strategy research: `docs/MASYU_ASSIST_STRATEGIES_CN.md` +- Historical implementation notes: `docs/MASYU_CHANGELOG.md` 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.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..304e9d8 --- /dev/null +++ b/src/app/DatasetPage.test.tsx @@ -0,0 +1,124 @@ +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', + ) + }) +}) diff --git a/src/app/DatasetPage.tsx b/src/app/DatasetPage.tsx new file mode 100644 index 0000000..b28c498 --- /dev/null +++ b/src/app/DatasetPage.tsx @@ -0,0 +1,271 @@ +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, + }) + 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.test.tsx b/src/app/EditorPage.test.tsx index 66e4a96..89a2e86 100644 --- a/src/app/EditorPage.test.tsx +++ b/src/app/EditorPage.test.tsx @@ -161,6 +161,27 @@ describe('EditorPage', () => { expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('line') }) + it('updates slitherlink puzzle stats as editor clues change', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + + fireEvent.focus(screen.getByRole('button', { name: /show puzzle stats/i })) + expect(within(screen.getByRole('tooltip')).getByText('Numbered cells 0 / 25 (0.0%)')).toBeInTheDocument() + + clickCanvas(canvas, 74, 74) + fireEvent.keyDown(canvas, { key: '3' }) + + expect(within(screen.getByRole('tooltip')).getByText('Numbered cells 1 / 25 (4.0%)')).toBeInTheDocument() + expect(within(screen.getByRole('tooltip')).getByText('Clue 3')).toBeInTheDocument() + expect(within(screen.getByRole('tooltip')).getByText('100.0%')).toBeInTheDocument() + }) + it('marks crosses with a strict right-click edge target', () => { render( @@ -259,32 +280,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 +310,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 da5a3fd..0af6a8d 100644 --- a/src/app/EditorPage.tsx +++ b/src/app/EditorPage.tsx @@ -1,312 +1,17 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, 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, } 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 { 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 - } - 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 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 - } - - 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 { @@ -314,11 +19,9 @@ export const EditorPage = () => { puzzle, sourceUrl, importError, - selectedPresetId, setPluginId, createBlankSlither, importFromUrl, - loadPreset, setSlitherCellClue, setSlitherEdgeMark, } = useEditorStore() @@ -326,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)) @@ -346,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 (
@@ -402,6 +58,7 @@ 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..00df36a 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -1,10 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' -import { cellKey, edgeKey } from '../domain/ir/keys' +import { cellKey, edgeKey, lineKey, tileKey } from '../domain/ir/keys' +import { createMasyuPuzzle } from '../domain/ir/masyu' import { createSlitherPuzzle } from '../domain/ir/slither' import type { EdgeMark, PuzzleIR } from '../domain/ir/types' -import { DEFAULT_SOLVE_CHUNK_SIZE, useSolverStore } from '../features/solver/solverStore' +import { rebuildTraceStatsCache } from '../domain/difficulty/traceStats' +import { + DEFAULT_MASYU_SAMPLE_URL, + DEFAULT_SLITHERLINK_SAMPLE_URL, + DEFAULT_SOLVE_CHUNK_SIZE, + useSolverStore, +} from '../features/solver/solverStore' import { WorkspacePage } from './WorkspacePage' import type { RuleStep } from '../domain/rules/types' @@ -63,6 +70,41 @@ describe('WorkspacePage', () => { expect(typeControls?.children[2]?.querySelector('[aria-label="Show Slitherlink legend"]')).not.toBeNull() }) + it('loads the default Masyu sample when selecting Masyu', async () => { + renderWorkspace() + + fireEvent.change(screen.getByDisplayValue('Slitherlink'), { target: { value: 'masyu' } }) + + await waitFor(() => { + const state = useSolverStore.getState() + expect(state.pluginId).toBe('masyu') + expect(state.sourceUrl).toBe(DEFAULT_MASYU_SAMPLE_URL) + expect(state.currentPuzzle.puzzleType).toBe('masyu') + expect(state.currentPuzzle.rows).toBe(5) + expect(state.currentPuzzle.cols).toBe(5) + }) + expect(screen.getByDisplayValue(DEFAULT_MASYU_SAMPLE_URL)).toBeInTheDocument() + }) + + it('reloads the default Slitherlink sample when switching back from Masyu', async () => { + renderWorkspace() + + fireEvent.change(screen.getByDisplayValue('Slitherlink'), { target: { value: 'masyu' } }) + await waitFor(() => expect(useSolverStore.getState().pluginId).toBe('masyu')) + + fireEvent.change(screen.getByDisplayValue('Masyu'), { target: { value: 'slitherlink' } }) + + await waitFor(() => { + const state = useSolverStore.getState() + expect(state.pluginId).toBe('slitherlink') + expect(state.sourceUrl).toBe(DEFAULT_SLITHERLINK_SAMPLE_URL) + expect(state.currentPuzzle.puzzleType).toBe('slitherlink') + expect(state.currentPuzzle.rows).toBe(10) + expect(state.currentPuzzle.cols).toBe(18) + }) + expect(screen.getByDisplayValue(DEFAULT_SLITHERLINK_SAMPLE_URL)).toBeInTheDocument() + }) + it('opens slitherlink board legend from the puzzle type row', () => { renderWorkspace() @@ -164,6 +206,32 @@ describe('WorkspacePage', () => { expect(screen.queryByRole('dialog', { name: /slitherlink rules/i })).not.toBeInTheDocument() }) + it('shows slitherlink puzzle stats from the solver board title', () => { + const puzzle = createSlitherPuzzle(10, 10) + puzzle.cells[cellKey(0, 0)] = { clue: { kind: 'number', value: 0 } } + puzzle.cells[cellKey(0, 1)] = { clue: { kind: 'number', value: 1 } } + puzzle.cells[cellKey(0, 2)] = { clue: { kind: 'number', value: 1 } } + puzzle.cells[cellKey(0, 3)] = { clue: { kind: 'number', value: 2 } } + puzzle.cells[cellKey(0, 4)] = { clue: { kind: 'number', value: 3 } } + puzzle.cells[cellKey(0, 5)] = { clue: { kind: 'number', value: '?' } } + useSolverStore.getState().loadPuzzle(puzzle, { pluginId: 'slitherlink' }) + + renderWorkspace() + + const boardTools = document.querySelector('.board-header-tools') + expect(boardTools?.children[0]?.tagName).toBe('SMALL') + expect(boardTools?.children[1]).toHaveClass('board-zoom-control') + + fireEvent.focus(screen.getByRole('button', { name: /show puzzle stats/i })) + + const statsTooltip = screen.getByRole('tooltip') + expect(within(statsTooltip).getByText('Numbered Cells')).toBeInTheDocument() + expect(within(statsTooltip).getByText('Numbered cells 5 / 100 (5.0%)')).toBeInTheDocument() + expect(within(statsTooltip).getByText('Clue 0')).toBeInTheDocument() + expect(within(statsTooltip).getByText('Clue 1')).toBeInTheDocument() + expect(within(statsTooltip).getByText('40.0%')).toBeInTheDocument() + }) + it('shows solve progress, then terminal report, and keeps solve buttons disabled after close', async () => { const puzzle = createSolvedLoopPuzzle() useSolverStore.setState((state) => ({ @@ -179,7 +247,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 +259,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 +279,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 +297,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() @@ -257,6 +325,178 @@ 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('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() + + 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, + traceStatsCache: rebuildTraceStatsCache(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).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) + 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() @@ -266,12 +506,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') @@ -364,6 +604,48 @@ describe('WorkspacePage', () => { expect(labels).toContain('C4') }) + it('draws Masyu tile colors on the solver board', () => { + const fillRect = vi.fn() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + { + clearRect: () => {}, + save: () => {}, + restore: () => {}, + scale: () => {}, + fillRect, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + strokeRect: () => {}, + fillText: () => {}, + arc: () => {}, + fill: () => {}, + setLineDash: () => {}, + } as unknown as CanvasRenderingContext2D, + ) + const puzzle = createMasyuPuzzle(2, 2) + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'masyu', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps: [], + pointer: 0, + highlightedEdges: [], + highlightedCells: [], + highlightedColorCells: [], + highlightedColorTiles: [], + solveProgress: null, + terminalReport: null, + })) + + renderWorkspace() + + expect(fillRect).toHaveBeenCalledWith(74, 74, 52, 52) + }) + it('toggles reasoning steps between recent 30 and all entries from the header', () => { const steps: RuleStep[] = Array.from({ length: 35 }, (_, index) => ({ id: `step-${index + 1}`, @@ -380,6 +662,7 @@ describe('WorkspacePage', () => { useSolverStore.setState((state) => ({ ...state, steps, + traceStatsCache: rebuildTraceStatsCache(state.initialPuzzle, steps), pointer: steps.length, terminalReport: null, })) @@ -398,16 +681,87 @@ describe('WorkspacePage', () => { expect(screen.getByText(/^1\. test rule$/i)).toBeInTheDocument() }) + it('summarizes Slitherlink edge updates in reasoning steps', () => { + const puzzle = createSlitherPuzzle(1, 1) + const edge = edgeKey([0, 0], [0, 1]) + const steps: RuleStep[] = [ + { + id: 'step-1', + ruleId: 'edge-rule', + ruleName: 'Edge Rule', + message: 'draw edge', + diffs: [{ kind: 'edge', edgeKey: edge, from: 'unknown', to: 'line' }], + affectedCells: [], + affectedEdges: [edge], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 1, + }, + ] + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'slitherlink', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), + pointer: 1, + terminalReport: null, + })) + + renderWorkspace() + + expect(screen.getByText('edge updates: 1')).toBeInTheDocument() + }) + + it('summarizes Masyu line updates and line crosses in reasoning steps', () => { + const puzzle = createMasyuPuzzle(2, 3) + const line = lineKey([0, 0], [0, 1]) + const cross = lineKey([0, 1], [0, 2]) + const steps: RuleStep[] = [ + { + id: 'step-1', + ruleId: 'line-rule', + ruleName: 'Line Rule', + message: 'line and cross', + diffs: [ + { kind: 'line', lineKey: line, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: cross, from: 'unknown', to: 'blank' }, + ], + affectedCells: [], + affectedEdges: [], + affectedLines: [line, cross], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 1, + }, + ] + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'masyu', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), + pointer: 1, + terminalReport: null, + })) + + renderWorkspace() + + expect(screen.getByText('line updates: 1, line crosses: 1')).toBeInTheDocument() + }) + 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, @@ -439,6 +793,13 @@ describe('WorkspacePage', () => { totalDurationMs: 1234, reasons: ['No line edges have been drawn.'], stats: { + totalUnits: 4, + lineUnits: 1, + blankUnits: 1, + unknownUnits: 2, + decidedUnits: 2, + decidedRatio: 0.5, + unitLabel: 'Edges', totalEdges: 4, lineEdges: 1, blankEdges: 1, @@ -456,4 +817,38 @@ describe('WorkspacePage', () => { expect(screen.getByText('1.23 s')).toBeInTheDocument() expect(screen.queryByText(/^Coverage$/i)).not.toBeInTheDocument() }) + + it('shows Masyu stalled decided line count and coverage in one stat', () => { + const puzzle = createMasyuPuzzle(1, 2) + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'masyu', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps: [], + pointer: 0, + solveProgress: null, + terminalReport: { + status: 'stalled', + stepCount: 0, + totalDurationMs: 500, + reasons: ['No line segments have been drawn.'], + stats: { + totalUnits: 1, + lineUnits: 0, + blankUnits: 0, + unknownUnits: 1, + decidedUnits: 0, + decidedRatio: 0, + unitLabel: 'Lines', + }, + }, + })) + + renderWorkspace() + + expect(screen.getByText(/^Decided Lines$/i)).toBeInTheDocument() + expect(screen.getByText(/^Unknown Lines$/i)).toBeInTheDocument() + expect(screen.getByText('0 / 1, 0.0%')).toBeInTheDocument() + }) }) diff --git a/src/app/WorkspacePage.tsx b/src/app/WorkspacePage.tsx index 68cc83f..f65770b 100644 --- a/src/app/WorkspacePage.tsx +++ b/src/app/WorkspacePage.tsx @@ -3,23 +3,28 @@ 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, currentPuzzle, steps, + traceStatsCache, pointer, highlightedCells, highlightedColorCells, + highlightedColorTiles, highlightedEdges, + highlightedLines, includeVertexNumbers, solveProgress, + goToStep, + isRunning, } = useSolverStore() const activeSteps = useMemo(() => steps.slice(0, pointer), [steps, pointer]) - const difficulty = useMemo(() => buildDifficultySnapshot(activeSteps), [activeSteps]) return (
@@ -28,23 +33,32 @@ export const WorkspacePage = () => {

PuzzleKit Web

-

A Step-wise and Explainable Inference Solver for Slitherlink.

+

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

- +
diff --git a/src/app/workspace.css b/src/app/workspace.css index d41450f..14469d3 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; } @@ -133,6 +133,99 @@ color: #6b7280; } +.puzzle-stats-anchor { + position: relative; + display: inline-flex; + align-items: baseline; +} + +.puzzle-stats-info-trigger { + appearance: none; + border: 0; + background: transparent; + color: #64748b; + cursor: help; + font-size: 0.68rem; + font-style: italic; + font-weight: 700; + line-height: 1; + padding: 1px 2px; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} + +.puzzle-stats-info-trigger:focus-visible { + border-radius: 4px; + outline: 2px solid #67e8f9; + outline-offset: 2px; +} + +.puzzle-stats-panel { + position: absolute; + top: calc(100% + 7px); + left: 0; + z-index: 30; + display: grid; + width: 240px; + gap: 8px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + box-shadow: 0 14px 34px rgb(15 23 42 / 0.16); + color: #334155; + font-size: 0.78rem; + font-weight: 500; + line-height: 1.35; + padding: 10px; +} + +.puzzle-stats-panel[hidden] { + display: none; +} + +.puzzle-stats-panel strong { + color: #0f172a; + font-size: 0.82rem; +} + +.puzzle-stats-summary { + color: #475569; +} + +.puzzle-stats-group { + display: grid; + gap: 4px; +} + +.puzzle-stats-group-title { + color: #0f172a; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.puzzle-stats-row { + display: flex; + justify-content: space-between; + gap: 14px; +} + +.puzzle-stats-row > span:last-child { + display: inline-flex; + gap: 6px; + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 700; + white-space: nowrap; +} + +.puzzle-stats-row small { + color: #64748b; + font-size: inherit; + font-weight: 600; +} + .board-focus-shell { outline: none; } @@ -945,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; @@ -960,181 +1053,371 @@ 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; } -.editor-board-card { - min-height: 0; +.stats-timeline-slider { + margin-top: 8px; } -.editor-size-row { +.stats-chart-grid { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; - align-items: end; - gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; } -.editor-size-row button { - min-width: 96px; +.trace-chart-card { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + padding: 10px; } -.editor-url-field { - margin-top: 8px; +.trace-chart-header { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; } -.primary-action { - border-color: #22d3ee; - background: #e0f2fe; +.trace-chart-header h3 { + margin: 0; color: #0f172a; - font-weight: 700; + font-size: 0.9rem; } -.preset-card-meta, -.preset-description { - color: #6b7280; - font-size: 0.82rem; - line-height: 1.35; +.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; } -.preset-tags { +.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; } -.preset-tags span { - border: 1px solid #cbd5e1; +.trace-chart-legend i { + width: 8px; + height: 8px; 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); +.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-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } -.preset-modal { +.rule-usage-empty { + margin: 0; +} + +.rule-usage-bars { 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; + 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; - 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; +.rule-usage-bar-meta { + min-width: 0; } -.preset-modal-header h2 { - margin: 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; - font-size: 1.2rem; } -.preset-modal-header p { - margin: 4px 0 0; - color: #4b5563; +.rule-step-summary { + display: block; + margin-top: 2px; + color: #64748b; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; } -.preset-modal-close { - flex: 0 0 auto; +.rule-usage-table-wrap { + margin-top: 10px; + overflow-x: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; } -.preset-modal-tools { - display: grid; - grid-template-columns: minmax(220px, 360px) minmax(0, 1fr); - align-items: end; - gap: 12px; +.rule-usage-table { + width: 100%; + border-collapse: collapse; + color: #334155; + font-size: 0.8rem; } -.preset-search-field input { - min-height: 38px; +.rule-usage-table th, +.rule-usage-table td { + padding: 7px 8px; + border-bottom: 1px solid #edf2f7; + text-align: left; + vertical-align: top; } -.preset-filter-row { - display: flex; - flex-wrap: wrap; - gap: 6px; +.rule-usage-table th { + background: #f8fafc; + color: #475569; + font-size: 0.74rem; + text-transform: uppercase; } -.preset-filter-row button { - min-height: 32px; +.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; + } + + .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 { + min-height: 0; } -.preset-filter-row button[data-active='true'] { +.editor-size-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + align-items: end; + gap: 8px; +} + +.editor-size-row button { + min-width: 96px; +} + +.editor-url-field { + margin-top: 8px; +} + +.primary-action { border-color: #22d3ee; background: #e0f2fe; color: #0f172a; font-weight: 700; } -.preset-action-error { - flex: 0 0 auto; - margin: 0; +.dataset-workspace-grid { + height: calc(100vh - 32px); + align-items: stretch; } -.preset-grid-scroll { +.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; } -.preset-grid { +.dataset-puzzle-card { display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - gap: 12px; -} - -.preset-library-card { - display: flex; - min-width: 0; - flex-direction: column; + grid-template-columns: 136px minmax(0, 1fr); gap: 10px; + min-width: 0; 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; + padding: 8px; } -.preset-preview { +.dataset-preview { display: grid; - width: 100%; - aspect-ratio: 16 / 9; + width: 136px; + aspect-ratio: 1; place-items: center; overflow: hidden; border: 1px solid #e5e7eb; @@ -1148,49 +1431,99 @@ button[data-active='true'] { font-weight: 700; } -.preset-preview img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.preset-preview-canvas { +.dataset-preview-canvas { display: block; width: 100%; height: 100%; } -.preset-card-body { +.dataset-card-body { display: flex; min-width: 0; - flex: 1; flex-direction: column; - gap: 6px; + justify-content: center; + gap: 5px; } -.preset-card-body h3 { +.dataset-card-body h3 { + overflow: hidden; margin: 0; color: #0f172a; - font-size: 1rem; + font-size: 0.92rem; + text-overflow: ellipsis; + white-space: nowrap; } -.preset-card-body .preset-description { - margin: 0; +.dataset-card-meta { + color: #6b7280; + font-size: 0.78rem; } -.preset-card-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); +.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; + 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; +} + +.dataset-filter-row { + display: flex; + flex-wrap: wrap; gap: 6px; } -.preset-card-actions button { - min-width: 0; - padding-right: 6px; - padding-left: 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; } -.preset-empty { +.dataset-empty { margin: 0; color: #6b7280; } @@ -1210,6 +1543,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) { @@ -1264,24 +1611,15 @@ details pre { grid-template-columns: 1fr 1fr; } - .preset-modal-backdrop { - padding: 12px; - } - - .preset-modal { - width: 100%; - height: 94vh; - } - - .preset-modal-header { - flex-direction: column; + .dataset-puzzle-card { + grid-template-columns: 104px minmax(0, 1fr); } - .preset-modal-tools { - grid-template-columns: 1fr; + .dataset-preview { + width: 104px; } - .preset-modal-close { - width: 100%; + .dataset-card-actions { + gap: 7px; } } diff --git a/src/domain/benchmark/runner.ts b/src/domain/benchmark/runner.ts index 8364d86..7d49701 100644 --- a/src/domain/benchmark/runner.ts +++ b/src/domain/benchmark/runner.ts @@ -1,8 +1,8 @@ import type { PuzzleIR } from '../ir/types' import { puzzleRegistry } from '../plugins/registry' +import { analyzePuzzleCompletion } from '../rules/completion' 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,18 +26,7 @@ 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 +const getTerminal = (puzzleType: string, puzzle: PuzzleIR) => analyzePuzzleCompletion(puzzleType, puzzle) const getStatusCountKey = ( status: BenchmarkPuzzleStatus, @@ -96,11 +85,11 @@ export const runBenchmarkItem = ( const rules = plugin.getRules() while (true) { if (performance.now() - startedAt >= options.timeoutMs) { - return finish('time-capped', getSlitherTerminal(item.puzzleType, puzzle)) + return finish('time-capped', getTerminal(item.puzzleType, puzzle)) } if (stepCount >= options.maxSteps) { - const terminal = getSlitherTerminal(item.puzzleType, puzzle) + const terminal = getTerminal(item.puzzleType, puzzle) return finish( terminal?.status === 'solved' ? 'solved' : 'step-capped', terminal, @@ -113,13 +102,13 @@ export const runBenchmarkItem = ( } catch (error) { return finish( 'runtime-error', - getSlitherTerminal(item.puzzleType, puzzle), + getTerminal(item.puzzleType, puzzle), error instanceof Error ? error.message : String(error), ) } if (!result.step) { - const terminal = getSlitherTerminal(item.puzzleType, puzzle) + const terminal = getTerminal(item.puzzleType, puzzle) return finish(terminal?.status ?? 'stalled', terminal) } diff --git a/src/domain/benchmark/types.ts b/src/domain/benchmark/types.ts index a87ce29..de662ce 100644 --- a/src/domain/benchmark/types.ts +++ b/src/domain/benchmark/types.ts @@ -1,4 +1,4 @@ -import type { SlitherCompletionReport } from '../rules/slither/completion' +import type { CompletionReport } from '../rules/completion' export type BenchmarkDatasetItem = { id: string @@ -37,7 +37,7 @@ export type BenchmarkPuzzleResult = { durationMs: number ruleUsage: Record ruleSteps: Record - terminal: SlitherCompletionReport | null + terminal: CompletionReport | null steps: [] error?: string } diff --git a/src/domain/difficulty/traceStats.test.ts b/src/domain/difficulty/traceStats.test.ts new file mode 100644 index 0000000..0c298d4 --- /dev/null +++ b/src/domain/difficulty/traceStats.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, edgeKey, vertexKey } from '../ir/keys' +import { createMasyuPuzzle } from '../ir/masyu' +import { createSlitherPuzzle } from '../ir/slither' +import type { RuleStep } from '../rules/types' +import { + appendTraceStatsStep, + buildRuleTraceStats, + buildTraceChartStats, + buildTraceStatsView, + createTraceStatsCache, + rebuildTraceStatsCache, + truncateTraceStatsCache, +} 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, line: 0, sector: 1, cell: 1, tile: 0, 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 Masyu line decisions as board progress', () => { + const puzzle = createMasyuPuzzle(1, 2) + const line = Object.keys(puzzle.lines)[0] + const steps: RuleStep[] = [ + makeStep(1, 'line-rule', 'Line Rule', 1, [ + { kind: 'line', lineKey: line, from: 'unknown', to: 'line' }, + ]), + ] + + const stats = buildTraceChartStats(puzzle, steps, 1) + + expect(stats.totalEdges).toBe(1) + expect(stats.current.boardProgressRatio).toBe(1) + expect(stats.current.edgeCoverageRatio).toBe(1) + }) + + 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) + }) +}) + +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 new file mode 100644 index 0000000..665bc17 --- /dev/null +++ b/src/domain/difficulty/traceStats.ts @@ -0,0 +1,510 @@ +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 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, + line: 0, + sector: 0, + cell: 0, + tile: 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('|'))), + ) + +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.puzzleType === 'masyu' ? puzzle.lines ?? {} : 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 decisionMarks = initialPuzzle.puzzleType === 'masyu' ? initialPuzzle.lines ?? {} : initialPuzzle.edges + const totalEdges = Object.keys(decisionMarks).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(decisionMarks)) { + 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], + line: [0], + sector: [0], + cell: [0], + tile: [0], + vertex: [0], + }, + } +} + +export const appendTraceStatsStep = (cache: TraceStatsCache, step: RuleStep): TraceStatsCache => { + const stepNumber = cache.points.length + const next: TraceStatsCache = { + ...cache, + } + + let edgeDiffs = 0 + let lineDiffs = 0 + let sectorDiffs = 0 + let cellDiffs = 0 + let tileDiffs = 0 + let vertexDiffs = 0 + + for (const diff of step.diffs) { + if (diff.kind === 'edge' || diff.kind === 'line') { + const key = diff.kind === 'edge' ? diff.edgeKey : diff.lineKey + if (diff.kind === 'edge') { + edgeDiffs += 1 + } else { + lineDiffs += 1 + } + const previous = next.edgeMarks[key] ?? 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[key] = 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 === 'tile') { + tileDiffs += 1 + } 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.line.push(next.diffPrefixCounts.line[next.diffPrefixCounts.line.length - 1] + lineDiffs) + 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.tile.push(next.diffPrefixCounts.tile[next.diffPrefixCounts.tile.length - 1] + tileDiffs) + 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, + line: cache.diffPrefixCounts.line[currentPointer] ?? 0, + sector: cache.diffPrefixCounts.sector[currentPointer] ?? 0, + cell: cache.diffPrefixCounts.cell[currentPointer] ?? 0, + tile: cache.diffPrefixCounts.tile[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[], + pointer: number, +): TraceChartStats => { + const currentPointer = clampPointer(pointer, steps.length) + const decisionMarks = initialPuzzle.puzzleType === 'masyu' ? initialPuzzle.lines ?? {} : initialPuzzle.edges + const totalEdges = Object.keys(decisionMarks).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(decisionMarks)) { + 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' || diff.kind === 'line') { + edgeMarks[diff.kind === 'edge' ? diff.edgeKey : diff.lineKey] = diff.to + } else if (diff.kind === 'cell') { + cellFills[diff.cellKey] = diff.toFill + } else if (diff.kind === 'tile') { + continue + } 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/domain/ir/keys.ts b/src/domain/ir/keys.ts index c4405a2..89b1de6 100644 --- a/src/domain/ir/keys.ts +++ b/src/domain/ir/keys.ts @@ -4,11 +4,15 @@ export const cellKey = (row: number, col: number): string => `${row},${col}` export const vertexKey = (row: number, col: number): string => `${row},${col}` +export const tileKey = (row: number, col: number): string => `${row},${col}` + export const parseCellKey = (key: string): CellCoord => { const [r, c] = key.split(',').map(Number) return [r, c] } +export const parseTileKey = (key: string): CellCoord => parseCellKey(key) + export const parseVertexKey = (key: string): Vertex => { const [r, c] = key.split(',').map(Number) return [r, c] @@ -45,6 +49,18 @@ export const parseEdgeKey = (key: string): [Vertex, Vertex] => { return sortVertices(p1, p2) } +export const lineKey = (a: CellCoord, b: CellCoord): string => { + const [p1, p2] = sortVertices(a, b) + return `${p1[0]},${p1[1]}-${p2[0]},${p2[1]}` +} + +export const parseLineKey = (key: string): [CellCoord, CellCoord] => { + const [left, right] = key.split('-') + const p1 = left.split(',').map(Number) as CellCoord + const p2 = right.split(',').map(Number) as CellCoord + return sortVertices(p1, p2) as [CellCoord, CellCoord] +} + export const getCellEdgeKeys = (row: number, col: number): string[] => [ edgeKey([row, col], [row, col + 1]), edgeKey([row + 1, col], [row + 1, col + 1]), @@ -52,6 +68,23 @@ export const getCellEdgeKeys = (row: number, col: number): string[] => [ edgeKey([row, col + 1], [row + 1, col + 1]), ] +export const getCellLineKeys = (row: number, col: number, rows: number, cols: number): string[] => { + const lines: string[] = [] + if (row > 0) { + lines.push(lineKey([row, col], [row - 1, col])) + } + if (row < rows - 1) { + lines.push(lineKey([row, col], [row + 1, col])) + } + if (col > 0) { + lines.push(lineKey([row, col], [row, col - 1])) + } + if (col < cols - 1) { + lines.push(lineKey([row, col], [row, col + 1])) + } + return lines +} + export const getCornerVertex = (row: number, col: number, corner: SectorCorner): Vertex => { if (corner === 'nw') return [row, col] if (corner === "ne") return [row, col + 1] diff --git a/src/domain/ir/masyu.test.ts b/src/domain/ir/masyu.test.ts new file mode 100644 index 0000000..3031a0f --- /dev/null +++ b/src/domain/ir/masyu.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { getCellLineKeys } from './keys' +import { createMasyuPuzzle } from './masyu' + +describe('createMasyuPuzzle', () => { + it('creates center-to-center line decisions and vertex-centered tiles', () => { + const puzzle = createMasyuPuzzle(5, 5) + + expect(Object.keys(puzzle.lines)).toHaveLength(5 * 4 + 5 * 4) + expect(Object.keys(puzzle.tiles)).toHaveLength(6 * 6) + expect(Object.keys(puzzle.edges)).toHaveLength(0) + expect(Object.keys(puzzle.sectors)).toHaveLength(0) + }) + + it('returns only in-board line keys around a cell', () => { + expect(getCellLineKeys(0, 0, 5, 5)).toHaveLength(2) + expect(getCellLineKeys(0, 1, 5, 5)).toHaveLength(3) + expect(getCellLineKeys(2, 2, 5, 5)).toHaveLength(4) + }) +}) diff --git a/src/domain/ir/masyu.ts b/src/domain/ir/masyu.ts new file mode 100644 index 0000000..895a2b4 --- /dev/null +++ b/src/domain/ir/masyu.ts @@ -0,0 +1,29 @@ +import { lineKey, tileKey } from './keys' +import { defaultPuzzleIR, type PuzzleIR } from './types' + +export const createMasyuPuzzle = (rows: number, cols: number): PuzzleIR => { + const puzzle = defaultPuzzleIR() + puzzle.puzzleType = 'masyu' + puzzle.title = 'masyu' + puzzle.rows = rows + puzzle.cols = cols + puzzle.margins = [0, 0, 0, 0] + + for (let r = 0; r < rows; r += 1) { + for (let c = 0; c < cols - 1; c += 1) { + puzzle.lines[lineKey([r, c], [r, c + 1])] = { mark: 'unknown' } + } + } + for (let r = 0; r < rows - 1; r += 1) { + for (let c = 0; c < cols; c += 1) { + puzzle.lines[lineKey([r, c], [r + 1, c])] = { mark: 'unknown' } + } + } + for (let r = 0; r <= rows; r += 1) { + for (let c = 0; c <= cols; c += 1) { + puzzle.tiles[tileKey(r, c)] = {} + } + } + + return puzzle +} diff --git a/src/domain/ir/normalize.ts b/src/domain/ir/normalize.ts index af66cae..bc4473b 100644 --- a/src/domain/ir/normalize.ts +++ b/src/domain/ir/normalize.ts @@ -1,4 +1,4 @@ -import { cellKey, edgeKey, parseSectorKey, parseVertexKey } from './keys' +import { cellKey, edgeKey, lineKey, parseLineKey, parseSectorKey, parseTileKey, parseVertexKey } from './keys' import type { PuzzleIR } from './types' const compareCoord = (a: string, b: string): number => { @@ -39,6 +39,17 @@ export const normalizePuzzle = (puzzle: PuzzleIR): Record => { return acc }, {}) + const lines = Object.entries(puzzle.lines ?? {}) + .map(([key, state]) => { + const [p1, p2] = parseLineKey(key) + return [lineKey(p1, p2), state] as const + }) + .sort(([a], [b]) => a.localeCompare(b)) + .reduce>((acc, [key, state]) => { + acc[key] = { mark: state.mark } + return acc + }, {}) + const sectors = Object.entries(puzzle.sectors) .sort(([a], [b]) => { const [ar, ac, aCorner] = parseSectorKey(a) @@ -70,6 +81,17 @@ export const normalizePuzzle = (puzzle: PuzzleIR): Record => { return acc }, {}) + const tiles = Object.entries(puzzle.tiles ?? {}) + .sort(([a], [b]) => { + const [ar, ac] = parseTileKey(a) + const [br, bc] = parseTileKey(b) + return ar === br ? ac - bc : ar - br + }) + .reduce>((acc, [key, state]) => { + acc[key] = { fill: state.fill ?? null } + return acc + }, {}) + return { gridType: puzzle.gridType, puzzleType: puzzle.puzzleType, @@ -79,7 +101,9 @@ export const normalizePuzzle = (puzzle: PuzzleIR): Record => { boxes: [...puzzle.boxes], cells, edges, + lines, sectors, + tiles, vertices, } } diff --git a/src/domain/ir/types.ts b/src/domain/ir/types.ts index 33cde43..8ff9b2f 100644 --- a/src/domain/ir/types.ts +++ b/src/domain/ir/types.ts @@ -9,6 +9,7 @@ export type NumberClueValue = number | '?' export type Clue = | { kind: 'number'; value: NumberClueValue } + | { kind: 'pearl'; color: 'white' | 'black' } | { kind: 'text'; text: string } | { kind: 'arrow'; value?: number; direction: string } | { kind: 'tapa'; values: number[] } @@ -75,6 +76,16 @@ export type EdgeState = { } } +export type LineMark = EdgeMark + +export type LineState = { + mark: LineMark +} + +export type TileState = { + fill?: string +} + export type SectorState = { constraintsMask: SectorConstraintMask } @@ -95,7 +106,9 @@ export interface PuzzleIR { boxes: number[] cells: Record edges: Record + lines: Record sectors: Record + tiles: Record vertices: Record metadata: Record } @@ -112,7 +125,9 @@ export const defaultPuzzleIR = (): PuzzleIR => ({ boxes: [], cells: {}, edges: {}, + lines: {}, sectors: {}, + tiles: {}, vertices: {}, metadata: {}, }) diff --git a/src/domain/parsers/puzzlink/index.ts b/src/domain/parsers/puzzlink/index.ts index 9d84856..8439b5e 100644 --- a/src/domain/parsers/puzzlink/index.ts +++ b/src/domain/parsers/puzzlink/index.ts @@ -3,3 +3,9 @@ export { encodeSlitherToPuzzlink, slitherPuzzlinkAdapter, } from './slitherPuzzlink' +export { + decodeMasyuFromPuzzlink, + encodeMasyuToPuzzlink, + masyuPuzzlinkAdapter, + number3Decode, +} from './masyuPuzzlink' diff --git a/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts b/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts new file mode 100644 index 0000000..d55de17 --- /dev/null +++ b/src/domain/parsers/puzzlink/masyuPuzzlink.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { cellKey } from '../../ir/keys' +import { decodeMasyuFromPuzzlink, number3Decode } from './masyuPuzzlink' + +const SAMPLE_URL = 'https://puzz.link/p?mashu/5/5/001390360' + +describe('number3Decode', () => { + it('unpacks each base-36 character into three trits', () => { + expect(number3Decode('9')).toEqual([1, 0, 0]) + expect(number3Decode('z')).toEqual([0, 2, 2]) + }) +}) + +describe('decodeMasyuFromPuzzlink', () => { + it('imports the sample Masyu puzzle with expected pearl coordinates', () => { + const puzzle = decodeMasyuFromPuzzlink(SAMPLE_URL) + + expect(puzzle.puzzleType).toBe('masyu') + expect(puzzle.rows).toBe(5) + expect(puzzle.cols).toBe(5) + expect(Object.keys(puzzle.lines)).toHaveLength(40) + expect(puzzle.cells[cellKey(1, 3)]?.clue).toEqual({ kind: 'pearl', color: 'white' }) + expect(puzzle.cells[cellKey(2, 0)]?.clue).toEqual({ kind: 'pearl', color: 'white' }) + expect(puzzle.cells[cellKey(2, 2)]?.clue).toEqual({ kind: 'pearl', color: 'white' }) + expect(puzzle.cells[cellKey(3, 4)]?.clue).toEqual({ kind: 'pearl', color: 'white' }) + expect(puzzle.cells[cellKey(4, 2)]?.clue).toEqual({ kind: 'pearl', color: 'black' }) + }) + + it('accepts Masyu aliases and strips optional header segments', () => { + expect(decodeMasyuFromPuzzlink('masyu/v:/3/1/9').cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'pearl', + color: 'white', + }) + expect(decodeMasyuFromPuzzlink('pearl/b/3/1/2').cells[cellKey(0, 2)]?.clue).toEqual({ + kind: 'pearl', + color: 'black', + }) + }) +}) diff --git a/src/domain/parsers/puzzlink/masyuPuzzlink.ts b/src/domain/parsers/puzzlink/masyuPuzzlink.ts new file mode 100644 index 0000000..d39bb66 --- /dev/null +++ b/src/domain/parsers/puzzlink/masyuPuzzlink.ts @@ -0,0 +1,113 @@ +import { z } from 'zod' +import { cellKey } from '../../ir/keys' +import { createMasyuPuzzle } from '../../ir/masyu' +import type { PuzzleIR } from '../../ir/types' +import type { PuzzleFormatAdapter } from '../types' + +const PUZZLINK_HOSTS = new Set(['puzz.link', 'pzplus.tck.mn', 'pzv.jp']) +const typeAlias: Record = { + masyu: 'masyu', + mashu: 'masyu', + pearl: 'masyu', +} + +const HeaderSchema = z.object({ + puzzleType: z.string(), + cols: z.coerce.number().int().positive(), + rows: z.coerce.number().int().positive(), + body: z.string(), +}) + +const parsePuzzlinkPath = (input: string) => { + if (input.includes('://')) { + const url = new URL(input) + if (!PUZZLINK_HOSTS.has(url.hostname.toLowerCase())) { + throw new Error('Only puzz.link, pzplus.tck.mn, and pzv.jp URLs are supported in this adapter.') + } + const q = decodeURIComponent(url.search.replace(/^\?/, '')).split('&')[0] ?? '' + if (q.length > 0) { + return q + } + const pathTokens = url.pathname.split('/').filter(Boolean) + if (pathTokens[0] === 'p') { + return pathTokens.slice(1).join('/') + } + throw new Error('Invalid puzz.link URL query.') + } + return input.replace(/^p\?/, '').split('&')[0] ?? '' +} + +const parseHeader = (path: string) => { + const tokens = path.split('/').filter(Boolean) + if (tokens[1] === 'v:') { + tokens.splice(1, 1) + } + if (tokens[1] === 'b') { + tokens.splice(1, 1) + } + if (tokens.length < 4) { + throw new Error('Malformed puzz.link Masyu puzzle path.') + } + return HeaderSchema.parse({ + puzzleType: tokens[0], + cols: tokens[1], + rows: tokens[2], + body: tokens.slice(3).join('/'), + }) +} + +export const number3Decode = (body: string): number[] => { + const result: number[] = [] + for (const ch of body.toLowerCase()) { + const value = Number.parseInt(ch, 36) + if (!Number.isInteger(value) || value < 0 || value > 35) { + throw new Error(`Invalid number3 character: "${ch}".`) + } + result.push(Math.floor(value / 9) % 3) + result.push(Math.floor(value / 3) % 3) + result.push(value % 3) + } + return result +} + +export const decodeMasyuFromPuzzlink = (input: string): PuzzleIR => { + const path = parsePuzzlinkPath(input) + const header = parseHeader(path) + const normalizedType = typeAlias[header.puzzleType] + if (normalizedType !== 'masyu') { + throw new Error(`Unsupported puzz.link type: ${header.puzzleType}`) + } + + const puzzle = createMasyuPuzzle(header.rows, header.cols) + puzzle.puzzleType = normalizedType + puzzle.title = normalizedType + puzzle.source = 'puzz.link' + puzzle.metadata.originalUrl = input + + const trits = number3Decode(header.body) + const totalCells = header.rows * header.cols + for (let idx = 0; idx < totalCells; idx += 1) { + const trit = trits[idx] ?? 0 + if (trit !== 1 && trit !== 2) { + continue + } + const r = Math.floor(idx / header.cols) + const c = idx % header.cols + puzzle.cells[cellKey(r, c)] = { + clue: { + kind: 'pearl', + color: trit === 1 ? 'white' : 'black', + }, + } + } + return puzzle +} + +export const encodeMasyuToPuzzlink = (): string => { + throw new Error('Masyu puzz.link export is not implemented yet.') +} + +export const masyuPuzzlinkAdapter: PuzzleFormatAdapter = { + decode: decodeMasyuFromPuzzlink, + encode: encodeMasyuToPuzzlink, +} diff --git a/src/domain/plugins/masyuPlugin.ts b/src/domain/plugins/masyuPlugin.ts index a1a27a7..612c311 100644 --- a/src/domain/plugins/masyuPlugin.ts +++ b/src/domain/plugins/masyuPlugin.ts @@ -1,13 +1,106 @@ +import { decodeMasyuFromPuzzlink, encodeMasyuToPuzzlink } from '../parsers/puzzlink' +import { masyuRules } from '../rules/masyu/rules' +import type { PuzzleIR } from '../ir/types' import type { PuzzlePlugin } from './types' +import type { PuzzleHelpContent, PuzzleLegendContent, PuzzleStatsContent } from './types' + +const formatPercent = (count: number, total: number): string => { + if (total <= 0) { + return '0.0%' + } + return `${((count / total) * 100).toFixed(1)}%` +} + +const masyuHelp: PuzzleHelpContent = { + title: 'Masyu Rules', + summary: 'Draw lines through orthogonally adjacent cells to form a loop that goes through every circle.', + rules: [ + 'The loop cannot branch off or cross itself.', + 'The loop must turn on black circles and travel straight through the cells before and after the circle.', + 'The loop must go straight through white circles, and turn in at least one of the cells on either side.', + ], + notes: ['Rule examples are planned for a later Masyu update.'], +} + +const masyuLegend: PuzzleLegendContent = { + title: 'Masyu Legend', + items: [ + { + label: 'Pearls', + description: 'White and black pearls are shown in the centers of cells.', + example: { + rows: 3, + cols: 3, + }, + }, + { + label: 'Lines and Crosses', + description: 'Lines connect cell centers. Crosses mark center connections that cannot be used.', + example: { + rows: 3, + cols: 3, + }, + }, + ], +} + +export const getMasyuStats = (puzzle: PuzzleIR): PuzzleStatsContent => { + let whitePearls = 0 + let blackPearls = 0 + + Object.values(puzzle.cells).forEach((cell) => { + if (cell.clue?.kind !== 'pearl') { + return + } + if (cell.clue.color === 'white') { + whitePearls += 1 + } else { + blackPearls += 1 + } + }) + + const pearlCount = whitePearls + blackPearls + const totalCells = puzzle.rows * puzzle.cols + + return { + title: 'Puzzle Stats', + summary: `Pearls ${pearlCount} / ${totalCells} (${formatPercent(pearlCount, totalCells)})`, + groups: [ + { + title: 'Board Size', + items: [{ label: 'Grid', value: `${puzzle.rows} × ${puzzle.cols}` }], + }, + { + title: 'Pearls', + items: [ + { + label: 'Total', + value: `${pearlCount} / ${totalCells}`, + detail: formatPercent(pearlCount, totalCells), + }, + { + label: 'White', + value: String(whitePearls), + detail: formatPercent(whitePearls, pearlCount), + }, + { + label: 'Black', + value: String(blackPearls), + detail: formatPercent(blackPearls, pearlCount), + }, + ], + }, + ], + } +} export const masyuPlugin: PuzzlePlugin = { id: 'masyu', - displayName: 'Masyu (planned)', - parse: () => { - throw new Error('Masyu parser not implemented yet.') - }, - encode: () => { - throw new Error('Masyu encoder not implemented yet.') - }, - getRules: () => [], + displayName: 'Masyu', + help: masyuHelp, + legend: masyuLegend, + getStats: getMasyuStats, + parse: decodeMasyuFromPuzzlink, + encode: encodeMasyuToPuzzlink, + getRules: () => masyuRules, } diff --git a/src/domain/plugins/slitherPlugin.test.ts b/src/domain/plugins/slitherPlugin.test.ts new file mode 100644 index 0000000..cd27064 --- /dev/null +++ b/src/domain/plugins/slitherPlugin.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { cellKey } from '../ir/keys' +import { createSlitherPuzzle } from '../ir/slither' +import { getSlitherStats } from './slitherPlugin' + +describe('getSlitherStats', () => { + it('counts numeric clue cells and clue distribution percentages', () => { + const puzzle = createSlitherPuzzle(10, 10) + puzzle.cells[cellKey(0, 0)] = { clue: { kind: 'number', value: 0 } } + puzzle.cells[cellKey(0, 1)] = { clue: { kind: 'number', value: 1 } } + puzzle.cells[cellKey(0, 2)] = { clue: { kind: 'number', value: 1 } } + puzzle.cells[cellKey(0, 3)] = { clue: { kind: 'number', value: 2 } } + puzzle.cells[cellKey(0, 4)] = { clue: { kind: 'number', value: 3 } } + puzzle.cells[cellKey(0, 5)] = { clue: { kind: 'number', value: '?' } } + puzzle.cells[cellKey(0, 6)] = { clue: { kind: 'text', text: 'A' } } + + const stats = getSlitherStats(puzzle) + const total = stats.groups[0].items[0] + const distribution = stats.groups[1].items + + expect(stats.summary).toBe('Numbered cells 5 / 100 (5.0%)') + expect(total).toEqual({ label: 'Total', value: '5 / 100', detail: '5.0%' }) + expect(distribution).toEqual([ + { label: 'Clue 0', value: '1', detail: '20.0%' }, + { label: 'Clue 1', value: '2', detail: '40.0%' }, + { label: 'Clue 2', value: '1', detail: '20.0%' }, + { label: 'Clue 3', value: '1', detail: '20.0%' }, + ]) + }) + + it('uses stable zero percentages when there are no numeric clues', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 0)] = { clue: { kind: 'number', value: '?' } } + + const stats = getSlitherStats(puzzle) + + expect(stats.summary).toBe('Numbered cells 0 / 9 (0.0%)') + expect(stats.groups[1].items).toEqual([ + { label: 'Clue 0', value: '0', detail: '0.0%' }, + { label: 'Clue 1', value: '0', detail: '0.0%' }, + { label: 'Clue 2', value: '0', detail: '0.0%' }, + { label: 'Clue 3', value: '0', detail: '0.0%' }, + ]) + }) +}) diff --git a/src/domain/plugins/slitherPlugin.ts b/src/domain/plugins/slitherPlugin.ts index cf46267..6526e4e 100644 --- a/src/domain/plugins/slitherPlugin.ts +++ b/src/domain/plugins/slitherPlugin.ts @@ -1,7 +1,13 @@ import { decodeSlitherFromPuzzlink, encodeSlitherToPuzzlink } from '../parsers/puzzlink' import { decodeSlitherFromPenpa } from '../parsers/penpa' import { slitherRules } from '../rules/slither/rules' -import type { PuzzleHelpContent, PuzzleLegendContent, PuzzlePlugin } from './types' +import type { PuzzleIR } from '../ir/types' +import type { + PuzzleHelpContent, + PuzzleLegendContent, + PuzzlePlugin, + PuzzleStatsContent, +} from './types' const parseSlitherInput = (input: string) => { try { @@ -195,11 +201,69 @@ const slitherLegend: PuzzleLegendContent = { ], } +const formatPercent = (count: number, total: number): string => { + if (total <= 0) { + return '0.0%' + } + return `${((count / total) * 100).toFixed(1)}%` +} + +export const getSlitherStats = (puzzle: PuzzleIR): PuzzleStatsContent => { + const clueCounts = new Map([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]) + let numberedCellCount = 0 + + Object.values(puzzle.cells).forEach((cell) => { + if (cell.clue?.kind !== 'number' || typeof cell.clue.value !== 'number') { + return + } + numberedCellCount += 1 + if (clueCounts.has(cell.clue.value)) { + clueCounts.set(cell.clue.value, (clueCounts.get(cell.clue.value) ?? 0) + 1) + } + }) + + const totalCells = puzzle.rows * puzzle.cols + + return { + title: 'Puzzle Stats', + summary: `Numbered cells ${numberedCellCount} / ${totalCells} (${formatPercent(numberedCellCount, totalCells)})`, + groups: [ + { + title: 'Numbered Cells', + items: [ + { + label: 'Total', + value: `${numberedCellCount} / ${totalCells}`, + detail: formatPercent(numberedCellCount, totalCells), + }, + ], + }, + { + title: 'Clue Distribution', + items: [0, 1, 2, 3].map((clue) => { + const count = clueCounts.get(clue) ?? 0 + return { + label: `Clue ${clue}`, + value: String(count), + detail: formatPercent(count, numberedCellCount), + } + }), + }, + ], + } +} + export const slitherPlugin: PuzzlePlugin = { id: 'slitherlink', displayName: 'Slitherlink', help: slitherHelp, legend: slitherLegend, + getStats: getSlitherStats, parse: parseSlitherInput, encode: encodeSlitherToPuzzlink, getRules: () => slitherRules, diff --git a/src/domain/plugins/types.ts b/src/domain/plugins/types.ts index 028a8e5..0105d62 100644 --- a/src/domain/plugins/types.ts +++ b/src/domain/plugins/types.ts @@ -54,11 +54,29 @@ export type PuzzleHelpContent = { } } +export type PuzzleStatsItem = { + label: string + value: string + detail?: string +} + +export type PuzzleStatsGroup = { + title: string + items: PuzzleStatsItem[] +} + +export type PuzzleStatsContent = { + title: string + summary: string + groups: PuzzleStatsGroup[] +} + export interface PuzzlePlugin { id: string displayName: string help?: PuzzleHelpContent legend?: PuzzleLegendContent + getStats?: (puzzle: PuzzleIR) => PuzzleStatsContent | null parse: (input: string) => PuzzleIR encode: (puzzle: PuzzleIR) => string getRules: () => Rule[] diff --git a/src/domain/rules/completion.ts b/src/domain/rules/completion.ts new file mode 100644 index 0000000..e54b92e --- /dev/null +++ b/src/domain/rules/completion.ts @@ -0,0 +1,35 @@ +import type { PuzzleIR } from '../ir/types' +import { analyzeMasyuCompletion } from './masyu/completion' +import { analyzeSlitherCompletion } from './slither/completion' + +export type CompletionStatus = 'solved' | 'stalled' + +export type CompletionStats = { + totalUnits: number + lineUnits: number + blankUnits: number + unknownUnits: number + decidedUnits: number + decidedRatio: number + unitLabel: string + [key: string]: number | string +} + +export type CompletionReport = { + status: CompletionStatus + stats: CompletionStats + reasons: string[] +} + +export const analyzePuzzleCompletion = ( + pluginId: string, + puzzle: PuzzleIR, +): CompletionReport | null => { + if (pluginId === 'slitherlink') { + return analyzeSlitherCompletion(puzzle) + } + if (pluginId === 'masyu') { + return analyzeMasyuCompletion(puzzle) + } + return null +} diff --git a/src/domain/rules/engine.bench.ts b/src/domain/rules/engine.bench.ts index b90aa45..01157a9 100644 --- a/src/domain/rules/engine.bench.ts +++ b/src/domain/rules/engine.bench.ts @@ -57,6 +57,14 @@ const applyDiffsWithJsonClone = () => { } continue } + if (diff.kind === 'line') { + if (!next.lines[diff.lineKey]) { + next.lines[diff.lineKey] = { mark: diff.to } + } else { + next.lines[diff.lineKey].mark = diff.to + } + continue + } if (diff.kind === 'vertex') { if (!next.vertices[diff.vertexKey]) { next.vertices[diff.vertexKey] = { candidateEdgeSets: diff.toCandidates } @@ -65,6 +73,17 @@ const applyDiffsWithJsonClone = () => { } continue } + if (diff.kind === 'tile') { + if (!next.tiles[diff.tileKey]) { + next.tiles[diff.tileKey] = {} + } + if (diff.toFill === null) { + delete next.tiles[diff.tileKey].fill + } else { + next.tiles[diff.tileKey].fill = diff.toFill + } + continue + } if (!next.cells[diff.cellKey]) { next.cells[diff.cellKey] = {} } diff --git a/src/domain/rules/engine.test.ts b/src/domain/rules/engine.test.ts index 43d1d5a..1e5d759 100644 --- a/src/domain/rules/engine.test.ts +++ b/src/domain/rules/engine.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { decodeSlitherFromPuzzlink } from '../parsers/puzzlink' -import { vertexKey } from '../ir/keys' +import { tileKey, vertexKey } from '../ir/keys' +import { createMasyuPuzzle } from '../ir/masyu' import { applyRuleDiffs, revertRuleDiffs, runNextRule } from './engine' import { slitherRules } from './slither/rules' import type { RuleDiff } from './types' @@ -63,4 +64,44 @@ describe('rule engine', () => { expect(rewound.cells['0,0']?.fill).toBeUndefined() expect(rewound.vertices[centerVertexKey].candidateEdgeSets).toEqual(fromCandidates) }) + + it('applies and reverts line diffs without mutating input puzzle', () => { + const puzzle = createMasyuPuzzle(2, 2) + const lineKey = Object.keys(puzzle.lines)[0] + const diffs: RuleDiff[] = [ + { + kind: 'line', + lineKey, + from: 'unknown', + to: 'line', + }, + ] + + const next = applyRuleDiffs(puzzle, diffs) + expect(next.lines[lineKey].mark).toBe('line') + expect(puzzle.lines[lineKey].mark).toBe('unknown') + + const rewound = revertRuleDiffs(next, diffs) + expect(rewound.lines[lineKey].mark).toBe('unknown') + }) + + it('applies and reverts tile fill diffs without mutating input puzzle', () => { + const puzzle = createMasyuPuzzle(2, 2) + const key = tileKey(1, 1) + const diffs: RuleDiff[] = [ + { + kind: 'tile', + tileKey: key, + fromFill: null, + toFill: 'green', + }, + ] + + const next = applyRuleDiffs(puzzle, diffs) + expect(next.tiles[key]?.fill).toBe('green') + expect(puzzle.tiles[key]?.fill).toBeUndefined() + + const rewound = revertRuleDiffs(next, diffs) + expect(rewound.tiles[key]?.fill).toBeUndefined() + }) }) diff --git a/src/domain/rules/engine.ts b/src/domain/rules/engine.ts index 1b58ec8..5caf121 100644 --- a/src/domain/rules/engine.ts +++ b/src/domain/rules/engine.ts @@ -3,7 +3,9 @@ import type { Rule, RuleDiff, RuleStep } from './types' type WritableBuckets = { cells: PuzzleIR['cells'] | null + tiles: PuzzleIR['tiles'] | null edges: PuzzleIR['edges'] | null + lines: PuzzleIR['lines'] | null sectors: PuzzleIR['sectors'] | null vertices: PuzzleIR['vertices'] | null } @@ -24,6 +26,16 @@ const applyDiffEntry = ( writable.edges[diff.edgeKey] = prev ? { ...prev, mark } : { mark } return } + if (diff.kind === 'line') { + const mark = mode === 'forward' ? diff.to : diff.from + if (!writable.lines) { + writable.lines = { ...(next.lines ?? {}) } + next.lines = writable.lines + } + const prev = writable.lines[diff.lineKey] + writable.lines[diff.lineKey] = prev ? { ...prev, mark } : { mark } + return + } if (diff.kind === 'sector') { const constraintsMask = mode === 'forward' ? diff.toMask : diff.fromMask if (!writable.sectors) { @@ -45,6 +57,22 @@ const applyDiffEntry = ( } return } + if (diff.kind === 'tile') { + const toFill = mode === 'forward' ? diff.toFill : diff.fromFill + if (!writable.tiles) { + writable.tiles = { ...(next.tiles ?? {}) } + next.tiles = writable.tiles + } + const prev = writable.tiles[diff.tileKey] + const tile = prev ? { ...prev } : {} + if (toFill === null) { + delete tile.fill + } else { + tile.fill = toFill + } + writable.tiles[diff.tileKey] = tile + return + } const toFill = mode === 'forward' ? diff.toFill : diff.fromFill if (!writable.cells) { writable.cells = { ...next.cells } @@ -68,7 +96,9 @@ const applyRuleDiffsInternal = ( const next: PuzzleIR = { ...puzzle } const writable: WritableBuckets = { cells: null, + tiles: null, edges: null, + lines: null, sectors: null, vertices: null, } @@ -126,7 +156,11 @@ export const runNextRule = ( message: result.message, diffs: result.diffs, affectedCells: result.affectedCells, + affectedTiles: + result.affectedTiles ?? result.diffs.flatMap((d) => (d.kind === 'tile' ? [d.tileKey] : [])), affectedEdges: result.diffs.flatMap((d) => (d.kind === 'edge' ? [d.edgeKey] : [])), + affectedLines: + result.affectedLines ?? result.diffs.flatMap((d) => (d.kind === 'line' ? [d.lineKey] : [])), affectedSectors: result.affectedSectors ?? result.diffs.flatMap((d) => (d.kind === 'sector' ? [d.sectorKey] : [])), diff --git a/src/domain/rules/masyu/completion.test.ts b/src/domain/rules/masyu/completion.test.ts new file mode 100644 index 0000000..b34ac18 --- /dev/null +++ b/src/domain/rules/masyu/completion.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../ir/keys' +import { createMasyuPuzzle } from '../../ir/masyu' +import type { LineMark, PuzzleIR } from '../../ir/types' +import { analyzeMasyuCompletion } from './completion' + +const markLine = (puzzle: PuzzleIR, key: string, mark: LineMark): void => { + puzzle.lines[key] = { ...puzzle.lines[key], mark } +} + +const addPearl = (puzzle: PuzzleIR, row: number, col: number, color: 'white' | 'black'): void => { + puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } +} + +const markRectLoop = ( + puzzle: PuzzleIR, + top: number, + left: number, + bottom: number, + right: number, +): void => { + for (let col = left; col < right; col += 1) { + markLine(puzzle, lineKey([top, col], [top, col + 1]), 'line') + markLine(puzzle, lineKey([bottom, col], [bottom, col + 1]), 'line') + } + for (let row = top; row < bottom; row += 1) { + markLine(puzzle, lineKey([row, left], [row + 1, left]), 'line') + markLine(puzzle, lineKey([row, right], [row + 1, right]), 'line') + } +} + +describe('Masyu completion analysis', () => { + it('returns solved for one valid loop with satisfied pearls', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 3, 3) + addPearl(puzzle, 0, 0, 'black') + addPearl(puzzle, 0, 1, 'white') + + expect(analyzeMasyuCompletion(puzzle)).toMatchObject({ + status: 'solved', + reasons: [], + }) + }) + + it('reports when no line segments have been drawn', () => { + const puzzle = createMasyuPuzzle(3, 3) + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('No line segments'))).toBe(true) + }) + + it('reports open path endpoints', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('degree 2'))).toBe(true) + expect(report.reasons.some((reason) => reason.includes('endpoint'))).toBe(true) + }) + + it('reports branch cells', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 3, 3) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('branch'))).toBe(true) + }) + + it('reports disconnected sub-loops', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 1, 1) + markRectLoop(puzzle, 2, 2, 3, 3) + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('connected component'))).toBe(true) + }) + + it('reports a black pearl that goes straight', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 3, 3) + addPearl(puzzle, 0, 1, 'black') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('black pearl'))).toBe(true) + }) + + it('reports a black pearl turn without straight extensions', () => { + const puzzle = createMasyuPuzzle(2, 2) + markRectLoop(puzzle, 0, 0, 1, 1) + addPearl(puzzle, 0, 0, 'black') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('black pearl'))).toBe(true) + }) + + it('reports a white pearl that turns on the pearl cell', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 3, 3) + addPearl(puzzle, 0, 0, 'white') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('white pearl'))).toBe(true) + }) + + it('reports a white pearl that never turns after passing through', () => { + const puzzle = createMasyuPuzzle(1, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + addPearl(puzzle, 0, 1, 'white') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.status).toBe('stalled') + expect(report.reasons.some((reason) => reason.includes('white pearl'))).toBe(true) + }) + + it('accepts a white pearl with one adjacent side turning', () => { + const puzzle = createMasyuPuzzle(4, 4) + markRectLoop(puzzle, 0, 0, 3, 3) + addPearl(puzzle, 0, 1, 'white') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.reasons.some((reason) => reason.includes('white pearl'))).toBe(false) + }) + + it('calculates decided line coverage', () => { + const puzzle = createMasyuPuzzle(1, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const report = analyzeMasyuCompletion(puzzle) + + expect(report.stats).toMatchObject({ + totalLines: 1, + lineLines: 1, + blankLines: 0, + unknownLines: 0, + decidedLines: 1, + decidedLineRatio: 1, + totalUnits: 1, + decidedUnits: 1, + decidedRatio: 1, + unitLabel: 'Lines', + }) + }) +}) diff --git a/src/domain/rules/masyu/completion.ts b/src/domain/rules/masyu/completion.ts new file mode 100644 index 0000000..a725eba --- /dev/null +++ b/src/domain/rules/masyu/completion.ts @@ -0,0 +1,232 @@ +import { cellKey, parseCellKey, parseLineKey } from '../../ir/keys' +import type { PuzzleIR } from '../../ir/types' +import type { CompletionReport, CompletionStats, CompletionStatus } from '../completion' +import { + areMasyuDirectionsOpposite, + areMasyuDirectionsTurn, + formatMasyuCellKeyLabel, + getMasyuDirectionalLine, + getMasyuIncidentDirectionalLines, + getMasyuTwoStepLine, + type MasyuDirection, +} from './rules/shared' + +export type MasyuCompletionStatus = CompletionStatus + +export type MasyuCompletionStats = CompletionStats & { + totalLines: number + lineLines: number + blankLines: number + unknownLines: number + decidedLines: number + decidedLineRatio: number +} + +export type MasyuCompletionReport = CompletionReport & { stats: MasyuCompletionStats } + +const buildLineStats = (puzzle: PuzzleIR): MasyuCompletionStats => { + let lineLines = 0 + let blankLines = 0 + let unknownLines = 0 + + for (const line of Object.values(puzzle.lines)) { + const mark = line?.mark ?? 'unknown' + if (mark === 'line') lineLines += 1 + else if (mark === 'blank') blankLines += 1 + else unknownLines += 1 + } + + const totalLines = lineLines + blankLines + unknownLines + const decidedLines = lineLines + blankLines + const decidedLineRatio = totalLines === 0 ? 0 : decidedLines / totalLines + + return { + totalUnits: totalLines, + lineUnits: lineLines, + blankUnits: blankLines, + unknownUnits: unknownLines, + decidedUnits: decidedLines, + decidedRatio: decidedLineRatio, + unitLabel: 'Lines', + totalLines, + lineLines, + blankLines, + unknownLines, + decidedLines, + decidedLineRatio, + } +} + +const toCellIndex = (puzzle: PuzzleIR, key: string): number => { + const [row, col] = parseCellKey(key) + return row * puzzle.cols + col +} + +const toCellLabel = (puzzle: PuzzleIR, idx: number): string => { + const row = Math.floor(idx / puzzle.cols) + const col = idx % puzzle.cols + return formatMasyuCellKeyLabel(cellKey(row, col)) +} + +const collectLoopReasons = (puzzle: PuzzleIR, lineCount: number): string[] => { + if (lineCount === 0) { + return ['No line segments have been drawn.'] + } + + const cellCount = puzzle.rows * puzzle.cols + const parent = Array.from({ length: cellCount }, (_, idx) => idx) + const rank = new Array(cellCount).fill(0) + const degree = new Map() + const lineRoots = new Set() + const lineEntries = Object.entries(puzzle.lines).filter(([, line]) => (line?.mark ?? 'unknown') === 'line') + + const find = (idx: number): number => { + if (parent[idx] !== idx) { + parent[idx] = find(parent[idx]) + } + return parent[idx] + } + const union = (a: number, b: number): void => { + const rootA = find(a) + const rootB = find(b) + if (rootA === rootB) { + return + } + if (rank[rootA] < rank[rootB]) { + parent[rootA] = rootB + } else if (rank[rootA] > rank[rootB]) { + parent[rootB] = rootA + } else { + parent[rootB] = rootA + rank[rootA] += 1 + } + } + + for (const [lineKey] of lineEntries) { + const [left, right] = parseLineKey(lineKey) + const leftKey = cellKey(left[0], left[1]) + const rightKey = cellKey(right[0], right[1]) + const leftIdx = toCellIndex(puzzle, leftKey) + const rightIdx = toCellIndex(puzzle, rightKey) + union(leftIdx, rightIdx) + degree.set(leftIdx, (degree.get(leftIdx) ?? 0) + 1) + degree.set(rightIdx, (degree.get(rightIdx) ?? 0) + 1) + } + + for (const [lineKey] of lineEntries) { + const [left] = parseLineKey(lineKey) + lineRoots.add(find(toCellIndex(puzzle, cellKey(left[0], left[1])))) + } + + const reasons: string[] = [] + if (lineRoots.size !== 1) { + reasons.push(`Line segments are split across ${lineRoots.size} connected component(s), indicating disconnected paths or sub-loops.`) + } + + let invalidDegreeCount = 0 + let firstEndpoint: string | null = null + let firstBranch: string | null = null + let firstOther: string | null = null + for (const [cellIdx, count] of degree.entries()) { + if (count === 2) { + continue + } + invalidDegreeCount += 1 + if (count === 1 && firstEndpoint === null) { + firstEndpoint = toCellLabel(puzzle, cellIdx) + } else if (count > 2 && firstBranch === null) { + firstBranch = toCellLabel(puzzle, cellIdx) + } else if (firstOther === null) { + firstOther = `${toCellLabel(puzzle, cellIdx)} has degree ${count}` + } + } + + if (invalidDegreeCount > 0) { + const examples = [firstEndpoint && `endpoint ${firstEndpoint}`, firstBranch && `branch ${firstBranch}`, firstOther] + .filter(Boolean) + .join(', ') + reasons.push( + `${invalidDegreeCount} line cell(s) do not have degree 2${examples ? `; first: ${examples}.` : '.'}`, + ) + } + + return reasons +} + +const getLineDirectionsAtCell = (puzzle: PuzzleIR, key: string): MasyuDirection[] => + Object.values(getMasyuIncidentDirectionalLines(puzzle, key)).flatMap((item) => + item && item.mark === 'line' ? [item.direction] : [], + ) + +const sideTurnsAfterWhitePearl = ( + puzzle: PuzzleIR, + pearlKey: string, + direction: MasyuDirection, +): boolean => { + const first = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (!first || first.mark !== 'line') { + return false + } + return getLineDirectionsAtCell(puzzle, first.neighborKey).some((neighborDirection) => + areMasyuDirectionsTurn(neighborDirection, direction), + ) +} + +const collectPearlReasons = (puzzle: PuzzleIR): string[] => { + let invalidBlackCount = 0 + let firstInvalidBlack: string | null = null + let invalidWhiteCount = 0 + let firstInvalidWhite: string | null = null + + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'pearl') { + continue + } + const lineDirections = getLineDirectionsAtCell(puzzle, key) + + if (cell.clue.color === 'black') { + const valid = + lineDirections.length === 2 && + areMasyuDirectionsTurn(lineDirections[0], lineDirections[1]) && + lineDirections.every((direction) => getMasyuTwoStepLine(puzzle, key, direction).second?.mark === 'line') + if (!valid) { + invalidBlackCount += 1 + firstInvalidBlack ??= `${formatMasyuCellKeyLabel(key)} must turn and continue straight after both exits` + } + continue + } + + const valid = + lineDirections.length === 2 && + areMasyuDirectionsOpposite(lineDirections[0], lineDirections[1]) && + lineDirections.some((direction) => sideTurnsAfterWhitePearl(puzzle, key, direction)) + if (!valid) { + invalidWhiteCount += 1 + firstInvalidWhite ??= `${formatMasyuCellKeyLabel(key)} must go straight and turn on at least one adjacent side` + } + } + + const reasons: string[] = [] + if (invalidBlackCount > 0) { + reasons.push( + `${invalidBlackCount} black pearl(s) are not satisfied${firstInvalidBlack ? `; first: ${firstInvalidBlack}.` : '.'}`, + ) + } + if (invalidWhiteCount > 0) { + reasons.push( + `${invalidWhiteCount} white pearl(s) are not satisfied${firstInvalidWhite ? `; first: ${firstInvalidWhite}.` : '.'}`, + ) + } + return reasons +} + +export const analyzeMasyuCompletion = (puzzle: PuzzleIR): MasyuCompletionReport => { + const stats = buildLineStats(puzzle) + const reasons = [...collectLoopReasons(puzzle, stats.lineLines), ...collectPearlReasons(puzzle)] + + return { + status: reasons.length === 0 ? 'solved' : 'stalled', + stats, + reasons, + } +} diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts new file mode 100644 index 0000000..e9ccb25 --- /dev/null +++ b/src/domain/rules/masyu/rules.test.ts @@ -0,0 +1,1549 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey, tileKey } from '../../ir/keys' +import { createMasyuPuzzle } from '../../ir/masyu' +import type { LineMark, PuzzleIR } from '../../ir/types' +import { runNextRule } from '../engine' +import type { Rule } from '../types' +import { masyuPlugin } from '../../plugins/masyuPlugin' +import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' +import { createMasyuCandidateBridgeLineRule } from './rules/bridges' +import { createBlackPearlCandidatePruningRule } from './rules/candidates' +import { + createMasyuColorLinePropagationRule, + createMasyuColorPearlPropagationRule, + createMasyuTileColorPropagationRule, +} from './rules/color' +import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' +import { createCellCompletionRule, createPearlCompletionRule } from './rules/completion' +import { createPreventPrematureLoopRule } from './rules/loop' +import { + createBlackDiagonalWhitePinchRule, + createBlackFacingConsecutiveWhitesRule, + createConsecutiveWhitePearlsStraightRule, + createDoubleBlackSqueezeRule, +} from './rules/patterns' +import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' +import { deterministicMasyuRules } from './rules' + +const markLine = (puzzle: PuzzleIR, key: string, mark: LineMark): void => { + puzzle.lines[key] = { ...puzzle.lines[key], mark } +} + +const addPearl = (puzzle: PuzzleIR, row: number, col: number, color: 'white' | 'black'): void => { + puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } +} + +const fillAllTiles = (puzzle: PuzzleIR, fill: 'green' | 'yellow'): void => { + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + puzzle.tiles[tileKey(row, col)] = { fill } + } + } +} + +const getLineDegree = (puzzle: PuzzleIR, row: number, col: number): number => + [ + row > 0 ? lineKey([row - 1, col], [row, col]) : null, + row < puzzle.rows - 1 ? lineKey([row, col], [row + 1, col]) : null, + col > 0 ? lineKey([row, col - 1], [row, col]) : null, + col < puzzle.cols - 1 ? lineKey([row, col], [row, col + 1]) : null, + ].filter((key) => key !== null && puzzle.lines[key]?.mark === 'line').length + +const expectLineDiffs = ( + diffs: NonNullable['apply']>>['diffs'] | undefined, + expected: Record, +): void => { + expect( + Object.fromEntries( + (diffs ?? []).map((diff) => [diff.kind === 'line' ? diff.lineKey : '', diff.kind === 'line' ? diff.to : '']), + ), + ).toEqual(expected) +} + +describe('Masyu pearl rules', () => { + it('White Circle Rule blanks a blocked axis and forces the other straight axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'white') + const south = lineKey([0, 1], [1, 1]) + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 1], [0, 0]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'blank', [east]: 'line', [west]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + }) + + it('White Circle Rule uses an existing blank to force the crossing axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + const east = lineKey([1, 1], [1, 2]) + const west = lineKey([1, 1], [1, 0]) + const north = lineKey([1, 1], [0, 1]) + const south = lineKey([1, 1], [2, 1]) + markLine(puzzle, east, 'blank') + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'blank', [north]: 'line', [south]: 'line' }) + }) + + it('White Circle Rule does nothing when both straight axes are still available', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + + expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + }) + + it('White Circle Rule rejects a vertical pass-through when both immediate turn cells are blocked', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const south = lineKey([3, 3], [4, 3]) + const east = lineKey([3, 3], [3, 4]) + const west = lineKey([3, 2], [3, 3]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank', [east]: 'line', [west]: 'line' }) + }) + + it('White Circle Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + markLine(puzzle, lineKey([2, 4], [3, 4]), 'blank') + markLine(puzzle, lineKey([3, 4], [4, 4]), 'blank') + const east = lineKey([3, 3], [3, 4]) + const west = lineKey([3, 2], [3, 3]) + const north = lineKey([2, 3], [3, 3]) + const south = lineKey([3, 3], [4, 3]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'blank', [north]: 'line', [south]: 'line' }) + }) + + it('White Circle Rule keeps an axis available when each side still has a turn candidate', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + }) + + it('White Circle Rule keeps an axis available when only one side can turn', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + + expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + }) + + it('White Circle Rule does not force the second white pearl vertical when the shared gap can still turn', () => { + const puzzle = createMasyuPuzzle(9, 10) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 4, 7, 'white') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') + markLine(puzzle, lineKey([4, 5], [4, 6]), 'line') + markLine(puzzle, lineKey([4, 6], [5, 6]), 'blank') + const result = createWhiteCircleRule().apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([3, 7], [4, 7]), + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([4, 7], [5, 7]), + from: 'unknown', + to: 'line', + }) + }) + + it('White Circle Rule continues a known line straight and blanks turn candidates', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line', [north]: 'blank', [south]: 'blank' }) + }) + + it('White Circle Rule does not force a straight line into a degree-2 neighbor', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + const result = createWhiteCircleRule().apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([1, 1], [1, 2]), + from: 'unknown', + to: 'line', + }) + }) + + it('White Circle Rule blanks turn candidates when a white pearl already has a straight pair', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) + }) + + it('White Circle Rule only highlights pearls that create new line decisions', () => { + const puzzle = createMasyuPuzzle(3, 4) + const unchangedPearl = cellKey(1, 1) + const changedPearl = cellKey(1, 2) + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const changedLine = lineKey([1, 2], [1, 3]) + + const result = createWhiteCircleRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([changedPearl]) + expect(result?.affectedCells).not.toContain(unchangedPearl) + expect(result?.affectedLines).toEqual([changedLine]) + expectLineDiffs(result?.diffs, { [changedLine]: 'blank' }) + }) + + it('White Circle Rule blocks the short side from continuing when the other side already runs straight south', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const northExtension = lineKey([0, 2], [1, 2]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [northExtension]: 'blank' }) + expect(result?.message).toContain('must turn in an adjacent cell') + }) + + it('White Circle Rule blocks the short side from continuing when the other side already runs straight east', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + const westExtension = lineKey([2, 0], [2, 1]) + + const result = createWhiteCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [westExtension]: 'blank' }) + }) + + it('White Circle Rule does not block either side when both sides only reach the pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + + expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + }) + + it('White Circle Rule ignores adjacent-turn continuation when the short-side extension leaves the board', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'blank') + + expect(createWhiteCircleRule().apply(puzzle)).toBeNull() + }) + + it('Black Circle Rule forces the opposite line and its straight extension at the border', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'black') + const south = lineKey([0, 1], [1, 1]) + const extension = lineKey([1, 1], [2, 1]) + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line', [extension]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + }) + + it('Black Circle Rule forces the opposite line and extension when one side is already blank', () => { + const puzzle = createMasyuPuzzle(3, 4) + addPearl(puzzle, 1, 1, 'black') + const west = lineKey([1, 1], [1, 0]) + const east = lineKey([1, 1], [1, 2]) + const extension = lineKey([1, 2], [1, 3]) + markLine(puzzle, west, 'blank') + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line', [extension]: 'line' }) + }) + + it('Black Circle Rule does not overwrite already-decided opposite line or extension', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'black') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + expect(createBlackCircleRule().apply(puzzle)).toBeNull() + }) + + it('Black Circle Rule only highlights pearls that create new line decisions', () => { + const puzzle = createMasyuPuzzle(5, 5) + const unchangedPearl = cellKey(2, 2) + const changedPearl = cellKey(0, 1) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 0, 1, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + const changedLine = lineKey([1, 1], [2, 1]) + + const result = createBlackCircleRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([changedPearl]) + expect(result?.affectedCells).not.toContain(unchangedPearl) + expect(result?.affectedLines).toEqual([changedLine]) + expectLineDiffs(result?.diffs, { [changedLine]: 'line' }) + }) + + it('Black Circle Rule rejects an exit whose second step would leave the board', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 4, 'black') + const east = lineKey([3, 4], [3, 5]) + const west = lineKey([3, 3], [3, 4]) + const extension = lineKey([3, 2], [3, 3]) + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'line', [extension]: 'line' }) + }) + + it('Black Circle Rule rejects an exit whose second step is already blank', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + const east = lineKey([3, 3], [3, 4]) + const eastExtension = lineKey([3, 4], [3, 5]) + const west = lineKey([3, 2], [3, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + markLine(puzzle, eastExtension, 'blank') + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank', [west]: 'line', [westExtension]: 'line' }) + }) + + it('Black Circle Rule turns away from a known line and extends that exit', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const extension = lineKey([2, 0], [2, 1]) + const east = lineKey([2, 2], [2, 3]) + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank', [extension]: 'line' }) + }) + + it('Black Circle Rule blanks remaining exits and extends both sides of a known turn', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const northExtension = lineKey([0, 2], [1, 2]) + + const result = createBlackCircleRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [south]: 'blank', + [westExtension]: 'line', + [northExtension]: 'line', + }) + }) + + it('registers Masyu rules in pearl-then-completion order', () => { + expect(masyuPlugin.getRules().map((rule) => rule.name)).toEqual([ + 'White Circle Rule', + 'Black Circle Rule', + 'Black Facing Consecutive Whites', + 'Black Diagonal White Pinch', + 'Consecutive White Pearls Straight', + 'Double Black Squeeze', + 'Masyu Tile Color Propagation', + 'Masyu Color-Pearl Propagation', + 'Masyu Color-Line Propagation', + 'Masyu Tile Connectivity Cut Coloring', + 'Masyu Candidate Bridge Line', + 'Prevent Premature Loop', + 'Black Pearl Candidate Pruning', + 'Pearl Completion', + 'Cell Completion', + 'Black Pearl Strong Inference', + ]) + }) + + it('applies a line diff on the sample Masyu puzzle', () => { + const puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/5/5/001390360') + const { step } = runNextRule(puzzle, masyuPlugin.getRules(), 1) + + expect(step?.ruleName).toBe('White Circle Rule') + expect(step?.diffs.some((diff) => diff.kind === 'line')).toBe(true) + }) +}) + +describe('Masyu loop rules', () => { + it('Masyu Tile Color Propagation seeds boundary tiles yellow', () => { + const puzzle = createMasyuPuzzle(2, 2) + const result = createMasyuTileColorPropagationRule().apply(puzzle) + const fills = new Map( + (result?.diffs ?? []).flatMap((diff) => + diff.kind === 'tile' ? [[diff.tileKey, diff.toFill] as const] : [], + ), + ) + + expect(fills.size).toBe(8) + for (let row = 0; row <= 2; row += 1) { + for (let col = 0; col <= 2; col += 1) { + const key = tileKey(row, col) + if (row === 1 && col === 1) { + expect(fills.has(key)).toBe(false) + } else { + expect(fills.get(key)).toBe('yellow') + } + } + } + }) + + it('Masyu Tile Color Propagation carries color through blank lines', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'blank') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('Masyu Tile Color Propagation flips color across line segments', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('Masyu Tile Color Propagation uses existing tile color anchors', () => { + const puzzle = createMasyuPuzzle(3, 3) + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 2), + fromFill: null, + toFill: 'green', + }) + }) + + it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NW tile', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const result = createMasyuColorPearlPropagationRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'yellow' }, + ]) + expect(result?.affectedCells).toEqual([cellKey(1, 1)]) + expect(result?.affectedTiles).toEqual([tileKey(1, 1), tileKey(2, 2)]) + expect(result?.message).toContain('White pearl') + }) + + it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NE tile', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } + + const result = createMasyuColorPearlPropagationRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 1), fromFill: null, toFill: 'green' }, + ]) + }) + + it('Masyu Color-Pearl Propagation ignores black pearls', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'black') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Color-Pearl Propagation does not overwrite an already colored opposite diagonal', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'green' } + + expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Color-Line Propagation forces a line between different adjacent tile colors', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) + expect(result?.affectedLines).toEqual([targetLine]) + expect(result?.affectedTiles).toEqual([tileKey(1, 2), tileKey(2, 2)]) + expect(result?.message).toContain('different colors') + }) + + it('Masyu Color-Line Propagation crosses a line between same adjacent tile colors', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [2, 1]) + puzzle.tiles[tileKey(2, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'blank' }) + expect(result?.message).toContain('same color') + }) + + it('Masyu Color-Line Propagation uses boundary tile colors beside edge-adjacent lines', () => { + const puzzle = createMasyuPuzzle(2, 2) + const targetLine = lineKey([0, 0], [0, 1]) + puzzle.tiles[tileKey(0, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) + }) + + it('Masyu Color-Line Propagation does not overwrite an already decided line', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + markLine(puzzle, targetLine, 'blank') + + expect(createMasyuColorLinePropagationRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Tile Connectivity Cut Coloring colors an articulation tile green between green sources', () => { + const puzzle = createMasyuPuzzle(4, 4) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + puzzle.tiles[tileKey(2, 3)] = { fill: 'green' } + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, + ]) + expect(result?.affectedTiles).toEqual([tileKey(2, 2)]) + expect(result?.message).toContain('inside cuts 1') + }) + + it('Masyu Tile Connectivity Cut Coloring colors every unknown tile in a blank-compressed bottleneck', () => { + const puzzle = createMasyuPuzzle(4, 5) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + puzzle.tiles[tileKey(2, 3)] = {} + puzzle.tiles[tileKey(2, 4)] = { fill: 'green' } + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, + { kind: 'tile', tileKey: tileKey(2, 3), fromFill: null, toFill: 'green' }, + ]) + }) + + it('Masyu Tile Connectivity Cut Coloring colors a line-enclosed tile green', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(1, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('unreachable-from-outside 1') + }) + + it('Masyu Tile Connectivity Cut Coloring keeps unknown line separators passable', () => { + const puzzle = createMasyuPuzzle(2, 2) + + expect(createMasyuTileConnectivityCutColoringRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Tile Connectivity Cut Coloring does not fire with only one green source component', () => { + const puzzle = createMasyuPuzzle(4, 4) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + + expect(createMasyuTileConnectivityCutColoringRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Candidate Bridge Line forces the only candidate bridge between two pearls', () => { + const puzzle = createMasyuPuzzle(1, 4) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 3, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + const bridge = lineKey([0, 1], [0, 2]) + + const result = createMasyuCandidateBridgeLineRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [bridge]: 'line' }) + expect(result?.affectedLines).toEqual([bridge]) + expect(result?.affectedCells).toEqual([cellKey(0, 1), cellKey(0, 2)]) + expect(result?.message).toContain('only remaining connection') + }) + + it('Masyu Candidate Bridge Line does not fire when two required groups have alternate routes', () => { + const puzzle = createMasyuPuzzle(2, 3) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 2, 'white') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Candidate Bridge Line ignores a bridge into an ordinary dead-end region', () => { + const puzzle = createMasyuPuzzle(1, 3) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 1, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Candidate Bridge Line treats existing line endpoints as required sources', () => { + const puzzle = createMasyuPuzzle(1, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + addPearl(puzzle, 0, 3, 'black') + const bridge = lineKey([0, 1], [0, 2]) + + const result = createMasyuCandidateBridgeLineRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [bridge]: 'line' }) + }) + + it('Masyu Candidate Bridge Line skips a forced bridge that would overflow endpoint degree', () => { + const puzzle = createMasyuPuzzle(3, 4) + addPearl(puzzle, 1, 0, 'white') + addPearl(puzzle, 1, 3, 'white') + for (const key of Object.keys(puzzle.lines)) { + markLine(puzzle, key, 'blank') + } + markLine(puzzle, lineKey([1, 0], [1, 1]), 'unknown') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) + + it('registers Masyu color propagation before premature loop prevention', () => { + const rules = masyuPlugin.getRules().map((rule) => rule.id) + + expect(rules).toContain('masyu-tile-color-propagation') + expect(rules).toContain('masyu-color-pearl-propagation') + expect(rules).toContain('masyu-color-line-propagation') + expect(rules).toContain('masyu-tile-connectivity-cut-coloring') + expect(rules).toContain('masyu-candidate-bridge-line') + expect(rules.indexOf('masyu-color-pearl-propagation')).toBe(rules.indexOf('masyu-tile-color-propagation') + 1) + expect(rules.indexOf('masyu-color-line-propagation')).toBe(rules.indexOf('masyu-color-pearl-propagation') + 1) + expect(rules.indexOf('masyu-tile-connectivity-cut-coloring')).toBe( + rules.indexOf('masyu-color-line-propagation') + 1, + ) + expect(rules.indexOf('masyu-candidate-bridge-line')).toBe( + rules.indexOf('masyu-tile-connectivity-cut-coloring') + 1, + ) + expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan(rules.indexOf('masyu-prevent-premature-loop')) + }) + + it('Prevent Premature Loop blanks a line that would close a smaller loop while other lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + const closingLine = lineKey([0, 0], [1, 0]) + + const result = createPreventPrematureLoopRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [closingLine]: 'blank' }) + expect(result?.affectedLines).toEqual([closingLine]) + expect(result?.message).toContain('smaller loop') + }) + + it('Prevent Premature Loop does not blank a candidate whose endpoints are in different components', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() + }) + + it('Prevent Premature Loop allows a closing line when no other confirmed lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() + }) +}) + +describe('Masyu black pearl candidate pruning', () => { + it('forces common exit and extension lines from the remaining black pearl candidates', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [northExtension]: 'line', [south]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + }) + + it('removes a black pearl exit when every candidate using it leaves a nearby white pearl impossible', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const west = lineKey([3, 2], [3, 3]) + const east = lineKey([3, 3], [3, 4]) + const northExtension = lineKey([1, 3], [2, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [west]: 'line', + [northExtension]: 'line', + [westExtension]: 'line', + [east]: 'blank', + }) + }) + + it('removes an exit that would give an adjacent black pearl degree 3 through the extension line', () => { + const puzzle = createMasyuPuzzle(6, 7) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 3, 4, 'black') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') + markLine(puzzle, lineKey([2, 4], [3, 4]), 'line') + const east = lineKey([3, 3], [3, 4]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: lineKey([1, 3], [2, 3]), from: 'unknown', to: 'line' }) + expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: east, from: 'unknown', to: 'blank' }) + }) + + it('removes candidates whose required black pearl extension is already blank', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const southExtension = lineKey([3, 2], [4, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: south, from: 'unknown', to: 'line' }) + expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: southExtension, from: 'unknown', to: 'line' }) + expect(result?.diffs).toContainEqual({ kind: 'line', lineKey: north, from: 'unknown', to: 'blank' }) + }) + + it('removes a black pearl candidate that would close a smaller loop', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') + markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [west]: 'line', + [westExtension]: 'line', + [south]: 'blank', + [east]: 'blank', + }) + }) + + it('allows a black pearl candidate that closes the only confirmed loop component', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') + markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [south]: 'blank', + }) + }) + + it('checks affected white pearl dependencies outside the candidate touched cells', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const west = lineKey([3, 2], [3, 3]) + const east = lineKey([3, 3], [3, 4]) + const northExtension = lineKey([1, 3], [2, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [west]: 'line', + [northExtension]: 'line', + [westExtension]: 'line', + [east]: 'blank', + }) + }) + + it('ignores unrelated impossible pearls inside the old fixed 5x5 scan area', () => { + const puzzle = createMasyuPuzzle(7, 7) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 5, 1, 'white') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 1], [5, 1]), 'blank') + markLine(puzzle, lineKey([5, 0], [5, 1]), 'blank') + markLine(puzzle, lineKey([5, 1], [5, 2]), 'blank') + markLine(puzzle, lineKey([5, 1], [6, 1]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const northExtension = lineKey([1, 3], [2, 3]) + const east = lineKey([3, 3], [3, 4]) + const eastExtension = lineKey([3, 4], [3, 5]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [northExtension]: 'line', + [east]: 'line', + [eastExtension]: 'line', + }) + }) + + it('does not run on a fully determined black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when all black pearl candidates remain symmetric', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('prunes only one black pearl per step on the reported 10x6 regression puzzle', () => { + let puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/10/6/0000b6103260i0902216') + const rules = masyuPlugin.getRules().filter((rule) => rule.id !== 'masyu-color-pearl-propagation') + let pruningStep: NonNullable['step']> | null = null + + for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { + const result = runNextRule(puzzle, rules, stepNumber) + expect(result.step).not.toBeNull() + if (result.step?.ruleName === 'Black Pearl Candidate Pruning') { + pruningStep = result.step + break + } + puzzle = result.nextPuzzle + } + + expect(pruningStep?.affectedCells).toEqual([cellKey(1, 6)]) + expectLineDiffs(pruningStep?.diffs, { + [lineKey([1, 6], [1, 7])]: 'line', + [lineKey([1, 7], [1, 8])]: 'line', + [lineKey([1, 5], [1, 6])]: 'blank', + }) + }) + + it('does not let White Circle Rule push the reported long puzzle into a degree-3 cell', () => { + const url = + 'https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090' + let puzzle = masyuPlugin.parse(url) + const unsafeLine = lineKey([2, 6], [2, 7]) + + for (let stepNumber = 1; stepNumber <= 35; stepNumber += 1) { + const result = runNextRule(puzzle, masyuPlugin.getRules(), stepNumber) + if (!result.step) { + break + } + expect(result.step.diffs).not.toContainEqual({ + kind: 'line', + lineKey: unsafeLine, + from: 'unknown', + to: 'line', + }) + puzzle = result.nextPuzzle + expect(getLineDegree(puzzle, 2, 6)).toBeLessThanOrEqual(2) + } + }) +}) + +describe('Masyu black pearl strong inference', () => { + it('crosses out a black pearl exit whose two-step assumption causes a degree contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + const north = lineKey([1, 2], [2, 2]) + + const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + expect(result?.affectedLines).toEqual([north]) + expect(result?.message).toContain('cell-degree contradiction') + }) + + it('uses deterministic downstream rules to find a contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + const westOfNeighbor = lineKey([1, 1], [1, 2]) + const eastOfNeighbor = lineKey([1, 2], [1, 3]) + const downstreamRule: Rule = { + id: 'test-downstream-degree', + name: 'Test Downstream Degree', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Force a downstream contradiction', + diffs: [ + { kind: 'line', lineKey: westOfNeighbor, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: eastOfNeighbor, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + affectedLines: [westOfNeighbor, eastOfNeighbor], + } + }, + } + + const result = createBlackPearlStrongInferenceRule(() => [downstreamRule]).apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.message).toContain('after 1 step') + }) + + it('does not copy a solved trial board back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + const unrelated = lineKey([4, 3], [4, 4]) + const harmlessRule: Rule = { + id: 'test-harmless-trial-progress', + name: 'Test Harmless Trial Progress', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Harmless trial-only progress', + diffs: [{ kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }], + affectedCells: [], + affectedLines: [unrelated], + } + }, + } + + const result = createBlackPearlStrongInferenceRule(() => [harmlessRule], { maxTrialSteps: 1 }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('returns null when the trial budget times out', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + + const result = createBlackPearlStrongInferenceRule(() => [], { maxMs: -1 }).apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not overwrite an already decided first exit segment', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + markLine(puzzle, north, 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + + const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ kind: 'line', lineKey: north, from: 'line', to: 'blank' }) + expect(puzzle.lines[north]?.mark).toBe('line') + }) + + it('registers strong inference after the deterministic Masyu rules', () => { + const rules = masyuPlugin.getRules() + + expect(rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id)).toEqual( + deterministicMasyuRules.map((rule) => rule.id), + ) + expect(rules.at(-1)?.id).toBe('masyu-black-pearl-strong-inference') + }) +}) + +describe('Masyu pattern rules', () => { + it('Black Facing Consecutive Whites forces away from horizontal consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 3, 'white') + addPearl(puzzle, 2, 4, 'white') + const west = lineKey([2, 0], [2, 1]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(2, 1)]) + }) + + it('Black Facing Consecutive Whites forces away from vertical consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 3, 2, 'black') + addPearl(puzzle, 1, 2, 'white') + addPearl(puzzle, 0, 2, 'white') + const south = lineKey([3, 2], [4, 2]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('Black Facing Consecutive Whites does not fire with only one distant white pearl', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 3, 'white') + + expect(createBlackFacingConsecutiveWhitesRule().apply(puzzle)).toBeNull() + }) + + it('Black Facing Consecutive Whites allows the gap cell to contain a pearl', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 3, 'white') + addPearl(puzzle, 2, 4, 'white') + const west = lineKey([2, 0], [2, 1]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls north of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 1, 3, 'white') + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls east of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + addPearl(puzzle, 3, 3, 'white') + const west = lineKey([2, 1], [2, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls south of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 3, 1, 'white') + addPearl(puzzle, 3, 3, 'white') + const north = lineKey([1, 2], [2, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls west of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 3, 1, 'white') + const east = lineKey([2, 2], [2, 3]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + }) + + it('Black Diagonal White Pinch does not fire when one diagonal white pearl is missing', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + + expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() + }) + + it('Black Diagonal White Pinch does not overwrite a blank forced line', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + + expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() + }) + + it('Consecutive White Pearls Straight forces vertical pass-through for three horizontal white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 1], [2, 1])]: 'line', + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight forces horizontal pass-through for three vertical white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 1, 2, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 1], [1, 2])]: 'line', + [lineKey([1, 2], [1, 3])]: 'line', + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight covers every pearl in a longer run', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 1, 'white') + addPearl(puzzle, 3, 2, 'white') + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 3, 4, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([3, 1], [4, 1])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([3, 2], [4, 2])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + [lineKey([3, 3], [4, 3])]: 'line', + [lineKey([2, 4], [3, 4])]: 'line', + [lineKey([3, 4], [4, 4])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight does not fire for only two consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + + expect(createConsecutiveWhitePearlsStraightRule().apply(puzzle)).toBeNull() + }) + + it('Consecutive White Pearls Straight does not overwrite a blank target line', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + }) + }) + + it('Double Black Squeeze blanks the opposite vertical exit between horizontal black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + const south = lineKey([2, 3], [3, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(2, 3)]) + }) + + it('Double Black Squeeze works in the reverse vertical direction between horizontal black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'blank') + const north = lineKey([1, 3], [2, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + }) + + it('Double Black Squeeze blanks the opposite horizontal exit between vertical black pearls', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 3, 'black') + addPearl(puzzle, 4, 3, 'black') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') + const east = lineKey([3, 3], [3, 4]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank' }) + }) + + it('Double Black Squeeze does not fire unless both opposite cells are black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze ignores adjacent black pearls that do not enclose a middle cell', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 3, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze does not overwrite an opposite exit that is already a line', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) +}) + +describe('Masyu Pearl Completion', () => { + it('forces the opposite white pearl exit when only the straight continuation remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const south = lineKey([2, 2], [3, 2]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('forces the only available straight pair on a white pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'line', [south]: 'line' }) + }) + + it('blanks remaining white pearl exits after a straight pair is complete', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + const west = lineKey([2, 1], [2, 2]) + const east = lineKey([2, 2], [2, 3]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'blank', [east]: 'blank' }) + }) + + it('does not force a white pearl when only a turning pair remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + + expect(createPearlCompletionRule().apply(puzzle)).toBeNull() + }) + + it('forces a black pearl turn continuation and extends both exits', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const southExtension = lineKey([3, 2], [4, 2]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [south]: 'line', + [westExtension]: 'line', + [southExtension]: 'line', + }) + }) + + it('forces the only available turning pair on a black pearl and extends it', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const west = lineKey([2, 1], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const southExtension = lineKey([3, 2], [4, 2]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [west]: 'line', + [south]: 'line', + [westExtension]: 'line', + [southExtension]: 'line', + }) + }) + + it('blanks remaining exits and extends a completed black pearl turn', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const northExtension = lineKey([0, 2], [1, 2]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [south]: 'blank', + [westExtension]: 'line', + [northExtension]: 'line', + }) + }) + + it('does not force a black pearl when only a straight pair remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + + expect(createPearlCompletionRule().apply(puzzle)).toBeNull() + }) + + it('does not overwrite a blank black pearl extension', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + + const result = createPearlCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line', [westExtension]: 'line' }) + }) +}) + +describe('Masyu Cell Completion', () => { + it('connects the only remaining candidate when a regular cell has one line', () => { + const puzzle = createMasyuPuzzle(3, 3) + const west = lineKey([1, 1], [1, 0]) + const east = lineKey([1, 1], [1, 2]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([1, 1], [0, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') + + const result = createCellCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + }) + + it('blanks every remaining candidate when a cell already has degree 2', () => { + const puzzle = createMasyuPuzzle(3, 3) + const north = lineKey([1, 1], [0, 1]) + const south = lineKey([1, 1], [2, 1]) + markLine(puzzle, lineKey([1, 1], [1, 0]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + + const result = createCellCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) + }) + + it('blanks a single remaining candidate on a regular dead-end cell', () => { + const puzzle = createMasyuPuzzle(1, 2) + const onlyLine = lineKey([0, 0], [0, 1]) + + const result = createCellCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [onlyLine]: 'blank' }) + }) + + it('does not apply pearl-specific completion to a white pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + + const result = createCellCompletionRule().apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not apply pearl-specific completion to a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + + const result = createCellCompletionRule().apply(puzzle) + + expect(result).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts new file mode 100644 index 0000000..a7ded28 --- /dev/null +++ b/src/domain/rules/masyu/rules.ts @@ -0,0 +1,42 @@ +import type { Rule } from '../types' +import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' +import { createMasyuCandidateBridgeLineRule } from './rules/bridges' +import { createBlackPearlCandidatePruningRule } from './rules/candidates' +import { + createMasyuColorLinePropagationRule, + createMasyuColorPearlPropagationRule, + createMasyuTileColorPropagationRule, +} from './rules/color' +import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' +import { createCellCompletionRule, createPearlCompletionRule } from './rules/completion' +import { createPreventPrematureLoopRule } from './rules/loop' +import { + createBlackDiagonalWhitePinchRule, + createBlackFacingConsecutiveWhitesRule, + createConsecutiveWhitePearlsStraightRule, + createDoubleBlackSqueezeRule, +} from './rules/patterns' +import { createBlackCircleRule, createWhiteCircleRule } from './rules/pearls' + +export const deterministicMasyuRules: Rule[] = [ + createWhiteCircleRule(), + createBlackCircleRule(), + createBlackFacingConsecutiveWhitesRule(), + createBlackDiagonalWhitePinchRule(), + createConsecutiveWhitePearlsStraightRule(), + createDoubleBlackSqueezeRule(), + createMasyuTileColorPropagationRule(), + createMasyuColorPearlPropagationRule(), + createMasyuColorLinePropagationRule(), + createMasyuTileConnectivityCutColoringRule(), + createMasyuCandidateBridgeLineRule(), + createPreventPrematureLoopRule(), + createBlackPearlCandidatePruningRule(), + createPearlCompletionRule(), + createCellCompletionRule(), +] + +export const masyuRules: Rule[] = [ + ...deterministicMasyuRules, + createBlackPearlStrongInferenceRule(() => deterministicMasyuRules), +] diff --git a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts new file mode 100644 index 0000000..e8b4f0b --- /dev/null +++ b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts @@ -0,0 +1,185 @@ +import { clonePuzzle } from '../../../ir/normalize' +import type { PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + applyMasyuLineAssumption, + runMasyuTrialUntilFixpoint, + type MasyuTrialResult, +} from './trial' +import { + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionalLine, + getMasyuIncidentDirectionalLines, + getMasyuTwoStepLine, + MASYU_DIRECTIONS, + oppositeMasyuDirection, + type MasyuDirection, +} from './shared' + +const STRONG_MAX_CANDIDATES = 200 +const STRONG_MAX_TRIAL_STEPS = 60 +const STRONG_MAX_MS = 4000 + +type BlackPearlStrongInferenceOptions = { + maxCandidates?: number + maxTrialSteps?: number + maxMs?: number +} + +type BlackPearlExitCandidate = { + pearlKey: string + direction: MasyuDirection + firstLine: string + secondLine: string + oppositeLine: string | null +} + +type StrongBranch = { + puzzle: PuzzleIR + setupOk: boolean + setupDescription: string +} + +const deriveProbeBudgets = (maxTrialSteps: number): number[] => { + const cappedMax = Math.max(1, maxTrialSteps) + return [12, 36, cappedMax] + .map((budget) => Math.min(budget, cappedMax)) + .filter((budget, index, arr) => arr.indexOf(budget) === index) +} + +const isUndecidedBlackPearl = (puzzle: PuzzleIR, pearlKey: string): boolean => + Object.values(getMasyuIncidentDirectionalLines(puzzle, pearlKey)).some((line) => line?.mark === 'unknown') + +const collectBlackPearlExitCandidates = (puzzle: PuzzleIR, maxCandidates: number): BlackPearlExitCandidate[] => { + const candidates: BlackPearlExitCandidate[] = [] + for (const [pearlKey, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'pearl' || cell.clue.color !== 'black' || !isUndecidedBlackPearl(puzzle, pearlKey)) { + continue + } + for (const direction of MASYU_DIRECTIONS) { + const { first, second } = getMasyuTwoStepLine(puzzle, pearlKey, direction) + if (!first || !second || first.mark !== 'unknown') { + continue + } + const opposite = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(direction)) + candidates.push({ + pearlKey, + direction, + firstLine: first.lineKey, + secondLine: second.lineKey, + oppositeLine: opposite?.lineKey ?? null, + }) + if (candidates.length >= maxCandidates) { + return candidates + } + } + } + return candidates +} + +const buildBranch = (puzzle: PuzzleIR, candidate: BlackPearlExitCandidate): StrongBranch => { + const branch = clonePuzzle(puzzle) + const assumptions: Array<[lineKey: string, mark: 'line' | 'blank']> = [ + [candidate.firstLine, 'line'], + [candidate.secondLine, 'line'], + ] + if (candidate.oppositeLine) { + assumptions.push([candidate.oppositeLine, 'blank']) + } + + let setupOk = true + for (const [lineKeyValue, mark] of assumptions) { + setupOk = applyMasyuLineAssumption(branch, lineKeyValue, mark) && setupOk + } + + const setupDescription = assumptions + .map(([lineKeyValue, mark]) => `${formatMasyuLineLabel(lineKeyValue)} ${mark}`) + .join(', ') + + return { puzzle: branch, setupOk, setupDescription } +} + +const immediateContradictionResult = (puzzle: PuzzleIR): MasyuTrialResult => ({ + contradiction: true, + timedOut: false, + exhausted: false, + puzzle, + stepsRun: 0, + elapsedMs: 0, + contradictionReason: { + kind: 'line-assumption', + message: 'line-assumption contradiction: this exit assumption conflicts with an already decided Masyu line', + }, +}) + +const describeTrialResult = (result: MasyuTrialResult): string => { + if (result.contradiction) { + return `${result.contradictionReason?.message ?? 'a contradiction'} after ${result.stepsRun} ${ + result.stepsRun === 1 ? 'step' : 'steps' + }` + } + if (result.exhausted) { + return `no contradiction within ${result.stepsRun} trial steps` + } + return `no contradiction after ${result.stepsRun} ${result.stepsRun === 1 ? 'step' : 'steps'}` +} + +export const createBlackPearlStrongInferenceRule = ( + getDeterministicRules: () => Rule[], + options: BlackPearlStrongInferenceOptions = {}, +): Rule => ({ + id: 'masyu-black-pearl-strong-inference', + name: 'Black Pearl Strong Inference', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const deterministicRules = getDeterministicRules() + const candidates = collectBlackPearlExitCandidates(puzzle, options.maxCandidates ?? STRONG_MAX_CANDIDATES) + if (candidates.length === 0) { + return null + } + + const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) + const budgets = deriveProbeBudgets(options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS) + for (const budget of budgets) { + for (const candidate of candidates) { + if (Date.now() > deadlineMs) { + return null + } + + const branch = buildBranch(puzzle, candidate) + const result = branch.setupOk + ? runMasyuTrialUntilFixpoint(branch.puzzle, deterministicRules, budget, deadlineMs) + : immediateContradictionResult(branch.puzzle) + if (result.timedOut) { + return null + } + if (!result.contradiction) { + continue + } + if ((puzzle.lines[candidate.firstLine]?.mark ?? 'unknown') !== 'unknown') { + continue + } + + return { + message: + `Black Pearl Strong Inference: assuming ${formatMasyuCellKeyLabel(candidate.pearlKey)} exits ${candidate.direction} ` + + `(${branch.setupDescription}) leads to ${describeTrialResult(result)}, so ${formatMasyuLineLabel( + candidate.firstLine, + )} is crossed out.`, + diffs: [ + { + kind: 'line', + lineKey: candidate.firstLine, + from: 'unknown', + to: 'blank', + }, + ], + affectedCells: [candidate.pearlKey], + affectedLines: [candidate.firstLine], + } + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/bridges.ts b/src/domain/rules/masyu/rules/bridges.ts new file mode 100644 index 0000000..e7a664a --- /dev/null +++ b/src/domain/rules/masyu/rules/bridges.ts @@ -0,0 +1,223 @@ +import { cellKey, parseLineKey } from '../../../ir/keys' +import type { CellCoord, LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + buildMasyuLineDiffs, + collectMasyuLineDecisionWithoutDegreeOverflow, + formatMasyuLineLabel, +} from './shared' + +type CandidateEdge = { + lineKey: string + mark: LineMark + left: string + right: string +} + +type CandidateGraph = { + cellKeys: string[] + edges: CandidateEdge[] + adjacency: Map +} + +const getEndpointKey = ([row, col]: CellCoord): string => cellKey(row, col) + +const getOtherEndpoint = (edge: CandidateEdge, node: string): string => + edge.left === node ? edge.right : edge.left + +const buildMasyuCandidateGraph = (puzzle: PuzzleIR): CandidateGraph => { + const cellKeys: string[] = [] + const adjacency = new Map() + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + cellKeys.push(key) + adjacency.set(key, []) + } + } + + const edges: CandidateEdge[] = [] + for (const lineKeyValue of Object.keys(puzzle.lines)) { + const mark = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (mark === 'blank') { + continue + } + const [leftCoord, rightCoord] = parseLineKey(lineKeyValue) + const edge = { + lineKey: lineKeyValue, + mark, + left: getEndpointKey(leftCoord), + right: getEndpointKey(rightCoord), + } + edges.push(edge) + adjacency.get(edge.left)?.push(edge) + adjacency.get(edge.right)?.push(edge) + } + + return { cellKeys, edges, adjacency } +} + +const getRequiredSources = (puzzle: PuzzleIR, graph: CandidateGraph): Set => { + const sources = new Set() + + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind === 'pearl') { + sources.add(key) + } + } + + for (const edge of graph.edges) { + if (edge.mark !== 'line') { + continue + } + sources.add(edge.left) + sources.add(edge.right) + } + + return sources +} + +const getSourceComponentCount = (graph: CandidateGraph, sources: ReadonlySet): number => { + const seen = new Set() + let sourceComponents = 0 + + for (const start of graph.cellKeys) { + if (seen.has(start)) { + continue + } + const stack = [start] + seen.add(start) + let hasSource = false + + while (stack.length > 0) { + const node = stack.pop() + if (node === undefined) { + continue + } + if (sources.has(node)) { + hasSource = true + } + for (const edge of graph.adjacency.get(node) ?? []) { + const neighbor = getOtherEndpoint(edge, node) + if (seen.has(neighbor)) { + continue + } + seen.add(neighbor) + stack.push(neighbor) + } + } + + if (hasSource) { + sourceComponents += 1 + } + } + + return sourceComponents +} + +export const createMasyuCandidateBridgeLineRule = (): Rule => ({ + id: 'masyu-candidate-bridge-line', + name: 'Masyu Candidate Bridge Line', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const graph = buildMasyuCandidateGraph(puzzle) + const requiredSources = getRequiredSources(puzzle, graph) + + if (requiredSources.size < 2 || getSourceComponentCount(graph, requiredSources) > 1) { + return null + } + + const decisions = new Map() + const affectedCells = new Set() + const affectedLines = new Set() + const discovery = new Map() + const low = new Map() + let timestamp = 0 + let firstForcedLine: string | null = null + + const rememberBridge = (edge: CandidateEdge, childRequired: number, totalRequired: number): void => { + if (edge.mark !== 'unknown' || childRequired === 0 || totalRequired - childRequired === 0) { + return + } + if (!collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, edge.lineKey, 'line')) { + return + } + affectedLines.add(edge.lineKey) + affectedCells.add(edge.left) + affectedCells.add(edge.right) + if (firstForcedLine === null) { + firstForcedLine = edge.lineKey + } + } + + const dfs = (node: string, parentEdge: string | null, totalRequired: number): number => { + discovery.set(node, timestamp) + low.set(node, timestamp) + timestamp += 1 + + let subtreeRequired = requiredSources.has(node) ? 1 : 0 + + for (const edge of graph.adjacency.get(node) ?? []) { + if (edge.lineKey === parentEdge) { + continue + } + const neighbor = getOtherEndpoint(edge, node) + if (!discovery.has(neighbor)) { + const childRequired = dfs(neighbor, edge.lineKey, totalRequired) + low.set(node, Math.min(low.get(node) ?? 0, low.get(neighbor) ?? 0)) + subtreeRequired += childRequired + + if ((low.get(neighbor) ?? 0) > (discovery.get(node) ?? 0)) { + rememberBridge(edge, childRequired, totalRequired) + } + continue + } + low.set(node, Math.min(low.get(node) ?? 0, discovery.get(neighbor) ?? 0)) + } + + return subtreeRequired + } + + for (const start of graph.cellKeys) { + if (discovery.has(start)) { + continue + } + const componentNodes: string[] = [] + const stack = [start] + const seen = new Set([start]) + + while (stack.length > 0) { + const node = stack.pop() + if (node === undefined) { + continue + } + componentNodes.push(node) + for (const edge of graph.adjacency.get(node) ?? []) { + const neighbor = getOtherEndpoint(edge, node) + if (seen.has(neighbor)) { + continue + } + seen.add(neighbor) + stack.push(neighbor) + } + } + + const totalRequired = componentNodes.filter((node) => requiredSources.has(node)).length + dfs(start, null, totalRequired) + } + + if (decisions.size === 0 || firstForcedLine === null) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + `This candidate line is the only remaining connection between required loop regions, so it must be part of the loop: ${formatMasyuLineLabel(firstForcedLine)}` + + `${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.`, + diffs, + affectedCells: [...affectedCells], + affectedLines: [...affectedLines], + } + }, +}) diff --git a/src/domain/rules/masyu/rules/candidates.ts b/src/domain/rules/masyu/rules/candidates.ts new file mode 100644 index 0000000..9747a95 --- /dev/null +++ b/src/domain/rules/masyu/rules/candidates.ts @@ -0,0 +1,53 @@ +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { buildMasyuLineDiffs, collectMasyuLineDecision, formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' +import { createMasyuLookaheadContext } from './lookahead' + +export const createBlackPearlCandidatePruningRule = (): Rule => ({ + id: 'masyu-black-pearl-candidate-pruning', + name: 'Black Pearl Candidate Pruning', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const context = createMasyuLookaheadContext(puzzle) + + for (const pearlKey of context.getBlackPearlKeys()) { + const decisions = new Map() + const incident = context.getIncidentEntries(new Map(), pearlKey) + const exitLineKeys = incident.map((item) => item.lineKey) + const candidates = context.getFeasibleBlackPearlCandidates(pearlKey) + if (candidates.length === 0) { + continue + } + + const commonLineKeys = [...candidates[0].lines].filter((lineKeyValue) => + candidates.every((candidate) => candidate.lines.has(lineKeyValue)), + ) + const excludedExitLineKeys = exitLineKeys.filter((lineKeyValue) => + candidates.every((candidate) => !candidate.exitLines.has(lineKeyValue)), + ) + + for (const lineKeyValue of commonLineKeys) { + collectMasyuLineDecision(decisions, puzzle, lineKeyValue, 'line') + } + for (const lineKeyValue of excludedExitLineKeys) { + collectMasyuLineDecision(decisions, puzzle, lineKeyValue, 'blank') + } + + if (decisions.size === 0) { + continue + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + const firstLine = diffs[0]?.lineKey + return { + message: firstLine + ? `Black pearl ${formatMasyuCellKeyLabel(pearlKey)} has only compatible candidate turns left, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Black pearl candidate pruning applied.', + diffs, + affectedCells: [pearlKey], + affectedLines: diffs.map((diff) => diff.lineKey), + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/color.ts b/src/domain/rules/masyu/rules/color.ts new file mode 100644 index 0000000..e880a04 --- /dev/null +++ b/src/domain/rules/masyu/rules/color.ts @@ -0,0 +1,350 @@ +import { parseCellKey, parseLineKey, parseTileKey, tileKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { buildMasyuLineDiffs, formatMasyuCellKeyLabel, formatMasyuLineLabel } from './shared' + +export type MasyuTileColor = 'green' | 'yellow' + +type Parity = 0 | 1 + +export const isMasyuTileColor = (fill: string | undefined): fill is MasyuTileColor => + fill === 'green' || fill === 'yellow' + +export const oppositeMasyuTileColor = (fill: MasyuTileColor): MasyuTileColor => + fill === 'green' ? 'yellow' : 'green' + +export const formatMasyuTileKeyLabel = (key: string): string => { + const [row, col] = parseTileKey(key) + return `T(${row}, ${col})` +} + +const applyParity = (color: MasyuTileColor, parity: Parity): MasyuTileColor => + parity === 0 ? color : oppositeMasyuTileColor(color) + +const isBoundaryTile = (puzzle: PuzzleIR, row: number, col: number): boolean => + row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols + +const collectMasyuTileColorDecision = ( + decisions: Map, + puzzle: PuzzleIR, + key: string, + toFill: MasyuTileColor, +): boolean => { + const currentFill = puzzle.tiles[key]?.fill + if (isMasyuTileColor(currentFill)) { + return currentFill === toFill + } + const existing = decisions.get(key) + if (existing !== undefined) { + return existing === toFill + } + decisions.set(key, toFill) + return true +} + +export const getMasyuLineTileRelation = ( + puzzle: PuzzleIR, + lineKeyValue: string, +): { leftTile: string; rightTile: string } | null => { + const [left, right] = parseLineKey(lineKeyValue) + if (left[0] === right[0] && Math.abs(left[1] - right[1]) === 1) { + const row = left[0] + const col = Math.min(left[1], right[1]) + if (row < 0 || row >= puzzle.rows || col < 0 || col >= puzzle.cols - 1) { + return null + } + return { + leftTile: tileKey(row, col + 1), + rightTile: tileKey(row + 1, col + 1), + } + } + if (left[1] === right[1] && Math.abs(left[0] - right[0]) === 1) { + const row = Math.min(left[0], right[0]) + const col = left[1] + if (row < 0 || row >= puzzle.rows - 1 || col < 0 || col >= puzzle.cols) { + return null + } + return { + leftTile: tileKey(row + 1, col), + rightTile: tileKey(row + 1, col + 1), + } + } + return null +} + +export const createMasyuColorLinePropagationRule = (): Rule => ({ + id: 'masyu-color-line-propagation', + name: 'Masyu Color-Line Propagation', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedLines = new Set() + const affectedTiles = new Set() + let firstReason: string | null = null + + for (const [lineKeyValue, lineState] of Object.entries(puzzle.lines ?? {})) { + if ((lineState?.mark ?? 'unknown') !== 'unknown') { + continue + } + + const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) + if (!relation) { + continue + } + + const leftColor = puzzle.tiles[relation.leftTile]?.fill + const rightColor = puzzle.tiles[relation.rightTile]?.fill + if (!isMasyuTileColor(leftColor) || !isMasyuTileColor(rightColor)) { + continue + } + + const to: LineMark = leftColor === rightColor ? 'blank' : 'line' + decisions.set(lineKeyValue, to) + affectedLines.add(lineKeyValue) + affectedTiles.add(relation.leftTile) + affectedTiles.add(relation.rightTile) + firstReason ??= + to === 'line' + ? `${formatMasyuTileKeyLabel(relation.leftTile)} and ${formatMasyuTileKeyLabel(relation.rightTile)} have different colors, so ${formatMasyuLineLabel(lineKeyValue)} must be a line` + : `${formatMasyuTileKeyLabel(relation.leftTile)} and ${formatMasyuTileKeyLabel(relation.rightTile)} have the same color, so ${formatMasyuLineLabel(lineKeyValue)} must be crossed out` + } + + if (decisions.size === 0) { + return null + } + + return { + message: `${firstReason ?? 'Known Masyu tile colors decide separating line marks'} (${decisions.size} line update(s)).`, + diffs: buildMasyuLineDiffs(decisions, puzzle), + affectedCells: [], + affectedLines: [...affectedLines], + affectedTiles: [...affectedTiles], + } + }, +}) + +export const createMasyuColorPearlPropagationRule = (): Rule => ({ + id: 'masyu-color-pearl-propagation', + name: 'Masyu Color-Pearl Propagation', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decidedTileFills = new Map() + const affectedCells = new Set() + const affectedTiles = new Set() + let firstReason: string | null = null + + const inferOppositeDiagonal = (pearlKey: string, knownTile: string, oppositeTile: string): void => { + const knownFill = puzzle.tiles[knownTile]?.fill + if (!isMasyuTileColor(knownFill)) { + return + } + const oppositeFill = puzzle.tiles[oppositeTile]?.fill + if (isMasyuTileColor(oppositeFill)) { + return + } + + const toFill = oppositeMasyuTileColor(knownFill) + if (!collectMasyuTileColorDecision(decidedTileFills, puzzle, oppositeTile, toFill)) { + return + } + affectedCells.add(pearlKey) + affectedTiles.add(knownTile) + affectedTiles.add(oppositeTile) + firstReason ??= + `White pearl ${formatMasyuCellKeyLabel(pearlKey)} is crossed straight, so diagonal tiles ` + + `${formatMasyuTileKeyLabel(knownTile)} and ${formatMasyuTileKeyLabel(oppositeTile)} must have opposite colors` + } + + for (const [key, cell] of Object.entries(puzzle.cells ?? {})) { + if (cell.clue?.kind !== 'pearl' || cell.clue.color !== 'white') { + continue + } + const [row, col] = parseCellKey(key) + const nw = tileKey(row, col) + const ne = tileKey(row, col + 1) + const sw = tileKey(row + 1, col) + const se = tileKey(row + 1, col + 1) + + inferOppositeDiagonal(key, nw, se) + inferOppositeDiagonal(key, se, nw) + inferOppositeDiagonal(key, ne, sw) + inferOppositeDiagonal(key, sw, ne) + } + + if (decidedTileFills.size === 0) { + return null + } + + const diffs: RuleApplication['diffs'] = [...decidedTileFills.entries()].map(([key, toFill]) => ({ + kind: 'tile' as const, + tileKey: key, + fromFill: (puzzle.tiles[key]?.fill ?? null) as string | null, + toFill, + })) + + return { + message: `${firstReason ?? 'White pearl diagonal tile colors force opposite colors'} (${diffs.length} tile update(s)).`, + diffs, + affectedCells: [...affectedCells], + affectedTiles: [...affectedTiles], + } + }, +}) + +export const createMasyuTileColorPropagationRule = (): Rule => ({ + id: 'masyu-tile-color-propagation', + name: 'Masyu Tile Color Propagation', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const parent = new Map() + const rank = new Map() + const parityToParent = new Map() + const inconsistentRoots = new Set() + + const ensureTile = (key: string): void => { + if (parent.has(key)) { + return + } + parent.set(key, key) + rank.set(key, 0) + parityToParent.set(key, 0) + } + + const find = (key: string): { root: string; parity: Parity } => { + ensureTile(key) + const currentParent = parent.get(key) + if (currentParent === undefined || currentParent === key) { + return { root: key, parity: 0 } + } + const parentResult = find(currentParent) + const compressedParity = ((parityToParent.get(key) ?? 0) ^ parentResult.parity) as Parity + parent.set(key, parentResult.root) + parityToParent.set(key, compressedParity) + return { root: parentResult.root, parity: compressedParity } + } + + const markInconsistent = (root: string): void => { + inconsistentRoots.add(find(root).root) + } + + const union = (tileA: string, tileB: string, relation: Parity): void => { + const rootA = find(tileA) + const rootB = find(tileB) + if (rootA.root === rootB.root) { + if ((rootA.parity ^ rootB.parity) !== relation) { + markInconsistent(rootA.root) + } + return + } + + const mergedParity = (rootA.parity ^ rootB.parity ^ relation) as Parity + const rankA = rank.get(rootA.root) ?? 0 + const rankB = rank.get(rootB.root) ?? 0 + const rootAWasInconsistent = inconsistentRoots.delete(rootA.root) + const rootBWasInconsistent = inconsistentRoots.delete(rootB.root) + + if (rankA < rankB) { + parent.set(rootA.root, rootB.root) + parityToParent.set(rootA.root, mergedParity) + if (rootAWasInconsistent || rootBWasInconsistent) { + inconsistentRoots.add(rootB.root) + } + return + } + + parent.set(rootB.root, rootA.root) + parityToParent.set(rootB.root, mergedParity) + if (rankA === rankB) { + rank.set(rootA.root, rankA + 1) + } + if (rootAWasInconsistent || rootBWasInconsistent) { + inconsistentRoots.add(rootA.root) + } + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + ensureTile(tileKey(row, col)) + } + } + + for (const [lineKeyValue, lineState] of Object.entries(puzzle.lines ?? {})) { + const mark: LineMark = lineState?.mark ?? 'unknown' + if (mark !== 'line' && mark !== 'blank') { + continue + } + const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) + if (!relation) { + continue + } + union(relation.leftTile, relation.rightTile, mark === 'line' ? 1 : 0) + } + + const anchoredRootColors = new Map() + const rememberAnchor = (key: string, color: MasyuTileColor): void => { + const { root, parity } = find(key) + const rootColor = applyParity(color, parity) + const current = anchoredRootColors.get(root) + if (current !== undefined && current !== rootColor) { + markInconsistent(root) + return + } + anchoredRootColors.set(root, rootColor) + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + if (isBoundaryTile(puzzle, row, col)) { + rememberAnchor(tileKey(row, col), 'yellow') + } + } + } + + for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { + if (isMasyuTileColor(tile.fill)) { + rememberAnchor(key, tile.fill) + } + } + + const decidedTileFills = new Map() + const affectedTiles = new Set() + let firstInferredTile: string | null = null + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + const key = tileKey(row, col) + const currentFill = puzzle.tiles[key]?.fill + if (isMasyuTileColor(currentFill)) { + continue + } + const { root, parity } = find(key) + if (inconsistentRoots.has(root)) { + continue + } + const rootColor = anchoredRootColors.get(root) + if (rootColor === undefined) { + continue + } + const inferredColor = applyParity(rootColor, parity) + decidedTileFills.set(key, inferredColor) + affectedTiles.add(key) + firstInferredTile ??= key + } + } + + if (decidedTileFills.size === 0) { + return null + } + + const diffs: RuleApplication['diffs'] = [...decidedTileFills.entries()].map(([key, toFill]) => ({ + kind: 'tile' as const, + tileKey: key, + fromFill: (puzzle.tiles[key]?.fill ?? null) as string | null, + toFill, + })) + + return { + message: `Known Masyu lines and the exterior boundary color tile regions; ${firstInferredTile ? formatMasyuTileKeyLabel(firstInferredTile) : 'matching tiles'} and related tiles are yellow/green (${diffs.length} tile update(s)).`, + diffs, + affectedCells: [], + affectedTiles: [...affectedTiles], + } + }, +}) diff --git a/src/domain/rules/masyu/rules/completion.ts b/src/domain/rules/masyu/rules/completion.ts new file mode 100644 index 0000000..7df2e0a --- /dev/null +++ b/src/domain/rules/masyu/rules/completion.ts @@ -0,0 +1,273 @@ +import { cellKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + areMasyuDirectionsOpposite, + areMasyuDirectionsTurn, + buildMasyuLineDiffs, + collectMasyuLineDecision, + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuIncidentDirectionalLines, + getMasyuTwoStepLine, + MASYU_DIRECTIONS, + type MasyuDirection, + type MasyuDirectionalLine, +} from './shared' + +type PearlColor = 'white' | 'black' + +const getPearlCellKeys = (puzzle: PuzzleIR): string[] => + Object.entries(puzzle.cells).flatMap(([key, cell]) => (cell.clue?.kind === 'pearl' ? [key] : [])) + +const getPearlColor = (puzzle: PuzzleIR, key: string): PearlColor | null => { + const clue = puzzle.cells[key]?.clue + return clue?.kind === 'pearl' ? clue.color : null +} + +const isLegalPearlPair = (color: PearlColor, left: MasyuDirection, right: MasyuDirection): boolean => + color === 'white' ? areMasyuDirectionsOpposite(left, right) : areMasyuDirectionsTurn(left, right) + +const getLegalPearlPairs = ( + color: PearlColor, + entries: MasyuDirectionalLine[], +): [MasyuDirectionalLine, MasyuDirectionalLine][] => { + const pairs: [MasyuDirectionalLine, MasyuDirectionalLine][] = [] + for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { + const left = entries[leftIndex] + const right = entries[rightIndex] + if (isLegalPearlPair(color, left.direction, right.direction)) { + pairs.push([left, right]) + } + } + } + return pairs +} + +const rememberPearlDecision = ( + decisions: Map, + puzzle: PuzzleIR, + lineKey: string, + to: LineMark, +): boolean => { + const beforeSize = decisions.size + return collectMasyuLineDecision(decisions, puzzle, lineKey, to) && decisions.size > beforeSize +} + +const rememberBlackExtension = ( + decisions: Map, + puzzle: PuzzleIR, + pearlKey: string, + direction: MasyuDirection, +): string | null => { + const extension = getMasyuTwoStepLine(puzzle, pearlKey, direction).second + if (!extension || !rememberPearlDecision(decisions, puzzle, extension.lineKey, 'line')) { + return null + } + return extension.lineKey +} + +export const createPearlCompletionRule = (): Rule => ({ + id: 'pearl-completion', + name: 'Pearl Completion', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + let firstReason: string | null = null + + const remember = (pearlKey: string, lineKey: string, to: LineMark, reason: string): void => { + if (!rememberPearlDecision(decisions, puzzle, lineKey, to)) { + return + } + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstLine = lineKey + firstReason = reason + } + } + + const rememberBlackExitExtensions = (pearlKey: string, directions: MasyuDirection[], reason: string): void => { + for (const direction of directions) { + const extensionLineKey = rememberBlackExtension(decisions, puzzle, pearlKey, direction) + if (extensionLineKey) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstLine = extensionLineKey + firstReason = reason + } + } + } + } + + for (const pearlKey of getPearlCellKeys(puzzle)) { + const color = getPearlColor(puzzle, pearlKey) + if (!color) { + continue + } + const directional = getMasyuIncidentDirectionalLines(puzzle, pearlKey) + const incident = MASYU_DIRECTIONS.flatMap((direction) => { + const item = directional[direction] + return item ? [item] : [] + }) + const lineEntries = incident.filter((item) => item.mark === 'line') + const unknownEntries = incident.filter((item) => item.mark === 'unknown') + const availableEntries = incident.filter((item) => item.mark !== 'blank') + const reason = + color === 'white' + ? 'must have two straight-through exits' + : 'must turn and extend from each exit' + + if (lineEntries.length === 2) { + if (!isLegalPearlPair(color, lineEntries[0].direction, lineEntries[1].direction)) { + continue + } + for (const item of unknownEntries) { + remember(pearlKey, item.lineKey, 'blank', reason) + } + if (color === 'black') { + rememberBlackExitExtensions( + pearlKey, + lineEntries.map((item) => item.direction), + reason, + ) + } + continue + } + + if (lineEntries.length === 1) { + const legalCandidates = unknownEntries.filter((item) => + isLegalPearlPair(color, lineEntries[0].direction, item.direction), + ) + if (legalCandidates.length !== 1) { + continue + } + remember(pearlKey, legalCandidates[0].lineKey, 'line', reason) + if (color === 'black') { + rememberBlackExitExtensions(pearlKey, [lineEntries[0].direction, legalCandidates[0].direction], reason) + } + continue + } + + if (lineEntries.length !== 0) { + continue + } + + const legalPairs = getLegalPearlPairs(color, availableEntries) + if (legalPairs.length !== 1) { + continue + } + + const [left, right] = legalPairs[0] + remember(pearlKey, left.lineKey, 'line', reason) + remember(pearlKey, right.lineKey, 'line', reason) + if (color === 'black') { + rememberBlackExitExtensions(pearlKey, [left.direction, right.direction], reason) + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine && firstReason + ? `${getPearlColor(puzzle, firstPearl) === 'white' ? 'White' : 'Black'} pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Pearl completion applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + +export const createCellCompletionRule = (): Rule => ({ + id: 'cell-completion', + name: 'Cell Completion', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstCell: string | null = null + let firstReason: string | null = null + + const remember = (key: string, lineKey: string, to: LineMark, reason: string): void => { + if (!collectMasyuLineDecision(decisions, puzzle, lineKey, to)) { + return + } + affectedCells.add(key) + if (firstCell === null) { + firstCell = key + firstReason = reason + } + } + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + const clue = puzzle.cells[key]?.clue + if (clue?.kind === 'pearl') { + continue + } + const directional = getMasyuIncidentDirectionalLines(puzzle, key) + const incident = MASYU_DIRECTIONS.flatMap((direction) => { + const item = directional[direction] + return item ? [item] : [] + }) + const lineEntries = incident.filter((item) => item.mark === 'line') + const unknownEntries = incident.filter((item) => item.mark === 'unknown') + if (unknownEntries.length === 0) { + continue + } + + if (lineEntries.length === 2) { + for (const item of unknownEntries) { + remember(key, item.lineKey, 'blank', 'it already has degree 2, so every other exit is blank') + } + continue + } + + if (lineEntries.length !== 1) { + if (lineEntries.length === 0 && unknownEntries.length === 1) { + remember( + key, + unknownEntries[0].lineKey, + 'blank', + 'using the only remaining candidate would create a dead end', + ) + } + continue + } + + if (unknownEntries.length === 1) { + remember( + key, + unknownEntries[0].lineKey, + 'line', + 'one line must continue through the only remaining candidate', + ) + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstCell && firstReason + ? `Cell ${formatMasyuCellKeyLabel(firstCell)}: ${firstReason}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Cell completion applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) diff --git a/src/domain/rules/masyu/rules/connectivity.ts b/src/domain/rules/masyu/rules/connectivity.ts new file mode 100644 index 0000000..e028712 --- /dev/null +++ b/src/domain/rules/masyu/rules/connectivity.ts @@ -0,0 +1,381 @@ +import { lineKey, parseTileKey, tileKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + formatMasyuTileKeyLabel, + isMasyuTileColor, + oppositeMasyuTileColor, + type MasyuTileColor, +} from './color' + +const OUTSIDE_COMPONENT = '__outside__' + +type TileAdjacency = { + left: string + right: string + separatorLine: string | null +} + +type ConnectivityCutPassOptions = { + target: MasyuTileColor + includeOutsideSource: boolean + getEffectiveTileColor: (key: string) => MasyuTileColor | null +} + +type ConnectivityColorReason = 'cut' | 'unreachable' + +type ConnectivityTileColorUpdate = { + tileKey: string + toFill: MasyuTileColor + reason: ConnectivityColorReason +} + +const isBoundaryTileKey = (puzzle: PuzzleIR, key: string): boolean => { + const [row, col] = parseTileKey(key) + return row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols +} + +const getTileAdjacencies = (puzzle: PuzzleIR): TileAdjacency[] => { + const adjacencies: TileAdjacency[] = [] + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const separatorLine = + row > 0 && row < puzzle.rows ? lineKey([row - 1, col], [row, col]) : null + adjacencies.push({ + left: tileKey(row, col), + right: tileKey(row, col + 1), + separatorLine, + }) + } + } + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + const separatorLine = + col > 0 && col < puzzle.cols ? lineKey([row, col - 1], [row, col]) : null + adjacencies.push({ + left: tileKey(row, col), + right: tileKey(row + 1, col), + separatorLine, + }) + } + } + + return adjacencies +} + +const getSeparatorMark = (puzzle: PuzzleIR, adjacency: TileAdjacency): LineMark | 'permanent-blank' => { + if (adjacency.separatorLine === null) { + return 'permanent-blank' + } + return puzzle.lines[adjacency.separatorLine]?.mark ?? 'unknown' +} + +const findConnectivityTileColorUpdates = ( + puzzle: PuzzleIR, + { target, includeOutsideSource, getEffectiveTileColor }: ConnectivityCutPassOptions, +): ConnectivityTileColorUpdate[] => { + const blocked = oppositeMasyuTileColor(target) + const parent = new Map() + const rank = new Map() + + const tileKeys: string[] = [] + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + tileKeys.push(tileKey(row, col)) + } + } + + const isCandidateTile = (key: string): boolean => getEffectiveTileColor(key) !== blocked + + const ensureNode = (key: string): void => { + if (parent.has(key)) { + return + } + parent.set(key, key) + rank.set(key, 0) + } + + const find = (key: string): string => { + ensureNode(key) + const currentParent = parent.get(key) + if (currentParent === undefined || currentParent === key) { + return key + } + const root = find(currentParent) + parent.set(key, root) + return root + } + + const union = (left: string, right: string): void => { + const rootA = find(left) + const rootB = find(right) + if (rootA === rootB) { + return + } + const rankA = rank.get(rootA) ?? 0 + const rankB = rank.get(rootB) ?? 0 + if (rankA < rankB) { + parent.set(rootA, rootB) + return + } + parent.set(rootB, rootA) + if (rankA === rankB) { + rank.set(rootA, rankA + 1) + } + } + + for (const key of tileKeys) { + if (isCandidateTile(key)) { + ensureNode(key) + } + } + if (includeOutsideSource) { + ensureNode(OUTSIDE_COMPONENT) + } + + const adjacencies = getTileAdjacencies(puzzle) + for (const adjacency of adjacencies) { + const mark = getSeparatorMark(puzzle, adjacency) + if (mark !== 'blank' && mark !== 'permanent-blank') { + continue + } + if (!isCandidateTile(adjacency.left) || !isCandidateTile(adjacency.right)) { + continue + } + union(adjacency.left, adjacency.right) + } + + const componentTiles = new Map() + const sourceComponents = new Set() + for (const key of tileKeys) { + if (!isCandidateTile(key)) { + continue + } + const root = find(key) + const tiles = componentTiles.get(root) ?? [] + tiles.push(key) + componentTiles.set(root, tiles) + if (getEffectiveTileColor(key) === target) { + sourceComponents.add(root) + } + } + if (includeOutsideSource) { + sourceComponents.add(find(OUTSIDE_COMPONENT)) + } + if (sourceComponents.size === 0) { + return [] + } + + const graph = new Map>() + const addGraphNode = (node: string): void => { + if (!graph.has(node)) { + graph.set(node, new Set()) + } + } + const addGraphEdge = (left: string, right: string): void => { + if (left === right) { + addGraphNode(left) + return + } + addGraphNode(left) + addGraphNode(right) + graph.get(left)?.add(right) + graph.get(right)?.add(left) + } + + for (const root of componentTiles.keys()) { + addGraphNode(root) + } + if (includeOutsideSource) { + addGraphNode(find(OUTSIDE_COMPONENT)) + } + + for (const adjacency of adjacencies) { + const mark = getSeparatorMark(puzzle, adjacency) + if (mark === 'line') { + continue + } + if (!isCandidateTile(adjacency.left) || !isCandidateTile(adjacency.right)) { + continue + } + addGraphEdge(find(adjacency.left), find(adjacency.right)) + } + + if (includeOutsideSource) { + for (const key of tileKeys) { + if (isBoundaryTileKey(puzzle, key) && isCandidateTile(key)) { + addGraphEdge(find(OUTSIDE_COMPONENT), find(key)) + } + } + } + + const discovery = new Map() + const low = new Map() + const subtreeSources = new Map() + const treeChildren = new Map() + const cutComponents = new Set() + const reachableComponents = new Set() + let timestamp = 0 + + const dfs = (node: string, parentNode: string | null, connectedNodes: string[]): void => { + discovery.set(node, timestamp) + low.set(node, timestamp) + timestamp += 1 + subtreeSources.set(node, sourceComponents.has(node) ? 1 : 0) + connectedNodes.push(node) + reachableComponents.add(node) + + for (const neighbor of graph.get(node) ?? []) { + if (neighbor === parentNode) { + continue + } + if (!discovery.has(neighbor)) { + const children = treeChildren.get(node) ?? [] + children.push(neighbor) + treeChildren.set(node, children) + dfs(neighbor, node, connectedNodes) + low.set(node, Math.min(low.get(node) ?? 0, low.get(neighbor) ?? 0)) + subtreeSources.set(node, (subtreeSources.get(node) ?? 0) + (subtreeSources.get(neighbor) ?? 0)) + continue + } + low.set(node, Math.min(low.get(node) ?? 0, discovery.get(neighbor) ?? 0)) + } + } + + const evaluateCuts = (node: string, totalSources: number): void => { + for (const neighbor of treeChildren.get(node) ?? []) { + if ((low.get(neighbor) ?? 0) >= (discovery.get(node) ?? 0)) { + const childSources = subtreeSources.get(neighbor) ?? 0 + if (childSources > 0 && totalSources - childSources > 0) { + cutComponents.add(node) + } + } + evaluateCuts(neighbor, totalSources) + } + } + + for (const node of sourceComponents) { + if (discovery.has(node)) { + continue + } + const connectedNodes: string[] = [] + dfs(node, null, connectedNodes) + const totalSources = connectedNodes.filter((component) => sourceComponents.has(component)).length + if (totalSources < 2) { + continue + } + evaluateCuts(node, totalSources) + } + + const updates = new Map() + for (const component of cutComponents) { + for (const key of componentTiles.get(component) ?? []) { + if (getEffectiveTileColor(key) === null) { + updates.set(key, { tileKey: key, toFill: target, reason: 'cut' }) + } + } + } + + const unreachableFill = oppositeMasyuTileColor(target) + for (const [component, tiles] of componentTiles) { + if (reachableComponents.has(component)) { + continue + } + for (const key of tiles) { + if (getEffectiveTileColor(key) === null) { + updates.set(key, { tileKey: key, toFill: unreachableFill, reason: 'unreachable' }) + } + } + } + + return tileKeys.flatMap((key) => updates.get(key) ?? []) +} + +export const createMasyuTileConnectivityCutColoringRule = (): Rule => ({ + id: 'masyu-tile-connectivity-cut-coloring', + name: 'Masyu Tile Connectivity Cut Coloring', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decidedTileFills = new Map() + const affectedTiles = new Set() + const stats = { + greenCuts: 0, + yellowCuts: 0, + greenUnreachable: 0, + yellowUnreachable: 0, + } + + const getEffectiveTileColor = (key: string): MasyuTileColor | null => { + const decided = decidedTileFills.get(key) + if (decided) { + return decided + } + const current = puzzle.tiles[key]?.fill + return isMasyuTileColor(current) ? current : null + } + + const rememberTileFill = (update: ConnectivityTileColorUpdate): void => { + const { tileKey: key, toFill, reason } = update + if (getEffectiveTileColor(key) !== null) { + return + } + decidedTileFills.set(key, toFill) + affectedTiles.add(key) + if (reason === 'cut' && toFill === 'green') { + stats.greenCuts += 1 + } else if (reason === 'cut' && toFill === 'yellow') { + stats.yellowCuts += 1 + } else if (reason === 'unreachable' && toFill === 'yellow') { + stats.greenUnreachable += 1 + } else if (reason === 'unreachable' && toFill === 'green') { + stats.yellowUnreachable += 1 + } + } + + for (const update of findConnectivityTileColorUpdates(puzzle, { + target: 'green', + includeOutsideSource: false, + getEffectiveTileColor, + })) { + rememberTileFill(update) + } + + for (const update of findConnectivityTileColorUpdates(puzzle, { + target: 'yellow', + includeOutsideSource: true, + getEffectiveTileColor, + })) { + rememberTileFill(update) + } + + if (decidedTileFills.size === 0) { + return null + } + + const diffs: RuleApplication['diffs'] = [] + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + const key = tileKey(row, col) + const toFill = decidedTileFills.get(key) + if (!toFill) { + continue + } + diffs.push({ + kind: 'tile' as const, + tileKey: key, + fromFill: (puzzle.tiles[key]?.fill ?? null) as string | null, + toFill, + }) + } + } + + const firstTile = diffs.find((diff) => diff.kind === 'tile')?.tileKey + return { + message: `Tile connectivity forces color updates near ${firstTile ? formatMasyuTileKeyLabel(firstTile) : 'the Masyu region graph'}: inside cuts ${stats.greenCuts}, outside cuts ${stats.yellowCuts}, unreachable-from-inside ${stats.greenUnreachable}, unreachable-from-outside ${stats.yellowUnreachable}.`, + diffs, + affectedCells: [], + affectedTiles: [...affectedTiles], + } + }, +}) diff --git a/src/domain/rules/masyu/rules/lookahead.ts b/src/domain/rules/masyu/rules/lookahead.ts new file mode 100644 index 0000000..a17aba7 --- /dev/null +++ b/src/domain/rules/masyu/rules/lookahead.ts @@ -0,0 +1,346 @@ +import type { LineMark, PuzzleIR } from '../../../ir/types' +import { + areMasyuDirectionsOpposite, + areMasyuDirectionsTurn, + MASYU_DIRECTIONS, + type MasyuDirection, + type MasyuDirectionalLine, +} from './shared' +import type { MasyuLineOverlay } from './loop' +import { createMasyuLookaheadGeometry } from './lookaheadGeometry' + +export type BlackPearlCandidate = { + exits: [MasyuDirection, MasyuDirection] + lines: Set + exitLines: Set + extensionLines: Set + blanks: Set +} + +export type MasyuLookaheadContext = { + getBlackPearlKeys: () => string[] + getIncidentEntries: (overlay: MasyuLineOverlay, key: string) => MasyuDirectionalLine[] + getFeasibleBlackPearlCandidates: (pearlKey: string) => BlackPearlCandidate[] +} + +const BLACK_CANDIDATE_EXIT_PAIRS: [MasyuDirection, MasyuDirection][] = [ + ['N', 'E'], + ['N', 'W'], + ['S', 'E'], + ['S', 'W'], +] + +const WHITE_CANDIDATE_AXES: [MasyuDirection, MasyuDirection][] = [ + ['N', 'S'], + ['E', 'W'], +] + +const addOverlayDecision = (overlay: Map, lineKey: string, mark: LineMark): boolean => { + const existing = overlay.get(lineKey) + if (existing !== undefined) { + return existing === mark + } + overlay.set(lineKey, mark) + return true +} + +const candidateToOverlay = (candidate: BlackPearlCandidate): Map | null => { + const overlay = new Map() + for (const lineKeyValue of candidate.lines) { + if (!addOverlayDecision(overlay, lineKeyValue, 'line')) { + return null + } + } + for (const lineKeyValue of candidate.blanks) { + if (!addOverlayDecision(overlay, lineKeyValue, 'blank')) { + return null + } + } + return overlay +} + +const mergeOverlay = (base: MasyuLineOverlay, next: MasyuLineOverlay): Map | null => { + const merged = new Map(base) + for (const [lineKeyValue, mark] of next.entries()) { + if (!addOverlayDecision(merged, lineKeyValue, mark)) { + return null + } + } + return merged +} + +export const createMasyuLookaheadContext = (puzzle: PuzzleIR): MasyuLookaheadContext => { + const geometry = createMasyuLookaheadGeometry(puzzle) + + const isOverlayConsistentWithPuzzle = (overlay: MasyuLineOverlay): boolean => { + for (const [lineKeyValue, mark] of overlay.entries()) { + const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (current !== 'unknown' && current !== mark) { + return false + } + } + return true + } + + const isCellDegreeValid = (overlay: MasyuLineOverlay, key: string): boolean => + geometry.getIncidentEntries(overlay, key).filter((item) => item.mark === 'line').length <= 2 + + const canApplyLocalDecisions = (overlay: MasyuLineOverlay, decisions: MasyuLineOverlay): boolean => { + const merged = mergeOverlay(overlay, decisions) + if (!merged || !isOverlayConsistentWithPuzzle(merged)) { + return false + } + for (const key of geometry.getTouchedCells(decisions.keys())) { + if ( + !isCellDegreeValid(merged, key) || + !isPearlShapeStillPossible(merged, key, { checkWhiteAdjacentTurn: false }) + ) { + return false + } + } + return true + } + + const canWhiteSideTurn = ( + overlay: MasyuLineOverlay, + pearlKey: string, + direction: MasyuDirection, + ): boolean => { + const { first, second } = geometry.getTwoStep(pearlKey, direction) + if (!first || geometry.getLineMark(overlay, first.lineKey) === 'blank') { + return false + } + if (second && geometry.getLineMark(overlay, second.lineKey) === 'line') { + return false + } + return geometry.getTurnCandidates(first.neighborKey, direction).some((line) => { + if (geometry.getLineMark(overlay, line.lineKey) === 'blank') { + return false + } + const turnOverlay = new Map() + if (!addOverlayDecision(turnOverlay, line.lineKey, 'line')) { + return false + } + if (second && !addOverlayDecision(turnOverlay, second.lineKey, 'blank')) { + return false + } + return canApplyLocalDecisions(overlay, turnOverlay) + }) + } + + const isPearlShapeStillPossible = ( + overlay: MasyuLineOverlay, + key: string, + options: { checkWhiteAdjacentTurn?: boolean } = {}, + ): boolean => { + const color = geometry.pearlColors.get(key) ?? null + if (!color) { + return true + } + const lineEntries = geometry.getIncidentEntries(overlay, key).filter((item) => item.mark === 'line') + if (lineEntries.length > 2) { + return false + } + if (lineEntries.length !== 2) { + return true + } + const [left, right] = lineEntries + if (color === 'black') { + if (!areMasyuDirectionsTurn(left.direction, right.direction)) { + return false + } + return [left.direction, right.direction].every((direction) => { + const extension = geometry.getTwoStep(key, direction).second + return extension !== null && geometry.getLineMark(overlay, extension.lineKey) !== 'blank' + }) + } + if (!areMasyuDirectionsOpposite(left.direction, right.direction)) { + return false + } + if (options.checkWhiteAdjacentTurn === false) { + return true + } + return [left.direction, right.direction].some((direction) => canWhiteSideTurn(overlay, key, direction)) + } + + const buildBlackPearlCandidate = ( + pearlKey: string, + exits: [MasyuDirection, MasyuDirection], + ): BlackPearlCandidate | null => { + const candidate: BlackPearlCandidate = { + exits, + lines: new Set(), + exitLines: new Set(), + extensionLines: new Set(), + blanks: new Set(), + } + + for (const direction of exits) { + const { first, second } = geometry.getTwoStep(pearlKey, direction) + if (!first || !second) { + return null + } + candidate.lines.add(first.lineKey) + candidate.lines.add(second.lineKey) + candidate.exitLines.add(first.lineKey) + candidate.extensionLines.add(second.lineKey) + } + + const selected = new Set(exits) + const incident = geometry.getIncident(pearlKey) + for (const direction of MASYU_DIRECTIONS) { + if (selected.has(direction)) { + continue + } + const line = incident[direction] + if (line) { + candidate.blanks.add(line.lineKey) + } + } + + return candidate + } + + const hasAnyBlackPearlCandidate = (overlay: MasyuLineOverlay, key: string): boolean => + BLACK_CANDIDATE_EXIT_PAIRS.some((exits) => { + const candidate = buildBlackPearlCandidate(key, exits) + const candidateOverlay = candidate ? candidateToOverlay(candidate) : null + return candidateOverlay !== null && canApplyLocalDecisions(overlay, candidateOverlay) + }) + + const buildWhiteAxisOverlay = ( + key: string, + axis: [MasyuDirection, MasyuDirection], + ): Map | null => { + const overlay = new Map() + const selected = new Set(axis) + const incident = geometry.getIncident(key) + for (const direction of axis) { + const line = incident[direction] + if (!line || !addOverlayDecision(overlay, line.lineKey, 'line')) { + return null + } + } + for (const direction of MASYU_DIRECTIONS) { + if (selected.has(direction)) { + continue + } + const line = incident[direction] + if (line && !addOverlayDecision(overlay, line.lineKey, 'blank')) { + return null + } + } + return overlay + } + + const hasAnyWhitePearlCandidate = (overlay: MasyuLineOverlay, key: string): boolean => + WHITE_CANDIDATE_AXES.some((axis) => { + const axisOverlay = buildWhiteAxisOverlay(key, axis) + const merged = axisOverlay ? mergeOverlay(overlay, axisOverlay) : null + return ( + axisOverlay !== null && + merged !== null && + canApplyLocalDecisions(overlay, axisOverlay) && + axis.some((direction) => canWhiteSideTurn(merged, key, direction)) + ) + }) + + const areAffectedPearlsStillPossible = (overlay: MasyuLineOverlay, centerKey: string): boolean => { + const affected = geometry.getAffectedPearls(overlay) + affected.add(centerKey) + for (const key of affected) { + const color = geometry.pearlColors.get(key) + if (!color) { + continue + } + const possible = + color === 'black' ? hasAnyBlackPearlCandidate(overlay, key) : hasAnyWhitePearlCandidate(overlay, key) + if (!possible) { + return false + } + } + return true + } + + const wouldCreatePrematureLoop = (assumedLineEndpoints: Iterable<[left: number, right: number]>): boolean => { + const parent = new Map() + const lineCounts = new Map() + const find = (root: number): number => { + if (!parent.has(root)) { + parent.set(root, root) + lineCounts.set(root, geometry.baseLineCounts.get(root) ?? 0) + return root + } + const nextParent = parent.get(root) ?? root + if (nextParent === root) { + return root + } + const next = find(nextParent) + parent.set(root, next) + return next + } + const union = (rootA: number, rootB: number): void => { + const left = find(rootA) + const right = find(rootB) + if (left === right) { + return + } + parent.set(right, left) + lineCounts.set(left, (lineCounts.get(left) ?? 0) + (lineCounts.get(right) ?? 0)) + } + + for (const [left, right] of assumedLineEndpoints) { + const leftRoot = find(geometry.findBase(left)) + const rightRoot = find(geometry.findBase(right)) + if (leftRoot === rightRoot) { + return geometry.totalBaseLineCount > (lineCounts.get(leftRoot) ?? 0) + } + union(leftRoot, rightRoot) + } + return false + } + + const hasPrematureLoopFromCandidate = (candidate: BlackPearlCandidate): boolean => { + const assumedLineEndpoints: Array<[left: number, right: number]> = [] + for (const lineKeyValue of candidate.lines) { + if ((puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line') { + continue + } + assumedLineEndpoints.push(geometry.lineEndpoints(lineKeyValue)) + } + return assumedLineEndpoints.length > 0 && wouldCreatePrematureLoop(assumedLineEndpoints) + } + + const isBlackPearlCandidateFeasible = (pearlKey: string, candidate: BlackPearlCandidate): boolean => { + const overlay = candidateToOverlay(candidate) + if (!overlay || !isOverlayConsistentWithPuzzle(overlay)) { + return false + } + for (const key of geometry.getTouchedCells([...candidate.lines, ...candidate.blanks])) { + if (!isCellDegreeValid(overlay, key) || !isPearlShapeStillPossible(overlay, key)) { + return false + } + } + if (hasPrematureLoopFromCandidate(candidate)) { + return false + } + return areAffectedPearlsStillPossible(overlay, pearlKey) + } + + const getFeasibleBlackPearlCandidates = (pearlKey: string): BlackPearlCandidate[] => { + const incidentLines = geometry.getIncidentEntries(new Map(), pearlKey).filter((item) => item.mark === 'line') + if (incidentLines.length >= 2) { + return [] + } + return BLACK_CANDIDATE_EXIT_PAIRS.flatMap((exits) => { + const candidate = buildBlackPearlCandidate(pearlKey, exits) + return candidate && isBlackPearlCandidateFeasible(pearlKey, candidate) ? [candidate] : [] + }) + } + + return { + getBlackPearlKeys: () => geometry.blackPearlKeys, + getIncidentEntries: geometry.getIncidentEntries, + getFeasibleBlackPearlCandidates, + } +} diff --git a/src/domain/rules/masyu/rules/lookaheadGeometry.ts b/src/domain/rules/masyu/rules/lookaheadGeometry.ts new file mode 100644 index 0000000..718f7e9 --- /dev/null +++ b/src/domain/rules/masyu/rules/lookaheadGeometry.ts @@ -0,0 +1,279 @@ +import { cellKey, parseCellKey, parseLineKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import { + getMasyuIncidentDirectionalLines, + getMasyuTurnCandidateLines, + getMasyuTwoStepLine, + MASYU_DIRECTIONS, + type MasyuDirection, + type MasyuDirectionalLine, + type MasyuTwoStepLine, +} from './shared' +import type { MasyuLineOverlay } from './loop' + +type PearlColor = 'white' | 'black' + +export type MasyuLookaheadGeometry = { + blackPearlKeys: string[] + pearlColors: Map + baseLineCounts: Map + totalBaseLineCount: number + findBase: (idx: number) => number + lineEndpoints: (lineKeyValue: string) => [left: number, right: number] + getIncident: (key: string) => Record + getTwoStep: (key: string, direction: MasyuDirection) => MasyuTwoStepLine + getTurnCandidates: (key: string, throughDirection: MasyuDirection) => MasyuDirectionalLine[] + getLineMark: (overlay: MasyuLineOverlay, key: string) => LineMark + getIncidentEntries: (overlay: MasyuLineOverlay, key: string) => MasyuDirectionalLine[] + getTouchedCells: (lineKeys: Iterable) => Set + getAffectedPearls: (overlay: MasyuLineOverlay) => Set +} + +export const createMasyuLookaheadGeometry = (puzzle: PuzzleIR): MasyuLookaheadGeometry => { + const blackPearlKeys: string[] = [] + const pearlColors = new Map() + const incidentCache = new Map>() + const twoStepCache = new Map>() + const turnCandidateCache = new Map>() + const lineEndpointCache = new Map() + const lineCellCache = new Map() + const dependencyCellPearls = new Map>() + const dependencyLinePearls = new Map>() + const cellCount = puzzle.rows * puzzle.cols + const baseParent = Array.from({ length: cellCount }, (_, idx) => idx) + const baseRank = new Array(cellCount).fill(0) + const baseLineCounts = new Map() + let totalBaseLineCount = 0 + + const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col + + const findBase = (idx: number): number => { + if (baseParent[idx] !== idx) { + baseParent[idx] = findBase(baseParent[idx]) + } + return baseParent[idx] + } + + const unionBase = (a: number, b: number): void => { + const rootA = findBase(a) + const rootB = findBase(b) + if (rootA === rootB) { + return + } + if (baseRank[rootA] < baseRank[rootB]) { + baseParent[rootA] = rootB + } else if (baseRank[rootA] > baseRank[rootB]) { + baseParent[rootB] = rootA + } else { + baseParent[rootB] = rootA + baseRank[rootA] += 1 + } + } + + const lineEndpoints = (lineKeyValue: string): [left: number, right: number] => { + const cached = lineEndpointCache.get(lineKeyValue) + if (cached) { + return cached + } + const [left, right] = parseLineKey(lineKeyValue) + const endpoints: [number, number] = [toCellIndex(left[0], left[1]), toCellIndex(right[0], right[1])] + lineEndpointCache.set(lineKeyValue, endpoints) + lineCellCache.set(lineKeyValue, [cellKey(left[0], left[1]), cellKey(right[0], right[1])]) + return endpoints + } + + const lineCells = (lineKeyValue: string): [left: string, right: string] => { + const cached = lineCellCache.get(lineKeyValue) + if (cached) { + return cached + } + lineEndpoints(lineKeyValue) + const cells = lineCellCache.get(lineKeyValue) + if (!cells) { + throw new Error(`Missing Masyu line geometry for ${lineKeyValue}`) + } + return cells + } + + const addDependency = (index: Map>, key: string, pearlKey: string): void => { + const set = index.get(key) ?? new Set() + set.add(pearlKey) + index.set(key, set) + } + + const getIncident = (key: string): Record => { + const cached = incidentCache.get(key) + if (cached) { + return cached + } + const incident = getMasyuIncidentDirectionalLines(puzzle, key) + incidentCache.set(key, incident) + return incident + } + + const getTwoStep = (key: string, direction: MasyuDirection): MasyuTwoStepLine => { + const cached = twoStepCache.get(key) + if (cached) { + return cached[direction] + } + const twoStep = { + N: getMasyuTwoStepLine(puzzle, key, 'N'), + E: getMasyuTwoStepLine(puzzle, key, 'E'), + S: getMasyuTwoStepLine(puzzle, key, 'S'), + W: getMasyuTwoStepLine(puzzle, key, 'W'), + } + twoStepCache.set(key, twoStep) + return twoStep[direction] + } + + const getTurnCandidates = (key: string, throughDirection: MasyuDirection): MasyuDirectionalLine[] => { + const cached = turnCandidateCache.get(key) + if (cached) { + return cached[throughDirection] + } + const turnCandidates = { + N: getMasyuTurnCandidateLines(puzzle, key, 'N'), + E: getMasyuTurnCandidateLines(puzzle, key, 'E'), + S: getMasyuTurnCandidateLines(puzzle, key, 'S'), + W: getMasyuTurnCandidateLines(puzzle, key, 'W'), + } + turnCandidateCache.set(key, turnCandidates) + return turnCandidates[throughDirection] + } + + const registerPearlDependencies = (pearlKey: string): void => { + const addCell = (key: string): void => addDependency(dependencyCellPearls, key, pearlKey) + const addLine = (key: string): void => addDependency(dependencyLinePearls, key, pearlKey) + addCell(pearlKey) + for (const direction of MASYU_DIRECTIONS) { + const { first, second } = getTwoStep(pearlKey, direction) + for (const line of [first, second]) { + if (!line) { + continue + } + addLine(line.lineKey) + const [left, right] = lineCells(line.lineKey) + addCell(left) + addCell(right) + } + if (!first) { + continue + } + for (const line of getTurnCandidates(first.neighborKey, direction)) { + addLine(line.lineKey) + const [left, right] = lineCells(line.lineKey) + addCell(left) + addCell(right) + } + } + } + + for (const lineKeyValue of Object.keys(puzzle.lines)) { + lineEndpoints(lineKeyValue) + if ((puzzle.lines[lineKeyValue]?.mark ?? 'unknown') !== 'line') { + continue + } + const [left, right] = lineEndpoints(lineKeyValue) + unionBase(left, right) + totalBaseLineCount += 1 + } + + for (const lineKeyValue of Object.keys(puzzle.lines)) { + if ((puzzle.lines[lineKeyValue]?.mark ?? 'unknown') !== 'line') { + continue + } + const [left] = lineEndpoints(lineKeyValue) + const root = findBase(left) + baseLineCounts.set(root, (baseLineCounts.get(root) ?? 0) + 1) + } + + for (const [key, cell] of Object.entries(puzzle.cells)) { + const clue = cell.clue + if (clue?.kind !== 'pearl') { + continue + } + pearlColors.set(key, clue.color) + if (clue.color === 'black') { + blackPearlKeys.push(key) + } + registerPearlDependencies(key) + } + + const getLineMark = (overlay: MasyuLineOverlay, key: string): LineMark => + overlay.get(key) ?? puzzle.lines[key]?.mark ?? 'unknown' + + const getIncidentEntries = (overlay: MasyuLineOverlay, key: string): MasyuDirectionalLine[] => { + const incident = getIncident(key) + return MASYU_DIRECTIONS.flatMap((direction) => { + const item = incident[direction] + return item ? [{ ...item, mark: getLineMark(overlay, item.lineKey) }] : [] + }) + } + + const getTouchedCells = (lineKeys: Iterable): Set => { + const cells = new Set() + for (const lineKeyValue of lineKeys) { + const [left, right] = lineCells(lineKeyValue) + cells.add(left) + cells.add(right) + } + return cells + } + + const getPearlsAtOrAdjacentToCell = (key: string): Set => { + const pearls = new Set() + const [row, col] = parseCellKey(key) + const cells = [ + [row, col], + [row - 1, col], + [row + 1, col], + [row, col - 1], + [row, col + 1], + ] + for (const [cellRow, cellCol] of cells) { + if (cellRow < 0 || cellRow >= puzzle.rows || cellCol < 0 || cellCol >= puzzle.cols) { + continue + } + const pearlKey = cellKey(cellRow, cellCol) + if (pearlColors.has(pearlKey)) { + pearls.add(pearlKey) + } + } + return pearls + } + + const getAffectedPearls = (overlay: MasyuLineOverlay): Set => { + const affected = new Set() + const touchedCells = getTouchedCells(overlay.keys()) + for (const key of touchedCells) { + for (const pearlKey of getPearlsAtOrAdjacentToCell(key)) { + affected.add(pearlKey) + } + for (const pearlKey of dependencyCellPearls.get(key) ?? []) { + affected.add(pearlKey) + } + } + for (const lineKeyValue of overlay.keys()) { + for (const pearlKey of dependencyLinePearls.get(lineKeyValue) ?? []) { + affected.add(pearlKey) + } + } + return affected + } + + return { + blackPearlKeys, + pearlColors, + baseLineCounts, + totalBaseLineCount, + findBase, + lineEndpoints, + getIncident, + getTwoStep, + getTurnCandidates, + getLineMark, + getIncidentEntries, + getTouchedCells, + getAffectedPearls, + } +} diff --git a/src/domain/rules/masyu/rules/loop.ts b/src/domain/rules/masyu/rules/loop.ts new file mode 100644 index 0000000..94cf64a --- /dev/null +++ b/src/domain/rules/masyu/rules/loop.ts @@ -0,0 +1,136 @@ +import { parseLineKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { buildMasyuLineDiffs, formatMasyuLineLabel } from './shared' + +export type MasyuLineOverlay = ReadonlyMap + +const getOverlayLineMark = (puzzle: PuzzleIR, overlay: MasyuLineOverlay, lineKey: string): LineMark => + overlay.get(lineKey) ?? puzzle.lines[lineKey]?.mark ?? 'unknown' + +const buildMasyuLineUnion = (puzzle: PuzzleIR, overlay: MasyuLineOverlay = new Map()) => { + const cellCount = puzzle.rows * puzzle.cols + const parent = Array.from({ length: cellCount }, (_, idx) => idx) + const rank = new Array(cellCount).fill(0) + const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col + const find = (idx: number): number => { + if (parent[idx] !== idx) { + parent[idx] = find(parent[idx]) + } + return parent[idx] + } + const union = (a: number, b: number): void => { + const rootA = find(a) + const rootB = find(b) + if (rootA === rootB) { + return + } + if (rank[rootA] < rank[rootB]) { + parent[rootA] = rootB + } else if (rank[rootA] > rank[rootB]) { + parent[rootB] = rootA + } else { + parent[rootB] = rootA + rank[rootA] += 1 + } + } + + const lineKeys = Object.keys(puzzle.lines).filter((lineKeyValue) => getOverlayLineMark(puzzle, overlay, lineKeyValue) === 'line') + for (const lineKeyValue of lineKeys) { + const [left, right] = parseLineKey(lineKeyValue) + union(toCellIndex(left[0], left[1]), toCellIndex(right[0], right[1])) + } + + return { find, lineKeys, toCellIndex } +} + +export const findMasyuPrematureLoopClosingLines = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): string[] => { + const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) + const lineComponentRoots = new Set( + lineKeys.map((lineKeyValue) => { + const [left] = parseLineKey(lineKeyValue) + return find(toCellIndex(left[0], left[1])) + }), + ) + const closingLines: string[] = [] + + for (const lineKeyValue of Object.keys(puzzle.lines)) { + if (getOverlayLineMark(puzzle, overlay, lineKeyValue) !== 'unknown') { + continue + } + const [left, right] = parseLineKey(lineKeyValue) + const leftRoot = find(toCellIndex(left[0], left[1])) + const rightRoot = find(toCellIndex(right[0], right[1])) + if (leftRoot !== rightRoot) { + continue + } + if (![...lineComponentRoots].some((root) => root !== leftRoot)) { + continue + } + closingLines.push(lineKeyValue) + } + + return closingLines +} + +export const hasMasyuPrematureLoop = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): boolean => { + const { find, lineKeys, toCellIndex } = buildMasyuLineUnion(puzzle, overlay) + const components = new Map }>() + + for (const lineKeyValue of lineKeys) { + const [left, right] = parseLineKey(lineKeyValue) + const leftIndex = toCellIndex(left[0], left[1]) + const rightIndex = toCellIndex(right[0], right[1]) + const root = find(leftIndex) + const component = components.get(root) ?? { edgeCount: 0, vertices: new Set() } + component.edgeCount += 1 + component.vertices.add(leftIndex) + component.vertices.add(rightIndex) + components.set(root, component) + } + + for (const component of components.values()) { + if (component.edgeCount >= component.vertices.size && lineKeys.length > component.edgeCount) { + return true + } + } + + return false +} + +export const createPreventPrematureLoopRule = (): Rule => ({ + id: 'masyu-prevent-premature-loop', + name: 'Prevent Premature Loop', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + let firstExample: string | null = null + + for (const lineKeyValue of findMasyuPrematureLoopClosingLines(puzzle)) { + decisions.set(lineKeyValue, 'blank') + if (firstExample === null) { + firstExample = formatMasyuLineLabel(lineKeyValue) + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstExample !== null + ? `${firstExample} would close a smaller loop while other lines remain outside it, so it must be blank.` + : 'Lines that would close a smaller loop while other lines remain outside it are blank.', + diffs, + affectedCells: [], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) diff --git a/src/domain/rules/masyu/rules/patterns.ts b/src/domain/rules/masyu/rules/patterns.ts new file mode 100644 index 0000000..204f0ac --- /dev/null +++ b/src/domain/rules/masyu/rules/patterns.ts @@ -0,0 +1,351 @@ +import { cellKey, parseCellKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + MASYU_DIRECTIONS, + buildMasyuLineDiffs, + collectMasyuLineDecision, + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionOffset, + getMasyuDirectionalLine, + oppositeMasyuDirection, + type MasyuDirection, +} from './shared' + +const isInBounds = (puzzle: PuzzleIR, row: number, col: number): boolean => + row >= 0 && row < puzzle.rows && col >= 0 && col < puzzle.cols + +const offsetCellKey = ( + puzzle: PuzzleIR, + originKey: string, + rowDelta: number, + colDelta: number, + distance = 1, +): string | null => { + const [row, col] = parseCellKey(originKey) + const targetRow = row + rowDelta * distance + const targetCol = col + colDelta * distance + return isInBounds(puzzle, targetRow, targetCol) ? cellKey(targetRow, targetCol) : null +} + +const getPearlCellKeys = (puzzle: PuzzleIR, color: 'white' | 'black'): string[] => + Object.entries(puzzle.cells).flatMap(([key, cell]) => + cell.clue?.kind === 'pearl' && cell.clue.color === color ? [key] : [], + ) + +const isPearl = (puzzle: PuzzleIR, key: string | null, color: 'white' | 'black'): boolean => + key !== null && puzzle.cells[key]?.clue?.kind === 'pearl' && puzzle.cells[key]?.clue?.color === color + +const rememberLine = ( + decisions: Map, + puzzle: PuzzleIR, + lineKey: string, + mark: LineMark, +): boolean => { + const beforeSize = decisions.size + return collectMasyuLineDecision(decisions, puzzle, lineKey, mark) && decisions.size > beforeSize +} + +export const createBlackFacingConsecutiveWhitesRule = (): Rule => ({ + id: 'masyu-black-facing-consecutive-whites', + name: 'Black Facing Consecutive Whites', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + + for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + for (const direction of MASYU_DIRECTIONS) { + const [rowDelta, colDelta] = getMasyuDirectionOffset(direction) + const gap = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta) + const firstWhite = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta, 2) + const secondWhite = offsetCellKey(puzzle, pearlKey, rowDelta, colDelta, 3) + if (!gap || !isPearl(puzzle, firstWhite, 'white') || !isPearl(puzzle, secondWhite, 'white')) { + continue + } + + const forced = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(direction)) + if (!forced || !rememberLine(decisions, puzzle, forced.lineKey, 'line')) { + continue + } + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstLine = forced.lineKey + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine + ? `Black pearl ${formatMasyuCellKeyLabel(firstPearl)} faces two consecutive white pearls, so ${formatMasyuLineLabel(firstLine)} is forced${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Black facing consecutive whites pattern applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + +const getSideDiagonalKeys = ( + puzzle: PuzzleIR, + originKey: string, + side: MasyuDirection, +): [string | null, string | null] => { + const [sideRowDelta, sideColDelta] = getMasyuDirectionOffset(side) + const perpendicularOffsets: [number, number][] = + side === 'N' || side === 'S' + ? [ + [0, -1], + [0, 1], + ] + : [ + [-1, 0], + [1, 0], + ] + return perpendicularOffsets.map(([rowDelta, colDelta]) => + offsetCellKey(puzzle, originKey, sideRowDelta + rowDelta, sideColDelta + colDelta), + ) as [string | null, string | null] +} + +export const createBlackDiagonalWhitePinchRule = (): Rule => ({ + id: 'masyu-black-diagonal-white-pinch', + name: 'Black Diagonal White Pinch', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + + for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + for (const side of MASYU_DIRECTIONS) { + const [leftDiagonal, rightDiagonal] = getSideDiagonalKeys(puzzle, pearlKey, side) + if (!isPearl(puzzle, leftDiagonal, 'white') || !isPearl(puzzle, rightDiagonal, 'white')) { + continue + } + + const forced = getMasyuDirectionalLine(puzzle, pearlKey, oppositeMasyuDirection(side)) + if (!forced || !rememberLine(decisions, puzzle, forced.lineKey, 'line')) { + continue + } + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstLine = forced.lineKey + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine + ? `Two diagonal white pearls pinch black pearl ${formatMasyuCellKeyLabel(firstPearl)}, so ${formatMasyuLineLabel(firstLine)} is forced${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Black diagonal white pinch pattern applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + +const collectWhitePearlRun = ( + puzzle: PuzzleIR, + startRow: number, + startCol: number, + rowDelta: number, + colDelta: number, +): string[] => { + const run: string[] = [] + let row = startRow + let col = startCol + while (isInBounds(puzzle, row, col)) { + const key = cellKey(row, col) + if (!isPearl(puzzle, key, 'white')) { + break + } + run.push(key) + row += rowDelta + col += colDelta + } + return run +} + +const forceWhitePearlRunLines = ( + puzzle: PuzzleIR, + decisions: Map, + affectedCells: Set, + run: string[], + forcedDirections: [MasyuDirection, MasyuDirection], +): string | null => { + let firstLine: string | null = null + for (const pearlKey of run) { + let addedForCell = false + for (const direction of forcedDirections) { + const line = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (!line || !rememberLine(decisions, puzzle, line.lineKey, 'line')) { + continue + } + addedForCell = true + if (firstLine === null) { + firstLine = line.lineKey + } + } + if (addedForCell) { + affectedCells.add(pearlKey) + } + } + return firstLine +} + +export const createConsecutiveWhitePearlsStraightRule = (): Rule => ({ + id: 'masyu-consecutive-white-pearls-straight', + name: 'Consecutive White Pearls Straight', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + + for (let row = 0; row < puzzle.rows; row += 1) { + let col = 0 + while (col < puzzle.cols) { + const run = collectWhitePearlRun(puzzle, row, col, 0, 1) + if (run.length >= 3) { + const line = forceWhitePearlRunLines(puzzle, decisions, affectedCells, run, ['N', 'S']) + if (firstPearl === null && line) { + firstPearl = run[0] + firstLine = line + } + } + col += Math.max(run.length, 1) + } + } + + for (let col = 0; col < puzzle.cols; col += 1) { + let row = 0 + while (row < puzzle.rows) { + const run = collectWhitePearlRun(puzzle, row, col, 1, 0) + if (run.length >= 3) { + const line = forceWhitePearlRunLines(puzzle, decisions, affectedCells, run, ['E', 'W']) + if (firstPearl === null && line) { + firstPearl = run[0] + firstLine = line + } + } + row += Math.max(run.length, 1) + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine + ? `Three or more consecutive white pearls force ${formatMasyuCellKeyLabel(firstPearl)} to pass perpendicular to the run, so ${formatMasyuLineLabel(firstLine)} is a line${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Consecutive white pearls straight pattern applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + +const squeezeAxes: { + blackDirections: [MasyuDirection, MasyuDirection] + perpendicularDirections: [MasyuDirection, MasyuDirection] +}[] = [ + { + blackDirections: ['E', 'W'], + perpendicularDirections: ['N', 'S'], + }, + { + blackDirections: ['N', 'S'], + perpendicularDirections: ['E', 'W'], + }, +] + +const collectDoubleBlackSqueeze = ( + puzzle: PuzzleIR, + decisions: Map, + middleKey: string, + perpendicularDirections: [MasyuDirection, MasyuDirection], +): string | null => { + const first = getMasyuDirectionalLine(puzzle, middleKey, perpendicularDirections[0]) + const second = getMasyuDirectionalLine(puzzle, middleKey, perpendicularDirections[1]) + if (!first || !second) { + return null + } + if (first.mark === 'blank') { + return rememberLine(decisions, puzzle, second.lineKey, 'blank') ? second.lineKey : null + } + if (second.mark === 'blank') { + return rememberLine(decisions, puzzle, first.lineKey, 'blank') ? first.lineKey : null + } + return null +} + +export const createDoubleBlackSqueezeRule = (): Rule => ({ + id: 'masyu-double-black-squeeze', + name: 'Double Black Squeeze', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstMiddle: string | null = null + let firstLine: string | null = null + + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const middleKey = cellKey(row, col) + for (const { blackDirections, perpendicularDirections } of squeezeAxes) { + const blackCells = blackDirections.map((direction) => { + const [rowDelta, colDelta] = getMasyuDirectionOffset(direction) + return offsetCellKey(puzzle, middleKey, rowDelta, colDelta) + }) + if (!blackCells.every((key) => isPearl(puzzle, key, 'black'))) { + continue + } + const line = collectDoubleBlackSqueeze(puzzle, decisions, middleKey, perpendicularDirections) + if (!line) { + continue + } + affectedCells.add(middleKey) + if (firstMiddle === null) { + firstMiddle = middleKey + firstLine = line + } + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstMiddle && firstLine + ? `The cell ${formatMasyuCellKeyLabel(firstMiddle)} between two black pearls cannot use a single perpendicular exit, so ${formatMasyuLineLabel(firstLine)} is blank${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Double black squeeze pattern applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) diff --git a/src/domain/rules/masyu/rules/pearls.ts b/src/domain/rules/masyu/rules/pearls.ts new file mode 100644 index 0000000..f58c98c --- /dev/null +++ b/src/domain/rules/masyu/rules/pearls.ts @@ -0,0 +1,368 @@ +import type { PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { + MASYU_DIRECTIONS, + areMasyuDirectionsOpposite, + areMasyuDirectionsTurn, + buildMasyuLineDiffs, + canMasyuLineBeAddedWithoutDegreeOverflow, + collectMasyuLineDecision, + collectMasyuLineDecisionWithoutDegreeOverflow, + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionalLine, + getMasyuTurnCandidateLines, + getMasyuTwoStepLine, + isMasyuLineAvailable, + oppositeMasyuDirection, + getMasyuIncidentDirectionalLines, + type MasyuDirection, +} from './shared' + +type Axis = [MasyuDirection, MasyuDirection] + +const PEARL_AXES: Axis[] = [ + ['N', 'S'], + ['E', 'W'], +] + +const getOppositeAxis = (axis: Axis): Axis => (axis[0] === 'N' ? ['E', 'W'] : ['N', 'S']) + +const getPearlCellKeys = (puzzle: PuzzleIR, color: 'white' | 'black'): string[] => + Object.entries(puzzle.cells).flatMap(([key, cell]) => + cell.clue?.kind === 'pearl' && cell.clue.color === color ? [key] : [], + ) + +const isWhiteAxisBlocked = (puzzle: PuzzleIR, pearlKey: string, axis: Axis): boolean => + axis + .map((direction) => getMasyuDirectionalLine(puzzle, pearlKey, direction)) + .some((item) => !item || !isMasyuLineAvailable(item) || !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, item.lineKey)) + +const canWhiteAxisSideTurn = (puzzle: PuzzleIR, pearlKey: string, direction: MasyuDirection): boolean => { + const { first, second } = getMasyuTwoStepLine(puzzle, pearlKey, direction) + if (!isMasyuLineAvailable(first) || !first || second?.mark === 'line') { + return false + } + return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some((candidate) => { + if (!isMasyuLineAvailable(candidate)) { + return false + } + const decisions = new Map() + if (!collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, first.lineKey, 'line')) { + return false + } + if (second && !collectMasyuLineDecision(decisions, puzzle, second.lineKey, 'blank')) { + return false + } + return collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, candidate.lineKey, 'line') + }) +} + +const isWhiteAxisTurnBlocked = (puzzle: PuzzleIR, pearlKey: string, axis: Axis): boolean => + axis.every((direction) => !canWhiteAxisSideTurn(puzzle, pearlKey, direction)) + +const isBlackExitAvailable = (puzzle: PuzzleIR, pearlKey: string, direction: MasyuDirection): boolean => { + const { first, second } = getMasyuTwoStepLine(puzzle, pearlKey, direction) + return isMasyuLineAvailable(first) && isMasyuLineAvailable(second) +} + +const collectNewMasyuLineDecision = ( + decisions: Map, + puzzle: PuzzleIR, + key: string, + to: 'line' | 'blank', +): boolean => { + const beforeSize = decisions.size + return collectMasyuLineDecision(decisions, puzzle, key, to) && decisions.size > beforeSize +} + +const collectNewMasyuLineDecisionWithoutDegreeOverflow = ( + decisions: Map, + puzzle: PuzzleIR, + key: string, + to: 'line' | 'blank', +): boolean => { + const beforeSize = decisions.size + return collectMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, key, to) && decisions.size > beforeSize +} + +export const createWhiteCircleRule = (): Rule => ({ + id: 'white-circle-rule', + name: 'White Circle Rule', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + let firstReason: string | null = null + + for (const pearlKey of getPearlCellKeys(puzzle, 'white')) { + const incident = getMasyuIncidentDirectionalLines(puzzle, pearlKey) + const lineEntries = MASYU_DIRECTIONS.flatMap((direction) => { + const item = incident[direction] + return item?.mark === 'line' ? [item] : [] + }) + const lineDirections = lineEntries.map((item) => item.direction) + + for (const axis of PEARL_AXES) { + if (!axis.every((direction) => incident[direction]?.mark === 'line')) { + continue + } + for (const straightSide of axis) { + const turnSide = oppositeMasyuDirection(straightSide) + const straightExtension = getMasyuTwoStepLine(puzzle, pearlKey, straightSide).second + const turnExtension = getMasyuTwoStepLine(puzzle, pearlKey, turnSide).second + if (straightExtension?.mark !== 'line' || !turnExtension) { + continue + } + if (collectNewMasyuLineDecision(decisions, puzzle, turnExtension.lineKey, 'blank')) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstLine = turnExtension.lineKey + firstReason = + 'must turn in an adjacent cell; one side already goes straight for two segments, so the other side cannot continue straight' + } + } + } + } + + if (lineEntries.length === 1) { + const straightDirection = oppositeMasyuDirection(lineEntries[0].direction) + let addedAny = false + for (const direction of MASYU_DIRECTIONS) { + const item = incident[direction] + if (!item) { + continue + } + const mark = direction === straightDirection ? 'line' : 'blank' + if (collectNewMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, item.lineKey, mark)) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstReason = 'must go straight through the pearl' + } + } + continue + } + + if (lineEntries.length === 2) { + if (!areMasyuDirectionsOpposite(lineDirections[0], lineDirections[1])) { + continue + } + let addedAny = false + for (const direction of MASYU_DIRECTIONS) { + if (lineDirections.includes(direction)) { + continue + } + const item = incident[direction] + if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstReason = 'must go straight through the pearl, so turn candidates are blank' + } + } + continue + } + + const unavailableAxes = PEARL_AXES.filter( + (axis) => isWhiteAxisBlocked(puzzle, pearlKey, axis) || isWhiteAxisTurnBlocked(puzzle, pearlKey, axis), + ) + if (unavailableAxes.length !== 1) { + continue + } + + const blockedAxis = unavailableAxes[0] + const straightAxis = getOppositeAxis(blockedAxis) + let addedAny = false + + for (const direction of blockedAxis) { + const item = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + for (const direction of straightAxis) { + const item = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (item && collectNewMasyuLineDecisionWithoutDegreeOverflow(decisions, puzzle, item.lineKey, 'line')) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + firstReason = 'has a blocked axis and must use the other axis' + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine + ? `White pearl ${formatMasyuCellKeyLabel(firstPearl)} ${firstReason ?? 'forces a local decision'}, so ${formatMasyuLineLabel(firstLine)} is decided${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'White circle rule applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) + +export const createBlackCircleRule = (): Rule => ({ + id: 'black-circle-rule', + name: 'Black Circle Rule', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const decisions = new Map() + const affectedCells = new Set() + let firstPearl: string | null = null + let firstLine: string | null = null + + for (const pearlKey of getPearlCellKeys(puzzle, 'black')) { + const incident = getMasyuIncidentDirectionalLines(puzzle, pearlKey) + const lineEntries = MASYU_DIRECTIONS.flatMap((direction) => { + const item = incident[direction] + return item?.mark === 'line' ? [item] : [] + }) + const lineDirections = lineEntries.map((item) => item.direction) + + if (lineEntries.length === 1) { + const lineDirection = lineEntries[0].direction + const opposite = incident[oppositeMasyuDirection(lineDirection)] + let addedAny = false + if (opposite && collectNewMasyuLineDecision(decisions, puzzle, opposite.lineKey, 'blank')) { + addedAny = true + if (firstLine === null) { + firstLine = opposite.lineKey + } + } + const extension = getMasyuTwoStepLine(puzzle, pearlKey, lineDirection).second + if (extension && collectNewMasyuLineDecision(decisions, puzzle, extension.lineKey, 'line')) { + addedAny = true + if (firstLine === null) { + firstLine = extension.lineKey + } + } + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + } + } + continue + } + + if (lineEntries.length === 2) { + if (!areMasyuDirectionsTurn(lineDirections[0], lineDirections[1])) { + continue + } + let addedAny = false + for (const direction of MASYU_DIRECTIONS) { + if (lineDirections.includes(direction)) { + continue + } + const item = incident[direction] + if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'blank')) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + for (const direction of lineDirections) { + const extension = getMasyuTwoStepLine(puzzle, pearlKey, direction).second + if (extension && collectNewMasyuLineDecision(decisions, puzzle, extension.lineKey, 'line')) { + addedAny = true + if (firstLine === null) { + firstLine = extension.lineKey + } + } + } + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + } + } + continue + } + + for (const direction of ['N', 'E', 'S', 'W'] as const) { + if (isBlackExitAvailable(puzzle, pearlKey, direction)) { + continue + } + const oppositeDirection = oppositeMasyuDirection(direction) + if (!isBlackExitAvailable(puzzle, pearlKey, oppositeDirection)) { + continue + } + + let addedAny = false + const blocked = getMasyuDirectionalLine(puzzle, pearlKey, direction) + if (blocked && collectNewMasyuLineDecision(decisions, puzzle, blocked.lineKey, 'blank')) { + addedAny = true + if (firstLine === null) { + firstLine = blocked.lineKey + } + } + + const opposite = getMasyuTwoStepLine(puzzle, pearlKey, oppositeDirection) + for (const item of [opposite.first, opposite.second]) { + if (item && collectNewMasyuLineDecision(decisions, puzzle, item.lineKey, 'line')) { + addedAny = true + if (firstLine === null) { + firstLine = item.lineKey + } + } + } + + if (addedAny) { + affectedCells.add(pearlKey) + if (firstPearl === null) { + firstPearl = pearlKey + } + } + } + } + + if (decisions.size === 0) { + return null + } + + const diffs = buildMasyuLineDiffs(decisions, puzzle) + return { + message: + firstPearl && firstLine + ? `Black pearl ${formatMasyuCellKeyLabel(firstPearl)} must turn, so ${formatMasyuLineLabel(firstLine)} is a line${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.` + : 'Black circle rule applied.', + diffs, + affectedCells: [...affectedCells], + affectedLines: diffs.map((diff) => diff.lineKey), + } + }, +}) diff --git a/src/domain/rules/masyu/rules/shared.ts b/src/domain/rules/masyu/rules/shared.ts new file mode 100644 index 0000000..70073c8 --- /dev/null +++ b/src/domain/rules/masyu/rules/shared.ts @@ -0,0 +1,224 @@ +import { cellKey, getCellLineKeys, lineKey, parseCellKey, parseLineKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { LineDiff } from '../../types' + +export type MasyuDirection = 'N' | 'E' | 'S' | 'W' + +export const MASYU_DIRECTIONS: MasyuDirection[] = ['N', 'E', 'S', 'W'] + +export const oppositeMasyuDirection = (direction: MasyuDirection): MasyuDirection => { + if (direction === 'N') return 'S' + if (direction === 'S') return 'N' + if (direction === 'E') return 'W' + return 'E' +} + +export const areMasyuDirectionsOpposite = ( + left: MasyuDirection, + right: MasyuDirection, +): boolean => oppositeMasyuDirection(left) === right + +export const areMasyuDirectionsTurn = ( + left: MasyuDirection, + right: MasyuDirection, +): boolean => left !== right && !areMasyuDirectionsOpposite(left, right) + +const directionOffsets: Record = { + N: [-1, 0], + E: [0, 1], + S: [1, 0], + W: [0, -1], +} + +export const getMasyuDirectionOffset = ( + direction: MasyuDirection, +): [rowDelta: number, colDelta: number] => directionOffsets[direction] + +export const formatMasyuCellLabel = (row: number, col: number): string => `(R${row + 1}, C${col + 1})` + +export const formatMasyuCellKeyLabel = (key: string): string => { + const [row, col] = parseCellKey(key) + return formatMasyuCellLabel(row, col) +} + +export const formatMasyuLineLabel = (key: string): string => { + const [left, right] = parseLineKey(key) + return `${formatMasyuCellLabel(left[0], left[1])}-${formatMasyuCellLabel(right[0], right[1])}` +} + +export type MasyuDirectionalLine = { + direction: MasyuDirection + lineKey: string + mark: LineMark + neighborKey: string +} + +export const getMasyuLineDirectionFromCell = ( + cell: string, + targetLineKey: string, +): MasyuDirection | null => { + const [row, col] = parseCellKey(cell) + const [left, right] = parseLineKey(targetLineKey) + const other = + left[0] === row && left[1] === col + ? right + : right[0] === row && right[1] === col + ? left + : null + if (!other) { + return null + } + if (other[0] === row - 1 && other[1] === col) return 'N' + if (other[0] === row + 1 && other[1] === col) return 'S' + if (other[0] === row && other[1] === col + 1) return 'E' + if (other[0] === row && other[1] === col - 1) return 'W' + return null +} + +export const getMasyuDirectionalLine = ( + puzzle: PuzzleIR, + originKey: string, + direction: MasyuDirection, +): MasyuDirectionalLine | null => { + const [row, col] = parseCellKey(originKey) + const [rowDelta, colDelta] = directionOffsets[direction] + const neighborRow = row + rowDelta + const neighborCol = col + colDelta + if (neighborRow < 0 || neighborRow >= puzzle.rows || neighborCol < 0 || neighborCol >= puzzle.cols) { + return null + } + const neighborKey = cellKey(neighborRow, neighborCol) + const lineKeyValue = lineKey([row, col], [neighborRow, neighborCol]) + return { + direction, + lineKey: lineKeyValue, + mark: puzzle.lines[lineKeyValue]?.mark ?? 'unknown', + neighborKey, + } +} + +export const getMasyuIncidentDirectionalLines = ( + puzzle: PuzzleIR, + key: string, +): Record => ({ + N: getMasyuDirectionalLine(puzzle, key, 'N'), + E: getMasyuDirectionalLine(puzzle, key, 'E'), + S: getMasyuDirectionalLine(puzzle, key, 'S'), + W: getMasyuDirectionalLine(puzzle, key, 'W'), +}) + +export const getMasyuForwardLine = ( + puzzle: PuzzleIR, + key: string, + direction: MasyuDirection, +): MasyuDirectionalLine | null => { + const first = getMasyuDirectionalLine(puzzle, key, direction) + if (!first) { + return null + } + return getMasyuDirectionalLine(puzzle, first.neighborKey, direction) +} + +export type MasyuTwoStepLine = { + first: MasyuDirectionalLine | null + second: MasyuDirectionalLine | null +} + +export const getMasyuTwoStepLine = ( + puzzle: PuzzleIR, + key: string, + direction: MasyuDirection, +): MasyuTwoStepLine => { + const first = getMasyuDirectionalLine(puzzle, key, direction) + return { + first, + second: first ? getMasyuDirectionalLine(puzzle, first.neighborKey, direction) : null, + } +} + +export const isMasyuLineAvailable = (line: MasyuDirectionalLine | null): boolean => + line !== null && line.mark !== 'blank' + +export const getMasyuTurnCandidateLines = ( + puzzle: PuzzleIR, + key: string, + throughDirection: MasyuDirection, +): MasyuDirectionalLine[] => { + const turnDirections: MasyuDirection[] = + throughDirection === 'N' || throughDirection === 'S' ? ['E', 'W'] : ['N', 'S'] + return turnDirections.flatMap((direction) => { + const item = getMasyuDirectionalLine(puzzle, key, direction) + return item ? [item] : [] + }) +} + +export const collectMasyuLineDecision = ( + decisions: Map, + puzzle: PuzzleIR, + key: string, + to: LineMark, +): boolean => { + const current = puzzle.lines[key]?.mark ?? 'unknown' + if (current === to) { + return true + } + if (current !== 'unknown') { + return false + } + const existing = decisions.get(key) + if (existing !== undefined) { + return existing === to + } + decisions.set(key, to) + return true +} + +export const getMasyuCellLineDegree = ( + puzzle: PuzzleIR, + key: string, + decisions: ReadonlyMap = new Map(), +): number => { + const [row, col] = parseCellKey(key) + return getCellLineKeys(row, col, puzzle.rows, puzzle.cols).filter( + (lineKeyValue) => (decisions.get(lineKeyValue) ?? puzzle.lines[lineKeyValue]?.mark ?? 'unknown') === 'line', + ).length +} + +export const canMasyuLineBeAddedWithoutDegreeOverflow = ( + puzzle: PuzzleIR, + key: string, + decisions: ReadonlyMap = new Map(), +): boolean => { + const current = decisions.get(key) ?? puzzle.lines[key]?.mark ?? 'unknown' + if (current === 'line') { + return true + } + if (current !== 'unknown') { + return false + } + const [left, right] = parseLineKey(key) + return [left, right].every(([row, col]) => getMasyuCellLineDegree(puzzle, cellKey(row, col), decisions) < 2) +} + +export const collectMasyuLineDecisionWithoutDegreeOverflow = ( + decisions: Map, + puzzle: PuzzleIR, + key: string, + to: LineMark, +): boolean => { + if (to === 'line' && !canMasyuLineBeAddedWithoutDegreeOverflow(puzzle, key, decisions)) { + return false + } + return collectMasyuLineDecision(decisions, puzzle, key, to) +} + +export const buildMasyuLineDiffs = ( + decisions: Map, + puzzle: PuzzleIR, +): LineDiff[] => + [...decisions.entries()].map(([key, to]) => ({ + kind: 'line' as const, + lineKey: key, + from: puzzle.lines[key]?.mark ?? 'unknown', + to, + })) diff --git a/src/domain/rules/masyu/rules/trial.ts b/src/domain/rules/masyu/rules/trial.ts new file mode 100644 index 0000000..9c639d7 --- /dev/null +++ b/src/domain/rules/masyu/rules/trial.ts @@ -0,0 +1,505 @@ +import { cellKey, parseLineKey, tileKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import { runNextRule } from '../../engine' +import type { Rule } from '../../types' +import { getMasyuLineTileRelation, isMasyuTileColor } from './color' +import { + areMasyuDirectionsOpposite, + areMasyuDirectionsTurn, + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuDirectionalLine, + getMasyuIncidentDirectionalLines, + getMasyuTurnCandidateLines, + getMasyuTwoStepLine, + MASYU_DIRECTIONS, + type MasyuDirection, +} from './shared' + +export type MasyuTrialContradictionReason = { + kind: + | 'cell-degree' + | 'pearl-shape' + | 'line-loop' + | 'tile-color' + | 'line-assumption' + message: string +} + +export type MasyuTrialResult = { + contradiction: boolean + timedOut: boolean + exhausted: boolean + puzzle: PuzzleIR + stepsRun: number + elapsedMs: number + contradictionReason?: MasyuTrialContradictionReason +} + +type Parity = 0 | 1 + +export const applyMasyuLineAssumption = (puzzle: PuzzleIR, lineKeyValue: string, to: LineMark): boolean => { + const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (current !== 'unknown') { + return current === to + } + puzzle.lines[lineKeyValue] = { ...puzzle.lines[lineKeyValue], mark: to } + return true +} + +const getLineDirectionsAtCell = (puzzle: PuzzleIR, key: string): MasyuDirection[] => + Object.values(getMasyuIncidentDirectionalLines(puzzle, key)).flatMap((item) => + item && item.mark === 'line' ? [item.direction] : [], + ) + +const getIncidentCounts = (puzzle: PuzzleIR, key: string): { lineCount: number; unknownCount: number } => { + let lineCount = 0 + let unknownCount = 0 + for (const item of Object.values(getMasyuIncidentDirectionalLines(puzzle, key))) { + if (!item) { + continue + } + if (item.mark === 'line') { + lineCount += 1 + } else if (item.mark === 'unknown') { + unknownCount += 1 + } + } + return { lineCount, unknownCount } +} + +const detectCellDegreeContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { + for (let row = 0; row < puzzle.rows; row += 1) { + for (let col = 0; col < puzzle.cols; col += 1) { + const key = cellKey(row, col) + const { lineCount, unknownCount } = getIncidentCounts(puzzle, key) + if (lineCount > 2) { + return { + kind: 'cell-degree', + message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: ${lineCount} line segments meet there`, + } + } + if (lineCount === 1 && unknownCount === 0) { + return { + kind: 'cell-degree', + message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: a closed cell has only one line segment`, + } + } + } + } + return null +} + +const canLineStillBeLine = (puzzle: PuzzleIR, lineKeyValue: string): boolean => { + const current = puzzle.lines[lineKeyValue]?.mark ?? 'unknown' + if (current === 'blank') { + return false + } + if (current === 'line') { + return true + } + const [left, right] = parseLineKey(lineKeyValue) + return [left, right].every(([row, col]) => getIncidentCounts(puzzle, cellKey(row, col)).lineCount < 2) +} + +const canBlackPearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { + const lineDirections = new Set(getLineDirectionsAtCell(puzzle, key)) + if (lineDirections.size > 2) { + return false + } + + const candidateTurns: Array<[MasyuDirection, MasyuDirection]> = [ + ['N', 'E'], + ['N', 'W'], + ['S', 'E'], + ['S', 'W'], + ] + + return candidateTurns.some(([leftDirection, rightDirection]) => { + const exits = new Set([leftDirection, rightDirection]) + for (const direction of lineDirections) { + if (!exits.has(direction)) { + return false + } + } + for (const direction of [leftDirection, rightDirection]) { + const { first, second } = getMasyuTwoStepLine(puzzle, key, direction) + if (!first || !second || !canLineStillBeLine(puzzle, first.lineKey) || !canLineStillBeLine(puzzle, second.lineKey)) { + return false + } + } + for (const direction of MASYU_DIRECTIONS) { + if (exits.has(direction)) { + continue + } + const line = getMasyuDirectionalLine(puzzle, key, direction) + if (line?.mark === 'line') { + return false + } + } + return true + }) +} + +const canWhiteSideStillTurn = (puzzle: PuzzleIR, key: string, direction: MasyuDirection): boolean => { + const first = getMasyuDirectionalLine(puzzle, key, direction) + if (!first || !canLineStillBeLine(puzzle, first.lineKey)) { + return false + } + const straightContinuation = getMasyuDirectionalLine(puzzle, first.neighborKey, direction) + if (straightContinuation?.mark === 'line') { + return false + } + return getMasyuTurnCandidateLines(puzzle, first.neighborKey, direction).some((turn) => + canLineStillBeLine(puzzle, turn.lineKey), + ) +} + +const canWhitePearlStillWork = (puzzle: PuzzleIR, key: string): boolean => { + const lineDirections = new Set(getLineDirectionsAtCell(puzzle, key)) + if (lineDirections.size > 2) { + return false + } + + const axes: Array<[MasyuDirection, MasyuDirection]> = [ + ['N', 'S'], + ['E', 'W'], + ] + return axes.some(([leftDirection, rightDirection]) => { + const axis = new Set([leftDirection, rightDirection]) + for (const direction of lineDirections) { + if (!axis.has(direction)) { + return false + } + } + for (const direction of [leftDirection, rightDirection]) { + const line = getMasyuDirectionalLine(puzzle, key, direction) + if (!line || !canLineStillBeLine(puzzle, line.lineKey)) { + return false + } + } + for (const direction of MASYU_DIRECTIONS) { + if (axis.has(direction)) { + continue + } + const line = getMasyuDirectionalLine(puzzle, key, direction) + if (line?.mark === 'line') { + return false + } + } + return canWhiteSideStillTurn(puzzle, key, leftDirection) || canWhiteSideStillTurn(puzzle, key, rightDirection) + }) +} + +const detectPearlContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'pearl') { + continue + } + const lineDirections = getLineDirectionsAtCell(puzzle, key) + if (lineDirections.length === 2) { + if ( + cell.clue.color === 'black' && + (!areMasyuDirectionsTurn(lineDirections[0], lineDirections[1]) || + !lineDirections.every((direction) => getMasyuTwoStepLine(puzzle, key, direction).second?.mark !== 'blank')) + ) { + return { + kind: 'pearl-shape', + message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a black pearl must turn and continue straight after both exits`, + } + } + if (cell.clue.color === 'white' && !areMasyuDirectionsOpposite(lineDirections[0], lineDirections[1])) { + return { + kind: 'pearl-shape', + message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a white pearl must go straight through`, + } + } + } + + const possible = + cell.clue.color === 'black' ? canBlackPearlStillWork(puzzle, key) : canWhitePearlStillWork(puzzle, key) + if (!possible) { + return { + kind: 'pearl-shape', + message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: no ${cell.clue.color} pearl continuation remains possible`, + } + } + } + return null +} + +const detectLineLoopContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { + const lineEntries = Object.entries(puzzle.lines).filter(([, line]) => (line?.mark ?? 'unknown') === 'line') + if (lineEntries.length === 0) { + return null + } + + const cellCount = puzzle.rows * puzzle.cols + const parent = Array.from({ length: cellCount }, (_, idx) => idx) + const rank = new Array(cellCount).fill(0) + const degree = new Map() + const toCellIndex = (row: number, col: number): number => row * puzzle.cols + col + const find = (idx: number): number => { + if (parent[idx] !== idx) { + parent[idx] = find(parent[idx]) + } + return parent[idx] + } + const union = (left: number, right: number): void => { + const rootLeft = find(left) + const rootRight = find(right) + if (rootLeft === rootRight) { + return + } + if (rank[rootLeft] < rank[rootRight]) { + parent[rootLeft] = rootRight + } else if (rank[rootLeft] > rank[rootRight]) { + parent[rootRight] = rootLeft + } else { + parent[rootRight] = rootLeft + rank[rootLeft] += 1 + } + } + + for (const [lineKeyValue] of lineEntries) { + const [left, right] = parseLineKey(lineKeyValue) + const leftIdx = toCellIndex(left[0], left[1]) + const rightIdx = toCellIndex(right[0], right[1]) + union(leftIdx, rightIdx) + degree.set(leftIdx, (degree.get(leftIdx) ?? 0) + 1) + degree.set(rightIdx, (degree.get(rightIdx) ?? 0) + 1) + } + + const componentEdgeCount = new Map() + const componentCells = new Map>() + for (const [lineKeyValue] of lineEntries) { + const [left, right] = parseLineKey(lineKeyValue) + const leftIdx = toCellIndex(left[0], left[1]) + const rightIdx = toCellIndex(right[0], right[1]) + const root = find(leftIdx) + componentEdgeCount.set(root, (componentEdgeCount.get(root) ?? 0) + 1) + const cells = componentCells.get(root) ?? new Set() + cells.add(leftIdx) + cells.add(rightIdx) + componentCells.set(root, cells) + } + + let closedLoopCount = 0 + let closedLoopLines = 0 + for (const [root, cells] of componentCells.entries()) { + const edgeCount = componentEdgeCount.get(root) ?? 0 + if (edgeCount !== cells.size) { + continue + } + let allDegreeTwo = true + for (const cell of cells) { + if ((degree.get(cell) ?? 0) !== 2) { + allDegreeTwo = false + break + } + } + if (!allDegreeTwo) { + continue + } + closedLoopCount += 1 + closedLoopLines += edgeCount + } + + if (closedLoopCount > 1 || (closedLoopCount === 1 && closedLoopLines < lineEntries.length)) { + return { + kind: 'line-loop', + message: + closedLoopCount > 1 + ? `line-loop contradiction: ${closedLoopCount} separate closed Masyu loops are present` + : `line-loop contradiction: a closed Masyu loop of ${closedLoopLines} segments exists while other line segments remain outside it`, + } + } + return null +} + +const detectTileColorContradiction = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => { + const parent = new Map() + const rank = new Map() + const parityToParent = new Map() + + const ensure = (key: string): void => { + if (parent.has(key)) { + return + } + parent.set(key, key) + rank.set(key, 0) + parityToParent.set(key, 0) + } + const find = (key: string): { root: string; parity: Parity } => { + ensure(key) + const currentParent = parent.get(key) + if (currentParent === undefined || currentParent === key) { + return { root: key, parity: 0 } + } + const found = find(currentParent) + const parity = ((parityToParent.get(key) ?? 0) ^ found.parity) as Parity + parent.set(key, found.root) + parityToParent.set(key, parity) + return { root: found.root, parity } + } + const union = (left: string, right: string, relation: Parity, source: string): MasyuTrialContradictionReason | null => { + const leftRoot = find(left) + const rightRoot = find(right) + if (leftRoot.root === rightRoot.root) { + if ((leftRoot.parity ^ rightRoot.parity) !== relation) { + return { + kind: 'tile-color', + message: `tile-color contradiction at ${formatMasyuLineLabel(source)}: line/tile parity requirements conflict`, + } + } + return null + } + const mergedParity = (leftRoot.parity ^ rightRoot.parity ^ relation) as Parity + const leftRank = rank.get(leftRoot.root) ?? 0 + const rightRank = rank.get(rightRoot.root) ?? 0 + if (leftRank < rightRank) { + parent.set(leftRoot.root, rightRoot.root) + parityToParent.set(leftRoot.root, mergedParity) + } else { + parent.set(rightRoot.root, leftRoot.root) + parityToParent.set(rightRoot.root, mergedParity) + if (leftRank === rightRank) { + rank.set(leftRoot.root, leftRank + 1) + } + } + return null + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + ensure(tileKey(row, col)) + } + } + + for (const [lineKeyValue, line] of Object.entries(puzzle.lines)) { + const mark = line?.mark ?? 'unknown' + if (mark !== 'line' && mark !== 'blank') { + continue + } + const relation = getMasyuLineTileRelation(puzzle, lineKeyValue) + if (!relation) { + continue + } + const contradiction = union(relation.leftTile, relation.rightTile, mark === 'line' ? 1 : 0, lineKeyValue) + if (contradiction) { + return contradiction + } + } + + const anchoredRootColors = new Map() + const rememberAnchor = (key: string, color: 'green' | 'yellow'): MasyuTrialContradictionReason | null => { + const { root, parity } = find(key) + const rootColor = (parity === 0 ? color : color === 'green' ? 'yellow' : 'green') as 'green' | 'yellow' + const current = anchoredRootColors.get(root) + if (current !== undefined && current !== rootColor) { + return { + kind: 'tile-color', + message: `tile-color contradiction at ${key}: fixed Masyu tile colors require both ${current} and ${rootColor}`, + } + } + anchoredRootColors.set(root, rootColor) + return null + } + + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + if (row === 0 || row === puzzle.rows || col === 0 || col === puzzle.cols) { + const contradiction = rememberAnchor(tileKey(row, col), 'yellow') + if (contradiction) { + return contradiction + } + } + } + } + for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { + if (!isMasyuTileColor(tile.fill)) { + continue + } + const contradiction = rememberAnchor(key, tile.fill) + if (contradiction) { + return contradiction + } + } + return null +} + +export const findMasyuHardContradictionReason = (puzzle: PuzzleIR): MasyuTrialContradictionReason | null => + detectCellDegreeContradiction(puzzle) ?? + detectPearlContradiction(puzzle) ?? + detectLineLoopContradiction(puzzle) ?? + detectTileColorContradiction(puzzle) + +export const runMasyuTrialUntilFixpoint = ( + puzzle: PuzzleIR, + deterministicRules: Rule[], + maxTrialSteps: number, + deadlineMs: number, +): MasyuTrialResult => { + const startedAt = performance.now() + const initialContradictionReason = findMasyuHardContradictionReason(puzzle) + if (initialContradictionReason) { + return { + contradiction: true, + timedOut: false, + exhausted: false, + puzzle, + stepsRun: 0, + elapsedMs: Math.max(0, performance.now() - startedAt), + contradictionReason: initialContradictionReason, + } + } + + let trial = puzzle + for (let stepNumber = 1; stepNumber <= maxTrialSteps; stepNumber += 1) { + if (Date.now() > deadlineMs) { + return { + contradiction: false, + timedOut: true, + exhausted: false, + puzzle: trial, + stepsRun: stepNumber - 1, + elapsedMs: Math.max(0, performance.now() - startedAt), + } + } + const { nextPuzzle, step } = runNextRule(trial, deterministicRules, stepNumber) + if (!step) { + const contradictionReason = findMasyuHardContradictionReason(trial) + return { + contradiction: contradictionReason !== null, + timedOut: false, + exhausted: false, + puzzle: trial, + stepsRun: stepNumber - 1, + elapsedMs: Math.max(0, performance.now() - startedAt), + contradictionReason: contradictionReason ?? undefined, + } + } + trial = nextPuzzle + const contradictionReason = findMasyuHardContradictionReason(trial) + if (contradictionReason) { + return { + contradiction: true, + timedOut: false, + exhausted: false, + puzzle: trial, + stepsRun: stepNumber, + elapsedMs: Math.max(0, performance.now() - startedAt), + contradictionReason, + } + } + } + + return { + contradiction: false, + timedOut: false, + exhausted: true, + puzzle: trial, + stepsRun: maxTrialSteps, + elapsedMs: Math.max(0, performance.now() - startedAt), + } +} diff --git a/src/domain/rules/slither/completion.ts b/src/domain/rules/slither/completion.ts index ebd366d..9437d0b 100644 --- a/src/domain/rules/slither/completion.ts +++ b/src/domain/rules/slither/completion.ts @@ -1,9 +1,10 @@ import { getCellEdgeKeys, parseCellKey, parseEdgeKey } from '../../ir/keys' import type { PuzzleIR } from '../../ir/types' +import type { CompletionReport, CompletionStats, CompletionStatus } from '../completion' -export type SlitherCompletionStatus = 'solved' | 'stalled' +export type SlitherCompletionStatus = CompletionStatus -export type SlitherCompletionStats = { +export type SlitherCompletionStats = CompletionStats & { totalEdges: number lineEdges: number blankEdges: number @@ -12,11 +13,7 @@ export type SlitherCompletionStats = { decidedEdgeRatio: number } -export type SlitherCompletionReport = { - status: SlitherCompletionStatus - stats: SlitherCompletionStats - reasons: string[] -} +export type SlitherCompletionReport = CompletionReport & { stats: SlitherCompletionStats } const buildEdgeStats = (puzzle: PuzzleIR): SlitherCompletionStats => { let lineEdges = 0 @@ -34,6 +31,13 @@ const buildEdgeStats = (puzzle: PuzzleIR): SlitherCompletionStats => { const decidedEdges = lineEdges + blankEdges return { + totalUnits: totalEdges, + lineUnits: lineEdges, + blankUnits: blankEdges, + unknownUnits: unknownEdges, + decidedUnits: decidedEdges, + decidedRatio: totalEdges === 0 ? 0 : decidedEdges / totalEdges, + unitLabel: 'Edges', totalEdges, lineEdges, blankEdges, diff --git a/src/domain/rules/types.ts b/src/domain/rules/types.ts index 9babda2..61afab5 100644 --- a/src/domain/rules/types.ts +++ b/src/domain/rules/types.ts @@ -1,4 +1,4 @@ -import type { EdgeMark, PuzzleIR, SectorConstraintMask, VertexCandidate } from '../ir/types' +import type { EdgeMark, LineMark, PuzzleIR, SectorConstraintMask, VertexCandidate } from '../ir/types' export type EdgeDiff = { kind: 'edge' @@ -14,6 +14,13 @@ export type SectorDiff = { toMask: SectorConstraintMask } +export type LineDiff = { + kind: 'line' + lineKey: string + from: LineMark + to: LineMark +} + export type CellDiff = { kind: 'cell' cellKey: string @@ -21,6 +28,13 @@ export type CellDiff = { toFill: string | null } +export type TileDiff = { + kind: 'tile' + tileKey: string + fromFill: string | null + toFill: string | null +} + export type VertexDiff = { kind: 'vertex' vertexKey: string @@ -28,7 +42,7 @@ export type VertexDiff = { toCandidates: VertexCandidate[] } -export type RuleDiff = EdgeDiff | SectorDiff | CellDiff | VertexDiff +export type RuleDiff = EdgeDiff | LineDiff | SectorDiff | CellDiff | TileDiff | VertexDiff export type RuleStep = { id: string @@ -37,7 +51,9 @@ export type RuleStep = { message: string diffs: RuleDiff[] affectedCells: string[] + affectedTiles?: string[] affectedEdges: string[] + affectedLines?: string[] affectedSectors: string[] timestamp: number durationMs: number @@ -47,6 +63,8 @@ export type RuleApplication = { message: string diffs: RuleDiff[] affectedCells: string[] + affectedTiles?: string[] + affectedLines?: string[] affectedSectors?: string[] } diff --git a/src/features/board/CanvasBoard.tsx b/src/features/board/CanvasBoard.tsx index ba231f5..136125d 100644 --- a/src/features/board/CanvasBoard.tsx +++ b/src/features/board/CanvasBoard.tsx @@ -4,7 +4,9 @@ import { getCornerEdgeKeys, parseCellKey, parseEdgeKey, + parseLineKey, parseSectorKey, + parseTileKey, } from '../../domain/ir/keys' import { SECTOR_MASK_ALL, @@ -13,12 +15,16 @@ import { type SectorCorner, } from '../../domain/ir/types' import type { PuzzleIR } from '../../domain/ir/types' +import { PuzzleStatsInfoButton } from '../puzzleStats/PuzzleStatsInfoButton' type Props = { puzzle: PuzzleIR + pluginId: string highlightedEdges: string[] + highlightedLines?: string[] highlightedCells: string[] highlightedColorCells: string[] + highlightedColorTiles?: string[] showVertexNumbers: boolean } @@ -46,11 +52,55 @@ const getSectorArcAngles = (corner: SectorCorner): [number, number] => { return [Math.PI, Math.PI * 1.5] } +const drawDecisionMark = ( + ctx: CanvasRenderingContext2D, + mark: 'unknown' | 'line' | 'blank', + highlighted: boolean, + x1: number, + y1: number, + x2: number, + y2: number, +): void => { + if (mark === 'line') { + ctx.strokeStyle = highlighted ? '#22d3ee' : '#38bdf8' + ctx.lineWidth = 4 + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + return + } + if (mark === 'blank') { + const [mx, my] = midpoint([x1, y1], [x2, y2]) + ctx.strokeStyle = highlighted ? '#f472b6' : '#94a3b8' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(mx - 6, my - 6) + ctx.lineTo(mx + 6, my + 6) + ctx.moveTo(mx + 6, my - 6) + ctx.lineTo(mx - 6, my + 6) + ctx.stroke() + } +} + +const getColorFillStyle = (fill: string | undefined, alpha: number): string | null => { + if (fill === 'green') { + return `rgba(34, 197, 94, ${alpha})` + } + if (fill === 'yellow') { + return `rgba(245, 158, 11, ${alpha})` + } + return null +} + export const CanvasBoard = ({ puzzle, + pluginId, highlightedEdges, + highlightedLines = [], highlightedCells, highlightedColorCells, + highlightedColorTiles = [], showVertexNumbers, }: Props) => { const canvasRef = useRef(null) @@ -80,6 +130,7 @@ export const CanvasBoard = ({ ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, width, height) + const isMasyu = puzzle.puzzleType === 'masyu' ctx.fillStyle = '#64748b' ctx.font = '600 12px Inter, sans-serif' @@ -94,14 +145,33 @@ export const CanvasBoard = ({ ctx.fillText(`C${c + 1}`, PADDING + c * CELL_SIZE + CELL_SIZE / 2, PADDING - 14) } - for (const [key, cell] of Object.entries(puzzle.cells)) { - const fill = cell.fill - if (fill !== 'green' && fill !== 'yellow') { - continue + if (!isMasyu) { + for (const [key, cell] of Object.entries(puzzle.cells)) { + const fill = cell.fill + const fillStyle = getColorFillStyle(fill, 0.24) + if (!fillStyle) { + continue + } + const [r, c] = parseCellKey(key) + ctx.fillStyle = fillStyle + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + } + } else { + const tileSize = CELL_SIZE + for (const [key, tile] of Object.entries(puzzle.tiles ?? {})) { + const fillStyle = getColorFillStyle(tile.fill, 0.24) + if (!fillStyle) { + continue + } + const [r, c] = parseTileKey(key) + ctx.fillStyle = fillStyle + ctx.fillRect( + PADDING + c * CELL_SIZE - tileSize / 2, + PADDING + r * CELL_SIZE - tileSize / 2, + tileSize, + tileSize, + ) } - const [r, c] = parseCellKey(key) - ctx.fillStyle = fill === 'green' ? 'rgba(34, 197, 94, 0.24)' : 'rgba(245, 158, 11, 0.24)' - ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) } for (const cell of highlightedCells) { @@ -110,21 +180,31 @@ export const CanvasBoard = ({ ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) } - for (const cell of highlightedColorCells) { - const fill = puzzle.cells[cell]?.fill - const [r, c] = parseCellKey(cell) - if (fill === 'green') { - ctx.fillStyle = 'rgba(34, 197, 94, 0.44)' - } else if (fill === 'yellow') { - ctx.fillStyle = 'rgba(245, 158, 11, 0.44)' - } else { - ctx.fillStyle = 'rgba(99, 102, 241, 0.2)' + if (!isMasyu) { + for (const cell of highlightedColorCells) { + const fill = puzzle.cells[cell]?.fill + const [r, c] = parseCellKey(cell) + ctx.fillStyle = getColorFillStyle(fill, 0.44) ?? 'rgba(99, 102, 241, 0.2)' + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + } + } else { + const tileSize = CELL_SIZE + for (const tile of highlightedColorTiles) { + const fill = puzzle.tiles[tile]?.fill + const [r, c] = parseTileKey(tile) + ctx.fillStyle = getColorFillStyle(fill, 0.44) ?? 'rgba(99, 102, 241, 0.2)' + ctx.fillRect( + PADDING + c * CELL_SIZE - tileSize / 2, + PADDING + r * CELL_SIZE - tileSize / 2, + tileSize, + tileSize, + ) } - ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) } - ctx.strokeStyle = '#cbd5e1' + ctx.strokeStyle = isMasyu ? '#94a3b8' : '#cbd5e1' ctx.lineWidth = 1 + ctx.setLineDash(isMasyu ? [4, 4] : []) for (let r = 0; r <= puzzle.rows; r += 1) { ctx.beginPath() ctx.moveTo(PADDING, PADDING + r * CELL_SIZE) @@ -137,23 +217,43 @@ export const CanvasBoard = ({ ctx.lineTo(PADDING + c * CELL_SIZE, PADDING + puzzle.rows * CELL_SIZE) ctx.stroke() } + ctx.setLineDash([]) + + if (isMasyu) { + ctx.strokeStyle = '#111827' + ctx.lineWidth = 3 + ctx.strokeRect(PADDING, PADDING, puzzle.cols * CELL_SIZE, puzzle.rows * CELL_SIZE) + } for (const [key, cell] of Object.entries(puzzle.cells)) { - if (cell.clue?.kind !== 'number') { + if (isMasyu && cell.clue?.kind !== 'pearl') { + continue + } + if (!isMasyu && cell.clue?.kind !== 'number') { continue } const [r, c] = parseCellKey(key) - ctx.fillStyle = '#111827' - ctx.font = 'bold 26px Inter, sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText( - String(cell.clue.value), - PADDING + c * CELL_SIZE + CELL_SIZE / 2, - PADDING + r * CELL_SIZE + CELL_SIZE / 2, - ) + const centerX = PADDING + c * CELL_SIZE + CELL_SIZE / 2 + const centerY = PADDING + r * CELL_SIZE + CELL_SIZE / 2 + if (cell.clue?.kind === 'pearl') { + const radius = CELL_SIZE * 0.28 + ctx.beginPath() + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) + ctx.fillStyle = cell.clue.color === 'black' ? '#111827' : '#ffffff' + ctx.fill() + ctx.strokeStyle = '#111827' + ctx.lineWidth = 2.4 + ctx.stroke() + } else if (cell.clue?.kind === 'number') { + ctx.fillStyle = '#111827' + ctx.font = 'bold 26px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(cell.clue.value), centerX, centerY) + } } + if (!isMasyu) { const sectorRadii = { notZero: CELL_SIZE * 0.19, notOne: CELL_SIZE * 0.24, @@ -209,34 +309,29 @@ export const CanvasBoard = ({ } ctx.restore() } + } - for (const [edge, state] of Object.entries(puzzle.edges)) { - const [v1, v2] = parseEdgeKey(edge) - const x1 = PADDING + v1[1] * CELL_SIZE - const y1 = PADDING + v1[0] * CELL_SIZE - const x2 = PADDING + v2[1] * CELL_SIZE - const y2 = PADDING + v2[0] * CELL_SIZE - - if (state.mark === 'line') { - ctx.strokeStyle = highlightedEdges.includes(edge) ? '#22d3ee' : '#38bdf8' - ctx.lineWidth = 4 - ctx.beginPath() - ctx.moveTo(x1, y1) - ctx.lineTo(x2, y2) - ctx.stroke() - } else if (state.mark === 'blank') { - const [mx, my] = midpoint([x1, y1], [x2, y2]) - ctx.strokeStyle = highlightedEdges.includes(edge) ? '#f472b6' : '#94a3b8' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.moveTo(mx - 6, my - 6) - ctx.lineTo(mx + 6, my + 6) - ctx.moveTo(mx + 6, my - 6) - ctx.lineTo(mx - 6, my + 6) - ctx.stroke() + if (isMasyu) { + for (const [line, state] of Object.entries(puzzle.lines ?? {})) { + const [v1, v2] = parseLineKey(line) + const x1 = PADDING + v1[1] * CELL_SIZE + CELL_SIZE / 2 + const y1 = PADDING + v1[0] * CELL_SIZE + CELL_SIZE / 2 + const x2 = PADDING + v2[1] * CELL_SIZE + CELL_SIZE / 2 + const y2 = PADDING + v2[0] * CELL_SIZE + CELL_SIZE / 2 + drawDecisionMark(ctx, state.mark, highlightedLines.includes(line), x1, y1, x2, y2) + } + } else { + for (const [edge, state] of Object.entries(puzzle.edges)) { + const [v1, v2] = parseEdgeKey(edge) + const x1 = PADDING + v1[1] * CELL_SIZE + const y1 = PADDING + v1[0] * CELL_SIZE + const x2 = PADDING + v2[1] * CELL_SIZE + const y2 = PADDING + v2[0] * CELL_SIZE + drawDecisionMark(ctx, state.mark, highlightedEdges.includes(edge), x1, y1, x2, y2) } } + if (!isMasyu) { ctx.fillStyle = '#111827' for (let r = 0; r <= puzzle.rows; r += 1) { for (let c = 0; c <= puzzle.cols; c += 1) { @@ -247,8 +342,9 @@ export const CanvasBoard = ({ ctx.fill() } } + } - if (showVertexNumbers) { + if (showVertexNumbers && !isMasyu) { ctx.fillStyle = '#64748b' ctx.font = '12px ui-monospace, monospace' ctx.textAlign = 'left' @@ -271,7 +367,9 @@ export const CanvasBoard = ({ height, highlightedCells, highlightedColorCells, + highlightedColorTiles, highlightedEdges, + highlightedLines, puzzle, showVertexNumbers, width, @@ -282,13 +380,14 @@ export const CanvasBoard = ({ let lineCount = 0 let blankCount = 0 let unknownCount = 0 - Object.values(puzzle.edges).forEach((edge) => { - if (edge.mark === 'line') lineCount += 1 - else if (edge.mark === 'blank') blankCount += 1 + const decisions = puzzle.puzzleType === 'masyu' ? Object.values(puzzle.lines ?? {}) : Object.values(puzzle.edges) + decisions.forEach((decision) => { + if (decision.mark === 'line') lineCount += 1 + else if (decision.mark === 'blank') blankCount += 1 else unknownCount += 1 }) return { lineCount, blankCount, unknownCount } - }, [puzzle.edges]) + }, [puzzle.edges, puzzle.lines, puzzle.puzzleType]) return (
@@ -298,6 +397,7 @@ export const CanvasBoard = ({ {puzzle.rows} × {puzzle.cols} +
@@ -322,7 +422,7 @@ export const CanvasBoard = ({
@@ -330,18 +430,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/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/editor/SlitherlinkEditorBoard.tsx b/src/features/editor/SlitherlinkEditorBoard.tsx index 5b9fa4f..f90679b 100644 --- a/src/features/editor/SlitherlinkEditorBoard.tsx +++ b/src/features/editor/SlitherlinkEditorBoard.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { cellKey, edgeKey, parseCellKey, parseEdgeKey } from '../../domain/ir/keys' import type { EdgeMark, NumberClueValue, PuzzleIR } from '../../domain/ir/types' +import { PuzzleStatsInfoButton } from '../puzzleStats/PuzzleStatsInfoButton' import type { SlitherClueDraft } from './editorStore' type Props = { puzzle: PuzzleIR + pluginId: string onCellClueChange: (key: string, value: SlitherClueDraft) => void onEdgeMarkChange: (key: string, mark: EdgeMark) => void } @@ -61,6 +63,7 @@ const isCellKeyInPuzzle = (key: string, puzzle: PuzzleIR): boolean => { export const SlitherlinkEditorBoard = ({ puzzle, + pluginId, onCellClueChange, onEdgeMarkChange, }: Props) => { @@ -377,6 +380,7 @@ export const SlitherlinkEditorBoard = ({ {puzzle.rows} × {puzzle.cols} +
Click/type clues, drag edges 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/explanation/ExplanationPanel.tsx b/src/features/explanation/ExplanationPanel.tsx index 7a311e5..8aeccef 100644 --- a/src/features/explanation/ExplanationPanel.tsx +++ b/src/features/explanation/ExplanationPanel.tsx @@ -5,6 +5,20 @@ type Props = { steps: RuleStep[] } +const buildStepMeta = (step: RuleStep): string => { + const edgeUpdates = step.diffs.filter((diff) => diff.kind === 'edge').length + const lineUpdates = step.diffs.filter((diff) => diff.kind === 'line' && diff.to === 'line').length + const lineCrosses = step.diffs.filter((diff) => diff.kind === 'line' && diff.to === 'blank').length + const sectorUpdates = step.affectedSectors.length + const parts = [ + edgeUpdates > 0 ? `edge updates: ${edgeUpdates}` : null, + lineUpdates > 0 ? `line updates: ${lineUpdates}` : null, + lineCrosses > 0 ? `line crosses: ${lineCrosses}` : null, + sectorUpdates > 0 ? `sector updates: ${sectorUpdates}` : null, + ].filter((part): part is string => part !== null) + return parts.length > 0 ? parts.join(', ') : 'edge updates: 0' +} + export const ExplanationPanel = ({ steps }: Props) => { const [showAllSteps, setShowAllSteps] = useState(false) const visibleEntries = useMemo( @@ -51,12 +65,7 @@ export const ExplanationPanel = ({ steps }: Props) => { {sequence}. {step.ruleName}

{step.message}

-

- edge updates: {step.diffs.filter((diff) => diff.kind === 'edge').length} - {step.affectedSectors.length > 0 - ? `, sector updates: ${step.affectedSectors.length}` - : ''} -

+

{buildStepMeta(step)}

)) )} diff --git a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx new file mode 100644 index 0000000..c3036be --- /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 = 'puzzle-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 ( + + ) +} diff --git a/src/features/puzzleStats/PuzzleStatsInfoButton.tsx b/src/features/puzzleStats/PuzzleStatsInfoButton.tsx new file mode 100644 index 0000000..f7990cd --- /dev/null +++ b/src/features/puzzleStats/PuzzleStatsInfoButton.tsx @@ -0,0 +1,60 @@ +import { useMemo, useState } from 'react' +import type { PuzzleIR } from '../../domain/ir/types' +import { puzzleRegistry } from '../../domain/plugins/registry' + +type Props = { + pluginId: string + puzzle: PuzzleIR +} + +export const PuzzleStatsInfoButton = ({ pluginId, puzzle }: Props) => { + const [isOpen, setIsOpen] = useState(false) + const stats = useMemo(() => { + const plugin = puzzleRegistry.get(pluginId) + return plugin?.getStats?.(puzzle) ?? null + }, [pluginId, puzzle]) + + if (!stats) { + return null + } + + return ( + + + + + ) +} diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index c669558..56f80b4 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -4,7 +4,13 @@ import type { ExportFormat } from '../../domain/exporters/types' import { puzzleRegistry } from '../../domain/plugins/registry' import { BoardLegendButton } from '../board/BoardLegendButton' import { PuzzleInfoButton } from '../puzzleInfo/PuzzleInfoButton' -import { buildDifficultySnapshot, MAX_SOLVE_CHUNK_SIZE, useSolverStore } from './solverStore' +import { + buildDifficultySnapshot, + DEFAULT_MASYU_SAMPLE_URL, + DEFAULT_SLITHERLINK_SAMPLE_URL, + MAX_SOLVE_CHUNK_SIZE, + useSolverStore, +} from './solverStore' export const ControlPanel = () => { const { @@ -45,7 +51,7 @@ export const ControlPanel = () => { [difficulty.ruleUsage], ) const terminalCoverage = terminalReport - ? `${(terminalReport.stats.decidedEdgeRatio * 100).toFixed(1)}%` + ? `${(terminalReport.stats.decidedRatio * 100).toFixed(1)}%` : '0.0%' const terminalDurationSeconds = terminalReport ? `${(terminalReport.totalDurationMs / 1000).toFixed(2)} s` @@ -63,8 +69,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%' @@ -80,7 +86,18 @@ export const ControlPanel = () => {