feat: add initial project scaffolding with Makefile, C sources, web UI, and documentation

Add Makefile with build targets for sudoku solver, generator, and coverage reporting.
Include NOTES.md explaining sudoku difficulty factors and solving techniques.
Add README.md with puzzle examples and generator output.
Implement app.js with Grid and Grid2 classes for rendering puzzle data in the browser.
Add box.c with functions for box indexing, grid-to-array conversion, and box value extraction.
Include footer.h with terminal size detection, cursor positioning, and footer display utilities.
Add index.html with CSS grid layout and script loading for the web interface.
Include rlib.h as a large utility header library.
This commit is contained in:
2025-01-10 21:53:10 +00:00
commit caac0f8341
41 changed files with 21766 additions and 0 deletions
+711
View File
@@ -0,0 +1,711 @@
<html>
<head>
</head>
<body>
<div id="app" class="app"></div>
<pre>
TODO:
- if orange fields, no other selections should be possible than deselect.
</pre>
<template id="template_sudoku_container">
<div style="width:640px;height:640px;font-size:1.2em;" class="container-content"></div>
</template>
<template id="template_sudoku_parser">
<div style="width:100%;height:100%" class="parser-content"></div>
</template>
<template id="template_sudoku_cell">
<div class="cell-content"></div>
</template>
<style type="text/css">
.app {
width: 400px;
height: 400px;
}
.cell-content {
font-family: 'Courier New', Courier, monospace;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
border: 1px solid #CCCCCC;
text-align: center;
height: 10%;
width: 10%;
float: left;
display: flex;
justify-content: center;
align-items: center;
font-size: calc(5px + 1vw);
}
.sudoku-cell-selected {
background-color: lightgreen;
}
.sudoku-cell-invalid {
color: red;
font-weight: bold;
}
</style>
<script type="text/javascript">
const game_size = 9;
const container_template = document.getElementById('template_sudoku_container');
const cell_template = document.getElementById("template_sudoku_cell");
const sudokuParserTemplate = document.getElementById('template_sudoku_parser');
const container = document.getElementById('app'); //.importNode(container_template.content, true);
const _hashChars = "abcdefghijklmnopqrstuvwxyz\"':[]0123456789";
function hashString(str) {
let result = 0;
for (let i = 0; i < str.length; i++) {
result += (_hashChars.indexOf(str[i]) + 1) * (i * 40);
}
return result;
}
class State {
number = 0
cells = []
selection = []
selectionCount = 0
_string = null
_hash = null
_json = null;
constructor(arg) {
if (isNaN(arg)) {
this.number = arg.number;
this.cells = arg.cells;
this.selectCount = arg.selectionCount;
this._hash = arg._hash;
this._json = arg;
} else {
this.number = arg;
}
}
toJson() {
if (this._json == null) {
const json = {
number: this.number,
cells: this.cells,
selectionCount: this.selectionCount,
_hash: this.getHash()
};
this._json = json;
}
return this._json;
}
getHash() {
if (this._hash == null) {
let started = false;
let count = 0;
let hash = this.cells.filter((cell) => {
if (!started && cell.state[0] == 0 && cell.state[1] == 1)
return false;
started = true;;
count++;
return true;
}).map(cell => {
return cell.state;
}).join('');
this._hash = `${count}${hash}`;
}
return this._hash
}
equals(otherState) {
if (otherState.selectionCount != this.selectionCount)
return false;
return otherState.getHash() == this.getHash();
}
toString() {
if (this._string == null) {
this._string = JSON.stringify({
number: this.number,
selection: this.selection.map(cell => cell.toString()),
cells: this.cells
});
}
return this._string
}
};
class Cell {
row = 0;
col = 0;
initial = false;
letter = null
name = null
options = [];
value = 0;
values = []
element = null;
app = null;
selected = false;
container = null;
async solve(){
this.app.pushState();
let originalValues = this.values;
let valid = false;
this.selected = false;
for(let i = 1; i < 10; i++){
this.addNumber(i);
if(this.validate())
if(await this.app.solve())
return true;
this.value = 0;
this.values = originalValues;
}
return false;
}
getState() {
return `${this.selected ? 1 : 0}${this.valid ? 1 : 0}`;
}
toggleSelect() {
this.selected = !this.selected;
if (this.selected) {
this.select();
//this.element.classList.add('sudoku-cell-selected');
} else {
this.deSelect();
//this.element.classList.remove('sudoku-cell-selected');
}
this.update();
return this.selected;
}
async addNumber(value) {
this.values.pop(value);
this.values.push(value);
this.value = Number(value);
const _this = this;
window.requestAnimationFrame(() => {
this.element.textContent = this.value == 0 ? "" : String(this.value);
})
this.validate();
//this.update();
}
onClick() {
//this.
if (!this.initial)
this.toggleSelect();
//this.app.onCellClick(this)
}
deSelect() {
this.selected = false;
this.element.classList.remove('sudoku-cell-selected');
}
select() {
this.selected = true;
this.element.classList.add('sudoku-cell-selected');
}
validateBox(){
let startRow = this.row - this.row % (9 / 3);
let startCol = this.col - this.col % (9 / 3);
for (let i = startRow; i < 9 / 3; i++) {
for (let j = startCol; j < 9 / 3; j++) {
let fieldIndex = (i * 9) + j;
console.info(fieldIndex);
if (this.app.cells[fieldIndex].value == this.value) {
return false;
}
}
return true
}
return true;
}
isValid() {
const _this = this;
this.valid = !this.value && (!this.app.cells.filter(cell => {
return cell.value != 0 &&
cell != _this &&
(cell.row == _this.row || cell.col == _this.col) &&
cell.value == _this.value;
}).length && this.validateBox()); //&& !this.app.getBoxValues(this.name).indexOf(this.value) == -1);
return this.valid;
}
update() {
if (this.selected)
this.select()
else
this.deSelect()
this.app.cells.forEach(cell => {
cell.validate()
})
this.element.textContent = this.value ? String(this.value) : "";
}
validate() {
if (this.isValid() || !this.value) {
this.element.classList.remove('sudoku-cell-invalid');
} else {
this.element.classList.add('sudoku-cell-invalid');
}
return this.valid;
}
destructor() {
//this.container.delete();
}
constructor(app, row, col) {
this.app = app;
this.container = document.importNode(cell_template.content, true);
this.row = row;
this.col = col;
this.selected = false;
this.letter = "abcdefghi"[row];
this.name = `${this.letter}${this.col}`
this.value = 0;
this.values = [];
this.element = this.container.querySelector('.cell-content');
this.valid = true;
const _this = this;
this.element.addEventListener('click', (e) => {
_this.onClick();
});
this.element.addEventListener('mousemove', (e) => {
if (!_this.initial && e.buttons == 1)
_this.select();
else if (!_this.initial && e.buttons == 2)
_this.deSelect();
else
_this.app.pushState()
});
this.element.addEventListener('mouseexit', (e) => {
if (!e.buttons) {
// _this.app.pushState();
}
});
this.element.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (!_this.initial && _this.selected) {
_this.deSelect();
} else {
_this.values.pop(_this.value);
_this.addNumber(0);
_this.deSelect();
_this.update();
//_this.app.deSelectAll();
}
});
}
redraw() {
}
render(container) {
container.appendChild(this.container);
}
toString() {
return `${this.row}:${this.col}`
}
}
class SudokuParser {
app = null
element = null
container = null
blank = true
content = ''
size = 9
constructor(app, container) {
//this.container = container;
this.app = app;
this.container = document.importNode(sudokuParserTemplate.content, true);
this.element = document.createElement('div');
this.element.style.width = '90%';
this.element.style.height = '50%';
this.element.border = '1px solid #CCCCCC';
this.content = '';
this.cells = []
this.element.contentEditable = true;
this.element.textContent = 'Paste puzzle here';//this.container.querySelector('.parser-content');
this.toggle();
this.blank = true;
const _this = this;
this.element.addEventListener('click', (e) => {
if (_this.blank)
_this.element.textContent = '';
_this.blank = false;
});
this.element.addEventListener('contextmenu', (e) => {
if (_this.blank)
_this.element.textContent = '';
this.blank = false;
});
this.element.addEventListener('input', (e) => {
_this.element.innerHTML = this.element.textContent;
_this.parseAndApply();
});
this.element.addEventListener('keyup', (e) => {
//_this.parseAndApply();
});
container.appendChild(this.element);
}
parseAndApply() {
this.parse();
this.apply();
}
parse() {
const content = this.element.textContent;
const regex = /\d/g;
const matches = [...content.matchAll(regex)];
let row = 0;
let col = 0;
const cells = []
const max = this.size * this.size;
matches.forEach(match => {
if (row * col == max) {
return;
}
if (col == 9) {
row++;
col = 0;
}
if (row == 9) {
row = 0;
col = 0;
}
const digit = match[0];
const cell = new Cell(this.app, row, col);
cell.addNumber(digit);
cell.initial = true;
cells.push(cell);
col++;
});
this.cells = cells;
}
apply() {
this.app.cells.forEach(cell => {
cell.initial = false;
cell.number = 0;
cell.numbers = [];
cell.addNumber(0);
});
this.cells.forEach(cell => {
const appCell = this.app.getCellByName(cell.name);
appCell.initial = cell.value != 0;
appCell.addNumber(cell.value);
});
this.toggle();
}
toggle() {
if (this.element.style.display == 'none') {
this.element.innerHTML = 'Paste here your puzzle';
}
this.element.style.display = this.element.style.display != 'none' ? 'none' : 'block';
}
}
class Sudoku {
cells = [];
game_size = 0;
cell_count = 0;
selectedCells = []
container = null
element = null
states = []
previousSelection = []
state_number = 1
parser = null;
status = null;
reset(){
this.cells.forEach(cell=>{
cell.values = []
cell.selected = false;
cell.addNumber(0);
})
}
loadSession() {
const session = this.getSession();
if (!session)
return null;
this.state_number = session.state_number;
this.states = session.states.map(state => {
return new State(state);
});
this.refreshState();
}
async getEmptyCell(){
return this.cells.filter(cell=>{
if(cell.value == 0)
return true
return false
})[0]
}
async solve() {
const cell = await this.getEmptyCell();
if(!cell)
return this.isValid();
return await cell.solve();
}
deleteSession(){
localStorage.removeItem('session');
}
getSession() {
const session = localStorage.getItem('session');
if (!session) {
return null
}
return JSON.parse(session);
}
saveSession() {
this.pushState();
const states = this.states.map(state => {
return state.toJson()
});
const session = {
state_number: this.state_number,
states: states
}
localStorage.setItem('session', JSON.stringify(session));
//console.info('session saved');
}
getBoxValues(cell_name) {
let values = this.cells.filter(cell => {
return cell.name != cell_name && cell.name[0] == cell_name[0]
}).map(cell => {
return cell.value
});
return values;
}
toggle() {
this.container.style.display = this.container.style.display != 'none' ? 'none' : 'block';
}
toggleParser() {
//this.parser.toggle();
this.deSelectAll();
this.parser.toggle()
}
constructor(container, game_size) {
const _this = this;
this.container = container
this.element = container
this.parser = new SudokuParser(this, this.container);
this.game_size = game_size;
this.cell_count = game_size * game_size;
for (let row = 0; row < this.game_size; row++) {
for (let col = 0; col < this.game_size; col++) {
this.cells.push(new Cell(this, row, col));
}
}
console.info("Loading session");
setTimeout(()=>{
if(_this.status == "applying state"){
_this.deleteSession();
window.location.reload();
}else{
console.info("Finished session validation");
}
},10000);
this.loadSession();
document.addEventListener('keypress', (e) => {
if (!isNaN(e.key) || e.key == 'd') {
let number = e.key == 'd' ? 0 : Number(e.key);
_this.addNumberToSelection(number);
//console.info({set:Number(e.key)});
}
if (e.key == 'p') {
_this.toggleParser();
}
if (e.key == 'u') {
_this.popState();
}
if (e.key == 'r') {
if (this.selection().length) {
this.pushState();
_this.deSelectAll();
} else {
let state = this.getLastState();
if (state) {
state.cells.filter(cell => cell.selected).forEach(cell => {
this.getCellByName(cell.name).select();
})
}
}
}
});
this.element.addEventListener('mousemove', (e) => {
//this.pushState();
})
document.addEventListener('dblclick', (e) => {
_this.previousSelection = _this.selection();
_this.cells.forEach(cell => {
cell.deSelect();
});
});
document.addEventListener('contextmenu', (e) => {
});
this.element.addEventListener('mouseexit', (e) => {
// Edge case while holding mouse button while dragging out. Should save state
_this.pushState();
});
this.element.addEventListener('mouseup', (e) => {
_this.pushState();
});
this.pushState()
}
isValid() {
return this.cells.filter(cell => !cell.isValid()).length == 0
}
createState() {
const state = new State(this.state_number)
let selectedCount = 0;
state.cells = this.cells.map(cell => {
if (cell.selected) {
selectedCount++;
}
return { name: cell.name, values: cell.values, value: cell.value, selected: cell.selected, state: cell.getState() }
});
state.selectedCount = selectedCount;
return state;
}
pushState() {
const state = this.createState();
const previousState = this.getLastState();
if (!previousState || !previousState.equals(state)) {
this.states.push(state);
this.state_number++;
this.saveSession();
//console.info({ pushState: state.getHash(), length: state.getHash().length, number: state.number });
}
}
refreshState() {
const state = this.getLastState();
if (!state)
return null;
this.applyState(state)
return state;
}
getLastState() {
return this.states.length ? this.states.at(this.states.length - 1) : null;
}
applyState(state) {
this.status = "applying state";
state.cells.forEach(stateCell => {
const cell = this.getCellByName(stateCell.name);
cell.selected = stateCell.selected;
cell.values = stateCell.values;
cell.value = stateCell.value;
cell.update();
})
this.status = "applied state"
}
popState() {
let state = this.states.pop();
if (!state)
return;
if (state.equals(this.createState())) {
return this.popState();
}
this.applyState(state);
this.saveSession();
//console.info({ popState: state.getHash(), length: state.getHash().length, number: state.number });
}
getCellByName(name) {
return this.cells.filter(cell => {
return cell.name == name
})[0]
}
deSelectAll() {
this.cells.forEach(cell => {
cell.deSelect();
});
}
selection() {
return this.cells.filter(cell => {
return cell.selected
});
}
onSelectionToggle() {
}
addNumberToSelection(number) {
const _this = this;
this.pushState();
this.selection().forEach((cell) => {
cell.addNumber(number)
cell.update();
})
_this.pushState();
if (this.isValid()) {
this.deSelectAll();
}
_this.pushState();
}
onCellDblClick(cell) {
this.previousSelection = this.selection();
this.popState()
let originalSelected = this.selctedCells
if (cell.selected) {
this.selectedCells.push(cell);
} else {
this.selectedCells.pop(cell);
}
if (!this.originalSelected != this.selectedCells) {
this.popState();
}
//console.info({selected:this.selectedCells});
}
render() {
this.cells.forEach(cell => {
cell.render(this.element);
});
}
}
const sudoku = new Sudoku(container, 9);
sudoku.render();
const app = sudoku;
/*
document.addEventListener('contextmenu',(e)=>{
e.preventDefault();
});*/
/*
for(let i = 0; i < game_size*game_size; i++){
const cell = document.importNode(cell_template.content,true);
app.appendChild(cell);
}*/
//document.body.appendChild(app);
</script>
</body>