From 21f9caec34a8bb36c79993ccadcaeb80026b22c7 Mon Sep 17 00:00:00 2001 From: Caleb Denio Date: Sun, 27 Sep 2026 12:40:14 -0400 Subject: [PATCH] support ipuz files --- Makefile | 4 +- README.md | 6 +- cmd/main.go | 12 +++- game/game.go | 42 +++++++++----- puz/ipuz.go | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++ puz/puz.go | 32 ----------- puz/shared.go | 65 +++++++++++++++++++++ 7 files changed, 263 insertions(+), 52 deletions(-) create mode 100644 puz/ipuz.go create mode 100644 puz/shared.go diff --git a/Makefile b/Makefile index 1bcfda0..3c93b08 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ 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 -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 diff --git a/README.md b/README.md index 969ff44..d46b27a 100644 --- a/README.md +++ b/README.md @@ -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) - Build: `make` @@ -16,5 +16,5 @@ todo - .puz checksums - fix shift+enter / shift+tab in other terminals - clear grid -- rebus (at least add message that rebus is not supported) -- .ipuz files +- rebus +- detect rebus for .puz files diff --git a/cmd/main.go b/cmd/main.go index ecd2777..59aa6b6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "encoding/json" "flag" "fmt" "log" @@ -20,10 +21,11 @@ func main() { }() debugMode := flag.Bool("debug", false, "") + dumpMode := flag.Bool("dump", false, "") flag.Parse() filename := flag.Arg(0) - file, err := os.ReadFile(filename) + file, err := os.Open(filename) if err != nil { log.Fatal(err) } @@ -33,11 +35,17 @@ func main() { log.Fatal(err) } - puzzle, err := puz.ParsePuz(file) + puzzle, err := puz.LoadPuzzle(file) if err != nil { log.Fatal(err) } + if dumpMode != nil && *dumpMode { + json.NewEncoder(os.Stdout).Encode(puzzle) + fmt.Print("\r\n") + return + } + fmt.Print("\x1b[?25l") defer func() { diff --git a/game/game.go b/game/game.go index b7982f5..e0a0bc6 100644 --- a/game/game.go +++ b/game/game.go @@ -21,23 +21,25 @@ const ( ) type State struct { - Puzzle *puz.Puzzle - PuzzleState []rune - SelectedCell int - SelectedClue *puz.Clue - LastKeySequence string - DebugMode bool - SolveState SolveState - CheckState []rune + Puzzle *puz.Puzzle + PuzzleState []rune + SelectedCell int + SelectedClue *puz.Clue + LastKeySequence string + DebugMode bool + SolveState SolveState + CheckState []rune + RebusConfirmation bool } func NewState(puzzle *puz.Puzzle) *State { return &State{ - Puzzle: puzzle, - PuzzleState: []rune(puzzle.State), - SelectedClue: puzzle.Clues[0], - SelectedCell: puzzle.Clues[0].Cells[0], - CheckState: make([]rune, len(puzzle.State)), + Puzzle: puzzle, + PuzzleState: []rune(puzzle.State), + SelectedClue: puzzle.Clues[0], + SelectedCell: puzzle.Clues[0].Cells[0], + 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 { + 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 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 } + if state.RebusConfirmation { + if buffer[0] == 'y' { + state.RebusConfirmation = false + return false + } else { + return true + } + } + state.LastKeySequence = fmt.Sprintf("%v", buffer) if buffer[0] == 3 { diff --git a/puz/ipuz.go b/puz/ipuz.go new file mode 100644 index 0000000..ed28f34 --- /dev/null +++ b/puz/ipuz.go @@ -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 +} diff --git a/puz/puz.go b/puz/puz.go index a5797c7..0b5f7c5 100644 --- a/puz/puz.go +++ b/puz/puz.go @@ -11,38 +11,6 @@ import ( "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 { if puzzle[index] == '.' { return false diff --git a/puz/shared.go b/puz/shared.go new file mode 100644 index 0000000..882f48c --- /dev/null +++ b/puz/shared.go @@ -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") + } +}