initial commit

This commit is contained in:
2021-02-19 18:30:02 -07:00
parent e13b4d737b
commit 7f9cffbfdd
16 changed files with 2865 additions and 0 deletions

83
src/graphql.ts Normal file
View File

@ -0,0 +1,83 @@
import {
Application,
Router,
RouterContext,
graphql,
makeExecutableSchema,
} from "./mods";
export interface ResolversProps {
Query?: any;
Mutation?: any;
[dynamicProperty: string]: any;
}
export interface ApplyGraphQLOptions {
app: Application;
path?: string;
typeDefs: any;
resolvers: ResolversProps;
context?: (ctx: RouterContext) => any;
}
export const applyGraphQL = ({
app,
path = "/graphql",
typeDefs,
resolvers,
context,
}: ApplyGraphQLOptions) => {
const schema = makeExecutableSchema({
typeDefs,
resolvers,
logger: {
log: (err: any) => console.log(err),
},
});
const router = new Router();
router.post(path, async (ctx) => {
const { response, request } = ctx;
if (!request.is("application/json") || !request.body) {
response.status = 415;
response.body = {
error: { message: "Request body must be in json format." },
};
return;
}
const contextResult = context ? await context(ctx) : ctx;
const { query, variables, operationName } = request.body;
try {
if (!query) {
response.status = 422;
response.body = {
error: { message: "Body missing 'query' parameter." },
};
return;
}
const result: any = await graphql(
schema,
query,
resolvers,
contextResult,
variables,
operationName
);
response.body = result;
} catch (error) {
response.status = 500;
response.body = {
error: error.message,
};
}
});
app.use(router.routes());
app.use(router.allowedMethods());
};

6
src/graphql/index.ts Normal file
View File

@ -0,0 +1,6 @@
import { mergeTypeDefs, mergeResolvers } from "../mods";
const modules = [require("./sudoku")];
export const typeDefs = mergeTypeDefs(modules.map((mod) => mod.typeDefs));
export const resolvers = mergeResolvers(modules.map((mod) => mod.resolvers));

55
src/graphql/sudoku.ts Normal file
View File

@ -0,0 +1,55 @@
import { gql } from "../mods";
import { generate } from "../sudoku/index";
export const typeDefs = gql`
type Cell {
value: Int
}
"""
A sudoku
"""
type Sudoku {
"The width of each region."
regionWidth: Int!
"The height of each region."
regionHeight: Int!
"The total number of cells in the board."
cells: Int!
"The 'raw' board, an array of cells in region-first order."
raw: [Cell!]!
"The rows of the board, from top to bottom."
rows: [[Cell!]!]!
"The columns of the board, from left to right."
columns: [[Cell!]!]!
"The regions of the board, book-ordered."
regions: [[Cell!]!]!
}
type Query {
"Generates a new sudoku."
generate(regionWidth: Int = 3, regionHeight: Int = 3, clues: Int!): Sudoku!
}
`;
type GenerateArguments = {
regionWidth: number;
regionHeight: number;
clues: number;
};
export const resolvers = {
Query: {
generate: (
obj: any,
{ regionWidth, regionHeight, clues }: GenerateArguments
) => {
return generate(regionWidth, regionHeight, clues);
},
},
};

69
src/main.ts Normal file
View File

@ -0,0 +1,69 @@
import { Application, bodyParser } from "./mods";
import { applyGraphQL } from "./graphql";
import { typeDefs, resolvers } from "./graphql/index";
import stoppable from "stoppable";
const runtime: { server: undefined | stoppable.StoppableServer } = {
server: undefined,
};
async function main() {
const app = new Application();
app.use(
bodyParser({
enableTypes: ["json"],
})
);
app.use(async (ctx, next) => {
const start = Date.now();
await next();
console.log(
`\x1b[32m%s\x1b[0m %s \x1b[32m%dms\x1b[0m\n---`,
ctx.request.method,
ctx.request.url,
Date.now() - start
);
});
applyGraphQL({
app,
typeDefs: typeDefs,
resolvers: resolvers,
});
runtime.server = stoppable(
app.listen({ port: parseInt(process.env.LISTEN_PORT!) || 4000 }, () => {
console.log(
`Server listening at http://localhost:${
process.env.LISTEN_PORT || 4000
}\n---`
);
})
);
}
["SIGINT", "SIGTERM"].forEach((sig) => {
process.on(sig, async () => {
console.log("\nCaught", sig);
console.log("Shutting down...");
if (runtime.server) {
runtime.server.stop((error, gracefully) => {
if (error) {
console.error(error);
process.exit(2);
} else if (!gracefully) {
console.warn("Server was not shut down gracefully.");
process.exit(1);
} else {
process.exit(0);
}
});
} else {
process.exit();
}
});
});
main();

20
src/mods.ts Normal file
View File

@ -0,0 +1,20 @@
// Koa
import Application from "koa";
export { Application };
export { Context } from "koa";
import Router from "koa-router";
export { Router };
export { RouterContext } from "koa-router";
import bodyParser from "koa-bodyparser";
export { bodyParser };
// graphql
export { graphql, GraphQLScalarType } from "graphql";
// graphql-tag
import gql from "graphql-tag";
export { gql };
// graphql-tools
export { makeExecutableSchema } from "@graphql-tools/schema";
export { mergeTypeDefs, mergeResolvers } from "@graphql-tools/merge";

37
src/sudoku/index.ts Normal file
View File

@ -0,0 +1,37 @@
import { SudokuMath } from "./math";
export type Cell = { value: number | null };
export type CellArray = Cell[];
export type Row = CellArray;
export type Column = CellArray;
export type Region = CellArray;
export type Puzzle = CellArray;
export type Sudoku = {
regionWidth: number;
regionHeight: number;
cells: number;
raw: Puzzle;
rows: () => Row[];
columns: () => Column[];
regions: () => Region[];
};
export function generate(
regionWidth: number,
regionHeight: number,
clues: number
): Sudoku {
const math = new SudokuMath(regionWidth, regionHeight);
const puzzle = math.generatePuzzle(clues);
return {
regionWidth,
regionHeight,
cells: math.boardCells,
raw: puzzle,
rows: () => math.regionsToRows(puzzle, true),
columns: () => math.regionsToCols(puzzle, true),
regions: () => math.chunkRegions(puzzle),
};
}

327
src/sudoku/math.ts Normal file
View File

@ -0,0 +1,327 @@
import { Cell, Row, Column, Region, Puzzle } from "./index";
import {
clone,
range,
shuffle,
chunkify,
mapArray,
addCellValuesToSet,
} from "./util";
function getTakenValues(region: Region, row: Row, col: Column) {
const filter = new Set<number>();
addCellValuesToSet(filter, region);
addCellValuesToSet(filter, row);
addCellValuesToSet(filter, col);
return filter;
}
export class SudokuMath {
regionWidth: number;
regionHeight: number;
boardWidth: number;
boardHeight: number;
width: number;
height: number;
boardCells: number;
regionCells: number;
legalValues: number[];
_regionsFromIndex: number[];
_rowsFromIndex: number[];
_colsFromIndex: number[];
_rowIndexToRegionIndex: number[];
_colIndexToRegionIndex: number[];
_regionIndexToRowIndex: number[];
_regionIndexToColIndex: number[];
constructor(regionWidth: number, regionHeight: number) {
this.regionWidth = regionWidth;
this.regionHeight = regionHeight;
this.boardWidth = regionHeight;
this.boardHeight = regionWidth;
this.width = regionWidth * this.boardWidth;
this.height = regionHeight * this.boardHeight;
this.boardCells = this.width * this.height;
this.regionCells = regionWidth * regionHeight;
this.legalValues = range(1, this.regionCells);
this._regionsFromIndex = Array(this.boardCells);
this._rowsFromIndex = Array(this.boardCells);
this._colsFromIndex = Array(this.boardCells);
this._rowIndexToRegionIndex = Array(this.boardCells);
this._colIndexToRegionIndex = Array(this.boardCells);
this._regionIndexToRowIndex = Array(this.boardCells);
this._regionIndexToColIndex = Array(this.boardCells);
for (let i = 0; i < this.boardCells; i++) {
this._regionsFromIndex[i] = this._regionFromRegionIndex(i);
const [row, col] = this._rowColFromRegionIndex(i);
this._rowsFromIndex[i] = row;
this._colsFromIndex[i] = col;
const rowIndex = row * this.width + col;
const colIndex = col * this.height + row;
this._rowIndexToRegionIndex[rowIndex] = i;
this._colIndexToRegionIndex[i] = rowIndex;
this._regionIndexToRowIndex[i] = rowIndex;
this._regionIndexToColIndex[colIndex] = i;
}
}
_regionFromRegionIndex(i: number) {
return Math.trunc(i / this.regionCells);
}
regionFromRegionIndex(i: number) {
return this._regionsFromIndex[i];
}
_rowColFromRegionIndex(i: number) {
const region = this.regionFromRegionIndex(i);
const cell = i % this.regionCells;
const regionRow = Math.trunc(region / this.boardWidth);
const regionCol = region % this.boardWidth;
const cellRow = Math.trunc(cell / this.regionWidth);
const cellCol = cell % this.regionWidth;
return [
regionRow * this.regionHeight + cellRow,
regionCol * this.regionWidth + cellCol,
];
}
rowColFromRegionIndex(i: number) {
return [this._rowsFromIndex[i], this._colsFromIndex[i]];
}
regionIndexToRowIndex(i: number) {
return this._regionIndexToRowIndex[i];
}
rowIndexToRegionIndex(i: number) {
return this._rowIndexToRegionIndex[i];
}
regionIndexToColIndex(i: number) {
return this._regionIndexToColIndex[i];
}
colIndexToRegionIndex(i: number) {
return this._colIndexToRegionIndex[i];
}
chunkRegions(cells: Cell[]) {
return chunkify(cells, this.regionCells);
}
regionsToRows(cells: Cell[], split = false) {
const rows = mapArray(cells, this._regionIndexToRowIndex);
return split ? chunkify(rows, this.width) : rows;
}
regionsToCols(cells: Cell[], split = false) {
const cols = mapArray(cells, this._regionIndexToColIndex);
return split ? chunkify(cols, this.height) : cols;
}
rowsToRegions(cells: Cell[], split = false) {
const regions = mapArray(cells, this._rowIndexToRegionIndex);
return split ? chunkify(regions, this.regionCells) : regions;
}
colsToRegions(cells: Cell[], split = false) {
const regions = mapArray(cells, this._colIndexToRegionIndex);
return split ? chunkify(regions, this.regionCells) : regions;
}
getBlankPuzzle(): Puzzle {
return Array(this.boardCells)
.fill(null)
.map((value) => ({ value }));
}
/**
* Returns the remaining legal values given a set of taken values.
*
* @param taken a set of taken values
*/
getLegalValues(taken: Set<number>) {
return this.legalValues.filter((value) => !taken.has(value));
}
/**
* Returns which values are unavailable at a given location, looking at the
* row, column, and region of the given cell index.
*
* @param cell
* @param puzzle
* @param regions
*/
getTakenValues(cell: number, puzzle: Puzzle, regions: Region[]) {
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
const [row, col] = this.rowColFromRegionIndex(cell);
const region = this.regionFromRegionIndex(cell);
return getTakenValues(regions[region], rows[row], cols[col]);
}
/**
* Returns whether a puzzle has only one solution.
*
* @param puzzle
*/
hasOneSolution(puzzle: Cell[]) {
const optimistic = clone(puzzle);
if (this.optimisticSolver(optimistic)) {
return true;
}
return this.backTrackingSolver(optimistic, 2) === 1;
}
/**
* Generates a single-solution sudoku puzzle with
*
* @param clues the number of cells to have pre-filled
*/
generatePuzzle(clues: number) {
const puzzle = this.getBlankPuzzle();
this.backTrackingSolver(puzzle, 1, shuffle);
if (clues === -1 || clues >= puzzle.length) return puzzle;
const orig = clone(puzzle);
const toRemove = puzzle.length - clues;
let removed: number[] = [];
let removeNext: number[] = shuffle(puzzle.map((_, i) => i));
const remove = () => {
const x = removeNext.shift() as any;
removed.push(x);
puzzle[x].value = null;
};
const replace = () => {
const x = removed.pop() as any;
removeNext.push(x);
puzzle[x].value = orig[x].value;
};
const removeCell = () => {
remove();
if (this.hasOneSolution(puzzle)) {
return true;
}
replace();
return false;
};
let fails = 0;
while (removed.length < toRemove) {
if (!removeCell()) {
fails++;
} else {
console.log(`Removed ${removed.length} cells.`);
fails = 0;
}
if (fails > removeNext.length) {
fails = 0;
console.log("Backstepping..");
Array(removed.length)
.fill(null)
.forEach(() => replace());
shuffle(removeNext);
}
}
return puzzle;
}
/**
* Attempt to solve the puzzle "optimistically". Only sets values which are
* certain, i.e. no guesses are made.
*
* Useful as a first pass.
*
* @param puzzle a region-ordered array of cells (each cell an object with
* a `value` key.
* @returns whether the puzzle was completely solved
*/
optimisticSolver(puzzle: Puzzle) {
const regions = this.chunkRegions(puzzle);
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
const solve = (): boolean => {
let foundValue = false;
let foundEmpty = false;
for (let i = 0, len = puzzle.length; i < len; i++) {
const cell = puzzle[i];
if (!!cell.value) continue;
foundEmpty = true;
const region = this.regionFromRegionIndex(i);
const [row, col] = this.rowColFromRegionIndex(i);
const taken = getTakenValues(regions[region], rows[row], cols[col]);
if (taken.size === this.regionCells - 1) {
cell.value = this.getLegalValues(taken)[0];
foundValue = true;
}
}
return foundValue && foundEmpty ? solve() : !foundEmpty;
};
return solve();
}
/**
* Backtracking solver. Mutates the puzzle during solve but eventually returns
* it to its initial state.
*
* @param puzzle see optimisticSolver
* @param stopAfter stop looking after this many solutions
* @param guessStrategy a function which takes an array of possible
* values for a cell, and returns the same values (in any order)
* @returns the number of solutions found
*/
backTrackingSolver(
puzzle: Puzzle,
stopAfter: number = -1,
guessStrategy: (values: number[]) => number[] = (values: number[]) => values
) {
const regions = this.chunkRegions(puzzle);
const rows = this.regionsToRows(puzzle, true);
const cols = this.regionsToCols(puzzle, true);
let solutions = 0;
const solve = (): boolean => {
for (let i = 0, len = puzzle.length; i < len; i++) {
const cell = puzzle[i];
if (!cell.value) {
const region = this.regionFromRegionIndex(i);
const [row, col] = this.rowColFromRegionIndex(i);
const avail = guessStrategy(
this.getLegalValues(
getTakenValues(regions[region], rows[row], cols[col])
)
);
for (let j = 0; j < avail.length; j++) {
cell.value = avail[j];
if (solve() && solutions === stopAfter) {
return true;
}
cell.value = null;
}
return false;
}
}
solutions++;
return true;
};
solve();
return solutions;
}
}

59
src/sudoku/util.ts Normal file
View File

@ -0,0 +1,59 @@
import { Cell } from "./index";
function randInt(lower: number, upper: number) {
return lower + Math.trunc(Math.random() * (upper - lower + 1));
}
export function shuffle(arr: any[]) {
const length = arr.length;
let lastIndex = length - 1;
for (let i = 0; i < length; i++) {
const rand = randInt(i, lastIndex);
const tmp = arr[rand];
arr[rand] = arr[i];
arr[i] = tmp;
}
return arr;
}
export function chunkify(arr: any[], chunkSize: number) {
const chunks = Array(arr.length / chunkSize);
for (let i = 0, len = chunks.length; i < len; i++) {
const start = i * chunkSize;
chunks[i] = arr.slice(start, start + chunkSize);
}
return chunks;
}
export function range(start: number, end: number) {
return Array(1 + end - start)
.fill(null)
.map((_, i) => start + i);
}
/**
* Re-orders an array. Given an array of elements, return a new array of those
* elements ordered by the indexes array.
*
* @param arr
* @param indexes
*/
export function mapArray(arr: any[], indexes: number[]) {
const newArr = Array(indexes.length);
for (let i = 0, len = arr.length; i < len; i++) {
newArr[i] = arr[indexes[i]];
}
return newArr;
}
export function clone(puzz: Cell[]): Cell[] {
return puzz.map(({ value }) => ({ value }));
}
export function addCellValuesToSet(set: Set<number>, cells: Cell[]) {
cells.forEach((cell) => {
if (!!cell.value) set.add(cell.value);
});
return set;
}

0
src/utils.ts Normal file
View File