support ipuz files

This commit is contained in:
cjdenio committed 2026-09-27 12:40:14 -04:00
1 parent 5d62a4b611
commit 21f9caec34
7 files changed
+250 -39

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
all: bin/crossword bin/crossword-ssh all: bin/crossword bin/crossword-ssh
bin/crossword: cmd/main.go puz/puz.go game/game.go bin/crossword: cmd/main.go puz/*.go game/game.go
go build -o $@ ./cmd go build -o $@ ./cmd
bin/crossword-ssh: ssh/main.go puz/puz.go game/game.go bin/crossword-ssh: ssh/main.go puz/*.go game/game.go
go build -o $@ ./ssh go build -o $@ ./ssh
+3 -3
View File
@@ -1,4 +1,4 @@
Very silly terminal-based crossword solver. It can play .puz files from a variety of sources, including [Crosshare](https://crosshare.org). Very silly terminal-based crossword solver. It can play .puz / .ipuz files from a variety of sources, including [Crosshare](https://crosshare.org).
- Requirements: [Go](https://go.dev) - Requirements: [Go](https://go.dev)
- Build: `make` - Build: `make`
@@ -16,5 +16,5 @@ todo
- .puz checksums - .puz checksums
- fix shift+enter / shift+tab in other terminals - fix shift+enter / shift+tab in other terminals
- clear grid - clear grid
- rebus (at least add message that rebus is not supported) - rebus
- .ipuz files - detect rebus for .puz files
+10 -2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@@ -20,10 +21,11 @@ func main() {
}() }()
debugMode := flag.Bool("debug", false, "") debugMode := flag.Bool("debug", false, "")
dumpMode := flag.Bool("dump", false, "")
flag.Parse() flag.Parse()
filename := flag.Arg(0) filename := flag.Arg(0)
file, err := os.ReadFile(filename) file, err := os.Open(filename)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -33,11 +35,17 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
puzzle, err := puz.ParsePuz(file) puzzle, err := puz.LoadPuzzle(file)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if dumpMode != nil && *dumpMode {
json.NewEncoder(os.Stdout).Encode(puzzle)
fmt.Print("\r\n")
return
}
fmt.Print("\x1b[?25l") fmt.Print("\x1b[?25l")
defer func() { defer func() {
+16
View File
@@ -29,6 +29,7 @@ type State struct {
DebugMode bool DebugMode bool
SolveState SolveState SolveState SolveState
CheckState []rune CheckState []rune
RebusConfirmation bool
} }
func NewState(puzzle *puz.Puzzle) *State { func NewState(puzzle *puz.Puzzle) *State {
@@ -38,6 +39,7 @@ func NewState(puzzle *puz.Puzzle) *State {
SelectedClue: puzzle.Clues[0], SelectedClue: puzzle.Clues[0],
SelectedCell: puzzle.Clues[0].Cells[0], SelectedCell: puzzle.Clues[0].Cells[0],
CheckState: make([]rune, len(puzzle.State)), CheckState: make([]rune, len(puzzle.State)),
RebusConfirmation: puzzle.HasRebus,
} }
} }
@@ -242,6 +244,11 @@ func (state *State) CheckWord(clue *puz.Clue) {
} }
func (state *State) RenderUI(w io.Writer) int { func (state *State) RenderUI(w io.Writer) int {
if state.RebusConfirmation {
fmt.Fprint(w, "This puzzle contains a rebus. This tool does not support rebuses.\r\nPress 'y' to continue anyway, or press any other key to exit.\r\n")
return 2
}
uiHeight := 0 uiHeight := 0
fmt.Fprintf(w, "\r\nTITLE: %s\r\n", state.Puzzle.Title) fmt.Fprintf(w, "\r\nTITLE: %s\r\n", state.Puzzle.Title)
@@ -424,6 +431,15 @@ func (state *State) HandleInput(buffer []byte) (exited bool) {
return false return false
} }
if state.RebusConfirmation {
if buffer[0] == 'y' {
state.RebusConfirmation = false
return false
} else {
return true
}
}
state.LastKeySequence = fmt.Sprintf("%v", buffer) state.LastKeySequence = fmt.Sprintf("%v", buffer)
if buffer[0] == 3 { if buffer[0] == 3 {
+154
View File
@@ -0,0 +1,154 @@
package puz
import (
"encoding/json"
"errors"
"strconv"
"strings"
)
type iPuzDimensions struct {
Width int `json:"width"`
Height int `json:"height"`
}
type iPuzClue struct {
Number int `json:"number"`
Clue string `json:"clue"`
}
type iPuzFile struct {
Title string `json:"title"`
Author string `json:"author"`
Copyright string `json:"copyright"`
Puzzle [][]any `json:"puzzle"`
Solution [][]string `json:"solution"`
Dimensions iPuzDimensions `json:"dimensions"`
Clues map[string][]iPuzClue `json:"clues"`
}
func ParseIPuz(file []byte) (*Puzzle, error) {
var parsed iPuzFile
err := json.Unmarshal(file, &parsed)
if err != nil {
return nil, err
}
puzzle := new(Puzzle)
puzzle.Title = parsed.Title
puzzle.Author = parsed.Author
puzzle.Copyright = parsed.Copyright
puzzle.Width = parsed.Dimensions.Width
puzzle.Height = parsed.Dimensions.Height
puzzle.ClueCount = len(parsed.Clues["Across"]) + len(parsed.Clues["Down"])
clueToCellMap := make(map[int][2]int)
state := strings.Builder{}
for y, row := range parsed.Puzzle {
for x, cell := range row {
switch c := cell.(type) {
case string:
if c == "#" {
state.WriteRune('.')
} else {
if i, err := strconv.Atoi(c); err == nil {
clueToCellMap[i] = [2]int{x, y}
}
state.WriteRune('-')
}
case float64:
clueToCellMap[int(c)] = [2]int{x, y}
state.WriteRune('-')
default:
state.WriteRune('-')
}
}
}
puzzle.State = state.String()
solution := strings.Builder{}
for _, row := range parsed.Solution {
for _, cell := range row {
if cell == "#" {
solution.WriteRune('.')
} else if len(cell) > 1 {
solution.WriteByte(cell[0])
puzzle.HasRebus = true
} else if len(cell) == 1 {
solution.WriteString(cell)
} else {
return nil, errors.New("invalid ipuz file")
}
}
}
puzzle.Solution = solution.String()
puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount)
puzzle.Cells = make([][2]*Clue, puzzle.Width*puzzle.Height)
for _, clue := range parsed.Clues["Across"] {
cellCoords := clueToCellMap[clue.Number]
clueCells := make([]int, 0)
clueSolution := strings.Builder{}
clueStruct := &Clue{
Clue: clue.Clue,
Direction: DirectionAcross,
Number: clue.Number,
}
for i := cellCoords[0]; i < puzzle.Width; i++ {
cell := parsed.Solution[cellCoords[1]][i]
if cell == "#" {
break
}
cellIdx := cellCoords[1]*puzzle.Width + i
clueCells = append(clueCells, cellIdx)
puzzle.Cells[cellIdx][0] = clueStruct
clueSolution.WriteByte(cell[0])
}
clueStruct.Solution = clueSolution.String()
clueStruct.Cells = clueCells
puzzle.Clues = append(puzzle.Clues, clueStruct)
}
for _, clue := range parsed.Clues["Down"] {
cellCoords := clueToCellMap[clue.Number]
clueCells := make([]int, 0)
clueSolution := strings.Builder{}
clueStruct := &Clue{
Clue: clue.Clue,
Direction: DirectionDown,
Number: clue.Number,
}
for i := cellCoords[1]; i < puzzle.Height; i++ {
cell := parsed.Solution[i][cellCoords[0]]
if cell == "#" {
break
}
cellIdx := i*puzzle.Width + cellCoords[0]
clueCells = append(clueCells, cellIdx)
puzzle.Cells[cellIdx][1] = clueStruct
clueSolution.WriteByte(cell[0])
}
clueStruct.Solution = clueSolution.String()
clueStruct.Cells = clueCells
puzzle.Clues = append(puzzle.Clues, clueStruct)
}
return puzzle, nil
}
-32
View File
@@ -11,38 +11,6 @@ import (
"golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/charmap"
) )
type Direction int
const (
DirectionAcross Direction = iota
DirectionDown
)
type Clue struct {
Clue string
Solution string
Cells []int
Direction Direction
Number int
}
type Puzzle struct {
ClueCount int
Width int
Height int
Title string
Author string
Copyright string
Solution string
State string
Clues []*Clue
Cells [][2]*Clue
}
func startOfDownClue(puzzle string, width, height, index int) bool { func startOfDownClue(puzzle string, width, height, index int) bool {
if puzzle[index] == '.' { if puzzle[index] == '.' {
return false return false
+65
View File
@@ -0,0 +1,65 @@
package puz
import (
"bufio"
"errors"
"io"
)
type Direction int
const (
DirectionAcross Direction = iota
DirectionDown
)
type Clue struct {
Clue string
Solution string
Cells []int
Direction Direction
Number int
}
type Puzzle struct {
ClueCount int
Width int
Height int
Title string
Author string
Copyright string
Solution string
State string
Clues []*Clue
Cells [][2]*Clue
HasRebus bool
}
func LoadPuzzle(r io.Reader) (*Puzzle, error) {
bufReader := bufio.NewReader(r)
header, err := bufReader.Peek(16)
if err != nil {
return nil, err
}
if string(header[2:14]) == "ACROSS&DOWN\x00" {
file, err := io.ReadAll(bufReader)
if err != nil {
return nil, err
}
return ParsePuz(file)
} else if header[0] == '{' {
file, err := io.ReadAll(bufReader)
if err != nil {
return nil, err
}
return ParseIPuz(file)
} else {
return nil, errors.New("could not detect file type")
}
}