initial commit
This commit is contained in:
parent
e13b4d737b
commit
7f9cffbfdd
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
**/.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.env*
|
||||
*.log
|
||||
|
||||
node_modules
|
||||
dist
|
122
.gitignore
vendored
Normal file
122
.gitignore
vendored
Normal file
@ -0,0 +1,122 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# deno
|
||||
.deno_plugins
|
||||
|
||||
# vscode
|
||||
.vscode
|
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@ -0,0 +1,34 @@
|
||||
# dev stage
|
||||
FROM node:14-alpine as dev
|
||||
WORKDIR /app
|
||||
RUN apk update && apk add --no-cache python3 make gcc g++
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# build stage
|
||||
FROM dev as build
|
||||
RUN npx tsc && npm prune --production
|
||||
|
||||
# production stage
|
||||
FROM node:14-alpine
|
||||
WORKDIR /app
|
||||
|
||||
RUN printf "%b" '#!'"/bin/sh\n\
|
||||
set -e\n\
|
||||
if [ ! -z \"\$RUN_MIGRATIONS\" ]; then\n\
|
||||
echo \"Running migrations.\"\n\
|
||||
npm run knex:migrate:latest\n\
|
||||
fi\n\
|
||||
exec \"\$@\"\n" > docker-entrypoint.sh && chmod +x docker-entrypoint.sh
|
||||
|
||||
# Copy over production modules and dist folder
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/db ./db
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
ENTRYPOINT [ "./docker-entrypoint.sh" ]
|
||||
CMD [ "node", "dist/main.js" ]
|
33
README.md
Normal file
33
README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# BlueBlog API
|
||||
A GraphQL blogging API.
|
||||
|
||||
## Features
|
||||
* Username/Password JWT based authentication
|
||||
* Blog posts and client-side encrypted journal entries
|
||||
* Obfuscated IDs via [hashids](https://www.npmjs.com/package/hashids)
|
||||
* Drafts for both of the above
|
||||
* Blog post edit history
|
||||
|
||||
## Environment Variables:
|
||||
|
||||
```
|
||||
# The secret used for JWT signatures creation and verification
|
||||
SECRET=my-super-secret
|
||||
|
||||
# So hashids are unique
|
||||
HASHIDS_SALT=salty
|
||||
|
||||
# PostgreSQL connection params
|
||||
DB_HOST=localhost
|
||||
DB_NAME=blueblog
|
||||
DB_USER=blueblog
|
||||
DB_PASSWORD=password
|
||||
DB_PORT=5432
|
||||
|
||||
# Application startup PostgresSQL connection attempts & retry delay
|
||||
DB_CONNECT_ATTEMPTS=6
|
||||
DB_CONNECT_RETRY_DELAY=5
|
||||
|
||||
# Service responds at http://localhost:$LISTEN_PORT/graphql
|
||||
LISTEN_PORT=4000
|
||||
```
|
1899
package-lock.json
generated
Normal file
1899
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "sudoku-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Sudoku generating and solving API",
|
||||
"main": "dist/main.js",
|
||||
"dependencies": {
|
||||
"@graphql-tools/merge": "^6.2.9",
|
||||
"@graphql-tools/schema": "^6.2.4",
|
||||
"dotenv": "^8.2.0",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"koa": "^2.13.1",
|
||||
"koa-bodyparser": "^4.3.0",
|
||||
"koa-router": "^9.4.0",
|
||||
"stoppable": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/koa": "^2.13.0",
|
||||
"@types/koa-bodyparser": "^4.3.0",
|
||||
"@types/koa-router": "^7.4.1",
|
||||
"@types/markdown-it": "^10.0.3",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"nodemon": "^2.0.7",
|
||||
"ts-node": "^8.10.2",
|
||||
"typescript": "^3.9.9"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node -r dotenv/config -r ts-node/register src/main.ts",
|
||||
"serve": "nodemon"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
"src"
|
||||
],
|
||||
"exec": "npm start",
|
||||
"ext": "ts"
|
||||
},
|
||||
"author": "Matt Low <matt@mlow.ca>",
|
||||
"license": "UNLICENSED",
|
||||
"private": true
|
||||
}
|
83
src/graphql.ts
Normal file
83
src/graphql.ts
Normal 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
6
src/graphql/index.ts
Normal 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
55
src/graphql/sudoku.ts
Normal 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
69
src/main.ts
Normal 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
20
src/mods.ts
Normal 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
37
src/sudoku/index.ts
Normal 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
327
src/sudoku/math.ts
Normal 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
59
src/sudoku/util.ts
Normal 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
0
src/utils.ts
Normal file
70
tsconfig.json
Normal file
70
tsconfig.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist" /* Redirect output structure to the directory. */,
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
},
|
||||
"include": ["./src"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user