Compare commits

...
3 Commits
Author SHA1 Message Date
cjdenio 60d1bd644c reveal/check/clear 2026-09-29 22:26:49 -04:00
cjdenio 50be9ab605 refactor .puz parser + detect rebuses 2026-09-29 21:20:13 -04:00
cjdenio b7c64dabe3 fix ipuz bug 2026-09-29 14:39:48 -04:00
4 changed files with 274 additions and 54 deletions

No files matched your search

-3
View File
@@ -11,9 +11,6 @@ Very silly terminal-based crossword solver. It can play .puz / .ipuz files from
todo todo
- timer - timer
- reveal grid/word
- .puz checksums - .puz checksums
- fix shift+enter / shift+tab in other terminals - fix shift+enter / shift+tab in other terminals
- clear grid
- rebus - rebus
- detect rebus for .puz files
+118 -25
View File
@@ -20,16 +20,26 @@ const (
Solved Solved
) )
type UIState int
const (
StateGame UIState = iota
StateRebusConfirmation
StateMenu
StateRevealMenu
StateCheckMenu
)
type State struct { type State struct {
Puzzle *puz.Puzzle Puzzle *puz.Puzzle
PuzzleState []rune PuzzleState []rune
SelectedCell int SelectedCell int
SelectedClue *puz.Clue SelectedClue *puz.Clue
LastKeySequence string LastKeySequence string
DebugMode bool DebugMode bool
SolveState SolveState SolveState SolveState
CheckState []rune CheckState []rune
RebusConfirmation bool UIState UIState
} }
func RenderWithLineWrap(s string, width int) (out string, lines int) { func RenderWithLineWrap(s string, width int) (out string, lines int) {
@@ -74,13 +84,17 @@ func RenderWithLineWrap(s string, width int) (out string, lines int) {
} }
func NewState(puzzle *puz.Puzzle) *State { func NewState(puzzle *puz.Puzzle) *State {
state := StateGame
if puzzle.HasRebus {
state = StateRebusConfirmation
}
return &State{ return &State{
Puzzle: puzzle, Puzzle: puzzle,
PuzzleState: []rune(puzzle.State), PuzzleState: []rune(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, UIState: state,
} }
} }
@@ -284,8 +298,25 @@ func (state *State) CheckWord(clue *puz.Clue) {
} }
} }
func (state *State) RevealWord(clue *puz.Clue) {
for i, cellIdx := range clue.Cells {
state.PuzzleState[cellIdx] = rune(clue.Solution[i])
state.CheckState[cellIdx] = 'y'
}
}
func (state *State) RevealPuzzle() {
for i, cell := range state.PuzzleState {
if cell == '.' {
continue
}
state.PuzzleState[i] = rune(state.Puzzle.Solution[i])
state.CheckState[i] = 'y'
}
}
func (state *State) RenderUI(w io.Writer, width int) int { func (state *State) RenderUI(w io.Writer, width int) int {
if state.RebusConfirmation { if state.UIState == StateRebusConfirmation {
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") 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 return 2
} }
@@ -313,6 +344,21 @@ func (state *State) RenderUI(w io.Writer, width int) int {
fmt.Fprintf(w, "%s\r\n", state.Puzzle.Copyright) fmt.Fprintf(w, "%s\r\n", state.Puzzle.Copyright)
uiHeight += 1 uiHeight += 1
switch state.UIState {
case StateGame:
fmt.Fprintln(w, AnsiDimmed("\r\n[enter]/[tab]: next word | [ctrl+a]: menu | [ctrl+c]: exit"))
uiHeight += 2
case StateMenu:
fmt.Fprintln(w, AnsiDimmed("\r\n[c]: check puzzle/word | [r]: reveal puzzle/word | [x]: clear grid | [q]: exit menu"))
uiHeight += 2
case StateCheckMenu:
fmt.Fprintln(w, AnsiDimmed("\r\n[w]: check word | [p]: check puzzle | [q]: exit menu"))
uiHeight += 2
case StateRevealMenu:
fmt.Fprintln(w, AnsiDimmed("\r\n[w]: reveal word | [p]: reveal puzzle | [q]: exit menu"))
uiHeight += 2
}
switch state.SolveState { switch state.SolveState {
case FilledNotSolved: case FilledNotSolved:
fmt.Fprintf(w, "\r\n%s\r\n", AnsiRed("The puzzle was filled, but at least 1 letter is incorrect...")) fmt.Fprintf(w, "\r\n%s\r\n", AnsiRed("The puzzle was filled, but at least 1 letter is incorrect..."))
@@ -471,26 +517,73 @@ func (state *State) SwitchDirections() bool {
return false return false
} }
func (state *State) ClearGrid() {
for i, cell := range state.PuzzleState {
if cell != '.' {
state.PuzzleState[i] = '-'
state.CheckState[i] = '\x00'
}
}
}
func (state *State) HandleInput(buffer []byte) (exited bool) { func (state *State) HandleInput(buffer []byte) (exited bool) {
if len(buffer) == 0 { if len(buffer) == 0 {
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 {
return true return true
} }
switch state.UIState {
case StateRebusConfirmation:
if buffer[0] == 'y' {
state.UIState = StateGame
return false
} else {
return true
}
case StateMenu:
switch buffer[0] {
case 'c':
state.UIState = StateCheckMenu
case 'r':
state.UIState = StateRevealMenu
case 'x':
state.ClearGrid()
state.UIState = StateGame
default:
state.UIState = StateGame
}
return false
case StateCheckMenu:
switch buffer[0] {
case 'w':
state.CheckWord(state.SelectedClue)
case 'p':
state.CheckPuzzle()
}
state.UIState = StateGame
return false
case StateRevealMenu:
switch buffer[0] {
case 'w':
state.RevealWord(state.SelectedClue)
case 'p':
state.RevealPuzzle()
return true
}
state.UIState = StateGame
return false
}
if buffer[0] == '\x01' {
state.UIState = StateMenu
return false
}
if buffer[0] == ' ' { if buffer[0] == ' ' {
state.SwitchDirections() state.SwitchDirections()
} }
+30 -12
View File
@@ -27,6 +27,27 @@ type iPuzFile struct {
Clues map[string][]iPuzClue `json:"clues"` Clues map[string][]iPuzClue `json:"clues"`
} }
func unwrapCell(cell any) (any, error) {
switch c := cell.(type) {
case string:
if c == "#" {
return "#", nil
} else if i, err := strconv.Atoi(c); err == nil {
return i, nil
} else {
return nil, errors.New("invalid string")
}
case float64:
return int(c), nil
case map[string]any:
if unwrappedCell, ok := c["cell"]; ok {
return unwrapCell(unwrappedCell) // recursion babyyyyyy
}
}
return nil, errors.New("unknown type")
}
func ParseIPuz(file []byte) (*Puzzle, error) { func ParseIPuz(file []byte) (*Puzzle, error) {
var parsed iPuzFile var parsed iPuzFile
err := json.Unmarshal(file, &parsed) err := json.Unmarshal(file, &parsed)
@@ -56,21 +77,18 @@ func ParseIPuz(file []byte) (*Puzzle, error) {
state := strings.Builder{} state := strings.Builder{}
for y, row := range parsed.Puzzle { for y, row := range parsed.Puzzle {
for x, cell := range row { for x, cell := range row {
switch c := cell.(type) { unwrappedCell, err := unwrapCell(cell)
if err != nil {
return nil, err
}
switch c := unwrappedCell.(type) {
case string: case string:
if c == "#" { state.WriteRune('.')
state.WriteRune('.') case int:
} else { clueToCellMap[c] = [2]int{x, y}
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('-') state.WriteRune('-')
default: default:
state.WriteRune('-') return nil, errors.New("unexpected type in .ipuz file")
} }
} }
} }
+126 -14
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors" "errors"
"io"
"math" "math"
"strings" "strings"
@@ -61,33 +62,126 @@ func readText(b *bufio.Reader, version string) (string, error) {
} }
} }
type ExtraBlock struct {
Name string
Data []byte
}
func readExtraBlock(r *bufio.Reader) (*ExtraBlock, error) {
name := make([]byte, 4)
_, err := r.Read(name)
if err != nil {
return nil, err
}
length := make([]byte, 2)
_, err = r.Read(length)
if err != nil {
return nil, err
}
_, err = r.Discard(2)
if err != nil {
return nil, err
}
data := make([]byte, binary.LittleEndian.Uint16(length))
_, err = r.Read(data)
if err != nil {
return nil, err
}
// consume trailing null byte
_, err = r.Discard(1)
if err != nil {
return nil, err
}
return &ExtraBlock{
Name: string(name),
Data: data,
}, nil
}
func ParsePuz(file []byte) (*Puzzle, error) { func ParsePuz(file []byte) (*Puzzle, error) {
r := bytes.NewReader(file)
_, err := r.Seek(2, io.SeekStart) // consume the checksum
if err != nil {
return nil, err
}
// read magic bytes // read magic bytes
magic := string(file[2:14]) magic := make([]byte, 12)
if magic != "ACROSS&DOWN\x00" { _, err = r.Read(magic)
if err != nil {
return nil, err
}
if !bytes.Equal(magic, []byte("ACROSS&DOWN\x00")) {
return nil, errors.New("not a valid .puz file") return nil, errors.New("not a valid .puz file")
} }
puzzle := new(Puzzle) puzzle := new(Puzzle)
_, err = r.Seek(10, io.SeekCurrent)
if err != nil {
return nil, err
}
// read header // read header
version := string(file[0x18:0x1B]) version := make([]byte, 4)
puzzle.Width = int(file[0x2C]) _, err = r.Read(version)
puzzle.Height = int(file[0x2D]) if err != nil {
puzzle.ClueCount = int(binary.LittleEndian.Uint16(file[0x2E:0x30])) return nil, err
}
_, err = r.Seek(16, io.SeekCurrent)
if err != nil {
return nil, err
}
width, err := r.ReadByte()
if err != nil {
return nil, err
}
height, err := r.ReadByte()
if err != nil {
return nil, err
}
puzzle.Width = int(width)
puzzle.Height = int(height)
clueCount := make([]byte, 2)
_, err = r.Read(clueCount)
if err != nil {
return nil, err
}
puzzle.ClueCount = int(binary.LittleEndian.Uint16(clueCount))
puzzleSize := puzzle.Width * puzzle.Height puzzleSize := puzzle.Width * puzzle.Height
solutionStart := 0x34
solutionEnd := 0x34 + puzzleSize
stateStart := solutionEnd _, err = r.Seek(4, io.SeekCurrent)
stateEnd := stateStart + puzzleSize if err != nil {
return nil, err
}
puzzle.Solution = string(file[solutionStart:solutionEnd]) solution := make([]byte, puzzleSize)
puzzle.State = string(file[stateStart:stateEnd]) _, err = r.Read(solution)
if err != nil {
return nil, err
}
state := make([]byte, puzzleSize)
_, err = r.Read(state)
if err != nil {
return nil, err
}
// read text puzzle.Solution = string(solution)
buf := bufio.NewReader(bytes.NewReader(file[stateEnd:])) puzzle.State = string(state)
// read text fields
buf := bufio.NewReader(r)
title, err := readText(buf, string(version[0])) title, err := readText(buf, string(version[0]))
if err != nil { if err != nil {
@@ -117,6 +211,24 @@ func ParsePuz(file []byte) (*Puzzle, error) {
clues = append(clues, clue) clues = append(clues, clue)
} }
// read the "note" field, currently unused
_, err = readText(buf, string(version[0]))
if err != nil {
return nil, err
}
for {
extra, err := readExtraBlock(buf)
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, err
}
if extra.Name == "RTBL" {
puzzle.HasRebus = true
}
}
// assign clue numbers // assign clue numbers
puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount) puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount)
puzzle.Cells = make([][2]*Clue, len(puzzle.Solution)) puzzle.Cells = make([][2]*Clue, len(puzzle.Solution))