Refactor Sudoku field and solver implementation

This commit is contained in:
2026-09-16 22:52:44 +02:00
parent 8a5ae8c640
commit f7f9c16184
24 changed files with 1574 additions and 603 deletions
+23 -19
View File
@@ -5,10 +5,10 @@ menschlichen Lösungsstrategien bearbeiten soll. Neben dem gelösten Feld soll
langfristig auch der Lösungsweg mit Kandidaten, Änderungen und Markierungen langfristig auch der Lösungsweg mit Kandidaten, Änderungen und Markierungen
verfügbar sein. verfügbar sein.
> [!WARNING] > [!NOTE]
> Das Projekt befindet sich in einer frühen Entwicklungsphase. Der aktuelle > Das Projekt befindet sich weiterhin in einer frühen Entwicklungsphase. Die
> Prüflauf ist wegen eines Vet-Fehlers noch nicht grün und die Demo kann während des > Basis ist getestet und die Demo läuft, der menschliche Solver unterstützt aber
> Lösens abstürzen. Siehe [Bekannte Einschränkungen](#bekannte-einschränkungen). > bisher nur vier grundlegende Strategien.
## Voraussetzungen ## Voraussetzungen
@@ -43,8 +43,9 @@ gofmt -w main.go field/*.go parser/*.go logic/*.go logic/strategies/*.go sudoku/
go vet ./... go vet ./...
``` ```
Aktuell gibt es noch keine automatisierten Tests. Neue Funktionalität sollte Die Pakete `field`, `parser`, `logic`, `logic/strategies` und `sudoku` besitzen
nach Möglichkeit mit paketnahen `*_test.go`-Tests ergänzt werden. Unit- und Regressionstests. Neue Funktionalität sollte weiterhin mit paketnahen,
vorzugsweise tabellengesteuerten `*_test.go`-Tests ergänzt werden.
## Architektur ## Architektur
@@ -70,8 +71,9 @@ Puzzle-Bank-Datei -> parser.PuzzleBank -> field.Field
Eine Strategie implementiert `strategies.Strategy`. `SearchProgressableCells` Eine Strategie implementiert `strategies.Strategy`. `SearchProgressableCells`
sammelt mögliche Änderungen, `ApplyAll` oder `ApplyNext` übernimmt sie in das sammelt mögliche Änderungen, `ApplyAll` oder `ApplyNext` übernimmt sie in das
Feld. Wird eine Zahl gesetzt, entfernt der Solver den entsprechenden Kandidaten Feld. Wird eine Zahl gesetzt, entfernt das Feldmodell den entsprechenden
aus der zugehörigen Zeile, Spalte und dem Block. Kandidaten automatisch und ohne Duplikate aus der zugehörigen Zeile, Spalte und
dem Block.
Derzeit in `main.go` aktiv: Derzeit in `main.go` aktiv:
@@ -88,25 +90,27 @@ implementiert.
`parser.PuzzleBank` erwartet das Format der `parser.PuzzleBank` erwartet das Format der
[Sudoku Exchange Puzzle Bank](data/sudoku-exchange-puzzle-bank/README.md): pro [Sudoku Exchange Puzzle Bank](data/sudoku-exchange-puzzle-bank/README.md): pro
Zeile einen 12-stelligen Hash, 81 Ziffern für das Rätsel und eine Zeile einen 12-stelligen Hash, 81 Ziffern für das Rätsel und eine
Schwierigkeitsbewertung. `0` steht für ein leeres Feld. Leere Zeilen werden Schwierigkeitsbewertung. Hash, Zeichenzahl, Ziffern, Rating und Ausgangsbelegung
übersprungen; alle eingelesenen Rätsel werden im Parser gespeichert. werden geprüft. `0` steht für ein leeres Feld. LF und CRLF werden unterstützt,
leere Zeilen werden übersprungen.
Für einzelne Rätsel steht außerdem `parser.PuzzleString` zur Verfügung. Er
akzeptiert genau 81 Ziffern. Parser-Zugriffe liefern bei einem ungültigen Index
einen Fehler; bei mehreren Puzzle-Bank-Einträgen wird der Index explizit an
`sudoku.New` übergeben.
Die Datensätze unter `data/sudoku-exchange-puzzle-bank/` stehen unter der dort Die Datensätze unter `data/sudoku-exchange-puzzle-bank/` stehen unter der dort
beiliegenden separaten Lizenz. beiliegenden separaten Lizenz.
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- `field.Field.IsValid` und die `IsSolved`-Prüfungen einzelner Bereiche sind
noch nicht implementiert. Ein vollständig belegtes, aber ungültiges Feld kann
daher als gelöst gelten.
- `logic.Solver.GetSolutionPath` liefert noch keinen Lösungsweg. - `logic.Solver.GetSolutionPath` liefert noch keinen Lösungsweg.
- Mehrere fortgeschrittene Strategien sind nur als leere Typen vorhanden. - Mehrere fortgeschrittene Strategien sind nur als leere Typen vorhanden.
- `go test ./...` meldet aktuell in `field/field.go`, dass eine Ganzzahl direkt - Der Solver besitzt noch kein gemeinsames Interface für menschliche,
in einen String konvertiert wird. Backtracking- und DLX-Implementierungen.
- Die Demo kann in `LastDigit.SearchProgressableCells` auf eine leere Zelle - Die Demo verwendet weiterhin einen fest kodierten Dateipfad und Puzzle-Index.
zugreifen und dadurch abstürzen. - Der alte öffentliche Bezeichner `InitStragies` bleibt vorerst als
- Fehlertexte und Bezeichner sind derzeit teilweise deutsch, teilweise Kompatibilitätsalias bestehen.
englisch; einige öffentliche Namen enthalten noch Tippfehler.
## Lizenz ## Lizenz
+22 -22
View File
@@ -20,7 +20,9 @@ Das erste stabile Release soll:
Backtracking, DLX und sehr fortgeschrittene menschliche Strategien folgen auf Backtracking, DLX und sehr fortgeschrittene menschliche Strategien folgen auf
dieses Kern-Release. dieses Kern-Release.
## Phase 0 Lauffähige und getestete Basis (P0) ## Phase 0 Lauffähige und getestete Basis (P0)
Abgeschlossen am 10. September 2026.
Zuerst wird der bestehende Zustand stabilisiert. In dieser Phase werden keine Zuerst wird der bestehende Zustand stabilisiert. In dieser Phase werden keine
neuen Lösungsstrategien ergänzt. neuen Lösungsstrategien ergänzt.
@@ -49,7 +51,9 @@ Abgeschlossen, wenn:
- `go run .` das ausgewählte Beispiel ohne Panic beendet und - `go run .` das ausgewählte Beispiel ohne Panic beendet und
- die bekannten Fehler jeweils einen Regressionstest besitzen. - die bekannten Fehler jeweils einen Regressionstest besitzen.
## Phase 1 Verlässliches Feldmodell (P0) ## Phase 1 Verlässliches Feldmodell (P0)
Abgeschlossen am 10. September 2026.
- `field.Properties` beim Erzeugen prüfen: positive Dimensionen, passende - `field.Properties` beim Erzeugen prüfen: positive Dimensionen, passende
Blockaufteilung und exakt passende Zellmatrix. Blockaufteilung und exakt passende Zellmatrix.
@@ -65,14 +69,16 @@ Abgeschlossen, wenn:
Fehler zurückgeben und das Feld unverändert lassen. Fehler zurückgeben und das Feld unverändert lassen.
- lesenden Zugriff auf Eigenschaften und Änderungshistorie anbieten, ohne - lesenden Zugriff auf Eigenschaften und Änderungshistorie anbieten, ohne
interne Slices veränderbar nach außen zu geben. interne Slices veränderbar nach außen zu geben.
- allgemeine und 9x9-spezifische Darstellung trennen; `StringNotesForNumber` - die fest codierte 9x9-Darstellung durch eine allgemeine Darstellung ersetzen
implementieren oder bis zu einem echten Bedarf aus der API entfernen. und `StringNotesForNumber` implementieren.
Abgeschlossen, wenn gültige, ungültige, unvollständige und gelöste Felder in Abgeschlossen, wenn gültige, ungültige, unvollständige und gelöste Felder in
Tabellentests eindeutig unterschieden werden und keine Mutation die Tabellentests eindeutig unterschieden werden und keine Mutation die
Feld-Invarianten umgehen kann. Feld-Invarianten umgehen kann.
## Phase 2 Robuste Parser- und Game-API (P0) ## Phase 2 Robuste Parser- und Game-API (P0)
Abgeschlossen am 10. September 2026.
- Parserfehler vereinheitlichen und mit Zeilennummer sowie fehlerhaftem Feld - Parserfehler vereinheitlichen und mit Zeilennummer sowie fehlerhaftem Feld
anreichern. anreichern.
@@ -95,17 +101,12 @@ und Parser sowie `Game` keine implizite Rätselauswahl mehr enthalten.
- ein Solver-Interface definieren, das später menschlichen Solver, Backtracking - ein Solver-Interface definieren, das später menschlichen Solver, Backtracking
und DLX austauschbar macht. und DLX austauschbar macht.
- `Run(int) bool` durch eine aussagekräftige Schritt-API ersetzen, zum Beispiel - `Run(int) (bool, error)` durch eine aussagekräftige Schritt-API ersetzen, zum Beispiel
mit den Zuständen `Progress`, `Solved`, `Stuck`, `Invalid` und `Failed`. mit den Zuständen `Progress`, `Solved`, `Stuck`, `Invalid` und `Failed`.
- Konfiguration aus den ungenutzten `conf`-Feldern ableiten oder diese entfernen.
- Strategie-Reihenfolge, Wiederholungspunkt und `ApplyNext`/`ApplyAll` eindeutig - Strategie-Reihenfolge, Wiederholungspunkt und `ApplyNext`/`ApplyAll` eindeutig
definieren. definieren.
- gefundene Änderungen vor dem Anwenden deduplizieren und auf Konflikte prüfen.
- Kandidaten nach jedem Zahlenschritt korrekt und deterministisch aktualisieren.
- Tippfehler in öffentlichen Namen (`InitStragies`, `TriggerdBy`) kontrolliert - Tippfehler in öffentlichen Namen (`InitStragies`, `TriggerdBy`) kontrolliert
migrieren und alle Aufrufer anpassen. migrieren und alle Aufrufer anpassen.
- Abbruch bei Stillstand, ungültigem Zustand und internem Fehler sauber durch
`sudoku.Game.Solve` reichen.
Abgeschlossen, wenn derselbe Input stets dieselben Schritte erzeugt und jeder Abgeschlossen, wenn derselbe Input stets dieselben Schritte erzeugt und jeder
Solverlauf genau einen überprüfbaren Endzustand besitzt. Solverlauf genau einen überprüfbaren Endzustand besitzt.
@@ -208,17 +209,16 @@ verwendet werden kann.
Diese Tickets bilden die kürzeste Route zu einem stabilen Zwischenstand: Diese Tickets bilden die kürzeste Route zu einem stabilen Zwischenstand:
1. Test-Helfer und Feldzugriffs-Tests erstellen. 1. ein gemeinsames Solver-Interface und aussagekräftige Schrittzustände
2. `ForEachCell`, `GetCell` und `Field.String` korrigieren. entwerfen.
3. Change-Aktionen sowie Nil- und Werteprüfung absichern. 2. Strategie-Reihenfolge sowie `ApplyNext` und `ApplyAll` als öffentlichen
4. `LastDigit` deduplizieren und den bekannten Panic per Regressionstest Vertrag festlegen und testen.
beseitigen. 3. `InitStragies` und `TriggerdBy` kontrolliert auf korrekt geschriebene Namen
5. `HiddenSingle` korrigieren und vollständig testen. migrieren.
6. `Part.IsSolved`, `Field.IsValid` und `Field.IsSolved` implementieren. 4. End-to-End-Tests für mehrere einfache und mittlere Puzzle-Bank-Rätsel
7. Puzzle-Bank-Parser atomar und indexsicher machen. ergänzen.
8. Solver-Schrittergebnis und Konflikterkennung einführen. 5. anschließend `NakedPair`, `NakedTriple`, `HiddenPair` und `HiddenTriple`
9. die vier Basisstrategien durch End-to-End-Rätseltests absichern. implementieren.
10. erst danach Paar-/Tripel-Strategien oder neue Bedienfunktionen beginnen.
Nach jedem Arbeitspaket müssen `gofmt`, `go test ./...` und `go vet ./...` Nach jedem Arbeitspaket müssen `gofmt`, `go test ./...` und `go vet ./...`
erfolgreich sein. Neue bekannte Baseline-Fehler sollen nicht angesammelt werden. erfolgreich sein. Neue bekannte Baseline-Fehler sollen nicht angesammelt werden.
+42 -88
View File
@@ -1,123 +1,77 @@
package field package field
// ────────────────────────────────────────────────────────────────────────────── //
// NOTES STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
import "slices" import "slices"
type Notes struct { type Notes struct {
numbers []int numbers []int
} }
func (n *Notes) Add(field *Field, cell *Cell, note int, trigger string, marks []Mark) { func (n *Notes) Add(f *Field, cell *Cell, note int, trigger string, marks []Mark) error {
if slices.Contains(n.numbers, note) { if n == nil || cell == nil || cell.notes != n {
return // Note already exists return ErrInvalidCell
} }
field.changes = append(field.changes, Change{ return f.AddChange(&ExternalChange{Cell: cell, Marks: marks, Action: ActionSetNote, Value: note, TriggerdBy: trigger})
Cell: cell,
marks: marks,
action: ActionSetNote,
value: note,
from: 0,
triggerdBy: trigger,
})
n.numbers = append(n.numbers, note)
} }
func (n *Notes) Remove(field *Field, cell *Cell, note int, trigger string, marks []Mark) { func (n *Notes) Remove(f *Field, cell *Cell, note int, trigger string, marks []Mark) error {
for i, existing := range n.numbers { if n == nil || cell == nil || cell.notes != n {
if existing == note { return ErrInvalidCell
field.changes = append(field.changes, Change{
Cell: cell,
marks: marks,
action: ActionRemoveNote,
value: 0,
from: note,
triggerdBy: trigger,
})
n.numbers = append(n.numbers[:i], n.numbers[i+1:]...)
return
}
} }
return f.AddChange(&ExternalChange{Cell: cell, Marks: marks, Action: ActionRemoveNote, From: note, TriggerdBy: trigger})
} }
func (n *Notes) Get() []int { func (n *Notes) Get() []int {
copySlice := make([]int, len(n.numbers)) if n == nil {
copy(copySlice, n.numbers) return nil
return copySlice }
return slices.Clone(n.numbers)
} }
func (n *Notes) Has(i int) bool { func (n *Notes) Has(number int) bool {
return slices.Contains(n.numbers, i) return n != nil && slices.Contains(n.numbers, number)
} }
// ────────────────────────────────────────────────────────────────────────────── //
// CELL STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Cell struct { type Cell struct {
number int number int
Notes *Notes notes *Notes
Pos *Position pos *Position
} }
func NewCell(number int, pos *Position) *Cell { func NewCell(number int, pos *Position) *Cell {
cell := Cell{ return &Cell{number: number, notes: &Notes{}, pos: pos}
number: number,
Pos: pos,
Notes: &Notes{},
}
return &cell
} }
func (c *Cell) SetNumber(field *Field, n int, trigger string, marks []Mark) { func (c *Cell) SetNumber(f *Field, number int, trigger string, marks []Mark) error {
if c.number == n { if c == nil {
return return ErrInvalidCell
} }
return f.AddChange(&ExternalChange{Cell: c, Marks: marks, Action: ActionSetNumber, Value: number, From: c.number, TriggerdBy: trigger})
if field == nil {
return
}
if n <= 0 || n > field.props.Rows || n > field.props.Columns {
return
}
field.changes = append(field.changes, Change{
Cell: c,
marks: marks,
action: ActionSetNumber,
value: n,
from: c.number,
triggerdBy: trigger,
})
c.number = n
} }
func (c *Cell) RemoveNumber(field *Field, trigger string, marks []Mark) { func (c *Cell) RemoveNumber(f *Field, trigger string, marks []Mark) error {
if c.number == 0 { if c == nil {
return return ErrInvalidCell
} }
return f.AddChange(&ExternalChange{Cell: c, Marks: marks, Action: ActionRemoveNumber, From: c.number, TriggerdBy: trigger})
if field == nil {
return
}
field.changes = append(field.changes, Change{
action: ActionSetNumber,
Cell: c,
value: 0,
from: c.number,
marks: marks,
triggerdBy: trigger,
})
c.number = 0
} }
func (c *Cell) GetNumber() int { func (c *Cell) GetNumber() int {
if c == nil {
return 0
}
return c.number return c.number
} }
func (c *Cell) GetNotes() *Notes {
if c == nil {
return nil
}
return c.notes
}
func (c *Cell) GetPosition() *Position {
if c == nil {
return nil
}
return c.pos
}
+166 -40
View File
@@ -1,13 +1,11 @@
package field package field
import ( import (
"fmt"
"image/color" "image/color"
"slices"
) )
// ────────────────────────────────────────────────────────────────────────────── //
// MARK STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Mark struct { type Mark struct {
Cell *Cell Cell *Cell
Change *Change Change *Change
@@ -15,94 +13,222 @@ type Mark struct {
} }
func (m *Mark) GetColor() color.Color { func (m *Mark) GetColor() color.Color {
if m == nil {
return nil
}
return m.color return m.color
} }
// ────────────────────────────────────────────────────────────────────────────── //
// CHANGE_ACTION TYPE //
// ────────────────────────────────────────────────────────────────────────────── //
type ChangeAction int type ChangeAction int
const ( const (
ActionSetNumber = iota ActionSetNumber ChangeAction = iota
ActionSetNote ActionSetNote
ActionRemoveNumber ActionRemoveNumber
ActionRemoveNote ActionRemoveNote
) )
// ────────────────────────────────────────────────────────────────────────────── //
// CHANGE STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type ExternalChange struct { type ExternalChange struct {
Cell *Cell Cell *Cell
Marks []Mark Marks []Mark
Action ChangeAction Action ChangeAction
Value int Value int
From int From int
TriggerdBy string //strategy or manuell TriggerdBy string
} }
// ────────────────────────────────────────────────────────────────────────────── //
// CHANGE STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Change struct { type Change struct {
Cell *Cell Cell *Cell
marks []Mark marks []Mark
action ChangeAction action ChangeAction
value int value int
from int from int
triggerdBy string //strategy or manuell triggerdBy string
} }
// ────────────────────────────────────────────────────────────────────────────── //
// GETTER //
// ────────────────────────────────────────────────────────────────────────────── //
func (c *Change) GetMarks() []Mark { func (c *Change) GetMarks() []Mark {
return c.marks if c == nil {
return nil
}
return slices.Clone(c.marks)
} }
func (c *Change) GetAction() ChangeAction { func (c *Change) GetAction() ChangeAction {
if c == nil {
return ActionSetNumber
}
return c.action return c.action
} }
func (c *Change) GetTo() int { func (c *Change) GetTo() int {
if c == nil {
return 0
}
return c.value return c.value
} }
func (c *Change) GetFrom() int { func (c *Change) GetFrom() int {
if c == nil {
return 0
}
return c.from return c.from
} }
func (c *Change) GetTriggeredBy() string { func (c *Change) GetTriggeredBy() string {
if c == nil {
return ""
}
return c.triggerdBy return c.triggerdBy
} }
// ────────────────────────────────────────────────────────────────────────────── // func (f *Field) AddChange(external *ExternalChange) error {
// ??? // if err := f.validateChange(external); err != nil {
// ────────────────────────────────────────────────────────────────────────────── // return err
}
func (c *Change) do() {
switch c.action {
cell := external.Cell
switch external.Action {
case ActionSetNumber: case ActionSetNumber:
c.Cell.number = c.value if cell.number == external.Value {
return nil
}
cell.number = external.Value
f.recordChange(external)
f.clearNotes(cell, external.TriggerdBy)
f.removePeerNote(cell, external.Value, external.TriggerdBy)
case ActionSetNote: case ActionSetNote:
c.Cell.Notes.numbers = append(c.Cell.Notes.numbers, c.value) if cell.notes.Has(external.Value) {
return nil
}
cell.notes.numbers = append(cell.notes.numbers, external.Value)
slices.Sort(cell.notes.numbers)
f.recordChange(external)
case ActionRemoveNumber: case ActionRemoveNumber:
c.Cell.number = 0 if cell.number == 0 {
return nil
}
cell.number = 0
f.recordChange(external)
case ActionRemoveNote: case ActionRemoveNote:
for i, existing := range c.Cell.Notes.numbers { for i, existing := range cell.notes.numbers {
if existing == c.from { if existing == external.From {
c.Cell.Notes.numbers = append(c.Cell.Notes.numbers[:i], c.Cell.Notes.numbers[i+1:]...) cell.notes.numbers = slices.Delete(cell.notes.numbers, i, i+1)
return f.recordChange(external)
break
} }
} }
} }
return nil
}
func (f *Field) AddChanges(changes []ExternalChange) error {
if f == nil || !f.IsValid() {
return ErrInvalidField
}
state := f.snapshot()
for i := range changes {
if err := f.AddChange(&changes[i]); err != nil {
f.restore(state)
return fmt.Errorf("apply change %d: %w", i, err)
}
}
return nil
}
func (f *Field) GetChanges() []ExternalChange {
if f == nil {
return nil
}
result := make([]ExternalChange, len(f.changes))
for i := range f.changes {
result[i] = ExternalChange{Cell: f.changes[i].Cell, Marks: slices.Clone(f.changes[i].marks), Action: f.changes[i].action, Value: f.changes[i].value, From: f.changes[i].from, TriggerdBy: f.changes[i].triggerdBy}
}
return result
}
func (f *Field) validateChange(change *ExternalChange) error {
if f == nil || !f.IsValid() {
return ErrInvalidField
}
if change == nil || change.Cell == nil || !f.ownsCell(change.Cell) {
return fmt.Errorf("%w: cell does not belong to field", ErrInvalidChange)
}
cell := change.Cell
if cell.notes == nil || cell.pos == nil {
return fmt.Errorf("%w: incomplete cell", ErrInvalidChange)
}
switch change.Action {
case ActionSetNumber:
if change.Value < 1 || change.Value > f.props.Rows {
return fmt.Errorf("%w: number %d is out of range", ErrInvalidChange, change.Value)
}
if change.From != cell.number {
return fmt.Errorf("%w: expected previous value %d, got %d", ErrInvalidChange, cell.number, change.From)
}
if f.hasPeerNumber(cell, change.Value) {
return fmt.Errorf("%w: number %d already exists in a peer", ErrInvalidChange, change.Value)
}
case ActionSetNote:
if cell.number != 0 {
return fmt.Errorf("%w: cannot add a note to a filled cell", ErrInvalidChange)
}
if change.Value < 1 || change.Value > f.props.Rows || f.hasPeerNumber(cell, change.Value) {
return fmt.Errorf("%w: note %d is not a candidate", ErrInvalidChange, change.Value)
}
case ActionRemoveNumber:
if change.From != cell.number {
return fmt.Errorf("%w: expected previous value %d, got %d", ErrInvalidChange, cell.number, change.From)
}
case ActionRemoveNote:
if change.From < 1 || change.From > f.props.Rows {
return fmt.Errorf("%w: note %d is out of range", ErrInvalidChange, change.From)
}
default:
return fmt.Errorf("%w: unknown action %d", ErrInvalidChange, change.Action)
}
return nil
}
func (f *Field) recordChange(external *ExternalChange) {
f.changes = append(f.changes, Change{Cell: external.Cell, marks: slices.Clone(external.Marks), action: external.Action, value: external.Value, from: external.From, triggerdBy: external.TriggerdBy})
}
func (f *Field) clearNotes(cell *Cell, trigger string) {
for _, note := range slices.Clone(cell.notes.numbers) {
cell.notes.numbers = removeNumber(cell.notes.numbers, note)
f.recordChange(&ExternalChange{Cell: cell, Action: ActionRemoveNote, From: note, TriggerdBy: trigger})
}
}
func (f *Field) removePeerNote(cell *Cell, number int, trigger string) {
seen := make(map[*Cell]struct{})
parts, err := f.GetEachPartAtPos(cell.pos)
if err != nil {
return
}
for _, part := range parts {
part.ForEachCell(func(peer *Cell) {
if peer == cell {
return
}
if _, exists := seen[peer]; exists {
return
}
seen[peer] = struct{}{}
if peer.notes.Has(number) {
peer.notes.numbers = removeNumber(peer.notes.numbers, number)
f.recordChange(&ExternalChange{Cell: peer, Action: ActionRemoveNote, From: number, TriggerdBy: trigger})
}
})
}
}
func removeNumber(numbers []int, number int) []int {
for i, existing := range numbers {
if existing == number {
return slices.Delete(numbers, i, i+1)
}
}
return numbers
} }
+4 -2
View File
@@ -3,6 +3,8 @@ package field
import "errors" import "errors"
var ( var (
outOfBound = errors.New("invalid coordinates") ErrOutOfBounds = errors.New("coordinates out of bounds")
invalidField = errors.New("can not parse Field") ErrInvalidField = errors.New("invalid field")
ErrInvalidCell = errors.New("invalid cell")
ErrInvalidChange = errors.New("invalid change")
) )
+352 -198
View File
@@ -2,13 +2,11 @@ package field
import ( import (
"fmt" "fmt"
"slices"
"strconv"
"strings" "strings"
) )
// ────────────────────────────────────────────────────────────────────────────── //
// META STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Properties struct { type Properties struct {
Rows int Rows int
Columns int Columns int
@@ -19,289 +17,445 @@ type Properties struct {
Rating float64 Rating float64
} }
// ────────────────────────────────────────────────────────────────────────────── //
// FIELD STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Field struct { type Field struct {
props *Properties props *Properties
cells [][]Cell cells [][]Cell
changes []Change changes []Change
} }
func New(props Properties, cells [][]Cell) *Field { type fieldSnapshot struct {
field := Field{ numbers [][]int
props: &props, notes [][][]int
cells: cells, changes []Change
}
return &field
} }
// ────────────────────────────────────────────────────────────────────────────── // func New(props Properties, cells [][]Cell) (*Field, error) {
// GETTER // if err := validateProperties(props); err != nil {
// ────────────────────────────────────────────────────────────────────────────── // return nil, err
}
func (f *Field) GetRow(r int) (*Row, error) { if len(cells) != props.Rows {
if r >= f.props.Rows || r < 0 { return nil, fmt.Errorf("%w: expected %d rows, got %d", ErrInvalidField, props.Rows, len(cells))
return nil, outOfBound
} }
cellPtrs := make([]*Cell, len(f.cells[r])) cloned := make([][]Cell, props.Rows)
for i := range f.cells[r] { for row := 0; row < props.Rows; row++ {
cellPtrs[i] = &f.cells[r][i] if len(cells[row]) != props.Columns {
return nil, fmt.Errorf("%w: row %d has %d cells, expected %d", ErrInvalidField, row, len(cells[row]), props.Columns)
}
cloned[row] = make([]Cell, props.Columns)
for column := 0; column < props.Columns; column++ {
source := &cells[row][column]
if source.pos == nil {
return nil, fmt.Errorf("%w: cell %d/%d has no position", ErrInvalidField, row, column)
}
if !positionMatches(source.pos, props, row, column) {
return nil, fmt.Errorf("%w: cell %d/%d has inconsistent position", ErrInvalidField, row, column)
}
if source.number < 0 || source.number > props.Rows {
return nil, fmt.Errorf("%w: cell %d/%d contains %d", ErrInvalidField, row, column, source.number)
}
if source.notes == nil {
return nil, fmt.Errorf("%w: cell %d/%d has no notes", ErrInvalidField, row, column)
}
notes, err := normalizeNotes(source.notes.numbers, props.Rows, source.number)
if err != nil {
return nil, fmt.Errorf("%w: cell %d/%d: %w", ErrInvalidField, row, column, err)
}
cloned[row][column] = Cell{
number: source.number,
notes: &Notes{numbers: notes},
pos: NewPosition(row, column, row/props.BlockSizeRow, column/props.BlockSizeColumn, row%props.BlockSizeRow, column%props.BlockSizeColumn),
}
}
} }
return &Row{ result := &Field{props: &props, cells: cloned}
Line: Line{ if !result.hasValidNumbers() || !result.hasValidNotes() {
cells: cellPtrs, return nil, fmt.Errorf("%w: duplicate values or invalid candidates", ErrInvalidField)
},
}, nil
}
func (f *Field) GetColumn(c int) (*Column, error) {
if c >= f.props.Columns || c < 0 {
return nil, outOfBound
} }
// Performance
result := &Column{
Line: Line{
cells: make([]*Cell, 0, f.props.Rows),
},
}
// Copying
for _, row := range f.cells {
result.cells = append(result.cells, &row[c])
}
return result, nil return result, nil
} }
func (f *Field) GetBlock(r, c int) (*Block, error) { func validateProperties(props Properties) error {
if r < 0 || r >= f.props.BlockRows || c < 0 || c >= f.props.BlockColumns { if props.Rows <= 0 || props.Columns <= 0 || props.Rows != props.Columns {
return nil, outOfBound return fmt.Errorf("%w: rows and columns must be equal and positive", ErrInvalidField)
} }
if props.BlockRows <= 0 || props.BlockColumns <= 0 || props.BlockSizeRow <= 0 || props.BlockSizeColumn <= 0 {
startRow := r * f.props.BlockSizeRow return fmt.Errorf("%w: block dimensions must be positive", ErrInvalidField)
startCol := c * f.props.BlockSizeColumn
block := &Block{
cells: make([][]*Cell, f.props.BlockSizeRow),
} }
if props.BlockRows*props.BlockSizeRow != props.Rows || props.BlockColumns*props.BlockSizeColumn != props.Columns {
for row := range block.cells { return fmt.Errorf("%w: block dimensions do not cover the field", ErrInvalidField)
// Effizientes Kopieren der Zeile
block.cells[row] = make([]*Cell, f.props.BlockSizeColumn)
for column := range block.cells[row] {
block.cells[row][column] = &f.cells[startRow+row][startCol+column]
} }
if props.BlockSizeRow*props.BlockSizeColumn != props.Rows {
return fmt.Errorf("%w: each block must contain %d cells", ErrInvalidField, props.Rows)
} }
return block, nil return nil
} }
func (f *Field) GetEachPartAtPos(pos *Position) []Part { func normalizeNotes(notes []int, max, cellNumber int) ([]int, error) {
if cellNumber != 0 && len(notes) != 0 {
return nil, fmt.Errorf("filled cells cannot contain notes")
}
result := slices.Clone(notes)
slices.Sort(result)
result = slices.Compact(result)
for _, note := range result {
if note < 1 || note > max {
return nil, fmt.Errorf("note %d is out of range", note)
}
}
return result, nil
}
func positionMatches(pos *Position, props Properties, row, column int) bool {
return pos.row == row && pos.column == column &&
pos.blockRow == row/props.BlockSizeRow && pos.blockColumn == column/props.BlockSizeColumn &&
pos.inBlockRow == row%props.BlockSizeRow && pos.inBlockColumn == column%props.BlockSizeColumn
}
func (f *Field) GetRow(row int) (*Row, error) {
if !f.hasStructure() || row < 0 || row >= f.props.Rows {
return nil, ErrOutOfBounds
}
cells := make([]*Cell, f.props.Columns)
for column := range f.cells[row] {
cells[column] = &f.cells[row][column]
}
return &Row{Line: Line{cells: cells}}, nil
}
func (f *Field) GetColumn(column int) (*Column, error) {
if !f.hasStructure() || column < 0 || column >= f.props.Columns {
return nil, ErrOutOfBounds
}
cells := make([]*Cell, f.props.Rows)
for row := range f.cells {
cells[row] = &f.cells[row][column]
}
return &Column{Line: Line{cells: cells}}, nil
}
func (f *Field) GetBlock(row, column int) (*Block, error) {
if !f.hasStructure() || row < 0 || row >= f.props.BlockRows || column < 0 || column >= f.props.BlockColumns {
return nil, ErrOutOfBounds
}
startRow := row * f.props.BlockSizeRow
startColumn := column * f.props.BlockSizeColumn
cells := make([][]*Cell, f.props.BlockSizeRow)
for blockRow := range cells {
cells[blockRow] = make([]*Cell, f.props.BlockSizeColumn)
for blockColumn := range cells[blockRow] {
cells[blockRow][blockColumn] = &f.cells[startRow+blockRow][startColumn+blockColumn]
}
}
return &Block{cells: cells}, nil
}
func (f *Field) GetEachPartAtPos(pos *Position) ([]Part, error) {
if pos == nil {
return nil, ErrOutOfBounds
}
row, err := f.GetRow(pos.row) row, err := f.GetRow(pos.row)
if err != nil { if err != nil {
return nil return nil, err
} }
column, err := f.GetColumn(pos.column) column, err := f.GetColumn(pos.column)
if err != nil { if err != nil {
return nil return nil, err
} }
block, err := f.GetBlock(pos.blockRow, pos.blockColumn) block, err := f.GetBlock(pos.blockRow, pos.blockColumn)
if err != nil { if err != nil {
return nil return nil, err
} }
return []Part{row, column, block}, nil
return append([]Part{}, row, column, block)
} }
func (f *Field) GetCell(r, c int) (*Cell, error) { func (f *Field) GetCell(row, column int) (*Cell, error) {
if r > f.props.Rows || c > f.props.Columns { if !f.hasStructure() || row < 0 || row >= f.props.Rows || column < 0 || column >= f.props.Columns {
return nil, outOfBound return nil, ErrOutOfBounds
} }
return &f.cells[r][c], nil return &f.cells[row][column], nil
}
func (f *Field) GetProperties() (Properties, error) {
if !f.hasStructure() {
return Properties{}, ErrInvalidField
}
return *f.props, nil
} }
func (f *Field) GetRating() float64 { func (f *Field) GetRating() float64 {
if !f.hasStructure() {
return 0
}
return f.props.Rating return f.props.Rating
} }
// ────────────────────────────────────────────────────────────────────────────── // func (f *Field) ForEachPart(fn func(Part)) {
// FOREACH FUNCTIONS // if !f.hasStructure() || fn == nil {
// ────────────────────────────────────────────────────────────────────────────── // return
}
func (f *Field) ForEachPart(fn func(part Part)) { f.ForEachRow(func(row *Row) { fn(row) })
f.ForEachRow(func(row *Row) { f.ForEachColumn(func(column *Column) { fn(column) })
fn(row) f.ForEachBlock(func(block *Block) { fn(block) })
})
f.ForEachColumn(func(column *Column) {
fn(column)
})
f.ForEachBlock(func(block *Block) {
fn(block)
})
} }
func (f *Field) ForEachPartAtPos(pos *Position, fn func(part Part)) { func (f *Field) ForEachPartAtPos(pos *Position, fn func(Part)) error {
parts := f.GetEachPartAtPos(pos) if fn == nil {
return nil
}
parts, err := f.GetEachPartAtPos(pos)
if err != nil {
return err
}
for _, part := range parts { for _, part := range parts {
fn(part) fn(part)
} }
return nil
}
func (f *Field) ForEachRow(fn func(*Row)) {
if !f.hasStructure() || fn == nil {
return
}
for row := 0; row < f.props.Rows; row++ {
part, _ := f.GetRow(row)
fn(part)
}
} }
func (f *Field) ForEachRow(fn func(row *Row)) { func (f *Field) ForEachColumn(fn func(*Column)) {
for i := range f.props.Rows { if !f.hasStructure() || fn == nil {
row, err := f.GetRow(i)
if err != nil {
fmt.Println(err.Error())
return return
} }
fn(row) for column := 0; column < f.props.Columns; column++ {
part, _ := f.GetColumn(column)
fn(part)
} }
} }
func (f *Field) ForEachColumn(fn func(column *Column)) { func (f *Field) ForEachBlock(fn func(*Block)) {
for i := range f.props.Columns { if !f.hasStructure() || fn == nil {
column, err := f.GetColumn(i)
if err != nil {
fmt.Println(err.Error())
return return
} }
fn(column) for row := 0; row < f.props.BlockRows; row++ {
for column := 0; column < f.props.BlockColumns; column++ {
part, _ := f.GetBlock(row, column)
fn(part)
}
} }
} }
func (f *Field) ForEachBlock(fn func(block *Block)) { func (f *Field) ForEachCell(fn func(*Cell)) {
for r := range f.props.BlockRows { if !f.hasStructure() || fn == nil {
for c := range f.props.BlockColumns {
block, err := f.GetBlock(r, c)
if err != nil {
fmt.Println(err.Error())
return return
} }
fn(block) for row := range f.cells {
for column := range f.cells[row] {
fn(&f.cells[row][column])
} }
} }
} }
func (f *Field) ForEachCell(fn func(cell *Cell)) {
for _, row := range f.cells {
for _, cell := range row {
fn(&cell)
}
}
}
// ────────────────────────────────────────────────────────────────────────────── //
// MODIFIER //
// ────────────────────────────────────────────────────────────────────────────── //
func (f *Field) AddChange(eChange *ExternalChange) {
change := Change{
Cell: eChange.Cell,
marks: eChange.Marks,
action: eChange.Action,
value: eChange.Value,
from: eChange.From,
triggerdBy: eChange.TriggerdBy,
}
change.do()
f.changes = append(f.changes, change)
}
func (f *Field) SetRating(rating float64) { func (f *Field) SetRating(rating float64) {
if f != nil && f.props != nil {
f.props.Rating = rating f.props.Rating = rating
}
} }
// ────────────────────────────────────────────────────────────────────────────── //
// STATE //
// ────────────────────────────────────────────────────────────────────────────── //
func (f *Field) String() string { func (f *Field) String() string {
//TODO: auf beliebige größen anpassen if !f.hasStructure() {
var sb strings.Builder return "<invalid field>"
}
// Oberer Rahmen var result strings.Builder
sb.WriteString("╔═══════╤═══════╤═══════╗\n") for row := 0; row < f.props.Rows; row++ {
if row > 0 && row%f.props.BlockSizeRow == 0 {
for i := range f.cells { result.WriteByte('\n')
sb.WriteString("║ ") // Linke Rahmenseite }
for column := 0; column < f.props.Columns; column++ {
for j, cell := range f.cells[i] { if column > 0 {
// Wert ausgeben oder Punkt für 0 if column%f.props.BlockSizeColumn == 0 {
val := cell.number result.WriteString(" | ")
if val == 0 {
sb.WriteString("·")
} else { } else {
sb.WriteString(string('0' + val)) result.WriteByte(' ')
} }
}
// Trennlinien zwischen Blöcken und Zellen value := f.cells[row][column].number
if (j+1)%3 == 0 { if value == 0 {
if j < 8 { result.WriteRune('·')
sb.WriteString(" │ ")
} else { } else {
sb.WriteString(" ║\n") // Rechte Rahmenseite + Zeilenumbruch result.WriteString(strconv.Itoa(value))
}
} else {
sb.WriteString(" ")
} }
} }
if row+1 < f.props.Rows {
// Horizontale Trennlinien nach jeder 3. Zeile result.WriteByte('\n')
if (i+1)%3 == 0 && i < 8 {
sb.WriteString("╟───────┼───────┼───────╢\n")
} }
} }
return result.String()
// Unterer Rahmen
sb.WriteString("╚═══════╧═══════╧═══════╝")
return sb.String()
} }
func (f *Field) StringNotesForNumber(n int) string { func (f *Field) StringNotesForNumber(number int) string {
return "StringNotesForNumber is not implementet" if !f.hasStructure() || number < 1 || number > f.props.Rows {
return ""
}
var result strings.Builder
f.ForEachCell(func(cell *Cell) {
if cell.notes.Has(number) {
fmt.Fprintf(&result, "%d/%d\n", cell.pos.row, cell.pos.column)
}
})
return strings.TrimSuffix(result.String(), "\n")
} }
func (f *Field) StringNotes() string { func (f *Field) StringNotes() string {
var str strings.Builder if !f.hasStructure() {
fmt.Fprintf(&str, "Notes:\n") return "Notes:"
}
var result strings.Builder
result.WriteString("Notes:\n")
f.ForEachCell(func(cell *Cell) { f.ForEachCell(func(cell *Cell) {
fmt.Fprintf(&str, "Pos: %d/%d - Notes: %v\n", cell.Pos.row, cell.Pos.column, cell.Notes.numbers) fmt.Fprintf(&result, "Pos: %d/%d - Notes: %v\n", cell.pos.row, cell.pos.column, cell.notes.numbers)
}) })
return str.String() return strings.TrimSuffix(result.String(), "\n")
} }
func (f *Field) IsSolved() bool { func (f *Field) IsSolved() bool {
result := true if !f.IsValid() {
f.ForEachCell(func(cell *Cell) {
if cell.number == 0 {
result = false
}
})
/* TODO:
if !f.isValid() {
return false return false
} }
*/ for row := range f.cells {
for column := range f.cells[row] {
return result if f.cells[row][column].number == 0 {
return false
}
}
}
return true
} }
func (f *Field) IsValid() bool { func (f *Field) IsValid() bool {
//In jedem Part gibt es jede Zahl max ein mal return f.hasStructure() && f.hasValidNumbers() && f.hasValidNotes()
//Die Zahl kommt nicht in intersecting parts vor }
func (f *Field) hasStructure() bool {
if f == nil || f.props == nil || validateProperties(*f.props) != nil || len(f.cells) != f.props.Rows {
return false
}
for row := range f.cells {
if len(f.cells[row]) != f.props.Columns {
return false
}
for column := range f.cells[row] {
cell := &f.cells[row][column]
if cell.notes == nil || cell.pos == nil || !positionMatches(cell.pos, *f.props, row, column) || cell.number < 0 || cell.number > f.props.Rows {
return false
}
}
}
return true
}
func (f *Field) hasValidNumbers() bool {
if !f.hasStructure() {
return false
}
valid := true
f.ForEachPart(func(part Part) {
seen := make(map[int]struct{}, f.props.Rows)
part.ForEachCell(func(cell *Cell) {
if cell.number == 0 || !valid {
return
}
if _, exists := seen[cell.number]; exists {
valid = false
return
}
seen[cell.number] = struct{}{}
})
})
return valid
}
func (f *Field) hasValidNotes() bool {
if !f.hasStructure() {
return false
}
valid := true
f.ForEachCell(func(cell *Cell) {
if !valid {
return
}
if cell.number != 0 && len(cell.notes.numbers) != 0 {
valid = false
return
}
previous := 0
for _, note := range cell.notes.numbers {
if note <= previous || note > f.props.Rows || f.hasPeerNumber(cell, note) {
valid = false
return
}
previous = note
}
})
return valid
}
func (f *Field) ownsCell(cell *Cell) bool {
if !f.hasStructure() || cell == nil || cell.pos == nil {
return false
}
row, column := cell.pos.row, cell.pos.column
return row >= 0 && row < f.props.Rows && column >= 0 && column < f.props.Columns && &f.cells[row][column] == cell
}
func (f *Field) hasPeerNumber(cell *Cell, number int) bool {
if !f.ownsCell(cell) {
return false
}
row, column := cell.pos.row, cell.pos.column
for index := 0; index < f.props.Columns; index++ {
if index != column && f.cells[row][index].number == number {
return true
}
}
for index := 0; index < f.props.Rows; index++ {
if index != row && f.cells[index][column].number == number {
return true
}
}
startRow := cell.pos.blockRow * f.props.BlockSizeRow
startColumn := cell.pos.blockColumn * f.props.BlockSizeColumn
for blockRow := 0; blockRow < f.props.BlockSizeRow; blockRow++ {
for blockColumn := 0; blockColumn < f.props.BlockSizeColumn; blockColumn++ {
peer := &f.cells[startRow+blockRow][startColumn+blockColumn]
if peer != cell && peer.number == number {
return true
}
}
}
return false return false
} }
func (f *Field) snapshot() fieldSnapshot {
state := fieldSnapshot{numbers: make([][]int, len(f.cells)), notes: make([][][]int, len(f.cells)), changes: slices.Clone(f.changes)}
for row := range f.cells {
state.numbers[row] = make([]int, len(f.cells[row]))
state.notes[row] = make([][]int, len(f.cells[row]))
for column := range f.cells[row] {
state.numbers[row][column] = f.cells[row][column].number
state.notes[row][column] = slices.Clone(f.cells[row][column].notes.numbers)
}
}
return state
}
func (f *Field) restore(state fieldSnapshot) {
for row := range f.cells {
for column := range f.cells[row] {
f.cells[row][column].number = state.numbers[row][column]
f.cells[row][column].notes.numbers = slices.Clone(state.notes[row][column])
}
}
f.changes = slices.Clone(state.changes)
}
+244
View File
@@ -0,0 +1,244 @@
package field
import (
"errors"
"reflect"
"strings"
"testing"
)
const solvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func classicCells(t *testing.T, digits string) (Properties, [][]Cell) {
t.Helper()
if len(digits) != 81 {
t.Fatalf("test puzzle has %d characters", len(digits))
}
props := Properties{Rows: 9, Columns: 9, BlockRows: 3, BlockColumns: 3, BlockSizeRow: 3, BlockSizeColumn: 3}
cells := make([][]Cell, props.Rows)
for row := range cells {
cells[row] = make([]Cell, props.Columns)
for column := range cells[row] {
position := NewPosition(row, column, row/3, column/3, row%3, column%3)
cells[row][column] = *NewCell(int(digits[row*9+column]-'0'), position)
}
}
return props, cells
}
func mustField(t *testing.T, digits string) *Field {
t.Helper()
props, cells := classicCells(t, digits)
result, err := New(props, cells)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return result
}
func TestNewValidatesStructureAndValues(t *testing.T) {
props, cells := classicCells(t, strings.Repeat("0", 81))
tests := []struct {
name string
mutate func(*Properties, [][]Cell)
}{
{name: "missing row", mutate: func(_ *Properties, cells [][]Cell) { cells[0] = cells[0][:8] }},
{name: "wrong position", mutate: func(_ *Properties, cells [][]Cell) { cells[0][0].pos = NewPosition(1, 0, 0, 0, 0, 0) }},
{name: "number out of range", mutate: func(_ *Properties, cells [][]Cell) { cells[0][0].number = 10 }},
{name: "invalid blocks", mutate: func(props *Properties, _ [][]Cell) { props.BlockRows = 2 }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
localProps := props
localCells := make([][]Cell, len(cells))
for row := range cells {
localCells[row] = append([]Cell(nil), cells[row]...)
}
test.mutate(&localProps, localCells)
if _, err := New(localProps, localCells); !errors.Is(err, ErrInvalidField) {
t.Fatalf("New() error = %v, want ErrInvalidField", err)
}
})
}
duplicate := "113456789" + solvedPuzzle[9:]
duplicateProps, duplicateCells := classicCells(t, duplicate)
if _, err := New(duplicateProps, duplicateCells); !errors.Is(err, ErrInvalidField) {
t.Fatalf("New() duplicate error = %v, want ErrInvalidField", err)
}
}
func TestGetCellBoundsAndForEachCellPointers(t *testing.T) {
f := mustField(t, strings.Repeat("0", 81))
for _, coordinates := range [][2]int{{-1, 0}, {0, -1}, {9, 0}, {0, 9}} {
if _, err := f.GetCell(coordinates[0], coordinates[1]); !errors.Is(err, ErrOutOfBounds) {
t.Errorf("GetCell(%d, %d) error = %v", coordinates[0], coordinates[1], err)
}
}
want, _ := f.GetCell(4, 5)
var found *Cell
f.ForEachCell(func(cell *Cell) {
if cell.GetPosition().GetRow() == 4 && cell.GetPosition().GetColumn() == 5 {
found = cell
}
})
if found != want {
t.Fatalf("ForEachCell() returned %p, want stored cell %p", found, want)
}
}
func TestValidityAndSolvedState(t *testing.T) {
incomplete := mustField(t, strings.Repeat("0", 81))
if !incomplete.IsValid() || incomplete.IsSolved() {
t.Fatalf("empty field: IsValid=%v IsSolved=%v", incomplete.IsValid(), incomplete.IsSolved())
}
solved := mustField(t, solvedPuzzle)
if !solved.IsValid() || !solved.IsSolved() {
t.Fatalf("solved field: IsValid=%v IsSolved=%v", solved.IsValid(), solved.IsSolved())
}
row, _ := solved.GetRow(0)
block, _ := solved.GetBlock(0, 0)
if !row.IsSolved() || !block.IsSolved() {
t.Fatal("completed row and block must be solved")
}
solved.cells[0][0].number = solved.cells[0][1].number
if solved.IsValid() || solved.IsSolved() {
t.Fatal("field with a duplicate must be invalid and unsolved")
}
}
func TestChangesAreValidatedAndAtomic(t *testing.T) {
f := mustField(t, strings.Repeat("0", 81))
first, _ := f.GetCell(0, 0)
second, _ := f.GetCell(0, 1)
err := f.AddChanges([]ExternalChange{
{Cell: first, Action: ActionSetNumber, Value: 1, From: 0},
{Cell: second, Action: ActionSetNumber, Value: 1, From: 0},
})
if !errors.Is(err, ErrInvalidChange) {
t.Fatalf("AddChanges() error = %v, want ErrInvalidChange", err)
}
if first.GetNumber() != 0 || second.GetNumber() != 0 || len(f.GetChanges()) != 0 {
t.Fatal("failed change batch was not rolled back")
}
foreign := NewCell(0, NewPosition(0, 0, 0, 0, 0, 0))
if err := f.AddChange(&ExternalChange{Cell: foreign, Action: ActionSetNumber, Value: 1}); !errors.Is(err, ErrInvalidChange) {
t.Fatalf("foreign cell error = %v, want ErrInvalidChange", err)
}
if err := (*Field)(nil).AddChanges(nil); !errors.Is(err, ErrInvalidField) {
t.Fatalf("nil field error = %v, want ErrInvalidField", err)
}
}
func TestNotesStaySortedUniqueAndArePropagated(t *testing.T) {
f := mustField(t, strings.Repeat("0", 81))
target, _ := f.GetCell(0, 0)
rowPeer, _ := f.GetCell(0, 1)
columnPeer, _ := f.GetCell(1, 0)
blockPeer, _ := f.GetCell(1, 1)
unrelated, _ := f.GetCell(4, 4)
for _, note := range []int{5, 1, 5} {
if err := target.GetNotes().Add(f, target, note, "test", nil); err != nil {
t.Fatalf("add target note: %v", err)
}
}
if got, want := target.GetNotes().Get(), []int{1, 5}; !reflect.DeepEqual(got, want) {
t.Fatalf("notes = %v, want %v", got, want)
}
for _, cell := range []*Cell{rowPeer, columnPeer, blockPeer, unrelated} {
if err := cell.GetNotes().Add(f, cell, 5, "test", nil); err != nil {
t.Fatalf("add peer note: %v", err)
}
}
if err := target.SetNumber(f, 5, "test", nil); err != nil {
t.Fatalf("SetNumber() error = %v", err)
}
for _, peer := range []*Cell{target, rowPeer, columnPeer, blockPeer} {
if peer.GetNotes().Has(5) {
row, column := peer.GetPosition().GetCoords()
t.Fatalf("note 5 was not removed at %d/%d", row, column)
}
}
if !unrelated.GetNotes().Has(5) {
t.Fatal("note was removed from an unrelated cell")
}
if f.IsValid() == false {
t.Fatal("field must remain valid after candidate propagation")
}
filled, _ := f.GetCell(0, 2)
if err := filled.SetNumber(f, 2, "test", nil); err != nil {
t.Fatal(err)
}
if err := filled.GetNotes().Add(f, filled, 3, "test", nil); !errors.Is(err, ErrInvalidChange) {
t.Fatalf("note on filled cell error = %v, want ErrInvalidChange", err)
}
if err := rowPeer.GetNotes().Add(f, rowPeer, 2, "test", nil); !errors.Is(err, ErrInvalidChange) {
t.Fatalf("invalid peer note error = %v, want ErrInvalidChange", err)
}
}
func TestRemoveNumberRecordsCorrectAction(t *testing.T) {
f := mustField(t, strings.Repeat("0", 81))
cell, _ := f.GetCell(0, 0)
if err := cell.SetNumber(f, 1, "set", nil); err != nil {
t.Fatal(err)
}
if err := cell.RemoveNumber(f, "remove", nil); err != nil {
t.Fatal(err)
}
changes := f.GetChanges()
if changes[len(changes)-1].Action != ActionRemoveNumber {
t.Fatalf("last action = %v, want ActionRemoveNumber", changes[len(changes)-1].Action)
}
}
func TestStringRendersDigitsAndNotes(t *testing.T) {
f := mustField(t, "900000000"+strings.Repeat("0", 72))
cell, _ := f.GetCell(0, 1)
if err := cell.GetNotes().Add(f, cell, 1, "test", nil); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(f.String(), "9 · ·") {
t.Fatalf("String() = %q", f.String())
}
if got := f.StringNotesForNumber(1); got != "0/1" {
t.Fatalf("StringNotesForNumber() = %q, want 0/1", got)
}
}
func TestGettersReturnDefensiveCopies(t *testing.T) {
f := mustField(t, strings.Repeat("0", 81))
props, err := f.GetProperties()
if err != nil {
t.Fatal(err)
}
props.Rows = 4
stored, _ := f.GetProperties()
if stored.Rows != 9 {
t.Fatal("GetProperties exposed internal properties")
}
cell, _ := f.GetCell(0, 0)
if err := cell.GetNotes().Add(f, cell, 1, "test", nil); err != nil {
t.Fatal(err)
}
notes := cell.GetNotes().Get()
notes[0] = 9
if !cell.GetNotes().Has(1) || cell.GetNotes().Has(9) {
t.Fatal("Notes.Get exposed its backing slice")
}
changes := f.GetChanges()
changes[0].Value = 9
if f.GetChanges()[0].Value != 1 {
t.Fatal("GetChanges exposed its backing slice")
}
}
+79 -67
View File
@@ -7,83 +7,72 @@ import (
type Part interface { type Part interface {
IsSolved() bool IsSolved() bool
ForEachCell(fn func(cell *Cell)) ForEachCell(func(*Cell))
GetMissingNumbers() []int GetMissingNumbers() []int
RemoveNote(field *Field, note int, trigger string, marks []Mark) RemoveNote(*Field, int, string, []Mark) error
String() string String() string
} }
// ────────────────────────────────────────────────────────────────────────────── //
// LINE STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Line struct { type Line struct {
cells []*Cell cells []*Cell
} }
func (l *Line) IsSolved() bool { func (l *Line) IsSolved() bool {
//TODO return partIsSolved(l)
return false
} }
func (l *Line) ForEachCell(fn func(cell *Cell)) { func (l *Line) ForEachCell(fn func(*Cell)) {
if l == nil || fn == nil {
return
}
for _, cell := range l.cells { for _, cell := range l.cells {
fn(cell) fn(cell)
} }
} }
func (l *Line) GetMissingNumbers() []int { func (l *Line) GetMissingNumbers() []int {
return getMissingNumbersHelper(len(l.cells), l.ForEachCell) if l == nil {
return nil
}
return getMissingNumbers(len(l.cells), l.ForEachCell)
} }
func (l *Line) RemoveNote(field *Field, note int, trigger string, marks []Mark) { func (l *Line) RemoveNote(f *Field, note int, trigger string, marks []Mark) error {
l.ForEachCell(func(cell *Cell) { return removeNoteFromPart(l, f, note, trigger, marks)
cell.Notes.Remove(field, cell, note, trigger, marks)
})
} }
func (l *Line) String() string { func (l *Line) String() string {
return lineString(l, "Line") return partString(l, "Line")
} }
// ────────────────────────────────────────────────────────────────────────────── //
// ROW STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Row struct { type Row struct {
Line Line
} }
func (r *Row) String() string { func (r *Row) String() string {
return lineString(r, "Row") return partString(r, "Row")
} }
// ────────────────────────────────────────────────────────────────────────────── //
// COLUMN STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Column struct { type Column struct {
Line Line
} }
func (c *Column) String() string { func (c *Column) String() string {
return lineString(c, "Column") return partString(c, "Column")
} }
// ────────────────────────────────────────────────────────────────────────────── //
// BLOCK STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Block struct { type Block struct {
cells [][]*Cell cells [][]*Cell
} }
func (b *Block) IsSolved() bool { func (b *Block) IsSolved() bool {
//TODO return partIsSolved(b)
return false
} }
func (b *Block) ForEachCell(fn func(cell *Cell)) { func (b *Block) ForEachCell(fn func(*Cell)) {
if b == nil || fn == nil {
return
}
for _, row := range b.cells { for _, row := range b.cells {
for _, cell := range row { for _, cell := range row {
fn(cell) fn(cell)
@@ -92,56 +81,79 @@ func (b *Block) ForEachCell(fn func(cell *Cell)) {
} }
func (b *Block) GetMissingNumbers() []int { func (b *Block) GetMissingNumbers() []int {
max := len(b.cells) * len(b.cells[0]) if b == nil || len(b.cells) == 0 {
return getMissingNumbersHelper(max, b.ForEachCell) return nil
}
size := 0
for _, row := range b.cells {
size += len(row)
}
return getMissingNumbers(size, b.ForEachCell)
} }
func (b *Block) RemoveNote(field *Field, note int, trigger string, marks []Mark) { func (b *Block) RemoveNote(f *Field, note int, trigger string, marks []Mark) error {
b.ForEachCell(func(cell *Cell) { return removeNoteFromPart(b, f, note, trigger, marks)
cell.Notes.Remove(field, cell, note, trigger, marks)
})
} }
func (b *Block) String() string { func (b *Block) String() string {
var str strings.Builder return partString(b, "Block")
str.WriteString("Block:\t")
lastLine := 0
b.ForEachCell(func(cell *Cell) {
fmt.Fprintf(&str, "%d ", cell.number)
if lastLine == cell.Pos.inBlockRow {
}
})
return str.String()
} }
// ────────────────────────────────────────────────────────────────────────────── // func partIsSolved(part Part) bool {
// HELPER // if part == nil {
// ────────────────────────────────────────────────────────────────────────────── // return false
}
func lineString(part Part, name string) string { cellCount := 0
var str strings.Builder seen := make(map[int]struct{})
fmt.Fprintf(&str, "%s:\t", name) valid := true
part.ForEachCell(func(cell *Cell) { part.ForEachCell(func(cell *Cell) {
fmt.Fprintf(&str, "%d ", cell.number) cellCount++
if cell == nil || cell.number <= 0 {
valid = false
return
}
if _, exists := seen[cell.number]; exists {
valid = false
}
seen[cell.number] = struct{}{}
}) })
return str.String() return valid && cellCount > 0 && len(seen) == cellCount
} }
func getMissingNumbersHelper(max int, iterate func(fn func(cell *Cell))) []int { func removeNoteFromPart(part Part, f *Field, note int, trigger string, marks []Mark) error {
present := make(map[int]bool, max) var firstErr error
part.ForEachCell(func(cell *Cell) {
if firstErr != nil || !cell.notes.Has(note) {
return
}
firstErr = cell.notes.Remove(f, cell, note, trigger, marks)
})
return firstErr
}
func partString(part Part, name string) string {
if part == nil {
return name + ":"
}
var result strings.Builder
fmt.Fprintf(&result, "%s:\t", name)
part.ForEachCell(func(cell *Cell) {
fmt.Fprintf(&result, "%d ", cell.GetNumber())
})
return strings.TrimSuffix(result.String(), " ")
}
func getMissingNumbers(max int, iterate func(func(*Cell))) []int {
present := make(map[int]struct{}, max)
iterate(func(cell *Cell) { iterate(func(cell *Cell) {
if cell.number != 0 { if cell != nil && cell.number != 0 {
present[cell.number] = true present[cell.number] = struct{}{}
} }
}) })
missing := make([]int, 0, max-len(present))
var missing []int for number := 1; number <= max; number++ {
for i := 1; i <= max; i++ { if _, exists := present[number]; !exists {
if !present[i] { missing = append(missing, number)
missing = append(missing, i)
} }
} }
return missing return missing
+27
View File
@@ -22,37 +22,64 @@ func NewPosition(row, column, blockRow, blockColumn, inBlockRow, inBlockColumn i
} }
func (p *Position) GetRow() int { func (p *Position) GetRow() int {
if p == nil {
return 0
}
return p.row return p.row
} }
func (p *Position) GetColumn() int { func (p *Position) GetColumn() int {
if p == nil {
return 0
}
return p.column return p.column
} }
func (p *Position) GetBlockRow() int { func (p *Position) GetBlockRow() int {
if p == nil {
return 0
}
return p.blockRow return p.blockRow
} }
func (p *Position) GetBlockColumn() int { func (p *Position) GetBlockColumn() int {
if p == nil {
return 0
}
return p.blockColumn return p.blockColumn
} }
func (p *Position) GetInBlockRow() int { func (p *Position) GetInBlockRow() int {
if p == nil {
return 0
}
return p.inBlockRow return p.inBlockRow
} }
func (p *Position) GetInBlockColumn() int { func (p *Position) GetInBlockColumn() int {
if p == nil {
return 0
}
return p.inBlockColumn return p.inBlockColumn
} }
func (p *Position) GetCoords() (int, int) { func (p *Position) GetCoords() (int, int) {
if p == nil {
return 0, 0
}
return p.row, p.column return p.row, p.column
} }
func (p *Position) GetBlockCoords() (int, int) { func (p *Position) GetBlockCoords() (int, int) {
if p == nil {
return 0, 0
}
return p.blockRow, p.blockColumn return p.blockRow, p.blockColumn
} }
func (p *Position) GetInBlockCoords() (int, int) { func (p *Position) GetInBlockCoords() (int, int) {
if p == nil {
return 0, 0
}
return p.inBlockRow, p.inBlockColumn return p.inBlockRow, p.inBlockColumn
} }
+53 -35
View File
@@ -1,65 +1,83 @@
package logic package logic
import ( import (
"errors"
"fmt"
"git.kleiax.de/homepage/field" "git.kleiax.de/homepage/field"
"git.kleiax.de/homepage/logic/strategies" "git.kleiax.de/homepage/logic/strategies"
) )
// ────────────────────────────────────────────────────────────────────────────── // var ErrNoProgress = errors.New("no strategy can solve the puzzle")
// SOLVER STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── //
type Solver struct { type Solver struct {
strategies []strategies.Strategy strategies []strategies.Strategy
returnTo int
field *field.Field field *field.Field
conf struct {
all bool
repeat bool
}
} }
func (s *Solver) Add(strategy strategies.Strategy) { func (s *Solver) Add(strategy strategies.Strategy) {
if strategy != nil {
s.strategies = append(s.strategies, strategy) s.strategies = append(s.strategies, strategy)
}
} }
func (s *Solver) InitStragies(field *field.Field) { func (s *Solver) InitStrategies(f *field.Field) error {
s.returnTo = 1 if f == nil || !f.IsValid() {
s.field = field return field.ErrInvalidField
}
s.field = f
for _, strategy := range s.strategies { for _, strategy := range s.strategies {
strategy.Init(field) strategy.Init(f)
} }
return nil
} }
func (s *Solver) Run(i int) bool { // InitStragies is kept for compatibility. New code should use InitStrategies.
for j := 0; j < len(s.strategies); j++ { func (s *Solver) InitStragies(f *field.Field) error {
if s.strategies[j].SearchProgressableCells() == 0 { return s.InitStrategies(f)
continue }
}
// Run applies strategies from the requested index until the field is solved or
for _, change := range s.strategies[j].ApplyAll() { // no strategy can make progress. It returns false without an error for a valid
if change.Action != field.ActionSetNumber { // but currently unsolved field.
continue func (s *Solver) Run(start int) (bool, error) {
} if s.field == nil || !s.field.IsValid() {
s.field.ForEachPartAtPos(change.Cell.Pos, func(part field.Part) { return false, field.ErrInvalidField
part.ForEachCell(func(cell *field.Cell) { }
cell.Notes.Remove(s.field, cell, change.Value, "remove note after insert of a number", nil) if start < 0 || start > len(s.strategies) {
}) return false, fmt.Errorf("strategy index %d out of range", start)
})
}
j = s.returnTo - 1
} }
for {
if s.field.IsSolved() { if s.field.IsSolved() {
return true return true, nil
}
progress := false
for index := start; index < len(s.strategies); index++ {
strategy := s.strategies[index]
if strategy.SearchProgressableCells() == 0 {
continue
}
changes, err := strategy.ApplyAll()
if err != nil {
return false, fmt.Errorf("apply strategy %q: %w", strategy.Name(), err)
}
if len(changes) == 0 {
continue
}
progress = true
break
}
if !progress {
return false, nil
}
start = 0
} }
return false
} }
func (s *Solver) Search() { func (s *Solver) Search() {}
}
func (s *Solver) GetSolutionPath() []field.ExternalChange { func (s *Solver) GetSolutionPath() []field.ExternalChange {
return nil return nil
+71
View File
@@ -0,0 +1,71 @@
package logic_test
import (
"errors"
"testing"
"git.kleiax.de/homepage/field"
"git.kleiax.de/homepage/logic"
"git.kleiax.de/homepage/logic/strategies"
"git.kleiax.de/homepage/parser"
)
const solvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func parsedField(t *testing.T, puzzle string) *field.Field {
t.Helper()
input := &parser.PuzzleString{}
if err := input.Parse([]byte(puzzle)); err != nil {
t.Fatal(err)
}
result, err := input.GetField(0)
if err != nil {
t.Fatal(err)
}
return result
}
func TestSolverRunSolvesAndDetectsNoProgress(t *testing.T) {
t.Run("solved", func(t *testing.T) {
f := parsedField(t, "0"+solvedPuzzle[1:])
solver := &logic.Solver{}
solver.Add(&strategies.LastDigit{})
if err := solver.InitStrategies(f); err != nil {
t.Fatal(err)
}
solved, err := solver.Run(0)
if err != nil || !solved || !f.IsSolved() {
t.Fatalf("Run() = (%v, %v), field solved = %v", solved, err, f.IsSolved())
}
})
t.Run("no progress", func(t *testing.T) {
f := parsedField(t, "0"+solvedPuzzle[1:])
solver := &logic.Solver{}
if err := solver.InitStrategies(f); err != nil {
t.Fatal(err)
}
solved, err := solver.Run(0)
if err != nil || solved {
t.Fatalf("Run() = (%v, %v), want (false, nil)", solved, err)
}
})
}
func TestSolverRejectsInvalidStateAndStrategyIndex(t *testing.T) {
solver := &logic.Solver{}
if _, err := solver.Run(0); !errors.Is(err, field.ErrInvalidField) {
t.Fatalf("Run() before initialization error = %v", err)
}
if err := solver.InitStrategies(nil); !errors.Is(err, field.ErrInvalidField) {
t.Fatalf("InitStrategies(nil) error = %v", err)
}
f := parsedField(t, "0"+solvedPuzzle[1:])
if err := solver.InitStrategies(f); err != nil {
t.Fatal(err)
}
if _, err := solver.Run(1); err == nil {
t.Fatal("Run() with invalid strategy index error = nil")
}
}
+10 -4
View File
@@ -17,13 +17,16 @@ func (ld *LastDigit) SearchProgressableCells() int {
missingNumbers := part.GetMissingNumbers() missingNumbers := part.GetMissingNumbers()
if len(missingNumbers) == 1 { if len(missingNumbers) == 1 {
var emptyCell *field.Cell var emptyCell *field.Cell
emptyCount := 0
part.ForEachCell(func(cell *field.Cell) { part.ForEachCell(func(cell *field.Cell) {
if cell.GetNumber() == 0 { if cell.GetNumber() == 0 {
emptyCell = cell emptyCell = cell
emptyCount++
} }
}) })
// fmt.Println(part) if emptyCount != 1 {
// fmt.Printf("Gefundene Zelle: %d/%d - %d, missungNumber: %v, Typ: %T\n", emptyCell.Pos.GetRow(), emptyCell.Pos.GetColumn(), emptyCell.GetNumber(), missingNumbers, part) return
}
change := field.ExternalChange{ change := field.ExternalChange{
Cell: emptyCell, Cell: emptyCell,
Action: field.ActionSetNumber, Action: field.ActionSetNumber,
@@ -31,10 +34,9 @@ func (ld *LastDigit) SearchProgressableCells() int {
From: emptyCell.GetNumber(), From: emptyCell.GetNumber(),
TriggerdBy: ld.getName(), TriggerdBy: ld.getName(),
} }
ld.changes = append(ld.changes, change) ld.queue(change)
} }
}) })
// fmt.Printf("LastDigit Changes %d\n", len(ld.changes))
return len(ld.changes) return len(ld.changes)
} }
@@ -42,6 +44,10 @@ func (ld *LastDigit) getName() string {
return "Last Digit" return "Last Digit"
} }
func (ld *LastDigit) Name() string {
return ld.getName()
}
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
// CRP STRUCTURE // // CRP STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
+9 -5
View File
@@ -15,8 +15,8 @@ func (hs *HiddenSingle) SearchProgressableCells() int {
m := make(map[int]int) m := make(map[int]int)
part.ForEachCell(func(cell *field.Cell) { part.ForEachCell(func(cell *field.Cell) {
notes := cell.Notes.Get() notes := cell.GetNotes().Get()
for note := range notes { for _, note := range notes {
m[note]++ m[note]++
} }
}) })
@@ -28,9 +28,9 @@ func (hs *HiddenSingle) SearchProgressableCells() int {
} }
} }
for num := range hiddenSingles { for _, num := range hiddenSingles {
part.ForEachCell(func(cell *field.Cell) { part.ForEachCell(func(cell *field.Cell) {
if cell.Notes.Has(num) { if cell.GetNumber() == 0 && cell.GetNotes().Has(num) {
change := field.ExternalChange{ change := field.ExternalChange{
Cell: cell, Cell: cell,
Action: field.ActionSetNumber, Action: field.ActionSetNumber,
@@ -38,7 +38,7 @@ func (hs *HiddenSingle) SearchProgressableCells() int {
From: cell.GetNumber(), From: cell.GetNumber(),
TriggerdBy: hs.getName(), TriggerdBy: hs.getName(),
} }
hs.changes = append(hs.changes, change) hs.queue(change)
} }
}) })
} }
@@ -50,6 +50,10 @@ func (hs *HiddenSingle) getName() string {
return "Hidden Single" return "Hidden Single"
} }
func (hs *HiddenSingle) Name() string {
return hs.getName()
}
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
// HIDDEN_PAIR STRUCTURE // // HIDDEN_PAIR STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
+6 -2
View File
@@ -13,7 +13,7 @@ type NakedSingle struct {
func (ns *NakedSingle) SearchProgressableCells() int { func (ns *NakedSingle) SearchProgressableCells() int {
ns.field.ForEachPart(func(part field.Part) { ns.field.ForEachPart(func(part field.Part) {
part.ForEachCell(func(cell *field.Cell) { part.ForEachCell(func(cell *field.Cell) {
candidates := cell.Notes.Get() candidates := cell.GetNotes().Get()
if len(candidates) == 1 && cell.GetNumber() == 0 { if len(candidates) == 1 && cell.GetNumber() == 0 {
change := field.ExternalChange{ change := field.ExternalChange{
Cell: cell, Cell: cell,
@@ -22,7 +22,7 @@ func (ns *NakedSingle) SearchProgressableCells() int {
From: cell.GetNumber(), From: cell.GetNumber(),
TriggerdBy: ns.getName(), TriggerdBy: ns.getName(),
} }
ns.changes = append(ns.changes, change) ns.queue(change)
} }
}) })
}) })
@@ -33,6 +33,10 @@ func (ns *NakedSingle) getName() string {
return "Naked Single" return "Naked Single"
} }
func (ns *NakedSingle) Name() string {
return ns.getName()
}
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
// NAKED_DOUBLE STRUCTURE // // NAKED_DOUBLE STRUCTURE //
// ────────────────────────────────────────────────────────────────────────────── // // ────────────────────────────────────────────────────────────────────────────── //
+12 -4
View File
@@ -1,6 +1,8 @@
package strategies package strategies
import ( import (
"sort"
"git.kleiax.de/homepage/field" "git.kleiax.de/homepage/field"
) )
@@ -15,11 +17,12 @@ type Notes struct {
func (n *Notes) SearchProgressableCells() int { func (n *Notes) SearchProgressableCells() int {
n.field.ForEachRow(func(row *field.Row) { n.field.ForEachRow(func(row *field.Row) {
row.ForEachCell(func(cell *field.Cell) { row.ForEachCell(func(cell *field.Cell) {
column, _ := n.field.GetColumn(cell.Pos.GetColumn()) pos := cell.GetPosition()
block, _ := n.field.GetBlock(cell.Pos.GetBlockRow(), cell.Pos.GetBlockColumn()) column, _ := n.field.GetColumn(pos.GetColumn())
block, _ := n.field.GetBlock(pos.GetBlockRow(), pos.GetBlockColumn())
candidates := intersection3(row.GetMissingNumbers(), column.GetMissingNumbers(), block.GetMissingNumbers()) candidates := intersection3(row.GetMissingNumbers(), column.GetMissingNumbers(), block.GetMissingNumbers())
for _, note := range candidates { for _, note := range candidates {
if cell.Notes.Has(note) || cell.GetNumber() != 0 { if cell.GetNotes().Has(note) || cell.GetNumber() != 0 {
continue continue
} }
ch := field.ExternalChange{ ch := field.ExternalChange{
@@ -30,7 +33,7 @@ func (n *Notes) SearchProgressableCells() int {
Marks: nil, Marks: nil,
From: 0, From: 0,
} }
n.changes = append(n.changes, ch) n.queue(ch)
} }
}) })
}) })
@@ -41,6 +44,10 @@ func (n *Notes) getName() string {
return "Make Notes" return "Make Notes"
} }
func (n *Notes) Name() string {
return n.getName()
}
func intersection3(a, b, c []int) []int { func intersection3(a, b, c []int) []int {
set := make(map[int]bool) set := make(map[int]bool)
@@ -76,6 +83,7 @@ func intersection3(a, b, c []int) []int {
for v := range set { for v := range set {
result = append(result, v) result = append(result, v)
} }
sort.Ints(result)
return result return result
} }
+82
View File
@@ -0,0 +1,82 @@
package strategies
import (
"strings"
"testing"
"git.kleiax.de/homepage/field"
)
const solvedGrid = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func strategyField(t *testing.T, digits string) *field.Field {
t.Helper()
props := field.Properties{Rows: 9, Columns: 9, BlockRows: 3, BlockColumns: 3, BlockSizeRow: 3, BlockSizeColumn: 3}
cells := make([][]field.Cell, 9)
for row := range cells {
cells[row] = make([]field.Cell, 9)
for column := range cells[row] {
pos := field.NewPosition(row, column, row/3, column/3, row%3, column%3)
cells[row][column] = *field.NewCell(int(digits[row*9+column]-'0'), pos)
}
}
result, err := field.New(props, cells)
if err != nil {
t.Fatal(err)
}
return result
}
func TestLastDigitDeduplicatesOverlappingParts(t *testing.T) {
f := strategyField(t, "0"+solvedGrid[1:])
strategy := &LastDigit{}
strategy.Init(f)
if got := strategy.SearchProgressableCells(); got != 1 {
t.Fatalf("SearchProgressableCells() = %d, want 1", got)
}
changes, err := strategy.ApplyAll()
if err != nil {
t.Fatalf("ApplyAll() error = %v", err)
}
if len(changes) != 1 || !f.IsSolved() {
t.Fatalf("changes = %d, solved = %v", len(changes), f.IsSolved())
}
}
func TestHiddenSingleUsesCandidateValuesNotSliceIndexes(t *testing.T) {
f := strategyField(t, strings.Repeat("0", 81))
first, _ := f.GetCell(0, 0)
second, _ := f.GetCell(0, 1)
third, _ := f.GetCell(0, 2)
for _, setup := range []struct {
cell *field.Cell
note int
}{{first, 9}, {second, 8}, {third, 8}} {
if err := setup.cell.GetNotes().Add(f, setup.cell, setup.note, "test", nil); err != nil {
t.Fatal(err)
}
}
strategy := &HiddenSingle{}
strategy.Init(f)
strategy.SearchProgressableCells()
found := false
for _, change := range strategy.changes {
if change.Cell == first && change.Value == 9 {
found = true
}
}
if !found {
t.Fatal("hidden single for candidate value 9 was not found")
}
}
func TestIntersection3IsSorted(t *testing.T) {
got := intersection3([]int{9, 1, 5}, []int{5, 9, 1}, []int{9, 5, 1})
want := []int{1, 5, 9}
for i := range want {
if got[i] != want[i] {
t.Fatalf("intersection3() = %v, want %v", got, want)
}
}
}
+23 -18
View File
@@ -12,8 +12,8 @@ import (
type Strategy interface { type Strategy interface {
Init(f *field.Field) Init(f *field.Field)
ApplyAll() []field.ExternalChange ApplyAll() ([]field.ExternalChange, error)
ApplyNext() field.ExternalChange ApplyNext() (field.ExternalChange, error)
Name() string Name() string
SearchProgressableCells() int SearchProgressableCells() int
} }
@@ -40,35 +40,40 @@ func (b *Base) Init(f *field.Field) {
b.field = f b.field = f
} }
func (b *Base) ApplyAll() []field.ExternalChange { func (b *Base) ApplyAll() ([]field.ExternalChange, error) {
changesCopy := make([]field.ExternalChange, len(b.changes)) changesCopy := make([]field.ExternalChange, len(b.changes))
copy(changesCopy, b.changes) copy(changesCopy, b.changes)
for _, change := range b.changes {
b.field.AddChange(&change)
}
b.changes = b.changes[:0] b.changes = b.changes[:0]
if err := b.field.AddChanges(changesCopy); err != nil {
return changesCopy return nil, err
}
return changesCopy, nil
} }
func (b *Base) ApplyNext() field.ExternalChange { func (b *Base) ApplyNext() (field.ExternalChange, error) {
if len(b.changes) < 1 { if len(b.changes) < 1 {
return field.ExternalChange{} return field.ExternalChange{}, nil
} }
changeCopy := b.changes[0] changeCopy := b.changes[0]
b.field.AddChange(&b.changes[0])
b.changes = b.changes[1:] b.changes = b.changes[1:]
if err := b.field.AddChange(&changeCopy); err != nil {
return field.ExternalChange{}, err
}
return changeCopy, nil
}
return changeCopy func (b *Base) queue(change field.ExternalChange) {
for _, existing := range b.changes {
if existing.Cell == change.Cell && existing.Action == change.Action &&
existing.Value == change.Value && existing.From == change.From {
return
}
}
b.changes = append(b.changes, change)
} }
func (b *Base) getName() string { func (b *Base) getName() string {
return "Unkown" return "Unknown"
} }
func (b *Base) Name() string { func (b *Base) Name() string {
+1 -1
View File
@@ -17,7 +17,7 @@ func main() {
solver.Add(&strategies.LastDigit{}) solver.Add(&strategies.LastDigit{})
solver.Add(&strategies.NakedSingle{}) solver.Add(&strategies.NakedSingle{})
solver.Add(&strategies.HiddenSingle{}) solver.Add(&strategies.HiddenSingle{})
game, err := sudoku.New(&parser.PuzzleBank{}, solver, openFile()) game, err := sudoku.New(&parser.PuzzleBank{}, solver, openFile(), 2)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
+90 -14
View File
@@ -1,30 +1,106 @@
package parser package parser
import "git.kleiax.de/homepage/field" import (
"errors"
"fmt"
"slices"
"strings"
//TODO: const für klassik sudoku board.Props "git.kleiax.de/homepage/field"
//TODO: funktion für [][]Cell generierung )
var (
ErrFieldIndex = errors.New("field index out of range")
ErrInvalidInput = errors.New("invalid puzzle input")
ErrNoPuzzles = errors.New("input contains no puzzles")
)
func ClassicProperties() field.Properties {
return field.Properties{
Rows: 9,
Columns: 9,
BlockRows: 3,
BlockColumns: 3,
BlockSizeRow: 3,
BlockSizeColumn: 3,
}
}
type Parser interface { type Parser interface {
Parse(data []byte) error Parse([]byte) error
GetField(i int) *field.Field GetField(int) (*field.Field, error)
GetAllFields() []field.Field GetAllFields() []*field.Field
} }
type ParserHelper struct { type ParserHelper struct {
fields []field.Field fields []*field.Field
} }
func (ph *ParserHelper) GetField(i int) *field.Field { func (ph *ParserHelper) GetField(index int) (*field.Field, error) {
if i < 0 || i >= len(ph.fields) { if ph == nil || index < 0 || index >= len(ph.fields) {
return &field.Field{} return nil, fmt.Errorf("%w: %d", ErrFieldIndex, index)
} }
return &ph.fields[i] return ph.fields[index], nil
} }
func (ph *ParserHelper) GetAllFields() []field.Field { func (ph *ParserHelper) GetAllFields() []*field.Field {
if len(ph.fields) == 0 { if ph == nil {
return nil return nil
} }
return ph.fields return slices.Clone(ph.fields)
}
func classicField(digits string, rating float64) (*field.Field, error) {
props := ClassicProperties()
if len(digits) != props.Rows*props.Columns {
return nil, fmt.Errorf("%w: puzzle has %d characters, expected 81", ErrInvalidInput, len(digits))
}
props.Rating = rating
cells := make([][]field.Cell, props.Rows)
for row := 0; row < props.Rows; row++ {
cells[row] = make([]field.Cell, props.Columns)
for column := 0; column < props.Columns; column++ {
char := digits[row*props.Columns+column]
if char < '0' || char > '9' {
return nil, fmt.Errorf("%w: puzzle contains invalid character %q at position %d", ErrInvalidInput, char, row*props.Columns+column)
}
position := field.NewPosition(
row,
column,
row/props.BlockSizeRow,
column/props.BlockSizeColumn,
row%props.BlockSizeRow,
column%props.BlockSizeColumn,
)
cells[row][column] = *field.NewCell(int(char-'0'), position)
}
}
result, err := field.New(props, cells)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidInput, err)
}
return result, nil
}
// PuzzleString parses one classic Sudoku represented by exactly 81 digits.
// Whitespace around the complete string is ignored; 0 denotes an empty cell.
type PuzzleString struct {
ParserHelper
}
func (p *PuzzleString) Parse(data []byte) error {
if p == nil {
return errors.New("nil puzzle string parser")
}
digits := strings.TrimSpace(string(data))
if digits == "" {
return ErrNoPuzzles
}
parsed, err := classicField(digits, 0)
if err != nil {
return fmt.Errorf("parse puzzle string: %w", err)
}
p.fields = []*field.Field{parsed}
return nil
} }
+110
View File
@@ -0,0 +1,110 @@
package parser
import (
"crypto/sha1"
"errors"
"fmt"
"strings"
"testing"
)
const validSolvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func bankRecord(digits string, rating float64) string {
hash := fmt.Sprintf("%x", sha1.Sum([]byte(digits)))[:12]
return fmt.Sprintf("%s %s %.1f", hash, digits, rating)
}
func TestPuzzleBankParsesLFCRLFAndFinalLine(t *testing.T) {
first := "0" + validSolvedPuzzle[1:]
data := bankRecord(first, 1.2) + "\r\n\n" + bankRecord(validSolvedPuzzle, 2.5)
parser := &PuzzleBank{}
if err := parser.Parse([]byte(data)); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got := len(parser.GetAllFields()); got != 2 {
t.Fatalf("field count = %d, want 2", got)
}
second, err := parser.GetField(1)
if err != nil {
t.Fatal(err)
}
if second.GetRating() != 2.5 || !second.IsSolved() {
t.Fatalf("second field: rating=%v solved=%v", second.GetRating(), second.IsSolved())
}
}
func TestPuzzleBankRejectsMalformedInput(t *testing.T) {
valid := bankRecord(validSolvedPuzzle, 1.2)
tests := map[string]string{
"empty": "\n\r\n",
"columns": "broken",
"hash length": "abc " + validSolvedPuzzle + " 1.2",
"hash mismatch": "ffffffffffff" + valid[12:],
"bad puzzle": bankRecord("x"+validSolvedPuzzle[1:], 1.2),
"bad rating": strings.TrimSuffix(valid, "1.2") + "nope",
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if err := (&PuzzleBank{}).Parse([]byte(input)); err == nil || (name != "empty" && !errors.Is(err, ErrInvalidInput)) {
t.Fatalf("Parse() error = %v", err)
}
})
}
}
func TestPuzzleBankParseIsAtomicAndDoesNotAppend(t *testing.T) {
parser := &PuzzleBank{}
valid := bankRecord(validSolvedPuzzle, 1.2)
if err := parser.Parse([]byte(valid)); err != nil {
t.Fatal(err)
}
original, _ := parser.GetField(0)
if err := parser.Parse([]byte(valid + "\ninvalid")); err == nil {
t.Fatal("invalid Parse() error = nil")
}
afterFailure, _ := parser.GetField(0)
if afterFailure != original || len(parser.GetAllFields()) != 1 {
t.Fatal("failed parse changed previously parsed fields")
}
if err := parser.Parse([]byte(valid)); err != nil {
t.Fatal(err)
}
if len(parser.GetAllFields()) != 1 {
t.Fatal("repeated Parse() appended fields")
}
}
func TestParserHelperBoundsAndDefensiveSlice(t *testing.T) {
parser := &PuzzleString{}
if err := parser.Parse([]byte(validSolvedPuzzle)); err != nil {
t.Fatal(err)
}
for _, index := range []int{-1, 1} {
if _, err := parser.GetField(index); !errors.Is(err, ErrFieldIndex) {
t.Fatalf("GetField(%d) error = %v", index, err)
}
}
fields := parser.GetAllFields()
fields[0] = nil
if stored, err := parser.GetField(0); err != nil || stored == nil {
t.Fatal("GetAllFields exposed its backing slice")
}
}
func TestPuzzleStringValidatesInput(t *testing.T) {
parser := &PuzzleString{}
if err := parser.Parse([]byte(" \n" + validSolvedPuzzle + "\n")); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if fields := parser.GetAllFields(); len(fields) != 1 || !fields[0].IsSolved() {
t.Fatal("valid puzzle string was not parsed")
}
invalid := []string{"", validSolvedPuzzle[:80], "x" + validSolvedPuzzle[1:], "11" + validSolvedPuzzle[2:]}
for _, input := range invalid {
if err := (&PuzzleString{}).Parse([]byte(input)); err == nil {
t.Fatalf("Parse(%q) error = nil", input)
}
}
}
+36 -43
View File
@@ -2,73 +2,66 @@ package parser
import ( import (
"bytes" "bytes"
"crypto/sha1"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings"
"git.kleiax.de/homepage/field" "git.kleiax.de/homepage/field"
) )
// https://github.com/grantm/sudoku-exchange-puzzle-bank/tree/master
type PuzzleBank struct { type PuzzleBank struct {
ParserHelper ParserHelper
} }
func (pb *PuzzleBank) Parse(data []byte) error { func (pb *PuzzleBank) Parse(data []byte) error {
if pb == nil {
return errors.New("nil puzzle bank parser")
}
lines := bytes.Split(data, []byte("\n")) var parsed []*field.Field
for _, line := range lines { for index, rawLine := range bytes.Split(data, []byte("\n")) {
if len(line) == 0 { // Überspringe leere Zeile lineNumber := index + 1
line := strings.TrimSpace(string(rawLine))
if line == "" {
continue continue
} }
if len(line) != 99 { columns := strings.Fields(line)
return fmt.Errorf("Zeile hat die falsche länge. soll: 100, ist: %d", len(line)) if len(columns) != 3 {
return fmt.Errorf("line %d: %w: expected hash, puzzle and rating", lineNumber, ErrInvalidInput)
}
if len(columns[0]) != 12 {
return fmt.Errorf("line %d: %w: hash has %d characters, expected 12", lineNumber, ErrInvalidInput, len(columns[0]))
}
if _, err := hex.DecodeString(columns[0]); err != nil {
return fmt.Errorf("line %d: %w: invalid hash: %v", lineNumber, ErrInvalidInput, err)
}
digest := sha1.Sum([]byte(columns[1]))
if columns[0] != hex.EncodeToString(digest[:])[:12] {
return fmt.Errorf("line %d: %w: hash does not match puzzle", lineNumber, ErrInvalidInput)
} }
//siehe Readme in github repo rating, err := strconv.ParseFloat(columns[2], 64)
//sha1Hash := bytes.TrimSpace(line[0:12]) // Wird aktuell nicht gebraucht, später zum sudoku vergleichen
sudokuStr := string(line[13:94])
ratingStr := string(line[96:99])
//fmt.Println(sudokuStr)
var rating float64
_, err := fmt.Sscanf(ratingStr, "%f", &rating)
if err != nil { if err != nil {
return errors.New("can nor parse raiting") return fmt.Errorf("line %d: %w: invalid rating %q: %v", lineNumber, ErrInvalidInput, columns[2], err)
} }
if rating < 0 || math.IsNaN(rating) || math.IsInf(rating, 0) {
props := field.Properties{ return fmt.Errorf("line %d: %w: rating must be a finite non-negative number", lineNumber, ErrInvalidInput)
Rows: 9,
Columns: 9,
BlockRows: 3,
BlockColumns: 3,
BlockSizeRow: 3,
BlockSizeColumn: 3,
Rating: rating,
} }
parsedField, err := classicField(columns[1], rating)
cells := make([][]field.Cell, props.Rows)
for i := range cells {
cells[i] = make([]field.Cell, props.Columns)
for j := range cells[i] {
number, err := strconv.Atoi(string(sudokuStr[i*9+j]))
if err != nil { if err != nil {
return fmt.Errorf("Kann Zeichen nicht in Zahl konvertieren") return fmt.Errorf("line %d: %w", lineNumber, err)
} }
pos := field.NewPosition( parsed = append(parsed, parsedField)
i,
j,
i/props.BlockRows,
j/props.BlockColumns,
i%props.BlockRows,
j%props.BlockColumns)
cells[i][j] = *field.NewCell(number, pos)
}
}
pb.fields = append(pb.fields, *field.New(props, cells))
} }
if len(parsed) == 0 {
return ErrNoPuzzles
}
pb.fields = parsed
return nil return nil
} }
+30 -23
View File
@@ -14,36 +14,43 @@ type Game struct {
solver logic.Solver solver logic.Solver
} }
func New(parser parser.Parser, solver logic.Solver, data []byte) (*Game, error) { func New(input parser.Parser, solver logic.Solver, data []byte, fieldIndex int) (*Game, error) {
err := parser.Parse(data) if input == nil {
if err != nil { return nil, errors.New("cannot create game: nil parser")
return &Game{}, fmt.Errorf("can not create game: %w", err)
} }
return &Game{solver: solver, field: parser.GetField(2)}, nil if err := input.Parse(data); err != nil {
return nil, fmt.Errorf("cannot create game: %w", err)
}
parsedField, err := input.GetField(fieldIndex)
if err != nil {
return nil, fmt.Errorf("cannot create game: %w", err)
}
if parsedField == nil || !parsedField.IsValid() {
return nil, fmt.Errorf("cannot create game: %w", field.ErrInvalidField)
}
return &Game{solver: solver, field: parsedField}, nil
} }
func (g *Game) Solve() error { func (g *Game) Solve() error {
g.solver.InitStragies(g.field) if g == nil || g.field == nil {
for !g.isFinished() { return field.ErrInvalidField
if !g.nextSolveStep() {
return errors.New("no strategy can solve the puzzle")
} }
if err := g.solver.InitStrategies(g.field); err != nil {
return err
}
solved, err := g.solver.Run(0)
if err != nil {
return err
}
if !solved {
return logic.ErrNoProgress
} }
return nil return nil
} }
func (g *Game) nextSolveStep() bool { func (g *Game) GetField() *field.Field {
return g.solver.Run(0) if g == nil {
} return nil
}
func (g *Game) prevSolveStep() { return g.field
// far far in the future
}
func (g *Game) isFinished() bool {
return g.field.IsSolved()
}
func (g *Game) GetField() field.Field {
return *g.field
} }
+65
View File
@@ -0,0 +1,65 @@
package sudoku
import (
"crypto/sha1"
"errors"
"fmt"
"testing"
"git.kleiax.de/homepage/logic"
"git.kleiax.de/homepage/logic/strategies"
"git.kleiax.de/homepage/parser"
)
const solvedSudoku = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func puzzleBankRecord(digits string, rating float64) string {
hash := fmt.Sprintf("%x", sha1.Sum([]byte(digits)))[:12]
return fmt.Sprintf("%s %s %.1f", hash, digits, rating)
}
func TestNewSelectsExplicitPuzzleIndex(t *testing.T) {
data := puzzleBankRecord("0"+solvedSudoku[1:], 1.0) + "\n" + puzzleBankRecord(solvedSudoku, 2.0)
game, err := New(&parser.PuzzleBank{}, logic.Solver{}, []byte(data), 1)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if game.GetField().GetRating() != 2 || !game.GetField().IsSolved() {
t.Fatal("New() did not select puzzle index 1")
}
}
func TestNewRejectsInvalidIndexAndInput(t *testing.T) {
valid := puzzleBankRecord(solvedSudoku, 1.0)
if _, err := New(&parser.PuzzleBank{}, logic.Solver{}, []byte(valid), 1); !errors.Is(err, parser.ErrFieldIndex) {
t.Fatalf("index error = %v, want ErrFieldIndex", err)
}
if _, err := New(&parser.PuzzleString{}, logic.Solver{}, []byte("invalid"), 0); err == nil {
t.Fatal("invalid input error = nil")
}
}
func TestSolveCompletesSimplePuzzle(t *testing.T) {
solver := logic.Solver{}
solver.Add(&strategies.LastDigit{})
game, err := New(&parser.PuzzleString{}, solver, []byte("0"+solvedSudoku[1:]), 0)
if err != nil {
t.Fatal(err)
}
if err := game.Solve(); err != nil {
t.Fatalf("Solve() error = %v", err)
}
if !game.GetField().IsSolved() {
t.Fatal("field is not solved")
}
}
func TestSolveReportsNoProgress(t *testing.T) {
game, err := New(&parser.PuzzleString{}, logic.Solver{}, []byte("0"+solvedSudoku[1:]), 0)
if err != nil {
t.Fatal(err)
}
if err := game.Solve(); !errors.Is(err, logic.ErrNoProgress) {
t.Fatalf("Solve() error = %v, want ErrNoProgress", err)
}
}
+10 -11
View File
@@ -1,11 +1,10 @@
- Feld immer wieder auf Validität checken (keine doppelten Zahlen in einem Part) damit keine Fehler bei den Strategien auftauchen können # Offene Punkte
- Validator in Logic oder Field?
- kommt in field Der priorisierte und aktuelle Stand befindet sich in [ROADMAP.md](ROADMAP.md).
- field sollte flag haben ob vor einem change geprüft wird, ob das Feld danach noch valid ist
- Für die solver ein Interface implementieren, damit die folgenden Solver benutzt werden können: - gemeinsames Interface für menschlichen Solver, Backtracking und DLX
- backtracking Solver implementieren
- dlx solver - strukturiertes, optionales Logging an den Anwendungsgrenzen ergänzen
- notes (menschlicher solver) - optionalen Diagnosemodus für zusätzliche Invariantenprüfungen entwickeln
- Was passiert wenn zum Beispiel bei hiddenSingle ein Treffer zwei Changes auslöst? Muss ein Überprüfung her, ob die betreffende Zelle schon in einem vorheringen Change bearbeitet wird? Ich denka ja - Lösungsweg, Gruppierung von Änderungen und Undo/Redo implementieren
- Debugging verbesser, Loglevel etc, überall sinnvolle Logs einbauen - weitere menschliche Lösungsstrategien in der Reihenfolge der Roadmap ergänzen
- Flag oder so für sich selbst überprüfende Sachen, nur im debug modus nach jedem bisschen das Feld auf validität prüfen