// examples/eight_puzzle — runnable demonstration of `@dir.astar` on the
// classic 3x3 sliding-tile puzzle (a.k.a. 8-puzzle).
//
// Fulfils Requirement 9.3 (at least 3 runnable examples) and 9.4 (`moon run`
// exits with code 0 and prints expected output). Showcases the unified
// weighted-search API from R3 on a pure state-space search problem, with a
// custom admissible heuristic (sum of Manhattan distances).
//
// Run with:
// moon run examples/eight_puzzle
//
// Board convention:
// - '0' marks the blank square.
// - Tiles '1'..'8' are the numbered pieces, indexed row-major.
// - The goal configuration is the string "123456780":
// 1 2 3
// 4 5 6
// 7 8 _
//
// Node type: we encode each 3x3 state as a 9-character `String` (digits
// '0'..'8'). `String` already satisfies `Eq + Hash`, so no custom wrapper is
// needed. This matches the design note in tasks.md 17.2:
// "MoonBit 无原生固定长度数组 hash,可以用 String".
///|
/// The goal configuration, blank in the bottom-right corner.
const GOAL : String = "123456780"
///|
/// Convert a `String` into a mutable `Array[Char]` so we can edit cells.
///
/// The 8-puzzle state is always 9 ASCII characters ('0'..'8' in some order),
/// so this is O(9) per call and never returns an empty array in practice.
fn to_chars(s : String) -> Array[Char] {
let out : Array[Char] = []
for ch in s {
out.push(ch)
}
out
}
///|
/// Rebuild a `String` from an `Array[Char]` of arbitrary length.
///
/// Used after editing the 9-character state array to produce the new node
/// value for A* — `String` is what we hash and compare on.
fn from_chars(chars : Array[Char]) -> String {
let buf = StringBuilder::new()
for ch in chars {
buf.write_char(ch)
}
buf.to_string()
}
///|
/// Map a digit character '0'..'8' to its integer value.
///
/// Any other character is treated as `-1`, which signals corrupt input and
/// will never occur for well-formed 8-puzzle states.
fn char_to_digit(ch : Char) -> Int {
match ch {
'0' => 0
'1' => 1
'2' => 2
'3' => 3
'4' => 4
'5' => 5
'6' => 6
'7' => 7
'8' => 8
_ => -1
}
}
///|
/// Locate the blank square (the '0' tile) within a state string.
///
/// Returns the 0-based index into the 9-character state. For well-formed
/// inputs there is always exactly one blank, so `-1` is never returned, but
/// we still handle it defensively to keep the search from crashing on bad
/// data (consistent with Requirement 1.4 — return `None` / no-op rather than
/// throw).
fn find_blank(state : String) -> Int {
let chars = to_chars(state)
for i in 0.. String {
let chars = to_chars(state)
let tmp = chars[blank]
chars[blank] = chars[target]
chars[target] = tmp
from_chars(chars)
}
///|
/// Enumerate all legal successor states reachable in one move.
///
/// Returns an `Array[(String, Int)]` where each element is `(next_state,
/// edge_weight)` as required by the weighted `@dir.astar` API. Every move in
/// the 8-puzzle has unit cost, so the edge weight is always `1` (so path
/// cost = number of moves).
fn neighbors(state : String) -> Array[(String, Int)] {
let blank = find_blank(state)
if blank < 0 {
return []
}
let row = blank / 3
let col = blank % 3
let moves : Array[(Int, Int)] = [
(row - 1, col),
(row + 1, col),
(row, col - 1),
(row, col + 1),
]
let out : Array[(String, Int)] = []
for m in moves {
let (r, c) = m
if r >= 0 && r < 3 && c >= 0 && c < 3 {
let target = r * 3 + c
out.push((swap_state(state, blank, target), 1))
}
}
out
}
///|
/// Manhattan-distance heuristic, summed over all non-blank tiles.
///
/// For each tile 1..8 we compute the grid distance between its current
/// location and its goal location in "123456780". This is admissible (each
/// tile needs at least that many moves to reach its home) and consistent, so
/// `@dir.astar` is guaranteed to return an optimal solution — exactly the
/// property exercised by this example.
fn manhattan(state : String) -> Int {
let chars = to_chars(state)
let mut total = 0
for i in 0.. String {
let chars = to_chars(state)
let buf = StringBuilder::new()
for r in 0..<3 {
for c in 0..<3 {
let ch = chars[r * 3 + c]
let display = if ch == '0' { '_' } else { ch }
buf.write_char(display)
if c < 2 {
buf.write_char(' ')
}
}
buf.write_char('\n')
}
buf.to_string()
}
///|
/// A named 8-puzzle scenario. `print_each_step` controls whether the solver
/// dumps every intermediate board (short runs only; the 15-20 step scenario
/// is noisy if this is on).
struct Scenario {
name : String
initial : String
print_each_step : Bool
}
///|
/// Solve one scenario with `@dir.astar` + Manhattan heuristic, then print a
/// human-readable report.
///
/// Output layout (in order):
/// 1. title line;
/// 2. initial board;
/// 3. heuristic value at the start (for sanity);
/// 4. either the solved step count (and optionally every intermediate
/// board) or a "no solution" diagnostic.
///
/// The function always returns `Unit` — any internal failure is reported
/// textually rather than thrown, so `moon run` still exits with code 0 as
/// required by Requirement 9.4.
fn solve_and_print(scenario : Scenario) -> Unit {
println("=== " + scenario.name + " ===")
println("Initial board:")
println(render_board(scenario.initial))
println(
"Heuristic (sum of Manhattan dist.) = " +
manhattan(scenario.initial).to_string(),
)
let result = @dir.astar(
scenario.initial,
fn(s) { neighbors(s) },
fn(s) { manhattan(s) },
fn(s) { s == GOAL },
)
match result {
Some((path, cost)) => {
// `path` contains one state per move plus the initial, so step count
// is `path.length() - 1`. `cost` is the A* total edge weight which,
// given unit edges, equals the step count — we print both to make the
// weighted-search API visible even for uniform-cost problems.
let steps = path.length() - 1
println(
"Solved in " +
steps.to_string() +
" moves (A* total cost = " +
cost.to_string() +
").",
)
if scenario.print_each_step {
println("Solution trace:")
for i in 0.. println("No solution found (input may be unsolvable).")
}
println("")
}
///|
/// Predefined puzzle scenarios exercising three difficulty tiers.
///
/// All three initial configurations are reachable from `GOAL` (they satisfy
/// the parity-of-inversions solvability rule for the 3x3 sliding puzzle).
///
/// * Scenario 1 — Easy (1 move): one tile out of place.
/// * Scenario 2 — Medium (6 moves): derived by 6 non-reversing scrambles.
/// * Scenario 3 — Challenging (optimal ~15-20 moves): Manhattan
/// heuristic lower bound is 14, the actual optimum ends up at 20.
///
/// The optimal move count is determined by A* itself at run time — the
/// commentary above states the expected range, while the program prints the
/// actual cost.
fn scenarios() -> Array[Scenario] {
[
{
name: "Scenario 1: easy (1-step swap)",
initial: "123450786",
print_each_step: true,
},
{
name: "Scenario 2: medium (~6 moves)",
initial: "152743086",
print_each_step: true,
},
{
name: "Scenario 3: challenging (Manhattan >= 12)",
initial: "724506831",
print_each_step: false,
},
]
}
///|
/// Program entry point. Runs every predefined scenario in order and prints
/// a short report. The process always exits with code 0 (no exceptions
/// thrown), satisfying Requirement 9.4.
fn main {
println("moonbit-pathfinding · eight_puzzle example")
println("Algorithm: A* with sum-of-Manhattan-distances heuristic")
println("Node type: String (9 chars, digits '0'..'8', '0' is blank)")
println("Goal state: \"" + GOAL + "\" (blank bottom-right)")
println("")
for scenario in scenarios() {
solve_and_print(scenario)
}
}