diff --git a/TODO.md b/TODO.md
index c243f18..601dac5 100644
--- a/TODO.md
+++ b/TODO.md
@@ -28,3 +28,19 @@ For screenshots:
]
const pieces = [{"letters":[["P","U","Z"],["L","",""],["A","",""]],"solutionTop":3,"solutionLeft":2},{"letters":[["B"],["R"],["A"]],"solutionTop":2,"solutionLeft":9},{"letters":[["","T","H"],["","E",""],["O","R","D"]],"solutionTop":6,"solutionLeft":5},{"letters":[["M","E"]],"solutionTop":5,"solutionLeft":3},{"letters":[["","I",""],["I","N","K"]],"solutionTop":5,"solutionLeft":8},{"letters":[["W"]],"solutionTop":8,"solutionLeft":4},{"letters":[["G"]],"solutionTop":5,"solutionLeft":1},{"letters":[["Y"]],"solutionTop":6,"solutionLeft":2},{"letters":[["Z","L","E"],["","E",""],["","T",""]],"solutionTop":3,"solutionLeft":5}];
+
+// todo later PR:
+
+- add some custom daily puzzles
+- Use the hasVisited state to announce
+- relocate to new file:
+ - getWordValidityGrids
+ - countingGrid
+- just rename dispatch params to dispatcher everywhere
+- put arrayToGrid to common or to word-logic package
+- consolidate control buttons into hamburger menu (and add share button)
+- add link to sponsors?
+- piecesOverlapQ should use getGridFromPieces
+- rules = use line-spacing in css instead of multiple br
+- rules = use the icons instead of words
+- improve large screen
diff --git a/src/components/App.js b/src/components/App.js
index fcfdbb0..a1b7801 100644
--- a/src/components/App.js
+++ b/src/components/App.js
@@ -3,6 +3,8 @@ import Game from "./Game";
import Heart from "./Heart";
import Rules from "./Rules";
import Stats from "./Stats";
+import CustomCreation from "./CustomCreation";
+import CustomShare from "./CustomShare";
import ControlBar from "./ControlBar";
import {
handleAppInstalled,
@@ -10,16 +12,23 @@ import {
} from "../common/handleInstall";
import Settings from "./Settings";
import {gameInit} from "../logic/gameInit";
+import {customInit} from "../logic/customInit";
import getDailySeed from "../common/getDailySeed";
import {gameReducer} from "../logic/gameReducer";
import {parseUrlQuery} from "../logic/parseUrlQuery";
import {getInitialState} from "../common/getInitialState";
import {hasVisitedSince} from "../common/hasVisitedSince";
+import {handleShare} from "../common/handleShare";
+import {convertGridToRepresentativeString} from "../logic/convertGridToRepresentativeString";
+import {getGridFromPieces} from "../logic/getGridFromPieces";
+import {crosswordValidQ, pickRandomIntBetween} from "@skedwards88/word_logic";
+import {trie} from "../logic/trie";
+import {resizeGrid} from "../logic/resizeGrid";
export default function App() {
// If a query string was passed,
// parse it to get the data to regenerate the game described by the query string
- const [seed, numLetters] = parseUrlQuery();
+ const [isCustom, seed, numLetters] = parseUrlQuery();
// Determine when the player last visited the game
// This is used to determine whether to show the rules or an announcement instead of the game
@@ -47,6 +56,7 @@ export default function App() {
{
seed,
numLetters,
+ isCustom,
},
gameInit,
);
@@ -57,9 +67,53 @@ export default function App() {
gameInit,
);
+ const [customState, dispatchCustomState] = React.useReducer(
+ gameReducer,
+ {},
+ customInit,
+ );
+
// todo consolidate lastVisited and setLastOpened?
const [, setLastOpened] = React.useState(Date.now());
+ function handleCustomGeneration() {
+ // If there is nothing to share, display a message with errors
+ if (!customState.pieces.some((piece) => piece.boardTop >= 0)) {
+ throw new Error("Add some letters to the board first!");
+ }
+
+ // Validate the grid
+ // - The UI restricts the grid size, so don't need to validate that
+ // - Make sure all letters are connected
+ // - Make sure all horizontal and vertical words are known
+ const grid = getGridFromPieces({
+ pieces: customState.pieces,
+ gridSize: customState.gridSize,
+ solution: false,
+ });
+
+ const {gameIsSolved, reason} = crosswordValidQ({
+ grid: grid,
+ trie: trie,
+ });
+
+ // If the board is not valid, display a message with errors
+ if (!gameIsSolved) {
+ throw new Error(reason);
+ }
+
+ // Center and resize/pad the grid
+ // Convert the grid to a representative string
+ const resizedGrid = resizeGrid(grid);
+ const cipherShift = pickRandomIntBetween(5, 9);
+ const representativeString = convertGridToRepresentativeString(
+ resizedGrid,
+ cipherShift,
+ );
+
+ return representativeString;
+ }
+
function handleVisibilityChange() {
// If the visibility of the app changes to become visible,
// update the state to force the app to re-render.
@@ -119,6 +173,13 @@ export default function App() {
);
}, [dailyGameState]);
+ React.useEffect(() => {
+ window.localStorage.setItem(
+ "crossjigCustomCreation",
+ JSON.stringify(customState),
+ );
+ }, [customState]);
+
switch (display) {
case "rules":
return ;
@@ -182,6 +243,110 @@ export default function App() {
);
+ case "custom":
+ return (
+
+
+ {
+ let representativeString;
+ try {
+ representativeString = handleCustomGeneration();
+ } catch (error) {
+ const invalidReason = error.message;
+ dispatchCustomState({
+ action: "updateInvalidReason",
+ invalidReason: invalidReason,
+ });
+ setDisplay("customError");
+ return;
+ }
+
+ dispatchGameState({
+ action: "playCustom",
+ representativeString,
+ });
+ setDisplay("game");
+ }}
+ >
+ Play
+
+ {
+ let representativeString;
+ try {
+ representativeString = handleCustomGeneration();
+ } catch (error) {
+ const invalidReason = error.message;
+ dispatchCustomState({
+ action: "updateInvalidReason",
+ invalidReason: invalidReason,
+ });
+ setDisplay("customError");
+ return;
+ }
+
+ // Share (or show the link if sharing is not supported)
+ if (navigator.canShare) {
+ handleShare({
+ appName: "Crossjig",
+ text: "Try this custom crossjig that I created!",
+ url: "https://crossjig.com",
+ representativeString,
+ });
+ } else {
+ dispatchCustomState({
+ action: "updateRepresentativeString",
+ representativeString,
+ });
+ setDisplay("customShare");
+ }
+ }}
+ >
+ Share
+
+ setDisplay("game")}>
+ Cancel
+
+
+
+
+ );
+
+ case "customError":
+ return (
+
+
{`Your game isn't ready to share yet: \n\n${customState.invalidReason}`}
+
{
+ dispatchCustomState({
+ action: "updateInvalidReason",
+ invalidReason: "",
+ });
+ setDisplay("custom");
+ }}
+ >
+ Ok
+
+
+ );
+
+ case "customShare":
+ return (
+
+ );
+
default:
return (
diff --git a/src/components/Board.js b/src/components/Board.js
index 5fb7bcb..f76a2be 100644
--- a/src/components/Board.js
+++ b/src/components/Board.js
@@ -85,12 +85,18 @@ function getHorizontalValidityGrid({grid, originalWords}) {
return horizontalValidityGrid;
}
-function getWordValidityGrids({pieces, gridSize}) {
- const originalWords = getWordsFromPieces({
- pieces,
- gridSize,
- solution: true,
- });
+export function getWordValidityGrids({
+ pieces,
+ gridSize,
+ includeOriginalSolution = true,
+}) {
+ const originalWords = includeOriginalSolution
+ ? getWordsFromPieces({
+ pieces,
+ gridSize,
+ solution: true,
+ })
+ : [];
const grid = getGridFromPieces({pieces, gridSize, solution: false});
@@ -117,15 +123,24 @@ export default function Board({
gameIsSolved,
dispatchGameState,
indicateValidity,
+ customCreation = false,
}) {
const boardPieces = pieces.filter(
(piece) => piece.boardTop >= 0 && piece.boardLeft >= 0,
);
- const overlapGrid = countingGrid(gridSize, gridSize, boardPieces);
+ const overlapGrid = customCreation
+ ? undefined
+ : countingGrid(gridSize, gridSize, boardPieces);
+
const [horizontalValidityGrid, verticalValidityGrid] = indicateValidity
- ? getWordValidityGrids({pieces, gridSize})
+ ? getWordValidityGrids({
+ pieces,
+ gridSize,
+ includeOriginalSolution: !customCreation,
+ })
: [undefined, undefined];
+
const pieceElements = boardPieces.map((piece) => (
setDisplay("rules")}
>
+ setDisplay("custom")}
+ >
+ ) : null;
+
+ return (
+
+
0}
+ dragPieceIDs={customState.dragState?.pieceIDs}
+ dragDestination={customState.dragState?.destination}
+ gridSize={customState.gridSize}
+ customCreation={true}
+ >
+
+ {dragGroup}
+
+ );
+}
+
+export default CustomCreation;
diff --git a/src/components/CustomShare.js b/src/components/CustomShare.js
new file mode 100644
index 0000000..a99a17d
--- /dev/null
+++ b/src/components/CustomShare.js
@@ -0,0 +1,40 @@
+import React from "react";
+
+export default function CustomShare({
+ representativeString,
+ dispatchCustomState,
+ setDisplay,
+}) {
+ const link = `https://crossjig.com?id=custom-${representativeString}`;
+
+ return (
+
+
{`Share your custom puzzle with this link!`}
+
{link}
+
+ {
+ try {
+ navigator.clipboard.writeText(link);
+ } catch (error) {
+ console.log("Error copying", error);
+ }
+ }}
+ >
+ Copy
+
+ {
+ dispatchCustomState({
+ action: "updateRepresentativeString",
+ representativeString: "",
+ });
+ setDisplay("custom");
+ }}
+ >
+ Ok
+
+
+
+ );
+}
diff --git a/src/components/GameOver.js b/src/components/GameOver.js
index 4acf5cf..757319d 100644
--- a/src/components/GameOver.js
+++ b/src/components/GameOver.js
@@ -33,7 +33,6 @@ export default function GameOver({dispatchGameState, gameState, setDisplay}) {
appName="Crossjig"
text="Check out this word puzzle!"
url="https://crossjig.com"
- query="puzzle"
seed={`${gameState.seed}_${gameState.numLetters}`}
>
diff --git a/src/components/Piece.js b/src/components/Piece.js
index 47ebc94..c430df2 100644
--- a/src/components/Piece.js
+++ b/src/components/Piece.js
@@ -106,6 +106,7 @@ export default function Piece({
pieceColIndex={colIndex}
overlapping={
isOnBoard &&
+ overlapGrid &&
overlapGrid[piece.boardTop + rowIndex][
piece.boardLeft + colIndex
] > 1
diff --git a/src/components/Rules.js b/src/components/Rules.js
index 06e1c91..eb1ebee 100644
--- a/src/components/Rules.js
+++ b/src/components/Rules.js
@@ -29,6 +29,9 @@ export default function Rules({setDisplay}) {
challenge is easiest on Monday and gets harder over the week.
+ Click the pencil to build your own puzzle to share with friends.
+
+
+
+
+
diff --git a/src/logic/arrayToGrid.js b/src/logic/arrayToGrid.js
new file mode 100644
index 0000000..71538d4
--- /dev/null
+++ b/src/logic/arrayToGrid.js
@@ -0,0 +1,17 @@
+// Converts a 1D array to a 2D square array
+export function arrayToGrid(array) {
+ // Error if array length does not allow for a square grid
+ if (!Number.isInteger(Math.sqrt(array.length))) {
+ throw new Error("Array length cannot form a square grid");
+ }
+
+ const gridSize = Math.sqrt(array.length);
+ let grid = [];
+ for (let rowIndex = 0; rowIndex < gridSize; rowIndex++) {
+ const start = rowIndex * gridSize;
+ const end = start + gridSize;
+ const row = array.slice(start, end);
+ grid.push(row);
+ }
+ return grid;
+}
diff --git a/src/logic/arrayToGrid.test.js b/src/logic/arrayToGrid.test.js
new file mode 100644
index 0000000..6c0edeb
--- /dev/null
+++ b/src/logic/arrayToGrid.test.js
@@ -0,0 +1,32 @@
+import {arrayToGrid} from "./arrayToGrid";
+
+describe("arrayToGrid", () => {
+ test("converts a 1D array to a 2D array", () => {
+ const input = [1, "2", 3, 4, 5, "Z", 7, 8, 9];
+ const output = arrayToGrid(input);
+ expect(output).toEqual([
+ [1, "2", 3],
+ [4, 5, "Z"],
+ [7, 8, 9],
+ ]);
+ });
+
+ test("works on arrays of length 1", () => {
+ const input = [10];
+ const output = arrayToGrid(input);
+ expect(output).toEqual([[10]]);
+ });
+
+ test("empty arrays are returned as empty arrays", () => {
+ const input = [];
+ const output = arrayToGrid(input);
+ expect(output).toEqual([]);
+ });
+
+ test("errors if array doesn't form a square", () => {
+ const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
+ expect(() => arrayToGrid(input)).toThrow(
+ "Array length cannot form a square grid",
+ );
+ });
+});
diff --git a/src/logic/cipherLetter.js b/src/logic/cipherLetter.js
new file mode 100644
index 0000000..d9c5469
--- /dev/null
+++ b/src/logic/cipherLetter.js
@@ -0,0 +1,23 @@
+export function cipherLetter(letter, shift) {
+ // Error if the letter is not a single uppercase character
+ if (!/^[A-Z]$/.test(letter)) {
+ throw new Error("Input letter must be a single uppercase character A-Z");
+ }
+
+ // Error if the shift is not an integer
+ if (!Number.isInteger(shift)) {
+ throw new Error("Input shift must be an integer");
+ }
+
+ // Convert the letter to its ASCII code
+ const ascii = letter.charCodeAt(0);
+
+ // Shift the ASCII by the shift amount, wrapping around if necessary.
+ // -65 converts the ASCII code for A-Z to a
+ // number between 0 and 25 (corresponding to alphabet position)
+ const shiftedAscii = ((((ascii - 65 + shift) % 26) + 26) % 26) + 65;
+
+ const cipheredLetter = String.fromCharCode(shiftedAscii);
+
+ return cipheredLetter;
+}
diff --git a/src/logic/cipherLetter.test.js b/src/logic/cipherLetter.test.js
new file mode 100644
index 0000000..0f050fc
--- /dev/null
+++ b/src/logic/cipherLetter.test.js
@@ -0,0 +1,105 @@
+import {cipherLetter} from "./cipherLetter";
+
+describe("cipherLetter", () => {
+ test("correctly shifts a middle letter with a positive shift", () => {
+ expect(cipherLetter("M", 5)).toBe("R");
+ });
+
+ test("correctly shifts a middle letter with a negative shift", () => {
+ expect(cipherLetter("M", -5)).toBe("H");
+ });
+
+ test("returns the same letter when shifted by 26", () => {
+ expect(cipherLetter("M", 26)).toBe("M");
+ });
+
+ test("correctly shifts for shifts > 26", () => {
+ expect(cipherLetter("M", 31)).toBe("R");
+ });
+
+ test("correctly shifts for shifts < -26", () => {
+ expect(cipherLetter("M", -31)).toBe("H");
+ });
+
+ test("ciphering a letter and then deciphering it with the negative shift returns the original letter", () => {
+ const letter = "M";
+ const shift = 5;
+ const cipheredLetter = cipherLetter(letter, shift);
+ const decipheredLetter = cipherLetter(cipheredLetter, -shift);
+ expect(decipheredLetter).toBe(letter);
+ });
+
+ test("wraps from 'Z' to 'A' with a positive shift", () => {
+ expect(cipherLetter("Z", 1)).toBe("A");
+ });
+
+ test("wraps from 'A' to 'Z' with a negative shift", () => {
+ expect(cipherLetter("A", -1)).toBe("Z");
+ });
+
+ test("errors for lowercase letters", () => {
+ expect(() => cipherLetter("a", 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for non-alphabetical characters", () => {
+ expect(() => cipherLetter("1", 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for empty strings", () => {
+ expect(() => cipherLetter("", 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for multiple characters", () => {
+ expect(() => cipherLetter("AB", 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for objects", () => {
+ expect(() => cipherLetter({a: 5}, 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for numbers", () => {
+ expect(() => cipherLetter(5, 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for arrays", () => {
+ expect(() => cipherLetter([5], 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for undefined", () => {
+ expect(() => cipherLetter(undefined, 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for null", () => {
+ expect(() => cipherLetter(null, 1)).toThrow(
+ "Input letter must be a single uppercase character A-Z",
+ );
+ });
+
+ test("errors for non-integer shifts", () => {
+ expect(() => cipherLetter("A", 1.5)).toThrow(
+ "Input shift must be an integer",
+ );
+ });
+
+ test("errors for non-number shifts", () => {
+ expect(() => cipherLetter("A", "4")).toThrow(
+ "Input shift must be an integer",
+ );
+ });
+});
diff --git a/src/logic/convertGridToRepresentativeString.js b/src/logic/convertGridToRepresentativeString.js
new file mode 100644
index 0000000..b8be7a7
--- /dev/null
+++ b/src/logic/convertGridToRepresentativeString.js
@@ -0,0 +1,44 @@
+import {cipherLetter} from "./cipherLetter";
+
+// Converts a 2D grid of letters and spaces into a representative string.
+// Spaces are represented by an integer indicating the number of consecutive spaces.
+// (Spaces that span rows are considered part of the same consecutive group of spaces.)
+// Letters in the string are shifted by the cipherShift amount,
+// and the shift amount is prepended to the string.
+export function convertGridToRepresentativeString(grid, cipherShift = 0) {
+ // Error if cipherShift is not an int between 0 and 9
+ if (!Number.isInteger(cipherShift) || cipherShift < 0 || cipherShift > 9) {
+ throw new Error("Input cipherShift must be a single digit integer");
+ }
+
+ let stringifiedGrid = `${cipherShift}`;
+ let spaceCount = 0;
+
+ for (const row of grid) {
+ for (const character of row) {
+ if (character === "") {
+ // If the character is a space, just increase the space count
+ spaceCount++;
+ } else {
+ // Otherwise, add the space count (if it is non-zero) and the letter to the string,
+ // and reset the space count
+ if (spaceCount) {
+ stringifiedGrid += spaceCount;
+ spaceCount = 0;
+ }
+ const cipheredCharacter = cipherLetter(
+ character.toUpperCase(),
+ -cipherShift,
+ );
+ stringifiedGrid += cipheredCharacter;
+ }
+ }
+ }
+
+ // If there are trailing spaces, add them to the string
+ if (spaceCount) {
+ stringifiedGrid += spaceCount;
+ }
+
+ return stringifiedGrid;
+}
diff --git a/src/logic/convertGridToRepresentativeString.test.js b/src/logic/convertGridToRepresentativeString.test.js
new file mode 100644
index 0000000..e6a6f10
--- /dev/null
+++ b/src/logic/convertGridToRepresentativeString.test.js
@@ -0,0 +1,108 @@
+import {convertGridToRepresentativeString} from "./convertGridToRepresentativeString";
+
+describe("convertGridToRepresentativeString", () => {
+ test("converts a 2D grid of single characters and empty strings into a representative string with consecutive empty strings represented by a the number of empty strings. Row breaks are ignored. The shift amount is prepended to the string.", () => {
+ const input = [
+ ["", "", "L", "U", "M", "P", "Y", "", "", ""],
+ ["", "", "", "L", "", "I", "", "", "", ""],
+ ["", "F", "A", "T", "T", "E", "R", "", "", ""],
+ ["", "L", "", "R", "", "R", "", "", "", ""],
+ ["", "A", "", "A", "", "S", "", "", "", ""],
+ ["", "M", "", "", "R", "", "", "", "", ""],
+ ["", "E", "U", "R", "E", "K", "A", "", "", ""],
+ ["", "S", "", "", "A", "", "", "", "", ""],
+ ["", "", "", "", "D", "R", "A", "W", "S", ""],
+ ["", "", "", "", "S", "", "", "", "", ""],
+ ];
+
+ const output = convertGridToRepresentativeString(input, 0);
+
+ expect(output).toEqual(
+ "02LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5",
+ );
+ });
+
+ test("shifts the letters by the negative of the amount indicated", () => {
+ const input = [
+ ["", "", "L", "U", "M", "P", "Y", "", "", ""],
+ ["", "", "", "L", "", "I", "", "", "", ""],
+ ["", "F", "A", "T", "T", "E", "R", "", "", ""],
+ ["", "L", "", "R", "", "R", "", "", "", ""],
+ ["", "A", "", "A", "", "S", "", "", "", ""],
+ ["", "M", "", "", "R", "", "", "", "", ""],
+ ["", "E", "U", "R", "E", "K", "A", "", "", ""],
+ ["", "S", "", "", "A", "", "", "", "", ""],
+ ["", "", "", "", "D", "R", "A", "W", "S", ""],
+ ["", "", "", "", "S", "", "", "", "", ""],
+ ];
+
+ const output = convertGridToRepresentativeString(input, 3);
+
+ expect(output).toEqual(
+ "32IRJMV6I1F5CXQQBO4I1O1O5X1X1P5J2O6BROBHX4P2X9AOXTP5P5",
+ );
+ });
+
+ test("if no shift is indicated, the letters are not shifted, and 0 is prepended to the string", () => {
+ const input = [
+ ["", "", "L", "U", "M", "P", "Y", "", "", ""],
+ ["", "", "", "L", "", "I", "", "", "", ""],
+ ["", "F", "A", "T", "T", "E", "R", "", "", ""],
+ ["", "L", "", "R", "", "R", "", "", "", ""],
+ ["", "A", "", "A", "", "S", "", "", "", ""],
+ ["", "M", "", "", "R", "", "", "", "", ""],
+ ["", "E", "U", "R", "E", "K", "A", "", "", ""],
+ ["", "S", "", "", "A", "", "", "", "", ""],
+ ["", "", "", "", "D", "R", "A", "W", "S", ""],
+ ["", "", "", "", "S", "", "", "", "", ""],
+ ];
+
+ const output = convertGridToRepresentativeString(input);
+
+ expect(output).toEqual(
+ "02LUMPY6L1I5FATTER4L1R1R5A1A1S5M2R6EUREKA4S2A9DRAWS5S5",
+ );
+ });
+
+ test("errors if the cipher shift is not an integer", () => {
+ const input = [
+ ["", "", "L", "U", "M", "P", "Y", "", "", ""],
+ ["", "", "", "L", "", "I", "", "", "", ""],
+ ["", "F", "A", "T", "T", "E", "R", "", "", ""],
+ ["", "L", "", "R", "", "R", "", "", "", ""],
+ ["", "A", "", "A", "", "S", "", "", "", ""],
+ ["", "M", "", "", "R", "", "", "", "", ""],
+ ["", "E", "U", "R", "E", "K", "A", "", "", ""],
+ ["", "S", "", "", "A", "", "", "", "", ""],
+ ["", "", "", "", "D", "R", "A", "W", "S", ""],
+ ["", "", "", "", "S", "", "", "", "", ""],
+ ];
+
+ expect(() => convertGridToRepresentativeString(input, 1.5)).toThrow(
+ "Input cipherShift must be a single digit integer",
+ );
+ });
+
+ test("errors if the cipher shift is not between 0 and 9", () => {
+ const input = [
+ ["", "", "L", "U", "M", "P", "Y", "", "", ""],
+ ["", "", "", "L", "", "I", "", "", "", ""],
+ ["", "F", "A", "T", "T", "E", "R", "", "", ""],
+ ["", "L", "", "R", "", "R", "", "", "", ""],
+ ["", "A", "", "A", "", "S", "", "", "", ""],
+ ["", "M", "", "", "R", "", "", "", "", ""],
+ ["", "E", "U", "R", "E", "K", "A", "", "", ""],
+ ["", "S", "", "", "A", "", "", "", "", ""],
+ ["", "", "", "", "D", "R", "A", "W", "S", ""],
+ ["", "", "", "", "S", "", "", "", "", ""],
+ ];
+
+ expect(() => convertGridToRepresentativeString(input, 11)).toThrow(
+ "Input cipherShift must be a single digit integer",
+ );
+
+ expect(() => convertGridToRepresentativeString(input, -1)).toThrow(
+ "Input cipherShift must be a single digit integer",
+ );
+ });
+});
diff --git a/src/logic/convertRepresentativeStringToGrid.js b/src/logic/convertRepresentativeStringToGrid.js
new file mode 100644
index 0000000..d665d2b
--- /dev/null
+++ b/src/logic/convertRepresentativeStringToGrid.js
@@ -0,0 +1,61 @@
+import {arrayToGrid} from "./arrayToGrid";
+import {cipherLetter} from "./cipherLetter";
+
+// Converts a string of letters and integers into a square grid of single letters and empty strings.
+// The first character in the string represents how much the letters in the string have been shifted in the alphabet.
+// Integers (excluding the first character) in the string are expanded into an equivalent number of empty strings.
+// Letters in the string are capitalized and shifted in the alphabet by the amount described by the first character.
+export function convertRepresentativeStringToGrid(string) {
+ // error if the string includes anything other than letters and numbers
+ if (!/^[a-zA-Z0-9]+$/.test(string)) {
+ throw new Error(
+ "Input string must only contain letters and numbers, and must not be empty",
+ );
+ }
+
+ // error if the first character is not an integer
+ if (!/^\d+$/.test(string[0])) {
+ throw new Error(
+ "First character in input string must be an integer that represents the cipher shift",
+ );
+ }
+
+ // The first character in the string is the cipher shift; the rest is the grid representation
+ const cipherShift = parseInt(string[0]);
+ const representativeString = string.slice(1);
+
+ // Convert the string to a list of stringified integers and single letters
+ // e.g. "2A11GT1" becomes ['2', 'A', '11','G', 'T', '1','1']
+ const splitString = representativeString.match(/\d+|[a-zA-Z]/gi);
+
+ // Expand the stringified integers in the list to an equal number of empty strings
+ // Also capitalize the letters
+ let list = [];
+ for (const value of splitString) {
+ if (/[a-zA-Z]/.test(value)) {
+ const decipheredLetter = cipherLetter(value.toUpperCase(), cipherShift);
+ list.push(decipheredLetter);
+ } else {
+ const numSpaces = parseInt(value);
+ list = [...list, ...Array(numSpaces).fill("")];
+ }
+ }
+
+ // Error if the list does not form a square grid between 8x8 and 12x12
+ const dimension = Math.sqrt(list.length);
+ if (dimension % 1 !== 0) {
+ throw new Error("Input string does not form a square grid");
+ }
+ if (dimension < 8 || dimension > 12) {
+ throw new Error("Input string must form a grid between 8x8 and 12x12");
+ }
+
+ // I'm assuming that people won't build custom query strings outside of the UI,
+ // so I don't need to remove whitespace from the edges or center the grid.
+ // (Since this is done when the custom query is generated via the UI.)
+ // By this same logic, I'm not validating that the puzzle consists of known words.
+ // (Since that is done when a player tries to generate the query string via the UI.)
+
+ const grid = arrayToGrid(list);
+ return grid;
+}
diff --git a/src/logic/convertRepresentativeStringToGrid.test.js b/src/logic/convertRepresentativeStringToGrid.test.js
new file mode 100644
index 0000000..19aea01
--- /dev/null
+++ b/src/logic/convertRepresentativeStringToGrid.test.js
@@ -0,0 +1,100 @@
+import {convertRepresentativeStringToGrid} from "./convertRepresentativeStringToGrid";
+
+describe("convertRepresentativeStringToGrid", () => {
+ test("converts a representative string with consecutive empty strings represented by a the number of empty strings into a 2D grid of single characters and empty strings.", () => {
+ const input = "02ABCDE6F1G5HIJKLM4N1O1P5Q1R1S5T2U6VWXYZA4B2C9DEFGH5I5";
+
+ const output = convertRepresentativeStringToGrid(input);
+
+ expect(output).toEqual([
+ ["", "", "A", "B", "C", "D", "E", "", "", ""],
+ ["", "", "", "F", "", "G", "", "", "", ""],
+ ["", "H", "I", "J", "K", "L", "M", "", "", ""],
+ ["", "N", "", "O", "", "P", "", "", "", ""],
+ ["", "Q", "", "R", "", "S", "", "", "", ""],
+ ["", "T", "", "", "U", "", "", "", "", ""],
+ ["", "V", "W", "X", "Y", "Z", "A", "", "", ""],
+ ["", "B", "", "", "C", "", "", "", "", ""],
+ ["", "", "", "", "D", "E", "F", "G", "H", ""],
+ ["", "", "", "", "I", "", "", "", "", ""],
+ ]);
+ });
+
+ test("shifts the letters by the amount indicated by the first character", () => {
+ const input = "32ABCDE6F1G5HIJKLM4N1O1P5Q1R1S5T2U6VWXYZA4B2C9DEFGH5I5";
+
+ const output = convertRepresentativeStringToGrid(input);
+
+ expect(output).toEqual([
+ ["", "", "D", "E", "F", "G", "H", "", "", ""],
+ ["", "", "", "I", "", "J", "", "", "", ""],
+ ["", "K", "L", "M", "N", "O", "P", "", "", ""],
+ ["", "Q", "", "R", "", "S", "", "", "", ""],
+ ["", "T", "", "U", "", "V", "", "", "", ""],
+ ["", "W", "", "", "X", "", "", "", "", ""],
+ ["", "Y", "Z", "A", "B", "C", "D", "", "", ""],
+ ["", "E", "", "", "F", "", "", "", "", ""],
+ ["", "", "", "", "G", "H", "I", "J", "K", ""],
+ ["", "", "", "", "L", "", "", "", "", ""],
+ ]);
+ });
+
+ test("returns an empty grid if there are no letters in the string", () => {
+ const input = "364";
+
+ const output = convertRepresentativeStringToGrid(input);
+
+ expect(output).toEqual([
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ]);
+ });
+
+ test("errors if input string contains characters other than letters and numbers", () => {
+ const input = "2-ABCDE5G";
+ expect(() => convertRepresentativeStringToGrid(input)).toThrow(
+ "Input string must only contain letters and numbers, and must not be empty",
+ );
+ });
+
+ test("errors if input string is empty", () => {
+ const input = "";
+ expect(() => convertRepresentativeStringToGrid(input)).toThrow(
+ "Input string must only contain letters and numbers, and must not be empty",
+ );
+ });
+
+ test("errors if first character is not an integer", () => {
+ const input = "ABCDEFGHI";
+ expect(() => convertRepresentativeStringToGrid(input, 3.5)).toThrow(
+ "First character in input string must be an integer that represents the cipher shift",
+ );
+ });
+
+ test("throws an error if the resulting list does not form a square grid", () => {
+ const input = "0AB2EF4";
+ expect(() => convertRepresentativeStringToGrid(input)).toThrow(
+ "Input string does not form a square grid",
+ );
+ });
+
+ test("throws an error if the resulting grid is smaller than 8x8", () => {
+ const input = "0AB2EF3";
+ expect(() => convertRepresentativeStringToGrid(input)).toThrow(
+ "Input string must form a grid between 8x8 and 12x12",
+ );
+ });
+
+ test("throws an error if the resulting grid is larger than 12x12", () => {
+ const input = "0AB165CD";
+ expect(() => convertRepresentativeStringToGrid(input)).toThrow(
+ "Input string must form a grid between 8x8 and 12x12",
+ );
+ });
+});
diff --git a/src/logic/customInit.js b/src/logic/customInit.js
new file mode 100644
index 0000000..e92ee04
--- /dev/null
+++ b/src/logic/customInit.js
@@ -0,0 +1,65 @@
+import sendAnalytics from "../common/sendAnalytics";
+import {updatePieceDatum} from "./assemblePiece";
+
+export function customInit(useSaved = true) {
+ const savedState = useSaved
+ ? JSON.parse(localStorage.getItem("crossjigCustomCreation"))
+ : undefined;
+
+ if (savedState) {
+ return savedState;
+ }
+
+ const alphabet = [
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ ];
+
+ const pieces = alphabet.map((letter, index) =>
+ updatePieceDatum({
+ letters: [letter],
+ solutionTop: undefined,
+ solutionLeft: undefined,
+ boardTop: undefined,
+ boardLeft: undefined,
+ id: index,
+ poolIndex: index,
+ }),
+ );
+
+ sendAnalytics("new_custom");
+
+ return {
+ pieces,
+ gridSize: 12,
+ dragCount: 0,
+ dragState: undefined,
+ representativeString: "",
+ invalidReason: "",
+ isCustomCreation: true,
+ };
+}
diff --git a/src/logic/gameInit.js b/src/logic/gameInit.js
index 75ed721..02b2873 100644
--- a/src/logic/gameInit.js
+++ b/src/logic/gameInit.js
@@ -4,6 +4,7 @@ import getRandomSeed from "../common/getRandomSeed";
import getDailySeed from "../common/getDailySeed";
import {getNumLettersForDay} from "./getNumLettersForDay";
import {getGridSizeForLetters} from "./getGridSizeForLetters";
+import {generatePuzzleFromRepresentativeString} from "./generatePuzzleFromRepresentativeString";
import {updatePieceDatum} from "./assemblePiece";
function validateSavedState(savedState) {
@@ -33,6 +34,7 @@ export function gameInit({
validityOpacity = 0.15,
useSaved = true,
isDaily = false,
+ isCustom = false,
seed,
}) {
const savedStateName = isDaily ? "dailyCrossjigState" : "crossjigState";
@@ -52,10 +54,8 @@ export function gameInit({
if (
savedState &&
savedState.seed &&
- //todo verify comment clarity
- // If daily, use the saved state if the seed matches
- // otherwise, we don't care if the seed matches
- (!isDaily || savedState.seed == seed) &&
+ // Make sure the seed matches (unless this isn't a daily or custom game)
+ ((!isDaily && !isCustom) || savedState.seed == seed) &&
validateSavedState(savedState) &&
// Use the saved state if daily even if the game is solved
// otherwise, don't use the saved state if the game is solved
@@ -64,11 +64,45 @@ export function gameInit({
return savedState;
}
- const minLetters = isDaily ? getNumLettersForDay() : numLetters || 30;
+ let pieces;
+ let maxShiftLeft;
+ let maxShiftRight;
+ let maxShiftUp;
+ let maxShiftDown;
+ let minLetters = isDaily
+ ? getNumLettersForDay()
+ : Math.min(Math.max(numLetters, 20), 60) || 30; // Custom puzzles can exceed the min/max letters used for a randomly generated game. Constrain min letters in this cases so that future randomly generated games don't use these extreme values.
let gridSize = getGridSizeForLetters(minLetters);
- let {pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} =
- generatePuzzle({gridSize: gridSize, minLetters: minLetters, seed: seed});
+ // If custom, attempt to generate the custom puzzle represented by the seed.
+ // If any errors are raised, catch them and just generate a random puzzle instead
+ if (isCustom) {
+ try {
+ ({
+ gridSize,
+ minLetters,
+ pieces,
+ maxShiftLeft,
+ maxShiftRight,
+ maxShiftUp,
+ maxShiftDown,
+ } = generatePuzzleFromRepresentativeString({representativeString: seed}));
+ } catch (error) {
+ console.error(
+ `Error generating custom puzzle from seed ${seed}. Will proceed to generate random game instead. Caught error: ${error}`,
+ );
+
+ ({pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} =
+ generatePuzzle({
+ gridSize: gridSize,
+ minLetters: minLetters,
+ seed: seed,
+ }));
+ }
+ } else {
+ ({pieces, maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} =
+ generatePuzzle({gridSize: gridSize, minLetters: minLetters, seed: seed}));
+ }
// Pad the puzzle with a square on each side and recenter the solution
maxShiftRight++;
diff --git a/src/logic/gameReducer.js b/src/logic/gameReducer.js
index 83b70b8..b699431 100644
--- a/src/logic/gameReducer.js
+++ b/src/logic/gameReducer.js
@@ -13,6 +13,7 @@ function updateStateForDragStart({
pointerStartPosition, // (object with fields `x` and `y`): The x and y position of the pointer, as captured by the pointer down event.
boardIsShifting, // (boolean): Whether the whole board is being dragged.
previousDragState,
+ isCustomCreation = false, // (boolean): Whether the player is creating a custom game
}) {
if (currentGameState.dragState !== undefined) {
console.warn("Tried to start a drag while a drag was in progress");
@@ -92,29 +93,45 @@ function updateStateForDragStart({
if (rectangles.length === 0) {
return currentGameState;
}
- const dragGroupX = Math.min(...rectangles.map((rect) => rect.top));
- const dragGroupY = Math.min(...rectangles.map((rect) => rect.left));
+ const dragGroupTop = Math.min(...rectangles.map((rect) => rect.top));
+ const dragGroupLeft = Math.min(...rectangles.map((rect) => rect.left));
pointerOffset = {
- x: pointerStartPosition.x - dragGroupY,
- y: pointerStartPosition.y - dragGroupX,
+ x: pointerStartPosition.x - dragGroupLeft,
+ y: pointerStartPosition.y - dragGroupTop,
};
}
+ // (For custom creation only)
+ // If dragging from the pool, add a dummy placeholder
+ let placeholderPoolPieces = [];
+ if (isCustomCreation && groupBoardTop === undefined) {
+ placeholderPoolPieces = piecesBeingDragged.map((piece) =>
+ updatePieceDatum(piece, {
+ letters: [[""]],
+ id: (piece.id + 1) * -1,
+ }),
+ );
+ }
+
currentGameState = {
...currentGameState,
- pieces: piecesNotBeingDragged.concat(
- piecesBeingDragged.map((piece) =>
- updatePieceDatum(piece, {
- boardTop: undefined,
- boardLeft: undefined,
- poolIndex: undefined,
- dragGroupTop:
- groupBoardTop === undefined ? 0 : piece.boardTop - groupBoardTop,
- dragGroupLeft:
- groupBoardLeft === undefined ? 0 : piece.boardLeft - groupBoardLeft,
- }),
- ),
- ),
+ pieces: piecesNotBeingDragged
+ .concat(
+ piecesBeingDragged.map((piece) =>
+ updatePieceDatum(piece, {
+ boardTop: undefined,
+ boardLeft: undefined,
+ poolIndex: undefined,
+ dragGroupTop:
+ groupBoardTop === undefined ? 0 : piece.boardTop - groupBoardTop,
+ dragGroupLeft:
+ groupBoardLeft === undefined
+ ? 0
+ : piece.boardLeft - groupBoardLeft,
+ }),
+ ),
+ )
+ .concat(placeholderPoolPieces),
dragCount: currentGameState.dragCount + 1,
dragState: updateDragState({
pieceIDs: piecesBeingDragged.map((piece) => piece.id),
@@ -124,6 +141,10 @@ function updateStateForDragStart({
pointerStartPosition: pointerStartPosition,
pointer: pointerStartPosition,
pointerOffset,
+ origin:
+ groupBoardTop !== undefined
+ ? {where: "board"}
+ : {where: "pool", index: poolIndex},
destination:
groupBoardTop !== undefined
? {where: "board", top: groupBoardTop, left: groupBoardLeft}
@@ -131,7 +152,11 @@ function updateStateForDragStart({
}),
};
- if (piecesBeingDragged.some((piece) => piece.poolIndex !== undefined)) {
+ // Don't bother updating the pool index for custom creation since the pool will never be depleted
+ if (
+ !isCustomCreation &&
+ piecesBeingDragged.some((piece) => piece.poolIndex !== undefined)
+ ) {
// A piece was removed from the pool, so recompute poolIndex for the other pieces.
let remainingPoolPieces = currentGameState.pieces.filter(
(piece) => piece.poolIndex !== undefined,
@@ -207,6 +232,100 @@ function updateStateForDragEnd(currentGameState) {
});
}
+function updateStateForCustomDragEnd(currentGameState) {
+ if (currentGameState.dragState === undefined) {
+ console.warn("dragEnd called with no dragState");
+ return currentGameState;
+ }
+
+ const destination = currentGameState.dragState.destination;
+ const origin = currentGameState.dragState.origin;
+ const draggedPieceIDs = currentGameState.dragState.pieceIDs;
+ let newPieces = [];
+ if (destination.where === "board") {
+ let maxID = Math.max(...currentGameState.pieces.map((piece) => piece.id));
+
+ // Any letters dropped on the board will overwrite anything at that position
+ // (this is a deviation from the standard game)
+ let overwrittenPositions = [];
+ for (const piece of currentGameState.pieces) {
+ if (draggedPieceIDs.includes(piece.id)) {
+ overwrittenPositions.push([
+ destination.top + piece.dragGroupTop,
+ destination.left + piece.dragGroupLeft,
+ ]);
+ }
+ }
+
+ for (const piece of currentGameState.pieces) {
+ if (draggedPieceIDs.includes(piece.id)) {
+ newPieces.push(
+ updatePieceDatum(piece, {
+ boardTop: destination.top + piece.dragGroupTop,
+ boardLeft: destination.left + piece.dragGroupLeft,
+ dragGroupTop: undefined,
+ dragGroupLeft: undefined,
+ }),
+ );
+
+ // If dragging from pool to board, also add a replacement to the pool
+ if (origin.where === "pool") {
+ maxID++;
+ newPieces.push(
+ updatePieceDatum(piece, {
+ dragGroupTop: undefined,
+ dragGroupLeft: undefined,
+ poolIndex: origin.index,
+ id: maxID,
+ }),
+ );
+ }
+ } else if (
+ piece.letters[0][0] !== "" &&
+ !overwrittenPositions.some(
+ (position) =>
+ position[0] == piece.boardTop && position[1] == piece.boardLeft,
+ )
+ ) {
+ newPieces.push(piece);
+ }
+ }
+ }
+ // If dragging from board to pool, clear the piece from the board but don't add it to the pool
+ else if (destination.where === "pool" && origin.where === "board") {
+ for (const piece of currentGameState.pieces) {
+ if (draggedPieceIDs.includes(piece.id)) {
+ continue;
+ } else if (piece.letters[0][0] !== "") {
+ newPieces.push(piece);
+ }
+ }
+ }
+ // If dragging from pool to pool, readd the piece to the pool at its original position
+ // (and get rid of the empty placeholder piece)
+ else if (destination.where === "pool" && origin.where === "pool") {
+ for (const piece of currentGameState.pieces) {
+ if (draggedPieceIDs.includes(piece.id)) {
+ newPieces.push(
+ updatePieceDatum(piece, {
+ poolIndex: origin.index,
+ dragGroupTop: undefined,
+ dragGroupLeft: undefined,
+ }),
+ );
+ } else if (piece.letters[0][0] !== "") {
+ newPieces.push(piece);
+ }
+ }
+ }
+
+ return {
+ ...currentGameState,
+ pieces: newPieces,
+ dragState: undefined,
+ };
+}
+
function giveHint(currentGameState) {
const pieces = cloneDeep(currentGameState.pieces);
const {maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} =
@@ -425,7 +544,17 @@ function updateCompletionState(gameState) {
export function gameReducer(currentGameState, payload) {
if (payload.action === "newGame") {
- return gameInit({...payload, seed: undefined, useSaved: false});
+ return gameInit({
+ ...payload,
+ seed: undefined,
+ useSaved: false,
+ isCustom: false,
+ });
+ } else if (payload.action === "playCustom") {
+ return gameInit({
+ seed: payload.representativeString,
+ isCustom: true,
+ });
} else if (payload.action === "changeValidityOpacity") {
return {
...currentGameState,
@@ -442,8 +571,6 @@ export function gameReducer(currentGameState, payload) {
hintTally: currentGameState.hintTally + 1,
});
} else if (payload.action === "dragStart") {
- // Fired on pointerdown on a piece anywhere.
- // Captures initial `dragState`. `destination` is initialized to where the piece already is.
const {pieceID, pointerID, pointerStartPosition} = payload;
return updateStateForDragStart({
currentGameState,
@@ -451,19 +578,19 @@ export function gameReducer(currentGameState, payload) {
pointerID,
pointerStartPosition,
boardIsShifting: false,
+ isCustomCreation: currentGameState.isCustomCreation,
});
} else if (payload.action === "dragNeighbors") {
- // Fired when the timer fires, if `!dragHasMoved`.
- //
- // Set `piece.isDragging` on all neighbors using `destination` to figure out
- // which pieces are neighbors. Implemented by dropping the current piece, then picking
- // it and all connected pieces up again.
+ // Fired when the timer fires, if `!dragHasMoved`
+ // Drop the current piece, then pick up it and all connected pieces
const {dragState} = currentGameState;
if (dragState === undefined || dragState.pieceIDs.length !== 1) {
return currentGameState;
}
- const droppedGameState = updateStateForDragEnd(currentGameState);
+ const droppedGameState = currentGameState.isCustomCreation
+ ? updateStateForCustomDragEnd(currentGameState)
+ : updateStateForDragEnd(currentGameState);
const connectedPieceIDs = getConnectedPieceIDs({
pieces: droppedGameState.pieces,
gridSize: droppedGameState.gridSize,
@@ -476,6 +603,7 @@ export function gameReducer(currentGameState, payload) {
pointerStartPosition: dragState.pointer,
boardIsShifting: false,
previousDragState: dragState,
+ isCustomCreation: currentGameState.isCustomCreation,
});
} else if (payload.action === "dragMove") {
// Fired on pointermove and on lostpointercapture.
@@ -498,7 +626,9 @@ export function gameReducer(currentGameState, payload) {
// Fired on lostpointercapture, after `dragMove`.
//
// Drop all dragged pieces to `destination` and clear `dragState`.
- return updateStateForDragEnd(currentGameState);
+ return currentGameState.isCustomCreation
+ ? updateStateForCustomDragEnd(currentGameState)
+ : updateStateForDragEnd(currentGameState);
} else if (payload.action === "shiftStart") {
// Fired on pointerdown in an empty square on the board.
//
@@ -511,6 +641,7 @@ export function gameReducer(currentGameState, payload) {
pointerID,
pointerStartPosition,
boardIsShifting: true,
+ isCustomCreation: currentGameState.isCustomCreation,
});
} else if (payload.action === "clearStreakIfNeeded") {
const lastDateWon = currentGameState.stats.lastDateWon;
@@ -534,6 +665,16 @@ export function gameReducer(currentGameState, payload) {
stats: newStats,
};
}
+ } else if (payload.action === "updateInvalidReason") {
+ return {
+ ...currentGameState,
+ invalidReason: payload.invalidReason,
+ };
+ } else if (payload.action === "updateRepresentativeString") {
+ return {
+ ...currentGameState,
+ representativeString: payload.representativeString,
+ };
} else {
console.log(`unknown action: ${payload.action}`);
return currentGameState;
diff --git a/src/logic/generatePuzzleFromRepresentativeString.js b/src/logic/generatePuzzleFromRepresentativeString.js
new file mode 100644
index 0000000..a3675b5
--- /dev/null
+++ b/src/logic/generatePuzzleFromRepresentativeString.js
@@ -0,0 +1,37 @@
+import seedrandom from "seedrandom";
+import {makePieces} from "./makePieces";
+import {shuffleArray, getMaxShifts} from "@skedwards88/word_logic";
+import {convertRepresentativeStringToGrid} from "./convertRepresentativeStringToGrid";
+import {updatePieceDatum} from "./assemblePiece";
+
+export function generatePuzzleFromRepresentativeString({representativeString}) {
+ const pseudoRandomGenerator = seedrandom(representativeString);
+
+ const grid = convertRepresentativeStringToGrid(representativeString);
+
+ const gridSize = grid.length;
+ const minLetters = grid.flat().filter((i) => i).length;
+
+ const {maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = getMaxShifts(
+ grid,
+ "",
+ );
+
+ const pieces = shuffleArray(makePieces(grid), pseudoRandomGenerator);
+ const pieceData = pieces.map((piece, index) =>
+ updatePieceDatum(piece, {
+ id: index,
+ poolIndex: index,
+ }),
+ );
+
+ return {
+ pieces: pieceData,
+ maxShiftLeft,
+ maxShiftRight,
+ maxShiftUp,
+ maxShiftDown,
+ gridSize,
+ minLetters,
+ };
+}
diff --git a/src/logic/parseUrlQuery.js b/src/logic/parseUrlQuery.js
index 3c1ddbf..9881004 100644
--- a/src/logic/parseUrlQuery.js
+++ b/src/logic/parseUrlQuery.js
@@ -1,14 +1,22 @@
export function parseUrlQuery() {
const searchParams = new URLSearchParams(document.location.search);
- const seedQuery = searchParams.get("puzzle");
+ const query = searchParams.get("id");
- // The seed query consists of two parts: the seed and the min number of letters, separated by an underscore
+ let isCustom;
let numLetters;
let seed;
- if (seedQuery) {
- [seed, numLetters] = seedQuery.split("_");
+
+ // The query differs depending on whether it represents a random puzzle or a custom puzzle:
+ // For custom puzzles, it is "custom-" followed by a representative string
+ // For random puzzles, it is the seed and the min number of letters, separated by an underscore
+ if (query && query.startsWith("custom-")) {
+ seed = query.substring("custom-".length);
+ isCustom = true;
+ } else if (query) {
+ [seed, numLetters] = query.split("_");
numLetters = parseInt(numLetters);
+ isCustom = false;
}
- return [seed, numLetters];
+ return [isCustom, seed, numLetters];
}
diff --git a/src/logic/resizeGrid.js b/src/logic/resizeGrid.js
new file mode 100644
index 0000000..6558973
--- /dev/null
+++ b/src/logic/resizeGrid.js
@@ -0,0 +1,81 @@
+import cloneDeep from "lodash.clonedeep";
+import {getMaxShifts} from "@skedwards88/word_logic";
+
+// Remove/add empty edge columns or rows to
+// get as close as possible to one empty row/column on each edge
+// and to center the contents,
+// while still keeping the grid square
+// and not making the grid smaller than 8x8
+export function resizeGrid(grid) {
+ if (grid.some((row) => row.length != grid.length)) {
+ throw new Error("Input grid is not square");
+ }
+
+ let paddedGrid = cloneDeep(grid);
+
+ // Get the current maximum number of empty columns/rows that are on the edge of the grid
+ const {maxShiftLeft, maxShiftRight, maxShiftUp, maxShiftDown} = getMaxShifts(
+ grid,
+ "",
+ );
+
+ // Get the width/height, not counting empty rows/cols on the edge
+ const usedWidth = grid.length - maxShiftLeft - maxShiftRight;
+ const usedHeight = grid[0].length - maxShiftUp - maxShiftDown;
+
+ // The optimum dimension is 1 empty row/col on each side
+ // but extra will be added to keep the grid square and at least 8x8
+ const targetDimension = Math.max(usedHeight + 2, usedWidth + 2, 8);
+
+ // Figure out the number of empty rows/cols on each edge
+ const targetPadTop = Math.floor((targetDimension - usedHeight) / 2);
+ const targetPadBottom = Math.ceil((targetDimension - usedHeight) / 2);
+ const targetPadLeft = Math.floor((targetDimension - usedWidth) / 2);
+ const targetPadRight = Math.ceil((targetDimension - usedWidth) / 2);
+
+ if (targetPadTop < maxShiftUp) {
+ // trim top
+ paddedGrid.splice(0, maxShiftUp - targetPadTop);
+ } else {
+ // or pad
+ const newRows = Array.from({length: targetPadTop - maxShiftUp}, () =>
+ Array(paddedGrid[0].length).fill(""),
+ );
+ paddedGrid = [...newRows, ...paddedGrid];
+ }
+
+ if (targetPadBottom < maxShiftDown) {
+ // trim bottom
+ paddedGrid.splice(-(maxShiftDown - targetPadBottom));
+ } else {
+ // or pad
+ const newRows = Array.from({length: targetPadBottom - maxShiftDown}, () =>
+ Array(paddedGrid[0].length).fill(""),
+ );
+ paddedGrid = [...paddedGrid, ...newRows];
+ }
+
+ if (targetPadLeft < maxShiftLeft) {
+ // trim left
+ paddedGrid.forEach((row) => row.splice(0, maxShiftLeft - targetPadLeft));
+ } else {
+ // or pad
+ paddedGrid = paddedGrid.map((row) => [
+ ...new Array(targetPadLeft - maxShiftLeft).fill(""),
+ ...row,
+ ]);
+ }
+
+ if (targetPadRight < maxShiftRight) {
+ // trim right
+ paddedGrid.forEach((row) => row.splice(-(maxShiftRight - targetPadRight)));
+ } else {
+ // or pad
+ paddedGrid = paddedGrid.map((row) => [
+ ...row,
+ ...new Array(targetPadRight - maxShiftRight).fill(""),
+ ]);
+ }
+
+ return paddedGrid;
+}
diff --git a/src/logic/resizeGrid.test.js b/src/logic/resizeGrid.test.js
new file mode 100644
index 0000000..e49f25f
--- /dev/null
+++ b/src/logic/resizeGrid.test.js
@@ -0,0 +1,483 @@
+import {resizeGrid} from "./resizeGrid";
+
+describe("resizeGrid", () => {
+ test("trims a grid down to 8x8", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "A", "B", "C", "", "", "", "", ""],
+ ["", "", "", "", "A", "B", "C", "", "", "", "", ""],
+ ["", "", "", "", "X", "Y", "Z", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("expands a grid up to 8x8", () => {
+ const input = [
+ ["A", "B", "C", "", "", ""],
+ ["A", "B", "C", "", "", ""],
+ ["X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", ""],
+ ["", "", "", "", "", ""],
+ ["", "", "", "", "", ""],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("pads a grid so that there 1 empty row/col on each edge", () => {
+ const input = [
+ ["A", "", "", "", "", "", "", "", "", "", "", "B"],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["C", "", "", "", "", "", "", "", "", "", "", "D"],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "A", "", "", "", "", "", "", "", "", "", "", "B", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "C", "", "", "", "", "", "", "", "", "", "", "D", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("centers content", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "A", "B", "C", "", "", "", "", "", "", "", ""],
+ ["", "A", "B", "C", "", "", "", "", "", "", "", ""],
+ ["", "X", "Y", "Z", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are on the left edge and need padding", () => {
+ const input = [
+ ["A", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["D", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["C", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "A", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "D", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "C", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are on the right edge and need padding", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", "A"],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "D"],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "C"],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "A", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "D", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "C", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are on the top edge and need padding", () => {
+ const input = [
+ ["A", "", "", "D", "", "", "", "", "", "", "", "C"],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "A", "", "", "D", "", "", "", "", "", "", "", "C", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are on the bottom edge and need padding", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["A", "", "", "D", "", "", "", "", "", "", "", "C"],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "A", "", "", "D", "", "", "", "", "", "", "", "C", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are near bottom edge and need trimming on the top and padding on the bottom", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "A", "B", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are near top edge and need trimming on the bottom and padding on the top", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "A", "B", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are near left edge", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works if the grid contents are near right edge", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "A", "B", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("testing odd number row/cols", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "A", "B", "C", "", "", "", "", ""],
+ ["", "", "", "A", "B", "C", "", "", "", "", ""],
+ ["", "", "", "X", "Y", "Z", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", ""],
+ ];
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("works on an empty grid", () => {
+ const input = [
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", "", "", "", "", ""],
+ ];
+
+ const expected = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(expected);
+ });
+
+ test("if the grid does not need adjustment, returns a copy of the grid", () => {
+ const input = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(resizeGrid(input)).toEqual(input);
+ expect(resizeGrid(input)).not.toBe(input);
+ });
+
+ test("errors if grid is taller than wide", () => {
+ const input = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(() => resizeGrid(input)).toThrow("Input grid is not square");
+ });
+
+ test("errors if grid is wider than tall", () => {
+ const input = [
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "A", "B", "C", "", "", ""],
+ ["", "", "X", "Y", "Z", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ["", "", "", "", "", "", "", ""],
+ ];
+
+ expect(() => resizeGrid(input)).toThrow("Input grid is not square");
+ });
+});
diff --git a/src/logic/updateDragState.js b/src/logic/updateDragState.js
index 11763c6..ca7bc06 100644
--- a/src/logic/updateDragState.js
+++ b/src/logic/updateDragState.js
@@ -15,6 +15,12 @@
// - if on the pool:
// - where: "pool"
// - index (integer): The starting index of the drag group in the pool
+// - origin: Info about where the piece originated
+// - if on the board:
+// - where: "board"
+// - if on the pool:
+// - where: "pool"
+// - index (integer): The starting index of the drag group in the pool
export function updateDragState(oldDragState = {}, updates = {}) {
return {
...oldDragState,
diff --git a/src/styles/App.css b/src/styles/App.css
index 757a452..d645cad 100644
--- a/src/styles/App.css
+++ b/src/styles/App.css
@@ -109,7 +109,8 @@ button:disabled {
border-bottom: 2px solid var(--light-color);
}
-#game {
+#game,
+#custom {
display: grid;
--box-size:
@@ -251,6 +252,10 @@ button:disabled {
justify-content: center;
}
+#custom #pool {
+ align-content: flex-start;
+}
+
.piece {
touch-action: none;
pointer-events: none;
@@ -267,6 +272,10 @@ button:disabled {
padding: 3vmin;
}
+#custom .pool-slot {
+ padding: 2vmin;
+}
+
.letter,
#board:not(:empty) {
cursor: grab;
@@ -315,3 +324,35 @@ body:has(#drag-group) {
display: flex;
flex-direction: column;
}
+
+.customMessage {
+ white-space: pre-line;
+ display: flex;
+ flex-direction: column;
+ text-align: center;
+ overflow: scroll;
+ justify-items: center;
+ justify-content: space-evenly;
+ align-items: center;
+ font-size: calc(var(--default-font-size) * 0.75);
+}
+
+.customMessage > div,
+.customMessage > a {
+ white-space: pre-line;
+ text-wrap: wrap;
+ overflow-wrap: break-word;
+ word-break: break-all;
+ padding: 10px;
+}
+
+#custom-message-buttons {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ width: 100%;
+}
+
+#custom-message-buttons > button {
+ margin: 10px;
+}
diff --git a/src/styles/ControlBar.css b/src/styles/ControlBar.css
index 0440395..4c201c5 100644
--- a/src/styles/ControlBar.css
+++ b/src/styles/ControlBar.css
@@ -39,6 +39,10 @@
background-image: url("../images/icons/heart.svg");
}
+#customButton {
+ background-image: url("../images/icons/custom.svg");
+}
+
#calendarButton {
background-image:
url("../images/icons/daily.svg"),
diff --git a/src/styles/LargeScreen.css b/src/styles/LargeScreen.css
index 48dd3f0..9ce7881 100644
--- a/src/styles/LargeScreen.css
+++ b/src/styles/LargeScreen.css
@@ -7,7 +7,8 @@
grid-template-rows: 1fr;
}
- #game {
+ #game,
+ #custom {
display: grid;
grid-area: game;
grid-template-areas: "board pool";
@@ -56,7 +57,8 @@
justify-items: center;
}
- #game {
+ #game,
+ #custom {
--box-size:
min(
calc((48vh - 1px) / var(--grid-rows)),
@@ -90,7 +92,8 @@
--default-box-size: min(4vh, 7vw, 1cm);
}
- #game {
+ #game,
+ #custom {
--box-size:
min(
calc((48vh - 1px) / var(--grid-rows)),