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