diff --git a/frontend/src/components/PasteRegistration.tsx b/frontend/src/components/PasteRegistration.tsx new file mode 100644 index 0000000..cf043f8 --- /dev/null +++ b/frontend/src/components/PasteRegistration.tsx @@ -0,0 +1,142 @@ +import { Typography } from "@mui/material"; +import Box from "@mui/material/Box"; +import HashIcon from "@mui/icons-material/NumbersRounded"; +import Button from "@mui/material/Button"; +import Snackbar from "@mui/material/Snackbar"; +import Alert from "@mui/material/Alert"; +import { alpha, useTheme } from "@mui/material/styles"; +import TextField from "components/TextField"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import { FC, useState } from "react"; +import { commentsActions } from "store/slices/commentsSlice"; +import { digitCodeActions } from "store/slices/digitCodeSlice"; +import { registrationActions } from "store/slices/registrationSlice"; +import { roundsActions } from "store/slices/roundsSlice"; +import { parse as parseTuringInfo } from "parsing/turing-copy-paste"; +import { parse as parseProblemBook } from "parsing/problem-book"; + +const PasteRegistration: FC = () => { + const dispatch = useAppDispatch(); + const registration = useAppSelector((state) => state.registration); + const [cardText, setCardText] = useState(""); + const [showNotFound, setShowNotFound] = useState(false); + const theme = useTheme(); + + function onSubmit() { + const problem = parseTuringInfo(cardText) || parseProblemBook(cardText); + console.log(problem); + if (problem === null) { + setShowNotFound(true); + return; + } + dispatch(registrationActions.updateHash(problem.code.toUpperCase())); + dispatch(roundsActions.reset()); + dispatch(commentsActions.reset()); + dispatch(digitCodeActions.reset()); + dispatch(registrationActions.fetchDone()); + dispatch(commentsActions.setCards(problem)); + } + + if (registration.status !== "new") { + return ( + } + value={registration.hash} + maxChars={10} + customRadius={ + registration.status === "ready" + ? theme.spacing(0, 0, 2, 2) + : undefined + } + /> + ); + } + + return ( + <> + { + setShowNotFound(false); + }} + > + { + setShowNotFound(false); + }} + severity="error" + sx={{ width: "100%" }} + variant="filled" + > + Could not parse the game setup. Did you copy&paste the whole setup + from turingmachine.info or + the{" "} + + problem book + + ? + + +
{ + e.preventDefault(); + onSubmit(); + }} + > + + You can paste a game setup string in the following text box. Supported + methods are:
+ 1. You can copy a generated game from{" "} + turingmachine.info. The + copied text needs to include the "#" and all the cards and verifiers. +
+ 2. You can copy from the{" "} + + problem book + + . Be sure to include the whole problem line. +
+ Paste Game Setup + } + value={cardText} + onChange={(value) => { + setCardText(value); + }} + withReset={true} + onReset={() => { + setCardText(""); + }} + /> + + + + + + + ); +}; + +export default PasteRegistration; diff --git a/frontend/src/parsing/__test__/problem-book.test.ts b/frontend/src/parsing/__test__/problem-book.test.ts new file mode 100644 index 0000000..e131464 --- /dev/null +++ b/frontend/src/parsing/__test__/problem-book.test.ts @@ -0,0 +1,38 @@ +import { parse } from "parsing/problem-book"; + +test("C65 6FF P", () => { + expect( + parse("5,#C65 6FF P,->,5,22,24,27,33,48,O252,O536,O658,O213,O613,O289") + ).toStrictEqual({ + code: "C656FFP", + color: 1, + ind: [5, 22, 24, 27, 33, 48], + crypt: [252, 536, 658, 213, 613, 289], + m: 0, + }); +}); + +test("F4D EXI", () => { + expect( + parse("10,#F4D EXI,->,25,23,24,16,36,4,42,14,B749,B643,B586,B325") + ).toStrictEqual({ + code: "F4DEXI", + color: 2, + ind: [25, 24, 36, 42], + fake: [23, 16, 4, 14], + crypt: [749, 643, 586, 325], + m: 1, + }); +}); + +test("I51 ZKH K", () => { + expect( + parse("4,#I51 ZKH K,->,N23,N24,N25,N41,N48,G681,G746,G440,G350,G771") + ).toStrictEqual({ + code: "I51ZKHK", + color: 0, + ind: [23, 24, 25, 41, 48], + crypt: [681, 746, 440, 350, 771], + m: 2, + }); +}); diff --git a/frontend/src/parsing/__test__/turing-copy-paste.test.ts b/frontend/src/parsing/__test__/turing-copy-paste.test.ts new file mode 100644 index 0000000..80f4c1c --- /dev/null +++ b/frontend/src/parsing/__test__/turing-copy-paste.test.ts @@ -0,0 +1,102 @@ +import { parse } from "parsing/turing-copy-paste"; + +test("A518FRM", () => { + expect( + parse( + `op #A51 8FR M Share Get Criteria and Verification cards in the box. A 1 652 B 7 405 C 12 616 D 15 331 E 16 505 Back to Homepage` + ) + ).toStrictEqual({ + ind: [1, 7, 12, 15, 16], + crypt: [652, 405, 616, 331, 505], + color: 2, + m: 0, + code: "A518FRM", + }); +}); + +test("A4BL4O", () => { + expect( + parse( + `op #A4B L4O Share Get Criteria and Verification cards in the box. A 5 578 B 10 376 C 11 566 D 17 618 Back to Homepage` + ) + ).toStrictEqual({ + ind: [5, 10, 11, 17], + crypt: [578, 376, 566, 618], + color: 0, + m: 0, + code: "A4BL4O", + }); +}); + +test("A4BL4O lowercase", () => { + expect( + parse( + `op #A4B L4O Share Get Criteria and Verification cards in the box. A 5 578 B 10 376 C 11 566 D 17 618 Back to Homepage`.toLowerCase() + ) + ).toStrictEqual({ + ind: [5, 10, 11, 17], + crypt: [578, 376, 566, 618], + color: 0, + m: 0, + code: "A4BL4O", + }); +}); + +test("A4BL4O uppercase", () => { + expect( + parse( + `op #A4B L4O Share Get Criteria and Verification cards in the box. A 5 578 B 10 376 C 11 566 D 17 618 Back to Homepage`.toUpperCase() + ) + ).toStrictEqual({ + ind: [5, 10, 11, 17], + crypt: [578, 376, 566, 618], + color: 0, + m: 0, + code: "A4BL4O", + }); +}); + +test("C65 6FF P", () => { + expect( + parse( + `op #C65 6FF P Share Get Criteria and Verification cards in the box. A 5 252 B 22 536 C 24 658 D 27 213 E 33 613 F 48 289 Back to Homepage` + ) + ).toStrictEqual({ + ind: [5, 22, 24, 27, 33, 48], + crypt: [252, 536, 658, 213, 613, 289], + color: 1, + m: 0, + code: "C656FFP", + }); +}); + +test("E52 NBU A", () => { + expect( + parse( + `rulebook page 3 #E52 NBU A Share Get Criteria and Verification cards in the box. a 1721 499 b 2214 296 c 2319 237 d 210 378 e 1815 594 Back t` + ) + ).toStrictEqual({ + ind: [17, 22, 23, 2, 18], + fake: [21, 14, 19, 10, 15], + crypt: [499, 296, 237, 378, 594], + color: 2, + m: 1, + code: "E52NBUA", + }); +}); + +// + +test("H5K M7S", () => { + expect( + parse( + `Nightmare Mode: see rulebook page 3 #H5K M7S Share Get Criteria and Verification cards in the box. 212171922 A 485 B 537 C 315 D 413 E 614 Back to Homepage` + ) + ).toStrictEqual({ + ind: [2, 12, 17, 19, 22], + crypt: [485, 537, 315, 413, 614], + color: 0, + m: 2, + code: "H5KM7S", + }); +}); diff --git a/frontend/src/parsing/problem-book.ts b/frontend/src/parsing/problem-book.ts new file mode 100644 index 0000000..1eec2da --- /dev/null +++ b/frontend/src/parsing/problem-book.ts @@ -0,0 +1,70 @@ +import { parseCode, getColor, type ParsedGame } from "./util"; + +export function parse(text: string): ParsedGame | null { + const problem = text.split("#")[1]; + if (!problem) { + return null; + } + const hashEnd = problem.indexOf(","); + const code = problem.slice(0, hashEnd).replace(/\s+/g, ""); + const parsedCode = parseCode(code); + if (parsedCode === null) { + return null; + } + const { mode, numVerifiers } = parsedCode; + const cardText = problem.split("->,")[1]; + if (!cardText) { + return null; + } + const cards = cardText.split(","); + if (mode === 0) { + // CLASSIC + const ind = []; + for (let i = 0; i < numVerifiers; i += 1) { + ind.push(Number(cards[i])); + } + const crypt = []; + for (let i = numVerifiers; i < numVerifiers * 2; i += 1) { + crypt.push(Number(cards[i].slice(1))); + } + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + return { ind, m: mode, crypt, code, color }; + } else if (mode === 1) { + // EXTREME + const ind = []; + const fake = []; + for (let i = 0; i < numVerifiers * 2; i += 2) { + ind.push(Number(cards[i])); + fake.push(Number(cards[i + 1])); + } + const crypt = []; + for (let i = numVerifiers * 2; i < numVerifiers * 3; i += 1) { + crypt.push(Number(cards[i].slice(1))); + } + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + return { ind, fake, m: mode, crypt, code, color }; + } else if (mode === 2) { + // NIGHTMARE + const ind = []; + for (let i = 0; i < numVerifiers; i += 1) { + ind.push(Number(cards[i].slice(1))); + } + const crypt = []; + for (let i = numVerifiers; i < numVerifiers * 2; i += 1) { + crypt.push(Number(cards[i].slice(1))); + } + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + return { ind, m: mode, crypt, code, color }; + } else { + return null; + } +} diff --git a/frontend/src/parsing/turing-copy-paste.ts b/frontend/src/parsing/turing-copy-paste.ts new file mode 100644 index 0000000..4205fe3 --- /dev/null +++ b/frontend/src/parsing/turing-copy-paste.ts @@ -0,0 +1,144 @@ +import { range, getColor, parseCode, type ParsedGame } from "./util"; + +function getCode(parts: string[]) { + let start = 0; + for (let i = 0; i < parts.length; i += 1) { + if (parts[i].startsWith("#")) { + start = i; + } else if (parts[i].toLowerCase().includes("share")) { + return parts.slice(start, i); + } + } + return null; +} + +function classicRegex(verifier: number) { + return String.fromCharCode(65 + verifier) + "(\\d{1,2})(\\d{3})"; +} + +function parseClassicMatch(match: string[]) { + const ind = []; + const crypt = []; + for (let i = 0; i < match.length; i += 1) { + if (i % 2 == 0) { + ind.push(Number(match[i])); + } else { + crypt.push(Number(match[i])); + } + } + return { ind, crypt }; +} + +// a 1721 499 b 2214 296 c 2319 237 d 210 378 e 1815 594 B +function extremeRegex(verifier: number) { + return ( + String.fromCharCode(65 + verifier) + + "((\\d)(\\d)|(\\d)(\\d{2})|(\\d{2})(\\d{2}))(\\d{3})(?![0-9])" + ); +} + +function parseExtremeMatch(match: string[]) { + const ind: number[] = []; + const fake: number[] = []; + const crypt: number[] = []; + const validMatches = match.filter((el) => el !== undefined); + for (let i = 0; i < validMatches.length; i += 1) { + if (i % 4 === 1) { + ind.push(Number(validMatches[i])); + } else if (i % 4 === 2) { + fake.push(Number(validMatches[i])); + } else if (i % 4 === 3) { + crypt.push(Number(validMatches[i])); + } + } + return { ind, fake, crypt }; +} + +function nightmareCryptRegex(verifier: number) { + return String.fromCharCode(65 + verifier) + "(\\d{3})"; +} + +function parseNightmareInd(text: string, numVerifiers: number) { + const indText = text.match( + new RegExp(`\\d{${numVerifiers},${numVerifiers * 2}}`) + ); + if (!indText) { + return null; + } + const indTextMatch = indText[0]; + const numSingleDigit = numVerifiers * 2 - indTextMatch.length; + const ind: number[] = []; + let currentChar = 0; + for (; currentChar < numSingleDigit; currentChar += 1) { + ind.push(Number(indTextMatch.slice(currentChar, currentChar + 1))); + } + for (; currentChar < indTextMatch.length; currentChar += 2) { + ind.push(Number(indTextMatch.slice(currentChar, currentChar + 2))); + } + return ind; +} + +export function parse(text: string): ParsedGame | null { + const parts = text.replaceAll(/\s+/g, " ").trim().split(" "); + const codeParts = getCode(parts); + if (codeParts === null) { + return null; + } + const code = codeParts.join("").slice(1).toUpperCase(); + const parsedCode = parseCode(code); + if (parsedCode === null) { + return null; + } + const { mode, numVerifiers } = parsedCode; + + const textWithoutSpaces = text.replaceAll(/\s+/g, "").toUpperCase(); + if (mode === 0) { + // CLASSIC + const match = textWithoutSpaces.match( + new RegExp(range(0, numVerifiers).map(classicRegex).join("")) + ); + if (!match) { + return null; + } + const { ind, crypt } = parseClassicMatch(match.slice(1)); + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + return { ind, crypt, color, m: mode, code }; + } else if (mode === 1) { + // EXTREME + const match = textWithoutSpaces.match( + new RegExp(range(0, numVerifiers).map(extremeRegex).join("")) + ); + if (!match) { + return null; + } + const { ind, fake, crypt } = parseExtremeMatch(match.slice(1)); + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + return { ind, fake, crypt, color, m: mode, code }; + } else if (mode === 2) { + // NIGHTMARE + const cryptMatch = textWithoutSpaces.match( + new RegExp(range(0, numVerifiers).map(nightmareCryptRegex).join("")) + ); + if (!cryptMatch) { + return null; + } + const crypt = cryptMatch.slice(1).map(Number); + const color = getColor(crypt[0]); + if (color === null) { + return null; + } + const ind = parseNightmareInd(textWithoutSpaces, numVerifiers); + if (ind === null) { + return null; + } + return { ind, color, m: mode, crypt, code }; + } else { + return null; + } +} diff --git a/frontend/src/parsing/util.ts b/frontend/src/parsing/util.ts new file mode 100644 index 0000000..9bab548 --- /dev/null +++ b/frontend/src/parsing/util.ts @@ -0,0 +1,86 @@ +import type { GameSetup } from "store/slices/commentsSlice"; + +export type ParsedGame = { + code: string; +} & GameSetup; + +// Adapted this from: https://github.com/manurFR/turingmachine/blob/25027290422fe858d99d17801d3a644b467ce732/checkcards.py +export const CHECK_CARDS = [ + [ + 201, 206, 215, 220, 227, 233, 244, 253, 261, 267, 274, 280, 286, 293, 302, + 309, 315, 322, 329, 334, 339, 346, 350, 356, 360, 370, 376, 381, 387, 392, + 396, 403, 407, 413, 419, 429, 434, 440, 447, 455, 462, 470, 475, 481, 485, + 491, 497, 503, 507, 516, 525, 532, 537, 546, 551, 560, 566, 572, 578, 582, + 588, 592, 596, 604, 609, 614, 618, 628, 632, 636, 640, 646, 650, 654, 661, + 665, 670, 681, 688, 695, 701, 709, 717, 723, 737, 741, 746, 751, 758, 766, + 771, 778, 782, 787, 795, + ], + [ + 205, 213, 219, 224, 232, 243, 252, 257, 266, 273, 279, 289, 299, 308, 314, + 319, 327, 332, 338, 344, 349, 355, 359, 369, 374, 379, 386, 391, 395, 402, + 406, 412, 418, 424, 433, 439, 445, 454, 461, 469, 474, 480, 484, 490, 496, + 502, 506, 515, 523, 530, 536, 543, 550, 558, 564, 571, 577, 581, 587, 591, + 595, 599, 608, 613, 617, 627, 631, 635, 639, 645, 649, 653, 658, 664, 669, + 680, 687, 694, 699, 708, 715, 720, 729, 736, 740, 744, 750, 757, 765, 770, + 776, 781, 786, 793, 798, + ], + [ + 204, 212, 217, 223, 231, 237, 251, 256, 264, 270, 278, 282, 288, 296, 304, + 312, 317, 325, 331, 337, 341, 348, 353, 358, 365, 373, 378, 385, 390, 394, + 401, 405, 410, 416, 423, 432, 437, 442, 453, 459, 464, 472, 479, 483, 487, + 495, 499, 505, 514, 520, 528, 534, 541, 549, 557, 563, 568, 576, 580, 586, + 590, 594, 598, 606, 611, 616, 625, 630, 634, 638, 643, 648, 652, 657, 663, + 668, 677, 686, 691, 697, 706, 714, 719, 726, 739, 743, 749, 755, 763, 769, + 775, 780, 785, 792, 797, + ], + [ + 202, 207, 216, 221, 228, 236, 247, 255, 263, 268, 277, 287, 294, 303, 311, + 316, 324, 330, 335, 340, 347, 352, 357, 362, 372, 377, 382, 391, 393, 399, + 404, 409, 414, 421, 430, 435, 441, 449, 458, 463, 471, 476, 482, 486, 492, + 498, 504, 509, 518, 527, 533, 540, 547, 553, 562, 567, 573, 579, 585, 589, + 593, 597, 605, 610, 615, 621, 629, 633, 637, 641, 647, 651, 656, 662, 667, + 673, 684, 690, 696, 704, 710, 718, 725, 733, 738, 742, 747, 754, 759, 767, + 773, 779, 783, 790, 796, + ], +]; + +export function getColor(checkCard: number) { + for (let i of range(0, 4)) { + if (CHECK_CARDS[i].includes(checkCard)) { + return i; + } + } + return null; +} + +export function range(start: number, end: number): number[] { + const result = []; + for (let i = start; i < end; i += 1) { + result.push(i); + } + return result; +} + +const MODE_MAP: { [key: string]: number } = { + A: 0, + B: 0, + C: 0, + D: 1, + E: 1, + F: 1, + G: 2, + H: 2, + I: 2, +}; + +export function parseCode(code: string) { + if (code.length < 2) { + return null; + } + const mode = MODE_MAP[code[0]]; + if (mode === undefined) { + return null; + } + const numVerifiers = Number(code[1]); + return { mode, numVerifiers }; +} diff --git a/frontend/src/store/slices/commentsSlice.ts b/frontend/src/store/slices/commentsSlice.ts index 7411516..3337188 100644 --- a/frontend/src/store/slices/commentsSlice.ts +++ b/frontend/src/store/slices/commentsSlice.ts @@ -1,5 +1,9 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { CriteriaCard, criteriaCardPool, CryptCard } from "hooks/useCriteriaCard"; +import { + CriteriaCard, + criteriaCardPool, + CryptCard, +} from "hooks/useCriteriaCard"; const verifiers: Verifier[] = ["A", "B", "C", "D", "E", "F"]; @@ -36,6 +40,13 @@ const createLetters = ( }; export type CommentsState = Comment[]; +export type GameSetup = { + ind: number[]; + crypt: number[]; + color: number; + fake?: number[]; + m: number; +}; const initialState: CommentsState = []; @@ -45,24 +56,18 @@ export const commentsSlice = createSlice({ reducers: { load: (_, action: PayloadAction) => action.payload, reset: () => initialState, - setCards: ( - state, - action: PayloadAction<{ - ind: number[]; - crypt: number[]; - color: number; - fake?: number[]; - m?: number; - }> - ) => { + setCards: (state, action: PayloadAction) => { const { ind, crypt, color, fake, m } = action.payload; const nightmare = m === 2; - const addAdditionalCardAttributes = (card: number, cryptNumber: number) => { + const addAdditionalCardAttributes = ( + card: number, + cryptNumber: number + ) => { return { ...criteriaCardPool.find((cc) => cc.id === card)!, nightmare, - cryptCard: {id: cryptNumber, color: color} as CryptCard, + cryptCard: { id: cryptNumber, color: color } as CryptCard, }; }; diff --git a/frontend/src/views/Registration.tsx b/frontend/src/views/Registration.tsx index 21e301c..792f4e5 100644 --- a/frontend/src/views/Registration.tsx +++ b/frontend/src/views/Registration.tsx @@ -13,11 +13,12 @@ import { registrationActions } from "store/slices/registrationSlice"; import HashCodeRegistration from "components/HashCodeRegistration"; import ManualRegistration from "components/ManualRegistration"; import { Card } from "@mui/material"; +import PasteRegistration from "components/PasteRegistration"; const Registration: FC = () => { const dispatch = useAppDispatch(); const registration = useAppSelector((state) => state.registration); - const [registrationMethod, setRegristationMethod] = useState("manual"); + const [registrationMethod, setRegristationMethod] = useState("paste"); function changeRegistrationMethod(e: React.ChangeEvent) { setRegristationMethod((e.target as HTMLInputElement).value); } @@ -59,6 +60,7 @@ const Registration: FC = () => { control={} label="Manual" /> + } label="Paste" /> } @@ -75,6 +77,7 @@ const Registration: FC = () => { )} + {registrationMethod === "paste" && } ); };