Compare commits

...
21 Commits
Author SHA1 Message Date
cjdenio d50b572fb5 license 2026-10-02 09:11:04 -06:00
cjdenio 7b35f86e32 fix issues 2026-10-01 16:07:45 -06:00
cjdenio 419f0ae3cf handle empty copyright 2026-10-01 10:40:54 -06:00
cjdenio b918c5be23 parse rebus cells 2026-09-29 22:50:24 -04:00
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
cjdenio c139490dc2 handle line breaks in clues 2026-09-29 13:47:29 -04:00
cjdenio 21f9caec34 support ipuz files 2026-09-27 12:40:14 -04:00
cjdenio 5d62a4b611 check word 2026-09-24 15:36:49 -06:00
cjdenio 176c3997cd better readme 2026-09-23 08:19:39 -06:00
cjdenio 4f92cdd941 make it possible to check puzzle in the future 2026-09-22 22:17:50 -06:00
cjdenio 143020a21d use "enum" for solve state 2026-09-17 13:50:57 -06:00
cjdenio 644ce0d273 fix bug 2026-09-11 22:00:00 -05:00
cjdenio c1d024a97e fix text encoding for utf-8 puz files 2026-09-11 17:38:37 -05:00
cjdenio 0f1da4c461 some SSH logging 2026-09-11 14:35:53 -05:00
cjdenio e6798b1a67 jump backwards 2026-09-11 14:25:51 -05:00
cjdenio 6a83b1c263 fix 100% cpu usage 2026-09-10 11:43:37 -05:00
cjdenio 1ae13edd35 makefile + small stuff 2026-09-10 10:11:42 -05:00
cjdenio a87433ce9e fix crash 2026-09-09 18:33:46 -05:00
cjdenio 6109f70a45 ssh 2026-09-09 18:00:16 -05:00
14 changed files with 1376 additions and 612 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Caleb Denio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
all: bin/crossword bin/crossword-ssh
bin/crossword: cmd/main.go puz/*.go game/game.go
go build -o $@ ./cmd
bin/crossword-ssh: ssh/main.go puz/*.go game/game.go
go build -o $@ ./ssh
+13 -2
View File
@@ -1,5 +1,16 @@
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`
- Usage: `./bin/crossword <file>.puz`
![demo](demo.gif)
---
todo
- fix bug with that one 6x6 puzzle
- correctly calculate uiHeight with multi-line clues
- timer
- .puz checksums
- fix shift+enter / shift+tab in other terminals
- rebus
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"github.com/cjdenio/crossword/game"
"github.com/cjdenio/crossword/puz"
"golang.org/x/term"
)
func getTermWidth() int {
w, _, _ := term.GetSize(int(os.Stdin.Fd()))
return w
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Print("!!! PROGRAM CRASHED !!! Error:\r\n", r, "\r\n")
}
}()
debugMode := flag.Bool("debug", false, "")
dumpMode := flag.Bool("dump", false, "")
flag.Parse()
filename := flag.Arg(0)
file, err := os.Open(filename)
if err != nil {
log.Fatal(err)
}
fileInfo, err := os.Stat(filename)
if err != nil {
log.Fatal(err)
}
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() {
fmt.Print("\x1b[?25h")
}()
termState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
log.Fatal(err)
}
defer term.Restore(int(os.Stdin.Fd()), termState)
state := game.NewState(puzzle)
if debugMode != nil {
state.DebugMode = *debugMode
}
// try to load a saveFile
saveFile, err := os.ReadFile(filename + ".save")
if err == nil {
err = state.LoadSaveFile(saveFile)
if state.DebugMode && err != nil {
state.LastKeySequence = fmt.Sprintf("failed to read savefile: %s", err)
}
}
uiHeight := state.RenderUI(os.Stdout, getTermWidth())
scanner := bufio.NewScanner(os.Stdin)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if len(data) == 0 {
return 0, nil, bufio.ErrFinalToken
}
if data[0] == 0x1b {
if len(data) < 3 {
return 0, nil, nil
}
return 3, data[0:3], nil
}
return 1, data[0:1], nil
})
for {
scanner.Scan()
buffer := scanner.Bytes()
exited := state.HandleInput(buffer)
if exited {
saveFile, err := state.CreateSaveFile()
if err == nil {
err = os.WriteFile(filename+".save", saveFile, fileInfo.Mode())
if err != nil && state.DebugMode {
state.LastKeySequence = fmt.Sprintf("failed to write savefile: %s", err)
}
}
state.SelectedClue = nil
state.SelectedCell = -1
fmt.Printf("\r\x1b[%dA", uiHeight)
fmt.Print("\x1b[J")
state.RenderUI(os.Stdout, getTermWidth())
return
}
fmt.Printf("\r\x1b[%dA", uiHeight)
fmt.Print("\x1b[J")
uiHeight = state.RenderUI(os.Stdout, getTermWidth())
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

-347
View File
@@ -1,347 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/cjdenio/crossword/puz"
)
type State struct {
Puzzle *puz.Puzzle
PuzzleState []rune
SelectedCell int
SelectedClue *puz.Clue
Goodbye bool
LastKeySequence string
DebugMode bool
SolveState int
}
func (state *State) MoveCursor(direction int) {
switch direction {
case 0: // up
if state.SelectedCell < state.Puzzle.Width { // if already in the top row, do nothing
return
}
for i := state.SelectedCell - state.Puzzle.Width; i >= 0; i -= state.Puzzle.Width {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 1: // right
if (state.SelectedCell+1)%state.Puzzle.Width == 0 { // if already in the right column, do nothing
return
}
for i := state.SelectedCell + 1; i%state.Puzzle.Width != 0; i += 1 {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 2: // down
if (state.SelectedCell) >= (state.Puzzle.Width*state.Puzzle.Height)-state.Puzzle.Width { // if already in the bottom row, do nothing
return
}
for i := state.SelectedCell + state.Puzzle.Width; i < (state.Puzzle.Width * state.Puzzle.Height); i += state.Puzzle.Width {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 3: // left
if state.SelectedCell%state.Puzzle.Width == 0 { // if already in the left column, do nothing
return
}
for i := state.SelectedCell - 1; (i+1)%state.Puzzle.Width != 0; i -= 1 {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
}
}
func (state *State) ClueFilled(clue *puz.Clue) bool {
for _, cell := range clue.Cells {
if state.PuzzleState[cell] == '-' {
return false
}
}
return true
}
func (state *State) FirstUnfilledCellForClue(clue *puz.Clue) int {
for _, cell := range clue.Cells {
if state.PuzzleState[cell] == '-' {
return cell
}
}
return clue.Cells[0]
}
func (state *State) NextWord() {
if state.SelectedClue == nil {
return
}
foundSelectedClue := false
for _, clue := range state.Puzzle.Clues {
if !foundSelectedClue && clue == state.SelectedClue {
foundSelectedClue = true
continue
}
if foundSelectedClue && clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for _, clue := range state.Puzzle.Clues {
if clue.Direction != state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for _, clue := range state.Puzzle.Clues {
if clue == state.SelectedClue {
return
} else if clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
}
func (state *State) PreviousWord() {
if state.SelectedClue == nil {
return
}
foundSelectedClue := false
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if !foundSelectedClue && clue == state.SelectedClue {
foundSelectedClue = true
continue
}
if foundSelectedClue && clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if clue.Direction != state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if clue == state.SelectedClue {
return
} else if clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
}
func (state *State) GridFilled() bool {
return !slices.Contains(state.PuzzleState, '-')
}
func (state *State) CheckPuzzle() bool {
for i, cell := range state.PuzzleState {
if cell == '.' {
continue
}
if cell != rune(state.Puzzle.Solution[i]) {
return false
}
}
return true
}
func (state *State) RenderUI() int {
selectedClueCells := []int{}
if state.SelectedClue != nil {
selectedClueCells = state.SelectedClue.Cells
}
uiHeight := 0
fmt.Printf("\r\nTITLE: %s\r\n", state.Puzzle.Title)
uiHeight += 2
fmt.Printf("AUTHOR: %s\r\n", state.Puzzle.Author)
uiHeight += 1
fmt.Print(RenderPuzzle(state.PuzzleState, state.Puzzle.Width, state.Puzzle.Height, selectedClueCells, state.SelectedCell) + "\r\n")
uiHeight += state.Puzzle.Height + 3
if state.SelectedClue != nil {
if state.SelectedClue.Direction == puz.DirectionAcross {
fmt.Printf("%d-across: %s\r\n\r\n", state.SelectedClue.Number, state.SelectedClue.Clue)
} else {
fmt.Printf("%d-down: %s\r\n\r\n", state.SelectedClue.Number, state.SelectedClue.Clue)
}
uiHeight += 2
}
fmt.Printf("%s\r\n", state.Puzzle.Copyright)
uiHeight += 1
switch state.SolveState {
case 1:
fmt.Printf("\r\n%s\r\n", AnsiRed("The puzzle was filled, but at least 1 letter is incorrect..."))
uiHeight += 2
case 2:
fmt.Printf("\r\n%s\r\n", AnsiGreen("The puzzle was solved!"))
uiHeight += 2
}
if state.LastKeySequence != "" && state.DebugMode {
fmt.Printf("\r\n%s\r\n", AnsiDimmed(state.LastKeySequence))
uiHeight += 2
}
if state.Goodbye {
fmt.Print("\r\nsee ya\r\n")
uiHeight += 2
}
return uiHeight
}
const (
AnsiInvert string = "\x1b[7m"
AnsiReset string = "\x1b[m"
AnsiWhiteBackground string = "\x1b[100m"
)
func AnsiInverted(s string) string {
return AnsiInvert + s + AnsiReset
}
func AnsiWhiteBackgrounded(s string) string {
return AnsiWhiteBackground + s + AnsiReset
}
func AnsiDimmed(s string) string {
return fmt.Sprintf("\x1b[2m%s\x1b[0m", s)
}
func AnsiGreen(s string) string {
return fmt.Sprintf("\x1b[32m%s\x1b[0m", s)
}
func AnsiRed(s string) string {
return fmt.Sprintf("\x1b[31m%s\x1b[0m", s)
}
func RenderPuzzle(puzzle []rune, width, height int, selectedClueCells []int, selectedCell int) string {
b := strings.Builder{}
b.WriteRune('┌')
for range (width * 2) + 1 {
b.WriteRune('─')
}
b.WriteString("┐\r\n")
for index, char := range puzzle {
if index%width == 0 {
b.WriteString("│ ")
}
cell := ""
switch char {
case '.':
cell = string(rune(0x2588))
case '-':
cell = AnsiDimmed("_")
default:
cell = string(char)
}
if selectedCell == index {
cell = AnsiInverted(cell)
} else if slices.Contains(selectedClueCells, index) {
cell = AnsiWhiteBackgrounded(cell)
}
b.WriteString(cell)
if (index+1)%width == 0 {
b.WriteString(" │\r\n")
} else if char == '.' && puzzle[index+1] == '.' {
b.WriteRune(0x2588)
} else {
// inefficient
if slices.Contains(selectedClueCells, index) && slices.Contains(selectedClueCells, index+1) {
b.WriteString(AnsiWhiteBackgrounded(" "))
} else {
b.WriteRune(' ')
}
}
}
b.WriteRune('└')
for range (width * 2) + 1 {
b.WriteRune('─')
}
b.WriteString("┘\r\n")
return b.String()
}
type SaveFile struct {
State string `json:"state"`
}
func (state *State) CreateSaveFile() ([]byte, error) {
return json.Marshal(SaveFile{
State: string(state.PuzzleState),
})
}
func (state *State) LoadSaveFile(f []byte) error {
var save SaveFile
err := json.Unmarshal(f, &save)
if err != nil {
return err
}
// verify the save file matches the shape of the puzzle
if len(save.State) != state.Puzzle.Width*state.Puzzle.Height {
return errors.New("save is invalid")
}
for i, cell := range state.Puzzle.Solution {
if (save.State[i] == '.' && cell != '.') || (save.State[i] != '.' && cell == '.') {
return errors.New("save is invalid")
}
}
state.PuzzleState = []rune(save.State)
return nil
}
+681
View File
@@ -0,0 +1,681 @@
package game
import (
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"unicode"
"github.com/cjdenio/crossword/puz"
)
type SolveState int
const (
Unsolved SolveState = iota
FilledNotSolved
Solved
)
type UIState int
const (
StateGame UIState = iota
StateRebusConfirmation
StateMenu
StateRevealMenu
StateCheckMenu
)
type State struct {
Puzzle *puz.Puzzle
PuzzleState []rune
SelectedCell int
SelectedClue *puz.Clue
LastKeySequence string
DebugMode bool
SolveState SolveState
CheckState []rune
UIState UIState
}
func RenderWithLineWrap(s string, width int) (out string, lines int) {
builder := strings.Builder{}
runes := []rune(s)
counter := 0
lines = 1
for i := 0; i < len(runes); i++ {
char := runes[i]
if char == '\n' {
builder.WriteString("\r\n")
lines++
counter = 0
continue
}
if char == '\r' && i < len(runes)-1 && runes[i+1] == '\n' {
builder.WriteString("\r\n")
lines++
counter = 0
i++
continue
}
builder.WriteRune(char)
counter++
if counter >= width && i < len(runes)-1 {
lines++
builder.WriteString("\r\n")
counter = 0
}
}
builder.WriteString("\r\n")
out = builder.String()
return
}
func NewState(puzzle *puz.Puzzle) *State {
state := StateGame
if puzzle.HasRebus {
state = StateRebusConfirmation
}
return &State{
Puzzle: puzzle,
PuzzleState: []rune(puzzle.State),
SelectedClue: puzzle.Clues[0],
SelectedCell: puzzle.Clues[0].Cells[0],
CheckState: make([]rune, len(puzzle.State)),
UIState: state,
}
}
func (state *State) MoveCursor(direction int) {
switch direction {
case 0: // up
if state.SelectedCell < state.Puzzle.Width { // if already in the top row, do nothing
return
}
for i := state.SelectedCell - state.Puzzle.Width; i >= 0; i -= state.Puzzle.Width {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 1: // right
if (state.SelectedCell+1)%state.Puzzle.Width == 0 { // if already in the right column, do nothing
return
}
for i := state.SelectedCell + 1; i%state.Puzzle.Width != 0; i += 1 {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 2: // down
if (state.SelectedCell) >= (state.Puzzle.Width*state.Puzzle.Height)-state.Puzzle.Width { // if already in the bottom row, do nothing
return
}
for i := state.SelectedCell + state.Puzzle.Width; i < (state.Puzzle.Width * state.Puzzle.Height); i += state.Puzzle.Width {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
case 3: // left
if state.SelectedCell%state.Puzzle.Width == 0 { // if already in the left column, do nothing
return
}
for i := state.SelectedCell - 1; (i+1)%state.Puzzle.Width != 0; i -= 1 {
if state.PuzzleState[i] != '.' {
state.SelectedCell = i
return
}
}
}
}
func (state *State) ClueFilled(clue *puz.Clue) bool {
for _, cell := range clue.Cells {
if state.PuzzleState[cell] == '-' {
return false
}
}
return true
}
func (state *State) FirstUnfilledCellForClue(clue *puz.Clue) int {
for _, cell := range clue.Cells {
if state.PuzzleState[cell] == '-' {
return cell
}
}
return clue.Cells[0]
}
func (state *State) NextWord() {
if state.SelectedClue == nil {
return
}
foundSelectedClue := false
for _, clue := range state.Puzzle.Clues {
if !foundSelectedClue && clue == state.SelectedClue {
foundSelectedClue = true
continue
}
if foundSelectedClue && clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for _, clue := range state.Puzzle.Clues {
if clue.Direction != state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for _, clue := range state.Puzzle.Clues {
if clue == state.SelectedClue {
return
} else if clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
}
func (state *State) PreviousWord() {
if state.SelectedClue == nil {
return
}
foundSelectedClue := false
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if !foundSelectedClue && clue == state.SelectedClue {
foundSelectedClue = true
continue
}
if foundSelectedClue && clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if clue.Direction != state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
for i := len(state.Puzzle.Clues) - 1; i >= 0; i-- {
clue := state.Puzzle.Clues[i]
if clue == state.SelectedClue {
return
} else if clue.Direction == state.SelectedClue.Direction && !state.ClueFilled(clue) {
state.SelectedClue = clue
state.SelectedCell = state.FirstUnfilledCellForClue(clue)
return
}
}
}
func (state *State) GridFilled() bool {
return !slices.Contains(state.PuzzleState, '-')
}
func (state *State) PuzzleSolved() bool {
for i, cell := range state.PuzzleState {
if cell == '.' {
continue
}
if cell != rune(state.Puzzle.Solution[i]) {
return false
}
}
return true
}
func (state *State) CheckPuzzle() {
result := make([]rune, len(state.PuzzleState))
for i, cell := range state.PuzzleState {
if cell == '.' || cell == '-' {
continue
}
if cell == rune(state.Puzzle.Solution[i]) {
result[i] = 'y'
} else {
result[i] = 'n'
}
}
state.CheckState = result
}
func (state *State) CheckWord(clue *puz.Clue) {
for i, cellIdx := range clue.Cells {
cellState := state.PuzzleState[cellIdx]
if cellState == '-' || cellState == '.' {
continue
}
if cellState == rune(clue.Solution[i]) {
state.CheckState[cellIdx] = 'y'
} else {
state.CheckState[cellIdx] = 'n'
}
}
}
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 {
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")
return 2
}
uiHeight := 0
fmt.Fprintf(w, "\r\nTITLE: %s\r\n", state.Puzzle.Title)
uiHeight += 2
fmt.Fprintf(w, "AUTHOR: %s\r\n", state.Puzzle.Author)
uiHeight += 1
fmt.Fprint(w, state.RenderPuzzle()+"\r\n")
uiHeight += state.Puzzle.Height + 3
if state.SelectedClue != nil {
directionStr := "down"
if state.SelectedClue.Direction == puz.DirectionAcross {
directionStr = "across"
}
clueStr, clueLines := RenderWithLineWrap(fmt.Sprintf("%d-%s: %s", state.SelectedClue.Number, directionStr, state.SelectedClue.Clue), width)
w.Write([]byte(clueStr))
uiHeight += clueLines
io.WriteString(w, "\r\n")
uiHeight += 1
}
if state.Puzzle.Copyright != "" {
fmt.Fprintf(w, "%s\r\n\r\n", state.Puzzle.Copyright)
uiHeight += 2
}
switch state.UIState {
case StateGame:
fmt.Fprint(w, AnsiDimmed("[enter]/[tab]: next word | [ctrl+a]: menu | [ctrl+c]: exit\r\n"))
uiHeight += 1
case StateMenu:
fmt.Fprint(w, AnsiDimmed("[c]: check puzzle/word | [r]: reveal puzzle/word | [x]: clear grid | [q]: exit menu\r\n"))
uiHeight += 1
case StateCheckMenu:
fmt.Fprint(w, AnsiDimmed("[w]: check word | [p]: check puzzle | [q]: exit menu\r\n"))
uiHeight += 1
case StateRevealMenu:
fmt.Fprint(w, AnsiDimmed("[w]: reveal word | [p]: reveal puzzle | [q]: exit menu\r\n"))
uiHeight += 1
}
switch state.SolveState {
case FilledNotSolved:
fmt.Fprintf(w, "\r\n%s\r\n", AnsiRed("The puzzle was filled, but at least 1 letter is incorrect..."))
uiHeight += 2
case Solved:
fmt.Fprintf(w, "\r\n%s\r\n", AnsiGreen("The puzzle was solved!"))
uiHeight += 2
}
if state.LastKeySequence != "" && state.DebugMode {
fmt.Fprintf(w, "\r\n%s\r\n", AnsiDimmed(state.LastKeySequence))
uiHeight += 2
}
return uiHeight
}
const (
AnsiInvert string = "\x1b[7m"
AnsiReset string = "\x1b[m"
AnsiWhiteBackground string = "\x1b[100m"
)
func AnsiInverted(s string) string {
return AnsiInvert + s + AnsiReset
}
func AnsiWhiteBackgrounded(s string) string {
return AnsiWhiteBackground + s + AnsiReset
}
func AnsiDimmed(s string) string {
return fmt.Sprintf("\x1b[2m%s\x1b[0m", s)
}
func AnsiGreen(s string) string {
return fmt.Sprintf("\x1b[32m%s\x1b[0m", s)
}
func AnsiRed(s string) string {
return fmt.Sprintf("\x1b[31m%s\x1b[0m", s)
}
func (state *State) RenderPuzzle() string {
selectedClueCells := []int{}
if state.SelectedClue != nil {
selectedClueCells = state.SelectedClue.Cells
}
b := strings.Builder{}
b.WriteRune('┌')
for range (state.Puzzle.Width * 2) + 1 {
b.WriteRune('─')
}
b.WriteString("┐\r\n")
for index, char := range state.PuzzleState {
if index%state.Puzzle.Width == 0 {
b.WriteString("│ ")
}
cell := ""
switch char {
case '.':
cell = string(rune(0x2588))
case '-':
cell = AnsiDimmed("_")
default:
cell = string(char)
}
if state.SelectedCell == index {
cell = AnsiInverted(cell)
} else if slices.Contains(selectedClueCells, index) {
cell = AnsiWhiteBackgrounded(cell)
}
if len(state.CheckState) == len(state.PuzzleState) {
switch state.CheckState[index] {
case 'y':
cell = AnsiGreen(cell)
case 'n':
cell = AnsiRed(cell)
}
}
b.WriteString(cell)
if (index+1)%state.Puzzle.Width == 0 {
b.WriteString(" │\r\n")
} else if char == '.' && state.PuzzleState[index+1] == '.' {
b.WriteRune(0x2588)
} else {
// inefficient
if slices.Contains(selectedClueCells, index) && slices.Contains(selectedClueCells, index+1) {
b.WriteString(AnsiWhiteBackgrounded(" "))
} else {
b.WriteRune(' ')
}
}
}
b.WriteRune('└')
for range (state.Puzzle.Width * 2) + 1 {
b.WriteRune('─')
}
b.WriteString("┘\r\n")
return b.String()
}
type SaveFile struct {
State string `json:"state"`
}
func (state *State) CreateSaveFile() ([]byte, error) {
return json.Marshal(SaveFile{
State: string(state.PuzzleState),
})
}
func (state *State) LoadSaveFile(f []byte) error {
var save SaveFile
err := json.Unmarshal(f, &save)
if err != nil {
return err
}
// verify the save file matches the shape of the puzzle
if len(save.State) != state.Puzzle.Width*state.Puzzle.Height {
return errors.New("save is invalid")
}
for i, cell := range state.Puzzle.Solution {
if (save.State[i] == '.' && cell != '.') || (save.State[i] != '.' && cell == '.') {
return errors.New("save is invalid")
}
}
state.PuzzleState = []rune(save.State)
return nil
}
func (state *State) SwitchDirections() bool {
switch state.SelectedClue.Direction {
case puz.DirectionAcross:
if state.Puzzle.Cells[state.SelectedCell][1] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
}
return true
case puz.DirectionDown:
if state.Puzzle.Cells[state.SelectedCell][0] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
}
return true
}
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) {
if len(buffer) == 0 {
return false
}
state.LastKeySequence = fmt.Sprintf("%v", buffer)
if buffer[0] == 3 {
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] == ' ' {
state.SwitchDirections()
}
if buffer[0] >= 0x61 && buffer[0] <= 0x7a {
cellWasFilled := state.PuzzleState[state.SelectedCell] != '-'
state.PuzzleState[state.SelectedCell] = unicode.ToUpper(rune(buffer[0]))
state.CheckState[state.SelectedCell] = 0x00
i := slices.Index(state.SelectedClue.Cells, state.SelectedCell)
if !cellWasFilled {
// jump to next unfilled cell in clue
for x := (i + 1) % len(state.SelectedClue.Cells); x != i; x = ((x + 1) % len(state.SelectedClue.Cells)) {
if state.PuzzleState[state.SelectedClue.Cells[x]] == '-' {
state.SelectedCell = state.SelectedClue.Cells[x]
break
}
}
} else {
// jump to next cell
if i < len(state.SelectedClue.Cells)-1 {
state.SelectedCell = state.SelectedClue.Cells[i+1]
}
}
if state.GridFilled() {
if state.PuzzleSolved() {
state.SolveState = Solved
return true
} else {
state.SolveState = FilledNotSolved
}
} else {
state.SolveState = Unsolved
}
}
if buffer[0] == 0x7f {
// is there a filled cell underneath the cursor?
if state.PuzzleState[state.SelectedCell] != '-' {
state.PuzzleState[state.SelectedCell] = '-'
} else {
i := slices.Index(state.SelectedClue.Cells, state.SelectedCell)
if i > 0 {
state.PuzzleState[state.SelectedClue.Cells[i-1]] = '-' // clear the previous cell
state.SelectedCell = state.SelectedClue.Cells[i-1]
}
}
}
if buffer[0] == '\r' || buffer[0] == '\t' {
state.NextWord()
}
if buffer[0] == '~' {
state.PreviousWord()
}
if string(buffer[0:2]) == "\x1b[" {
if (buffer[2] == 68 || buffer[2] == 67) && state.SelectedClue != nil && state.SelectedClue.Direction == puz.DirectionDown {
state.SwitchDirections()
} else if (buffer[2] == 65 || buffer[2] == 66) && state.SelectedClue != nil && state.SelectedClue.Direction == puz.DirectionAcross {
state.SwitchDirections()
} else {
switch buffer[2] {
case 68: // left
state.MoveCursor(3)
case 67: // right
state.MoveCursor(1)
case 65: // up
state.MoveCursor(0)
case 66: // down
state.MoveCursor(2)
}
switch state.SelectedClue.Direction {
case puz.DirectionAcross:
if state.Puzzle.Cells[state.SelectedCell][0] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
} else {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
}
case puz.DirectionDown:
if state.Puzzle.Cells[state.SelectedCell][1] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
} else {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
}
}
}
}
return false
}
+9 -1
View File
@@ -7,4 +7,12 @@ require (
golang.org/x/text v0.41.0
)
require golang.org/x/sys v0.47.0 // indirect
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
golang.org/x/crypto v0.31.0 // indirect
)
require (
github.com/gliderlabs/ssh v0.3.8
golang.org/x/sys v0.47.0 // indirect
)
+6
View File
@@ -1,3 +1,9 @@
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
-211
View File
@@ -1,211 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"slices"
"unicode"
"github.com/cjdenio/crossword/puz"
"golang.org/x/term"
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Print("!!! PROGRAM CRASHED !!! Error:\r\n", r, "\r\n")
}
}()
debugMode := flag.Bool("debug", false, "")
flag.Parse()
filename := flag.Arg(0)
file, err := os.ReadFile(filename)
if err != nil {
log.Fatal(err)
}
fileInfo, err := os.Stat(filename)
if err != nil {
log.Fatal(err)
}
puzzle, err := puz.ParsePuz(file)
if err != nil {
log.Fatal(err)
}
fmt.Print("\x1b[?25l")
defer func() {
fmt.Print("\x1b[?25h")
}()
termState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
log.Fatal(err)
}
defer term.Restore(int(os.Stdin.Fd()), termState)
state := State{
Puzzle: puzzle,
PuzzleState: []rune(puzzle.State),
SelectedClue: puzzle.Clues[0],
SelectedCell: puzzle.Clues[0].Cells[0],
}
if debugMode != nil {
state.DebugMode = *debugMode
}
// try to load a saveFile
saveFile, err := os.ReadFile(filename + ".save")
if err == nil {
err = state.LoadSaveFile(saveFile)
if state.DebugMode && err != nil {
state.LastKeySequence = fmt.Sprintf("failed to read savefile: %s", err)
}
}
uiHeight := state.RenderUI()
scanner := bufio.NewScanner(os.Stdin)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if len(data) == 0 {
return 0, nil, bufio.ErrFinalToken
}
if data[0] == 0x1b {
if len(data) < 3 {
return 0, nil, nil
}
return 3, data[0:3], nil
}
return 1, data[0:1], nil
})
for {
scanner.Scan()
buffer := scanner.Bytes()
state.LastKeySequence = fmt.Sprintf("%v", buffer)
if buffer[0] == 3 {
saveFile, err := state.CreateSaveFile()
if err == nil {
err = os.WriteFile(filename+".save", saveFile, fileInfo.Mode())
if err != nil && state.DebugMode {
state.LastKeySequence = fmt.Sprintf("failed to write savefile: %s", err)
}
}
state.SelectedClue = nil
state.SelectedCell = -1
state.Goodbye = true
fmt.Printf("\r\x1b[%dA", uiHeight)
fmt.Print("\x1b[J")
state.RenderUI()
return
}
if buffer[0] == ' ' {
switch state.SelectedClue.Direction {
case puz.DirectionAcross:
if state.Puzzle.Cells[state.SelectedCell][1] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
}
case puz.DirectionDown:
if state.Puzzle.Cells[state.SelectedCell][0] != nil {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
}
}
}
if buffer[0] >= 0x61 && buffer[0] <= 0x7a {
cellWasFilled := state.PuzzleState[state.SelectedCell] != '-'
state.PuzzleState[state.SelectedCell] = unicode.ToUpper(rune(buffer[0]))
i := slices.Index(state.SelectedClue.Cells, state.SelectedCell)
if !cellWasFilled {
// jump to next unfilled cell in clue
for x := i + 1; x < len(state.SelectedClue.Cells); x++ {
if state.PuzzleState[state.SelectedClue.Cells[x]] == '-' {
state.SelectedCell = state.SelectedClue.Cells[x]
break
}
}
} else {
// jump to next cell
if i < len(state.SelectedClue.Cells)-1 {
state.SelectedCell = state.SelectedClue.Cells[i+1]
}
}
if state.GridFilled() {
if state.CheckPuzzle() {
state.SolveState = 2
} else {
state.SolveState = 1
}
} else {
state.SolveState = 0
}
}
if buffer[0] == 0x7f {
// is there a filled cell underneath the cursor?
if state.PuzzleState[state.SelectedCell] != '-' {
state.PuzzleState[state.SelectedCell] = '-'
} else {
i := slices.Index(state.SelectedClue.Cells, state.SelectedCell)
if i > 0 {
state.PuzzleState[state.SelectedClue.Cells[i-1]] = '-' // clear the previous cell
state.SelectedCell = state.SelectedClue.Cells[i-1]
}
}
}
if buffer[0] == '\r' {
state.NextWord()
}
if buffer[0] == '~' {
state.PreviousWord()
}
if string(buffer[0:2]) == "\x1b[" {
if (buffer[2] == 68 || buffer[2] == 67) && state.SelectedClue != nil && state.SelectedClue.Direction == puz.DirectionDown {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
} else if (buffer[2] == 65 || buffer[2] == 66) && state.SelectedClue != nil && state.SelectedClue.Direction == puz.DirectionAcross {
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
} else {
switch buffer[2] {
case 68: // left
state.MoveCursor(3)
case 67: // right
state.MoveCursor(1)
case 65: // up
state.MoveCursor(0)
case 66: // down
state.MoveCursor(2)
}
switch state.SelectedClue.Direction {
case puz.DirectionAcross:
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][0]
case puz.DirectionDown:
state.SelectedClue = state.Puzzle.Cells[state.SelectedCell][1]
}
}
}
fmt.Printf("\r\x1b[%dA", uiHeight)
fmt.Print("\x1b[J")
uiHeight = state.RenderUI()
}
}
+183
View File
@@ -0,0 +1,183 @@
package puz
import (
"encoding/json"
"errors"
"slices"
"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 {
Kind []string `json:"kind"`
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 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) {
var parsed iPuzFile
err := json.Unmarshal(file, &parsed)
if err != nil {
return nil, err
}
if len(parsed.Kind) == 0 || !slices.ContainsFunc(parsed.Kind, func(k string) bool {
return strings.HasPrefix(k, "http://ipuz.org/crossword")
}) {
return nil, errors.New("ipuz file is not a crossword")
}
puzzle := new(Puzzle)
puzzle.RebusCells = make(map[int]string)
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 {
unwrappedCell, err := unwrapCell(cell)
if err != nil {
return nil, err
}
switch c := unwrappedCell.(type) {
case string:
state.WriteRune('.')
case int:
clueToCellMap[c] = [2]int{x, y}
state.WriteRune('-')
default:
return nil, errors.New("unexpected type in .ipuz file")
}
}
}
puzzle.State = state.String()
solution := strings.Builder{}
for y, row := range parsed.Solution {
for x, cell := range row {
if cell == "#" {
solution.WriteRune('.')
} else if len(cell) > 1 {
solution.WriteByte(cell[0])
puzzle.RebusCells[y*puzzle.Width+x] = cell
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
}
+164 -51
View File
@@ -5,44 +5,15 @@ import (
"bytes"
"encoding/binary"
"errors"
"io"
"math"
"regexp"
"strconv"
"strings"
"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
@@ -75,60 +46,160 @@ func startOfAcrossClue(puzzle string, width, index int) bool {
return false
}
func readText(b *bufio.Reader) (string, error) {
func readText(b *bufio.Reader, version string) (string, error) {
text, err := b.ReadString(0x00)
if err != nil {
return "", err
}
if version == "1" {
decoded, err := charmap.ISO8859_1.NewDecoder().String(text[:len(text)-1])
if err != nil {
return "", err
}
return decoded, nil
return strings.TrimSpace(decoded), nil
} else {
return strings.TrimSpace(string(text[:len(text)-1])), nil
}
}
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) {
r := bytes.NewReader(file)
_, err := r.Seek(2, io.SeekStart) // consume the checksum
if err != nil {
return nil, err
}
// read magic bytes
magic := string(file[2:14])
if magic != "ACROSS&DOWN\x00" {
magic := make([]byte, 12)
_, 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")
}
puzzle := new(Puzzle)
puzzle.RebusCells = make(map[int]string)
_, err = r.Seek(10, io.SeekCurrent)
if err != nil {
return nil, err
}
// read header
puzzle.Width = int(file[0x2C])
puzzle.Height = int(file[0x2D])
puzzle.ClueCount = int(binary.LittleEndian.Uint16(file[0x2E:0x30]))
version := make([]byte, 4)
_, err = r.Read(version)
if err != nil {
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
solutionStart := 0x34
solutionEnd := 0x34 + puzzleSize
stateStart := solutionEnd
stateEnd := stateStart + puzzleSize
_, err = r.Seek(4, io.SeekCurrent)
if err != nil {
return nil, err
}
puzzle.Solution = string(file[solutionStart:solutionEnd])
puzzle.State = string(file[stateStart:stateEnd])
solution := make([]byte, puzzleSize)
_, 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
buf := bufio.NewReader(bytes.NewReader(file[stateEnd:]))
puzzle.Solution = string(solution)
puzzle.State = string(state)
title, err := readText(buf)
// read text fields
buf := bufio.NewReader(r)
title, err := readText(buf, string(version[0]))
if err != nil {
return nil, err
}
puzzle.Title = title
author, err := readText(buf)
author, err := readText(buf, string(version[0]))
if err != nil {
return nil, err
}
puzzle.Author = author
copyright, err := readText(buf)
copyright, err := readText(buf, string(version[0]))
if err != nil {
return nil, err
}
@@ -137,13 +208,55 @@ func ParsePuz(file []byte) (*Puzzle, error) {
// read clues
clues := make([]string, 0, puzzle.ClueCount)
for range puzzle.ClueCount {
clue, err := readText(buf)
clue, err := readText(buf, string(version[0]))
if err != nil {
return nil, err
}
clues = append(clues, clue)
}
// read the "note" field, currently unused
_, err = readText(buf, string(version[0]))
if err != nil {
return nil, err
}
extraBlocks := make(map[string]*ExtraBlock)
for {
extra, err := readExtraBlock(buf)
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, err
}
extraBlocks[extra.Name] = extra
}
grbs, grbsOk := extraBlocks["GRBS"]
trbl, trblOk := extraBlocks["RTBL"]
if grbsOk && trblOk {
puzzle.HasRebus = true
rebusCellMap := make(map[int][]int)
for i, b := range grbs.Data {
if b > 0 {
rebusCellMap[int(b)-1] = append(rebusCellMap[int(b)-1], i)
}
}
rebusData := regexp.MustCompile(`\s*(\d+):(\w+);\s*`).FindAllStringSubmatch(string(trbl.Data), -1)
for _, rebus := range rebusData {
cell, err := strconv.Atoi(rebus[1])
if err != nil {
continue
}
for _, c := range rebusCellMap[cell] {
puzzle.RebusCells[c] = rebus[2]
}
}
}
// assign clue numbers
puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount)
puzzle.Cells = make([][2]*Clue, len(puzzle.Solution))
+67
View File
@@ -0,0 +1,67 @@
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
RebusCells map[int]string
}
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")
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"bufio"
"fmt"
"io"
"log"
"os"
"github.com/cjdenio/crossword/game"
"github.com/cjdenio/crossword/puz"
"github.com/gliderlabs/ssh"
)
func main() {
file, err := os.ReadFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
port := ":2222"
if portEnv, ok := os.LookupEnv("PORT"); ok && portEnv != "" {
port = ":" + portEnv
}
ssh.Handle(func(s ssh.Session) {
log.Printf("new connection from %s (username %s)\n", s.RemoteAddr().String(), s.User())
puzzle, err := puz.ParsePuz(file)
if err != nil {
log.Fatal(err)
}
state := game.NewState(puzzle)
io.WriteString(s, "\x1b[?25l")
defer func() {
io.WriteString(s, "\x1b[?25h")
}()
pty, winCh, isPty := s.Pty()
if !isPty {
return
}
windowWidth := pty.Window.Width
uiHeight := state.RenderUI(s, windowWidth)
go func() {
for window := range winCh {
windowWidth = window.Width
}
}()
scanner := bufio.NewScanner(s)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if len(data) == 0 {
return 0, nil, bufio.ErrFinalToken
}
if data[0] == 0x1b {
if len(data) < 3 {
return 0, nil, nil
}
return 3, data[0:3], nil
}
return 1, data[0:1], nil
})
for {
ok := scanner.Scan()
if !ok {
break
}
buffer := scanner.Bytes()
exited := state.HandleInput(buffer)
if exited {
state.SelectedClue = nil
state.SelectedCell = -1
fmt.Fprintf(s, "\r\x1b[%dA", uiHeight)
fmt.Fprint(s, "\x1b[J")
state.RenderUI(s, windowWidth)
break
}
fmt.Fprintf(s, "\r\x1b[%dA", uiHeight)
fmt.Fprint(s, "\x1b[J")
uiHeight = state.RenderUI(s, windowWidth)
}
log.Printf("%s disconnected\n", s.RemoteAddr().String())
})
log.Printf("starting on port %s...\n", port)
log.Fatal(ssh.ListenAndServe(port, nil))
}