[
  {
    "path": ".gitignore",
    "content": "dist\ndist-*\ncabal-dev\n*.o\n*.hi\n*.hie\n*.chi\n*.chs.h\n*.dyn_o\n*.dyn_hi\n.hpc\n.hsenv\n.cabal-sandbox/\ncabal.sandbox.config\n*.prof\n*.aux\n*.hp\n*.eventlog\n.stack-work/\ncabal.project.local\ncabal.project.local~\n.HTF/\n.ghc.environment.*\nresult\nnode_modules\nclient/built/\n.vscode/tasks.json\n*~\n.direnv\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Revision history for incredible\n\n## 0.1.0.0 -- YYYY-mm-dd\n\n* First version. Released on an unsuspecting world.\n"
  },
  {
    "path": "Gen/Main.hs",
    "content": "module Main where\n\nimport           Control.Monad\nimport           Incredible.App (writePuzzleMachine)\nimport           Incredible.Data\nimport           Incredible.Puzzle\nimport           Options.Applicative\nimport           System.Random\nimport System.Random.Stateful\n\n\ndata GenConfig = GenConfig {\n  genSeed :: Maybe Int\n, genTest :: Bool\n} deriving (Eq, Ord, Show)\n\ngenOpts :: Parser GenConfig\ngenOpts = do\n  GenConfig <$> configSeed <*> configTest\n  where\n    configSeed = option auto $ mconcat [\n                      long \"seed\"\n                    , short 's'\n                    , metavar \"SEED\"\n                    , value Nothing\n                    , help \"Seed for the random number generator\"\n                    ]\n    configTest = flag False True $ mconcat [\n                      long \"test\"\n                    , short 't'\n                    , help \"Generate a smaller test puzzle\"\n                    ]\n\nmain :: IO ()\nmain = do\n  opts <- execParser $ info (genOpts <**> helper) fullDesc\n  seed <- case genSeed opts of\n    Just s -> pure s\n    Nothing -> randomIO\n  putStrLn $ \"Using seed: \" <> show seed\n  let\n    (label, puzzle) = if genTest opts then (\"test-machine-\", testPuzzle) else (\"machine-\", gamePuzzle)\n    fp = label <> show seed <> \".json\"\n  gen <- newIOGenM $ mkStdGen seed\n  generated <- generatePuzzle gen puzzle\n  let m = mm generated\n  possible <- machineIsSolvable m\n  when (not possible) $ fail \"Machine is not solvable\"\n  writePuzzleMachine fp m\n  putStrLn \"Machine written\"\n  where\n    mm arr = MetaMachine arr (TileSize 740 740) 1000 mempty"
  },
  {
    "path": "LICENSE",
    "content": "Code is licensed as\nCopyright (c) 2024, xkcd\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n    * Redistributions of source code must retain the above copyright\n      notice, this list of conditions and the following disclaimer.\n\n    * Redistributions in binary form must reproduce the above\n      copyright notice, this list of conditions and the following\n      disclaimer in the documentation and/or other materials provided\n      with the distribution.\n\n    * Neither the name of xkcd nor the names of other\n      contributors may be used to endorse or promote products derived\n      from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nArt is licensed under Creative Commons Attribution-NonCommercial 2.5 License."
  },
  {
    "path": "README.md",
    "content": "# Incredible\n\n[https://xkcd.com/2916](https://xkcd.com/2916)\n\n## Development\n\nThere is a Nix shell defined in `flake.nix` that can be accessed with `nix develop`. This includes everything to build the client and the server.\n\n### Frontend\n\nThe client is built with NodeJS 20.\n\nA live development version can be started with `npm run start:dev` in `client/`.\n\nProduction JS can be built with `nix build .#incredible-client` or `npm run build` in `client/`.\n\n### Backend\n\nThe server is built with GHC 9.6.4.\n\nTo build and run the webserver with Cabal, `cabal run incredible-server` or with Nix, `nix run .#incredible:exe:incredible-server`.\n\nThere is also an executable for generating puzzles, `cabal run incredible-gen` or with Nix, `nix run .#incredible:exe:incredible-gen`.\n\n### VM\n\nYou can build a Nix VM with the frontend and backend built with the configuration in `/config` with `nixos-rebuild build-vm --flake .#incredible-vm`.\n\nThe VM can then be run with `./result/bin/run-nixos-vm`.\n"
  },
  {
    "path": "api-spec.json",
    "content": "{\"info\":{\"description\":\"Swagger API docs for Incredible\",\"title\":\"Incredible API\",\"version\":\"0\"},\"paths\":{\"/blueprint/file\":{\"post\":{\"parameters\":[{\"in\":\"header\",\"name\":\"X-WorkOrder\",\"required\":false,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/Blueprint\"}}}},\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"items\":[{\"$ref\":\"#/components/schemas/UUID\"},{\"items\":[{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"},{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"}],\"maxItems\":2,\"minItems\":2,\"type\":\"array\"}],\"maxItems\":2,\"minItems\":2,\"type\":\"array\"}}},\"description\":\"\"},\"400\":{\"description\":\"Invalid `body` or `X-WorkOrder`\"}}}},\"/folio/{blueprintid}\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"blueprintid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/Folio\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`blueprintid` not found\"}}}},\"/machine/current\":{\"get\":{\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/VersionedMachine (Maybe BlueprintID)\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}}}}},\"/machine/current/version\":{\"get\":{\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"type\":\"integer\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}}}}},\"/machine/{version}\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"version\",\"required\":true,\"schema\":{\"type\":\"integer\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/VersionedMachine (Maybe BlueprintID)\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`version` not found\"}}}},\"/machine/delta/{startVersion}/current\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"startVersion\",\"required\":true,\"schema\":{\"type\":\"integer\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"items\":[{\"type\":\"integer\"},{\"$ref\":\"#/components/schemas/VersionedMachine ModData\"}],\"maxItems\":2,\"minItems\":2,\"type\":\"array\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`startVersion` not found\"}}}},\"/machine/delta/{startVersion}/{endVersion}\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"startVersion\",\"required\":true,\"schema\":{\"type\":\"integer\"}},{\"in\":\"path\",\"name\":\"endVersion\",\"required\":true,\"schema\":{\"type\":\"integer\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"items\":[{\"type\":\"integer\"},{\"$ref\":\"#/components/schemas/VersionedMachine ModData\"}],\"maxItems\":2,\"minItems\":2,\"type\":\"array\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`startVersion` or `endVersion` not found\"}}}},\"/puzzle\":{\"get\":{\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"additionalProperties\":{\"$ref\":\"#/components/schemas/Puzzle\"},\"type\":\"object\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}},\"X-WorkOrder\":{\"schema\":{\"type\":\"string\"}}}}}}},\"/moderate/puzzle/{puzzleid}/blueprintid\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"puzzleid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"items\":{\"$ref\":\"#/components/schemas/UUID\"},\"type\":\"array\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`puzzleid` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/puzzle/{puzzleid}/blueprint\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"puzzleid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"items\":{\"items\":[{\"$ref\":\"#/components/schemas/UUID\"},{\"$ref\":\"#/components/schemas/Blueprint\"}],\"maxItems\":2,\"minItems\":2,\"type\":\"array\"},\"type\":\"array\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`puzzleid` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/puzzle/{puzzleid}\":{\"get\":{\"parameters\":[{\"in\":\"path\",\"name\":\"puzzleid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/Puzzle\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"`puzzleid` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/puzzle/{puzzleid}/reissue\":{\"post\":{\"parameters\":[{\"in\":\"path\",\"name\":\"puzzleid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"204\":{\"description\":\"\"},\"404\":{\"description\":\"`puzzleid` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/build/{X}/{Y}\":{\"post\":{\"parameters\":[{\"in\":\"path\",\"name\":\"X\",\"required\":true,\"schema\":{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"}},{\"in\":\"path\",\"name\":\"Y\",\"required\":true,\"schema\":{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"}}],\"requestBody\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/InspectionReport\"}}}},\"responses\":{\"204\":{\"description\":\"\"},\"400\":{\"description\":\"Invalid `body`\"},\"404\":{\"description\":\"`X` or `Y` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/burn/{blueprintid}\":{\"post\":{\"parameters\":[{\"in\":\"path\",\"name\":\"blueprintid\",\"required\":true,\"schema\":{\"format\":\"uuid\",\"type\":\"string\"}}],\"responses\":{\"204\":{\"description\":\"\"},\"404\":{\"description\":\"`blueprintid` not found\"}},\"security\":[{\"BasicAuth\":[]}]}},\"/moderate/machine/current\":{\"get\":{\"responses\":{\"200\":{\"content\":{\"application/json;charset=utf-8\":{\"schema\":{\"$ref\":\"#/components/schemas/VersionedMachine ModData\"}}},\"description\":\"\",\"headers\":{\"Cache-Control\":{\"schema\":{\"type\":\"string\"}}}}},\"security\":[{\"BasicAuth\":[]}]}}},\"components\":{\"schemas\":{\"UUID\":{\"example\":\"00000000-0000-0000-0000-000000000000\",\"format\":\"uuid\",\"type\":\"string\"},\"Blueprint\":{\"example\":{\"puzzle\":\"00000000-0000-0000-0000-000000000000\",\"submittedAt\":null,\"title\":\"Lauren Ipsum\",\"widgets\":{}},\"properties\":{\"puzzle\":{\"type\":\"string\"},\"submittedAt\":{\"$ref\":\"#/components/schemas/UTCTime\"},\"title\":{\"type\":\"string\"},\"widgets\":{\"$ref\":\"#/components/schemas/Object\"}},\"required\":[\"puzzle\",\"title\",\"widgets\"],\"type\":\"object\"},\"UTCTime\":{\"example\":\"2016-07-22T00:00:00Z\",\"format\":\"yyyy-mm-ddThh:MM:ssZ\",\"type\":\"string\"},\"Object\":{\"additionalProperties\":true,\"description\":\"Arbitrary JSON object.\",\"type\":\"object\"},\"Folio\":{\"properties\":{\"blueprint\":{\"$ref\":\"#/components/schemas/Blueprint\"},\"puzzle\":{\"$ref\":\"#/components/schemas/Puzzle\"},\"snapshot\":{\"$ref\":\"#/components/schemas/Object\"}},\"required\":[\"puzzle\",\"blueprint\",\"snapshot\"],\"type\":\"object\"},\"Puzzle\":{\"example\":{\"inputs\":[{\"balls\":[{\"rate\":1,\"type\":1}],\"x\":0.5,\"y\":0}],\"outputs\":[{\"balls\":[{\"rate\":1,\"type\":1}],\"x\":0.5,\"y\":1}],\"reqTiles\":[\"UpLeft\"],\"spec\":{}},\"properties\":{\"inputs\":{\"items\":{\"$ref\":\"#/components/schemas/Gateway\"},\"type\":\"array\"},\"outputs\":{\"items\":{\"$ref\":\"#/components/schemas/Gateway\"},\"type\":\"array\"},\"reqTiles\":{\"items\":{\"$ref\":\"#/components/schemas/RelativeCell\"},\"type\":\"array\"},\"spec\":{\"$ref\":\"#/components/schemas/Object\"}},\"required\":[\"reqTiles\",\"inputs\",\"outputs\",\"spec\"],\"type\":\"object\"},\"RelativeCell\":{\"enum\":[\"UpLeft\",\"Up\",\"UpRight\",\"Left\",\"Right\",\"DownLeft\",\"Down\",\"DownRight\"],\"type\":\"string\"},\"Gateway\":{\"example\":{\"blueprint\":\"00000000-0000-0000-0000-000000000000\",\"puzzle\":\"00000000-0000-0000-0000-000000000000\",\"to_mod\":20},\"properties\":{\"balls\":{\"items\":{\"$ref\":\"#/components/schemas/GatewayBall\"},\"type\":\"array\"},\"x\":{\"format\":\"double\",\"type\":\"number\"},\"y\":{\"format\":\"double\",\"type\":\"number\"}},\"required\":[\"x\",\"y\",\"balls\"],\"type\":\"object\"},\"GatewayBall\":{\"properties\":{\"rate\":{\"format\":\"double\",\"type\":\"number\"},\"type\":{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"}},\"required\":[\"type\",\"rate\"],\"type\":\"object\"},\"VersionedMachine (Maybe BlueprintID)\":{\"example\":{\"grid\":[[\"00000000-0000-0000-0000-000000000000\",\"00000000-0000-0000-0000-000000000000\"],[\"00000000-0000-0000-0000-000000000000\",null]],\"ms_per_ball\":1.0e-3,\"prio_puzzles\":[],\"tile_size\":{\"x\":700,\"y\":700},\"version\":0},\"properties\":{\"grid\":{\"items\":{\"items\":{\"$ref\":\"#/components/schemas/UUID\"},\"type\":\"array\"},\"type\":\"array\"},\"ms_per_ball\":{\"format\":\"double\",\"type\":\"number\"},\"prio_puzzle\":{\"items\":{\"$ref\":\"#/components/schemas/UUID\"},\"type\":\"array\"},\"tile_size\":{\"$ref\":\"#/components/schemas/TileSize\"},\"version\":{\"type\":\"integer\"}},\"required\":[\"grid\",\"version\",\"tile_size\",\"ms_per_ball\"],\"type\":\"object\"},\"TileSize\":{\"properties\":{\"x\":{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"},\"y\":{\"maximum\":9223372036854775807,\"minimum\":-9223372036854775808,\"type\":\"integer\"}},\"required\":[\"x\",\"y\"],\"type\":\"object\"},\"VersionedMachine ModData\":{\"example\":{\"grid\":[[{\"blueprint\":\"00000000-0000-0000-0000-000000000000\",\"puzzle\":\"00000000-0000-0000-0000-000000000000\"},{\"puzzle\":\"00000000-0000-0000-0000-000000000000\",\"to_mod\":20}],[{\"puzzle\":\"00000000-0000-0000-0000-000000000000\",\"to_mod\":11},{\"puzzle\":\"00000000-0000-0000-0000-000000000000\"}]],\"ms_per_ball\":1.0e-3,\"prio_puzzles\":[],\"tile_size\":{\"x\":700,\"y\":700},\"version\":0},\"properties\":{\"grid\":{\"items\":{\"items\":{\"$ref\":\"#/components/schemas/ModData\"},\"type\":\"array\"},\"type\":\"array\"},\"ms_per_ball\":{\"format\":\"double\",\"type\":\"number\"},\"prio_puzzle\":{\"items\":{\"$ref\":\"#/components/schemas/UUID\"},\"type\":\"array\"},\"tile_size\":{\"$ref\":\"#/components/schemas/TileSize\"},\"version\":{\"type\":\"integer\"}},\"required\":[\"grid\",\"version\",\"tile_size\",\"ms_per_ball\"],\"type\":\"object\"},\"InspectionReport\":{\"properties\":{\"blueprint\":{\"$ref\":\"#/components/schemas/UUID\"},\"snapshot\":{\"$ref\":\"#/components/schemas/Object\"}},\"required\":[\"blueprint\",\"snapshot\"],\"type\":\"object\"},\"ModData\":{\"example\":{\"blueprint\":\"00000000-0000-0000-0000-000000000000\",\"puzzle\":\"00000000-0000-0000-0000-000000000000\",\"to_mod\":20},\"properties\":{\"blueprint\":{\"type\":\"string\"},\"puzzle\":{\"type\":\"string\"},\"to_mod\":{\"type\":\"integer\"}},\"required\":[\"puzzle\"],\"type\":\"object\"}},\"securitySchemes\":{\"BasicAuth\":{\"description\":\"Basic Authentication\",\"scheme\":\"basic\",\"type\":\"http\"}}},\"openapi\":\"3.0.0\"}"
  },
  {
    "path": "app/Main.hs",
    "content": "{-# LANGUAGE OverloadedStrings #-}\nmodule Main where\n\nimport           Incredible.Config\nimport qualified Incredible.DataStore.Memory as MemoryStore\nimport qualified Incredible.DataStore.Redis as IRedis\nimport           Incredible.App\nimport           Network.Wai.Handler.Warp\nimport           Options.Applicative\n\ndata IncredibleOptions = IncredibleOptions {\n  incredibleConfigPath :: FilePath\n, incredibleMachinePath :: FilePath\n, incredibleDBMem :: Bool\n}\n\nincredibleOpts :: Parser IncredibleOptions\nincredibleOpts = do\n  IncredibleOptions <$> configPath <*> machinePath <*> useMem\n  where\n    configPath = strOption $ mconcat [\n                      long \"config\"\n                    , short 'c'\n                    , metavar \"CONFIG\"\n                    , value \"config/incredible.toml\"\n                    , help \"Path to the incredible config file\"\n                    ]\n    machinePath = strOption $ mconcat [\n                      long \"machine\"\n                    , short 'm'\n                    , metavar \"MACHINE\"\n                    , value \"config/machine.json\"\n                    , help \"Path to the incredible machine file\"\n                    ]\n    useMem = flag False True $ mconcat [\n                      long \"mem\"\n                    , help \"if we should use an in-memory datastore.\"\n                    ]\n\nmain :: IO ()\nmain = do\n  putStrLn \"Starting Incredible...\"\n  opts <- execParser $ info (incredibleOpts <**> helper) fullDesc\n  putStrLn $ \"Using config file: \" ++ incredibleConfigPath opts\n  cnf <- readIncredibleConfig $ incredibleConfigPath opts\n  putStrLn $ \"Using machine file: \" ++ incredibleMachinePath opts\n  puzzleMachine <- loadPuzzleMachine $ incredibleMachinePath opts\n  putStrLn $ \"Init incredible app...\"\n  webApp <- if incredibleDBMem opts\n             then incredibleApp <$> openMachineShop cnf puzzleMachine MemoryStore.initIncredibleState\n             else incredibleApp <$> openMachineShop cnf puzzleMachine IRedis.initIncredibleState\n  let runWeb = run (incredibleWebPort $ incredibleWebConfig cnf) webApp\n  putStrLn $ \"Running on port: \" ++ show (incredibleWebPort $ incredibleWebConfig cnf)\n  runWeb\n"
  },
  {
    "path": "client/babel.config.json",
    "content": "{\n  \"presets\": [\n    [\"@babel/preset-env\", { \"targets\": \"defaults\" }],\n    \"@babel/preset-typescript\",\n    [\n      \"@babel/preset-react\",\n      {\n        \"runtime\": \"automatic\",\n        \"importSource\": \"@emotion/react\"\n      }\n    ]\n  ],\n  \"plugins\": [\"@emotion\"]\n}\n"
  },
  {
    "path": "client/comic.json",
    "content": "{\n  \"name\": \"Incredible\",\n  \"alt\": \"The Credible Machine\",\n  \"url\": \"/incredible\",\n  \"publicPath\": \"/\",\n  \"width\": 740,\n  \"height\": 740,\n  \"apiEndpoint\": \"http://localhost:8888/\"\n}\n"
  },
  {
    "path": "client/eslint.config.js",
    "content": "import eslint from '@eslint/js'\nimport * as reactQuery from '@tanstack/eslint-plugin-query'\nimport hooksPlugin from 'eslint-plugin-react-hooks'\nimport jsxRuntime from 'eslint-plugin-react/configs/jsx-runtime.js'\nimport reactRecommended from 'eslint-plugin-react/configs/recommended.js'\nimport tseslint from 'typescript-eslint'\n\nexport default tseslint.config(\n  { ignores: ['*.config.js', 'loaders/*'] },\n  { files: ['src/*'] },\n  eslint.configs.recommended,\n  ...tseslint.configs.recommendedTypeChecked,\n  reactRecommended,\n  jsxRuntime,\n  {\n    plugins: { '@tanstack/query': reactQuery },\n    rules: reactQuery.configs.recommended.rules,\n  },\n  {\n    plugins: { 'react-hooks': hooksPlugin },\n    rules: hooksPlugin.configs.recommended.rules,\n  },\n  {\n    rules: {\n      '@typescript-eslint/no-unused-vars': [\n        'error',\n        {\n          args: 'all',\n          argsIgnorePattern: '^_',\n          caughtErrors: 'all',\n          caughtErrorsIgnorePattern: '^_',\n          destructuredArrayIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n        },\n      ],\n      'react/no-unknown-property': ['error', { ignore: ['css'] }],\n      'react-hooks/exhaustive-deps': [\n        'warn',\n        {\n          additionalHooks:\n            '(useRapierEffect|useRigidBody|useCollider|useLoopHandler)',\n        },\n      ],\n    },\n  },\n  {\n    languageOptions: {\n      parserOptions: {\n        project: true,\n        tsconfigRootDir: import.meta.dirname,\n      },\n    },\n  },\n)\n"
  },
  {
    "path": "client/loaders/comic-image-loader.js",
    "content": "import loaderUtils from 'loader-utils'\nimport sharp from 'sharp'\nimport { callbackify } from 'util'\n\nasync function processImage(inputBuffer) {\n  const options = this.getOptions()\n\n  const img = sharp(inputBuffer)\n  const meta = await img.metadata()\n\n  const scaleImage = async (scale) => {\n    const { data, info } = await img\n      .resize({\n        width: Math.floor(meta.width * (scale / options.baseScale)),\n      })\n      .png({ palette: !!options.quant })\n      .toBuffer({ resolveWithObject: true })\n\n    const interpolatedName = loaderUtils.interpolateName(this, options.name, {\n      content: data,\n    })\n    this.emitFile(interpolatedName, data)\n    return { scale, data, info, interpolatedName }\n  }\n\n  const publicPath = options.publicPath ? options.publicPath : ''\n\n  const scaleResults = await Promise.all(options.scales.map(scaleImage))\n  const url = {}\n  const srcSetItems = []\n  for (const result of scaleResults) {\n    const imgURL = publicPath + result.interpolatedName\n    url[`${result.scale}x`] = imgURL\n    srcSetItems.push(`${imgURL} ${result.scale}x`)\n  }\n\n  const outputData = {\n    width: Math.floor(meta.width * (1 / options.baseScale)),\n    height: Math.floor(meta.height * (1 / options.baseScale)),\n    url,\n    srcSet: srcSetItems.join(', '),\n  }\n  return `module.exports = ${JSON.stringify(outputData)}`\n}\n\nconst processImageCb = callbackify(processImage)\n\nexport default function downscale(inputBuffer) {\n  this.cacheable()\n  const cb = this.async()\n  processImageCb.call(this, inputBuffer, cb)\n}\n\nexport const raw = true\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"name\": \"incredible\",\n  \"version\": \"1.0.0\",\n  \"description\": \"TBD\",\n  \"private\": true,\n  \"main\": \"src/index.tsx\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"webpack --mode production\",\n    \"profile\": \"webpack --mode production --profile --json=bundle-stats.json\",\n    \"build:dev\": \"webpack --mode development\",\n    \"build:api-types\": \"openapi-typescript ../api-spec.json -o ./src/generated/api-spec.d.ts\",\n    \"start:dev\": \"webpack serve --mode=development\"\n  },\n  \"author\": \"Max Goodhart <c@chromakode.com>\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@babel/preset-react\": \"^7.24.1\",\n    \"@dimforge/rapier2d\": \"^0.12.0\",\n    \"@emotion/react\": \"^11.11.4\",\n    \"@react-hook/intersection-observer\": \"^3.1.1\",\n    \"@react-hook/latest\": \"^1.0.3\",\n    \"@react-hook/size\": \"^2.1.2\",\n    \"@tanstack/react-query\": \"^5.28.9\",\n    \"@types/lodash\": \"^4.17.0\",\n    \"assert-never\": \"^1.2.1\",\n    \"framer-motion\": \"^11.0.22\",\n    \"html-webpack-plugin\": \"^5.6.0\",\n    \"lodash-es\": \"^4.17.21\",\n    \"mitt\": \"^3.0.1\",\n    \"openapi-fetch\": \"^0.9.3\",\n    \"prettier\": \"^3.2.5\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-moveable\": \"^0.56.0\",\n    \"terser-webpack-plugin\": \"^5.3.10\",\n    \"tiny-invariant\": \"^1.3.3\",\n    \"typescript\": \"^5.4.2\",\n    \"webpack\": \"^5.90.3\",\n    \"webpack-cli\": \"^5.1.4\",\n    \"weighted\": \"^1.0.0\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.24.3\",\n    \"@babel/preset-env\": \"^7.24.3\",\n    \"@babel/preset-typescript\": \"^7.24.1\",\n    \"@emotion/babel-plugin\": \"^11.11.0\",\n    \"@pmmmwh/react-refresh-webpack-plugin\": \"^0.5.11\",\n    \"@tanstack/eslint-plugin-query\": \"^5.28.6\",\n    \"@types/prettier\": \"^2.7.3\",\n    \"@types/react\": \"^18.2.67\",\n    \"@types/react-dom\": \"^18.2.22\",\n    \"babel-loader\": \"^9.1.3\",\n    \"eslint\": \"^8.57.0\",\n    \"eslint-plugin-react\": \"^7.34.1\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"loader-utils\": \"^3.2.1\",\n    \"openapi-typescript\": \"^6.7.5\",\n    \"prettier-plugin-organize-imports\": \"^3.2.4\",\n    \"sharp\": \"^0.33.3\",\n    \"typescript-eslint\": \"^7.3.1\",\n    \"webpack-bundle-analyzer\": \"^4.10.1\",\n    \"webpack-dev-server\": \"^4.15.2\"\n  },\n  \"engines\": {\n    \"node\": \">=20.0.0 <21.0.0\"\n  }\n}\n"
  },
  {
    "path": "client/prettier.config.mjs",
    "content": "export default {\n  trailingComma: 'all',\n  tabWidth: 2,\n  semi: false,\n  singleQuote: true,\n  plugins: ['prettier-plugin-organize-imports'],\n}\n"
  },
  {
    "path": "client/src/api.ts",
    "content": "import { QueryClient } from '@tanstack/react-query'\nimport createClient, { Middleware } from 'openapi-fetch'\nimport comic from '../comic.json'\n\nimport type { paths } from './generated/api-spec'\n\nexport const apiClient = createClient<paths>({\n  baseUrl: process.env.API_ENDPOINT ?? comic.apiEndpoint,\n})\n\nconst throwOnError: Middleware = {\n  async onResponse(res) {\n    if (res.status >= 400) {\n      const body: string = await res.clone().text()\n      throw new Error(body)\n    }\n    return undefined\n  },\n}\n\napiClient.use(throwOnError)\n\nexport const queryClient = new QueryClient()\n"
  },
  {
    "path": "client/src/components/BallPitMechanism.tsx",
    "content": "import { useCallback, useMemo, useRef } from 'react'\nimport { coords, vectorAngle } from '../lib/coords'\nimport { inBounds } from '../lib/utils'\nimport { Ball, BallType, Bounds } from '../types'\nimport {\n  MachineContextProvider,\n  MachineContextProviderRef,\n  useMachine,\n} from './MachineContext'\nimport { GRAVITY, PhysicsContextProvider } from './PhysicsContext'\nimport {\n  BOTTOM_CHUTE_DROP,\n  BOTTOM_CHUTE_EXIT_OFFSET,\n  BOTTOM_MECHANISM_HEIGHT,\n  BOTTOM_PIT_HEIGHT,\n  BOTTOM_TANK_HEIGHT,\n  BOTTOM_TANK_SPACING,\n  BOTTOM_TANK_TARGET_TOP,\n  BOTTOM_TANK_WIDTH,\n} from './constants'\nimport { useMetaMachineClient } from './useMetaMachineClient'\nimport { BASE_BALL_LIFETIME_TICKS, Balls } from './widgets/Balls'\nimport Boat from './widgets/Boat'\nimport { BottomChute } from './widgets/BottomChute'\nimport { BottomPit } from './widgets/BottomPit'\nimport { BottomTank } from './widgets/BottomTank'\nimport { BallSpawner } from './widgets/SpawnInput'\n\nconst tankIdxByType = [2, 0, 3, 1]\n\n/**\n * Bottom pool with balls fed by chutes at the bottom of the puzzle.\n *\n */\nexport function BallPitMechanism({\n  tilesX,\n  tilesY,\n  tileWidth,\n  tileHeight,\n  stepRateMultiplier = 1,\n}: {\n  tilesX: number\n  tilesY: number\n  tileWidth: number\n  tileHeight: number\n  stepRateMultiplier?: number\n}) {\n  const { msPerBall, simulationBoundsRef } = useMachine()\n  const subMachineRef = useRef<MachineContextProviderRef>(null)\n\n  const totalWidth = tilesX * tileWidth\n  const totalHeight = tilesY * tileHeight\n  const pitX = totalWidth / 2\n  const pitY =\n    totalHeight + BOTTOM_MECHANISM_HEIGHT - BOTTOM_PIT_HEIGHT / 2 - 10\n  const tankCount = 4\n  const firstTankCenterX =\n    pitX -\n    (4 * BOTTOM_TANK_WIDTH + 3 * BOTTOM_TANK_SPACING) / 2 +\n    BOTTOM_TANK_WIDTH / 2\n  const tankTopY = pitY - BOTTOM_TANK_HEIGHT - 400\n\n  const lastLineBounds: Bounds = [\n    0,\n    (tilesY - 1) * tileHeight,\n    totalWidth,\n    tilesY * tileHeight,\n  ]\n\n  const bounds: Bounds = useMemo(\n    () => [0, 0, totalWidth, totalHeight + BOTTOM_MECHANISM_HEIGHT],\n    [totalHeight, totalWidth],\n  )\n\n  // Load the last row of machines\n  const { metaMachine } = useMetaMachineClient({ viewBounds: lastLineBounds })\n\n  /**\n   * Velocity of ball to hit the bool target\n   */\n  const getBallVx = useCallback(\n    (x: number, y: number, ballType: BallType) => {\n      const tankIdx = tankIdxByType[ballType - 1]\n      const targetX =\n        firstTankCenterX + tankIdx * (BOTTOM_TANK_WIDTH + BOTTOM_TANK_SPACING)\n\n      const [tx, ty] = coords.toRapier.vector(\n        targetX,\n        tankTopY + BOTTOM_TANK_TARGET_TOP,\n      )\n\n      return (tx - x) / Math.sqrt((2 * (y - ty)) / -GRAVITY.y)\n    },\n    [firstTankCenterX, tankTopY],\n  )\n\n  // When balls are received in the chutes in the meta machine, spawn a ball in our machine targeting the pool.\n  const handleReceiveBall = useCallback(\n    (ball: Ball) => {\n      const { current: subMachine } = subMachineRef\n      if (!subMachine) {\n        return\n      }\n\n      const { x, y } = ball.translation()\n\n      subMachine.createBall(\n        ...coords.fromRapier.vector(x, y),\n        ball.userData.ballType,\n        {\n          vx: getBallVx(x, y, ball.userData.ballType),\n          overrideDamping: 0,\n        },\n      )\n    },\n    [getBallVx],\n  )\n\n  if (!metaMachine) {\n    return null\n  }\n\n  const tanks = []\n  for (let tankType = 1; tankType <= tankCount; tankType++) {\n    tanks.push(\n      <BottomTank\n        x={\n          firstTankCenterX +\n          tankIdxByType[tankType - 1] *\n            (BOTTOM_TANK_WIDTH + BOTTOM_TANK_SPACING)\n        }\n        y={tankTopY + BOTTOM_TANK_HEIGHT / 2}\n        type={tankType}\n        key={`tank-${tankType}`}\n      />,\n    )\n  }\n\n  const chutes = []\n  const ballSpawns = []\n  for (let xt = 0; xt < tilesX; xt++) {\n    const machine = metaMachine.getMachine(xt, tilesY - 1)\n    if (!machine) {\n      continue\n    }\n\n    for (const output of machine.puzzle.outputs) {\n      if (output.y !== 1) {\n        continue\n      }\n\n      const x = xt * tileWidth + tileWidth * output.x\n      const y = totalHeight\n\n      // If the output exists in simulation, put a chute under it, otherwise, proxy it with a spawner.\n      if (inBounds(x, y, simulationBoundsRef.current)) {\n        chutes.push(\n          <BottomChute\n            key={`${xt}-${output.x}`}\n            x={x}\n            y={y + BOTTOM_CHUTE_DROP}\n            angle={vectorAngle(\n              { x, y: y + BOTTOM_CHUTE_EXIT_OFFSET },\n              { x: pitX, y: pitY },\n            )}\n            onReceiveBall={handleReceiveBall}\n          />,\n        )\n      } else {\n        ballSpawns.push(\n          <BallSpawner\n            key={`${xt}-${output.x}`}\n            x={x}\n            y={y + BOTTOM_CHUTE_DROP}\n            vx={getBallVx(\n              ...coords.toRapier.vector(x, y + BOTTOM_CHUTE_DROP),\n              output.balls[0]['type'], // No combined outputs at bottom of map\n            )}\n            overrideDamping={0}\n            balls={output.balls}\n          />,\n        )\n      }\n    }\n  }\n\n  return (\n    <>\n      {chutes}\n      <PhysicsContextProvider stepRateMultiplier={stepRateMultiplier}>\n        <MachineContextProvider\n          ref={subMachineRef}\n          msPerBall={msPerBall}\n          initialSimulationBounds={bounds}\n          initialViewBounds={bounds}\n        >\n          {ballSpawns}\n          {tanks}\n          <Boat id=\"boat\" x={pitX} y={pitY - 10} />\n          <BottomPit x={pitX} y={pitY} />\n          <Balls lifetimeTicks={3.5 * BASE_BALL_LIFETIME_TICKS} />\n        </MachineContextProvider>\n      </PhysicsContextProvider>\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/CenteredSlippyMap.tsx",
    "content": "import { animate, motion, useMotionValue, useTransform } from 'framer-motion'\nimport {\n  ComponentProps,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useState,\n} from 'react'\nimport { Bounds } from '../types'\n\nexport interface SlippyMapRef {\n  getPosition(): [x: number, y: number]\n  isAnimating(): boolean\n  animateTo(x: number, y: number, zoom?: number): Promise<void>\n  jumpTo(x: number, y: number, zoom?: number): void\n  stop(): void\n}\n\nexport interface CenteredSlippyMapProps {\n  width: number\n  height: number\n  totalWidth: number\n  totalHeight: number\n  innerClassName?: string\n  onPosition: (viewBounds: Bounds) => void\n  onDragStart?: ComponentProps<typeof motion.div>['onDragStart']\n  children: React.ReactElement\n  initialX?: number\n  initialY?: number\n  initialZoom?: number\n}\n\nexport const CenteredSlippyMap = forwardRef<\n  SlippyMapRef,\n  CenteredSlippyMapProps\n>(function CenteredSlippyMap(\n  {\n    width,\n    height,\n    totalWidth,\n    totalHeight,\n    innerClassName,\n    onPosition,\n    onDragStart,\n    children,\n    initialX = 0,\n    initialY = 0,\n    initialZoom = 1,\n  }: CenteredSlippyMapProps,\n  ref,\n) {\n  const scaleVal = useMotionValue(initialZoom)\n  const [scale, setScale] = useState(initialZoom)\n\n  // The input position in unzoomed child screen space, centered in viewport.\n  // We need to scale it by the zoom level into screen space.\n  const centerXVal = useMotionValue(-initialX * initialZoom)\n  const centerYVal = useMotionValue(-initialY * initialZoom)\n\n  // Translation offset in screen space of drag gesture\n  const dragXVal = useTransform(() => centerXVal.get() + 0.5 * width)\n  const dragYVal = useTransform(() => centerYVal.get() + 0.5 * height)\n\n  const handleUpdate = useCallback(() => {\n    const x = dragXVal.get()\n    const y = dragYVal.get()\n    const scale = scaleVal.get()\n\n    const scaleFactor = 1 / scale\n    const viewBounds: Bounds = [\n      -x * scaleFactor,\n      -y * scaleFactor,\n      (-x + width) * scaleFactor,\n      (-y + height) * scaleFactor,\n    ]\n    onPosition(viewBounds)\n  }, [dragXVal, dragYVal, scaleVal, width, height, onPosition])\n\n  // Initial set of bounds on mount\n  useEffect(handleUpdate, [handleUpdate])\n\n  const getPosition = useCallback(() => {\n    return [\n      -centerXVal.get() / scaleVal.get(),\n      -centerYVal.get() / scaleVal.get(),\n    ] as [number, number]\n  }, [centerXVal, centerYVal, scaleVal])\n\n  const isAnimating = useCallback(() => {\n    return centerXVal.isAnimating() || centerYVal.isAnimating()\n  }, [centerXVal, centerYVal])\n\n  const animateTo = useCallback(\n    async (x: number, y: number, zoom: number = 1) => {\n      await Promise.all([\n        setScale(zoom),\n        animate(scaleVal, zoom),\n        animate(centerXVal, -x * zoom),\n        animate(centerYVal, -y * zoom),\n      ])\n    },\n    [scaleVal, centerXVal, centerYVal],\n  )\n\n  const jumpTo = useCallback(\n    (x: number, y: number, zoom: number = 1) => {\n      setScale(zoom)\n      scaleVal.set(zoom)\n      centerXVal.set(-x * zoom)\n      centerYVal.set(-y * zoom)\n    },\n    [scaleVal, centerXVal, centerYVal],\n  )\n\n  const stop = useCallback(() => {\n    scaleVal.stop()\n    centerXVal.stop()\n    centerYVal.stop()\n  }, [scaleVal, centerXVal, centerYVal])\n\n  useImperativeHandle(ref, () => ({\n    getPosition,\n    isAnimating,\n    animateTo,\n    jumpTo,\n    stop,\n  }))\n\n  const dragConstraints = useMemo(() => {\n    const right = -0.5 * scale * width\n    const left = right - (totalWidth - width) * scale\n    const bottom = -0.5 * scale * height\n    const top = bottom - (totalHeight - height) * scale\n    return {\n      left,\n      right,\n      top,\n      bottom,\n    }\n  }, [height, scale, totalHeight, totalWidth, width])\n\n  return (\n    <div\n      css={{\n        overflow: 'hidden',\n        width,\n        height,\n        userSelect: 'none',\n        // Allow framer to handle touch pans\n        touchAction: 'none',\n      }}\n    >\n      <motion.div\n        css={{\n          position: 'relative',\n          width: totalWidth,\n          height: totalHeight,\n          overflow: 'hidden',\n          contain: 'strict',\n        }}\n        style={{\n          x: dragXVal,\n          y: dragYVal,\n          scale: scaleVal,\n          originX: 0,\n          originY: 0,\n        }}\n        className={innerClassName}\n        whileDrag={{ cursor: 'grabbing' }}\n        onDragStart={onDragStart}\n        onUpdate={handleUpdate}\n        dragConstraints={dragConstraints}\n        _dragX={centerXVal}\n        _dragY={centerYVal}\n        drag\n      >\n        {children}\n      </motion.div>\n    </div>\n  )\n})\n"
  },
  {
    "path": "client/src/components/Comic.tsx",
    "content": "import imgEdit from '@art/edit-icon_4x.png'\nimport imgView from '@art/view-icon_4x.png'\nimport { motion } from 'framer-motion'\nimport { isEqual, random, throttle } from 'lodash'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport comic from '../../comic.json'\nimport { Bounds } from '../types'\nimport { ComicBrowseView } from './ComicBrowseView'\nimport { ComicImage } from './ComicImage'\nimport { ComicPuzzleView } from './ComicPuzzleView'\nimport { FullscreenComicContainer } from './FullscreenComicContainer'\nimport LoadingSpinner from './LoadingSpinner'\nimport { ToolButton, comicDropShadow } from './WidgetPalette'\nimport { paramToNumber, useLocationHashParams } from './useLocationHashParams'\nimport {\n  SavedMachine,\n  useGetPuzzles,\n  useMetaMachineClient,\n} from './useMetaMachineClient'\n\nexport interface MetaMachineLink {\n  xt: number\n  yt: number\n  v?: number\n}\n\nexport function useHashLink(): {\n  hashLink: MetaMachineLink | null\n  updateHashLink: (link: MetaMachineLink) => void\n} {\n  const { locationHashParams, setLocationHashParams } = useLocationHashParams()\n  const lastRef = useRef<MetaMachineLink | null>(null)\n\n  const hashLink = useMemo(() => {\n    const xt = paramToNumber(locationHashParams.get('xt'))\n    const yt = paramToNumber(locationHashParams.get('yt'))\n    const v = paramToNumber(locationHashParams.get('v'))\n    if (xt == null || yt == null) {\n      return null\n    }\n    const nextVal = {\n      xt,\n      yt,\n      v,\n    }\n    lastRef.current = nextVal\n    return nextVal\n  }, [locationHashParams])\n\n  const updateHashLink = useCallback(\n    (update: MetaMachineLink) => {\n      const nextVal = { ...lastRef.current, ...update }\n      if (isEqual(nextVal, lastRef.current)) {\n        return\n      }\n      lastRef.current = nextVal\n      const { xt, yt, v } = nextVal\n      setLocationHashParams({\n        xt: String(xt),\n        yt: String(yt),\n        v: v != null ? String(v) : undefined,\n      })\n    },\n    [setLocationHashParams],\n  )\n\n  return { hashLink, updateHashLink }\n}\n\nexport default function Comic() {\n  const { hashLink, updateHashLink } = useHashLink()\n  const [mode, setMode] = useState<'create' | 'browse'>(\n    hashLink ? 'browse' : 'create',\n  )\n\n  const [viewBounds, setViewBounds] = useState<Bounds>(() => [\n    0,\n    0,\n    comic.width,\n    comic.height,\n  ])\n\n  const throttledSetViewBounds = useMemo(() => throttle(setViewBounds, 250), [])\n\n  const [lastSubmission, setLastSubmission] = useState<SavedMachine>()\n  const { metaMachine, allTilesLoaded } = useMetaMachineClient({\n    viewBounds,\n    lastSubmission,\n    version: hashLink?.v,\n  })\n\n  const [isInitialLoading, setInitialLoading] = useState(true)\n  useEffect(() => {\n    if (allTilesLoaded) {\n      setInitialLoading(false)\n    }\n  }, [allTilesLoaded])\n\n  const puzzles = useGetPuzzles()\n\n  // TODO: track seen puzzles\n  const [puzzleIdx, setPuzzleIdx] = useState<number>(0)\n  useEffect(() => {\n    if (!puzzles) {\n      return\n    }\n    setPuzzleIdx(random(puzzles.length - 1))\n  }, [puzzles])\n\n  const handleSubmitBlueprint = useCallback((location: SavedMachine) => {\n    setLastSubmission(location)\n    setMode('browse')\n  }, [])\n\n  const handleToggleMode = useCallback(() => {\n    setMode((curMode) => (curMode === 'create' ? 'browse' : 'create'))\n  }, [])\n\n  if (!metaMachine) {\n    return <LoadingSpinner />\n  }\n\n  const puzzle = puzzles ? puzzles[puzzleIdx] : undefined\n\n  return (\n    <FullscreenComicContainer>\n      <motion.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: isInitialLoading ? 0 : 1 }}\n        transition={{ delay: 0.25 }}\n        css={{ contain: 'paint' }}\n      >\n        <ComicBrowseView\n          metaMachine={metaMachine}\n          lastSubmission={lastSubmission}\n          onPosition={throttledSetViewBounds}\n          hashLink={hashLink}\n          updateHashLink={updateHashLink}\n          isActive={mode === 'browse'}\n        />\n      </motion.div>\n      {puzzle && (\n        <motion.div\n          css={{\n            position: 'absolute',\n            left: 0,\n            top: 0,\n            width: '100%',\n            height: '100%',\n            backgroundColor: 'white',\n          }}\n          initial={{ opacity: mode === 'create' ? 1 : 0 }}\n          animate={{\n            opacity: mode === 'create' ? 1 : 0,\n            scale: mode === 'create' ? 1 : 0.9,\n          }}\n          transition={{ type: 'spring', duration: 0.25 }}\n          style={{\n            pointerEvents: mode === 'create' && puzzle ? 'all' : 'none',\n          }}\n        >\n          <ComicPuzzleView\n            key={`${puzzle.id}-${lastSubmission?.blueprintId}`}\n            puzzle={puzzle}\n            metaMachine={metaMachine}\n            onSubmit={handleSubmitBlueprint}\n            isActive={mode === 'create'}\n          />\n        </motion.div>\n      )}\n      {puzzle && (\n        <ToolButton\n          initial={{ scale: 0 }}\n          animate={\n            mode === 'create' ? { scale: 1 } : { scale: 1, rotateY: 180 }\n          }\n          transition={isInitialLoading ? { scale: { delay: 0.5 } } : undefined}\n          onClick={handleToggleMode}\n          aria-label={\n            mode === 'browse' ? 'Build another blueprint' : 'View the machine'\n          }\n          css={{\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n            position: 'absolute',\n            right: 10,\n            bottom: 10,\n            width: 50,\n            height: 50,\n            transformStyle: 'preserve-3d',\n          }}\n        >\n          <ComicImage\n            img={imgEdit}\n            css={[\n              {\n                position: 'absolute',\n                transform: 'rotateY(180deg) translateZ(0.01px)',\n                backfaceVisibility: 'hidden',\n              },\n              comicDropShadow,\n            ]}\n          />\n          <ComicImage\n            img={imgView}\n            css={[\n              {\n                backfaceVisibility: 'hidden',\n              },\n              comicDropShadow,\n            ]}\n          />\n        </ToolButton>\n      )}\n    </FullscreenComicContainer>\n  )\n}\n"
  },
  {
    "path": "client/src/components/ComicBrowseView.tsx",
    "content": "import imgFollowBall from '@art/follow-ball_4x.png'\nimport imgPermalink from '@art/permalink_4x.png'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport { random } from 'lodash'\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from 'react'\nimport { Bounds } from '../types'\nimport { MetaMachineLink } from './Comic'\nimport { ComicImage } from './ComicImage'\nimport LoadingSpinner from './LoadingSpinner'\nimport {\n  SlippyMetaMachineRef,\n  SlippyMetaMachineView,\n  SlippyMetaMachineViewProps,\n} from './MetaMachineView'\nimport { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext'\nimport { ToolButton, comicDropShadow } from './WidgetPalette'\nimport { MetaMachineInfo, SavedMachine } from './useMetaMachineClient'\n\nfunction findRandomTile(metaMachine: MetaMachineInfo) {\n  const { tilesX, tilesY } = metaMachine\n\n  for (let tries = 0; tries < 16; tries++) {\n    const xt = random(0, tilesX - 1)\n    const yt = random(0, tilesY - 1)\n    if (metaMachine.hasBlueprint(xt, yt)) {\n      return { xt, yt }\n    }\n  }\n\n  // <3, jamslunt interfoggle\n  return { xt: 2, yt: 2 }\n}\n\nexport function ComicBrowseView({\n  lastSubmission,\n  metaMachine,\n  isActive,\n  onPosition,\n  hashLink,\n  updateHashLink,\n}: {\n  lastSubmission: SavedMachine | undefined\n  metaMachine: MetaMachineInfo\n  isActive: boolean\n  onPosition: SlippyMetaMachineViewProps['onPosition']\n  hashLink: MetaMachineLink | null\n  updateHashLink: (link: MetaMachineLink) => void\n}) {\n  const viewRef = useRef<SlippyMetaMachineRef>(null)\n\n  const getMap = () => viewRef?.current?.mapRef?.current\n  const getMachine = () => viewRef?.current?.machineRef?.current\n\n  const { tileWidth, tileHeight, tilesX, tilesY } = metaMachine\n\n  function centeredScreenCoords({ xt, yt }: { xt: number; yt: number }) {\n    return [xt * tileWidth + tileWidth / 2, yt * tileHeight + tileHeight / 2]\n  }\n\n  const [initialCenterX, initialCenterY] = centeredScreenCoords(\n    lastSubmission ?? hashLink ?? findRandomTile(metaMachine),\n  )\n\n  const isMovingToSubmission = useRef(false)\n\n  useLayoutEffect(() => {\n    async function doSubmitAnimate() {\n      if (lastSubmission) {\n        isMovingToSubmission.current = true\n        getMachine()?.unfollowBall()\n        getMap()?.jumpTo(initialCenterX, initialCenterY, 1)\n        await new Promise((r) => setTimeout(r, 150))\n        await getMap()?.animateTo(initialCenterX, initialCenterY, 0.8)\n        updateHashLink({\n          xt: lastSubmission.xt,\n          yt: lastSubmission.yt,\n        })\n        isMovingToSubmission.current = false\n      }\n    }\n    void doSubmitAnimate()\n  }, [lastSubmission, initialCenterX, initialCenterY, updateHashLink])\n\n  const updateHashLinkFromPosition = useCallback(\n    ({ includeVersion }: { includeVersion?: boolean } = {}) => {\n      const map = getMap()\n      if (!map) {\n        return\n      }\n\n      const [x, y] = map.getPosition()\n      const xt = Math.floor(x / tileWidth)\n      const yt = Math.floor(y / tileHeight)\n\n      if (xt < 0 || xt > tilesX - 1 || yt < 0 || yt > tilesY - 1) {\n        return\n      }\n\n      const nextHashLink: MetaMachineLink = { xt, yt }\n      if (includeVersion) {\n        nextHashLink.v = metaMachine.version\n      }\n      updateHashLink(nextHashLink)\n    },\n    [\n      metaMachine.version,\n      tileHeight,\n      tileWidth,\n      tilesX,\n      tilesY,\n      updateHashLink,\n    ],\n  )\n\n  const handlePosition = useCallback(\n    (viewBounds: Bounds) => {\n      onPosition?.(viewBounds)\n\n      if (\n        getMap()?.isAnimating() ||\n        isMovingToSubmission.current ||\n        !isActive\n      ) {\n        return\n      }\n      updateHashLinkFromPosition()\n    },\n    [onPosition, isActive, updateHashLinkFromPosition],\n  )\n\n  useEffect(() => {\n    const map = getMap()\n    if (!hashLink || !map) {\n      return\n    }\n    const [x, y] = map.getPosition()\n    const xt = Math.floor(x / tileWidth)\n    const yt = Math.floor(y / tileHeight)\n    if (hashLink.xt === xt && hashLink.yt === yt) {\n      return\n    }\n    getMachine()?.unfollowBall()\n    void map.animateTo(\n      hashLink.xt * tileWidth + tileWidth / 2,\n      hashLink.yt * tileHeight + tileHeight / 2,\n      0.8,\n    )\n  }, [hashLink, tileHeight, tileWidth])\n\n  const [hasCopied, setHasCopied] = useState(false)\n\n  const handleClickPermalink = useCallback(() => {\n    if (!hashLink) {\n      return\n    }\n    async function doCopy() {\n      updateHashLinkFromPosition({ includeVersion: true })\n      await navigator.clipboard?.writeText(window.location.toString())\n      setHasCopied(true)\n      await new Promise((r) => setTimeout(r, 1000))\n      setHasCopied(false)\n    }\n    void doCopy()\n  }, [hashLink, updateHashLinkFromPosition])\n\n  // Ball follow mode\n  const startFollowingBall = useCallback(() => {\n    const map = getMap()\n    const machine = getMachine()\n    if (!map || !machine) {\n      return\n    }\n\n    map.stop()\n\n    let [lastX, lastY] = map.getPosition()\n    machine.followBall((x: number, y: number) => {\n      const nextX = lastX * 0.75 + x * 0.25\n      const nextY = lastY * 0.75 + y * 0.25\n      map.jumpTo(nextX, nextY, 0.8)\n      lastX = nextX\n      lastY = nextY\n    })\n  }, [])\n\n  const handleDragStart = useCallback(() => {\n    getMachine()?.unfollowBall()\n  }, [])\n\n  useEffect(() => {\n    function handleKey(ev: KeyboardEvent) {\n      if (ev.key.toLowerCase() === 'b' && ev.ctrlKey && ev.altKey) {\n        startFollowingBall()\n      }\n    }\n    window.addEventListener('keydown', handleKey, false)\n    return () => {\n      window.removeEventListener('keydown', handleKey, false)\n    }\n  }, [startFollowingBall])\n\n  return (\n    <>\n      <PhysicsContextProvider stepRateMultiplier={isActive ? 1 : 0}>\n        <PhysicsLoader spinner={<LoadingSpinner />}>\n          <SlippyMetaMachineView\n            ref={viewRef}\n            {...metaMachine}\n            initialX={initialCenterX}\n            initialY={initialCenterY}\n            initialZoom={0.8}\n            onPosition={handlePosition}\n            onDragStart={handleDragStart}\n          />\n        </PhysicsLoader>\n      </PhysicsContextProvider>\n      <ToolButton\n        onClick={startFollowingBall}\n        aria-label=\"Follow a random ball\"\n        css={[\n          {\n            position: 'absolute',\n            left: 10,\n            bottom: 10,\n            width: 50,\n            height: 50,\n            cursor: 'pointer',\n          },\n          comicDropShadow,\n        ]}\n      >\n        <ComicImage img={imgFollowBall} />\n      </ToolButton>\n      <ToolButton\n        onClick={handleClickPermalink}\n        aria-label=\"Copy permalink\"\n        css={[\n          {\n            position: 'absolute',\n            left: 10,\n            top: 10,\n            width: 50,\n            height: 50,\n            cursor: 'pointer',\n          },\n          comicDropShadow,\n        ]}\n      >\n        <ComicImage img={imgPermalink} />\n      </ToolButton>\n      <AnimatePresence>\n        {hasCopied && (\n          <motion.div\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { delay: 1 } }}\n            css={[\n              {\n                position: 'absolute',\n                left: 68,\n                top: 10,\n                fontFamily: 'xkcd-Regular-v3',\n                background: 'black',\n                color: 'white',\n                padding: '4px 8px',\n              },\n              comicDropShadow,\n            ]}\n          >\n            Copied!\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/ComicImage.tsx",
    "content": "import {\n  HTMLAttributes,\n  ImgHTMLAttributes,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useRef,\n} from 'react'\nimport { TICK_MS, useLoopHandler } from './PhysicsContext'\n\nexport interface ComicImageProps\n  extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet'> {\n  img: typeof import('*.png').default\n  alt?: string\n}\n\nexport const ComicImage = forwardRef<HTMLImageElement, ComicImageProps>(\n  function ComicImage({ img, alt = '', ...props }, ref) {\n    return (\n      <img\n        {...props}\n        ref={ref}\n        src={img.url['2x']}\n        srcSet={img.srcSet}\n        width={img.width}\n        height={img.height}\n        alt={alt}\n        draggable=\"false\"\n      />\n    )\n  },\n)\n\nexport const ComicImageAnimation = function ComicImageAnimation({\n  imgs,\n  rateMs = 0,\n  showIdx = null,\n  ...props\n}: {\n  imgs: Array<typeof import('*.png').default>\n  rateMs?: number\n  showIdx?: number | null\n  animate?: boolean\n} & HTMLAttributes<HTMLDivElement>) {\n  const ref = useRef<HTMLImageElement>(null)\n  const lastIdx = useRef<number | null>(null)\n\n  const updateIdx = useCallback((idx: number) => {\n    if (idx === lastIdx.current) {\n      return\n    }\n\n    const { current: el } = ref\n    if (!el) {\n      return\n    }\n    if (lastIdx.current != null) {\n      el.children[lastIdx.current].setAttribute('style', 'opacity: 0')\n    }\n    el.children[idx].setAttribute('style', 'opacity: 1')\n    lastIdx.current = idx\n  }, [])\n\n  useEffect(() => {\n    if (showIdx != null) {\n      updateIdx(showIdx)\n      return\n    } else if (lastIdx.current == null) {\n      // On first render, always show first frame, even if paused.\n      updateIdx(0)\n    }\n  }, [imgs, rateMs, updateIdx, showIdx])\n\n  const ticksPerFrame = rateMs / TICK_MS\n  const isStatic = showIdx != null || rateMs === 0\n  useLoopHandler(\n    ({ getCurrentTick }) => {\n      if (isStatic) {\n        return\n      }\n      const currentTick = getCurrentTick()\n      updateIdx(Math.floor(currentTick / ticksPerFrame) % imgs.length)\n    },\n    [imgs.length, isStatic, ticksPerFrame, updateIdx],\n    !isStatic,\n  )\n\n  return (\n    <div\n      ref={ref}\n      css={{\n        position: 'relative',\n        width: imgs[0].width,\n        height: imgs[0].height,\n        img: {\n          position: 'absolute',\n          left: 0,\n          top: 0,\n          opacity: 0,\n          isolation: 'isolate',\n        },\n        contain: 'strict',\n      }}\n      {...props}\n    >\n      {imgs.map((img, idx) => (\n        <ComicImage key={idx} img={img} />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/ComicPuzzleView.tsx",
    "content": "import { AnimatePresence } from 'framer-motion'\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport invariant from 'tiny-invariant'\nimport { emptyWidgets } from '../page/fixtures/emptyMachine'\nimport { Bounds, PuzzleOrder, WidgetCollection } from '../types'\nimport LoadingSpinner from './LoadingSpinner'\nimport { MachineContextProvider } from './MachineContext'\nimport {\n  MachineTileContextProvider,\n  MachineTileContextProviderRef,\n} from './MachineTileContext'\nimport MachineTileEditor from './MachineTileEditor'\nimport { NamePrompt } from './NamePrompt'\nimport { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext'\nimport {\n  MetaMachineInfo,\n  SavedMachine,\n  useSubmitBlueprint,\n} from './useMetaMachineClient'\n\nfunction saveSolution(location: {\n  blueprintId: string\n  xt: number\n  yt: number\n}) {\n  try {\n    const existingValue = JSON.parse(\n      localStorage.getItem('contraptions') ?? '[]',\n    ) as Array<unknown>\n    localStorage['contraptions'] = JSON.stringify([...existingValue, location])\n  } catch (err) {\n    console.warn(\n      `Submitted ${location.blueprintId}, failed to save to localStorage`,\n      err,\n    )\n  }\n}\n\nexport function ComicPuzzleView({\n  puzzle,\n  metaMachine,\n  isActive,\n  onSubmit,\n}: {\n  puzzle: PuzzleOrder\n  metaMachine: MetaMachineInfo | null\n  isActive: boolean\n  onSubmit: (location: SavedMachine) => void\n}) {\n  const machineTileRef = useRef<MachineTileContextProviderRef>()\n\n  const submitBlueprint = useSubmitBlueprint()\n\n  const [isNaming, setNaming] = useState(false)\n  const [widgets, setWidgets] = useState<WidgetCollection | null>(null)\n\n  const handleSubmit = useCallback((widgets: WidgetCollection) => {\n    setWidgets(widgets)\n    setNaming(true)\n  }, [])\n\n  const handleCancelName = useCallback(() => {\n    setNaming(false)\n  }, [])\n\n  const handleSend = useCallback(\n    (title: string) => {\n      async function doSubmit() {\n        invariant(widgets, 'widgets cannot be null')\n        invariant(puzzle, 'puzzle cannot be null')\n\n        // TODO: spinner\n        const location = await submitBlueprint.mutateAsync({\n          puzzleId: puzzle.id,\n          workOrder: puzzle.workOrder,\n          title,\n          widgets,\n        })\n\n        if (location) {\n          const snapshot = machineTileRef.current?.snapshot()\n          onSubmit({ ...location, title, widgets, puzzle, snapshot })\n          saveSolution(location)\n        }\n      }\n      void doSubmit()\n    },\n    [onSubmit, puzzle, submitBlueprint, widgets],\n  )\n\n  const tileBounds: Bounds | null = useMemo(\n    () =>\n      metaMachine\n        ? [0, 0, metaMachine.tileWidth, metaMachine.tileHeight]\n        : null,\n    [metaMachine],\n  )\n\n  if (!metaMachine || !puzzle || !tileBounds) {\n    return <LoadingSpinner />\n  }\n\n  return (\n    <PhysicsContextProvider stepRateMultiplier={isActive ? 1 : 0}>\n      <PhysicsLoader spinner={<LoadingSpinner />}>\n        <MachineContextProvider\n          initialSimulationBounds={tileBounds}\n          initialViewBounds={tileBounds}\n          msPerBall={metaMachine.msPerBall}\n        >\n          <MachineTileContextProvider ref={machineTileRef} bounds={tileBounds}>\n            <AnimatePresence>\n              {isNaming ? (\n                <NamePrompt onSubmit={handleSend} onCancel={handleCancelName} />\n              ) : null}\n            </AnimatePresence>\n            <MachineTileEditor\n              key={puzzle.id}\n              puzzle={puzzle}\n              initialWidgets={emptyWidgets}\n              onSubmit={handleSubmit}\n            />\n          </MachineTileContextProvider>\n        </MachineContextProvider>\n      </PhysicsLoader>\n    </PhysicsContextProvider>\n  )\n}\n"
  },
  {
    "path": "client/src/components/DebugOverlay.tsx",
    "content": "import { useContext, useRef } from 'react'\nimport { coords } from '../lib/coords'\nimport { MachineTileContext } from './MachineTileContext'\nimport { useLoopHandler } from './PhysicsContext'\n\nexport default function DebugOverlay({\n  ticksBetweenUpdates = 0,\n}: {\n  ticksBetweenUpdates?: number\n}) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  const { width, height } = useContext(MachineTileContext)\n\n  const ticksRef = useRef<number | null>(null)\n\n  useLoopHandler(\n    ({ world }) => {\n      const context = canvasRef.current?.getContext('2d')\n      if (!context) {\n        return\n      }\n\n      const { current: ticksSinceUpdate } = ticksRef\n      if (\n        ticksBetweenUpdates > 0 &&\n        ticksSinceUpdate != null &&\n        ticksSinceUpdate < ticksBetweenUpdates\n      ) {\n        ticksRef.current = ticksSinceUpdate + 1\n        return\n      }\n\n      context.clearRect(0, 0, width, height)\n      const { vertices, colors } = world.debugRender()\n\n      for (let i = 0; i < vertices.length / 4; i += 1) {\n        const red = colors[i * 4 + 0] * 255\n        const green = colors[i * 4 + 1] * 255\n        const blue = colors[i * 4 + 2] * 255\n        const alpha = colors[i * 4 + 3] * 255\n\n        context.beginPath()\n        context.strokeStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`\n        context.lineWidth = 1.5\n        context.moveTo(\n          ...coords.fromRapier.vector(vertices[i * 4 + 0], vertices[i * 4 + 1]),\n        )\n        context.lineTo(\n          ...coords.fromRapier.vector(vertices[i * 4 + 2], vertices[i * 4 + 3]),\n        )\n        context.closePath()\n        context.stroke()\n      }\n\n      ticksRef.current = 0\n    },\n    [width, height, ticksRef, ticksBetweenUpdates],\n  )\n\n  return (\n    <div\n      css={{\n        position: 'relative',\n        width: '100%',\n        height: '100%',\n        pointerEvents: 'none',\n        zIndex: 100,\n      }}\n    >\n      <canvas ref={canvasRef} height={width} width={height} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/EditorTutorials.tsx",
    "content": "import imgTutorialCongrats from '@art/tutorial_congrats_4x.png'\nimport imgTutorialExpiry from '@art/tutorial_expiry_4x.png'\nimport imgTutorialRouted from '@art/tutorial_routed_4x.png'\nimport { AnimatePresence } from 'framer-motion'\nimport { useCallback, useMemo, useState } from 'react'\nimport { ComicImage } from './ComicImage'\nimport { SwooshyDialog } from './SwooshyDialog'\n\nconst tutorials = {\n  routed: imgTutorialRouted,\n  expiry: imgTutorialExpiry,\n  submit: imgTutorialCongrats,\n} as const\n\nconst tutorialKeys = Object.keys(tutorials)\n\ntype TutorialKey = keyof typeof tutorials\ntype TutorialState = { [K in TutorialKey]: boolean }\n\nconst STORE_KEY = 'contraptionTips'\n\nfunction loadState(): TutorialState {\n  let data: Record<string, boolean> = {}\n  try {\n    const stored = localStorage.getItem(STORE_KEY)\n    data = JSON.parse(stored ?? '{}') as Record<string, boolean>\n  } catch {\n    // Use defaults\n  }\n\n  return Object.fromEntries(\n    tutorialKeys.map((key) => [key, Boolean(data[key]) ?? false]),\n  ) as TutorialState\n}\n\nfunction saveState(state: TutorialState) {\n  localStorage[STORE_KEY] = JSON.stringify(state)\n}\n\nexport function useTutorials() {\n  const [seenTutorials, setSeenTutorials] = useState(loadState)\n\n  const [visibleTutorial, setVisibleTutorial] = useState<TutorialKey | null>(\n    null,\n  )\n\n  const showTutorial = useCallback(\n    (key: TutorialKey) => {\n      if (seenTutorials[key] || visibleTutorial != null) {\n        return\n      }\n      setVisibleTutorial(key)\n    },\n    [seenTutorials, visibleTutorial],\n  )\n\n  const dismissTutorial = useCallback(\n    (key: TutorialKey) => {\n      setVisibleTutorial(null)\n\n      const nextState = { ...seenTutorials, [key]: true }\n      setSeenTutorials(nextState)\n      saveState(nextState)\n    },\n    [seenTutorials],\n  )\n\n  return useMemo(\n    () => ({ seenTutorials, visibleTutorial, showTutorial, dismissTutorial }),\n    [dismissTutorial, seenTutorials, showTutorial, visibleTutorial],\n  )\n}\n\nexport function EditorTutorials({\n  visibleTutorial,\n  onDismissTutorial,\n}: {\n  visibleTutorial: TutorialKey | null\n  onDismissTutorial: (key: TutorialKey) => void\n}) {\n  const handleDismiss = useCallback(() => {\n    if (!visibleTutorial) {\n      return\n    }\n    onDismissTutorial(visibleTutorial)\n  }, [onDismissTutorial, visibleTutorial])\n\n  return (\n    <AnimatePresence>\n      {visibleTutorial ? (\n        <SwooshyDialog onDismiss={handleDismiss}>\n          <div\n            css={{\n              display: 'flex',\n              position: 'relative',\n            }}\n          >\n            <ComicImage\n              img={tutorials[visibleTutorial]}\n              css={{\n                filter: 'drop-shadow(5px 5px 0 rgba(0, 0, 0, 0.5))',\n              }}\n            />\n            <button\n              css={{\n                position: 'absolute',\n                background: 'none',\n                border: 'none',\n                top: 0,\n                right: 0,\n                width: 24,\n                height: 24,\n                cursor: 'pointer',\n              }}\n              onClick={handleDismiss}\n              aria-label=\"Close\"\n            />\n          </div>\n        </SwooshyDialog>\n      ) : null}\n    </AnimatePresence>\n  )\n}\n"
  },
  {
    "path": "client/src/components/FullscreenComicContainer.tsx",
    "content": "import useSize from '@react-hook/size'\nimport {\n  ReactNode,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from 'react'\nimport comic from '../../comic.json'\nimport InnerComicBorder from './InnerComicBorder'\nimport { SwooshyDialog } from './SwooshyDialog'\n\nfunction useIsFullscreen() {\n  const [isFullscreen, setFullscreen] = useState(false)\n\n  useEffect(() => {\n    function handleFullscreenChange() {\n      setFullscreen(\n        Boolean(document.fullscreenElement ?? document.webkitFullscreenElement),\n      )\n    }\n\n    document.addEventListener('fullscreenchange', handleFullscreenChange)\n    return () => {\n      document.removeEventListener('fullscreenchange', handleFullscreenChange)\n    }\n  })\n\n  return isFullscreen\n}\n\nexport type DisplayContextType = {\n  isFullscreen: boolean\n  orientation: 'portrait' | 'landscape'\n}\n\nexport const DisplayContext = createContext<DisplayContextType>({\n  isFullscreen: false,\n  orientation: 'portrait',\n})\n\nexport function useDisplayState() {\n  return useContext(DisplayContext)\n}\n\nexport function FullscreenComicContainer({\n  children,\n}: {\n  children: ReactNode\n}) {\n  const ref = useRef<HTMLDivElement>(null)\n\n  const [canFullscreen, setCanFullscreen] = useState(false)\n  const [fullscreenFailed, setFullscreenFailed] = useState(false)\n\n  const [width, height] = useSize(ref)\n\n  const isFullscreen = useIsFullscreen()\n\n  const [isTouch] = useState(\n    () => window.matchMedia('(pointer: coarse)').matches,\n  )\n\n  useEffect(() => {\n    const { current: el } = ref\n    if (!el) {\n      return\n    }\n    setCanFullscreen(\n      'requestFullscreen' in el || 'webkitRequestFullscreen' in el,\n    )\n  }, [])\n\n  const handleMaybeFullscreen = useCallback(() => {\n    const { current: el } = ref\n\n    async function tryFullscreen() {\n      if (!el) {\n        return\n      }\n\n      if (\n        document.fullscreenElement !== el &&\n        document.webkitFullscreenElement !== el\n      ) {\n        try {\n          if ('requestFullscreen' in el) {\n            await el.requestFullscreen?.()\n          } else if ('webkitRequestFullscreen' in el) {\n            await el.webkitRequestFullscreen?.()\n          }\n        } catch {\n          setFullscreenFailed(true)\n        }\n      }\n    }\n    void tryFullscreen()\n  }, [])\n\n  const comicScale = Math.min(width / comic.width, height / comic.height)\n\n  return (\n    <div\n      ref={ref}\n      css={{\n        position: 'relative',\n        width: '100%',\n        height: '100%',\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        background: 'white',\n      }}\n    >\n      <InnerComicBorder\n        style={\n          comicScale !== 1\n            ? {\n                transform: `scale(${comicScale})`,\n              }\n            : undefined\n        }\n      >\n        {isTouch && !isFullscreen && !fullscreenFailed && canFullscreen && (\n          <SwooshyDialog\n            css={{\n              zIndex: 100,\n            }}\n            onDismiss={handleMaybeFullscreen}\n          >\n            <button\n              onClick={handleMaybeFullscreen}\n              css={{\n                width: 365,\n                padding: 16,\n                fontFamily: 'xkcd-Regular-v3',\n                fontSize: 50,\n                background: 'rgba(0, 0, 0, .75)',\n                color: 'white',\n                border: 'none',\n                borderRadius: 16,\n              }}\n            >\n              tap to enter fullscreen\n            </button>\n          </SwooshyDialog>\n        )}\n        <DisplayContext.Provider\n          value={{\n            isFullscreen,\n            orientation: height > width ? 'portrait' : 'landscape',\n          }}\n        >\n          {children}\n        </DisplayContext.Provider>\n      </InnerComicBorder>\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/InnerComicBorder.tsx",
    "content": "import { CSSProperties, ReactNode } from 'react'\nimport comic from '../../comic.json'\n\nexport default function InnerComicBorder({\n  style,\n  children,\n}: {\n  style?: CSSProperties\n  children: ReactNode\n}) {\n  return (\n    <div\n      css={{\n        position: 'relative',\n        width: comic.width,\n        height: comic.height,\n        '&:after': {\n          content: '\"\"',\n          position: 'absolute',\n          border: '2px solid black',\n          inset: 0,\n          zIndex: 100,\n          pointerEvents: 'none',\n        },\n      }}\n      style={style}\n    >\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/LoadingSpinner.tsx",
    "content": "import { ComicImage } from './ComicImage'\n\nimport ballBlueImg from '@art/ball-blue_4x.png'\nimport ballGreenImg from '@art/ball-green_4x.png'\nimport ballRedImg from '@art/ball-red_4x.png'\nimport ballYellowImg from '@art/ball-yellow_4x.png'\nimport { css, keyframes } from '@emotion/react'\n\nconst rotate = keyframes`\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n`\n\nconst fadeIn = keyframes`\n  0% {\n    opacity: 0;\n  }\n  50% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n`\n\nconst counterRotateStyle = css({\n  animation: `${rotate} 1s linear infinite reverse`,\n})\n\nexport default function LoadingSpinner({ className }: { className?: string }) {\n  return (\n    <div\n      css={{\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        height: '100%',\n      }}\n      className={className}\n    >\n      <div\n        css={{\n          position: 'absolute',\n          display: 'grid',\n          gridTemplateColumns: '1fr 1fr',\n          gap: 4,\n          margin: 4,\n          animation: `1s linear infinite ${rotate}, 500ms ease-in ${fadeIn}`,\n        }}\n      >\n        <ComicImage css={counterRotateStyle} img={ballBlueImg} />\n        <ComicImage css={counterRotateStyle} img={ballGreenImg} />\n        <ComicImage css={counterRotateStyle} img={ballRedImg} />\n        <ComicImage css={counterRotateStyle} img={ballYellowImg} />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/MachineContext.tsx",
    "content": "import { noop } from 'lodash'\nimport mitt, { Emitter } from 'mitt'\nimport {\n  MutableRefObject,\n  ReactNode,\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\n\nimport type { RigidBody } from '@dimforge/rapier2d'\nimport { coords } from '../lib/coords'\nimport { BallSnapshot, BodySnapshot, snapshotBody } from '../lib/snapshot'\nimport { inBounds, useIdGen } from '../lib/utils'\nimport { BallType, Bounds } from '../types'\nimport { PhysicsContext } from './PhysicsContext'\n\nexport interface BallData {\n  id: string\n  snapshot: BodySnapshot\n  age: number\n  type: BallType\n  overrideDamping?: number\n}\n\nexport type BallDestroyReason = 'expiry'\n\nexport type BallFollowCallback = (x: number, y: number) => void\n\ntype MachineEvents = {\n  createBall: BallData\n  destroyBall: string\n  exitBall: string\n  renewBall: string\n  killBall: string\n  followBall: BallFollowCallback\n  unfollowBall: void\n  ballExpired: void\n}\n\ntype ActiveBalls = Record<\n  string,\n  {\n    type: BallType\n    body: RigidBody\n    renewTick: number\n  }\n>\n\nexport type CreateBallOptions = {\n  vx?: number\n  vy?: number\n  overrideDamping?: number\n}\n\nexport type MachineContextType = {\n  msPerBall: number\n  createBall: (\n    x: number,\n    y: number,\n    type: BallType,\n    opts?: CreateBallOptions,\n  ) => void\n  destroyBall: (id: string, reason?: BallDestroyReason) => void\n  /** Destroy the ball when it leaves the viewable area */\n  exitBall: (id: string) => void\n  renewBall: (id: string) => void\n  killBall: (id: string) => void\n  clearBalls: () => void\n  followBall: (callback: BallFollowCallback) => void\n  unfollowBall: () => void\n  registerBall: (\n    id: string,\n    type: BallType,\n    body: RigidBody,\n    renewTick: number,\n  ) => void\n  unregisterBall: (id: string) => void\n  snapshotBalls: (bounds: Bounds) => BallSnapshot[]\n  restoreBalls: (ballSnapshots: BallSnapshot[]) => void\n  simulationBoundsRef: MutableRefObject<Bounds>\n  viewBoundsRef: MutableRefObject<Bounds>\n  events: Emitter<MachineEvents>\n}\n\nexport interface MachineContextProviderRef {\n  createBall: MachineContextType['createBall']\n  clearBalls: MachineContextType['clearBalls']\n  followBall: MachineContextType['followBall']\n  unfollowBall: MachineContextType['unfollowBall']\n  events: MachineContextType['events']\n  simulationBoundsRef: MachineContextType['simulationBoundsRef']\n  viewBoundsRef: MachineContextType['viewBoundsRef']\n}\n\nconst infiniteBounds: Bounds = [-Infinity, -Infinity, Infinity, Infinity]\n\nexport const MachineContext = createContext<MachineContextType>({\n  msPerBall: Infinity,\n  createBall: noop,\n  destroyBall: noop,\n  exitBall: noop,\n  renewBall: noop,\n  killBall: noop,\n  followBall: noop,\n  unfollowBall: noop,\n  clearBalls: noop,\n  registerBall: noop,\n  unregisterBall: noop,\n  snapshotBalls: () => [],\n  restoreBalls: noop,\n  simulationBoundsRef: { current: infiniteBounds },\n  viewBoundsRef: { current: infiniteBounds },\n  events: mitt(),\n})\n\nexport const MachineContextProvider = forwardRef(\n  function MachineContextProvider(\n    {\n      msPerBall,\n      initialSimulationBounds = infiniteBounds,\n      initialViewBounds = infiniteBounds,\n      children,\n    }: {\n      msPerBall: number\n      initialSimulationBounds?: Bounds\n      initialViewBounds?: Bounds\n      children: ReactNode\n    },\n    ref,\n  ) {\n    const physics = useContext(PhysicsContext)\n\n    const nextBallId = useIdGen()\n\n    const [events] = useState(() => mitt<MachineEvents>())\n\n    const activeBalls = useRef<ActiveBalls>({})\n\n    const createBall = useCallback(\n      (\n        x: number,\n        y: number,\n        type: BallType,\n        { vx = 0, vy = 0, overrideDamping }: CreateBallOptions = {},\n      ) => {\n        events.emit('createBall', {\n          id: nextBallId(),\n          snapshot: {\n            x: coords.toRapier.x(x),\n            y: coords.toRapier.y(y),\n            angle: 0,\n            vx,\n            vy,\n            va: 0,\n          },\n          age: 0,\n          type,\n          overrideDamping,\n        })\n      },\n      [events, nextBallId],\n    )\n\n    const destroyBall = useCallback(\n      (id: string, reason?: BallDestroyReason) => {\n        events.emit('destroyBall', id)\n        if (reason === 'expiry') {\n          events.emit('ballExpired')\n        }\n      },\n      [events],\n    )\n\n    const exitBall = useCallback(\n      (id: string) => {\n        events.emit('exitBall', id)\n      },\n      [events],\n    )\n\n    const renewBall = useCallback(\n      (id: string) => {\n        events.emit('renewBall', id)\n      },\n      [events],\n    )\n\n    const killBall = useCallback(\n      (id: string) => {\n        events.emit('killBall', id)\n      },\n      [events],\n    )\n\n    const followBall = useCallback(\n      (callback: BallFollowCallback) => {\n        events.emit('followBall', callback)\n      },\n      [events],\n    )\n\n    const unfollowBall = useCallback(() => {\n      events.emit('unfollowBall')\n    }, [events])\n\n    const clearBalls = useCallback(() => {\n      Object.keys(activeBalls.current).forEach((id) => destroyBall(id))\n    }, [destroyBall])\n\n    const registerBall = useCallback(\n      (id: string, type: BallType, body: RigidBody, renewTick: number) => {\n        activeBalls.current[id] = { type, body, renewTick }\n      },\n      [],\n    )\n\n    const unregisterBall = useCallback((id: string) => {\n      delete activeBalls.current[id]\n    }, [])\n\n    const snapshotBalls = useCallback(\n      (bounds: Bounds) => {\n        if (!physics) {\n          return []\n        }\n        const currentTick = physics.getCurrentTick()\n        return Object.values(activeBalls.current)\n          .filter(({ body }) => {\n            const { x, y } = body.translation()\n            return inBounds(x, y, bounds)\n          })\n          .map(({ type, body, renewTick }) => ({\n            ...snapshotBody(body),\n            age: currentTick - renewTick,\n            type,\n          }))\n      },\n      [physics],\n    )\n\n    const restoreBalls = useCallback(\n      (ballSnapshots: BallSnapshot[]) => {\n        for (const { type, age, ...snapshot } of ballSnapshots) {\n          events.emit('createBall', {\n            id: nextBallId(),\n            snapshot,\n            age,\n            type,\n          })\n        }\n      },\n      [events, nextBallId],\n    )\n\n    const simulationBoundsRef = useRef<Bounds>(initialSimulationBounds)\n    const viewBoundsRef = useRef<Bounds>(initialViewBounds)\n\n    const contextValue: MachineContextType = useMemo(\n      () => ({\n        msPerBall,\n        createBall,\n        destroyBall,\n        exitBall,\n        renewBall,\n        killBall,\n        followBall,\n        unfollowBall,\n        clearBalls,\n        registerBall,\n        unregisterBall,\n        snapshotBalls,\n        restoreBalls,\n        simulationBoundsRef,\n        viewBoundsRef,\n        events,\n      }),\n      [\n        msPerBall,\n        createBall,\n        destroyBall,\n        exitBall,\n        renewBall,\n        killBall,\n        followBall,\n        unfollowBall,\n        clearBalls,\n        registerBall,\n        unregisterBall,\n        snapshotBalls,\n        restoreBalls,\n        events,\n      ],\n    )\n\n    useImperativeHandle(ref, () => ({\n      createBall,\n      followBall,\n      unfollowBall,\n      clearBalls,\n      events,\n      simulationBoundsRef,\n      viewBoundsRef,\n    }))\n\n    return (\n      <MachineContext.Provider value={contextValue}>\n        {children}\n      </MachineContext.Provider>\n    )\n  },\n)\n\nexport function useMachine() {\n  return useContext(MachineContext)\n}\n"
  },
  {
    "path": "client/src/components/MachineTileContext.tsx",
    "content": "import { mapValues, noop } from 'lodash'\nimport {\n  DependencyList,\n  ReactNode,\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n} from 'react'\n\nimport type RAPIER from '@dimforge/rapier2d'\nimport type {\n  Collider,\n  ColliderDesc,\n  RigidBody,\n  RigidBodyDesc,\n} from '@dimforge/rapier2d'\nimport { coords } from '../lib/coords'\nimport {\n  MachineSnapshot,\n  applySnapshotToBody,\n  offsetSnapshot,\n  snapshotBody,\n} from '../lib/snapshot'\nimport { inBounds } from '../lib/utils'\nimport { Bounds, isBall } from '../types'\nimport { useMachine } from './MachineContext'\nimport { useLoopHandler, useRapierEffect } from './PhysicsContext'\n\ntype ActiveBodies = Record<string, RigidBody>\n\nexport type MachineTileContextType = {\n  bounds: Bounds\n  width: number\n  height: number\n  registerBody: (key: string, body: RigidBody) => void\n  unregisterBody: (key: string) => void\n  snapshot: () => MachineSnapshot\n  loadSnapshot: (snapshot: MachineSnapshot) => void\n}\n\nexport interface MachineTileContextProviderRef {\n  snapshot: MachineTileContextType['snapshot']\n  loadSnapshot: MachineTileContextType['loadSnapshot']\n}\n\nexport const MachineTileContext = createContext<MachineTileContextType>({\n  bounds: [0, 0, 0, 0],\n  width: 0,\n  height: 0,\n  registerBody: noop,\n  unregisterBody: noop,\n  snapshot: () => ({ widgets: {}, balls: [] }),\n  loadSnapshot: noop,\n})\n\nexport const MachineTileContextProvider = forwardRef(\n  function MachineTileContextProvider(\n    {\n      bounds,\n      initialSnapshot,\n      children,\n    }: {\n      bounds: Bounds\n      initialSnapshot?: MachineSnapshot\n      children: ReactNode\n    },\n    ref,\n  ) {\n    const { snapshotBalls, restoreBalls, destroyBall } = useMachine()\n\n    const [x1, y1, x2, y2] = bounds\n    const stableBounds = useMemo(() => [x1, y1, x2, y2], [x1, x2, y1, y2])\n\n    const width = x2 - x1\n    const height = y2 - y1\n\n    const activeBodies = useRef<ActiveBodies>({})\n\n    const registerBody = useCallback((key: string, body: RigidBody) => {\n      activeBodies.current[key] = body\n    }, [])\n\n    const unregisterBody = useCallback((key: string) => {\n      delete activeBodies.current[key]\n    }, [])\n\n    const snapshot = useCallback(() => {\n      const [x1, y1, x2, y2] = stableBounds\n      const rapierBounds: Bounds = [\n        // This is tricky: because rapier's y axis grows in the opposite direction of ours, we swap y1 and y2.\n        ...coords.toRapier.vector(x1, y2),\n        ...coords.toRapier.vector(x2, y1),\n      ]\n\n      return {\n        widgets: mapValues(activeBodies.current, (body, key) => ({\n          ...snapshotBody(body),\n          key,\n        })),\n        balls: snapshotBalls(rapierBounds),\n      }\n    }, [snapshotBalls, stableBounds])\n\n    const loadSnapshot = useCallback(\n      (snapshot: MachineSnapshot) => {\n        const [x1, y1] = stableBounds\n\n        // Tile snapshots are based on a zero origin. We must offset the translation coordinates to our tile position in rapier space.\n        const [rapierOffsetX, rapierOffsetY] = coords.toRapier.vector(x1, y1)\n\n        for (const [id, widgetSnapshot] of Object.entries(snapshot.widgets)) {\n          const body = activeBodies.current[id]\n          if (body) {\n            applySnapshotToBody(\n              offsetSnapshot(rapierOffsetX, rapierOffsetY, widgetSnapshot),\n              body,\n            )\n          }\n        }\n\n        restoreBalls(\n          snapshot.balls.map((snapshot) =>\n            offsetSnapshot(rapierOffsetX, rapierOffsetY, snapshot),\n          ),\n        )\n      },\n      [restoreBalls, stableBounds],\n    )\n\n    const contextValue = useMemo(\n      () => ({\n        bounds,\n        width,\n        height,\n        registerBody,\n        unregisterBody,\n        snapshot,\n        loadSnapshot,\n      }),\n      [\n        bounds,\n        width,\n        height,\n        registerBody,\n        unregisterBody,\n        snapshot,\n        loadSnapshot,\n      ],\n    )\n\n    useImperativeHandle(ref, () => ({\n      snapshot,\n      loadSnapshot,\n    }))\n\n    const hasRestoredSnapshot = useRef(false)\n    useRapierEffect(\n      ({ world, rapier: { Cuboid } }) => {\n        if (initialSnapshot && !hasRestoredSnapshot.current) {\n          loadSnapshot(initialSnapshot)\n          hasRestoredSnapshot.current = true\n        }\n\n        // Clean up balls when tiles removed\n        return () => {\n          world.intersectionsWithShape(\n            coords.toRapier.vectorObject(x1 + width / 2, y1 + height / 2),\n            0,\n            new Cuboid(...coords.toRapier.lengths(width / 2, height / 2)),\n            (collider) => {\n              const body = collider.parent()\n              if (!body || !isBall(body)) {\n                return true\n              }\n\n              destroyBall(body.userData.id)\n              return true\n            },\n          )\n        }\n      },\n      [loadSnapshot, initialSnapshot, x1, width, y1, height, destroyBall],\n    )\n\n    return (\n      <MachineTileContext.Provider value={contextValue}>\n        {children}\n      </MachineTileContext.Provider>\n    )\n  },\n)\n\nexport function useRigidBody(\n  create: (rapier: typeof RAPIER) => {\n    key: string | null\n    bodyDesc: RigidBodyDesc\n    colliderDescs?: ColliderDesc[]\n  },\n  deps: DependencyList,\n) {\n  const { registerBody, unregisterBody } = useContext(MachineTileContext)\n\n  const bodyRef = useRef<RigidBody | null>(null)\n\n  useRapierEffect(({ rapier, world }) => {\n    const { key, bodyDesc, colliderDescs } = create(rapier)\n    const body = world.createRigidBody(bodyDesc)\n    bodyRef.current = body\n\n    colliderDescs\n      ? colliderDescs.map((colliderDesc) =>\n          world.createCollider(colliderDesc, body),\n        )\n      : null\n\n    if (key != null) {\n      registerBody(key, body)\n    }\n\n    return () => {\n      if (key != null) {\n        unregisterBody(key)\n      }\n      setTimeout(() => {\n        world.removeRigidBody(body)\n      }, 0)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, deps)\n\n  return bodyRef\n}\n\nexport function useSensorInTile(\n  collider: Collider | undefined,\n  handler: (otherCollider: Collider) => void,\n  deps: DependencyList,\n) {\n  const { bounds } = useContext(MachineTileContext)\n\n  useLoopHandler(\n    ({ world }) => {\n      if (!collider) {\n        return\n      }\n\n      world.intersectionPairsWith(collider, (otherCollider) => {\n        const [x, y] = coords.fromBody.vector(otherCollider)\n        if (inBounds(x, y, bounds)) {\n          handler(otherCollider)\n        }\n      })\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [collider, ...deps],\n  )\n}\n\nexport function useMachineTile() {\n  return useContext(MachineTileContext)\n}\n"
  },
  {
    "path": "client/src/components/MachineTileEditor.tsx",
    "content": "import imgSubmit from '@art/submit_4x.png'\nimport imgWrench from '@art/wrench_4x.png'\nimport { PropsOf } from '@emotion/react'\nimport useLatest from '@react-hook/latest'\nimport { motion } from 'framer-motion'\nimport { max } from 'lodash'\nimport React, {\n  KeyboardEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from 'react'\nimport Moveable, {\n  OnDrag,\n  OnResize,\n  OnResizeStart,\n  OnRotate,\n} from 'react-moveable'\nimport { px, useIdGen } from '../lib/utils'\nimport { Puzzle, WidgetCollection } from '../types'\nimport { ComicImage } from './ComicImage'\nimport DebugOverlay from './DebugOverlay'\nimport { EditorTutorials, useTutorials } from './EditorTutorials'\nimport { useDisplayState } from './FullscreenComicContainer'\nimport { useMachine } from './MachineContext'\nimport { useMachineTile } from './MachineTileContext'\nimport { useLoopHandler } from './PhysicsContext'\nimport WidgetPalette, { ToolButton, comicDropShadow } from './WidgetPalette'\nimport { MAX_WIDGET_COUNT } from './constants'\nimport { getPositionStyles } from './positionStyles'\nimport {\n  PaletteItem,\n  WidgetData,\n  Widgets,\n  stickerList,\n  widgetList,\n} from './widgets'\nimport { Balls } from './widgets/Balls'\nimport MachineFrame from './widgets/MachineFrame'\nimport { getNextWheelSpeed } from './widgets/Wheel'\n\nconst DEG_TO_RAD = Math.PI / 180\n\nexport interface EditableWidget {\n  id: string\n  onSelect?: (\n    ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,\n    id: string,\n  ) => void\n  isSelected?: boolean\n}\n\ninterface Selection {\n  id: string\n  el: HTMLDivElement\n}\n\nexport function useSelectHandlers(\n  id: EditableWidget['id'],\n  onSelect: EditableWidget['onSelect'],\n) {\n  const handleSelect = useCallback(\n    (\n      ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,\n    ) => {\n      onSelect?.(ev, id)\n    },\n    [id, onSelect],\n  )\n  return { onMouseDown: handleSelect, onTouchStart: handleSelect }\n}\n\ntype Widgets = Record<string, WidgetData>\n\n// use arrow keys for fun and profit\n// ... but only when a wheel is selected\nexport function useWheelSpeed(\n  setWidgets: (setState: (widgets: Widgets) => Widgets) => void,\n  selection: Selection | undefined,\n) {\n  // no advantage to recreating everything each time selection changes imo\n  const selectionRef = useRef(selection)\n  selectionRef.current = selection\n  useEffect(() => {\n    function handleKey(ev: KeyboardEvent) {\n      const key = ev.key.toLowerCase()\n      const { current: selection } = selectionRef\n      if (selection == null) {\n        return\n      }\n      setWidgets((widgets) => {\n        const selectedWheel = widgets[selection.id]\n        if (selectedWheel.type === 'spokedwheel') {\n          const speed = getNextWheelSpeed(selectedWheel, key)\n          if (speed != null) {\n            return {\n              ...widgets,\n              [selection.id]: {\n                ...selectedWheel,\n                speed,\n              },\n            }\n          }\n        }\n        return widgets\n      })\n    }\n\n    // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something\n    window.addEventListener('keydown', handleKey, false)\n    return () => {\n      // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something\n      window.removeEventListener('keydown', handleKey, false)\n    }\n  }, [selectionRef, setWidgets])\n}\n\nconst dragBounds: PropsOf<typeof Moveable>['bounds'] = {\n  left: 0,\n  top: 0,\n  right: 0,\n  bottom: 0,\n  position: 'css',\n}\n\nexport default function MachineTileEditor({\n  puzzle,\n  initialWidgets,\n  onSubmit,\n}: {\n  puzzle: Puzzle\n  initialWidgets: WidgetCollection\n  onSubmit?: (widgets: WidgetCollection) => void\n}) {\n  const { clearBalls, events: machineEvents } = useMachine()\n  const { width, height } = useMachineTile()\n  const display = useDisplayState()\n\n  const isMobilePalette =\n    display.isFullscreen && display.orientation === 'portrait'\n\n  const moveableRef = useRef<Moveable>(null)\n  const [isShowingPalette, setShowingPalette] = useState(false)\n  const [isManipulating, setManipulating] = useState(false)\n  const [selection, setSelection] = useState<Selection>()\n  const [isValidOutputs, setValidOutputs] = useState(false)\n\n  const nextId = useIdGen(\n    () => max(Object.keys(initialWidgets).map(Number)) ?? 0,\n  )\n  const [widgets, setWidgets] =\n    useState<Record<string, WidgetData>>(initialWidgets)\n  const latestWidgets = useLatest(widgets)\n  const widgetCount = Object.keys(widgets).length\n\n  const tutorials = useTutorials()\n  const canSubmit = isValidOutputs && widgetCount <= MAX_WIDGET_COUNT\n\n  useEffect(() => {\n    function showExpiryTutorial() {\n      tutorials.showTutorial('expiry')\n    }\n    function showRoutedTutorial() {\n      tutorials.showTutorial('routed')\n    }\n    machineEvents.on('ballExpired', showExpiryTutorial)\n    machineEvents.on('exitBall', showRoutedTutorial)\n    return () => {\n      machineEvents.off('ballExpired', showExpiryTutorial)\n      machineEvents.off('exitBall', showRoutedTutorial)\n    }\n  }, [machineEvents, tutorials])\n\n  useEffect(() => {\n    if (canSubmit) {\n      tutorials.showTutorial('submit')\n    }\n  }, [canSubmit, tutorials])\n\n  const handleStartManipulating = useCallback(() => {\n    setManipulating(true)\n  }, [])\n\n  const handleEndManipulating = useCallback(() => {\n    setManipulating(false)\n  }, [])\n\n  useLoopHandler(() => {\n    const { current: moveable } = moveableRef\n    if (!moveable) {\n      return\n    }\n\n    setTimeout(() => {\n      moveable.updateRect()\n    }, 0)\n  }, [])\n\n  const applyImmediateStyles = useCallback(\n    (\n      selection: Selection,\n      { x, y, angle }: { x?: number; y?: number; angle?: number },\n    ) => {\n      const curWidget = latestWidgets.current[selection.id]\n\n      // Not all widgets have rotation so we have to do some type checks for ts to be happy with this.\n      const curAngle = 'angle' in curWidget ? curWidget.angle : 0\n\n      Object.assign(\n        selection.el,\n        getPositionStyles(\n          x ?? curWidget.x,\n          y ?? curWidget.y,\n          angle ?? curAngle,\n        ),\n      )\n    },\n    [latestWidgets],\n  )\n\n  const handleSelect = useCallback(\n    (\n      ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,\n      id: string,\n    ) => {\n      async function doSelect() {\n        const { current: moveable } = moveableRef\n        if (!moveable) {\n          return\n        }\n\n        setSelection({ id, el: ev.currentTarget })\n        await moveable.waitToChangeTarget()\n        moveable.dragStart(ev.nativeEvent)\n      }\n      void doSelect()\n    },\n    [setSelection],\n  )\n\n  const handleDrag = useCallback(\n    ({ translate: [x, y] }: OnDrag) => {\n      if (!selection) {\n        return\n      }\n\n      applyImmediateStyles(selection, { x, y })\n\n      setWidgets((curWidgets) => {\n        const selectedWidget = curWidgets[selection.id]\n        return {\n          ...curWidgets,\n          [selection.id]: {\n            ...selectedWidget,\n            x,\n            y,\n          },\n        }\n      })\n    },\n    [applyImmediateStyles, selection],\n  )\n\n  const handleRotate = useCallback(\n    ({ rotation: degRotation }: OnRotate) => {\n      if (!selection) {\n        return\n      }\n\n      const angle = degRotation * DEG_TO_RAD\n\n      applyImmediateStyles(selection, { angle })\n\n      setWidgets((curWidgets) => {\n        const selectedWidget = curWidgets[selection.id]\n        return {\n          ...curWidgets,\n          [selection.id]: {\n            ...selectedWidget,\n            angle,\n          },\n        }\n      })\n    },\n    [applyImmediateStyles, selection],\n  )\n\n  const handleResize = useCallback(\n    ({\n      target,\n      width,\n      height,\n      drag: {\n        translate: [x, y],\n        transform,\n      },\n    }: OnResize) => {\n      if (!selection) {\n        return\n      }\n\n      // It's necessary to immediately apply the styles, otherwise react-moveable gets stale values and glitches out.\n      target.style.transform = transform\n      target.style.width = px(width)\n      target.style.height = px(height)\n\n      setWidgets((curWidgets) => {\n        const selectedWidget = curWidgets[selection.id]\n        return {\n          ...curWidgets,\n          [selection.id]: {\n            ...selectedWidget,\n            x,\n            y,\n            width,\n            height,\n          },\n        }\n      })\n    },\n    [selection],\n  )\n\n  const handleResizeStart = useCallback((ev: OnResizeStart) => {\n    ev.setMin([25, 25])\n    setManipulating(true)\n  }, [])\n\n  const handleDeselect = useCallback((ev: React.MouseEvent<HTMLDivElement>) => {\n    if (ev.target === ev.currentTarget) {\n      setSelection(undefined)\n      setShowingPalette(false)\n    }\n  }, [])\n\n  const handleOpenPalette = useCallback(() => {\n    setShowingPalette(true)\n  }, [])\n\n  const handleAddWidget = useCallback(\n    (create: PaletteItem<WidgetData>['create']) => {\n      setWidgets((curWidgets) => ({\n        ...curWidgets,\n        [nextId()]: create(width / 2, height / 2),\n      }))\n    },\n    [height, nextId, width],\n  )\n\n  const handleTrashWidget = useCallback(() => {\n    if (!selection) {\n      return\n    }\n\n    setWidgets(({ [selection.id]: _removed, ...curWidgets }) => curWidgets)\n    setSelection(undefined)\n  }, [selection])\n\n  const [widgetsKey, setWidgetsKey] = useState(0)\n  const handleEmergencyStop = useCallback(() => {\n    // Remove and recreate widgets\n    setWidgetsKey((x) => x + 1)\n    clearBalls()\n  }, [clearBalls])\n\n  const handleSubmit = useCallback(() => {\n    onSubmit?.(widgets)\n  }, [onSubmit, widgets])\n\n  const [showDebugOverlay, setShowDebugOverlay] = useState(false)\n  // Delete key trashes widgets and ctrl+option+shift+d shows the debug overlay\n  useEffect(() => {\n    function handleKey(ev: KeyboardEvent) {\n      if (ev.key === 'Delete') {\n        handleTrashWidget()\n      } else if (\n        ev.key.toLowerCase() === 'd' &&\n        ev.shiftKey &&\n        ev.ctrlKey &&\n        ev.metaKey\n      ) {\n        setShowDebugOverlay((prev) => !prev)\n      } else if (ev.key === 'Escape') {\n        setShowDebugOverlay(false)\n      }\n    }\n    // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something\n    window.addEventListener('keydown', handleKey, false)\n    return () => {\n      // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something\n      window.removeEventListener('keydown', handleKey, false)\n    }\n  }, [handleTrashWidget, selection, setShowDebugOverlay])\n\n  useWheelSpeed(setWidgets, selection)\n\n  const selectedWidget = selection ? widgets[selection.id] : null\n  const selectedWidgetInfo =\n    selectedWidget == null\n      ? null\n      : selectedWidget.type === 'sticker'\n        ? stickerList[selectedWidget.sticker]\n        : widgetList[selectedWidget.type]\n\n  return (\n    <div\n      onMouseDown={handleDeselect}\n      css={{\n        position: 'relative',\n        width,\n        height,\n        overflow: 'hidden',\n        userSelect: 'none',\n      }}\n    >\n      <EditorTutorials\n        visibleTutorial={tutorials.visibleTutorial}\n        onDismissTutorial={tutorials.dismissTutorial}\n      />\n      {/* portal so can display outside extents? */}\n      <Moveable\n        ref={moveableRef}\n        css={{\n          '.moveable-control.moveable-origin': {\n            display: 'none',\n          },\n        }}\n        target={selection?.el}\n        onDrag={handleDrag}\n        onRotate={handleRotate}\n        onResize={handleResize}\n        onResizeStart={handleResizeStart}\n        onDragStart={handleStartManipulating}\n        onDragEnd={handleEndManipulating}\n        onRotateStart={handleStartManipulating}\n        onRotateEnd={handleEndManipulating}\n        onResizeEnd={handleEndManipulating}\n        keepRatio={selectedWidgetInfo?.isSquare}\n        rotatable={selectedWidgetInfo?.canRotate}\n        resizable={selectedWidgetInfo?.canResize}\n        bounds={dragBounds}\n        draggable\n        snappable\n      />\n      <Widgets\n        key={widgetsKey}\n        widgets={widgets}\n        onSelect={handleSelect}\n        selectedId={selection?.id}\n      />\n      <Balls />\n      <motion.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        transition={{ duration: 1 }}\n      >\n        <MachineFrame\n          inputs={puzzle.inputs}\n          outputs={puzzle.outputs}\n          onValidate={setValidOutputs}\n          spawnBallsTop\n          spawnBallsLeft\n          spawnBallsRight\n          validateOutputs\n        />\n      </motion.div>\n      <motion.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        transition={{ delay: 0.5, duration: 1 }}\n      >\n        {!isMobilePalette && (\n          <ToolButton\n            onClick={handleOpenPalette}\n            css={[\n              {\n                position: 'absolute',\n                right: 10,\n                top: 10,\n                opacity: isShowingPalette ? 0 : 1,\n                transition: 'opacity 100ms ease-out',\n              },\n              comicDropShadow,\n            ]}\n            aria-label=\"Toolbox\"\n          >\n            <ComicImage img={imgWrench} />\n          </ToolButton>\n        )}\n        <ToolButton\n          initial={false}\n          animate={{\n            scale:\n              tutorials.seenTutorials.submit ||\n              tutorials.visibleTutorial === 'submit'\n                ? 1\n                : 0,\n            opacity: canSubmit ? 1 : 0.5,\n          }}\n          onClick={handleSubmit}\n          disabled={!canSubmit}\n          aria-label=\"Submit your blueprint\"\n          css={[\n            {\n              position: 'absolute',\n              left: 10,\n              top: 10,\n              width: 50,\n              height: 50,\n              cursor: canSubmit ? 'pointer' : 'not-allowed',\n            },\n            comicDropShadow,\n          ]}\n        >\n          <ComicImage img={imgSubmit} />\n        </ToolButton>\n      </motion.div>\n      <WidgetPalette\n        onAdd={handleAddWidget}\n        onTrash={handleTrashWidget}\n        onEmergencyStop={handleEmergencyStop}\n        widgetCount={widgetCount}\n        isHorizontal={isMobilePalette}\n        css={\n          isMobilePalette\n            ? {\n                position: 'fixed',\n                bottom: -116,\n                height: 100,\n                left: 8,\n                right: 8,\n              }\n            : {\n                position: 'absolute',\n                right: 10,\n                top: 10,\n                bottom: 10,\n                width: 72,\n                transform: isShowingPalette\n                  ? ''\n                  : `translateX(calc(100% + 10px))`,\n                opacity: isManipulating ? 0.1 : 1,\n                transition: 'transform 250ms ease, opacity 100ms ease-out',\n                pointerEvents: isManipulating ? 'none' : 'all',\n              }\n        }\n      />\n      {showDebugOverlay && <DebugOverlay />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/MachineTilePlaceholder.tsx",
    "content": "import imgConstruction1 from '@art/construction-1_4x.png'\nimport imgConstruction2 from '@art/construction-2_4x.png'\nimport imgConstruction3 from '@art/construction-3_4x.png'\nimport imgConstruction4 from '@art/construction-4_4x.png'\nimport imgConstruction5 from '@art/construction-5_4x.png'\nimport imgConstruction6 from '@art/construction-6_4x.png'\nimport imgConstruction7 from '@art/construction-7_4x.png'\nimport imgConstruction8 from '@art/construction-8_4x.png'\nimport imgConstruction9 from '@art/construction-9_4x.png'\nimport { random, sampleSize } from 'lodash'\nimport { useMemo } from 'react'\nimport { coords } from '../lib/coords'\nimport { isBall } from '../types'\nimport { ComicImage } from './ComicImage'\nimport { useMachine } from './MachineContext'\nimport { useCollider, useCollisionHandler } from './PhysicsContext'\nimport { BALL_RADIUS } from './constants'\n\nconst imgConstructionChoices = [\n  imgConstruction1,\n  imgConstruction2,\n  imgConstruction3,\n  imgConstruction4,\n  imgConstruction5,\n  imgConstruction6,\n  imgConstruction7,\n  imgConstruction8,\n  imgConstruction9,\n]\n\nexport default function MachineTilePlaceholder({\n  tileWidth,\n  tileHeight,\n  xt,\n  yt,\n}: {\n  tileWidth: number\n  tileHeight: number\n  xt: number\n  yt: number\n}) {\n  const { destroyBall } = useMachine()\n\n  // If balls exit finished tiles into the placeholder, destroy them.\n  const ballCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents }) =>\n      ColliderDesc.cuboid(\n        ...coords.toRapier.lengths(\n          tileWidth / 2 - BALL_RADIUS * 2,\n          tileHeight / 2 - BALL_RADIUS * 2,\n        ),\n      )\n        .setTranslation(\n          ...coords.toRapier.vector(\n            xt * tileWidth + tileWidth / 2,\n            yt * tileHeight + tileHeight / 2,\n          ),\n        )\n        .setSensor(true)\n        .setActiveEvents(ActiveEvents.COLLISION_EVENTS),\n    [tileHeight, tileWidth, xt, yt],\n  )\n\n  useCollisionHandler(\n    'start',\n    ballCollider,\n    (otherCollider) => {\n      const body = otherCollider.parent()\n      if (!body) {\n        return\n      }\n\n      if (isBall(body)) {\n        destroyBall(body.userData.id)\n      }\n    },\n    [],\n  )\n\n  const imgs = useMemo(\n    () => sampleSize(imgConstructionChoices, random(2, 3)),\n    [],\n  )\n\n  return (\n    <div\n      css={{\n        position: 'absolute',\n        background: 'white',\n        left: xt * tileWidth,\n        top: yt * tileHeight,\n        width: tileWidth,\n        height: tileHeight,\n        border: '1px solid black',\n        boxSizing: 'border-box',\n        pointerEvents: 'none',\n        zIndex: 20,\n      }}\n    >\n      {imgs.map((img, idx) => (\n        <ComicImage\n          key={idx}\n          img={img}\n          css={{\n            position: 'absolute',\n            left: 0,\n            top: 0,\n          }}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/MetaMachineView.tsx",
    "content": "import { ClassNames } from '@emotion/react'\nimport { throttle } from 'lodash'\nimport React, {\n  RefObject,\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\nimport { MachineSnapshot } from '../lib/snapshot'\nimport { gridViewBounds, iterTiles, tileKey } from '../lib/tiles'\nimport { Bounds, Puzzle, WidgetCollection } from '../types'\nimport { BallPitMechanism } from './BallPitMechanism'\nimport {\n  CenteredSlippyMap,\n  CenteredSlippyMapProps,\n  SlippyMapRef,\n} from './CenteredSlippyMap'\nimport {\n  MachineContextProvider,\n  MachineContextProviderRef,\n} from './MachineContext'\nimport { MachineTileContextProvider } from './MachineTileContext'\nimport MachineTilePlaceholder from './MachineTilePlaceholder'\nimport { deferATick } from './PhysicsContext'\nimport { BOTTOM_MECHANISM_HEIGHT } from './constants'\nimport { MetaMachineInfo } from './useMetaMachineClient'\nimport { Widgets } from './widgets'\nimport { Balls } from './widgets/Balls'\nimport MachineFrame from './widgets/MachineFrame'\n\nconst MetaMachineTile = React.memo(function MetaMachineTiles({\n  puzzle,\n  widgets,\n  snapshot,\n  title,\n  tileWidth,\n  tileHeight,\n  xt,\n  yt,\n  spawnBallsTop,\n  spawnBallsLeft,\n  spawnBallsRight,\n}: {\n  puzzle: Puzzle\n  widgets: WidgetCollection\n  snapshot: MachineSnapshot | undefined\n  title: string | undefined\n  tileWidth: number\n  tileHeight: number\n  xt: number\n  yt: number\n  spawnBallsTop: boolean\n  spawnBallsLeft: boolean\n  spawnBallsRight: boolean\n}) {\n  // Stagger tile renders so we don't have entire rows or columns mounting and updating the world state at the same time.\n  const [isRendered, setRendered] = useState(false)\n  useEffect(() => {\n    function draw() {\n      setRendered(true)\n    }\n    const timeout = deferATick(draw)\n    return () => {\n      clearTimeout(timeout)\n    }\n  }, [])\n\n  if (!isRendered) {\n    return\n  }\n\n  return (\n    <MachineTileContextProvider\n      initialSnapshot={snapshot}\n      bounds={[\n        xt * tileWidth,\n        yt * tileHeight,\n        (xt + 1) * tileWidth,\n        (yt + 1) * tileHeight,\n      ]}\n    >\n      <MachineFrame\n        title={title}\n        inputs={puzzle.inputs}\n        outputs={puzzle.outputs}\n        spawnBallsTop={spawnBallsTop}\n        spawnBallsLeft={spawnBallsLeft}\n        spawnBallsRight={spawnBallsRight}\n      />\n      <Widgets widgets={widgets} />\n    </MachineTileContextProvider>\n  )\n})\n\nexport interface SlippyMetaMachineViewProps extends MetaMachineInfo {\n  initialX?: number\n  initialY?: number\n  initialZoom?: number\n  simulateOutset?: number\n  onPosition?: CenteredSlippyMapProps['onPosition']\n  onDragStart?: CenteredSlippyMapProps['onDragStart']\n}\n\nexport interface SlippyMetaMachineRef {\n  mapRef: RefObject<SlippyMapRef | undefined>\n  machineRef: RefObject<MachineContextProviderRef | undefined>\n}\n\nexport const SlippyMetaMachineView = forwardRef<\n  SlippyMetaMachineRef,\n  SlippyMetaMachineViewProps\n>(function SlippyMetaMachineView(\n  {\n    getMachine,\n    tilesX,\n    tilesY,\n    tileWidth,\n    tileHeight,\n    msPerBall,\n    initialX = 0,\n    initialY = 0,\n    initialZoom = 1,\n    simulateOutset = 150,\n    onPosition,\n    onDragStart,\n  }: SlippyMetaMachineViewProps,\n  ref,\n) {\n  const mapRef = useRef<SlippyMapRef>(null)\n  const machineRef = useRef<MachineContextProviderRef>(null)\n\n  const [xt1, setXt1] = useState(0)\n  const [yt1, setYt1] = useState(0)\n  const [xt2, setXt2] = useState(1)\n  const [yt2, setYt2] = useState(1)\n\n  const totalWidth = tileWidth * tilesX\n  const totalHeight = tileHeight * tilesY + BOTTOM_MECHANISM_HEIGHT\n\n  const [isBottomVisible, setBottomVisible] = useState(false)\n\n  const updatePosition = useMemo(\n    () =>\n      throttle((viewBounds: Bounds) => {\n        const { current: machine } = machineRef\n        if (!machine) {\n          return\n        }\n\n        machine.viewBoundsRef.current = viewBounds\n\n        const [xt1, yt1, xt2, yt2] = gridViewBounds(\n          viewBounds,\n          tilesX,\n          tilesY,\n          tileWidth,\n          tileHeight,\n          simulateOutset,\n        )\n\n        setXt1(xt1)\n        setYt1(yt1)\n        setXt2(xt2)\n        setYt2(yt2)\n\n        machine.simulationBoundsRef.current = [\n          xt1 * tileWidth,\n          yt1 * tileHeight,\n          (xt2 + 1) * tileWidth,\n          (yt2 + 1) * tileHeight,\n        ]\n\n        onPosition?.(viewBounds)\n\n        const [, , , y2] = viewBounds\n        setBottomVisible(y2 > totalHeight - BOTTOM_MECHANISM_HEIGHT)\n      }, 1000 / 60),\n    [\n      tilesX,\n      tilesY,\n      tileWidth,\n      tileHeight,\n      simulateOutset,\n      onPosition,\n      totalHeight,\n    ],\n  )\n\n  useImperativeHandle(ref, () => ({ mapRef, machineRef }))\n\n  return (\n    <ClassNames>\n      {({ css }) => (\n        <CenteredSlippyMap\n          ref={mapRef}\n          width={tileWidth}\n          height={tileHeight}\n          totalWidth={totalWidth}\n          totalHeight={totalHeight}\n          innerClassName={css({ outline: '1px solid black' })}\n          onPosition={updatePosition}\n          onDragStart={onDragStart}\n          initialX={initialX}\n          initialY={initialY}\n          initialZoom={initialZoom}\n        >\n          <MachineContextProvider ref={machineRef} msPerBall={msPerBall}>\n            {Array.from(iterTiles(xt1, yt1, xt2, yt2), ([xt, yt]) => {\n              const data = getMachine(xt, yt)\n              if (!data) {\n                return (\n                  <MachineTilePlaceholder\n                    key={tileKey(xt, yt)}\n                    tileWidth={tileWidth}\n                    tileHeight={tileHeight}\n                    xt={xt}\n                    yt={yt}\n                  />\n                )\n              }\n              const { blueprintId, title, puzzle, widgets, snapshot } = data\n              return (\n                <MetaMachineTile\n                  key={`${tileKey(xt, yt)}-${blueprintId}`}\n                  puzzle={puzzle}\n                  widgets={widgets}\n                  snapshot={snapshot}\n                  title={title}\n                  tileWidth={tileWidth}\n                  tileHeight={tileHeight}\n                  xt={xt}\n                  yt={yt}\n                  spawnBallsTop={yt === yt1 || !getMachine(xt, yt - 1)}\n                  spawnBallsLeft={xt === xt1 || !getMachine(xt - 1, yt)}\n                  spawnBallsRight={xt === xt2 || !getMachine(xt + 1, yt)}\n                />\n              )\n            })}\n            <BallPitMechanism\n              tileWidth={tileWidth}\n              tileHeight={tileHeight}\n              tilesX={tilesX}\n              tilesY={tilesY}\n              stepRateMultiplier={isBottomVisible ? 1 : 0.05}\n            />\n            <Balls />\n          </MachineContextProvider>\n        </CenteredSlippyMap>\n      )}\n    </ClassNames>\n  )\n})\n"
  },
  {
    "path": "client/src/components/NamePrompt.tsx",
    "content": "import { css } from '@emotion/react'\nimport { sample } from 'lodash'\nimport React, { useCallback, useMemo, useState } from 'react'\nimport { SwooshyDialog, dialogStyles } from './SwooshyDialog'\n\nconst superlatives = [\n  'illustrious',\n  'magnificent',\n  'peculiar',\n  'fascinating',\n  'marvelous',\n  'confounding',\n]\n\nconst nouns = ['invention', 'device', 'machine', 'contraption']\n\nconst namePromptStyles = css({\n  fontFamily: 'xkcd-Regular-v3',\n  padding: 16,\n  userSelect: 'none',\n  display: 'flex',\n  flexDirection: 'column',\n  gap: 16,\n  width: 350,\n\n  '.title': {\n    fontSize: '20px',\n    lineHeight: '135%',\n  },\n\n  'button, input': {\n    fontFamily: 'xkcd-Regular-v3',\n    border: '2px solid black',\n    padding: 8,\n    borderRadius: 4,\n  },\n\n  input: {\n    fontSize: '20px',\n    background: '#eee',\n  },\n\n  button: {\n    fontSize: '16px',\n    background: 'white',\n    boxShadow: '3px 3px 0 rgba(0, 0, 0, 0.5)',\n    cursor: 'pointer',\n\n    '&:active': {\n      transform: 'translate(3px, 3px)',\n      background: '#eee',\n      boxShadow: 'none',\n    },\n\n    '&:disabled': {\n      pointerEvents: 'none',\n    },\n  },\n})\n\nexport function NamePrompt({\n  onSubmit,\n  onCancel,\n}: {\n  onSubmit: (name: string) => void\n  onCancel: () => void\n}) {\n  const description = useMemo(\n    () => `${sample(superlatives)} ${sample(nouns)}`,\n    [],\n  )\n\n  const [name, setName] = useState('')\n\n  const handleNameChange = useCallback(\n    (ev: React.ChangeEvent<HTMLInputElement>) => {\n      setName(ev.target.value)\n    },\n    [],\n  )\n\n  const handleSubmit = useCallback(\n    (ev: React.FormEvent<HTMLFormElement>) => {\n      ev.preventDefault()\n      onSubmit(name)\n    },\n    [name, onSubmit],\n  )\n\n  return (\n    <SwooshyDialog onDismiss={onCancel}>\n      <form\n        className=\"wrapper\"\n        css={[dialogStyles, namePromptStyles]}\n        onSubmit={handleSubmit}\n      >\n        <span className=\"title\">\n          What will you name this\n          <br />\n          {description}?\n        </span>\n        <input\n          css={{\n            textAlign: 'center',\n            fontSize: '20px',\n          }}\n          value={name}\n          onChange={handleNameChange}\n          autoFocus\n        />\n        <div\n          css={{\n            display: 'flex',\n            justifyContent: 'center',\n            gap: 12,\n          }}\n        >\n          <button onClick={onCancel} type=\"button\">\n            Cancel\n          </button>\n          <button disabled={name.length === 0} type=\"submit\">\n            Submit\n          </button>\n        </div>\n      </form>\n    </SwooshyDialog>\n  )\n}\n"
  },
  {
    "path": "client/src/components/PhysicsContext.tsx",
    "content": "import type RAPIER from '@dimforge/rapier2d'\nimport {\n  EventQueue,\n  type Collider,\n  type ColliderDesc,\n  type ImpulseJoint,\n  type JointData,\n  type RigidBody,\n  type World,\n} from '@dimforge/rapier2d'\nimport useLatest from '@react-hook/latest'\nimport { random } from 'lodash'\nimport mitt, { Emitter } from 'mitt'\nimport {\n  DependencyList,\n  EffectCallback,\n  ReactNode,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from 'react'\nimport { ms } from '../lib/utils'\n\ntype LoopEvents = {\n  step: void\n} & {\n  [K in `collision_start_${number}` | `collision_end_${number}`]: number\n}\n\nexport const GRAVITY = { x: 0.0, y: -9.81 }\nexport const TICK_MS = 1000 / 60\n\nexport interface PhysicsContextType {\n  tickMs: number\n  rapier: typeof RAPIER\n  world: World\n  events: Emitter<LoopEvents>\n  getCurrentTick: () => number\n}\n\nexport const PhysicsContext = createContext<PhysicsContextType | null>(null)\n\nexport function PhysicsContextProvider({\n  stepRateMultiplier = 1,\n  debug,\n  children,\n}: {\n  stepRateMultiplier?: number\n  debug?: boolean\n  children: ReactNode\n}) {\n  const [contextValue, setContextValue] = useState<PhysicsContextType | null>(\n    null,\n  )\n\n  const eventQueueRef = useRef<EventQueue | undefined>()\n  const baseTimeRef = useRef(0)\n  const tickCountRef = useRef(0)\n  const isDebugRef = useLatest(debug)\n\n  const getCurrentTick = useCallback(() => tickCountRef.current, [])\n\n  // TODO: pause when page inactive\n  useEffect(() => {\n    let destroyed = false\n    let world: World | undefined\n\n    async function loadAndInit() {\n      const rapier = await import('@dimforge/rapier2d')\n      if (destroyed) {\n        return\n      }\n\n      const world = new rapier.World(GRAVITY)\n      world.numSolverIterations = 4\n      world.timestep = TICK_MS / 1000\n      const events = mitt<LoopEvents>()\n      eventQueueRef.current = new rapier.EventQueue(true)\n      setContextValue({\n        tickMs: TICK_MS,\n        rapier,\n        world,\n        events,\n        getCurrentTick,\n      })\n    }\n\n    void loadAndInit()\n\n    return () => {\n      destroyed = true\n      world?.free()\n    }\n  }, [getCurrentTick, isDebugRef])\n\n  const resetBaseTime = useCallback(\n    (now: number, tickMs: number) => {\n      const newBaseTime = now - (tickCountRef.current + 1) * tickMs\n      if (isDebugRef.current) {\n        console.debug(`Skipping ${newBaseTime - baseTimeRef.current}ms`)\n      }\n      baseTimeRef.current = newBaseTime\n    },\n    [isDebugRef],\n  )\n\n  useEffect(() => {\n    if (stepRateMultiplier === 0) {\n      return\n    }\n\n    let lastTickTime = performance.now()\n\n    const tickMs = Math.floor(TICK_MS * (1 / stepRateMultiplier))\n    resetBaseTime(lastTickTime, tickMs)\n\n    let raf: number = -1\n    function step() {\n      if (!contextValue) {\n        return\n      }\n\n      const { current: eventQueue } = eventQueueRef\n      const { world, events } = contextValue\n      if (!eventQueue || !world) {\n        return\n      }\n\n      const now = performance.now()\n\n      // Skip forward in case of large pauses.\n      if (now - lastTickTime > 30 * tickMs) {\n        resetBaseTime(now, tickMs)\n      }\n\n      const neededTicks = Math.ceil((now - baseTimeRef.current) / tickMs)\n\n      while (tickCountRef.current < neededTicks) {\n        const rapierStart = performance.now()\n        world.step(eventQueue)\n        const rapierEnd = performance.now()\n\n        const eventQueueCallbackStart = performance.now()\n        eventQueue.drainCollisionEvents((handle1, handle2, started) => {\n          const eventName = started ? 'start' : 'end'\n          events.emit(`collision_${eventName}_${handle1}`, handle2)\n          events.emit(`collision_${eventName}_${handle2}`, handle1)\n        })\n        const eventQueueCallbackEnd = performance.now()\n\n        const stepCallbackStart = performance.now()\n        events.emit('step')\n        const stepCallbackEnd = performance.now()\n\n        if (isDebugRef.current && tickCountRef.current % 60 === 0) {\n          console.debug({\n            rapier: ms(rapierEnd - rapierStart),\n            eventQueue: ms(eventQueueCallbackEnd - eventQueueCallbackStart),\n            stepCallback: ms(stepCallbackEnd - stepCallbackStart),\n            bodies: world.bodies.len(),\n          })\n        }\n\n        tickCountRef.current++\n        lastTickTime = performance.now()\n      }\n\n      raf = requestAnimationFrame(step)\n    }\n\n    raf = requestAnimationFrame(step)\n\n    return () => {\n      cancelAnimationFrame(raf)\n    }\n  }, [contextValue, isDebugRef, resetBaseTime, stepRateMultiplier])\n\n  return (\n    <PhysicsContext.Provider value={contextValue}>\n      {children}\n    </PhysicsContext.Provider>\n  )\n}\n\nexport function useRapierEffect(\n  rapierEffect: (physics: PhysicsContextType) => ReturnType<EffectCallback>,\n  deps: DependencyList,\n) {\n  const physics = useContext(PhysicsContext)\n\n  useEffect(() => {\n    if (!physics) {\n      return\n    }\n\n    return rapierEffect(physics)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [physics, ...deps])\n}\n\nexport function useCollider(\n  create: (rapier: typeof RAPIER) => ColliderDesc | null,\n  deps: DependencyList,\n) {\n  // TODO: needed to make this a setState for useCollisionHandler to get collider object updates because of the useRapierEffect updates the ref async. Ideally this would be solved without triggering a render. A JSX component-based approach would be more ergonomic than the hooks here.\n  const [collider, setCollider] = useState<Collider>()\n\n  useRapierEffect(({ rapier, world }) => {\n    const colliderDesc = create(rapier)\n    if (!colliderDesc) {\n      return\n    }\n    const collider = world.createCollider(colliderDesc)\n    setCollider(collider)\n    return () => {\n      setTimeout(() => {\n        world.removeCollider(collider, false)\n      }, 0)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, deps)\n\n  return collider\n}\n\n// TODO: Test this! It looks right but I haven't tested it <.<  >.>\nexport function useImpulseJoint(\n  create: (rapier: typeof RAPIER) => JointData | null,\n  body1Ref: React.MutableRefObject<RigidBody | null>,\n  body2Ref: React.MutableRefObject<RigidBody | null>,\n  deps: DependencyList,\n) {\n  const jointRef = useRef<ImpulseJoint | null>(null)\n  useRapierEffect(\n    ({ rapier, world }) => {\n      const jointDesc = create(rapier)\n      const { current: left } = body1Ref\n      const { current: right } = body2Ref\n\n      if (!jointDesc || !left || !right) {\n        return\n      }\n\n      const joint = world.createImpulseJoint(jointDesc, left, right, false)\n      jointRef.current = joint\n\n      return () => {\n        setTimeout(() => {\n          world.removeImpulseJoint(joint, false)\n        }, 0)\n      }\n    },\n    // don't support changing 'create' since it'll break the deps array on every render =(\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [deps, body1Ref, body2Ref],\n  )\n\n  return jointRef\n}\n\nexport function useLoopHandler(\n  handler: (physics: PhysicsContextType) => void,\n  deps: DependencyList,\n  enabled: boolean = true,\n) {\n  useRapierEffect((physics) => {\n    if (!enabled) {\n      return\n    }\n\n    const { events } = physics\n    function triggerHandler() {\n      handler(physics)\n    }\n    events.on('step', triggerHandler)\n    return () => {\n      events.off('step', triggerHandler)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, deps)\n}\n\nexport function useCollisionHandler(\n  event: 'start' | 'end',\n  collider: Collider | undefined,\n  handler: (otherCollider: Collider) => void,\n  deps: DependencyList,\n) {\n  useRapierEffect(\n    (physics) => {\n      if (!collider) {\n        return\n      }\n\n      const { world, events } = physics\n      function triggerHandler(handle: number) {\n        const otherCollider = world.getCollider(handle)\n        handler(otherCollider)\n      }\n\n      const { handle } = collider\n      events.on(`collision_${event}_${handle}`, triggerHandler)\n      return () => {\n        events.off(`collision_${event}_${handle}`, triggerHandler)\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [event, collider, ...deps],\n  )\n}\n\nexport function usePhysicsLoaded() {\n  return useContext(PhysicsContext) != null\n}\n\nexport function PhysicsLoader({\n  spinner,\n  children,\n}: {\n  spinner: ReactNode\n  children: ReactNode\n}) {\n  const isPhysicsLoaded = usePhysicsLoaded()\n  if (!isPhysicsLoaded) {\n    return spinner\n  }\n  return children\n}\n\nexport function deferATick(callback: () => void) {\n  return setTimeout(() => {\n    callback()\n  }, random(TICK_MS))\n}\n"
  },
  {
    "path": "client/src/components/SwooshyDialog.tsx",
    "content": "import { css } from '@emotion/react'\nimport { motion } from 'framer-motion'\nimport React, { ReactNode, useCallback, useRef } from 'react'\n\nexport const dialogStyles = css({\n  border: '2px solid black',\n  background: 'white',\n  boxShadow: '5px 5px 0 rgba(0, 0, 0, 0.5)',\n  borderRadius: 4,\n})\n\nexport function SwooshyDialog({\n  className,\n  onDismiss,\n  children,\n}: {\n  className?: string\n  onDismiss?: () => void\n  children: ReactNode\n}) {\n  const ref = useRef<HTMLDivElement>(null)\n  const handleClick = useCallback(\n    (ev: React.MouseEvent<HTMLDivElement>) => {\n      if (ev.target === ev.currentTarget) {\n        onDismiss?.()\n      }\n    },\n    [onDismiss],\n  )\n\n  return (\n    <motion.div\n      ref={ref}\n      onClick={handleClick}\n      initial={{ scale: 0, opacity: 0 }}\n      animate={{ scale: 1, opacity: 1 }}\n      exit={{ scale: 0, opacity: 0 }}\n      transition={{ type: 'spring', duration: 0.65 }}\n      css={{\n        position: 'absolute',\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        width: '100%',\n        height: '100%',\n        zIndex: 50,\n        '.wrapper': {\n          display: 'flex',\n          position: 'relative',\n        },\n      }}\n      className={className}\n    >\n      {children}\n    </motion.div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/WidgetPalette.tsx",
    "content": "import imgEmergencyStop from '@art/emergency-stop_4x.png'\nimport imgTrash from '@art/trash_4x.png'\nimport { PropsOf, css } from '@emotion/react'\nimport { motion } from 'framer-motion'\nimport { ComicImage } from './ComicImage'\nimport { dialogStyles } from './SwooshyDialog'\nimport { MAX_WIDGET_COUNT } from './constants'\nimport { PaletteItem, WidgetData, stickerList, widgetList } from './widgets'\n\nexport const comicDropShadow = css({\n  filter: 'drop-shadow(2px 2px 0 rgba(0, 0, 0, 0.5))',\n})\n\nexport function ToolButton({\n  disabled,\n  className,\n  'aria-label': ariaLabel,\n  onClick,\n  children,\n  ...props\n}: PropsOf<typeof motion.button>) {\n  return (\n    <motion.button\n      {...props}\n      onClick={onClick}\n      disabled={disabled}\n      whileTap={disabled ? undefined : { scale: 0.9 }}\n      css={{\n        padding: 0,\n        border: 'none',\n        background: 'none',\n        cursor: disabled ? 'default' : 'pointer',\n        zIndex: 20,\n      }}\n      className={className}\n      aria-label={ariaLabel}\n    >\n      {children}\n    </motion.button>\n  )\n}\n\nexport default function WidgetPalette({\n  className,\n  widgetCount,\n  isHorizontal,\n  onAdd,\n  onTrash,\n  onEmergencyStop,\n}: {\n  className?: string\n  widgetCount: number\n  isHorizontal: boolean\n  onAdd: (create: PaletteItem<WidgetData>['create']) => void\n  onTrash: () => void\n  onEmergencyStop: () => void\n}) {\n  const canAddWidgets = widgetCount < MAX_WIDGET_COUNT\n\n  function renderList(list: Record<string, PaletteItem<WidgetData>>) {\n    return Object.entries(list).map(\n      ([type, { preview: WidgetPreview, create }]) => (\n        <div\n          key={type}\n          onClick={() => onAdd(create)}\n          css={{\n            flexShrink: 0,\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n            maxWidth: '100%',\n            maxHeight: '100%',\n            aspectRatio: 1,\n            cursor: 'pointer',\n            '@media (pointer: fine)': {\n              ':hover': {\n                background: '#ccc',\n              },\n            },\n          }}\n        >\n          <WidgetPreview />\n        </div>\n      ),\n    )\n  }\n\n  return (\n    <div\n      css={[\n        dialogStyles,\n        {\n          display: 'flex',\n          alignItems: 'center',\n          gap: 12,\n          zIndex: 70,\n        },\n      ]}\n      style={\n        isHorizontal\n          ? {\n              flexDirection: 'row',\n              paddingRight: 12,\n            }\n          : {\n              flexDirection: 'column',\n              paddingBottom: 12,\n            }\n      }\n      className={className}\n    >\n      <div\n        css={[\n          {\n            flex: 1,\n            display: 'flex',\n            flexDirection: isHorizontal ? 'row' : 'column',\n            maxWidth: '100%',\n            maxHeight: '100%',\n            overflow: 'auto',\n            scrollbarWidth: 'thin',\n            opacity: canAddWidgets ? 1 : 0.5,\n            pointerEvents: canAddWidgets ? 'all' : 'none',\n          },\n          isHorizontal && { height: '100%' },\n        ]}\n      >\n        {renderList(widgetList)}\n        <div\n          css={{\n            borderBottom: '1px dashed black',\n            paddingTop: 10,\n            marginBottom: 10,\n            marginLeft: 4,\n            marginRight: 4,\n          }}\n        />\n        {renderList(stickerList)}\n      </div>\n      {widgetCount > 0.75 * MAX_WIDGET_COUNT && (\n        <div\n          css={{\n            display: 'flex',\n            height: 26,\n            alignItems: 'center',\n            justifyContent: 'center',\n            borderTop: '2px solid black',\n            color: canAddWidgets ? 'black' : 'red',\n          }}\n        >\n          {widgetCount} / {MAX_WIDGET_COUNT}\n        </div>\n      )}\n      <ToolButton\n        onClick={onTrash}\n        aria-label=\"Delete selection\"\n        css={{\n          height: imgTrash.height,\n        }}\n      >\n        <ComicImage img={imgTrash} />\n      </ToolButton>\n      <ToolButton\n        onClick={onEmergencyStop}\n        aria-label=\"Emergency stop\"\n        css={{\n          height: imgEmergencyStop.height,\n        }}\n      >\n        <ComicImage img={imgEmergencyStop} />\n      </ToolButton>\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/constants.tsx",
    "content": "import imgBottomPit from '@art/bottom_pit_4x.png'\nimport imgBottomChute from '@art/chute-0v_4x.png'\nimport imgBottomTank from '@art/tank-blue_4x.png'\n\nexport const BALL_RADIUS = 8\nexport const INPUT_SPINNER_SIZE = 42\nexport const INPUT_SPINNER_SPEED = 2\nexport const INPUT_TEETH_COUNT = 8\nexport const INPUT_WIDTH = 2 * INPUT_SPINNER_SIZE + 5\nexport const MAX_WIDGET_COUNT = 100\nexport const TRIANGLE_BUMPER_SENSOR_OFFSET = 0.2\nexport const TRIANGLE_BUMPER_RADIUS_RATIO = 0.14159\nexport const TRIANGLE_BUMPER_SENSOR_FUDGE = 2.2\nexport const TRIANGLE_BUMPER_STRENGTH = 35\nexport const ROUND_BUMPER_STRENGTH = 35\nexport const BONK_ANIMATION_DELAY_MS = 200\nexport const TRIANGLE_BUMPER_CONTACT_DISTANCE = 0.1\nexport const ROAND_BUMPER_RADIUS_RATIO = 0.49\nexport const ROUND_BUMPER_FALLBACK_RATIO = 0.9\nexport const BOTTOM_PIT_WIDTH = imgBottomPit.width\nexport const BOTTOM_PIT_HEIGHT = imgBottomPit.height\nexport const BOTTOM_PIT_EDGE_WIDTH = 94\nexport const BOTTOM_MECHANISM_HEIGHT = 3000\nexport const BOTTOM_CHUTE_DROP = 50\nexport const BOTTOM_CHUTE_EXIT_OFFSET = 24\nexport const BOTTOM_CHUTE_HEIGHT = imgBottomChute.height\nexport const BOTTOM_TANK_WIDTH = imgBottomTank.width\nexport const BOTTOM_TANK_HEIGHT = imgBottomTank.height\nexport const BOTTOM_TANK_SPACING = 100\nexport const BOTTOM_TANK_TARGET_TOP = 8\n"
  },
  {
    "path": "client/src/components/moderation/BlueprintButton.tsx",
    "content": "import useIntersectionObserver from '@react-hook/intersection-observer'\nimport { truncate } from 'lodash'\nimport { useCallback, useRef } from 'react'\nimport { Puzzle } from '../../types'\nimport { default as ModMachineTileView } from './ModMachineTileView'\nimport { ServerBlueprint } from './modTypes'\n\nexport function BlueprintButton({\n  blueprintId,\n  blueprint,\n  puzzle,\n  tileWidth,\n  tileHeight,\n  isSelected,\n  isApproved,\n  onSelect,\n}: {\n  blueprintId: string\n  blueprint: ServerBlueprint\n  puzzle: Puzzle\n  tileWidth: number\n  tileHeight: number\n  isSelected: boolean\n  isApproved: boolean\n  onSelect: (blueprintId: string) => void\n}) {\n  const ref = useRef<HTMLButtonElement>(null)\n  const { isIntersecting } = useIntersectionObserver(ref)\n\n  const handleClick = useCallback(() => {\n    onSelect(blueprintId)\n  }, [blueprintId, onSelect])\n\n  return (\n    <button\n      ref={ref}\n      css={{\n        position: 'relative',\n        background: 'none',\n        border: 'none',\n        outline: `3px solid ${isSelected ? 'blue' : isApproved ? 'green' : 'black'}`,\n        borderRadius: 4,\n        width: 150,\n        height: 150,\n        padding: 0,\n      }}\n      onClick={handleClick}\n    >\n      <div\n        css={{\n          position: 'absolute',\n          right: 2,\n          bottom: 2,\n          color: isApproved ? 'green' : isSelected ? 'blue' : 'gray',\n          fontWeight: 'bold',\n          background: 'white',\n          textAlign: 'right',\n          zIndex: 10,\n        }}\n      >\n        {isApproved\n          ? 'current approved'\n          : isSelected\n            ? 'viewing'\n            : truncate(blueprint.title, { length: 42 })}\n      </div>\n\n      {isIntersecting && (\n        <ModMachineTileView\n          puzzle={puzzle}\n          blueprint={blueprint}\n          width={150}\n          height={150}\n          tileWidth={tileWidth}\n          tileHeight={tileHeight}\n        />\n      )}\n    </button>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/ContextGridForMachineAt.tsx",
    "content": "import { css } from '@emotion/react'\nimport { gridDimensions, iterTiles, tileKey } from '../../lib/tiles'\nimport { inBounds } from '../../lib/utils'\nimport { Puzzle } from '../../types'\nimport LoadingSpinner from '../LoadingSpinner'\nimport ModMachineTileView from './ModMachineTileView'\nimport { ModTileInputOutputView } from './ModTileInputOutputView'\nimport { ModLocation, ModMachine, ServerBlueprint } from './modTypes'\nimport { useContextBlueprints, useContextPuzzles } from './moderatorClient'\n\nconst TILE_WIDTH = 100\n\nconst tileWrapperStyles = css({\n  outline: '1px solid rgba(0, 0, 0, .35)',\n  userSelect: 'none',\n})\n\nconst centerTextStyles = css({\n  display: 'flex',\n  justifyContent: 'center',\n  alignItems: 'center',\n  width: '100%',\n  height: '100%',\n})\n\nfunction OOBTile() {\n  return (\n    <div\n      css={[\n        { color: 'gainsboro', backgroundColor: 'slategrey' },\n        tileWrapperStyles,\n        centerTextStyles,\n      ]}\n    >\n      OUT OF\n      <br />\n      BOUNDS\n    </div>\n  )\n}\n\nfunction EmptyTile({\n  puzzle,\n  toModCount,\n  onClick,\n}: {\n  puzzle: Puzzle\n  toModCount: number | undefined\n  onClick?: () => void\n}) {\n  return (\n    <div\n      onClick={onClick}\n      css={[\n        {\n          position: 'relative',\n          backgroundColor: 'gainsboro',\n          overflow: 'hidden',\n        },\n        tileWrapperStyles,\n        centerTextStyles,\n      ]}\n    >\n      {toModCount}\n      <ModTileInputOutputView puzzle={puzzle} />\n    </div>\n  )\n}\n\nexport default function ContextGridForMachineAt({\n  modMachine,\n  xt: viewXt,\n  yt: viewYt,\n  selectedBlueprint,\n  tileOutset = 2,\n  onSelectLocation,\n}: {\n  modMachine: ModMachine\n  selectedBlueprint: ServerBlueprint | undefined\n  tileOutset?: number\n  onSelectLocation: (xt: number, yt: number) => void\n} & ModLocation) {\n  const sideSize: number = tileOutset * 2 + 1\n\n  const [tilesX, tilesY] = gridDimensions(modMachine.grid)\n\n  const contextLocs: Array<\n    | (ModLocation & { oob: false; toModCount: number | undefined })\n    | {\n        xt: number\n        yt: number\n        oob: true\n      }\n  > = Array.from(\n    iterTiles(\n      viewXt - tileOutset,\n      viewYt - tileOutset,\n      viewXt + tileOutset,\n      viewYt + tileOutset,\n    ),\n    ([xt, yt]) =>\n      inBounds(xt, yt, [0, 0, tilesX - 1, tilesY - 1])\n        ? {\n            xt,\n            yt,\n            oob: false,\n            puzzle: modMachine.grid[yt][xt].puzzle,\n            blueprint: modMachine.grid[yt][xt].blueprint,\n            toModCount: modMachine.grid[yt][xt].to_mod,\n          }\n        : { xt, yt, oob: true },\n  )\n\n  const puzzles = useContextPuzzles(\n    contextLocs.map((l) => (l.oob ? undefined : l.puzzle)),\n  )\n  const blueprints = useContextBlueprints(\n    contextLocs.map((l) => (l.oob ? undefined : l.blueprint)),\n  )\n\n  const contextTiles = contextLocs.map((loc, idx) => {\n    const { xt, yt, oob } = loc\n    const key = tileKey(xt, yt)\n\n    const blueprint = blueprints[idx].data?.blueprint\n    const puzzle = puzzles[idx].data\n\n    if (puzzles[idx].isLoading || blueprints[idx].isLoading) {\n      return <LoadingSpinner key={key} />\n    }\n\n    // This would be annoying to useCallback so I'm skipping it\n    const handleClick = () => {\n      onSelectLocation(xt, yt)\n    }\n\n    if (oob) {\n      return <OOBTile key={key} />\n    } else if (!puzzle) {\n      return <LoadingSpinner key={key} />\n    } else if (!loc.blueprint) {\n      return (\n        <EmptyTile\n          key={key}\n          puzzle={puzzle}\n          toModCount={loc.toModCount}\n          onClick={handleClick}\n        />\n      )\n    } else if (xt === viewXt && yt === viewYt) {\n      return (\n        <div key={key} onClick={handleClick} css={tileWrapperStyles}>\n          {selectedBlueprint ? (\n            <ModMachineTileView\n              puzzle={puzzle}\n              blueprint={blueprint}\n              width={TILE_WIDTH}\n              height={TILE_WIDTH}\n              tileWidth={modMachine.tile_size.x}\n              tileHeight={modMachine.tile_size.y}\n            />\n          ) : null}\n        </div>\n      )\n    } else {\n      return (\n        <div key={key} css={tileWrapperStyles} onClick={handleClick}>\n          <ModMachineTileView\n            puzzle={puzzle}\n            blueprint={blueprint}\n            width={TILE_WIDTH}\n            height={TILE_WIDTH}\n            tileWidth={modMachine.tile_size.x}\n            tileHeight={modMachine.tile_size.y}\n          />\n        </div>\n      )\n    }\n  })\n\n  return (\n    <div\n      css={{\n        display: 'grid',\n        gridTemplateColumns: `repeat(${sideSize}, ${TILE_WIDTH}px)`,\n        gridTemplateRows: `repeat(${sideSize}, ${TILE_WIDTH}px)`,\n        gridColumnGap: '0px',\n        gridRowGap: '0px',\n        gridAutoFlow: 'row',\n        aspectRatio: '1',\n        justifyItems: 'center',\n        alignItems: 'center',\n        width: sideSize * TILE_WIDTH,\n      }}\n    >\n      {contextTiles}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/LiveMachinePreview.tsx",
    "content": "import imgCheck from '@art/check-circle_4x.png'\nimport imgWrong from '@art/wrong-circle_4x.png'\nimport {\n  ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\nimport { Bounds, Puzzle } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport LoadingSpinner from '../LoadingSpinner'\nimport {\n  MachineContextProvider,\n  MachineContextProviderRef,\n} from '../MachineContext'\nimport {\n  MachineTileContextProvider,\n  MachineTileContextProviderRef,\n} from '../MachineTileContext'\nimport { PhysicsContextProvider } from '../PhysicsContext'\nimport { MAX_WIDGET_COUNT } from '../constants'\nimport { CircleGauge } from '../widgets/CircleGauge'\nimport ModMachineTileView from './ModMachineTileView'\nimport { ModLocation, ModMachine, ServerBlueprint } from './modTypes'\nimport {\n  useApproveBlueprint,\n  useBurnBlueprint,\n  useReissuePuzzle,\n} from './moderatorClient'\n\nconst MIN_SECONDS_TO_MOD = 30\n\nfunction useCountdown(initialSeconds: number) {\n  const [seconds, setSeconds] = useState(initialSeconds)\n\n  useEffect(() => {\n    const startTime = performance.now()\n\n    function dec() {\n      const now = performance.now()\n      const remainingSeconds = initialSeconds - (now - startTime) / 1000\n      setSeconds(remainingSeconds)\n      if (remainingSeconds <= 0) {\n        clearInterval(interval)\n      }\n    }\n    const interval = setInterval(dec, 150)\n    return () => {\n      clearInterval(interval)\n    }\n  }, [initialSeconds])\n\n  return seconds\n}\n\nfunction ValidationLine({\n  isValid,\n  children,\n}: {\n  isValid: boolean\n  children: ReactNode\n}) {\n  return (\n    <div\n      css={{\n        display: 'flex',\n        alignItems: 'center',\n        gap: 8,\n      }}\n    >\n      <ComicImage img={isValid ? imgCheck : imgWrong} />\n      <span>{children}</span>\n    </div>\n  )\n}\n\nexport function LiveMachinePreview({\n  loc,\n  modMachine,\n  puzzle,\n  blueprintId,\n  blueprint,\n  onNextBlueprint,\n}: {\n  loc: ModLocation\n  modMachine: ModMachine\n  puzzle: Puzzle\n  blueprintId: string | undefined\n  blueprint: ServerBlueprint | undefined\n  onNextBlueprint: () => void\n}) {\n  const machineRef = useRef<MachineContextProviderRef>(null)\n  const machineTileRef = useRef<MachineTileContextProviderRef>(null)\n\n  const approveBlueprint = useApproveBlueprint()\n  const burnBlueprint = useBurnBlueprint()\n  const reissuePuzzle = useReissuePuzzle()\n\n  const actionCountdown = useCountdown(MIN_SECONDS_TO_MOD)\n\n  const [isValidOutputs, setValidOutputs] = useState(false)\n  const widgetCount = blueprint ? Object.keys(blueprint.widgets).length : 0\n  const isWidgetCountValid = widgetCount <= MAX_WIDGET_COUNT\n  const canApprove =\n    actionCountdown <= 0 && isValidOutputs && isWidgetCountValid\n\n  const handleOutputValidate = useCallback((isValid: boolean) => {\n    setValidOutputs(isValid)\n  }, [])\n\n  let modStatus = ''\n  const isError =\n    approveBlueprint.isError || burnBlueprint.isError || reissuePuzzle.isError\n  if (approveBlueprint.isPending || burnBlueprint.isPending) {\n    modStatus = 'Working...'\n  } else if (isError) {\n    modStatus = 'Error :('\n  } else if (approveBlueprint.isSuccess) {\n    modStatus = 'Approved!'\n  } else if (burnBlueprint.isSuccess) {\n    modStatus = 'Burnt!'\n  } else if (reissuePuzzle.isSuccess) {\n    modStatus = 'Reissued!'\n  }\n\n  const handleApprove = useCallback(() => {\n    const { current: machineTile } = machineTileRef\n    if (!machineTile || !blueprintId) {\n      return\n    }\n    const snapshot = machineTile.snapshot()\n    approveBlueprint.mutate({\n      xt: loc.xt,\n      yt: loc.yt,\n      blueprintId,\n      snapshot,\n    })\n  }, [approveBlueprint, blueprintId, loc.xt, loc.yt])\n\n  const handleBurn = useCallback(() => {\n    if (!blueprintId) {\n      return\n    }\n    void burnBlueprint.mutate({ puzzleId: loc.puzzle, blueprintId })\n    onNextBlueprint()\n  }, [blueprintId, burnBlueprint, loc.puzzle, onNextBlueprint])\n\n  const handleReissue = useCallback(() => {\n    if (!blueprint) {\n      return\n    }\n    void reissuePuzzle.mutate({ puzzleId: blueprint.puzzle })\n  }, [blueprint, reissuePuzzle])\n\n  const tileBounds: Bounds = useMemo(\n    () => [0, 0, modMachine.tile_size.x, modMachine.tile_size.y],\n    [modMachine.tile_size.x, modMachine.tile_size.y],\n  )\n\n  return (\n    <div\n      css={{\n        display: 'flex',\n        flexDirection: 'column',\n        alignItems: 'center',\n        gap: 16,\n      }}\n    >\n      <PhysicsContextProvider>\n        <MachineContextProvider\n          ref={machineRef}\n          msPerBall={modMachine.ms_per_ball}\n          initialSimulationBounds={tileBounds}\n          initialViewBounds={tileBounds}\n        >\n          <MachineTileContextProvider ref={machineTileRef} bounds={tileBounds}>\n            {!puzzle ? (\n              <LoadingSpinner />\n            ) : (\n              <ModMachineTileView\n                puzzle={puzzle}\n                blueprint={blueprint}\n                width={500}\n                height={500}\n                tileWidth={modMachine.tile_size.x}\n                tileHeight={modMachine.tile_size.y}\n                onValidate={handleOutputValidate}\n              />\n            )}\n          </MachineTileContextProvider>\n        </MachineContextProvider>\n      </PhysicsContextProvider>\n      {blueprint ? (\n        <>\n          <div css={{ display: 'flex', flexDirection: 'column', gap: 8 }}>\n            <h3 css={{ margin: 0 }}>&quot;{blueprint.title}&quot;</h3>\n            <ValidationLine isValid={isValidOutputs}>\n              {isValidOutputs\n                ? 'All outputs are valid'\n                : 'Not all outputs are valid'}\n            </ValidationLine>\n            <ValidationLine isValid={isWidgetCountValid}>\n              {widgetCount} / {MAX_WIDGET_COUNT} widgets used\n            </ValidationLine>\n          </div>\n          <div\n            css={{\n              display: 'flex',\n              flexDirection: 'row',\n              flexWrap: 'wrap',\n              gap: 8,\n            }}\n          >\n            <CircleGauge\n              value={actionCountdown / 30}\n              lineWidth={0.5}\n              css={{\n                width: 20,\n                stroke: 'gray',\n              }}\n            />\n            <button onClick={handleApprove} disabled={!canApprove}>\n              Approve\n            </button>\n            <button onClick={handleBurn}>Burn</button>\n            <button onClick={handleReissue}>Reissue Puzzle</button>\n            {modStatus && (\n              <span style={{ color: isError ? 'red' : 'black' }}>\n                {modStatus}\n              </span>\n            )}\n          </div>\n        </>\n      ) : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/ModMachineTileView.tsx",
    "content": "import { Puzzle, WidgetCollection } from '../../types'\nimport { Widgets } from '../widgets'\nimport { Balls } from '../widgets/Balls'\nimport MachineFrame from '../widgets/MachineFrame'\nimport { ModTileInputOutputView } from './ModTileInputOutputView'\nimport { ServerBlueprint } from './modTypes'\n\nexport default function ModMachineTileView({\n  puzzle,\n  blueprint,\n  width,\n  height,\n  tileWidth,\n  tileHeight,\n  onValidate,\n  className,\n}: {\n  puzzle: Puzzle\n  blueprint?: ServerBlueprint\n  width: number\n  height: number\n  tileWidth: number\n  tileHeight: number\n  onValidate?: (isValid: boolean) => void\n  className?: string\n}) {\n  return (\n    <div css={{ position: 'relative', width, height, overflow: 'hidden' }}>\n      <div\n        css={{\n          position: 'absolute',\n          left: 0,\n          top: 0,\n          width: tileWidth,\n          height: tileHeight,\n          transform: `scale(${width / tileWidth})`,\n          transformOrigin: 'left top',\n          aspectRatio: '1',\n        }}\n        className={className}\n      >\n        {blueprint ? (\n          <Widgets widgets={blueprint.widgets as WidgetCollection} />\n        ) : null}\n        {puzzle ? (\n          <MachineFrame\n            key={blueprint?.puzzle}\n            inputs={puzzle.inputs}\n            outputs={puzzle.outputs}\n            onValidate={onValidate}\n            validateOutputs={onValidate != null}\n            spawnBallsTop\n            spawnBallsLeft\n            spawnBallsRight\n          />\n        ) : null}\n        <Balls />\n      </div>\n      <ModTileInputOutputView puzzle={puzzle} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/ModTileInputOutputView.tsx",
    "content": "import { css } from '@emotion/react'\nimport { percent } from '../../lib/utils'\nimport { Puzzle, PuzzlePosition } from '../../types'\n\nconst posClassNames = ['blue', 'red', 'green', 'yellow']\nconst posStyles = css({\n  width: 6,\n  height: 6,\n  zIndex: 20,\n\n  '&.input': {\n    borderRadius: '100%',\n  },\n\n  '&.blue': {\n    background: 'blue',\n  },\n\n  '&.red': {\n    background: 'red',\n  },\n\n  '&.green': {\n    background: 'green',\n  },\n\n  '&.yellow': {\n    background: 'yellow',\n  },\n})\n\nexport function TinyInputOutput({\n  x,\n  y,\n  balls,\n  isInput,\n  style,\n}: {\n  isInput?: boolean\n  style?: React.CSSProperties\n} & PuzzlePosition) {\n  return (\n    <div\n      style={{\n        ...style,\n        position: 'absolute',\n        left: `${percent(x)}`,\n        top: `${percent(y)}`,\n        transform: 'translate(-50%, -50%)',\n      }}\n    >\n      {balls.map(({ type }, idx) => (\n        <div\n          key={idx}\n          css={posStyles}\n          className={`${posClassNames[type - 1]} ${isInput && 'input'}`}\n        ></div>\n      ))}\n    </div>\n  )\n}\n\nexport function ModTileInputOutputView({ puzzle }: { puzzle: Puzzle }) {\n  return (\n    <>\n      {puzzle.inputs.map((input, idx) => (\n        <TinyInputOutput key={idx} {...input} isInput />\n      ))}\n      {puzzle.outputs.map((input, idx) => (\n        <TinyInputOutput key={idx} {...input} />\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/Moderator.tsx",
    "content": "import { Global } from '@emotion/react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { clamp } from 'lodash'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { components } from '../../generated/api-spec'\nimport { gridDimensions } from '../../lib/tiles'\nimport LoadingSpinner from '../LoadingSpinner'\nimport { paramToNumber, useLocationHashParams } from '../useLocationHashParams'\nimport { BlueprintButton } from './BlueprintButton'\nimport ContextGridForMachineAt from './ContextGridForMachineAt'\nimport { LiveMachinePreview } from './LiveMachinePreview'\nimport SelectTileForm from './SelectTileForm'\nimport { ModLocation } from './modTypes'\nimport { getEmptyTile, locFromPosition, sortCandidateMap } from './modUtils'\nimport {\n  puzzleQueryOptions,\n  useBlueprint,\n  useCandidateBlueprints,\n  useModeratorMachine,\n} from './moderatorClient'\n\nfunction useModHashParams(): {\n  hashLoc: ModLocation | null\n  setHashLoc: (loc: ModLocation) => void\n} {\n  const { locationHashParams, setLocationHashParams } = useLocationHashParams()\n\n  const hashLoc = useMemo(() => {\n    const blueprint = locationHashParams.get('blueprint') ?? undefined\n    const puzzle = locationHashParams.get('puzzle')\n    const xt = paramToNumber(locationHashParams.get('xt'))\n    const yt = paramToNumber(locationHashParams.get('yt'))\n    if (!puzzle || xt == null || yt == null) {\n      return null\n    }\n    return {\n      blueprint,\n      puzzle,\n      xt,\n      yt,\n    }\n  }, [locationHashParams])\n\n  const setHashLoc = useCallback(\n    (loc: ModLocation) => {\n      setLocationHashParams({\n        blueprint: loc.blueprint,\n        puzzle: loc.puzzle,\n        xt: String(loc.xt),\n        yt: String(loc.yt),\n      })\n    },\n    [setLocationHashParams],\n  )\n\n  return { hashLoc, setHashLoc }\n}\n\nfunction BlueprintModerator({\n  modMachine,\n}: {\n  modMachine: components['schemas']['VersionedMachine ModData']\n}) {\n  const { hashLoc, setHashLoc } = useModHashParams()\n\n  const [loc, setLoc] = useState(() =>\n    hashLoc\n      ? locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt)\n      : getEmptyTile(modMachine),\n  )\n\n  const queryClient = useQueryClient()\n\n  const { data: locFolio } = useBlueprint(loc.blueprint)\n  const { data: candidateBlueprints } = useCandidateBlueprints({\n    puzzleId: loc.puzzle,\n  })\n\n  const [selectedBlueprintId, setSelectedBlueprintId] = useState<\n    string | undefined\n  >(hashLoc?.blueprint)\n\n  useEffect(() => {\n    setHashLoc({\n      ...loc,\n      blueprint: selectedBlueprintId,\n    })\n  }, [loc, selectedBlueprintId, setHashLoc])\n\n  useEffect(() => {\n    if (!hashLoc) {\n      return\n    }\n    setLoc(locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt))\n    setSelectedBlueprintId(hashLoc.blueprint)\n  }, [hashLoc, modMachine.grid])\n\n  const sortedCandidateBlueprints = useMemo(\n    () =>\n      candidateBlueprints\n        ? sortCandidateMap(candidateBlueprints, loc.blueprint)\n        : null,\n    [candidateBlueprints, loc.blueprint],\n  )\n\n  const selectedBlueprint =\n    selectedBlueprintId === loc.blueprint\n      ? locFolio?.blueprint\n      : selectedBlueprintId\n        ? candidateBlueprints?.get(selectedBlueprintId)\n        : undefined\n\n  const { data: locPuzzle } = useQuery(puzzleQueryOptions(loc.puzzle))\n\n  const handleGotoLoc = useCallback(\n    (rawXt: number, rawYt: number) => {\n      const [tilesX, tilesY] = gridDimensions(modMachine.grid)\n      const xt = clamp(rawXt, 0, tilesX - 1)\n      const yt = clamp(rawYt, 0, tilesY - 1)\n      setLoc({\n        xt,\n        yt,\n        ...modMachine.grid[yt][xt],\n      })\n      void queryClient.invalidateQueries({\n        queryKey: ['moderator', 'machine'],\n      })\n    },\n    [modMachine.grid, queryClient],\n  )\n\n  const handleNextEmpty = useCallback(() => {\n    void queryClient\n      .invalidateQueries({\n        queryKey: ['moderator', 'machine'],\n      })\n      .then(() => {\n        setLoc(getEmptyTile(modMachine))\n      })\n  }, [modMachine, queryClient])\n\n  const handleNextBlueprint = useCallback(() => {\n    if (!sortedCandidateBlueprints || !selectedBlueprintId) {\n      return\n    }\n    const currentIdx = sortedCandidateBlueprints.findIndex(\n      ([id]) => id === selectedBlueprintId,\n    )\n    if (currentIdx === -1) {\n      return\n    }\n    const nextItem = sortedCandidateBlueprints[currentIdx + 1]\n    if (nextItem) {\n      setSelectedBlueprintId(nextItem[0])\n    }\n  }, [selectedBlueprintId, sortedCandidateBlueprints])\n\n  // If the selected blueprint id isn't valid, pick a suitable one\n  useEffect(() => {\n    if (\n      selectedBlueprintId != null &&\n      (selectedBlueprintId === loc.blueprint ||\n        !candidateBlueprints ||\n        candidateBlueprints.has(selectedBlueprintId))\n    ) {\n      return\n    }\n\n    if (loc.blueprint != null) {\n      setSelectedBlueprintId(loc.blueprint)\n      return\n    }\n\n    if (candidateBlueprints) {\n      const firstId = candidateBlueprints.keys().next()\n      setSelectedBlueprintId(firstId.done === false ? firstId.value : undefined)\n    }\n  }, [selectedBlueprintId, candidateBlueprints, loc.blueprint])\n\n  return (\n    <div>\n      <Global\n        styles={{\n          body: {\n            fontFamily: 'xkcd-Regular-v3',\n          },\n          'h1, h2, h3': {\n            fontWeight: 'normal',\n          },\n        }}\n      />\n      <h1>Incredible Modview</h1>\n      <div\n        css={{\n          display: 'flex',\n          alignItems: 'top',\n          justifyContent: 'center',\n          minHeight: 700,\n\n          '& > div': {\n            display: 'flex',\n            flexDirection: 'column',\n            alignItems: 'center',\n            flex: 1,\n            gap: 16,\n          },\n\n          h2: {\n            margin: 0,\n          },\n        }}\n      >\n        <div>\n          <h2>Context Window</h2>\n          <ContextGridForMachineAt\n            modMachine={modMachine}\n            selectedBlueprint={selectedBlueprint}\n            onSelectLocation={handleGotoLoc}\n            {...loc}\n          />\n          <SelectTileForm\n            xt={loc.xt}\n            yt={loc.yt}\n            onGotoLoc={handleGotoLoc}\n            onNextEmpty={handleNextEmpty}\n          />\n        </div>\n        <div>\n          <h2>Candidate Machine</h2>\n          {locPuzzle ? (\n            <LiveMachinePreview\n              key={selectedBlueprintId}\n              loc={loc}\n              modMachine={modMachine}\n              puzzle={locPuzzle}\n              blueprintId={selectedBlueprintId}\n              blueprint={selectedBlueprint}\n              onNextBlueprint={handleNextBlueprint}\n            />\n          ) : (\n            <LoadingSpinner />\n          )}\n        </div>\n      </div>\n      <h2 css={{ marginTop: 48 }}>Candidate Machine Options</h2>\n      {\n        <div\n          css={{\n            display: 'flex',\n            flexDirection: 'row',\n            flexWrap: 'wrap',\n            alignItems: 'center',\n            justifyContent: 'center',\n            margin: 16,\n            gap: 8,\n          }}\n        >\n          {loc.blueprint && locFolio && (\n            <BlueprintButton\n              key={loc.blueprint}\n              blueprintId={loc.blueprint}\n              blueprint={locFolio.blueprint}\n              puzzle={locFolio.puzzle}\n              tileWidth={modMachine.tile_size.x}\n              tileHeight={modMachine.tile_size.y}\n              isSelected={loc.blueprint === selectedBlueprintId}\n              isApproved={true}\n              onSelect={setSelectedBlueprintId}\n            />\n          )}\n          {sortedCandidateBlueprints && locPuzzle\n            ? sortedCandidateBlueprints.map(([blueprintId, blueprint]) => (\n                <BlueprintButton\n                  key={blueprintId}\n                  blueprintId={blueprintId}\n                  blueprint={blueprint}\n                  puzzle={locPuzzle}\n                  tileWidth={modMachine.tile_size.x}\n                  tileHeight={modMachine.tile_size.y}\n                  isSelected={blueprintId === selectedBlueprintId}\n                  isApproved={blueprintId === loc.blueprint}\n                  onSelect={setSelectedBlueprintId}\n                />\n              ))\n            : null}\n        </div>\n      }\n    </div>\n  )\n}\n\nexport default function Moderator() {\n  const { data: modMachine } = useModeratorMachine()\n\n  if (!modMachine) {\n    return <LoadingSpinner css={{ height: '100vh' }} />\n  }\n\n  return <BlueprintModerator modMachine={modMachine} />\n}\n"
  },
  {
    "path": "client/src/components/moderation/SelectTileForm.tsx",
    "content": "import React, { useCallback } from 'react'\n\nexport default function SelectTileForm({\n  xt,\n  yt,\n  onGotoLoc,\n  onNextEmpty,\n}: {\n  xt: number\n  yt: number\n  onGotoLoc: (xt: number, yt: number) => void\n  onNextEmpty: () => void\n}) {\n  const handleChangeX = useCallback(\n    (ev: React.ChangeEvent<HTMLInputElement>) => {\n      onGotoLoc(Number(ev.target.value), yt)\n    },\n    [onGotoLoc, yt],\n  )\n\n  const handleChangeY = useCallback(\n    (ev: React.ChangeEvent<HTMLInputElement>) => {\n      onGotoLoc(xt, Number(ev.target.value))\n    },\n    [onGotoLoc, xt],\n  )\n\n  return (\n    <div\n      css={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}\n    >\n      <form css={{ display: 'flex', gap: 8, input: { width: '5ch' } }}>\n        <label htmlFor=\"gotoX\">X:</label>\n        <input\n          type=\"number\"\n          value={xt}\n          onChange={handleChangeX}\n          id=\"gotoX\"\n        ></input>\n        <label htmlFor=\"gotoY\">Y:</label>\n        <input\n          type=\"number\"\n          value={yt}\n          onChange={handleChangeY}\n          id=\"gotoY\"\n        ></input>\n      </form>\n      <button onClick={onNextEmpty}>Go to random tile</button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/moderation/interestingWeights.ts",
    "content": "import { WidgetType } from '../widgets'\n\nexport const interestingWeights: Record<WidgetType, number> = {\n  brick: 1,\n  anvil: 3,\n  attractor: 8,\n  repulsor: 9,\n  board: 5,\n  fan: 10,\n  hammer: 3,\n  sword: 6,\n  leftbumper: 5,\n  rightbumper: 5,\n  roundbumper: 5,\n  hook: 7,\n  lefthook: 8,\n  cushion: 5,\n  spokedwheel: 6,\n  prism: 10,\n  sticker: 5,\n  ballstand: 10,\n  cup: 5,\n  catswat: 10,\n}\n"
  },
  {
    "path": "client/src/components/moderation/modTypes.d.ts",
    "content": "import { components } from '../../generated/api-spec'\n\nexport type ModLocation = {\n  puzzle: string\n  blueprint?: string\n  xt: number\n  yt: number\n}\n\nexport type ServerBlueprint = components['schemas']['Blueprint']\n\nexport type CandidateMap = Map<string, ServerBlueprint>\n\nexport type ModMachine = components['schemas']['VersionedMachine ModData']\n"
  },
  {
    "path": "client/src/components/moderation/modUtils.ts",
    "content": "import { random, sample, sortBy } from 'lodash'\nimport { gridDimensions, iterTiles } from '../../lib/tiles'\nimport { intersectBounds } from '../../lib/utils'\nimport { WidgetCollection } from '../../types'\nimport { interestingWeights } from './interestingWeights'\nimport {\n  CandidateMap,\n  ModLocation,\n  ModMachine,\n  ServerBlueprint,\n} from './modTypes'\n\nexport function getRandEmptyNearTile(\n  modMachine: ModMachine,\n  baseXt: number,\n  baseYt: number,\n  contextWindow: number,\n): ModLocation | undefined {\n  const [tilesX, tilesY] = gridDimensions(modMachine.grid)\n  const nearbyEmpties: ModLocation[] = []\n  for (const [xt, yt] of iterTiles(\n    ...intersectBounds(\n      [baseXt, baseYt, baseXt + contextWindow, baseYt + contextWindow],\n      [0, 0, tilesX - 1, tilesY - 1],\n    ),\n  )) {\n    const tile = modMachine.grid[yt][xt]\n    if (!tile.blueprint && tile.to_mod) {\n      nearbyEmpties.push({ ...modMachine.grid[yt][xt], xt, yt })\n    }\n  }\n  if (nearbyEmpties.length > 0) {\n    return sample(nearbyEmpties)\n  }\n  return undefined\n}\n\nexport function getEmptyTile(modMachine: ModMachine): ModLocation {\n  const [tilesX, tilesY] = gridDimensions(modMachine.grid)\n  const emptyTiles: ModLocation[] = []\n  for (const [xt, yt] of iterTiles(0, 0, tilesX - 1, tilesY - 1)) {\n    const { blueprint } = modMachine.grid[yt][xt]\n    if (blueprint) {\n      const nearbyEmpty = getRandEmptyNearTile(modMachine, xt, yt, 5)\n      if (nearbyEmpty) {\n        return nearbyEmpty\n      }\n    } else {\n      emptyTiles.push({ ...modMachine.grid[yt][xt], xt, yt })\n    }\n  }\n  if (emptyTiles.length > 0) {\n    return emptyTiles[random(emptyTiles.length)]\n  }\n  const xt = random(tilesX)\n  const yt = random(tilesY)\n  return { ...modMachine.grid[yt][xt], xt, yt }\n}\n\nexport function calculateInterest(b: ServerBlueprint): number {\n  const widgets = b.widgets as WidgetCollection\n  let interestingness: number = 0\n  for (const [_, widget] of Object.entries(widgets)) {\n    interestingness += interestingWeights[widget.type]\n  }\n  return interestingness\n}\n\nexport function sortCandidateMap(\n  candidates: CandidateMap,\n  currentBlueprintId: string | undefined,\n): Array<[string, ServerBlueprint]> {\n  return sortBy([...candidates.entries()], ([blueprintId, blueprint]) =>\n    blueprintId === currentBlueprintId\n      ? -Infinity\n      : -calculateInterest(blueprint),\n  )\n}\n\nexport function locFromPosition(\n  grid: ModMachine['grid'],\n  xt: number,\n  yt: number,\n) {\n  return {\n    ...grid[yt][xt],\n    xt: xt,\n    yt: yt,\n  }\n}\n"
  },
  {
    "path": "client/src/components/moderation/moderatorClient.ts",
    "content": "import {\n  queryOptions,\n  useMutation,\n  useQueries,\n  useQuery,\n} from '@tanstack/react-query'\nimport { apiClient, queryClient } from '../../api'\nimport { MachineSnapshot } from '../../lib/snapshot'\nimport { blueprintQueryOptions } from '../useMetaMachineClient'\nimport { CandidateMap } from './modTypes'\n\nexport function puzzleQueryOptions(puzzleId: string | undefined) {\n  return queryOptions({\n    enabled: puzzleId != null,\n    queryKey: ['moderator', 'puzzle', puzzleId],\n    queryFn: async ({ signal }) => {\n      const { data } = await apiClient.GET('/moderate/puzzle/{puzzleid}', {\n        params: {\n          path: {\n            puzzleid: puzzleId!,\n          },\n        },\n        credentials: 'include',\n        signal,\n      })\n      return data\n    },\n    staleTime: Infinity,\n  })\n}\n\nexport function useModeratorMachine() {\n  return useQuery({\n    queryKey: ['moderator', 'machine'],\n    queryFn: async ({ signal }) => {\n      const { data } = await apiClient.GET('/moderate/machine/current', {\n        credentials: 'include',\n        signal,\n      })\n      return data\n    },\n    staleTime: 30 * 1000,\n  })\n}\n\nexport function useCandidateBlueprints({ puzzleId }: { puzzleId: string }) {\n  return useQuery<CandidateMap>({\n    queryKey: ['moderator', 'blueprints', puzzleId],\n    queryFn: async ({ signal }) => {\n      const { data } = await apiClient.GET(\n        '/moderate/puzzle/{puzzleid}/blueprint',\n        {\n          params: {\n            path: {\n              puzzleid: puzzleId,\n            },\n          },\n          credentials: 'include',\n          signal,\n        },\n      )\n      return new Map(data)\n    },\n  })\n}\n\nexport function useBlueprint(blueprintId: string | undefined) {\n  return useQuery(blueprintQueryOptions(blueprintId))\n}\n\nexport function useContextBlueprints(blueprintIds: Array<string | undefined>) {\n  return useQueries({\n    queries: blueprintIds.map((id) => blueprintQueryOptions(id)),\n  })\n}\n\nexport function useContextPuzzles(puzzleIds: Array<string | undefined>) {\n  return useQueries({\n    queries: puzzleIds.map(puzzleQueryOptions),\n  })\n}\n\nexport function useApproveBlueprint() {\n  return useMutation({\n    mutationFn: ({\n      xt,\n      yt,\n      blueprintId,\n      snapshot,\n    }: {\n      xt: number\n      yt: number\n      blueprintId: string\n      snapshot: MachineSnapshot\n    }) => {\n      return apiClient.POST('/moderate/build/{X}/{Y}', {\n        params: {\n          path: {\n            X: xt,\n            Y: yt,\n          },\n        },\n        body: {\n          blueprint: blueprintId,\n          snapshot: snapshot as unknown as Record<string, unknown>,\n        },\n        credentials: 'include',\n      })\n    },\n    onSuccess: () => {\n      void queryClient.invalidateQueries({ queryKey: ['moderator', 'machine'] })\n    },\n  })\n}\n\nexport function useBurnBlueprint() {\n  return useMutation({\n    mutationFn: ({\n      blueprintId,\n    }: {\n      puzzleId: string\n      blueprintId: string\n    }) => {\n      return apiClient.POST('/moderate/burn/{blueprintid}', {\n        params: {\n          path: {\n            blueprintid: blueprintId,\n          },\n        },\n        credentials: 'include',\n      })\n    },\n    onSuccess: (_data, { puzzleId }) => {\n      void queryClient.invalidateQueries({\n        queryKey: ['moderator', 'blueprints', puzzleId],\n      })\n    },\n  })\n}\n\nexport function useReissuePuzzle() {\n  return useMutation({\n    mutationFn: ({ puzzleId }: { puzzleId: string }) => {\n      return apiClient.POST('/moderate/puzzle/{puzzleid}/reissue', {\n        params: {\n          path: {\n            puzzleid: puzzleId,\n          },\n        },\n        credentials: 'include',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "client/src/components/positionStyles.ts",
    "content": "import type { RigidBody } from '@dimforge/rapier2d'\nimport {\n  MutableRefObject,\n  RefObject,\n  useCallback,\n  useEffect,\n  useRef,\n} from 'react'\nimport { coords } from '../lib/coords'\nimport { inBoundsObject, px } from '../lib/utils'\nimport { useMachine } from './MachineContext'\nimport { useLoopHandler } from './PhysicsContext'\n\nexport function getPositionStyles(x: number, y: number, angle: number = 0) {\n  return {\n    position: 'absolute' as const,\n    top: 0,\n    left: 0,\n    transform: `translate(${px(x)}, ${px(y)}) translate(-50%, -50%) rotate(${angle}rad)`,\n  }\n}\n\nexport function usePositionedBodyRef<T extends HTMLElement>(\n  bodyRef: RefObject<RigidBody>,\n  {\n    width,\n    height,\n    initialX,\n    initialY,\n    initialAngle,\n    xBasis = 0,\n    yBasis = 0,\n  }: {\n    width: number\n    height: number\n    initialX: number\n    initialY: number\n    initialAngle?: number\n    xBasis?: number\n    yBasis?: number\n  },\n): MutableRefObject<T | null> {\n  const elRef = useRef<T>(null)\n\n  const { viewBoundsRef } = useMachine()\n\n  const wasVisibleRef = useRef(true)\n\n  const updateStyle = useCallback(() => {\n    const { current: el } = elRef\n    const { current: body } = bodyRef\n\n    if (!el) {\n      return\n    }\n\n    const [bodyX, bodyY] = body ? coords.fromBody.vector(body) : []\n    const x = bodyX ?? initialX\n    const y = bodyY ?? initialY\n    if (x == null || y == null) {\n      el.style.display = 'none'\n      wasVisibleRef.current = false\n      return\n    }\n\n    const isVisible = inBoundsObject(x, y, width, height, viewBoundsRef.current)\n    if (isVisible != wasVisibleRef.current) {\n      el.style.display = isVisible ? 'block' : 'none'\n    }\n    if (isVisible) {\n      Object.assign(\n        el.style,\n        getPositionStyles(\n          x - xBasis,\n          y - yBasis,\n          body ? coords.fromBody.angle(body) : initialAngle,\n        ),\n      )\n    }\n    wasVisibleRef.current = isVisible\n  }, [\n    bodyRef,\n    initialX,\n    initialY,\n    initialAngle,\n    width,\n    height,\n    viewBoundsRef,\n    xBasis,\n    yBasis,\n  ])\n\n  useEffect(updateStyle)\n  useLoopHandler(updateStyle, [updateStyle])\n\n  return elRef\n}\n"
  },
  {
    "path": "client/src/components/useLocationHashParams.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react'\n\nexport function paramToNumber(text: string | null | undefined) {\n  if (text == null) {\n    return undefined\n  }\n\n  const val = Number(text)\n  if (isNaN(val)) {\n    return undefined\n  }\n\n  return val\n}\n\nexport function useLocationHashParams() {\n  const [, triggerUpdate] = useState(0)\n\n  useEffect(() => {\n    function handleHashChange() {\n      triggerUpdate((x) => x + 1)\n    }\n    window.addEventListener('hashchange', handleHashChange)\n    return () => {\n      window.removeEventListener('hashchange', handleHashChange)\n    }\n  }, [])\n\n  const params = useMemo(\n    () => new URLSearchParams(location.hash.slice(1)),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [location.hash],\n  )\n\n  const setLocationHashParams = useCallback(\n    (data: Record<string, string | null | undefined>) => {\n      const params = new URLSearchParams()\n      for (const [k, v] of Object.entries(data)) {\n        if (v == null) {\n          continue\n        }\n        params.set(k, v)\n      }\n\n      const newHash = new URLSearchParams(params).toString()\n      const currentURLWithoutHash = window.location.href.split('#')[0]\n      window.location.replace(`${currentURLWithoutHash}#${newHash}`)\n    },\n    [],\n  )\n\n  return {\n    locationHashParams: params,\n    setLocationHashParams,\n  }\n}\n"
  },
  {
    "path": "client/src/components/useMetaMachineClient.ts",
    "content": "import {\n  queryOptions,\n  useMutation,\n  useQueries,\n  useQuery,\n} from '@tanstack/react-query'\nimport { dropRightWhile, isNull } from 'lodash'\nimport { useCallback, useMemo } from 'react'\nimport invariant from 'tiny-invariant'\nimport { apiClient, queryClient } from '../api'\nimport { MachineSnapshot } from '../lib/snapshot'\nimport {\n  gridDimensions,\n  gridViewBounds,\n  iterTiles,\n  tileKey,\n} from '../lib/tiles'\nimport { Bounds, Puzzle, PuzzleOrder, WidgetCollection } from '../types'\nimport { WidgetData } from './widgets'\n\nexport interface TileData {\n  blueprintId: string\n  title: string\n  puzzle: Puzzle\n  widgets: WidgetCollection\n  snapshot?: MachineSnapshot\n}\n\nexport type GetMachineFunction = (\n  xt: number,\n  yt: number,\n) => TileData | undefined\n\nexport interface MetaMachineInfo {\n  version: number\n  tileWidth: number\n  tileHeight: number\n  tilesX: number\n  tilesY: number\n  getMachine: GetMachineFunction\n  hasBlueprint: (xt: number, yt: number) => boolean\n  msPerBall: number\n}\n\nexport interface MetaMachineClient {\n  isLoading: boolean\n  allTilesLoaded: boolean\n  metaMachine: MetaMachineInfo | null\n}\n\nexport interface SavedBlueprintLocation {\n  blueprintId: string\n  xt: number\n  yt: number\n}\n\nexport type SavedMachine = SavedBlueprintLocation & TileData\n\nexport function blueprintQueryOptions(blueprintid: string | undefined) {\n  return queryOptions({\n    enabled: blueprintid != null,\n    queryKey: ['folio', blueprintid],\n    queryFn: async ({ signal }) => {\n      invariant(blueprintid, 'blueprintid must be non-null')\n      const { data } = await apiClient.GET('/folio/{blueprintid}', {\n        params: {\n          path: {\n            blueprintid,\n          },\n        },\n        signal,\n      })\n      return data\n    },\n    staleTime: Infinity,\n  })\n}\n\nexport function useMetaMachineClient({\n  viewBounds,\n  lastSubmission,\n  version: fetchVersion,\n  fetchOutset = 2000,\n}: {\n  viewBounds: Bounds\n  lastSubmission?: SavedMachine | undefined\n  version?: number\n  fetchOutset?: number\n}): MetaMachineClient {\n  const { data: machineData, isLoading } = useQuery({\n    queryKey: ['machine', fetchVersion ?? 'current'],\n    queryFn: async ({ signal }) => {\n      if (fetchVersion != null) {\n        const { data } = await apiClient.GET('/machine/{version}', {\n          params: { path: { version: fetchVersion } },\n          signal,\n        })\n        return data\n      } else {\n        const { data } = await apiClient.GET('/machine/current', { signal })\n        return data\n      }\n    },\n  })\n\n  const tileWidth = machineData?.tile_size.x ?? 0\n  const tileHeight = machineData?.tile_size.y ?? 0\n  const msPerBall = machineData?.ms_per_ball ?? Infinity\n  const version = machineData?.version ?? 0\n\n  const trimmedGrid = machineData?.grid\n    ? dropRightWhile(machineData.grid, (row, idx) => {\n        if (idx === 0) {\n          return false\n        }\n        if (lastSubmission && idx <= lastSubmission.yt) {\n          return false\n        }\n        return row.every(isNull)\n      })\n    : []\n\n  const [tilesX, tilesY] = machineData ? gridDimensions(trimmedGrid) : [0, 0]\n\n  const tileBounds: Bounds = machineData\n    ? gridViewBounds(\n        viewBounds,\n        tilesX,\n        tilesY,\n        tileWidth,\n        tileHeight,\n        fetchOutset,\n      )\n    : [0, 0, 0, 0]\n\n  const tiles = [...iterTiles(...tileBounds)]\n  const { sparseTileData, allTilesLoaded } = useQueries({\n    queries: machineData\n      ? tiles.map(([xt, yt]) => blueprintQueryOptions(trimmedGrid[yt][xt]))\n      : [],\n    combine: (results) => {\n      const sparseTileData: Record<string, TileData> = {}\n      let allTilesLoaded = results.length > 0\n      for (let i = 0; i < results.length; i++) {\n        allTilesLoaded &&= !results[i].isLoading\n\n        const data = results[i].data\n        const [xt, yt] = tiles[i]\n        if (!data) {\n          continue\n        }\n\n        sparseTileData[tileKey(xt, yt)] = {\n          blueprintId: trimmedGrid[yt][xt],\n          title: data.blueprint.title,\n          puzzle: data.puzzle,\n          widgets: data.blueprint.widgets as Record<string, WidgetData>,\n          snapshot: data.snapshot as unknown as MachineSnapshot,\n        }\n      }\n      return { sparseTileData, allTilesLoaded }\n    },\n  })\n\n  const getMachine = useCallback(\n    (xt: number, yt: number) =>\n      xt === lastSubmission?.xt && yt === lastSubmission?.yt\n        ? lastSubmission\n        : sparseTileData[tileKey(xt, yt)],\n    [lastSubmission, sparseTileData],\n  )\n\n  const hasBlueprint = useCallback(\n    (xt: number, yt: number) => machineData?.grid[yt][xt] != null,\n    [machineData?.grid],\n  )\n\n  return useMemo(\n    () => ({\n      isLoading,\n      allTilesLoaded,\n      metaMachine: machineData\n        ? {\n            version,\n            tilesX,\n            tilesY,\n            tileWidth,\n            tileHeight,\n            getMachine,\n            hasBlueprint,\n            msPerBall,\n          }\n        : null,\n    }),\n    [\n      allTilesLoaded,\n      getMachine,\n      hasBlueprint,\n      isLoading,\n      machineData,\n      msPerBall,\n      tileHeight,\n      tileWidth,\n      tilesX,\n      tilesY,\n      version,\n    ],\n  )\n}\n\nexport function useGetPuzzles(): PuzzleOrder[] | undefined {\n  const { data, isStale } = useQuery({\n    queryKey: ['puzzle'],\n    queryFn: async ({ signal }) => {\n      const { data, response } = await apiClient.GET('/puzzle', {\n        signal,\n      })\n\n      const workOrder = response.headers.get('X-WorkOrder')\n      if (!workOrder) {\n        return\n      }\n\n      const puzzles = data\n        ? Object.entries(data).map(([puzzleId, puzzleData]) => ({\n            id: puzzleId,\n            workOrder: workOrder,\n            inputs: puzzleData.inputs,\n            outputs: puzzleData.outputs,\n          }))\n        : undefined\n\n      return puzzles\n    },\n    staleTime: Infinity,\n  })\n\n  return !isStale ? data : undefined\n}\n\nexport function useSubmitBlueprint() {\n  return useMutation({\n    mutationFn: async ({\n      puzzleId,\n      workOrder,\n      title,\n      widgets,\n    }: {\n      puzzleId: string\n      workOrder: string\n      title: string\n      widgets: WidgetCollection\n    }) => {\n      const { data } = await apiClient.POST('/blueprint/file', {\n        body: { puzzle: puzzleId, title, widgets },\n        headers: { 'X-WorkOrder': workOrder },\n      })\n\n      if (!data) {\n        return null\n      }\n\n      const [blueprintId, [xt, yt]] = data\n      return { blueprintId, xt, yt }\n    },\n    onSuccess: () => {\n      void queryClient.invalidateQueries({ queryKey: ['puzzle'] })\n    },\n  })\n}\n"
  },
  {
    "path": "client/src/components/widgets/Anvil.tsx",
    "content": "import imgAnvil from '@art/anvil_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface AnvilWidget extends Vector, Angled {\n  type: 'anvil'\n}\n\nexport function AnvilPreview() {\n  return <ComicImage img={imgAnvil} css={{ width: '70%', height: 'auto' }} />\n}\n\nexport function Anvil({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: AnvilWidget & EditableWidget) {\n  const width = imgAnvil.width\n  const height = imgAnvil.height\n\n  useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Top\n          ColliderDesc.triangle(\n            coords.toRapier.vectorObject(-width / 2, -height / 2),\n            coords.toRapier.vectorObject(width / 2, -height / 2),\n            coords.toRapier.vectorObject(0, 0),\n          ).setRestitution(0.7),\n\n          // Bottom\n          ColliderDesc.triangle(\n            coords.toRapier.vectorObject(-23, height / 2),\n            coords.toRapier.vectorObject(34, height / 2),\n            coords.toRapier.vectorObject(-14, 0),\n          ).setRestitution(0.7),\n\n          // Center\n          ColliderDesc.cuboid(...coords.toRapier.lengths(11, 10))\n            .setTranslation(...coords.toRapier.vector(-6, 0))\n            .setRestitution(0.7),\n        ],\n      }\n    },\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgAnvil}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/AttractorRepulsor.tsx",
    "content": "import imgAttractor from '@art/attractor_4x.png'\nimport imgRepulsor from '@art/repulsor_4x.png'\nimport { useState } from 'react'\nimport { coords, vectorAngle, vectorDistance } from '../../lib/coords'\nimport { Sized, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useSensorInTile } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider, useLoopHandler } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface AttractorWidget extends Vector, Sized {\n  type: 'attractor'\n}\n\nexport interface RepulsorWidget extends Vector, Sized {\n  type: 'repulsor'\n}\n\nexport function AttractorPreview() {\n  return <ComicImage img={imgAttractor} />\n}\n\nexport function RepulsorPreview() {\n  return <ComicImage img={imgRepulsor} />\n}\n\nconst startTime = performance.now()\n\nexport function AttractorRepulsor({\n  id,\n  onSelect,\n  isSelected,\n  className,\n  x,\n  y,\n  width,\n  strength,\n}: Vector & Sized & EditableWidget & { className?: string; strength: number }) {\n  const fieldSize = width\n  const radius = 10\n\n  const img = strength < 0 ? imgAttractor : imgRepulsor\n\n  const boxCollider = useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.ball(coords.toRapier.length(radius))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRestitution(0.5),\n    [radius, x, y],\n  )\n\n  const fieldCollider = useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.ball(coords.toRapier.length(fieldSize))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setSensor(true),\n    [fieldSize, x, y],\n  )\n\n  const falloffDistance = coords.toRapier.length(fieldSize)\n  const [angle, setAngle] = useState(0)\n  const [scale, setScale] = useState(1)\n\n  useSensorInTile(\n    fieldCollider,\n    (otherCollider) => {\n      const body = otherCollider.parent()\n      if (!boxCollider || !boxCollider.isValid() || !body) {\n        return\n      }\n\n      const distance = vectorDistance(\n        boxCollider.translation(),\n        body.translation(),\n      )\n\n      const angle = vectorAngle(boxCollider.translation(), body.translation())\n\n      const falloff = Math.max(\n        0,\n        Math.pow((falloffDistance - distance) / falloffDistance, 2),\n      )\n\n      const forceVector = {\n        x: strength * falloff * Math.cos(angle),\n        y: strength * falloff * Math.sin(angle),\n      }\n\n      body.applyImpulse(forceVector, true)\n    },\n    [boxCollider, falloffDistance, strength],\n  )\n\n  useLoopHandler(() => {\n    const currentTime = performance.now()\n\n    if (strength < 0) {\n      setAngle(((360 * (currentTime - startTime)) / 4000) % 360)\n      setScale(1 + 0.08 * Math.sin((Math.PI * (currentTime - startTime)) / 900))\n    } else {\n      setAngle(360 - (((360 * (currentTime - startTime)) / 20000) % 360))\n      setScale(1 + 0.05 * Math.sin((Math.PI * (currentTime - startTime)) / 500))\n    }\n  }, [strength])\n\n  return (\n    <div\n      {...useSelectHandlers(id, onSelect)}\n      css={{\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        background: isSelected\n          ? 'radial-gradient(circle closest-side at center, transparent, rgba(0, 0, 0, .25))'\n          : 'transparent',\n        width: fieldSize,\n        height: fieldSize,\n        borderRadius: '100%',\n      }}\n      style={getPositionStyles(x, y, 0)}\n    >\n      <ComicImage\n        img={img}\n        style={{\n          transform: `rotate(-${angle}deg) scale(${scale})`,\n        }}\n      />\n      <div\n        css={{\n          position: 'absolute',\n          width: radius,\n          height: radius,\n        }}\n        className={className}\n        style={{\n          borderRadius: '100%',\n        }}\n      />\n    </div>\n  )\n}\n\nexport function Attractor(props: AttractorWidget & EditableWidget) {\n  return (\n    <AttractorRepulsor css={{}} {...props} strength={-0.1}></AttractorRepulsor>\n  )\n}\n\nexport function Repulsor(props: RepulsorWidget & EditableWidget) {\n  return <AttractorRepulsor css={{}} {...props} strength={0.5} />\n}\n"
  },
  {
    "path": "client/src/components/widgets/BallStand.tsx",
    "content": "import imgBallStand from '@art/ball-stand_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface BallStandWidget extends Vector, Angled {\n  type: 'ballstand'\n}\n\nexport function BallStandPreview() {\n  return (\n    <ComicImage img={imgBallStand} css={{ width: 'auto', height: '80%' }} />\n  )\n}\n\nexport function BallStand({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: BallStandWidget & EditableWidget) {\n  const width = imgBallStand.width\n  const height = imgBallStand.height\n\n  useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Stand\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 4, height / 2 - 6),\n          )\n            .setTranslation(...coords.toRapier.vector(0, 4))\n            .setRestitution(0.75),\n\n          // Base\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 2, height / 12),\n          )\n            .setTranslation(\n              ...coords.toRapier.vector(0, height / 2 - height / 12),\n            )\n            .setRestitution(0.75),\n\n          // Top left\n          ColliderDesc.triangle(\n            coords.toRapier.vectorObject(width / 5, -height / 2),\n            coords.toRapier.vectorObject(0, -height / 2 + 10),\n            coords.toRapier.vectorObject(0, -height / 2 + 4),\n          ).setRestitution(0.1),\n\n          // Top right\n          ColliderDesc.triangle(\n            coords.toRapier.vectorObject(-width / 5, -height / 2),\n            coords.toRapier.vectorObject(0, -height / 2 + 10),\n            coords.toRapier.vectorObject(0, -height / 2 + 4),\n          ).setRestitution(0.1),\n        ],\n      }\n    },\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgBallStand}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Balls.tsx",
    "content": "import ballBlueImg from '@art/ball-blue_4x.png'\nimport ballGreenImg from '@art/ball-green_4x.png'\nimport ballRedImg from '@art/ball-red_4x.png'\nimport ballYellowImg from '@art/ball-yellow_4x.png'\nimport type RAPIER from '@dimforge/rapier2d'\nimport type { World } from '@dimforge/rapier2d'\nimport { ClassNames, css } from '@emotion/react'\nimport useLatest from '@react-hook/latest'\nimport { sample } from 'lodash'\nimport { MutableRefObject, useRef } from 'react'\nimport { coords } from '../../lib/coords'\nimport { inBoundsOutset, px } from '../../lib/utils'\nimport { UserData } from '../../types'\nimport {\n  BallData,\n  BallDestroyReason,\n  BallFollowCallback,\n  MachineContextType,\n  useMachine,\n} from '../MachineContext'\nimport { useRapierEffect } from '../PhysicsContext'\nimport {\n  BALL_RADIUS,\n  BOTTOM_CHUTE_DROP,\n  BOTTOM_CHUTE_HEIGHT,\n} from '../constants'\n\ntype BallRecord = BallData & { el: HTMLDivElement }\n\ninterface BallActor {\n  step: () => void\n  destroy: (reason?: BallDestroyReason) => void\n  exit: () => void\n  renew: () => void\n  die: () => void\n  follow: (callback: BallFollowCallback) => void\n  unfollow: () => void\n}\n\nexport const BASE_BALL_LIFETIME_TICKS = 30 * 60 // 30 seconds at 60tps\n\nconst ballStyles = css({\n  position: 'absolute',\n  left: 0,\n  top: 0,\n  pointerEvents: 'none',\n  width: 2 * BALL_RADIUS,\n  height: 2 * BALL_RADIUS,\n  backgroundSize: 'contain',\n  zIndex: 5,\n  contain: 'strict',\n  '&.blue': {\n    backgroundImage: `url(${ballBlueImg.url['2x']})`,\n  },\n  '&.red': {\n    backgroundImage: `url(${ballRedImg.url['2x']})`,\n  },\n  '&.green': {\n    backgroundImage: `url(${ballGreenImg.url['2x']})`,\n  },\n  '&.yellow': {\n    backgroundImage: `url(${ballYellowImg.url['2x']})`,\n  },\n})\n\nexport const ballClassNames = ['blue', 'red', 'green', 'yellow']\n\nfunction runBall(\n  { id, type, age, snapshot, overrideDamping, el }: BallRecord,\n  ballClassName: string,\n  { RigidBodyDesc, RigidBodyType, ColliderDesc }: typeof RAPIER,\n  world: World,\n  machineRef: MutableRefObject<MachineContextType>,\n  getCurrentTick: () => number,\n  lifetimeTicks: number,\n): BallActor {\n  el.className = ballClassName\n\n  const bodyDesc = new RigidBodyDesc(RigidBodyType.Dynamic)\n    .setTranslation(snapshot.x, snapshot.y)\n    .setRotation(snapshot.angle)\n    .setLinvel(snapshot.vx, snapshot.vy)\n    .setAngvel(snapshot.va)\n    .setCcdEnabled(true)\n    .setUserData({ type: 'BallData', id, ballType: type } satisfies UserData)\n\n  const colliderDesc = ColliderDesc.ball(coords.toRapier.length(BALL_RADIUS))\n\n  if (type === 2) {\n    colliderDesc.setRestitution(0.8).setDensity(1)\n  }\n\n  if (type === 3) {\n    colliderDesc.setMass(0.75)\n  }\n\n  if (type === 4) {\n    bodyDesc.setLinearDamping(2)\n    colliderDesc.setDensity(0.3).setRestitution(0.6)\n  }\n\n  if (overrideDamping != null) {\n    bodyDesc.setLinearDamping(overrideDamping)\n  }\n\n  const body = world.createRigidBody(bodyDesc)\n  const collider = world.createCollider(colliderDesc, body)\n\n  let wasVisible = true\n  let isDying = false\n  let isExiting = false\n  let followCallback: BallFollowCallback | null = null\n  let renewTick = getCurrentTick() - age\n\n  machineRef.current.registerBall(id, type, body, renewTick)\n\n  const actor: BallActor = {\n    step() {\n      const { simulationBoundsRef, viewBoundsRef } = machineRef.current\n\n      const ballAge = getCurrentTick() - renewTick\n      if (ballAge >= lifetimeTicks && !followCallback) {\n        this.die()\n      }\n\n      const [x, y] = coords.fromBody.vector(body)\n\n      followCallback?.(x, y)\n\n      if (\n        !inBoundsOutset(\n          x,\n          y,\n          BOTTOM_CHUTE_DROP + BOTTOM_CHUTE_HEIGHT,\n          simulationBoundsRef.current,\n        )\n      ) {\n        machineRef.current?.destroyBall(id)\n        el.style.display = 'none'\n        return\n      }\n\n      const isVisible = inBoundsOutset(x, y, BALL_RADIUS, viewBoundsRef.current)\n\n      if (isVisible != wasVisible) {\n        el.style.display = isVisible ? 'block' : 'none'\n      }\n      if (!isVisible && isExiting) {\n        machineRef.current?.destroyBall(id)\n        return\n      }\n      if (isVisible) {\n        el.style.transform = `translate(${px(x - BALL_RADIUS)}, ${px(y - BALL_RADIUS)})`\n      }\n      wasVisible = isVisible\n    },\n\n    destroy() {\n      el.remove()\n      machineRef.current.unregisterBall(id)\n\n      // Immediately destroying the body seems to cause panics when iterating over colliders.\n      setTimeout(() => {\n        world.removeRigidBody(body)\n      }, 0)\n    },\n\n    exit() {\n      isExiting = true\n    },\n\n    renew() {\n      renewTick = getCurrentTick()\n      machineRef.current.registerBall(id, type, body, renewTick)\n    },\n\n    die() {\n      if (isDying) {\n        return\n      }\n      isDying = true\n\n      el.style.transition = 'opacity 200ms ease-in'\n      el.style.opacity = '0'\n\n      setTimeout(() => {\n        world.removeCollider(collider, false)\n      }, 0)\n\n      setTimeout(() => {\n        machineRef.current?.destroyBall(id, 'expiry')\n      }, 200)\n    },\n\n    follow(callback: BallFollowCallback) {\n      followCallback = callback\n    },\n\n    unfollow() {\n      followCallback = null\n    },\n  }\n\n  actor.step()\n\n  return actor\n}\n\nexport function BallsRunner({\n  lifetimeTicks,\n  ballClassName,\n}: {\n  lifetimeTicks: number\n  ballClassName: string\n}) {\n  const machine = useMachine()\n  const parentRef = useRef<HTMLDivElement>(null)\n\n  const { events } = machine\n  const machineRef = useLatest(machine)\n\n  useRapierEffect(\n    ({ events: worldEvents, rapier, world, getCurrentTick }) => {\n      const actors: Record<string, BallActor> = {}\n      let followingId: string | undefined = undefined\n\n      function handleCreateBall(ball: BallData) {\n        const el = document.createElement('div')\n        actors[ball.id] = runBall(\n          { ...ball, el },\n          `${ballClassName} ${ballClassNames[ball.type - 1]}`,\n          rapier,\n          world,\n          machineRef,\n          getCurrentTick,\n          lifetimeTicks,\n        )\n        parentRef.current?.appendChild(el)\n      }\n\n      function handleDestroyBall(id: string) {\n        actors[id]?.destroy()\n        delete actors[id]\n      }\n\n      function handleExitBall(id: string) {\n        actors[id]?.exit()\n      }\n\n      function handleRenewBall(id: string) {\n        actors[id]?.renew()\n      }\n\n      function handleKillBall(id: string) {\n        actors[id]?.die()\n      }\n\n      function handleFollowBall(callback: BallFollowCallback) {\n        handleUnfollowBall()\n        followingId = sample(Object.keys(actors))\n        if (!followingId) {\n          return\n        }\n        actors[followingId].follow(callback)\n      }\n\n      function handleUnfollowBall() {\n        if (followingId && actors[followingId]) {\n          actors[followingId].unfollow()\n        }\n      }\n\n      function handleStep() {\n        Object.values(actors).forEach((actor) => actor.step())\n      }\n\n      events.on('createBall', handleCreateBall)\n      events.on('destroyBall', handleDestroyBall)\n      events.on('exitBall', handleExitBall)\n      events.on('renewBall', handleRenewBall)\n      events.on('killBall', handleKillBall)\n      events.on('followBall', handleFollowBall)\n      events.on('unfollowBall', handleUnfollowBall)\n      worldEvents.on('step', handleStep)\n\n      return () => {\n        events.off('createBall', handleCreateBall)\n        events.off('destroyBall', handleDestroyBall)\n        events.off('exitBall', handleExitBall)\n        events.off('renewBall', handleRenewBall)\n        events.off('killBall', handleKillBall)\n        events.off('followBall', handleFollowBall)\n        events.off('unfollowBall', handleUnfollowBall)\n        worldEvents.off('step', handleStep)\n        Object.values(actors).forEach((actor) => actor.destroy())\n      }\n    },\n    [ballClassName, events, lifetimeTicks, machineRef],\n  )\n\n  return <div ref={parentRef} />\n}\n\nexport function Balls({\n  lifetimeTicks = BASE_BALL_LIFETIME_TICKS,\n}: {\n  lifetimeTicks?: number\n}) {\n  return (\n    <ClassNames>\n      {({ css }) => (\n        <BallsRunner\n          lifetimeTicks={lifetimeTicks}\n          ballClassName={css(ballStyles)}\n        />\n      )}\n    </ClassNames>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Board.tsx",
    "content": "import imgBoard from '@art/board_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface BoardWidget extends Vector, Angled {\n  type: 'board'\n}\n\nexport function BoardPreview() {\n  return (\n    <ComicImage\n      img={imgBoard}\n      css={{ width: '100%', height: 'auto', transform: 'rotate(-45deg)' }}\n    />\n  )\n}\n\nexport function Board({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: BoardWidget & EditableWidget) {\n  const width = imgBoard.width\n  const height = imgBoard.height\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setRestitution(0.5),\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgBoard}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Boat.tsx",
    "content": "import boatImage from '@art/boat_4x.png'\nimport { type Vector } from '@dimforge/rapier2d'\nimport { coords } from '../../lib/coords'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget } from '../MachineTileEditor'\nimport { useLoopHandler, useRapierEffect } from '../PhysicsContext'\nimport { getPositionStyles, usePositionedBodyRef } from '../positionStyles'\n\nexport default function Boat({ id, x, y }: Vector & EditableWidget) {\n  const radius = 30\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: id,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic)\n          .setLinearDamping(6)\n          .setAngularDamping(2),\n        colliderDescs: [\n          // Weight\n          ColliderDesc.capsule(\n            coords.toRapier.length(boatImage.width / 3 - radius),\n            coords.toRapier.length(radius),\n          )\n            .setTranslation(...coords.toRapier.vector(30, 45))\n            .setRotation(Math.PI / 2)\n            .setMass(12),\n\n          // Sensor\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(\n              boatImage.width / 3,\n              boatImage.height / 2,\n            ),\n          )\n            .setTranslation(...coords.toRapier.vector(0, 10))\n            .setSensor(true),\n        ],\n      }\n    },\n    [id],\n  )\n\n  useRapierEffect(() => {\n    const { current: body } = bodyRef\n    if (!body) {\n      return\n    }\n\n    body.setTranslation(coords.toRapier.vectorObject(x, y), true)\n  }, [bodyRef, x, y])\n\n  useLoopHandler(\n    ({ world }) => {\n      const { current: body } = bodyRef\n      if (!body) {\n        return\n      }\n\n      const rotation = body.rotation()\n      const springConstant = -1\n      body.resetTorques(true)\n      body.applyTorqueImpulse(rotation * springConstant, true)\n\n      let countTouchingWater = 0\n      world.intersectionPairsWith(body.collider(1), () => {\n        countTouchingWater++\n      })\n\n      if (countTouchingWater) {\n        body.applyImpulse(\n          { x: 0, y: coords.toRapier.y(-6 * countTouchingWater) },\n          true,\n        )\n      }\n    },\n    [bodyRef],\n  )\n\n  return (\n    <ComicImage\n      ref={usePositionedBodyRef(bodyRef, {\n        width: boatImage.width,\n        height: boatImage.height,\n        initialX: x,\n        initialY: y,\n      })}\n      css={{ zIndex: 20 }}\n      style={getPositionStyles(x, y)}\n      img={boatImage}\n    ></ComicImage>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/BottomChute.tsx",
    "content": "import imgBottomChute from '@art/chute-0v_4x.png'\nimport imgBottomChute25L from '@art/chute-25l_4x.png'\nimport imgBottomChute25R from '@art/chute-25r_4x.png'\nimport imgBottomChute50L from '@art/chute-50l_4x.png'\nimport imgBottomChute50R from '@art/chute-50r_4x.png'\nimport imgBottomChute75L from '@art/chute-75l_4x.png'\nimport imgBottomChute75R from '@art/chute-75r_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Ball, Vector, isBall } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useMachine } from '../MachineContext'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\nimport { BOTTOM_CHUTE_EXIT_OFFSET } from '../constants'\nimport { getPositionStyles } from '../positionStyles'\n\nexport function BottomChute({\n  x,\n  y,\n  angle,\n  onReceiveBall,\n}: {\n  onReceiveBall: (ball: Ball) => void\n} & Angled &\n  Vector) {\n  const { destroyBall } = useMachine()\n\n  const width = imgBottomChute.width\n  const height = imgBottomChute.height\n\n  const sensorCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 8))\n        .setTranslation(\n          ...coords.toRapier.vector(x, y + BOTTOM_CHUTE_EXIT_OFFSET),\n        )\n        .setSensor(true)\n        .setActiveEvents(ActiveEvents.COLLISION_EVENTS),\n    [height, width, x, y],\n  )\n\n  useCollisionHandler(\n    'start',\n    sensorCollider,\n    (otherCollider) => {\n      const body = otherCollider.parent()\n      if (!body || !isBall(body)) {\n        return\n      }\n\n      onReceiveBall(body)\n\n      destroyBall(body.userData.id)\n    },\n    [],\n  )\n\n  const angleDeg = angle * (180 / Math.PI) - 90\n  let img = imgBottomChute\n  if (angleDeg < -40) {\n    img = imgBottomChute75R\n  } else if (angleDeg < -10) {\n    img = imgBottomChute50R\n  } else if (angleDeg < -2) {\n    img = imgBottomChute25R\n  } else if (angleDeg < 2) {\n    img = imgBottomChute\n  } else if (angleDeg < 10) {\n    img = imgBottomChute25L\n  } else if (angleDeg < 40) {\n    img = imgBottomChute50L\n  } else {\n    img = imgBottomChute75L\n  }\n\n  return (\n    <ComicImage\n      img={img}\n      css={{ zIndex: 20 }}\n      style={getPositionStyles(x, y)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/BottomPit.tsx",
    "content": "import imgBottomPit from '@art/bottom_pit_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useCollider } from '../PhysicsContext'\nimport {\n  BOTTOM_PIT_EDGE_WIDTH,\n  BOTTOM_PIT_HEIGHT,\n  BOTTOM_PIT_WIDTH,\n} from '../constants'\nimport { getPositionStyles } from '../positionStyles'\n\nexport function BottomPit({ x, y }: Vector) {\n  const width = BOTTOM_PIT_WIDTH\n  const height = BOTTOM_PIT_HEIGHT\n  const edgeWidth = BOTTOM_PIT_EDGE_WIDTH\n  const bottomHeight = 92\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2))\n        .setTranslation(\n          ...coords.toRapier.vector(x - width / 2 + edgeWidth / 2, y),\n        )\n        .setRestitution(0.1),\n    [edgeWidth, height, width, x, y],\n  )\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2))\n        .setTranslation(\n          ...coords.toRapier.vector(x + width / 2 - edgeWidth / 2, y),\n        )\n        .setRestitution(0.1),\n    [edgeWidth, height, width, x, y],\n  )\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(\n        ...coords.toRapier.lengths(width / 2, bottomHeight / 2),\n      )\n        .setTranslation(\n          ...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2),\n        )\n        .setRestitution(0.1),\n    [height, width, x, y],\n  )\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(\n        ...coords.toRapier.lengths(width / 2, bottomHeight / 2),\n      )\n        .setTranslation(\n          ...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2),\n        )\n        .setRestitution(0.1),\n    [height, width, x, y],\n  )\n\n  return <ComicImage img={imgBottomPit} style={getPositionStyles(x, y)} />\n}\n"
  },
  {
    "path": "client/src/components/widgets/BottomTank.tsx",
    "content": "import tankBlueOpenImg from '@art/tank-blue-open_4x.png'\nimport tankBlueImg from '@art/tank-blue_4x.png'\nimport tankGreenOpenImg from '@art/tank-green-open_4x.png'\nimport tankGreenImg from '@art/tank-green_4x.png'\nimport tankRedOpenImg from '@art/tank-red-open_4x.png'\nimport tankRedImg from '@art/tank-red_4x.png'\nimport tankYellowOpenImg from '@art/tank-yellow-open_4x.png'\nimport tankYellowImg from '@art/tank-yellow_4x.png'\nimport { useMemo, useState } from 'react'\nimport { coords } from '../../lib/coords'\nimport { BallType, Vector, isBall } from '../../types'\nimport { ComicImageAnimation } from '../ComicImage'\nimport { useMachine } from '../MachineContext'\nimport {\n  TICK_MS,\n  useCollider,\n  useCollisionHandler,\n  useLoopHandler,\n} from '../PhysicsContext'\nimport { BOTTOM_TANK_HEIGHT, BOTTOM_TANK_WIDTH } from '../constants'\nimport { getPositionStyles } from '../positionStyles'\nimport { lineCuboid } from './lib/lineCuboid'\n\nconst tankImages = [\n  [tankBlueImg, tankBlueOpenImg],\n  [tankRedImg, tankRedOpenImg],\n  [tankGreenImg, tankGreenOpenImg],\n  [tankYellowImg, tankYellowOpenImg],\n]\n\nconst intervalTicks = Math.floor((12 * 1000) / TICK_MS)\nconst openTime = Math.floor(800 / TICK_MS)\n\nexport function BottomTank({\n  x,\n  y,\n  type,\n}: {\n  type: BallType\n} & Vector) {\n  const { killBall } = useMachine()\n  const [isOpen, setOpen] = useState(false)\n\n  const basis = useMemo(() => ({ xBasis: -x, yBasis: -y }), [x, y])\n\n  // Left top funnel\n  useCollider(\n    ({ ColliderDesc, CoefficientCombineRule }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: -43, y1: -209, x2: -28, y2: -180, thickness: 2 },\n        basis,\n      )\n        .setRestitution(0.05)\n        .setRestitutionCombineRule(CoefficientCombineRule.Min),\n    [basis],\n  )\n\n  // Right top funnel\n  useCollider(\n    ({ ColliderDesc, CoefficientCombineRule }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: 43, y1: -209, x2: 28, y2: -180, thickness: 2 },\n        basis,\n      )\n        .setRestitution(0.05)\n        .setRestitutionCombineRule(CoefficientCombineRule.Min),\n    [basis],\n  )\n\n  // Left top\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: -85, y1: -164, x2: -32, y2: -190, thickness: 17 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Right top\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: 85, y1: -164, x2: 32, y2: -190, thickness: 17 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Left edge\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: -79, y1: -170, x2: -79, y2: 205, thickness: 20 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Right edge\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: 79, y1: -170, x2: 79, y2: 205, thickness: 20 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Left lip\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: -86, y1: 172, x2: -86, y2: 205, thickness: 20 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Right lip\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: 82, y1: 164, x2: 82, y2: 209, thickness: 26 },\n        basis,\n      ),\n    [basis],\n  )\n\n  // Bottom bar\n  useCollider(\n    ({ ColliderDesc }) =>\n      lineCuboid(\n        ColliderDesc,\n        { x1: -82, y1: 188, x2: isOpen ? -82 : 82, y2: 188, thickness: 7 },\n        basis,\n      ),\n    [basis, isOpen],\n  )\n\n  const sensorCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents }) =>\n      ColliderDesc.cuboid(\n        ...coords.toRapier.lengths(\n          BOTTOM_TANK_WIDTH / 2 - 26,\n          BOTTOM_TANK_HEIGHT / 2 - 26,\n        ),\n      )\n        .setTranslation(...coords.toRapier.vector(x, y + 10))\n        .setSensor(true)\n        .setActiveEvents(ActiveEvents.COLLISION_EVENTS),\n    [x, y],\n  )\n\n  useCollisionHandler(\n    'start',\n    sensorCollider,\n    (otherCollider) => {\n      const body = otherCollider.parent()\n      if (!body || !isBall(body)) {\n        return\n      }\n\n      if (body.userData.ballType !== type) {\n        killBall(body.userData.id)\n      }\n    },\n    [],\n  )\n\n  useLoopHandler(({ getCurrentTick }) => {\n    const currentTick = getCurrentTick()\n    if (currentTick % intervalTicks === 0) {\n      setOpen(true)\n    }\n    if (currentTick % intervalTicks === openTime) {\n      setOpen(false)\n    }\n  }, [])\n\n  return (\n    <ComicImageAnimation\n      imgs={tankImages[type - 1]}\n      showIdx={isOpen ? 1 : 0}\n      style={getPositionStyles(x, y)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Brick.tsx",
    "content": "import imgBrick from '@art/brick_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface BrickWidget extends Vector, Angled {\n  type: 'brick'\n}\n\nexport function BrickPreview() {\n  return <ComicImage img={imgBrick} css={{ width: '80%', height: 'auto' }} />\n}\n\nexport function Brick({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: BrickWidget & EditableWidget) {\n  const width = imgBrick.width\n  const height = imgBrick.height\n\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setRestitution(0.75),\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgBrick}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/CatSwat.tsx",
    "content": "import imgCatNoSwat from '@art/cat-noswat_4x.png'\nimport imgCatSwat from '@art/cat-swat_4x.png'\nimport type { Collider } from '@dimforge/rapier2d'\nimport { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'\nimport { coords, vectorScale } from '../../lib/coords'\nimport { RandallPath, rotate } from '../../lib/utils'\nimport { Angled, Vector, isBall } from '../../types'\nimport { ComicImage, ComicImageAnimation } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollisionHandler, useRapierEffect } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\nimport { pointToVectorObject } from './Prism'\nimport Ball from './lib/ball'\nimport { lineCuboid } from './lib/lineCuboid'\n\nexport interface CatSwatWidget extends Vector, Angled {\n  type: 'catswat'\n  catMass: number\n}\n\nexport function CatSwatPreview() {\n  return (\n    <div\n      css={{\n        position: 'relative',\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        width: '80%',\n        height: '80%',\n        overflow: 'hidden',\n      }}\n    >\n      <ComicImage\n        img={imgCatNoSwat}\n        css={{\n          position: 'absolute',\n          width: '100%',\n          height: 'auto',\n          left: 8,\n        }}\n      />\n    </div>\n  )\n}\n\nconst imgs = [imgCatNoSwat, imgCatSwat]\nconst CAT_REST_KEY = imgs.indexOf(imgCatNoSwat)\nconst CAT_BONK_KEY = imgs.indexOf(imgCatSwat)\nconst MAX_CAT_SWAT_ANGLE = Math.PI / 2.0\n\nconst imageScale = 4\nconst width = imgCatSwat.width * imageScale\nconst height = imgCatSwat.height * imageScale\nconst basis = { xBasis: width / 2.0, yBasis: height / 2.0, scale: imageScale }\n\nconst CAT_PATH: RandallPath[] = [\n  // tail\n  { x1: 22, y1: 82, x2: 25, y2: 108, thickness: 2 },\n  { x1: 25, y1: 108, x2: 13, y2: 139, thickness: 2 },\n  { x1: 13, y1: 139, x2: 18, y2: 165, thickness: 2 },\n  { x1: 18, y1: 165, x2: 32, y2: 175, thickness: 2 },\n  { x1: 32, y1: 175, x2: 58, y2: 172, thickness: 2 },\n  // body\n  { x1: 88, y1: 100, x2: 145, y2: 100, thickness: 150 },\n  // ears\n  { x1: 95, y1: 28, x2: 99, y2: 1, thickness: 1 },\n  { x1: 99, y1: 1, x2: 118, y2: 18, thickness: 1 },\n  { x1: 118, y1: 18, x2: 138, y2: 18, thickness: 1 },\n  { x1: 138, y1: 18, x2: 155, y2: 5, thickness: 1 },\n  { x1: 155, y1: 5, x2: 158, y2: 27, thickness: 1 },\n]\n\nfunction performSwat(\n  swatCollider: Collider | undefined,\n  ballCollider: Collider,\n  inBabyJailRef: React.MutableRefObject<NodeJS.Timeout | undefined>,\n  inSwipeModeRef: React.MutableRefObject<NodeJS.Timeout | undefined>,\n  setImgKey: Dispatch<SetStateAction<number>>,\n  catMass: number,\n) {\n  const ball = ballCollider.parent()\n  const cat = swatCollider?.parent()\n  const collision = swatCollider?.contactCollider(ballCollider, 0.2)\n  if (\n    inBabyJailRef.current != null ||\n    !ball ||\n    !isBall(ball) ||\n    !swatCollider ||\n    !cat ||\n    !collision\n  ) {\n    return\n  }\n\n  // illegal to swat behind\n  const catRotation = swatCollider.rotation()\n  const impactNormalr = rotate(-catRotation, collision.normal1)\n  const impactAngler = Math.atan2(impactNormalr.y, impactNormalr.x)\n  if (Math.abs(impactAngler) > MAX_CAT_SWAT_ANGLE) {\n    return\n  }\n\n  // if want swat and not swat then start swat\n  //   keep swat 300ms\n  //   chill in baby jail for 200ms\n  //   leave baby jail reset image\n  //   rdy for swat\n  if (!inSwipeModeRef.current) {\n    setImgKey(CAT_BONK_KEY)\n    inSwipeModeRef.current = setTimeout(\n      () => {\n        //   console.log('swipe mode over. jail for babies begins.')\n        inSwipeModeRef.current = undefined\n        inBabyJailRef.current = setTimeout(\n          () => {\n            setImgKey(CAT_REST_KEY)\n            inBabyJailRef.current = undefined\n          },\n          Math.random() * 300 + 100,\n        )\n      },\n      Math.random() * 100 + 100,\n    )\n  }\n\n  // swat\n  const adjustment = (Math.random() * Math.PI) / 8 - Math.PI / 16\n  const adjustedAngle = rotate(adjustment, collision.normal1)\n  const contactPoint = collision.point2\n  const swipeForce = vectorScale(\n    adjustedAngle,\n    (catMass * 8) / Math.max(ball.invMass(), Number.MIN_VALUE),\n  )\n  ball.applyImpulseAtPoint(swipeForce, contactPoint, true)\n}\n\nexport function CatSwat({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n  catMass,\n}: CatSwatWidget & EditableWidget) {\n  const [imgKey, setImgKey] = useState(CAT_REST_KEY)\n\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType, ActiveEvents }) => {\n      return {\n        key: id,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setAdditionalMassProperties(\n            catMass,\n            pointToVectorObject(basis, 106, 116),\n            catMass,\n          )\n          .setCcdEnabled(true)\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // the cats silly little tail and body\n          ...CAT_PATH.map((path) =>\n            lineCuboid(ColliderDesc, path, basis).setDensity(0),\n          ),\n          // the cats head - can trigger swat\n          Ball(ColliderDesc.ball.bind(undefined), basis, 32, {\n            x: 129,\n            y: 54,\n          }).setActiveEvents(\n            ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n          ),\n          // the cats bbutt - can trigger swat\n          Ball(ColliderDesc.ball.bind(undefined), basis, 45, {\n            x: 107,\n            y: 136,\n          }).setActiveEvents(\n            ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n          ),\n          // swat sensor - make sure this is last\n          Ball(ColliderDesc.ball.bind(undefined), basis, 126, {\n            x: 126,\n            y: 109,\n          })\n            .setSensor(true)\n            .setActiveEvents(\n              ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n            ),\n        ],\n      }\n    },\n    [angle, x, y, catMass, id],\n  )\n\n  // grab the collider(s) we need off the body when they are ready for use\n  const [swatCollider, setSwatCollider] = useState<Collider | undefined>()\n  const [swatCollider2, setSwatCollider2] = useState<Collider | undefined>()\n  const [swatCollider3, setSwatCollider3] = useState<Collider | undefined>()\n  useRapierEffect(() => {\n    const numColliders = bodyRef.current?.numColliders() || 0\n    if (numColliders > 0) {\n      const swat1 = bodyRef.current?.collider(numColliders - 1)\n      const swat2 = bodyRef.current?.collider(numColliders - 2)\n      const swat3 = bodyRef.current?.collider(numColliders - 3)\n      setSwatCollider(swat1)\n      setSwatCollider2(swat2)\n      setSwatCollider3(swat3)\n    }\n  }, [bodyRef, setSwatCollider, x, y, angle])\n\n  const inBabyJailRef = useRef<NodeJS.Timeout | undefined>()\n  const inSwipeModeRef = useRef<NodeJS.Timeout | undefined>(undefined)\n  useEffect(() => {\n    return () => {\n      clearTimeout(inBabyJailRef.current)\n      clearTimeout(inSwipeModeRef.current)\n    }\n  }, [])\n\n  // way to many refs\n  // and function arguments\n  useCollisionHandler(\n    'start',\n    swatCollider,\n    (ballCollider) => {\n      performSwat(\n        swatCollider,\n        ballCollider,\n        inBabyJailRef,\n        inSwipeModeRef,\n        setImgKey,\n        catMass,\n      )\n    },\n    [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider, catMass, bodyRef],\n  )\n  useCollisionHandler(\n    'start',\n    swatCollider2,\n    (ballCollider) => {\n      performSwat(\n        swatCollider2,\n        ballCollider,\n        inBabyJailRef,\n        inSwipeModeRef,\n        setImgKey,\n        catMass,\n      )\n    },\n    [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider2, catMass, bodyRef],\n  )\n\n  useCollisionHandler(\n    'start',\n    swatCollider3,\n    (ballCollider) => {\n      performSwat(\n        swatCollider3,\n        ballCollider,\n        inBabyJailRef,\n        inSwipeModeRef,\n        setImgKey,\n        catMass,\n      )\n    },\n    [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider3, catMass, bodyRef],\n  )\n\n  return (\n    <ComicImageAnimation\n      {...useSelectHandlers(id, onSelect)}\n      imgs={imgs}\n      showIdx={imgKey}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/CircleGauge.tsx",
    "content": "import { clamp } from 'lodash'\nimport { SVGAttributes } from 'react'\n\nconst PI_2 = 2 * Math.PI\n\nexport function CircleGauge({\n  value: rawValue,\n  lineWidth = 0.2,\n  ...props\n}: { value: number; lineWidth?: number } & SVGAttributes<SVGElement>) {\n  const value = clamp(rawValue, 0, 1)\n  const largeArc = value > 0.5 ? 1 : 0\n  const halfLineWidth = lineWidth / 2\n  const radius = 1 - halfLineWidth\n  const x = radius * Math.sin(value * PI_2) + 1\n  const y = 1 - radius * Math.cos(value * PI_2)\n\n  return (\n    <svg {...props} viewBox=\"0 0 2 2\">\n      {value === 1 ? (\n        <circle\n          r={radius}\n          cx={1}\n          cy={1}\n          strokeWidth={lineWidth}\n          fill=\"transparent\"\n        />\n      ) : (\n        <path\n          d={`M 1 ${halfLineWidth} A ${radius} ${radius} 0 ${largeArc} 1 ${x} ${y}`}\n          strokeWidth={lineWidth}\n          fill=\"transparent\"\n        />\n      )}\n    </svg>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Cup.tsx",
    "content": "import imgCup from '@art/cup_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface CupWidget extends Vector, Angled {\n  type: 'cup'\n}\n\nexport function CupPreview() {\n  return <ComicImage img={imgCup} css={{ width: '50%', height: 'auto' }} />\n}\n\nexport function Cup({ id, onSelect, x, y, angle }: CupWidget & EditableWidget) {\n  const width = imgCup.width\n  const height = imgCup.height\n\n  useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Bottom\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 3, height / 12),\n          )\n            .setTranslation(\n              ...coords.toRapier.vector(0, height / 2 - height / 12),\n            )\n            .setRestitution(0.9),\n\n          // Left\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 16, height / 2 - 2),\n          )\n            .setTranslation(...coords.toRapier.vector(-width / 3, 0))\n            .setRotation(coords.toRapier.angle(-Math.PI / 16))\n            .setRestitution(0.9),\n\n          // Right\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 16, height / 2 - 2),\n          )\n            .setTranslation(...coords.toRapier.vector(width / 3, 0))\n            .setRotation(coords.toRapier.angle(Math.PI / 16))\n            .setRestitution(0.9),\n        ],\n      }\n    },\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgCup}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Cushion.tsx",
    "content": "import imgCushion from '@art/cushion_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface CushionWidget extends Vector, Angled {\n  type: 'cushion'\n}\n\nexport function CushionPreview() {\n  return <ComicImage img={imgCushion} css={{ width: '70%', height: 'auto' }} />\n}\n\nexport function Cushion({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: CushionWidget & EditableWidget) {\n  const width = imgCushion.width\n  const height = imgCushion.height\n\n  const widthRatio = width / 328\n  const heightRatio = height / 157\n\n  useCollider(\n    ({ ColliderDesc, CoefficientCombineRule }) => {\n      const vectorConversion = (xIn: number, yIn: number): [number, number] =>\n        coords.toRapier.vector(\n          -0.5 * width + xIn * widthRatio,\n          -0.5 * height + yIn * heightRatio,\n        )\n\n      return ColliderDesc.roundConvexHull(\n        new Float32Array([\n          ...vectorConversion(6, 40),\n          ...vectorConversion(112, 10),\n          ...vectorConversion(214, 10),\n          ...vectorConversion(321, 42),\n          ...vectorConversion(317, 55),\n          ...vectorConversion(320, 76),\n          ...vectorConversion(315, 100),\n          ...vectorConversion(322, 114),\n          ...vectorConversion(220, 146),\n          ...vectorConversion(139, 149),\n          ...vectorConversion(10, 120),\n          ...vectorConversion(13, 101),\n          ...vectorConversion(12, 58),\n        ]),\n        coords.toRapier.length(5.5 * widthRatio),\n      )!\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setRestitutionCombineRule(CoefficientCombineRule.Min)\n        .setFrictionCombineRule(CoefficientCombineRule.Max)\n        .setRestitution(0.0)\n        .setFriction(0.999)\n    },\n    [angle, width, height, x, y, widthRatio, heightRatio],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgCushion}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Fan.tsx",
    "content": "import img1 from '@art/fan-blade1_4x.png'\nimport img2 from '@art/fan-blade2_4x.png'\nimport img3 from '@art/fan-blade3_4x.png'\nimport img4 from '@art/fan-blade4_4x.png'\nimport { coords, vectorDistance } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage, ComicImageAnimation } from '../ComicImage'\nimport { useRigidBody, useSensorInTile } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { TICK_MS, useCollider, usePhysicsLoaded } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface FanWidget extends Vector, Angled {\n  type: 'fan'\n}\n\nconst imgs = [img1, img2, img3, img4]\n\nexport function FanPreview() {\n  return <ComicImage img={img1} css={{ width: '50%', height: 'auto' }} />\n}\n\nexport default function Fan({\n  id,\n  onSelect,\n  isSelected,\n  x,\n  y,\n  angle,\n}: FanWidget & EditableWidget) {\n  const width = img1.width\n  const height = img1.height\n  const bladesWidth = width / 5\n  const bodyHeight = height / 3\n  const airOffset = 20\n  const radius = 10\n  const length = 300\n  const strength = 0.05\n\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Body\n          ColliderDesc.roundCuboid(\n            ...coords.toRapier.lengths(\n              width / 2 - radius,\n              bodyHeight / 2 - radius,\n            ),\n            coords.toRapier.length(radius),\n          ).setRestitution(0.5),\n\n          // Blades\n          ColliderDesc.roundCuboid(\n            ...coords.toRapier.lengths(\n              bladesWidth / 2 - radius,\n              height / 2 - radius,\n            ),\n            coords.toRapier.length(radius),\n          )\n            .setTranslation(\n              ...coords.toRapier.vector(width / 2 - bladesWidth / 2, 0),\n            )\n            .setRestitution(2.5),\n        ],\n      }\n    },\n    [angle, bladesWidth, bodyHeight, height, width, x, y],\n  )\n\n  const airCollider = useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(\n        ...coords.toRapier.lengths((length - airOffset) / 2, height / 2),\n      )\n        .setTranslation(\n          ...coords.toRapier.vector(\n            x + 0.5 * length * Math.cos(angle),\n            y + 0.5 * length * Math.sin(angle),\n          ),\n        )\n        .setRotation(coords.toRapier.angle(angle))\n        .setSensor(true),\n    [angle, height, x, y],\n  )\n\n  const xComponent = Math.cos(coords.toRapier.angle(angle))\n  const yComponent = Math.sin(coords.toRapier.angle(angle))\n  const falloffDistance = coords.toRapier.length(length)\n\n  useSensorInTile(\n    airCollider,\n    (otherCollider) => {\n      const { current: body } = bodyRef\n      const otherBody = otherCollider.parent()\n      if (!body || !otherBody) {\n        return\n      }\n\n      const distance = vectorDistance(\n        otherBody.translation(),\n        body.translation(),\n      )\n\n      const falloff = Math.max(\n        0,\n        Math.pow((falloffDistance - distance) / falloffDistance, 2),\n      )\n\n      const forceVector = {\n        x: strength * falloff * xComponent,\n        y: strength * falloff * yComponent,\n      }\n\n      otherBody.applyImpulse(forceVector, true)\n    },\n    [bodyRef, falloffDistance, xComponent, yComponent],\n  )\n\n  const hasPhysics = usePhysicsLoaded()\n\n  return (\n    <div\n      {...useSelectHandlers(id, onSelect)}\n      css={{\n        width,\n        height,\n      }}\n      style={getPositionStyles(x, y, angle)}\n    >\n      <ComicImageAnimation\n        css={{\n          position: 'absolute',\n        }}\n        imgs={imgs}\n        rateMs={hasPhysics ? TICK_MS : 0}\n      />\n      {isSelected && (\n        <div\n          css={{\n            position: 'absolute',\n            background:\n              'linear-gradient(to right, rgba(0, 0, 0, .5), transparent)',\n            pointerEvents: 'none',\n            width: length,\n            height,\n            marginLeft: airOffset * 2,\n            opacity: 0.25,\n          }}\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Hammer.tsx",
    "content": "import imgHammer from '@art/hammer_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface HammerWidget extends Vector, Angled {\n  type: 'hammer'\n}\n\nexport function HammerPreview() {\n  return (\n    <ComicImage\n      img={imgHammer}\n      css={{ width: '100%', height: 'auto', transform: 'rotate(-45deg)' }}\n    />\n  )\n}\n\nexport function Hammer({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: HammerWidget & EditableWidget) {\n  const width = imgHammer.width\n  const height = imgHammer.height\n  const headWidth = 23\n  const shaftThickness = 10\n  const handleLength = 30\n  const handleThickness = 12\n  const handleRadius = 2\n\n  useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Shaft\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(width / 2, shaftThickness / 2),\n          ).setRestitution(0.7),\n\n          // Handle\n          ColliderDesc.roundCuboid(\n            ...coords.toRapier.lengths(\n              handleLength / 2 - handleRadius,\n              handleThickness / 2 - handleRadius,\n              handleRadius,\n            ),\n          )\n            .setTranslation(\n              ...coords.toRapier.vector(-width / 2 + handleLength / 2 + 4, 0),\n            )\n            .setRestitution(0.7),\n\n          // Head\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(headWidth / 2, height / 2),\n          )\n            .setTranslation(\n              ...coords.toRapier.vector(width / 2 - headWidth / 2, 0),\n            )\n            .setRestitution(0.8),\n        ],\n      }\n    },\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgHammer}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Hook.tsx",
    "content": "import { coords } from '../../lib/coords'\nimport { RandallPath } from '../../lib/utils'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useLoopHandler, useRapierEffect } from '../PhysicsContext'\nimport { usePositionedBodyRef } from '../positionStyles'\nimport { lineCuboid } from './lib/lineCuboid'\n\nimport imgHook from '@art/hook_4x.png'\n\nexport interface HookWidgetBase extends Vector, Angled {\n  type: string\n  mass?: number\n  restitution?: number\n  damping?: number\n  disableSpring?: boolean\n  isLeft?: boolean\n}\n\nexport interface HookWidget extends HookWidgetBase {\n  type: 'hook'\n}\n\nexport interface LeftHookWidget extends HookWidgetBase {\n  type: 'lefthook'\n}\n\nexport function HookPreview() {\n  return (\n    <ComicImage\n      img={imgHook}\n      css={{ width: '100%', height: 'auto', transform: 'rotate(-45deg)' }}\n    />\n  )\n}\n\nexport function FlippedHookPreview() {\n  return (\n    <ComicImage\n      img={imgHook}\n      css={{\n        width: '100%',\n        height: 'auto',\n        transform: 'scaleX(-1) rotate(45deg)',\n      }}\n    />\n  )\n}\n\nconst paths: RandallPath[] = [\n  // * handle: 1,9 -> 206,9, width: 12\n  { x1: 1, y1: 9, x2: 190, y2: 9, thickness: 9 },\n  // * thickboy: 188,9 -> 206,9, width: 12\n  { x1: 188, y1: 9, x2: 206, y2: 9, thickness: 12 },\n  // * hookbuddyPt1: 202,16 -> 222,26, width: 6\n  { x1: 202, y1: 16, x2: 222, y2: 26, thickness: 6 },\n  // * hookbuddyPt2: 220,26 -> 237,14, width: 6\n  { x1: 220, y1: 26, x2: 237, y2: 14, thickness: 6 },\n  // * hookbuddyPt3: 237,2 -> 237,16, width: 6d\n  { x1: 237, y1: 16, x2: 237, y2: 2, thickness: 6 },\n]\nconst xBasis = imgHook.width / 2\nconst yBasis = imgHook.height / 2\n\nfunction horizontalMirrorPaths(pathNodes: RandallPath[]): RandallPath[] {\n  return pathNodes.map((value) => {\n    return {\n      x1: imgHook.width - value.x1,\n      y1: value.y1,\n      x2: imgHook.width - value.x2,\n      y2: value.y2,\n      thickness: value.thickness,\n    }\n  })\n}\n\nconst mirrorPaths: RandallPath[] = horizontalMirrorPaths(paths)\n\nexport default function Hook({\n  id,\n  onSelect,\n  x,\n  y,\n  mass = 1.3,\n  restitution = 0.1,\n  damping = 0.999,\n  disableSpring = false,\n  isLeft,\n}: HookWidgetBase & EditableWidget) {\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: id,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic)\n          .lockTranslations()\n          .setAdditionalMassProperties(\n            // initial mass\n            mass,\n            // initial center of mass\n\n            isLeft\n              ? coords.toRapier.vectorObject(-170.5 + xBasis, 9.5 - yBasis)\n              : coords.toRapier.vectorObject(170.5 - xBasis, 9.5 - yBasis),\n            // initial angular inertia\n            mass,\n          )\n          .setAngularDamping(damping)\n          .setCcdEnabled(true),\n        colliderDescs: [\n          ...(isLeft ? mirrorPaths : paths).map((path) =>\n            lineCuboid(ColliderDesc, path, { xBasis, yBasis })\n              .setRestitution(restitution)\n              .setDensity(0),\n          ),\n        ],\n      }\n    },\n    [id, mass, isLeft, damping, restitution],\n  )\n\n  useLoopHandler(\n    (_) => {\n      const { current: body } = bodyRef\n      if (body == null) {\n        return\n      }\n\n      if (disableSpring) {\n        body.resetTorques(false)\n        return\n      }\n\n      const rotation = body.rotation()\n      if (Math.abs(rotation) > 0.001) {\n        const springConstant = -mass * damping\n        body.resetTorques(true)\n        body.applyTorqueImpulse(rotation * springConstant, false)\n      }\n    },\n    [bodyRef, damping, disableSpring, mass],\n  )\n\n  // Update position without recreating so angular momentum is preserved during edits\n  useRapierEffect(() => {\n    const { current: body } = bodyRef\n    if (!body) {\n      return\n    }\n    body.setTranslation(coords.toRapier.vectorObject(x, y), true)\n  }, [bodyRef, x, y])\n\n  return (\n    <div\n      ref={usePositionedBodyRef(bodyRef, {\n        width: imgHook.width,\n        height: imgHook.height,\n        initialX: x,\n        initialY: y,\n      })}\n      {...useSelectHandlers(id, onSelect)}\n    >\n      <ComicImage\n        img={imgHook}\n        style={{\n          transform: isLeft ? 'scaleX(-1)' : undefined,\n        }}\n      />\n    </div>\n  )\n}\n\nexport function LeftHook(props: LeftHookWidget & EditableWidget) {\n  return <Hook {...props} isLeft />\n}\n"
  },
  {
    "path": "client/src/components/widgets/InputOutput.tsx",
    "content": "import imgRoller1 from '@art/one-roller-1_4x.png'\nimport imgRoller2 from '@art/one-roller-2_4x.png'\nimport imgRoller3 from '@art/one-roller-3_4x.png'\nimport imgRoller4 from '@art/one-roller-4_4x.png'\nimport imgRoller5 from '@art/one-roller-5_4x.png'\nimport type { ColliderDesc } from '@dimforge/rapier2d'\nimport { sample } from 'lodash'\nimport { useMemo } from 'react'\nimport { coords } from '../../lib/coords'\nimport {\n  BallData,\n  BallTypeRate,\n  PuzzlePosition,\n  Vector,\n  isBall,\n} from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useMachine } from '../MachineContext'\nimport { useRigidBody } from '../MachineTileContext'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\nimport {\n  INPUT_SPINNER_SIZE,\n  INPUT_SPINNER_SPEED,\n  INPUT_TEETH_COUNT,\n  INPUT_WIDTH,\n} from '../constants'\nimport { getPositionStyles, usePositionedBodyRef } from '../positionStyles'\nimport { ballClassNames } from './Balls'\n\nconst imgRollerChoices = [\n  imgRoller1,\n  imgRoller2,\n  imgRoller3,\n  imgRoller4,\n  imgRoller5,\n]\n\nexport type InputOutputSide = 'left' | 'top' | 'right' | 'bottom'\n\nexport function positionToSide(\n  position: PuzzlePosition,\n): InputOutputSide | undefined {\n  if (position.y > 0 && position.y < 1) {\n    if (position.x === 0) {\n      return 'left'\n    } else if (position.x === 1) {\n      return 'right'\n    }\n  } else {\n    if (position.y === 0) {\n      return 'top'\n    } else if (position.y === 1) {\n      return 'bottom'\n    }\n  }\n}\n\nfunction Triangle({ className }: { className?: string }) {\n  // Thanks to https://blog.kalehmann.de/blog/2020/09/10/css-centered-equilateral-triangle.html\n  return (\n    <svg viewBox=\"0 0 120 120\" className={className}>\n      <polygon points=\"60 95, 10 10, 110 10\" />\n    </svg>\n  )\n}\n\nfunction TypeIndicators({\n  x,\n  y,\n  balls,\n  side,\n}: {\n  x: number\n  y: number\n  balls: BallTypeRate[]\n  side: InputOutputSide\n}) {\n  const size = 12\n  const offset = 36\n  return (\n    <div\n      css={{\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        gap: 4,\n        '&.top': {\n          marginTop: offset,\n        },\n        '&.bottom': {\n          marginTop: -offset,\n        },\n        '&.left, &.right': {\n          flexDirection: 'column',\n        },\n        '&.left': {\n          marginLeft: offset,\n          svg: {\n            transform: 'rotate(90deg)',\n          },\n        },\n        '&.right': {\n          marginLeft: -offset,\n          svg: {\n            transform: 'rotate(-90deg)',\n          },\n        },\n      }}\n      style={getPositionStyles(x, y)}\n      className={side}\n    >\n      {balls.map(({ type }) => (\n        <Triangle\n          key={type}\n          css={{\n            height: size,\n            stroke: 'black',\n            strokeWidth: 10,\n            opacity: 0.65,\n            '&.blue': {\n              fill: '#4057bf',\n            },\n            '&.red': {\n              fill: '#ff1600',\n            },\n            '&.green': {\n              fill: '#44bf40',\n            },\n            '&.yellow': {\n              fill: '#f8eb06',\n            },\n          }}\n          className={ballClassNames[type - 1] ?? undefined}\n        />\n      ))}\n    </div>\n  )\n}\n\nfunction Roller({\n  x,\n  y,\n  rotationSpeed,\n  spokes,\n}: Vector & {\n  rotationSpeed: number\n  spokes?: number\n}) {\n  const innerCircleRadius = 14\n  const img = useMemo(() => sample(imgRollerChoices)!, [])\n\n  const bodyRef = useRigidBody(\n    ({\n      RigidBodyDesc,\n      ColliderDesc,\n      RigidBodyType,\n      CoefficientCombineRule,\n    }) => {\n      const toothColliders: ColliderDesc[] =\n        spokes != null\n          ? Array(spokes)\n              .fill(0)\n              .map((_, index) => {\n                return ColliderDesc.cuboid(\n                  ...coords.toRapier.lengths(INPUT_SPINNER_SIZE / 2, 0.5),\n                )\n                  .setRotation((index * Math.PI) / spokes)\n                  .setRestitution(0.01)\n                  .setFriction(1)\n              })\n          : []\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.KinematicVelocityBased)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .lockTranslations()\n          .setAngvel(rotationSpeed),\n        colliderDescs: [\n          ColliderDesc.ball(coords.toRapier.length(innerCircleRadius))\n            .setRestitution(0.01)\n            .setRestitutionCombineRule(CoefficientCombineRule.Min),\n          ...toothColliders,\n        ],\n      }\n    },\n    [spokes, x, y, rotationSpeed],\n  )\n\n  return (\n    <ComicImage\n      img={img}\n      ref={usePositionedBodyRef(bodyRef, {\n        width: INPUT_SPINNER_SIZE,\n        height: INPUT_SPINNER_SIZE,\n        initialX: x,\n        initialY: y,\n      })}\n      css={{ zIndex: 10 }}\n    />\n  )\n}\n\nexport default function InputOutput({\n  x,\n  y,\n  side,\n  balls,\n  isInput,\n  onReceiveBall,\n}: {\n  side: InputOutputSide\n  isInput: boolean\n  onReceiveBall?: (ballData: BallData) => void\n} & PuzzlePosition) {\n  const { renewBall } = useMachine()\n\n  const isVertical = side === 'left' || side === 'right'\n\n  const sensorCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(INPUT_WIDTH / 4, 6))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(isVertical ? Math.PI / 2 : 0)\n        .setSensor(true)\n        .setActiveEvents(ActiveEvents.COLLISION_EVENTS),\n    [isVertical, x, y],\n  )\n\n  // Ramp to help feed vertical inputs\n  useCollider(\n    ({ ColliderDesc }) =>\n      isInput && isVertical\n        ? ColliderDesc.cuboid(...coords.toRapier.lengths(INPUT_WIDTH / 2, 2))\n            .setTranslation(\n              ...coords.toRapier.vector(\n                side === 'left'\n                  ? x - INPUT_SPINNER_SIZE * 1.5\n                  : x + INPUT_SPINNER_SIZE * 1.5,\n                y - INPUT_SPINNER_SIZE / 3,\n              ),\n            )\n            .setRotation(side === 'left' ? -Math.PI / 6 : Math.PI / 6)\n        : null,\n    [isInput, isVertical, side, x, y],\n  )\n\n  useCollisionHandler(\n    'start',\n    sensorCollider,\n    (otherCollider) => {\n      const body = otherCollider.parent()\n      if (!sensorCollider || !body) {\n        return\n      }\n\n      if (!isBall(body)) {\n        return\n      }\n\n      body.setLinvel({ x: 0, y: 0 }, true)\n      body.setAngvel(0, true)\n\n      renewBall(body.userData.id)\n      onReceiveBall?.(body.userData)\n    },\n    [onReceiveBall, renewBall],\n  )\n\n  const offset = INPUT_WIDTH / 2 - INPUT_SPINNER_SIZE / 2\n  const spinDirection =\n    (isInput ? 1 : -1) * (side === 'left' || side === 'bottom' ? -1 : 1)\n\n  return (\n    <>\n      {!isInput ? (\n        <TypeIndicators x={x} y={y} balls={balls} side={side} />\n      ) : null}\n      <Roller\n        x={isVertical ? x : x - offset}\n        y={isVertical ? y - offset : y}\n        rotationSpeed={-INPUT_SPINNER_SPEED * spinDirection}\n        spokes={INPUT_TEETH_COUNT / 2}\n      />\n      <Roller\n        x={isVertical ? x : x + offset}\n        y={isVertical ? y + offset : y}\n        rotationSpeed={INPUT_SPINNER_SPEED * spinDirection}\n        spokes={INPUT_TEETH_COUNT / 2}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/LeftBumper.tsx",
    "content": "import imgBonkOff from '@art/tribonker-off_4x.png'\nimport imgBonkOn from '@art/tribonker-on_4x.png'\nimport { useState } from 'react'\nimport { coords, vectorScale } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage, ComicImageAnimation } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\nimport {\n  BONK_ANIMATION_DELAY_MS,\n  TRIANGLE_BUMPER_CONTACT_DISTANCE,\n  TRIANGLE_BUMPER_RADIUS_RATIO,\n  TRIANGLE_BUMPER_SENSOR_FUDGE,\n  TRIANGLE_BUMPER_SENSOR_OFFSET,\n  TRIANGLE_BUMPER_STRENGTH,\n} from '../constants'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface LeftBumperWidget extends Vector, Angled {\n  type: 'leftbumper'\n}\n\nconst imgs = [imgBonkOff, imgBonkOn]\n\nexport function LeftBumperPreview() {\n  return <ComicImage img={imgBonkOff} css={{ width: '50%', height: 'auto' }} />\n}\n\nexport default function LeftBumper({\n  id,\n  onSelect,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  isSelected,\n  x,\n  y,\n  angle,\n}: LeftBumperWidget & EditableWidget) {\n  const width = imgBonkOff.width\n  const height = imgBonkOff.height\n  const triangleRadius = width * TRIANGLE_BUMPER_RADIUS_RATIO\n  const sensorOffsetRadius = triangleRadius * TRIANGLE_BUMPER_SENSOR_OFFSET\n\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle))\n          .setCcdEnabled(true),\n        colliderDescs: [\n          // Body\n          ColliderDesc.roundTriangle(\n            coords.toRapier.vectorObject(\n              0.5 * width - triangleRadius,\n              -0.5 * height + triangleRadius,\n            ),\n            coords.toRapier.vectorObject(\n              -0.5 * width + triangleRadius,\n              0.5 * height - triangleRadius,\n            ),\n            coords.toRapier.vectorObject(\n              0.5 * width - triangleRadius,\n              0.5 * height - triangleRadius,\n            ),\n            coords.toRapier.length(triangleRadius),\n          ).setRestitution(0.1),\n        ],\n      }\n    },\n    [angle, triangleRadius, height, width, x, y],\n  )\n\n  const bumperCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) =>\n      ColliderDesc.roundConvexPolyline(\n        new Float32Array([\n          ...coords.toRapier.vector(\n            0.5 * width - triangleRadius * TRIANGLE_BUMPER_SENSOR_FUDGE,\n            -0.5 * height + triangleRadius,\n          ),\n          ...coords.toRapier.vector(\n            -0.5 * width,\n            0.5 * height - triangleRadius,\n          ),\n        ]),\n        coords.toRapier.length(sensorOffsetRadius),\n      )!\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setSensor(true)\n        .setActiveEvents(\n          ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n        )\n        .setActiveCollisionTypes(ActiveCollisionTypes.ALL),\n    [angle, triangleRadius, sensorOffsetRadius, width, height, x, y],\n  )\n\n  const strength = TRIANGLE_BUMPER_STRENGTH\n  const bonkOnMS = BONK_ANIMATION_DELAY_MS\n\n  const [isContacting, setContacting] = useState(0)\n\n  useCollisionHandler(\n    'start',\n    bumperCollider,\n    (otherCollider) => {\n      const otherBody = otherCollider.parent()\n      if (!bumperCollider || !otherBody) {\n        return\n      }\n\n      const contact = bumperCollider.contactCollider(\n        otherCollider,\n        TRIANGLE_BUMPER_CONTACT_DISTANCE,\n      )\n\n      if (contact == null) {\n        return\n      }\n\n      const forceVector = vectorScale(\n        contact.normal1,\n        strength / Math.max(otherBody.invMass(), Number.MIN_VALUE),\n      )\n\n      otherBody.applyImpulseAtPoint(forceVector, contact.point2, true)\n      setContacting( ( isContacting ) => isContacting + 1)\n      setTimeout(() => setContacting(( isContacting ) => Math.max(isContacting - 1, 0)), bonkOnMS)\n    },\n    [bodyRef, strength, isContacting, bonkOnMS],\n  )\n\n  return (\n    <ComicImageAnimation\n      {...useSelectHandlers(id, onSelect)}\n      imgs={imgs}\n      showIdx={isContacting > 0 ? 1 : 0}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/MachineFrame.tsx",
    "content": "import { groupBy, sortBy } from 'lodash'\nimport { useCallback, useRef } from 'react'\nimport { coords } from '../../lib/coords'\nimport { Puzzle, PuzzlePosition, Sized } from '../../types'\nimport { useMachineTile } from '../MachineTileContext'\nimport { useCollider } from '../PhysicsContext'\nimport { INPUT_WIDTH } from '../constants'\nimport InputOutput, { InputOutputSide, positionToSide } from './InputOutput'\nimport OutputValidator from './OutputValidator'\nimport SpawnInput from './SpawnInput'\n\nfunction Line({\n  left,\n  top,\n  width,\n  height,\n}: { left: number; top: number } & Sized) {\n  useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2))\n        .setTranslation(\n          ...coords.toRapier.vector(left + width / 2, top + height / 2),\n        )\n        .setRestitution(0.5),\n    [height, width, left, top],\n  )\n\n  return (\n    <div\n      css={{\n        background: 'black',\n      }}\n      style={{\n        position: 'absolute',\n        left,\n        top,\n        width,\n        height,\n      }}\n    />\n  )\n}\n\nfunction hLinesWithBreaks(\n  positions: PuzzlePosition[],\n  offsetX: number,\n  y: number,\n  width: number,\n) {\n  const line = []\n  let left = 0\n\n  const sortedPositions = sortBy(positions, (p) => p.x)\n  for (let i = 0; i < sortedPositions.length; i++) {\n    const { x } = sortedPositions[i]\n    line.push(\n      <Line\n        key={i}\n        left={offsetX + left}\n        top={y}\n        width={x * width - left - INPUT_WIDTH / 2}\n        height={1}\n      />,\n    )\n\n    left = x * width + INPUT_WIDTH / 2\n  }\n\n  line.push(\n    <Line\n      key=\"line-end\"\n      left={offsetX + left}\n      top={y}\n      width={width - left}\n      height={1}\n    />,\n  )\n\n  return line\n}\n\nfunction vLinesWithBreaks(\n  positions: PuzzlePosition[],\n  x: number,\n  offsetY: number,\n  height: number,\n) {\n  const line = []\n  let top = 0\n\n  const sortedPositions = sortBy(positions, (p) => p.y)\n  for (let i = 0; i < sortedPositions.length; i++) {\n    const { y } = sortedPositions[i]\n    line.push(\n      <Line\n        key={i}\n        left={x}\n        top={offsetY + top}\n        width={1}\n        height={y * height - top - INPUT_WIDTH / 2}\n      />,\n    )\n\n    top = y * height + INPUT_WIDTH / 2\n  }\n\n  line.push(\n    <Line\n      key=\"line-end\"\n      left={x}\n      top={offsetY + top}\n      width={1}\n      height={height - top}\n    />,\n  )\n\n  return line\n}\n\nexport default function MachineFrame({\n  inputs,\n  outputs,\n  title,\n  spawnBallsTop,\n  spawnBallsLeft,\n  spawnBallsRight,\n  validateOutputs,\n  onValidate,\n}: Pick<Puzzle, 'inputs' | 'outputs'> & {\n  title?: string | undefined\n  spawnBallsTop?: boolean\n  spawnBallsLeft?: boolean\n  spawnBallsRight?: boolean\n  validateOutputs?: boolean\n  onValidate?: (isValid: boolean) => void\n}) {\n  const {\n    bounds: [offsetX, offsetY],\n    width,\n    height,\n  } = useMachineTile()\n\n  const outputStateMap = useRef<Record<string, boolean>>({})\n  const prevValid = useRef(false)\n  const handleValidate = useCallback(\n    (id: string, isValid: boolean) => {\n      outputStateMap.current[id] = isValid\n      const isAllValid = Object.values(outputStateMap.current).every(Boolean)\n      if (isAllValid !== prevValid.current) {\n        onValidate?.(isAllValid)\n        prevValid.current = isAllValid\n      }\n    },\n    [onValidate],\n  )\n\n  const OutputComponent = validateOutputs ? OutputValidator : InputOutput\n\n  const inputGroups = groupBy(inputs, positionToSide)\n  const outputGroups = groupBy(outputs, positionToSide)\n\n  function renderInputs(\n    side: InputOutputSide,\n    sideInputs: PuzzlePosition[] | undefined,\n  ) {\n    if (!sideInputs) {\n      return null\n    }\n    return sideInputs.map(({ x, y, balls }, idx) => (\n      <SpawnInput\n        key={`${side}-${idx}`}\n        x={offsetX + width * x}\n        y={offsetY + height * y}\n        balls={balls}\n        side={side}\n      />\n    ))\n  }\n\n  return (\n    <>\n      {title && (\n        <div\n          css={{\n            position: 'absolute',\n            fontFamily: 'xkcd-Regular-v3',\n            fontSize: 16,\n            maxWidth: width - 6,\n            marginLeft: 6,\n            marginTop: 4,\n            overflow: 'hidden',\n            textOverflow: 'ellipsis',\n            whiteSpace: 'nowrap',\n            textShadow: '2px 0 white, -2px 0 white, 0 -2px white, 0 2px white',\n            zIndex: 1,\n          }}\n          style={{\n            left: offsetX,\n            top: offsetY,\n          }}\n        >\n          &ldquo;{title}&rdquo;\n        </div>\n      )}\n\n      {spawnBallsTop && renderInputs('top', inputGroups['top'])}\n      {spawnBallsLeft && renderInputs('left', inputGroups['left'])}\n      {spawnBallsRight && renderInputs('right', inputGroups['right'])}\n\n      {Object.entries(outputGroups).flatMap(([side, outputs]) =>\n        outputs.map(({ x, y, balls }, idx) => (\n          <OutputComponent\n            key={`${side}-${idx}`}\n            id={`${side}-${idx}`}\n            x={offsetX + width * x}\n            y={offsetY + height * y}\n            balls={balls}\n            isInput={false}\n            side={side as InputOutputSide}\n            onValidate={handleValidate}\n          />\n        )),\n      )}\n\n      {hLinesWithBreaks(inputGroups.top ?? [], offsetX, offsetY, width)}\n      {hLinesWithBreaks(\n        outputGroups.bottom ?? [],\n        offsetX,\n        offsetY + height - 1,\n        width,\n      )}\n      {vLinesWithBreaks(\n        [...(inputGroups.left ?? []), ...(outputGroups.left ?? [])],\n        offsetX,\n        offsetY,\n        height,\n      )}\n      {vLinesWithBreaks(\n        [...(inputGroups.right ?? []), ...(outputGroups.right ?? [])],\n        offsetX + width - 1,\n        offsetY,\n        height,\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/OutputValidator.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { BallData, PuzzlePosition } from '../../types'\nimport { ComicImageAnimation } from '../ComicImage'\nimport { useMachine } from '../MachineContext'\nimport { getPositionStyles } from '../positionStyles'\nimport InputOutput, { InputOutputSide } from './InputOutput'\nimport { BALL_RATE_VARIANCE } from './SpawnInput'\n\nimport imgCheck from '@art/check-circle_4x.png'\nimport imgWrong from '@art/wrong-circle_4x.png'\nimport { sumBy } from 'lodash'\nimport { CircleGauge } from './CircleGauge'\n\nconst imgs = [imgCheck, imgWrong]\n\nconst UPDATE_MS = 1000 / 15\nconst RATE_WINDOW_MS = 10 * 1000\n\nexport default function OutputValidator({\n  x,\n  y,\n  balls: balls,\n  side,\n  id,\n  onValidate,\n}: {\n  side: InputOutputSide\n  id: string\n  onValidate: (id: string, isValid: boolean) => void\n} & PuzzlePosition) {\n  const { msPerBall, exitBall } = useMachine()\n\n  const [gaugeLevel, setGaugeLevel] = useState(0)\n\n  const expectedTypes = useMemo(\n    () => new Set(balls.map(({ type }) => type)),\n    [balls],\n  )\n\n  const totalRate = sumBy(balls, ({ rate }) => rate)\n  const msPerSpawn = msPerBall / totalRate\n  const maxGauge = RATE_WINDOW_MS / msPerSpawn\n  // Allow the gauge to fill 2 balls (+ expected variance) past 100%, so it doesn't immediately drop between receiving balls.\n  const maxGaugeOverfill = maxGauge + 3 + 0.5 * BALL_RATE_VARIANCE\n\n  const gaugePercent = gaugeLevel / maxGauge\n  const isValid = gaugePercent >= 1\n\n  const handleReceiveBall = useCallback(\n    (ballData: BallData) => {\n      const delta = expectedTypes.has(ballData.ballType) ? 1 : -1\n\n      setGaugeLevel((curLevel) =>\n        // Add 1 + the expected 1 the RATE_WINDOW_MS will decay per ball.\n        Math.min(curLevel + delta * 2, maxGaugeOverfill),\n      )\n\n      exitBall(ballData.id)\n    },\n    [exitBall, expectedTypes, maxGaugeOverfill],\n  )\n\n  useEffect(() => {\n    const interval = setInterval(() => {\n      // The gauge decays fully after RATE_WINDOW_MS.\n      setGaugeLevel((curLevel) =>\n        Math.max(0, curLevel - maxGauge * (UPDATE_MS / RATE_WINDOW_MS)),\n      )\n    }, UPDATE_MS)\n    return () => {\n      clearInterval(interval)\n    }\n  }, [maxGauge, msPerSpawn])\n\n  useEffect(() => {\n    onValidate?.(id, isValid)\n  }, [id, isValid, onValidate])\n\n  return (\n    <>\n      <div\n        css={{\n          display: 'flex',\n          alignItems: 'center',\n          justifyContent: 'center',\n          width: 26,\n          height: 26,\n          marginLeft: side === 'bottom' ? -32 : side === 'right' ? -40 : 40,\n          marginTop: side === 'bottom' ? -40 : -32,\n          borderRadius: '100%',\n          outline: '2px solid white',\n          pointerEvents: 'none',\n          opacity: 0.75,\n          zIndex: 10,\n        }}\n        style={getPositionStyles(x, y)}\n      >\n        <ComicImageAnimation\n          css={{\n            position: 'absolute',\n          }}\n          imgs={imgs}\n          showIdx={isValid ? 0 : 1}\n        />\n        <CircleGauge\n          value={gaugePercent}\n          lineWidth={0.25}\n          css={{\n            position: 'absolute',\n            stroke: isValid ? 'darkgreen' : 'darkred',\n            fill: 'transparent',\n          }}\n        />\n      </div>\n      <InputOutput\n        x={x}\n        y={y}\n        balls={balls}\n        side={side}\n        isInput={false}\n        onReceiveBall={handleReceiveBall}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Prism.tsx",
    "content": "import { useCallback } from 'react'\nimport { coords } from '../../lib/coords'\nimport { Angled, Ball, Vector, isBall } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nimport imgPrism from '@art/prism_4x.png'\nimport type { Collider, RigidBody } from '@dimforge/rapier2d'\nimport { Basis } from '../../lib/utils'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\n\nexport interface PrismWidget extends Vector, Angled {\n  type: 'prism'\n}\n\nexport function PrismPreview() {\n  return <ComicImage img={imgPrism} css={{ width: '80%', height: 'auto' }} />\n}\n\nexport function pointToVectorObject(\n  { xBasis, yBasis }: Basis,\n  x: number,\n  y: number,\n) {\n  return coords.toRapier.vectorObject(x - xBasis, y - yBasis)\n}\n\nconst width = imgPrism.width\nconst height = imgPrism.height\nconst MIN_CONTACT_DISTANCE = 0.1\nconst PRISM_REFRACTION_INDEX = 2.0\n\nfunction setRefractionVector(\n  // 2:1 <-> 1:2 is fine, but fyi btw 80:1 <-> 1:80 is really really not - it gets weird as you go towards 0\n  indexRatio: (ball: Ball) => number,\n  // new speed is equal to old speed multiplied by this\n  speedMult: (ball: Ball) => number,\n  // prism\n  prismCollider: Collider | undefined,\n  otherCollider: Collider,\n  // are you joining the prism party? or leaving?\n  headingIn: boolean,\n  // stuff prints when this is non-null\n  logString?: string,\n) {\n  const ball: RigidBody | null = otherCollider.parent()\n  if (ball == null || !isBall(ball) || prismCollider == null) {\n    return null\n  }\n\n  const contact = prismCollider.contactCollider(\n    otherCollider,\n    MIN_CONTACT_DISTANCE,\n  )\n  if (contact == null) {\n    return null\n  }\n\n  // I did a lot of holding up two pencils U_U\n  // normal1 points towards the 'outside' of the prism, and normal2 points inside\n  // so when you're coming into the prism you want normal1 and when you're going out you want normal2\n  // (or the reverse and then you don't invert the velocity vector)\n  const normal = headingIn ? contact.normal1 : contact.normal2\n  const invNormal = headingIn ? contact.normal2 : contact.normal1\n  const ballVellocity = ball.linvel()\n  const ballMass = ball.mass()\n\n  const surfaceAngle = Math.atan2(normal.y, normal.x)\n  // please make sure all vectors are fastened into their seatbelts and pointed in the same direction as the normals\n  const velocityAngle = Math.atan2(\n    -1.0 * ballVellocity.y,\n    -1.0 * ballVellocity.x,\n  )\n  // convert to surface normal coordinate system\n  const angleInc = velocityAngle - surfaceAngle\n  // sin(incident) / sin(refraction) = index destination / index start\n  const angleRefr = Math.asin(\n    // clamp to the domain of asin *just in case*\n    Math.max(Math.min(Math.sin(angleInc) / indexRatio(ball), 1), -1),\n  )\n\n  if (logString) {\n    console.log(`** ${logString}\n        surfaceNormal: ${contact.normal1.x.toFixed(3)}, ${contact.normal1.y.toFixed(3)}\n        surfaceNormal2: ${contact.normal2.x.toFixed(3)}, ${contact.normal2.y.toFixed(3)}\n        prismRot: ${(prismCollider.rotation() / Math.PI).toFixed(3)}\n        surfNorm: x:${normal.x.toFixed(3)}, y:${normal.y.toFixed(3)}\n        surfAngle/pi: ${(surfaceAngle / Math.PI).toFixed(3)}\n        velocityAngle/pi: ${(velocityAngle / Math.PI).toFixed(3)}\n        ballType: ${ball.userData.ballType}\n        ballMass: ${ballMass}\n        angleInc/pi: ${(angleInc / Math.PI).toFixed(3)}\n        angleRefr/pi: ${(angleRefr / Math.PI).toFixed(3)}\n        ballVelocity: ${ballVellocity.x.toFixed(3)}, ${ballVellocity.y.toFixed(3)}\n        indexRatio: ${indexRatio(ball)}\n      `)\n  }\n\n  const newX =\n    invNormal.x * Math.cos(angleRefr) - invNormal.y * Math.sin(angleRefr)\n  const newY =\n    invNormal.x * Math.sin(angleRefr) + invNormal.y * Math.cos(angleRefr)\n\n  const speed =\n    Math.sqrt(ballVellocity.x ** 2 + ballVellocity.y ** 2) * speedMult(ball)\n\n  if (logString) {\n    console.log(`<<${logString}\n          new normal: x:${newX.toFixed(3)}, y:${newY.toFixed(3)}\n    >>`)\n  }\n  ball.setLinvel({ x: newX * speed, y: newY * speed }, true)\n}\n\nexport default function Prism({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: PrismWidget & EditableWidget) {\n  const collider = useCollider(\n    ({ ColliderDesc, ActiveEvents }) => {\n      const vector = pointToVectorObject.bind(undefined, {\n        xBasis: width / 2.0,\n        yBasis: height / 2.0,\n      })\n      return ColliderDesc.triangle(\n        vector(0, height),\n        vector(width, height),\n        vector(width / 2.0, 0),\n      )\n        .setSensor(true)\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setActiveEvents(\n          ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n        )\n    },\n    [x, y, angle],\n  )\n\n  const collisionStart = useCallback(\n    (otherCollider: Collider) => {\n      setRefractionVector(\n        // TODO: base on ball properties\n        (_) => PRISM_REFRACTION_INDEX,\n        // TODO: idk if we want to be able to speed up or slow down the balls, but it is possible here\n        (_) => 1.0,\n        collider,\n        otherCollider,\n        true,\n      )\n    },\n    [collider],\n  )\n\n  const collisionEnd = useCallback(\n    (otherCollider: Collider) => {\n      setRefractionVector(\n        // TODO: see above\n        (_) => 1.0 / PRISM_REFRACTION_INDEX,\n        (_) => 1.0,\n        collider,\n        otherCollider,\n        false,\n      )\n    },\n    [collider],\n  )\n\n  useCollisionHandler('start', collider, collisionStart, [])\n  useCollisionHandler('end', collider, collisionEnd, [])\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgPrism}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/QuantumGate.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-unsafe-member-access */\n/* eslint-disable @typescript-eslint/no-unsafe-argument */\n/* eslint-disable @typescript-eslint/no-unsafe-assignment */\n/* eslint-disable @typescript-eslint/no-unsafe-call */\nimport { coords, vectorAngle, vectorDistance, vectorMagnitude } from '../../lib/coords'\nimport { Sized, Vector } from '../../types'\nimport { useSensorInTile } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider } from '../PhysicsContext'\nimport { getPositionStyles } from '../positionStyles'\n\n\nexport interface QuantumGateSlowWidget extends Vector, Sized {\n  type: 'quantumgateslow'\n}\n\nexport interface QuantumGateFastWidget extends Vector, Sized {\n  type: 'quantumgatefast'\n}\n\n\nconst throb1 = `\n@keyframes throb1 {\n\n  0% {\n\n    background: radial-gradient(circle at center, purple 0, orange 8px)\n\n  }\n  \n  6% {\n    background: radial-gradient(circle at center, purple 0, orange 9px)\n  }\n  \n  12% {\n    background: radial-gradient(circle at center, purple 0, orange 10px)\n  }\n\n  18% {\n    background: radial-gradient(circle at center, purple 0, orange 11px)\n  }\n\n  25% {\n    background: radial-gradient(circle at center, purple 0, orange 12px)\n  }\n\n  34% {\n    background: radial-gradient(circle at center, purple 0, orange 13px)\n  }\n\n  50% {\n    background: radial-gradient(circle at center, purple 0, orange 14px)\n  }\n\n  \n  64% {\n    background: radial-gradient(circle at center, purple 0, orange 13px)\n  }\n\n  75% {\n    background: radial-gradient(circle at center, purple 0, orange 12px)\n  }\n\n  81% {\n    background: radial-gradient(circle at center, purple 0, orange 11px)\n  }\n\n  87% {\n    background: radial-gradient(circle at center, purple 0, orange 10px)\n  }\n\n  93% {\n    background: radial-gradient(circle at center, purple 0, orange 9px)\n  }\n\n  100% {\n\n    background: radial-gradient(circle at center, purple 0, orange 8px)\n\n  }\n\n}\n`\n\nconst throb2 = `\n@keyframes throb2 {\n\n  0% {\n\n    background: radial-gradient(circle at center, cyan 0, red 8px)\n\n  }\n  \n  6% {\n    background: radial-gradient(circle at center, cyan 0, red 9px)\n  }\n  \n  12% {\n    background: radial-gradient(circle at center, cyan 0, red 10px)\n  }\n\n  18% {\n    background: radial-gradient(circle at center, cyan 0, red 11px)\n  }\n\n  25% {\n    background: radial-gradient(circle at center, cyan 0, red 12px)\n  }\n\n  34% {\n    background: radial-gradient(circle at center, cyan 0, red 13px)\n  }\n\n  50% {\n    background: radial-gradient(circle at center, cyan 0, red 14px)\n  }\n\n  \n  64% {\n    background: radial-gradient(circle at center, cyan 0, red 13px)\n  }\n\n  75% {\n    background: radial-gradient(circle at center, cyan 0, red 12px)\n  }\n\n  81% {\n    background: radial-gradient(circle at center, cyan 0, red 11px)\n  }\n\n  87% {\n    background: radial-gradient(circle at center, cyan 0, red 10px)\n  }\n\n  93% {\n    background: radial-gradient(circle at center, cyan 0, red 9px)\n  }\n\n  100% {\n\n    background: radial-gradient(circle at center, cyan 0, red 8px)\n\n  }\n\n}\n`\n\nexport function QuantumGateSlowPreview() {\n\n\n  return (\n    <div>\n    <style>{throb1}</style>\n    <div\n    style={{\n      width: '22px',\n      height: '22px',\n      borderRadius: '24px',\n      animation: 'throb1 1.5s linear infinite'\n    }}\n    ></div>\n    </div>\n  )\n}\n\nexport function QuantumGateFastPreview() {\n  return (\n    <div>\n    <style>{throb2}</style>\n    <div\n    style={{\n      width: '18px',\n      height: '18px',\n      borderRadius: '24px',\n      animation: 'throb2 1s linear infinite'\n    }}\n    ></div>\n    </div>\n  )\n}\n\n\nexport function QuantumGate({\n  id,\n  onSelect,\n  isSelected,\n  className,\n  x,\n  y,\n  width,\n  strength,\n  speedLimit,\n}: Vector & Sized & EditableWidget & { className?: string; strength: number; speedLimit: number }) {\n\n  const fieldSize = width\n  const radius = 10\n\n  const boxCollider = useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.ball(coords.toRapier.length(radius))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRestitution(0.5)\n        .setSensor(true),\n    [radius, x, y],\n  )\n\n  const fieldCollider = useCollider(\n    ({ ColliderDesc }) =>\n      ColliderDesc.ball(coords.toRapier.length(fieldSize))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setSensor(true),\n    [fieldSize, x, y],\n  )\n\n  const falloffDistance = coords.toRapier.length(fieldSize) \n\n  useSensorInTile(\n    fieldCollider,\n    (otherCollider) => {\n      \n      const body = otherCollider.parent()\n      if (!boxCollider || !body) {\n        return\n      }\n\n      const speedLimitRapier = coords.toRapier.length( speedLimit )\n      const speed = vectorMagnitude( body.linvel() ) * Math.sign( speedLimit )\n\n      if ( speed < speedLimitRapier ) {\n\n        const distance = vectorDistance(\n          boxCollider.translation(),\n          body.translation(),\n        )\n\n        const angle = vectorAngle(boxCollider.translation(), body.translation())\n\n        const falloff = Math.max(\n          0,\n          Math.pow((falloffDistance - distance) / falloffDistance, 3),\n        )\n\n        const forceVector = {\n          x: strength * falloff * Math.cos(angle),\n          y: strength * falloff * Math.sin(angle),\n        }\n\n        body.applyImpulse(forceVector, true)\n      }\n    },\n    [boxCollider, falloffDistance, strength, speedLimit],\n  )\n\n  return (\n    <div\n      {...useSelectHandlers(id, onSelect)}\n      css={{\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        background: isSelected\n          ? 'radial-gradient(circle closest-side at center, transparent, rgba(0, 0, 0, .25))'\n          : 'transparent',\n        width: fieldSize,\n        height: fieldSize,\n        borderRadius: '100%',\n      }}\n      style={getPositionStyles(x, y)}\n    >\n      <div\n        css={{\n          position: 'absolute',\n          width: radius,\n          height: radius,\n        }}\n        className={className}\n        style={{\n          borderRadius: '100%',\n        }}\n      />\n    </div>\n  )\n}\n\nexport function QuantumGateSlow(props: QuantumGateSlowWidget & EditableWidget) {\n  return (\n    <div>\n    <style>{throb1}</style>\n    <QuantumGate\n      css={{\n        animation: 'throb1 1.5s linear infinite',\n        width: '22px',\n        height: '22px',\n        borderRadius: '24px',\n      }}\n      {...props}\n      strength={10}\n      speedLimit={-400}\n    />\n    </div>\n  )\n}\n\nexport function QuantumGateFast(props: QuantumGateFastWidget & EditableWidget) {\n  return (\n    <div>\n    <style>{throb2}</style> \n    <QuantumGate\n      css={{\n        animation: 'throb2 1s linear infinite',\n        width: '18px',\n        height: '18px',\n        borderRadius: '24px',\n      }}\n      {...props}\n      strength={3}\n      speedLimit={200}\n    />\n    </div>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/RightBumper.tsx",
    "content": "import imgBonkOff from '@art/mirrortribonker-off_4x.png'\nimport imgBonkOn from '@art/mirrortribonker-on_4x.png'\nimport { useState } from 'react'\nimport { coords, vectorScale } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage, ComicImageAnimation } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\nimport {\n  BONK_ANIMATION_DELAY_MS,\n  TRIANGLE_BUMPER_CONTACT_DISTANCE,\n  TRIANGLE_BUMPER_RADIUS_RATIO,\n  TRIANGLE_BUMPER_SENSOR_FUDGE,\n  TRIANGLE_BUMPER_SENSOR_OFFSET,\n  TRIANGLE_BUMPER_STRENGTH,\n} from '../constants'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface RightBumperWidget extends Vector, Angled {\n  type: 'rightbumper'\n}\n\nconst imgs = [imgBonkOff, imgBonkOn]\nconst strength = TRIANGLE_BUMPER_STRENGTH\nconst bonkOnMs = BONK_ANIMATION_DELAY_MS\n\nexport function RightBumperPreview() {\n  return <ComicImage img={imgBonkOff} css={{ width: '50%', height: 'auto' }} />\n}\n\nexport default function RightBumper({\n  id,\n  onSelect,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  isSelected,\n  x,\n  y,\n  angle,\n}: RightBumperWidget & EditableWidget) {\n  const width = imgBonkOff.width\n  const height = imgBonkOff.height\n  const triangleRadius = width * TRIANGLE_BUMPER_RADIUS_RATIO\n  const sensorOffsetRadius = triangleRadius * TRIANGLE_BUMPER_SENSOR_OFFSET\n\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle))\n          .setCcdEnabled(true),\n        colliderDescs: [\n          // Body\n          ColliderDesc.roundTriangle(\n            coords.toRapier.vectorObject(\n              -0.5 * width + triangleRadius,\n              -0.5 * height + triangleRadius,\n            ),\n            coords.toRapier.vectorObject(\n              0.5 * width - triangleRadius,\n              0.5 * height - triangleRadius,\n            ),\n            coords.toRapier.vectorObject(\n              -0.5 * width + triangleRadius,\n              0.5 * height - triangleRadius,\n            ),\n            coords.toRapier.length(triangleRadius),\n          ).setRestitution(0.1),\n        ],\n      }\n    },\n    [angle, triangleRadius, height, width, x, y],\n  )\n\n  const bumperCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) =>\n      ColliderDesc.roundConvexPolyline(\n        new Float32Array([\n          ...coords.toRapier.vector(\n            -0.5 * width + triangleRadius * TRIANGLE_BUMPER_SENSOR_FUDGE,\n            -0.5 * height + triangleRadius,\n          ),\n          ...coords.toRapier.vector(0.5 * width, 0.5 * height - triangleRadius),\n        ]),\n        coords.toRapier.length(sensorOffsetRadius),\n      )!\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setRotation(coords.toRapier.angle(angle))\n        .setSensor(true)\n        .setActiveEvents(\n          ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n        )\n        .setActiveCollisionTypes(ActiveCollisionTypes.ALL),\n    [angle, triangleRadius, sensorOffsetRadius, width, height, x, y],\n  )\n\n  const [isContacting, setContacting] = useState(0)\n\n  useCollisionHandler(\n    'start',\n    bumperCollider,\n    (otherCollider) => {\n      const otherBody = otherCollider.parent()\n      if (!bumperCollider || !otherBody) {\n        return\n      }\n\n      const contact = bumperCollider.contactCollider(\n        otherCollider,\n        TRIANGLE_BUMPER_CONTACT_DISTANCE,\n      )\n\n      if (contact == null) {\n        return\n      }\n\n      const forceVector = vectorScale(\n        contact.normal1,\n        strength / Math.max(otherBody.invMass(), Number.MIN_VALUE),\n      )\n\n      otherBody.applyImpulseAtPoint(forceVector, contact.point2, true)\n      setContacting( ( isContacting ) => isContacting + 1)\n      setTimeout(() => setContacting( ( isContacting ) => Math.max(isContacting - 1, 0)), bonkOnMs)\n    },\n    [bodyRef, strength, isContacting, bonkOnMs],\n  )\n\n  return (\n    <ComicImageAnimation\n      {...useSelectHandlers(id, onSelect)}\n      imgs={imgs}\n      showIdx={isContacting > 0 ? 1 : 0}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/RoundBumper.tsx",
    "content": "import imgBonkOff from '@art/bonker-off_4x.png'\nimport imgBonkOn from '@art/bonker-on_4x.png'\nimport { useState } from 'react'\nimport {\n  coords,\n  vectorDifference,\n  vectorNorm,\n  vectorScale,\n} from '../../lib/coords'\nimport { Vector, isBall } from '../../types'\nimport { ComicImage, ComicImageAnimation } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useCollider, useCollisionHandler } from '../PhysicsContext'\nimport {\n  BONK_ANIMATION_DELAY_MS,\n  TRIANGLE_BUMPER_CONTACT_DISTANCE as BUMPER_CONTACT_DISTANCE,\n  ROAND_BUMPER_RADIUS_RATIO,\n  ROUND_BUMPER_FALLBACK_RATIO,\n  ROUND_BUMPER_STRENGTH,\n} from '../constants'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface RoundBumperWidget extends Vector {\n  type: 'roundbumper'\n}\n\nconst imgs = [imgBonkOff, imgBonkOn]\nconst strength = ROUND_BUMPER_STRENGTH\nconst bonkOnMs = BONK_ANIMATION_DELAY_MS\n\nexport function RoundBumperPreview() {\n  return <ComicImage img={imgBonkOff} css={{ width: '65%', height: 'auto' }} />\n}\n\nexport default function RoundBumper({\n  id,\n  onSelect,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  isSelected,\n  x,\n  y,\n}: RoundBumperWidget & EditableWidget) {\n  const width = imgBonkOff.width\n  const bumperRadius = width * ROAND_BUMPER_RADIUS_RATIO\n\n  const bodyRef = useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setCcdEnabled(true),\n        colliderDescs: [\n          ColliderDesc.capsule(\n            0,\n            coords.toRapier.length(bumperRadius * ROUND_BUMPER_FALLBACK_RATIO),\n          ).setRestitution(1),\n        ],\n      }\n    },\n    [bumperRadius, x, y],\n  )\n\n  const bumperCollider = useCollider(\n    ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) =>\n      ColliderDesc.capsule(0, coords.toRapier.length(bumperRadius))\n        .setTranslation(...coords.toRapier.vector(x, y))\n        .setSensor(true)\n        .setActiveEvents(\n          ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS,\n        )\n        .setActiveCollisionTypes(ActiveCollisionTypes.ALL),\n    [bumperRadius, x, y],\n  )\n\n  const [isContacting, setContacting] = useState(0)\n\n  useCollisionHandler(\n    'start',\n    bumperCollider,\n    (otherCollider) => {\n      const { current: body } = bodyRef\n      if (!bumperCollider || !body) {\n        return\n      }\n      const otherBody = otherCollider.parent()\n      if (!otherBody) {\n        return\n      }\n\n      const contact = bumperCollider.contactCollider(\n        otherCollider,\n        BUMPER_CONTACT_DISTANCE,\n      )\n\n      if (contact !== null) {\n        const bodyIsBall = isBall(otherBody)\n\n        otherBody.applyImpulseAtPoint(\n          vectorScale(\n            vectorNorm(\n              vectorDifference(\n                otherCollider.translation(),\n                bumperCollider.translation(),\n              ),\n            ),\n            strength /\n              Math.max(\n                bodyIsBall ? otherBody.invMass() : otherBody.mass(),\n                Number.MIN_VALUE,\n              ),\n          ),\n          contact?.point2,\n          true,\n        )\n        setContacting((isContacting) => isContacting + 1)\n        setTimeout(\n          () => setContacting((isContacting) => Math.max(isContacting - 1, 0)),\n          bonkOnMs,\n        )\n      }\n    },\n    [bodyRef, strength, isContacting, bonkOnMs],\n  )\n\n  return (\n    <ComicImageAnimation\n      {...useSelectHandlers(id, onSelect)}\n      imgs={imgs}\n      showIdx={isContacting > 0 ? 1 : 0}\n      style={getPositionStyles(x, y, 0)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/SpawnInput.tsx",
    "content": "import { sumBy } from 'lodash'\nimport { useCallback, useRef } from 'react'\nimport weighted from 'weighted'\nimport { PuzzlePosition } from '../../types'\nimport { CreateBallOptions, useMachine } from '../MachineContext'\nimport {\n  PhysicsContextType,\n  useLoopHandler,\n  useRapierEffect,\n} from '../PhysicsContext'\nimport { BALL_RADIUS, INPUT_SPINNER_SIZE } from '../constants'\nimport InputOutput, { InputOutputSide } from './InputOutput'\n\nexport const BALL_RATE_VARIANCE = 1 // 50% either direction\n\nexport function BallSpawner({\n  x,\n  y,\n  vx,\n  vy,\n  overrideDamping,\n  balls,\n}: PuzzlePosition & CreateBallOptions) {\n  const { createBall, msPerBall } = useMachine()\n\n  const nextBallTick = useRef(0)\n\n  const scheduleNextBall = useCallback(\n    ({ tickMs, getCurrentTick }: PhysicsContextType) => {\n      const totalRate = sumBy(balls, ({ rate }) => rate)\n      const msPerSpawn = msPerBall / totalRate\n      const periodTicks = msPerSpawn / tickMs\n      const variance = 1 + (0.5 - Math.random()) * BALL_RATE_VARIANCE\n      nextBallTick.current =\n        getCurrentTick() + Math.round(periodTicks * variance)\n    },\n    [msPerBall, balls],\n  )\n\n  useRapierEffect(scheduleNextBall, [scheduleNextBall])\n\n  useLoopHandler(\n    (physics) => {\n      const currentTick = physics.getCurrentTick()\n      if (currentTick >= nextBallTick.current) {\n        const { type } = weighted(\n          balls,\n          balls.map(({ rate }) => rate),\n        )\n        createBall(x, y, type, { vx, vy, overrideDamping })\n        scheduleNextBall(physics)\n        return\n      }\n    },\n    [createBall, overrideDamping, scheduleNextBall, balls, vx, vy, x, y],\n  )\n\n  return null\n}\n\nexport default function SpawnInput({\n  x,\n  y,\n  balls,\n  side,\n}: { side: InputOutputSide } & PuzzlePosition) {\n  // Side inputs are tricky since we don't have gravity. We spawn the ball to the side with a line sloping down.\n  const sideSpawnOffset = 0.75 * INPUT_SPINNER_SIZE\n  const xOffset =\n    side === 'left' ? -sideSpawnOffset : side === 'right' ? sideSpawnOffset : 0\n\n  const yOffset =\n    side === 'top'\n      ? -BALL_RADIUS\n      : side === 'bottom'\n        ? BALL_RADIUS\n        : -BALL_RADIUS * 2\n\n  return (\n    <>\n      <BallSpawner x={x + xOffset} y={y + yOffset} balls={balls} />\n      <InputOutput x={x} y={y} balls={balls} side={side} isInput />\n    </>\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Sticker.tsx",
    "content": "import imgCat from '@art/cat_4x.png'\nimport imgDeterminism from '@art/determinism-sign_4x.png'\nimport imgFigure1Happy from '@art/figure1-happy_4x.png'\nimport imgFigure1Sad from '@art/figure1-sad_4x.png'\nimport imgFigure2Happy from '@art/figure2-happy_4x.png'\nimport imgFigure2Sad from '@art/figure2-sad_4x.png'\nimport imgFigure3Happy from '@art/figure3-happy_4x.png'\nimport imgFigure3Sad from '@art/figure3-sad_4x.png'\nimport imgKingbun from '@art/kingbun_4x.png'\nimport imgKnievel from '@art/knievel_4x.png'\nimport imgSquobject from '@art/squobject_4x.png'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport const stickers = {\n  'figure1-happy': imgFigure1Happy,\n  'figure1-sad': imgFigure1Sad,\n  'figure2-happy': imgFigure2Happy,\n  'figure2-sad': imgFigure2Sad,\n  'figure3-happy': imgFigure3Happy,\n  'figure3-sad': imgFigure3Sad,\n  knievel: imgKnievel,\n  squobject: imgSquobject,\n  determinism: imgDeterminism,\n  kingbun: imgKingbun,\n  cat: imgCat,\n} as const\n\nexport type StickerName = keyof typeof stickers\n\nconst smolStickers = new Set<StickerName>(['cat', 'kingbun'])\n\nexport interface StickerWidget extends Vector, Angled {\n  type: 'sticker'\n  sticker: StickerName\n}\n\nexport function StickerPreview({ sticker }: { sticker: StickerName }) {\n  return (\n    <ComicImage\n      img={stickers[sticker]}\n      css={{\n        maxWidth: smolStickers.has(sticker) ? '40%' : '75%',\n        maxHeight: '80%',\n        width: 'auto',\n        height: 'auto',\n      }}\n    />\n  )\n}\n\nexport function Sticker({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n  sticker,\n}: StickerWidget & EditableWidget) {\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={stickers[sticker]}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Sword.tsx",
    "content": "import imgSword from '@art/sword_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { getPositionStyles } from '../positionStyles'\n\nexport interface SwordWidget extends Vector, Angled {\n  type: 'sword'\n}\n\nexport function SwordPreview() {\n  return (\n    <ComicImage\n      img={imgSword}\n      css={{ width: '100%', height: 'auto', transform: 'rotate(-45deg)' }}\n    />\n  )\n}\n\nexport function Sword({\n  id,\n  onSelect,\n  x,\n  y,\n  angle,\n}: SwordWidget & EditableWidget) {\n  const width = imgSword.width\n  const height = imgSword.height\n  const guardWidth = 9\n  const guardOffset = 35\n  const handleThickness = 10\n  const tipLength = handleThickness\n\n  useRigidBody(\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      return {\n        key: null,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)\n          .setTranslation(...coords.toRapier.vector(x, y))\n          .setRotation(coords.toRapier.angle(angle)),\n        colliderDescs: [\n          // Handle\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(\n              width / 2 - tipLength / 2,\n              handleThickness / 2,\n            ),\n          )\n            .setTranslation(...coords.toRapier.vector(-tipLength, 0))\n            .setRestitution(0.9),\n\n          // Tip\n          ColliderDesc.triangle(\n            coords.toRapier.vectorObject(\n              width / 2 - tipLength,\n              -handleThickness / 2,\n            ),\n            coords.toRapier.vectorObject(width / 2, 0),\n            coords.toRapier.vectorObject(\n              width / 2 - tipLength,\n              handleThickness / 2,\n            ),\n          ).setRestitution(0.9),\n\n          // Guard\n          ColliderDesc.cuboid(\n            ...coords.toRapier.lengths(guardWidth / 2, height / 2),\n          )\n            .setTranslation(...coords.toRapier.vector(-guardOffset, 0))\n            .setRestitution(0.9),\n        ],\n      }\n    },\n    [angle, height, width, x, y],\n  )\n\n  return (\n    <ComicImage\n      {...useSelectHandlers(id, onSelect)}\n      img={imgSword}\n      style={getPositionStyles(x, y, angle)}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/Wheel.tsx",
    "content": "import { coords } from '../../lib/coords'\nimport { Vector } from '../../types'\nimport { ComicImage } from '../ComicImage'\nimport { useRigidBody } from '../MachineTileContext'\nimport { EditableWidget, useSelectHandlers } from '../MachineTileEditor'\nimport { useRapierEffect } from '../PhysicsContext'\nimport { usePositionedBodyRef } from '../positionStyles'\n\nimport imgWheel from '@art/spoked-wheel_4x.png'\nimport type { ColliderDesc } from '@dimforge/rapier2d'\nimport { useRef } from 'react'\nimport { rotate } from '../../lib/utils'\nimport { pointToVectorObject } from './Prism'\n\nexport interface SpokedWheelWidget extends Vector {\n  type: 'spokedwheel'\n  speed: number\n}\n\nexport function SpokedWheelPreview() {\n  return <ComicImage img={imgWheel} css={{ width: '85%', height: 'auto' }} />\n}\n\nexport function getNextWheelSpeed(wheel: SpokedWheelWidget, key: string) {\n  const currentSpeed = wheel.speed\n  if (\n    key === 'arrowleft' ||\n    (key === 'arrowdown' && currentSpeed < 0) ||\n    (key === 'arrowup' && currentSpeed > 0)\n  ) {\n    return currentSpeed + 1\n  } else if (\n    key === 'arrowright' ||\n    (key === 'arrowup' && currentSpeed < 0) ||\n    (key === 'arrowdown' && currentSpeed > 0)\n  ) {\n    return currentSpeed - 1\n  } else {\n    return null\n  }\n}\n\n// I think this is a really ugly bag of constants and object descriptions\nconst OUTER_WHEEL_RADIUS = 40.0\nconst WHEEL_TOP = 38\nconst WHEEL_LEFT = 79\nconst NUM_SPOKES = 7\n\nconst spokeCapsule = {\n  boundRect: { width: 9.0, height: 10.0 },\n  distanceFromTop: 12.0,\n}\nconst upperConvexPiece = {\n  x: 76,\n  y: 18,\n  topWidth: 7,\n  bottomWidth: 4.5,\n  height: 11.0,\n}\n// typing it liuke this is pretty dumb\ntype Description<T> = {\n  readonly [P in keyof T]: T[P]\n}\ntype HullDescription = Description<typeof upperConvexPiece>\nconst bottomConvexPiece: HullDescription = {\n  x: 77,\n  y: 28,\n  topWidth: 4.5,\n  bottomWidth: 8,\n  height: 10,\n}\n\n/*\n * the spokes are made from a capsule (the top) and then two convex hulls\n * looking something like this:\n *\n *  ^\n * | |\n *  v\n * \\ /\n * / \\\n *\n */\nfunction makeConvexHull(hullDesc: HullDescription, rotationAngle: number) {\n  const { x, y, topWidth, bottomWidth, height } = hullDesc\n  rotate.bind(undefined, rotationAngle)\n  const makePoint = (x: number, y: number) =>\n    rotate(rotationAngle, pointToVectorObject({ xBasis, yBasis }, x, y))\n  const bottomXAdjust = (topWidth - bottomWidth) / 2.0\n  return [\n    makePoint(x, y),\n    makePoint(x + topWidth, y),\n    makePoint(x + bottomXAdjust, y + height),\n    makePoint(x + bottomXAdjust + bottomWidth, y + height),\n  ].flatMap(({ x, y }) => [x, y])\n}\n\nfunction makeSpoke(index: number, colliderDesc: typeof ColliderDesc) {\n  const rotationAngle = (index * 2.0 * Math.PI) / NUM_SPOKES\n  const translationRotation = rotate(\n    rotationAngle,\n    coords.toRapier.vectorObject(0, spokeCapsule.distanceFromTop, {\n      xBasis: 0,\n      yBasis: yBasis,\n    }),\n  )\n  return [\n    colliderDesc\n      .capsule(\n        ...coords.toRapier.lengths(\n          spokeCapsule.boundRect.height / 2.0,\n          spokeCapsule.boundRect.width / 2.0,\n        ),\n      )\n      .setRotation(rotationAngle)\n      .setTranslation(translationRotation.x, translationRotation.y),\n    colliderDesc.convexHull(\n      new Float32Array(makeConvexHull(upperConvexPiece, rotationAngle)),\n    ),\n    colliderDesc.convexHull(\n      new Float32Array(makeConvexHull(bottomConvexPiece, rotationAngle)),\n    ),\n  ]\n}\n\nconst xBasis = imgWheel.width / 2\nconst yBasis = imgWheel.height / 2\n\nexport default function SpokedWheel({\n  id,\n  onSelect,\n  x,\n  y,\n  isSelected,\n  speed,\n}: SpokedWheelWidget & EditableWidget) {\n  const bodyRef = useRigidBody(\n    // @ts-expect-error mad about possibly null convexHull. dont worrty though im filtering null values out\n    ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {\n      const wheelCenter = coords.toRapier.vectorObject(\n        WHEEL_LEFT,\n        WHEEL_TOP + OUTER_WHEEL_RADIUS,\n        {\n          xBasis,\n          yBasis,\n        },\n      )\n      return {\n        key: id,\n        bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic)\n          .lockTranslations()\n          .setAdditionalMassProperties(2, wheelCenter, 2)\n          .setAngularDamping(1),\n        colliderDescs: [\n          ...Array(NUM_SPOKES)\n            .fill(0)\n            .flatMap((_, index) => {\n              return makeSpoke(index, ColliderDesc)\n            })\n            .filter((v) => v != null),\n          ColliderDesc.ball(\n            coords.toRapier.length(OUTER_WHEEL_RADIUS),\n          ).setTranslation(wheelCenter.x, wheelCenter.y),\n        ],\n      }\n    },\n    [id],\n  )\n\n  // Update position without recreating so angular momentum is preserved during edits\n  useRapierEffect(() => {\n    const { current: body } = bodyRef\n    if (!body) {\n      return\n    }\n    body.setTranslation(coords.toRapier.vectorObject(x, y), true)\n  }, [bodyRef, x, y])\n\n  const selectedRef = useRef(isSelected)\n  selectedRef.current = isSelected\n  // separate effect for setting speed so we're not resetting torque when we translate\n  useRapierEffect(() => {\n    const { current: body } = bodyRef\n    if (!body) {\n      return\n    }\n    body.resetTorques(true)\n    body.addTorque(speed, true)\n  }, [bodyRef, speed])\n\n  return (\n    <ComicImage\n      ref={usePositionedBodyRef(bodyRef, {\n        width: imgWheel.width,\n        height: imgWheel.height,\n        initialX: x,\n        initialY: y,\n      })}\n      {...useSelectHandlers(id, onSelect)}\n      img={imgWheel}\n    />\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/index.tsx",
    "content": "import assertNever from 'assert-never'\nimport { mapValues } from 'lodash'\nimport React, { FC } from 'react'\nimport { WidgetCollection } from '../../types'\nimport { useMachineTile } from '../MachineTileContext'\nimport { EditableWidget } from '../MachineTileEditor'\nimport { Anvil, AnvilPreview, AnvilWidget } from './Anvil'\nimport {\n  Attractor,\n  AttractorPreview,\n  AttractorWidget,\n  Repulsor,\n  RepulsorPreview,\n  RepulsorWidget,\n} from './AttractorRepulsor'\nimport { BallStand, BallStandPreview, BallStandWidget } from './BallStand'\nimport { Board, BoardPreview, BoardWidget } from './Board'\nimport { Brick, BrickPreview, BrickWidget } from './Brick'\nimport { CatSwat, CatSwatPreview, CatSwatWidget } from './CatSwat'\nimport { Cup, CupPreview, CupWidget } from './Cup'\nimport { Cushion, CushionPreview, CushionWidget } from './Cushion'\nimport Fan, { FanPreview, FanWidget } from './Fan'\nimport { Hammer, HammerPreview, HammerWidget } from './Hammer'\nimport Hook, {\n  FlippedHookPreview,\n  HookPreview,\n  HookWidget,\n  LeftHook,\n  LeftHookWidget,\n} from './Hook'\nimport LeftBumper, { LeftBumperPreview, LeftBumperWidget } from './LeftBumper'\nimport Prism, { PrismPreview, PrismWidget } from './Prism'\nimport RightBumper, {\n  RightBumperPreview,\n  RightBumperWidget,\n} from './RightBumper'\nimport RoundBumper, {\n  RoundBumperPreview,\n  RoundBumperWidget,\n} from './RoundBumper'\nimport {\n  Sticker,\n  StickerName,\n  StickerPreview,\n  StickerWidget,\n  stickers,\n} from './Sticker'\nimport { Sword, SwordPreview, SwordWidget } from './Sword'\nimport SpokedWheel, { SpokedWheelPreview, SpokedWheelWidget } from './Wheel'\n\nexport type WidgetData =\n  | BoardWidget\n  | BrickWidget\n  | HammerWidget\n  | SwordWidget\n  | AnvilWidget\n  | FanWidget\n  | AttractorWidget\n  | RepulsorWidget\n  | LeftBumperWidget\n  | RightBumperWidget\n  | HookWidget\n  | LeftHookWidget\n  | RoundBumperWidget\n  | CushionWidget\n  | PrismWidget\n  //| QuantumGateSlowWidget\n  //| QuantumGateFastWidget\n  | SpokedWheelWidget\n  | BallStandWidget\n  | CupWidget\n  | StickerWidget\n  | CatSwatWidget\nexport type WidgetType = WidgetData['type']\n\nexport interface PaletteItem<T> {\n  preview: FC\n  create: (x: number, y: number) => T\n  canRotate?: boolean\n  canResize?: boolean\n  isSquare?: boolean\n}\n\nexport const widgetList: Partial<Record<WidgetType, PaletteItem<WidgetData>>> =\n  {\n    board: {\n      preview: BoardPreview,\n      create: (x: number, y: number) => ({\n        type: 'board',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    hammer: {\n      preview: HammerPreview,\n      create: (x: number, y: number) => ({\n        type: 'hammer',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    sword: {\n      preview: SwordPreview,\n      create: (x: number, y: number) => ({\n        type: 'sword',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    hook: {\n      preview: HookPreview,\n      create: (x: number, y: number) => ({\n        type: 'hook',\n        x,\n        y,\n        angle: 0,\n      }),\n    },\n    lefthook: {\n      preview: FlippedHookPreview,\n      create: (x: number, y: number) => ({\n        type: 'lefthook',\n        x,\n        y,\n        angle: 0,\n      }),\n    },\n    anvil: {\n      preview: AnvilPreview,\n      create: (x: number, y: number) => ({\n        type: 'anvil',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    brick: {\n      preview: BrickPreview,\n      create: (x: number, y: number) => ({\n        type: 'brick',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    fan: {\n      preview: FanPreview,\n      create: (x: number, y: number) => ({\n        type: 'fan',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    cushion: {\n      preview: CushionPreview,\n      create: (x: number, y: number) => ({\n        type: 'cushion',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    roundbumper: {\n      preview: RoundBumperPreview,\n      create: (x: number, y: number) => ({\n        type: 'roundbumper',\n        x,\n        y,\n      }),\n    },\n    rightbumper: {\n      preview: RightBumperPreview,\n      create: (x: number, y: number) => ({\n        type: 'rightbumper',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    leftbumper: {\n      preview: LeftBumperPreview,\n      create: (x: number, y: number) => ({\n        type: 'leftbumper',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n\n    attractor: {\n      preview: AttractorPreview,\n      create: (x: number, y: number) => ({\n        type: 'attractor',\n        x,\n        y,\n        width: 150,\n        height: 10,\n      }),\n      canResize: true,\n      isSquare: true,\n    },\n    repulsor: {\n      preview: RepulsorPreview,\n      create: (x: number, y: number) => ({\n        type: 'repulsor',\n        x,\n        y,\n        width: 150,\n        height: 10,\n      }),\n      canResize: true,\n      isSquare: true,\n    } /*\n  quantumgateslow: {\n    preview: QuantumGateSlowPreview,\n    create: (x: number, y: number) => ({\n      type: 'quantumgateslow',\n      x,\n      y,\n      width: 150,\n      height: 10,\n    }),\n    canResize: true,\n    isSquare: true,\n  },\n  quantumgatefast: {\n    preview: QuantumGateFastPreview,\n    create: (x: number, y: number) => ({\n      type: 'quantumgatefast',\n      x,\n      y,\n      width: 150,\n      height: 10,\n    }),\n    canResize: true,\n    isSquare: true,\n  },\n  */,\n    prism: {\n      preview: PrismPreview,\n      create: (x: number, y: number) => ({\n        type: 'prism',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    spokedwheel: {\n      preview: SpokedWheelPreview,\n      create: (x: number, y: number) => ({\n        type: 'spokedwheel',\n        x,\n        y,\n        speed: 25,\n        angle: 0,\n      }),\n    },\n    ballstand: {\n      preview: BallStandPreview,\n      create: (x: number, y: number) => ({\n        type: 'ballstand',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    cup: {\n      preview: CupPreview,\n      create: (x: number, y: number) => ({\n        type: 'cup',\n        x,\n        y,\n        angle: 0,\n      }),\n      canRotate: true,\n    },\n    catswat: {\n      preview: CatSwatPreview,\n      create: (x: number, y: number) => ({\n        type: 'catswat',\n        x,\n        y,\n        angle: 0,\n        catMass: 2,\n      }),\n      canRotate: true,\n    },\n  }\n\nexport const stickerList: Record<\n  string,\n  PaletteItem<StickerWidget>\n> = mapValues(stickers, (_, sticker) => ({\n  preview: () => <StickerPreview sticker={sticker as StickerName} />,\n  create: (x: number, y: number) => ({\n    type: 'sticker' as const,\n    sticker: sticker as StickerName,\n    x,\n    y,\n    angle: 0,\n  }),\n  canRotate: true,\n}))\n\nexport function Widget(\n  props: WidgetData & Partial<EditableWidget> & { id: EditableWidget['id'] },\n) {\n  const { type } = props\n\n  if (type === 'board') {\n    return <Board {...props} />\n  } else if (type === 'brick') {\n    return <Brick {...props} />\n  } else if (type === 'hammer') {\n    return <Hammer {...props} />\n  } else if (type === 'sword') {\n    return <Sword {...props} />\n  } else if (type === 'anvil') {\n    return <Anvil {...props} />\n  } else if (type === 'fan') {\n    return <Fan {...props} />\n  } else if (type === 'leftbumper') {\n    return <LeftBumper {...props} />\n  } else if (type === 'rightbumper') {\n    return <RightBumper {...props} />\n  } else if (type === 'hook') {\n    return <Hook {...props} />\n  } else if (type === 'lefthook') {\n    return <LeftHook {...props} />\n  } else if (type === 'roundbumper') {\n    return <RoundBumper {...props} />\n  } else if (type === 'cushion') {\n    return <Cushion {...props} />\n  } else if (type === 'prism') {\n    return <Prism {...props} />\n  } else if (type === 'spokedwheel') {\n    return <SpokedWheel {...props} />\n  } else if (type === 'attractor') {\n    return <Attractor {...props} />\n  } else if (type === 'repulsor') {\n    return <Repulsor {...props} />\n  } else if (type === 'ballstand') {\n    return <BallStand {...props} />\n  } else if (type === 'cup') {\n    return <Cup {...props} />\n  } else if (type === 'catswat') {\n    return <CatSwat {...props} />\n  } else if (type === 'sticker') {\n    return <Sticker {...props} /> /*\n    /*\n  } else if (type === 'quantumgateslow') {\n    return <QuantumGateSlow {...props} />\n  } else if (type === 'quantumgatefast') {\n    return <QuantumGateFast {...props} />\n  */\n  } else {\n    assertNever(type, true)\n  }\n}\n\nfunction WidgetsUnmemoized({\n  widgets,\n  onSelect,\n  selectedId,\n}: {\n  widgets: WidgetCollection\n  onSelect?: EditableWidget['onSelect']\n  selectedId?: string | undefined\n}) {\n  const {\n    bounds: [offsetX, offsetY],\n  } = useMachineTile()\n\n  return Object.entries(widgets).map(([id, widget]) => {\n    let { x, y } = widget\n    x = x + offsetX\n    y = y + offsetY\n\n    return (\n      <Widget\n        key={id}\n        {...widget}\n        id={id}\n        x={x}\n        y={y}\n        onSelect={onSelect}\n        isSelected={selectedId === id}\n      />\n    )\n  })\n}\n\n// Separated to work around an eslint react/prop-types false positive.\nexport const Widgets = React.memo(WidgetsUnmemoized)\n"
  },
  {
    "path": "client/src/components/widgets/lib/ball.ts",
    "content": "import { ColliderDesc } from '@dimforge/rapier2d'\nimport { coords } from '../../../lib/coords'\nimport { Basis } from '../../../lib/utils'\n\ntype BallFunc = typeof ColliderDesc.ball\n/**\n * helpful function for converting paths you got from an image into a ball\n * @param create function that can create a ball\n * @param basis how to convert the provided values to a coordinate system in pixels centered at 0, 0\n * @param radius radius\n * @param center center of the ball\n */\nexport default function Ball(\n  create: BallFunc,\n  basis: Basis,\n  radius: number,\n  center: { x: number; y: number },\n): ColliderDesc {\n  const scale = basis.scale || 1\n  //pointToVectorObject(basis, center.x, center.y)\n  return create(coords.toRapier.length(radius / scale)).setTranslation(\n    ...coords.toRapier.vector(center.x, center.y, basis),\n  )\n}\n"
  },
  {
    "path": "client/src/components/widgets/lib/lineCuboid.ts",
    "content": "import type { ColliderDesc } from '@dimforge/rapier2d'\nimport { coords } from '../../../lib/coords'\nimport { Basis, RandallPath } from '../../../lib/utils'\n\nexport function lineCuboid(\n  c: typeof ColliderDesc,\n  { x1, x2, y1, y2, thickness }: RandallPath,\n  { xBasis, yBasis, scale = 1 }: Basis,\n): ColliderDesc {\n  const width = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) / scale\n  const height = thickness / scale\n  const rotation = -Math.atan2(y2 - y1, x2 - x1)\n\n  return c\n    .cuboid(...coords.toRapier.lengths(width / 2, height / 2))\n    .setTranslation(\n      ...coords.toRapier.vector(\n        ((x1 + x2) / 2 - xBasis) / scale,\n        ((y1 + y2) / 2 - yBasis) / scale,\n      ),\n    )\n    .setRotation(rotation)\n}\n"
  },
  {
    "path": "client/src/custom.d.ts",
    "content": "import type { ComicGlobal } from '.'\n\ndeclare global {\n  interface Window {\n    Comic: ComicGlobal\n  }\n\n  interface Document {\n    webkitFullscreenElement?: Element\n  }\n\n  interface HTMLElement {\n    requestFullscreen?: () => Promise<void>\n    webkitRequestFullscreen?: () => Promise<void>\n  }\n}\n"
  },
  {
    "path": "client/src/generated/api-spec.d.ts",
    "content": "/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\n\nexport interface paths {\n  \"/blueprint/file\": {\n    post: {\n      parameters: {\n        header?: {\n          \"X-WorkOrder\"?: string;\n        };\n      };\n      requestBody?: {\n        content: {\n          \"application/json;charset=utf-8\": components[\"schemas\"][\"Blueprint\"];\n        };\n      };\n      responses: {\n        200: {\n          content: {\n            \"application/json;charset=utf-8\": [components[\"schemas\"][\"UUID\"], [number, number]];\n          };\n        };\n        /** @description Invalid `body` or `X-WorkOrder` */\n        400: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/folio/{blueprintid}\": {\n    get: {\n      parameters: {\n        path: {\n          blueprintid: string;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"Folio\"];\n          };\n        };\n        /** @description `blueprintid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/machine/current\": {\n    get: {\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"VersionedMachine (Maybe BlueprintID)\"];\n          };\n        };\n      };\n    };\n  };\n  \"/machine/current/version\": {\n    get: {\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": number;\n          };\n        };\n      };\n    };\n  };\n  \"/machine/{version}\": {\n    get: {\n      parameters: {\n        path: {\n          version: number;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"VersionedMachine (Maybe BlueprintID)\"];\n          };\n        };\n        /** @description `version` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/machine/delta/{startVersion}/current\": {\n    get: {\n      parameters: {\n        path: {\n          startVersion: number;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": [number, components[\"schemas\"][\"VersionedMachine ModData\"]];\n          };\n        };\n        /** @description `startVersion` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/machine/delta/{startVersion}/{endVersion}\": {\n    get: {\n      parameters: {\n        path: {\n          startVersion: number;\n          endVersion: number;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": [number, components[\"schemas\"][\"VersionedMachine ModData\"]];\n          };\n        };\n        /** @description `startVersion` or `endVersion` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/puzzle\": {\n    get: {\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n            \"X-WorkOrder\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": {\n              [key: string]: components[\"schemas\"][\"Puzzle\"];\n            };\n          };\n        };\n      };\n    };\n  };\n  \"/moderate/puzzle/{puzzleid}/blueprintid\": {\n    get: {\n      parameters: {\n        path: {\n          puzzleid: string;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"UUID\"][];\n          };\n        };\n        /** @description `puzzleid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/puzzle/{puzzleid}/blueprint\": {\n    get: {\n      parameters: {\n        path: {\n          puzzleid: string;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": [components[\"schemas\"][\"UUID\"], components[\"schemas\"][\"Blueprint\"]][];\n          };\n        };\n        /** @description `puzzleid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/puzzle/{puzzleid}\": {\n    get: {\n      parameters: {\n        path: {\n          puzzleid: string;\n        };\n      };\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"Puzzle\"];\n          };\n        };\n        /** @description `puzzleid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/puzzle/{puzzleid}/reissue\": {\n    post: {\n      parameters: {\n        path: {\n          puzzleid: string;\n        };\n      };\n      responses: {\n        204: {\n          content: never;\n        };\n        /** @description `puzzleid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/build/{X}/{Y}\": {\n    post: {\n      parameters: {\n        path: {\n          X: number;\n          Y: number;\n        };\n      };\n      requestBody?: {\n        content: {\n          \"application/json;charset=utf-8\": components[\"schemas\"][\"InspectionReport\"];\n        };\n      };\n      responses: {\n        204: {\n          content: never;\n        };\n        /** @description Invalid `body` */\n        400: {\n          content: never;\n        };\n        /** @description `X` or `Y` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/burn/{blueprintid}\": {\n    post: {\n      parameters: {\n        path: {\n          blueprintid: string;\n        };\n      };\n      responses: {\n        204: {\n          content: never;\n        };\n        /** @description `blueprintid` not found */\n        404: {\n          content: never;\n        };\n      };\n    };\n  };\n  \"/moderate/machine/current\": {\n    get: {\n      responses: {\n        200: {\n          headers: {\n            \"Cache-Control\"?: string;\n          };\n          content: {\n            \"application/json;charset=utf-8\": components[\"schemas\"][\"VersionedMachine ModData\"];\n          };\n        };\n      };\n    };\n  };\n}\n\nexport type webhooks = Record<string, never>;\n\nexport interface components {\n  schemas: {\n    /**\n     * Format: uuid\n     * @example 00000000-0000-0000-0000-000000000000\n     */\n    UUID: string;\n    /**\n     * @example {\n     *   \"puzzle\": \"00000000-0000-0000-0000-000000000000\",\n     *   \"submittedAt\": null,\n     *   \"title\": \"Lauren Ipsum\",\n     *   \"widgets\": {}\n     * }\n     */\n    Blueprint: {\n      puzzle: string;\n      submittedAt?: components[\"schemas\"][\"UTCTime\"];\n      title: string;\n      widgets: components[\"schemas\"][\"Object\"];\n    };\n    /**\n     * Format: yyyy-mm-ddThh:MM:ssZ\n     * @example 2016-07-22T00:00:00Z\n     */\n    UTCTime: string;\n    /** @description Arbitrary JSON object. */\n    Object: {\n      [key: string]: unknown;\n    };\n    Folio: {\n      blueprint: components[\"schemas\"][\"Blueprint\"];\n      puzzle: components[\"schemas\"][\"Puzzle\"];\n      snapshot: components[\"schemas\"][\"Object\"];\n    };\n    /**\n     * @example {\n     *   \"inputs\": [\n     *     {\n     *       \"balls\": [\n     *         {\n     *           \"rate\": 1,\n     *           \"type\": 1\n     *         }\n     *       ],\n     *       \"x\": 0.5,\n     *       \"y\": 0\n     *     }\n     *   ],\n     *   \"outputs\": [\n     *     {\n     *       \"balls\": [\n     *         {\n     *           \"rate\": 1,\n     *           \"type\": 1\n     *         }\n     *       ],\n     *       \"x\": 0.5,\n     *       \"y\": 1\n     *     }\n     *   ],\n     *   \"reqTiles\": [\n     *     \"UpLeft\"\n     *   ],\n     *   \"spec\": {}\n     * }\n     */\n    Puzzle: {\n      inputs: components[\"schemas\"][\"Gateway\"][];\n      outputs: components[\"schemas\"][\"Gateway\"][];\n      reqTiles: components[\"schemas\"][\"RelativeCell\"][];\n      spec: components[\"schemas\"][\"Object\"];\n    };\n    /** @enum {string} */\n    RelativeCell: \"UpLeft\" | \"Up\" | \"UpRight\" | \"Left\" | \"Right\" | \"DownLeft\" | \"Down\" | \"DownRight\";\n    /**\n     * @example {\n     *   \"blueprint\": \"00000000-0000-0000-0000-000000000000\",\n     *   \"puzzle\": \"00000000-0000-0000-0000-000000000000\",\n     *   \"to_mod\": 20\n     * }\n     */\n    Gateway: {\n      balls: components[\"schemas\"][\"GatewayBall\"][];\n      /** Format: double */\n      x: number;\n      /** Format: double */\n      y: number;\n    };\n    GatewayBall: {\n      /** Format: double */\n      rate: number;\n      type: number;\n    };\n    /**\n     * @example {\n     *   \"grid\": [\n     *     [\n     *       \"00000000-0000-0000-0000-000000000000\",\n     *       \"00000000-0000-0000-0000-000000000000\"\n     *     ],\n     *     [\n     *       \"00000000-0000-0000-0000-000000000000\",\n     *       null\n     *     ]\n     *   ],\n     *   \"ms_per_ball\": 0.001,\n     *   \"prio_puzzles\": [],\n     *   \"tile_size\": {\n     *     \"x\": 700,\n     *     \"y\": 700\n     *   },\n     *   \"version\": 0\n     * }\n     */\n    \"VersionedMachine (Maybe BlueprintID)\": {\n      grid: components[\"schemas\"][\"UUID\"][][];\n      /** Format: double */\n      ms_per_ball: number;\n      prio_puzzle?: components[\"schemas\"][\"UUID\"][];\n      tile_size: components[\"schemas\"][\"TileSize\"];\n      version: number;\n    };\n    TileSize: {\n      x: number;\n      y: number;\n    };\n    /**\n     * @example {\n     *   \"grid\": [\n     *     [\n     *       {\n     *         \"blueprint\": \"00000000-0000-0000-0000-000000000000\",\n     *         \"puzzle\": \"00000000-0000-0000-0000-000000000000\"\n     *       },\n     *       {\n     *         \"puzzle\": \"00000000-0000-0000-0000-000000000000\",\n     *         \"to_mod\": 20\n     *       }\n     *     ],\n     *     [\n     *       {\n     *         \"puzzle\": \"00000000-0000-0000-0000-000000000000\",\n     *         \"to_mod\": 11\n     *       },\n     *       {\n     *         \"puzzle\": \"00000000-0000-0000-0000-000000000000\"\n     *       }\n     *     ]\n     *   ],\n     *   \"ms_per_ball\": 0.001,\n     *   \"prio_puzzles\": [],\n     *   \"tile_size\": {\n     *     \"x\": 700,\n     *     \"y\": 700\n     *   },\n     *   \"version\": 0\n     * }\n     */\n    \"VersionedMachine ModData\": {\n      grid: components[\"schemas\"][\"ModData\"][][];\n      /** Format: double */\n      ms_per_ball: number;\n      prio_puzzle?: components[\"schemas\"][\"UUID\"][];\n      tile_size: components[\"schemas\"][\"TileSize\"];\n      version: number;\n    };\n    InspectionReport: {\n      blueprint: components[\"schemas\"][\"UUID\"];\n      snapshot: components[\"schemas\"][\"Object\"];\n    };\n    /**\n     * @example {\n     *   \"blueprint\": \"00000000-0000-0000-0000-000000000000\",\n     *   \"puzzle\": \"00000000-0000-0000-0000-000000000000\",\n     *   \"to_mod\": 20\n     * }\n     */\n    ModData: {\n      blueprint?: string;\n      puzzle: string;\n      to_mod?: number;\n    };\n  };\n  responses: never;\n  parameters: never;\n  requestBodies: never;\n  headers: never;\n  pathItems: never;\n}\n\nexport type $defs = Record<string, never>;\n\nexport type external = Record<string, never>;\n\nexport type operations = Record<string, never>;\n"
  },
  {
    "path": "client/src/image.d.ts",
    "content": "declare module '*.png' {\n  interface ComicImage {\n    width: number\n    height: number\n    url: {\n      '4x': string\n      '2x': string\n    }\n    srcSet: string\n  }\n  const content: ComicImage\n  export default content\n}\n"
  },
  {
    "path": "client/src/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title><%= comic.name %></title>\n    <style>\n      @font-face {\n        font-family: 'xkcd-Regular-v3';\n        src: url('https://xkcd.com/s/4fcbf3.woff') format('woff');\n      }\n\n      body {\n        text-align: center;\n      }\n    </style>\n  </head>\n  <body>\n    <!-- Comic HTML -->\n    <div id=\"comic\" style=\"height: <%= comic.height %>px; width: <%= comic.width %>px\"></div>\n\n    <!-- Embeds -->\n    <%= tags.bodyTags %>\n  </body>\n</html>\n"
  },
  {
    "path": "client/src/index.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\n\nimport { QueryClientProvider } from '@tanstack/react-query'\nimport comic from '../comic.json'\nimport { queryClient } from './api'\nimport Comic from './components/Comic'\n\n// TODO: comic global\nexport interface ComicGlobal {}\n\nexport function styleContainer(el: HTMLElement) {\n  el.style.cssText = `\n  position: relative;\n  width: ${comic.width}px;\n  height: ${comic.height}px;\n  margin: 0 auto;\n  font-variant: normal;\n`\n}\n\nfunction init() {\n  const comicEl = document.getElementById('comic')\n  if (!comicEl) {\n    return\n  }\n\n  styleContainer(comicEl)\n\n  const root = createRoot(comicEl)\n  root.render(\n    <StrictMode>\n      <QueryClientProvider client={queryClient}>\n        <Comic />\n      </QueryClientProvider>\n    </StrictMode>,\n  )\n}\n\ninit()\n"
  },
  {
    "path": "client/src/lib/coords.ts",
    "content": "import type { Collider, RigidBody } from '@dimforge/rapier2d'\nimport type { Vector } from '../types'\nimport { Basis } from './utils'\n\nexport const M_PER_PX = 1 / 50\n\nexport const coords = {\n  toRapier: {\n    x(distance: number) {\n      return distance * M_PER_PX\n    },\n\n    y(distance: number) {\n      return -distance * M_PER_PX\n    },\n\n    length(this: void, length: number) {\n      return length * M_PER_PX\n    },\n\n    lengths<T extends number[]>(...lengths: T): T {\n      return lengths.map(this.length) as T\n    },\n\n    vector(\n      x: number,\n      y: number,\n      { xBasis, yBasis, scale = 1 }: Basis = { xBasis: 0, yBasis: 0, scale: 1 },\n    ): [number, number] {\n      return [this.x((x - xBasis) / scale), this.y((y - yBasis) / scale)]\n    },\n\n    vectorObject(\n      x: number,\n      y: number,\n      { xBasis, yBasis, scale = 1 }: Basis = { xBasis: 0, yBasis: 0, scale: 1 },\n    ): Vector {\n      return {\n        x: this.x((x - xBasis) / scale),\n        y: this.y((y - yBasis) / scale),\n      }\n    },\n\n    angle(angle: number) {\n      return -angle\n    },\n  },\n\n  fromRapier: {\n    x(distance: number) {\n      return distance / M_PER_PX\n    },\n\n    y(distance: number) {\n      return -distance / M_PER_PX\n    },\n\n    length(this: void, length: number) {\n      return length / M_PER_PX\n    },\n\n    vector(x: number, y: number): [number, number] {\n      return [this.x(x), this.y(y)]\n    },\n\n    angle(angle: number) {\n      return -angle\n    },\n  },\n\n  fromBody: {\n    vector(body: RigidBody | Collider) {\n      const { x, y } = body.translation()\n      return coords.fromRapier.vector(x, y)\n    },\n\n    angle(body: RigidBody | Collider) {\n      return coords.fromRapier.angle(body.rotation())\n    },\n  },\n}\n\nexport function vectorMagnitude(a: Vector) {\n  return Math.sqrt(a.x * a.x + a.y * a.y)\n}\n\nexport function vectorDistance(a: Vector, b: Vector) {\n  return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2))\n}\n\nexport function vectorAngle(a: Vector, b: Vector) {\n  return Math.atan2(b.y - a.y, b.x - a.x)\n}\n\nexport function vectorDifference(a: Vector, b: Vector): Vector {\n  return { x: a.x - b.x, y: a.y - b.y }\n}\n\nexport function vectorNorm(a: Vector): Vector {\n  const invLength = 1.0 / Math.sqrt(a.x * a.x + a.y * a.y)\n\n  return { x: a.x * invLength, y: a.y * invLength }\n}\n\nexport function vectorScale(a: Vector, scale: number): Vector {\n  return { x: a.x * scale, y: a.y * scale }\n}\n\nexport function vectorRotate(toRotate: Vector, angleDegrees: number): Vector {\n  const angle = angleDegrees * (Math.PI / 180.0)\n  const cosA = Math.cos(angle)\n  const sinA = Math.cos(angle)\n  const { x, y } = toRotate\n\n  return { x: cosA * x - sinA * y, y: sinA * x + cosA * y }\n}\n"
  },
  {
    "path": "client/src/lib/snapshot.tsx",
    "content": "import type { RigidBody, Vector } from '@dimforge/rapier2d'\nimport { Angled, BallType } from '../types'\n\nexport interface BodySnapshot extends Vector, Angled {\n  vx: number\n  vy: number\n  va: number\n}\n\nexport interface WidgetSnapshot extends BodySnapshot {\n  key: string\n}\n\nexport interface BallSnapshot extends BodySnapshot {\n  type: BallType\n  age: number\n}\n\nexport interface MachineSnapshot {\n  widgets: Record<string, WidgetSnapshot>\n  balls: BallSnapshot[]\n}\n\nexport function snapshotBody(body: RigidBody): BodySnapshot {\n  const { x: vx, y: vy } = body.linvel()\n  return {\n    ...body.translation(),\n    angle: body.rotation(),\n    vx,\n    vy,\n    va: body.angvel(),\n  }\n}\nexport function applySnapshotToBody(\n  snapshot: BodySnapshot,\n  body: RigidBody,\n  offsetX = 0,\n  offsetY = 0,\n) {\n  body.setTranslation(\n    { x: snapshot.x + offsetX, y: snapshot.y + offsetY },\n    true,\n  )\n  body.setRotation(snapshot.angle, true)\n  body.setLinvel({ x: snapshot.vx, y: snapshot.vy }, true)\n  body.setAngvel(snapshot.va, true)\n}\n\nexport function offsetSnapshot<T extends BodySnapshot>(\n  xOffset: number,\n  yOffset: number,\n  { x, y, ...rest }: T,\n) {\n  return { ...rest, x: x + xOffset, y: y + yOffset }\n}\n"
  },
  {
    "path": "client/src/lib/tiles.tsx",
    "content": "import { Bounds } from '../types'\nimport { intersectBounds } from './utils'\n\nexport type Grid<T> = T[][]\n\nexport function tileBounds(\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n  tileWidth: number,\n  tileHeight: number,\n  outset: number = 0,\n): Bounds {\n  const xt1 = Math.floor((x1 - outset) / tileWidth)\n  const yt1 = Math.floor((y1 - outset) / tileHeight)\n\n  // The bottom right bounds are -1 tile to factor in the last tile's width and height.\n  const xt2 = Math.ceil((x2 + outset) / tileWidth) - 1\n  const yt2 = Math.ceil((y2 + outset) / tileHeight) - 1\n\n  return [xt1, yt1, xt2, yt2]\n}\n\nexport function* iterTiles(\n  xt1: number,\n  yt1: number,\n  xt2: number,\n  yt2: number,\n): Generator<[xt: number, yt: number]> {\n  for (let yt = yt1; yt <= yt2; yt++) {\n    for (let xt = xt1; xt <= xt2; xt++) {\n      yield [xt, yt]\n    }\n  }\n}\n\nexport function gridDimensions(grid: Grid<unknown>) {\n  const tilesX = grid[0].length\n  const tilesY = grid.length\n  return [tilesX, tilesY]\n}\n\nexport function gridViewBounds(\n  viewBounds: Bounds,\n  tilesX: number,\n  tilesY: number,\n  tileWidth: number,\n  tileHeight: number,\n  outset: number,\n): Bounds {\n  const rawBounds = tileBounds(...viewBounds, tileWidth, tileHeight, outset)\n  return intersectBounds(rawBounds, [0, 0, tilesX - 1, tilesY - 1])\n}\n\nexport function tileKey(xt: number, yt: number) {\n  return `${xt},${yt}`\n}\n"
  },
  {
    "path": "client/src/lib/utils.ts",
    "content": "import { useCallback, useRef } from 'react'\nimport { Bounds } from '../types'\n\nexport function useIdGen(getInitial?: () => number) {\n  const nextIdRef = useRef<number | null>(null)\n  if (nextIdRef.current === null) {\n    nextIdRef.current = getInitial ? getInitial() : 0\n  }\n  return useCallback(() => String(nextIdRef.current!++), [])\n}\n\nexport function px(value: number) {\n  return `${value}px`\n}\n\nexport function percent(value: number) {\n  return `${(100 * value).toFixed(2)}%`\n}\n\nexport function ms(value: number) {\n  return `${value.toFixed(1)}ms`\n}\n\nexport function inBounds(x: number, y: number, [x1, y1, x2, y2]: Bounds) {\n  return x >= x1 && x <= x2 && y >= y1 && y <= y2\n}\n\nexport function inBoundsOutset(\n  x: number,\n  y: number,\n  outset: number,\n  [x1, y1, x2, y2]: Bounds,\n) {\n  return (\n    x >= x1 - outset && x <= x2 + outset && y >= y1 - outset && y <= y2 + outset\n  )\n}\n\nexport function inBoundsObject(\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  [x1, y1, x2, y2]: Bounds,\n) {\n  return (\n    x >= x1 - 0.5 * width &&\n    x <= x2 + 0.5 * width &&\n    y >= y1 - 0.5 * height &&\n    y <= y2 + 0.5 * height\n  )\n}\n\nexport function intersectBounds(\n  [ax1, ay1, ax2, ay2]: Bounds,\n  [bx1, by1, bx2, by2]: Bounds,\n): Bounds {\n  return [\n    Math.max(ax1, bx1),\n    Math.max(ay1, by1),\n    Math.min(ax2, bx2),\n    Math.min(ay2, by2),\n  ]\n}\n\nexport function rotate(\n  rotationAngle: number,\n  { x, y }: { x: number; y: number },\n) {\n  return {\n    x: x * Math.cos(rotationAngle) - y * Math.sin(rotationAngle),\n    y: x * Math.sin(rotationAngle) + y * Math.cos(rotationAngle),\n  }\n}\n\nexport interface Basis {\n  xBasis: number\n  yBasis: number\n  scale?: number\n}\n\nexport interface RandallPath {\n  x1: number\n  y1: number\n  x2: number\n  y2: number\n  thickness: number\n}\n"
  },
  {
    "path": "client/src/page/demo-editor.tsx",
    "content": "import { StrictMode, useRef, useState } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport comic from '../../comic.json'\nimport InnerComicBorder from '../components/InnerComicBorder'\nimport {\n  MachineContextProvider,\n  MachineContextProviderRef,\n} from '../components/MachineContext'\nimport {\n  MachineTileContextProvider,\n  MachineTileContextProviderRef,\n} from '../components/MachineTileContext'\nimport MachineTileEditor from '../components/MachineTileEditor'\nimport { PhysicsContextProvider } from '../components/PhysicsContext'\nimport { MachineSnapshot } from '../lib/snapshot'\nimport { Bounds } from '../types'\nimport { emptyPuzzle, emptyWidgets } from './fixtures/emptyMachine'\n\nconst comicBounds: Bounds = [0, 0, comic.width, comic.height]\n\nfunction DemoEditor() {\n  const machineRef = useRef<MachineContextProviderRef>()\n  const machineTileRef = useRef<MachineTileContextProviderRef>()\n  const [snapshot, setSnapshot] = useState<MachineSnapshot | undefined>()\n\n  return (\n    <div css={{ width: '100%', height: '100%' }}>\n      <PhysicsContextProvider debug>\n        <MachineContextProvider\n          ref={machineRef}\n          initialSimulationBounds={comicBounds}\n          initialViewBounds={comicBounds}\n          msPerBall={1000}\n        >\n          <MachineTileContextProvider\n            ref={machineTileRef}\n            bounds={[0, 0, comic.width, comic.height]}\n          >\n            <MachineTileEditor\n              puzzle={emptyPuzzle}\n              initialWidgets={emptyWidgets}\n            />\n          </MachineTileContextProvider>\n        </MachineContextProvider>\n      </PhysicsContextProvider>\n      <button\n        onClick={() => {\n          const nextSnapshot = machineTileRef.current?.snapshot()\n          setSnapshot(nextSnapshot)\n          console.log(nextSnapshot)\n        }}\n      >\n        snapshot\n      </button>\n      <button\n        onClick={() => {\n          if (!snapshot) {\n            return\n          }\n          machineRef.current?.clearBalls()\n          machineTileRef.current?.loadSnapshot(snapshot)\n        }}\n      >\n        load\n      </button>\n    </div>\n  )\n}\n\nconst root = createRoot(document.getElementsByTagName('main')[0])\nroot.render(\n  <StrictMode>\n    <InnerComicBorder>\n      <DemoEditor />\n    </InnerComicBorder>\n  </StrictMode>,\n)\n"
  },
  {
    "path": "client/src/page/demo-map.tsx",
    "content": "import { StrictMode, useEffect, useRef } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { SlippyMapRef } from '../components/CenteredSlippyMap'\nimport InnerComicBorder from '../components/InnerComicBorder'\nimport { SlippyMetaMachineView } from '../components/MetaMachineView'\nimport { PhysicsContextProvider } from '../components/PhysicsContext'\nimport { emptyPuzzle } from './fixtures/emptyMachine'\n\nconst getMachine = () => ({\n  puzzle: emptyPuzzle,\n  widgets: {},\n  snapshot: { widgets: {}, balls: [] },\n})\n\nfunction DemoMap({ demoPanning }: { demoPanning?: boolean }) {\n  const mapRef = useRef<SlippyMapRef>(null)\n\n  useEffect(() => {\n    if (!demoPanning) {\n      return\n    }\n    const interval = setInterval(() => {\n      void mapRef.current?.animateTo(\n        1000 + 1000 * Math.random(),\n        1000 + 1000 * Math.random(),\n        0.25 + Math.random(),\n      )\n    }, 1000)\n    return () => {\n      clearInterval(interval)\n    }\n  }, [demoPanning])\n\n  return (\n    <SlippyMetaMachineView\n      ref={mapRef}\n      getMachine={getMachine}\n      tilesX={100}\n      tilesY={100}\n      tileWidth={740}\n      tileHeight={740}\n      initialX={370}\n      initialY={370}\n      initialZoom={1}\n      msPerBall={1000}\n    />\n  )\n}\n\nconst root = createRoot(document.getElementsByTagName('main')[0])\nroot.render(\n  <StrictMode>\n    <PhysicsContextProvider>\n      <InnerComicBorder>\n        <DemoMap />\n      </InnerComicBorder>\n    </PhysicsContextProvider>\n  </StrictMode>,\n)\n"
  },
  {
    "path": "client/src/page/demo-viewer.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query'\nimport { StrictMode, useState } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport comic from '../../comic.json'\nimport { queryClient } from '../api'\nimport InnerComicBorder from '../components/InnerComicBorder'\nimport LoadingSpinner from '../components/LoadingSpinner'\nimport { SlippyMetaMachineView } from '../components/MetaMachineView'\nimport { PhysicsContextProvider } from '../components/PhysicsContext'\nimport { useMetaMachineClient } from '../components/useMetaMachineClient'\nimport { Bounds } from '../types'\n\nfunction DemoViewer() {\n  const [viewBounds, setViewBounds] = useState<Bounds>(() => [\n    0,\n    0,\n    comic.width,\n    comic.height,\n  ])\n\n  const { metaMachine } = useMetaMachineClient({ viewBounds })\n\n  /*\n  const clippedMetaMachine = useMemo(\n    () =>\n      metaMachine\n        ? {\n            ...metaMachine,\n            tilesY: 1,\n          }\n        : null,\n    [metaMachine],\n  )\n  */\n\n  if (!metaMachine) {\n    return <LoadingSpinner />\n  }\n\n  return (\n    <SlippyMetaMachineView\n      {...metaMachine}\n      initialX={metaMachine.tileWidth / 2}\n      initialY={metaMachine.tileHeight / 2}\n      initialZoom={0.8}\n      onPosition={setViewBounds}\n    />\n  )\n}\n\nconst root = createRoot(document.getElementsByTagName('main')[0])\nroot.render(\n  <StrictMode>\n    <QueryClientProvider client={queryClient}>\n      <PhysicsContextProvider>\n        <InnerComicBorder>\n          <DemoViewer />\n        </InnerComicBorder>\n      </PhysicsContextProvider>\n    </QueryClientProvider>\n  </StrictMode>,\n)\n"
  },
  {
    "path": "client/src/page/fixtures/demoMachine.tsx",
    "content": "import { MachineSnapshot } from '../../lib/snapshot'\nimport { WidgetCollection } from '../../types'\n\nexport const demoWidgets: WidgetCollection = {\n  '5': {\n    type: 'box',\n    x: 250,\n    y: 250,\n    width: 300,\n    height: 50,\n    radius: 10,\n    angle: 0.5,\n  },\n  '6': {\n    type: 'box',\n    x: 500,\n    y: 500,\n    width: 300,\n    height: 50,\n    radius: 10,\n    angle: -0.5,\n  },\n}\n\nexport const demoMachineSnapshot = JSON.parse(\n  '{\"widgets\":{},\"balls\":[{\"x\":8.975435256958008,\"y\":-9.810032844543457,\"angle\":2.885564088821411,\"vx\":-2.705263376235962,\"vy\":-1.5371406078338623,\"va\":19.34440040588379,\"content\":\"a\"},{\"x\":7.692915439605713,\"y\":-10.51138687133789,\"angle\":-2.2343804836273193,\"vx\":-3.9428491592407227,\"vy\":-2.2134084701538086,\"va\":28.329132080078125,\"content\":\"a\"},{\"x\":4.501882076263428,\"y\":-13.557295799255371,\"angle\":-0.1986425369977951,\"vx\":-5.142702102661133,\"vy\":-7.7870965003967285,\"va\":31.062326431274414,\"content\":\"a\"},{\"x\":5.1932291984558105,\"y\":-12.760199546813965,\"angle\":1.2377831935882568,\"vx\":-4.3410539627075195,\"vy\":-6.500412940979004,\"va\":8.374754905700684,\"content\":\"a\"},{\"x\":4.514957904815674,\"y\":-13.993621826171875,\"angle\":-1.2085216045379639,\"vx\":-4.4352498054504395,\"vy\":-8.419651985168457,\"va\":-25.047870635986328,\"content\":\"a\"},{\"x\":9.449705123901367,\"y\":-9.26404094696045,\"angle\":-2.575472593307495,\"vx\":-0.8248745203018188,\"vy\":-3.4031763076782227,\"va\":-3.193057060241699,\"content\":\"a\"},{\"x\":7.161441326141357,\"y\":-10.801380157470703,\"angle\":-1.4147210121154785,\"vx\":-3.81803560256958,\"vy\":-2.145299196243286,\"va\":27.38218116760254,\"content\":\"a\"},{\"x\":4.619111061096191,\"y\":-12.328167915344238,\"angle\":2.2932069301605225,\"vx\":-5.486085891723633,\"vy\":-6.218703746795654,\"va\":-1.2065143585205078,\"content\":\"a\"},{\"x\":6.813584327697754,\"y\":-11.048445701599121,\"angle\":1.022476077079773,\"vx\":-3.7953476905822754,\"vy\":-3.113901376724243,\"va\":27.218482971191406,\"content\":\"a\"},{\"x\":10.294193267822266,\"y\":-8.79842472076416,\"angle\":2.754018783569336,\"vx\":-0.5922894477844238,\"vy\":1.405465841293335,\"va\":-4.644935131072998,\"content\":\"a\"},{\"x\":9.193114280700684,\"y\":-9.453070640563965,\"angle\":3.117631673812866,\"vx\":-1.8953272104263306,\"vy\":-2.372345209121704,\"va\":-22.189990997314453,\"content\":\"a\"},{\"x\":6.585815906524658,\"y\":-10.611485481262207,\"angle\":-2.3107378482818604,\"vx\":-5.231949806213379,\"vy\":-3.8384335041046143,\"va\":-5.054391384124756,\"content\":\"a\"},{\"x\":10.743350982666016,\"y\":-8.557778358459473,\"angle\":-1.5281773805618286,\"vx\":0.9246402978897095,\"vy\":0.6801395416259766,\"va\":6.3471879959106445,\"content\":\"a\"},{\"x\":5.733545780181885,\"y\":-11.912044525146484,\"angle\":2.4343180656433105,\"vx\":-4.162356853485107,\"vy\":-4.988518238067627,\"va\":21.603681564331055,\"content\":\"a\"},{\"x\":8.61172866821289,\"y\":-6.4012298583984375,\"angle\":-0.5336070656776428,\"vx\":3.637646436691284,\"vy\":-4.474791526794434,\"va\":-24.598651885986328,\"content\":\"a\"},{\"x\":7.655956268310547,\"y\":-9.740026473999023,\"angle\":3.051384210586548,\"vx\":-4.372570514678955,\"vy\":-2.2418456077575684,\"va\":22.209049224853516,\"content\":\"a\"},{\"x\":7.4611616134643555,\"y\":-10.295101165771484,\"angle\":0.9108231663703918,\"vx\":-4.526586055755615,\"vy\":-2.7867684364318848,\"va\":-23.128990173339844,\"content\":\"a\"},{\"x\":8.679278373718262,\"y\":-7.47233772277832,\"angle\":1.0729705095291138,\"vx\":2.3603856563568115,\"vy\":-7.143428325653076,\"va\":-30.80522918701172,\"content\":\"a\"},{\"x\":9.778550148010254,\"y\":-8.408007621765137,\"angle\":2.385110378265381,\"vx\":3.560293197631836,\"vy\":-8.745379447937012,\"va\":-8.403362274169922,\"content\":\"a\"},{\"x\":6.7873358726501465,\"y\":-5.226980686187744,\"angle\":2.788355827331543,\"vx\":3.381765365600586,\"vy\":-1.9069362878799438,\"va\":-24.230148315429688,\"content\":\"a\"},{\"x\":8.536648750305176,\"y\":-9.046698570251465,\"angle\":-2.760697603225708,\"vx\":1.303579330444336,\"vy\":-10.710572242736816,\"va\":6.679976940155029,\"content\":\"a\"},{\"x\":8.941713333129883,\"y\":-5.7540974617004395,\"angle\":-1.2194831371307373,\"vx\":4.259580612182617,\"vy\":-3.652670383453369,\"va\":24.855722427368164,\"content\":\"a\"},{\"x\":9,\"y\":-6.957531452178955,\"angle\":0,\"vx\":0,\"vy\":-10.791023254394531,\"va\":0,\"content\":\"a\"},{\"x\":6.574840068817139,\"y\":-4.898984432220459,\"angle\":0.07853177189826965,\"vx\":2.384286403656006,\"vy\":-2.0486931800842285,\"va\":30.227041244506836,\"content\":\"a\"},{\"x\":5.728461265563965,\"y\":-4.241207122802734,\"angle\":2.634758949279785,\"vx\":3.3576877117156982,\"vy\":-0.8329310417175293,\"va\":-16.845815658569336,\"content\":\"a\"},{\"x\":7,\"y\":-3.533907413482666,\"angle\":0,\"vx\":0,\"vy\":-7.030494213104248,\"va\":0,\"content\":\"a\"},{\"x\":8,\"y\":-2.9803924560546875,\"angle\":0,\"vx\":0,\"vy\":-6.2129950523376465,\"va\":0,\"content\":\"a\"},{\"x\":9,\"y\":-2.7780611515045166,\"angle\":0,\"vx\":0,\"vy\":-5.885995388031006,\"va\":0,\"content\":\"a\"},{\"x\":5,\"y\":-2.495002031326294,\"angle\":0,\"vx\":0,\"vy\":-5.395495891571045,\"va\":0,\"content\":\"a\"},{\"x\":6,\"y\":-1.8600777387619019,\"angle\":0,\"vx\":0,\"vy\":-4.087497234344482,\"va\":0,\"content\":\"a\"},{\"x\":5,\"y\":-1.1686092615127563,\"angle\":0,\"vx\":0,\"vy\":-1.7984994649887085,\"va\":0,\"content\":\"a\"},{\"x\":7,\"y\":-1.1396561861038208,\"angle\":0,\"vx\":0,\"vy\":-1.6349996328353882,\"va\":0,\"content\":\"a\"},{\"x\":9,\"y\":-1.069146752357483,\"angle\":0,\"vx\":0,\"vy\":-1.1445001363754272,\"va\":0,\"content\":\"a\"}]}',\n) as MachineSnapshot\n"
  },
  {
    "path": "client/src/page/fixtures/emptyMachine.tsx",
    "content": "import { Puzzle, WidgetCollection } from '../../types'\n\nexport const emptyPuzzle: Puzzle = {\n  inputs: [\n    { x: 0.5, y: 0, balls: [{ type: 1, rate: 1 }] },\n    { x: 0, y: 0.5, balls: [{ type: 2, rate: 1 }] },\n    { x: 1, y: 0.75, balls: [{ type: 3, rate: 1 }] },\n    { x: 1, y: 0.25, balls: [{ type: 4, rate: 1 }] },\n  ],\n  outputs: [\n    { x: 0.5, y: 1, balls: [{ type: 1, rate: 1 }] },\n    { x: 1, y: 0.5, balls: [{ type: 2, rate: 1 }] },\n    { x: 0, y: 0.75, balls: [{ type: 3, rate: 1 }] },\n    { x: 0, y: 0.25, balls: [{ type: 4, rate: 1 }] },\n  ],\n}\n\nexport const emptyWidgets: WidgetCollection = {}\n"
  },
  {
    "path": "client/src/page/moderator.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query'\nimport { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { queryClient } from '../api'\nimport Moderator from '../components/moderation/Moderator'\n\nconst root = createRoot(document.getElementsByTagName('main')[0])\nroot.render(\n  <StrictMode>\n    <QueryClientProvider client={queryClient}>\n      <Moderator />\n    </QueryClientProvider>\n  </StrictMode>,\n)\n"
  },
  {
    "path": "client/src/page/page.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title><%= comic.name %></title>\n    <style>\n      @font-face {\n        font-family: 'xkcd-Regular-v3';\n        src: url('https://xkcd.com/s/4fcbf3.woff') format('woff');\n      }\n\n      body {\n        text-align: center;\n      }\n    </style>\n  </head>\n  <body>\n    <main />\n    <%= tags.bodyTags %>\n  </body>\n</html>\n"
  },
  {
    "path": "client/src/types.ts",
    "content": "import type { RigidBody, Vector } from '@dimforge/rapier2d'\nimport { isNumber, isString } from 'lodash'\nimport { WidgetData } from './components/widgets'\n\nexport type { Vector }\n\nexport interface Sized {\n  width: number\n  height: number\n}\n\nexport interface Angled {\n  angle: number\n}\n\nexport interface OutputLoc {\n  pos: Vector\n}\n\nexport type BallType = number\nexport type BallTypeRate = { type: BallType; rate: number }\nexport type PuzzlePosition = { balls: BallTypeRate[] } & Vector\n\nexport interface Puzzle {\n  inputs: PuzzlePosition[]\n  outputs: PuzzlePosition[]\n}\n\nexport interface PuzzleOrder extends Puzzle {\n  id: string\n  workOrder: string\n}\n\nexport type WidgetCollection = Record<string, WidgetData>\n\nexport type Bounds = [x1: number, y1: number, x2: number, y2: number]\n\nexport type BallData = {\n  type: 'BallData'\n  id: string\n  ballType: BallType\n}\n\nexport type UserData = BallData & { type: unknown }\n\nexport type Ball = RigidBody & { userData: BallData }\n\nexport function isBall(body: RigidBody): body is Ball {\n  const userData = body.userData\n  if (userData == null) {\n    return false\n  }\n  const ballData = userData as BallData\n  return (\n    ballData.type === 'BallData' &&\n    isNumber(ballData.ballType) &&\n    isString(ballData.id)\n  )\n}\n"
  },
  {
    "path": "client/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"sourceMap\": true,\n    \"noImplicitAny\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"es2020\",\n    \"target\": \"es6\",\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"@emotion/react\",\n    \"allowJs\": true,\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"paths\": {\n      \"@art/*\": [\"./art/*\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "client/webpack.config.js",
    "content": "import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'\nimport HtmlWebpackPlugin from 'html-webpack-plugin'\nimport path from 'path'\nimport reactRefreshBabel from 'react-refresh/babel'\nimport TerserPlugin from 'terser-webpack-plugin'\nimport webpack from 'webpack'\n\nimport comicData from './comic.json' assert { type: 'json' }\n\nfunction buildComic(_env, argv) {\n  return {\n    name: 'comic',\n    entry: {\n      index: './src/index.tsx',\n      moderator: './src/page/moderator.tsx',\n      'demo-editor': './src/page/demo-editor.tsx',\n      'demo-map': './src/page/demo-map.tsx',\n      'demo-viewer': './src/page/demo-viewer.tsx',\n    },\n    output: {\n      path: path.resolve(import.meta.dirname, 'built'),\n      filename: '[name].js',\n      publicPath: comicData.publicPath,\n    },\n    module: {\n      rules: [\n        {\n          test: /\\.tsx?$/,\n          use: {\n            loader: 'babel-loader',\n            options: {\n              plugins: [\n                argv.mode === 'development' && reactRefreshBabel,\n              ].filter(Boolean),\n            },\n          },\n          exclude: /node_modules/,\n        },\n        {\n          test: /\\.png$/,\n          use: {\n            loader: 'comic-image-loader',\n            options: {\n              name: 'static/[contenthash:6].[ext]',\n              publicPath: comicData.publicPath,\n              quant: true,\n              baseScale: 4,\n              scales: [2, 4],\n            },\n          },\n        },\n      ],\n    },\n    resolve: {\n      extensions: ['.tsx', '.ts', '.js'],\n      alias: {\n        lodash: 'lodash-es',\n        '@art': path.resolve(import.meta.dirname, 'art/'),\n      },\n    },\n    resolveLoader: {\n      modules: ['node_modules', path.resolve(import.meta.dirname, 'loaders')],\n    },\n    cache: argv.mode === 'development' ? { type: 'filesystem' } : false,\n    devtool: argv.mode === 'development' ? 'eval-source-map' : 'source-map',\n    optimization: {\n      minimizer: [\n        new TerserPlugin({\n          extractComments: false,\n        }),\n      ],\n    },\n    plugins: [\n      new webpack.BannerPlugin(\n        'by chromako.de, spyhi, oh no, LiraNuna, and DirtyPunk',\n      ),\n      new webpack.EnvironmentPlugin({ API_ENDPOINT: null }),\n      argv.mode === 'development' && new ReactRefreshWebpackPlugin(),\n      ...['index', 'moderator', 'demo-map', 'demo-editor', 'demo-viewer'].map(\n        (name) =>\n          new HtmlWebpackPlugin({\n            inject: false,\n            minify: false,\n            scriptLoading: 'blocking',\n            template: `src/${name === 'index' ? 'index' : 'page/page'}.ejs`,\n            filename: `${name}.html`,\n            chunks: [name],\n            templateParameters: (_compilation, _assets, assetTags) => ({\n              tags: assetTags,\n              comic: comicData,\n            }),\n          }),\n      ),\n    ].filter(Boolean),\n    experiments: {\n      asyncWebAssembly: true,\n    },\n    devServer: {\n      hot: true,\n    },\n  }\n}\n\nexport default buildComic\n"
  },
  {
    "path": "config/incredible.toml",
    "content": "[web]\nport = 8888\nbase_url = \"/\"\norigins = [\"http://localhost\", \"http://127.0.0.1\", \"http://localhost:8889\", \"http://localhost:8888\", \"http://127.0.0.1:8889\", \"http://127.0.0.1:8888\"]\n\n[cache]\nmachines = 1000\npuzzles = 10\nblueprints = 100000\ndeltas = 20000\nsnapshots = 20000\nblueprint2puzzle = 20000\n\n[mods]\nuser = \"$2b$05$LVPgqAq9laeGKP9QofeRCu/rak.9pRnRCm4cVXwafpETcCLR3R8ta\"\n\n[redis]\ndatabase = 0\nhost = \"127.0.0.1\"\nport = 6379\nmaxConnections = 10000\nmaxIdleTimeout = 60\nretry_count = 5\nworkorder_ttl = 7200 # 2 hours\norderbook_ttl = 1200 # 20 minutes\ntls = false\n"
  },
  {
    "path": "config/machine.json",
    "content": "{\"tile_size\":{\"x\":740,\"y\":740},\"ms_per_ball\":1000.0,\"prio_puzzles\":[],\"grid\":[[{\"reqTiles\":[],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.8,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.8,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.65,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.65,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Right\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.65,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.65,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.8,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.8,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.65,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.65,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.35,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.35,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Right\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.65,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.8,\"balls\":[{\"type\":3,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.8,\"balls\":[{\"type\":3,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":1.0}]},{\"x\":1.0,\"y\":0.35,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.65,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.65,\"balls\":[{\"type\":2,\"rate\":1.0}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.35,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.8,\"balls\":[{\"type\":3,\"rate\":0.25}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":1.0}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.8,\"balls\":[{\"type\":3,\"rate\":0.25}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.625}]},{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.625}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}],[{\"reqTiles\":[\"Up\",\"Right\"],\"inputs\":[{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":0.8,\"y\":1.0,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.5}]},{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":2,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":1.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\",\"Left\"],\"inputs\":[{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.25}]},{\"x\":0.8,\"y\":0.0,\"balls\":[{\"type\":1,\"rate\":0.5}]},{\"x\":0.0,\"y\":0.5,\"balls\":[{\"type\":3,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.35,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.375}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.375}]},{\"x\":0.0,\"y\":0.2,\"balls\":[{\"type\":1,\"rate\":0.5}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.625}]},{\"x\":0.5,\"y\":0.0,\"balls\":[{\"type\":3,\"rate\":0.625}]}],\"outputs\":[{\"x\":0.2,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.625}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":3,\"rate\":0.625}]}],\"spec\":{}},{\"reqTiles\":[\"Up\"],\"inputs\":[{\"x\":0.2,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.35,\"y\":0.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"outputs\":[{\"x\":0.5,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]},{\"x\":0.65,\"y\":1.0,\"balls\":[{\"type\":4,\"rate\":0.5}]}],\"spec\":{}}]]}"
  },
  {
    "path": "docs/Main.hs",
    "content": "{-# LANGUAGE DataKinds #-}\n{-# LANGUAGE DerivingVia #-}\n{-# LANGUAGE GADTs #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE LexicalNegation #-}\n{-# LANGUAGE OverloadedLists #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE TypeFamilies #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAGE TypeApplications #-}\n{-# LANGUAGE TypeOperators #-}\n{-# OPTIONS_GHC -fno-warn-orphans #-}\n{-# LANGUAGE InstanceSigs #-}\nmodule Main where\n\nimport           Control.Lens\nimport qualified Data.Aeson as JS\nimport qualified Data.Array as Array\nimport qualified Data.ByteString.Lazy as BSL\nimport qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap\nimport           Data.OpenApi (OpenApi, SecurityScheme (..), _openApiComponents, _componentsSecuritySchemes, SecurityDefinitions (..), allOperations, security, SecurityRequirement (SecurityRequirement))\nimport qualified Data.OpenApi as OpenApi\nimport           Data.Time (UTCTime)\nimport qualified Data.UUID as UUID\nimport Incredible.API\nimport Incredible.Data\nimport Options.Applicative\nimport Servant.OpenApi (HasOpenApi(toOpenApi))\nimport Data.Data\nimport qualified Data.Text as T\nimport Servant (BasicAuth)\nimport qualified Servant.API\n\nnewtype IncredibleDocsOptions = IncredibleDocsOptions {\n  incredibleDocsOutputPath :: FilePath\n}\n\nincredibleOpts :: Parser IncredibleDocsOptions\nincredibleOpts = do\n  IncredibleDocsOptions <$> configPath\n  where\n    configPath = strOption $ mconcat [\n                      long \"output\"\n                    , short 'o'\n                    , metavar \"OUTPUT\"\n                    , help \"Path to the incredible docs output\"\n                    ]\n\nmain :: IO ()\nmain = do\n  opts <- execParser $ info (incredibleOpts <**> helper) fullDesc\n  BSL.writeFile (incredibleDocsOutputPath opts) $ JS.encode incredibleSwagger\n\nincredibleSwagger :: OpenApi\nincredibleSwagger = toOpenApi incredibleAPI\n  & OpenApi.info . OpenApi.title   .~ \"Incredible API\"\n  & OpenApi.info . OpenApi.version .~ \"0\"\n  & OpenApi.info . OpenApi.description ?~ \"Swagger API docs for Incredible\"\n\ninstance OpenApi.ToSchema Folio where\n  declareNamedSchema _ = do\n    puzzleScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Puzzle)\n    blueprintScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Blueprint)\n    msnapshotScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy (Maybe Snapshot))\n    pure $ OpenApi.NamedSchema (Just \"Folio\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"puzzle\", puzzleScheme)\n                                 , (\"blueprint\", blueprintScheme)\n                                 , (\"snapshot\", msnapshotScheme)\n                                 ]\n      & OpenApi.required      .~ [\"puzzle\", \"blueprint\", \"snapshot\"]\n\ninstance OpenApi.ToSchema TileSize where\n  declareNamedSchema _ = do\n    intScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Int)\n    pure $ OpenApi.NamedSchema (Just \"TileSize\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"x\", intScheme)\n                                 , (\"y\", intScheme)                                 ]\n      & OpenApi.required      .~ [\"x\", \"y\"]\n\ninstance OpenApi.ToSchema (MetaMachine (Maybe BlueprintID)) where\n  declareNamedSchema _ = do\n    mBlueprintIDRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe BlueprintID]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"MetaMachine (Maybe BlueprintID)\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", mBlueprintIDRef)\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleMetaMachineMaybeBlueprintID\n\ninstance OpenApi.ToSchema (MetaMachine (Maybe Blueprint)) where\n  declareNamedSchema _ = do\n    mBlueprintRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe Blueprint]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"MetaMachine (Maybe Blueprint)\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", mBlueprintRef)\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleMetaMachineMaybeBlueprint\n\ninstance OpenApi.ToSchema (MetaMachine ModData) where\n  declareNamedSchema _ = do\n    modDataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[ModData]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"MetaMachine ModData\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", modDataRef)\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleMetaMachineModData\n\ninstance OpenApi.ToSchema (VersionedMachine (Maybe BlueprintID)) where\n  declareNamedSchema _ = do\n    blueprintIDRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe BlueprintID]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"VersionedMachine (Maybe BlueprintID)\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", blueprintIDRef)\n                                 , (\"version\", OpenApi.toSchemaRef (Proxy :: Proxy Integer))\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"version\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleVersionedMachineMaybeBlueprintID\n\ninstance OpenApi.ToSchema (VersionedMachine (Maybe Blueprint)) where\n  declareNamedSchema _ = do\n    mBlueprintRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe Blueprint]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"VersionedMachine (Maybe Blueprint)\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", mBlueprintRef)\n                                 , (\"version\", OpenApi.toSchemaRef (Proxy :: Proxy Integer))\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"version\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleVersionedMachineMaybeBlueprint\n\ninstance OpenApi.ToSchema (VersionedMachine ModData) where\n  declareNamedSchema _ = do\n    modDataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[ModData]])\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"VersionedMachine ModData\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"grid\", modDataRef)\n                                 , (\"version\", OpenApi.toSchemaRef (Proxy :: Proxy Integer))\n                                 , (\"tile_size\", tileSizeSchema)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"grid\", \"version\", \"tile_size\", \"ms_per_ball\"]\n      & OpenApi.example ?~ JS.toJSON exampleVersionedMachineModData\n\ninstance OpenApi.ToSchema a => OpenApi.ToSchema (MachineUpdates a) where\n  declareNamedSchema _ = do\n    dataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [((X, Y), a)])\n    gridSizeRef <- OpenApi.declareSchemaRef (Proxy::Proxy ((X, Y), (X, Y)))\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize)\n    prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID])\n    pure $ OpenApi.NamedSchema (Just \"VersionedMachine ModData\") $ mempty\n      & OpenApi.type_         ?~ OpenApi.OpenApiObject\n      & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                 [ (\"tile_size\", tileSizeSchema)\n                                 , (\"grid_size\", gridSizeRef)\n                                 , (\"ms_per_ball\", doubleSchema)\n                                 , (\"construction\", dataRef)\n                                 , (\"prio_puzzle\", prioPzlSchema)\n                                 ]\n      & OpenApi.required      .~ [\"tile_size\", \"ms_per_ball\", \"construction\", \"grid_size\", \"prio_puzzle\"]\n\ninstance OpenApi.ToSchema RelativeCell where\n  declareNamedSchema _ = do\n    pure $ OpenApi.NamedSchema (Just \"RelativeCell\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiString\n        & OpenApi.enum_         ?~ (map JS.toJSON $ enumFrom (minBound::RelativeCell))\n\ninstance OpenApi.ToSchema Puzzle where\n  declareNamedSchema _ = do\n    reqRef <- OpenApi.declareSchemaRef (Proxy::Proxy [RelativeCell])\n    gatesRef <- OpenApi.declareSchemaRef (Proxy::Proxy [Gateway])\n    objRef <- OpenApi.declareSchemaRef (Proxy::Proxy JS.Object)\n    pure $ OpenApi.NamedSchema (Just \"Puzzle\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"reqTiles\", reqRef)\n                                   , (\"inputs\", gatesRef)\n                                   , (\"outputs\", gatesRef)\n                                   , (\"spec\", objRef)\n                                   ]\n        & OpenApi.required      .~ [\"reqTiles\", \"inputs\", \"outputs\", \"spec\"]\n        & OpenApi.example ?~ JS.toJSON examplePuzzle\n\ninstance OpenApi.ToSchema Blueprint where\n  declareNamedSchema _ = do\n    utcRef <- OpenApi.declareSchemaRef (Proxy::Proxy (Maybe UTCTime))\n    objRef <- OpenApi.declareSchemaRef (Proxy::Proxy JS.Object)\n    pure $ OpenApi.NamedSchema (Just \"Blueprint\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"puzzle\", OpenApi.Inline (mempty & OpenApi.type_ ?~ OpenApi.OpenApiString))\n                                   , (\"title\", OpenApi.Inline (mempty & OpenApi.type_ ?~ OpenApi.OpenApiString))\n                                   , (\"submittedAt\", utcRef)\n                                   , (\"widgets\", objRef)\n                                   ]\n        & OpenApi.required      .~ [\"puzzle\", \"title\", \"widgets\"]\n        & OpenApi.example ?~ JS.toJSON exampleBlueprint\n\ninstance OpenApi.ToSchema InspectionReport where\n  declareNamedSchema _ = do\n    bpRef <- OpenApi.declareSchemaRef (Proxy::Proxy BlueprintID)\n    snapshotRef <- OpenApi.declareSchemaRef (Proxy::Proxy Snapshot)\n    pure $ OpenApi.NamedSchema (Just \"InspectionReport\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"blueprint\", bpRef)\n                                   , (\"snapshot\", snapshotRef)\n                                   ]\n        & OpenApi.required      .~ [\"blueprint\", \"snapshot\"]\n\ninstance OpenApi.ToSchema ModData where\n  declareNamedSchema _ =\n    pure $ OpenApi.NamedSchema (Just \"ModData\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"puzzle\", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiString)\n                                   , (\"blueprint\", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiString)\n                                   , (\"to_mod\", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiInteger)\n                                   ]\n        & OpenApi.required      .~ [\"puzzle\"]\n        & OpenApi.example ?~ JS.toJSON exampleModData\n\ninstance OpenApi.ToSchema GatewayBall where\n  declareNamedSchema _ = do\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    intRef <- OpenApi.declareSchemaRef (Proxy :: Proxy Int)\n    pure $ OpenApi.NamedSchema (Just \"GatewayBall\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"type\", intRef)\n                                   , (\"rate\", doubleSchema)\n                                   ]\n        & OpenApi.required      .~ [\"type\", \"rate\"]\n\ninstance OpenApi.ToSchema Gateway where\n  declareNamedSchema _ = do\n    doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double)\n    gwBallsRef <- OpenApi.declareSchemaRef (Proxy :: Proxy [GatewayBall])\n    pure $ OpenApi.NamedSchema (Just \"Gateway\") $\n      mempty\n        & OpenApi.type_         ?~ OpenApi.OpenApiObject\n        & OpenApi.properties    .~ InsOrdHashMap.fromList\n                                   [ (\"x\", doubleSchema)\n                                   , (\"y\", doubleSchema)\n                                   , (\"balls\", gwBallsRef)\n                                   ]\n        & OpenApi.required      .~ [\"x\", \"y\", \"balls\"]\n        & OpenApi.example ?~ JS.toJSON exampleModData\n\ninstance HasOpenApi api => HasOpenApi (BasicAuth realm auth Servant.API.:> api) where\n  toOpenApi Proxy = addSecurity $ toOpenApi $ Proxy @api\n   where\n    addSecurity = addSecurityRequirement identifier . addSecurityScheme identifier securityScheme\n    identifier :: T.Text = \"BasicAuth\"\n    securityScheme =\n      SecurityScheme\n        { _securitySchemeType = OpenApi.SecuritySchemeHttp OpenApi.HttpSchemeBasic\n        , _securitySchemeDescription = Just \"Basic Authentication\"\n        }\n\naddSecurityScheme :: T.Text -> SecurityScheme -> OpenApi -> OpenApi\naddSecurityScheme securityIdentifier securityScheme openApi =\n  openApi\n    { _openApiComponents =\n        (_openApiComponents openApi)\n          { _componentsSecuritySchemes =\n              _componentsSecuritySchemes (_openApiComponents openApi)\n                <> SecurityDefinitions (InsOrdHashMap.singleton securityIdentifier securityScheme)\n          }\n    }\n\naddSecurityRequirement :: T.Text -> OpenApi -> OpenApi\naddSecurityRequirement securityRequirement =\n  allOperations\n    . security\n    %~ ((SecurityRequirement $ InsOrdHashMap.singleton securityRequirement []) :)\n\nexampleBlueprint :: Blueprint\nexampleBlueprint =\n  Blueprint (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID)\n            \"Lauren Ipsum\"\n            Nothing\n            mempty\n\nexamplePuzzle :: Puzzle\nexamplePuzzle =\n  Puzzle [RCUpLeft] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty\n\nexampleModData :: ModData\nexampleModData = ModData (Just (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID)) (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID) (Just 20)\n\nexampleVersionedMachineMaybeBlueprintID :: VersionedMachine (Maybe BlueprintID)\nexampleVersionedMachineMaybeBlueprintID = VersionedMachine 0 exampleMetaMachineMaybeBlueprintID\n\nexampleMetaMachineMaybeBlueprintID :: MetaMachine (Maybe BlueprintID)\nexampleMetaMachineMaybeBlueprintID =\n  MetaMachine (Array.array ((0,0),(1,1))\n                           [ ((0,0), Just (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID))\n                           , ((0,1), Just (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID))\n                           , ((1,0), Just (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID))\n                           , ((1,1), Nothing)\n                           ]) (TileSize 700 700) 0.001 mempty\n\nexampleVersionedMachineMaybeBlueprint :: VersionedMachine (Maybe Blueprint)\nexampleVersionedMachineMaybeBlueprint = VersionedMachine 0 exampleMetaMachineMaybeBlueprint\n\nexampleMetaMachineMaybeBlueprint :: MetaMachine (Maybe Blueprint)\nexampleMetaMachineMaybeBlueprint =\n  MetaMachine (Array.array ((0,0),(1,1))\n                           [ ((0,0), Just exampleBlueprint)\n                           , ((0,1), Nothing)\n                           , ((1,0), Just exampleBlueprint)\n                           , ((1,1), Nothing)\n                           ]) (TileSize 700 700) 0.001 mempty\n\nexampleVersionedMachineModData :: VersionedMachine ModData\nexampleVersionedMachineModData = VersionedMachine 0 exampleMetaMachineModData\n\nexampleMetaMachineModData :: MetaMachine ModData\nexampleMetaMachineModData =\n  MetaMachine (Array.array ((0,0),(1,1))\n                           [ ((0,0), ModData (Just (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID)) (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID) Nothing)\n                           , ((0,1), ModData Nothing (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID) $ Just 11)\n                           , ((1,0), ModData Nothing (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID) $ Just 20)\n                           , ((1,1), ModData Nothing (read \"00000000-0000-0000-0000-000000000000\"::UUID.UUID) Nothing)\n                           ]) (TileSize 700 700) 0.001 mempty\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"incredible\";\n\n  inputs.nixpkgs.follows = \"haskellNix/nixpkgs-unstable\";\n  inputs.nixpkgs-unstable.url = \"github:NixOS/nixpkgs?ref=09ad7aaffd920d5817fc56f77ab5ddd1628cbe08\";\n  inputs.haskellNix.url = \"github:input-output-hk/haskell.nix\";\n  inputs.flake-utils.url = \"github:numtide/flake-utils\";\n\n  nixConfig = {\n    extra-trusted-public-keys = [\n      \"hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=\"\n    ];\n    extra-substituters = [\n      \"https://cache.iog.io\"\n    ];\n  };\n\n  outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, haskellNix, ... }:\n    let\n      frontendModule = system: import ./nix/incredible-frontend.nix (self.outputs.packages.${system}.incredible-client);\n      serverModule = system: import ./nix/incredible-server.nix (self.outputs.packages.${system}.\"incredible:exe:incredible-server\");\n    in flake-utils.lib.eachSystem [ \"x86_64-linux\" \"x86_64-darwin\" \"aarch64-darwin\" ] (system:\n    let\n      pkgs = import nixpkgs { inherit system overlays;\n                              inherit (haskellNix) config; };\n      pkgs-unstable = import nixpkgs-unstable { inherit system overlays; };\n      # TODO add prefetch-npm-deps to devShell\n      incredible-client = script: pkgs-unstable.buildNpmPackage {\n        pname = \"incredible\";\n        version = \"1.0.0\";\n        src = ./client;\n        nodejs = pkgs-unstable.nodejs_20;\n        npmDepsHash = \"sha256-IXOXhgf2tpysiOa3B5faHF1IWZ2uwzGzwHS+Z5frqgI=\"; #nix run nixpkgs#prefetch-npm-deps client/package-lock.json\n        npmBuildScript = script;\n      };\n    \n      deploy = import ./nix/deploy.nix { inherit pkgs; };\n      incredible-docs = pkgs.runCommand \"incredible-docs-gen\" {} ''\n        mkdir $out\n        ${flake.packages.\"incredible:exe:incredible-docs\"}/bin/incredible-docs --output $out/incredible-docs.json\n        ${pkgs.openapi-generator-cli}/bin/openapi-generator-cli generate -i $out/incredible-docs.json -g html --skip-validate-spec \n        cp index.html $out/index.html\n      '';\n      overlays = [ haskellNix.overlay\n        (final: prev: {\n          incredible =\n            final.haskell-nix.project' {\n              src = ./.;\n              index-state = \"2024-03-16T23:00:16Z\";\n              compiler-nix-name = \"ghc964\";\n              shell = {\n                tools = {\n                  cabal = {};\n                  ghcid = {};\n                  haskell-language-server = {};\n                };\n                withHoogle = true;\n                buildInputs = with pkgs-unstable; [\n                  nodejs_20 nodePackages.npm nodePackages.webpack nodePackages.webpack-cli\n                  prefetch-npm-deps redis\n                ];\n              };\n\n              modules =\n                [{ enableLibraryProfiling = true;\n                   enableProfiling = true;\n                 }\n                ];\n            };\n        })\n      ];\n\n      flake = pkgs.incredible.flake { };\n\n    in nixpkgs.lib.recursiveUpdate flake {\n      packages.incredible-client = incredible-client \"build\";\n      packages.incredible-client-dev = incredible-client \"build:dev\";\n      packages.incredible-digital-ocean = (pkgs.nixos {\n        imports =  [\n            (serverModule system)\n            (frontendModule system)\n            (import ./nix/incredible-digital-ocean.nix)\n            (import ./nix/incredible-cfg.nix)\n            (import ./nix/profiles/staging.nix)\n          ];\n      }).digitalOceanImage;\n      packages.incredible-docs = incredible-docs;\n      apps.deploy = {\n        type = \"app\";\n        program = \"${deploy}/bin/deployScript\";\n      };\n    }) // {\n      nixosConfigurations =\n      let system = \"x86_64-linux\";\n      in {\n        incredible-do-staging = nixpkgs.lib.nixosSystem {\n          inherit system;\n          modules = [\n            (serverModule system)\n            (frontendModule system)\n            (import ./nix/incredible-digital-ocean.nix)\n            (import ./nix/incredible-cfg.nix)\n            (import ./nix/profiles/staging.nix)\n          ];\n        };\n\n        incredible-vm = nixpkgs.lib.nixosSystem {\n          inherit system;\n          modules = [\n            (serverModule system)\n            (frontendModule  system)\n            (import ./nix/incredible-qemu.nix)\n            (import ./nix/incredible-cfg.nix)\n            (import ./nix/profiles/staging.nix)\n          ];\n        };\n      };\n    };\n}\n"
  },
  {
    "path": "incredible.cabal",
    "content": "cabal-version:      3.0\nname:               incredible\nversion:            0\n-- synopsis:\n-- description:\nlicense:            BSD-3-Clause\nlicense-file:       LICENSE\nauthor:             tolt\nmaintainer:         kevincotrone@gmail.com\n-- copyright:\nbuild-type:         Simple\nextra-doc-files:    CHANGELOG.md\n-- extra-source-files:\n\ncommon warnings\n    ghc-options: -Wall\n\ncommon deps\n  default-language: Haskell2010\n  ghc-options: -O2\n  build-depends:\n    , aeson\n    , async\n    , array\n    , base >=4.12 && < 4.20\n    , bcrypt\n    , binary\n    , bitvec\n    , bytes\n    , bytestring\n    , containers\n    , crypton\n    , crypton-x509-system\n    , data-default\n    , deepseq\n    , directory\n    , exceptions\n    , extra\n    , filepath\n    , hashable\n    , hedis\n    , http-types\n    , indexed-traversable\n    , lens\n    , lrucache\n    , mtl\n    , monad-loops\n    , optparse-applicative\n    , process\n    , random\n    , safe\n    , servant\n    , servant-multipart\n    , servant-server\n    , stm\n    , temporary\n    , text\n    , tls\n    , time\n    , toml-parser\n    , transformers\n    , unordered-containers\n    , uuid\n    , vector\n    , wai\n    , wai-cors\n    , wai-extra\n    , warp\n\nlibrary\n    import:           warnings, deps\n    exposed-modules:\n      Incredible.AntiEvil\n      Incredible.API\n      Incredible.App\n      Incredible.Config\n      Incredible.Data\n      Incredible.DataStore\n      Incredible.DataStore.Memory\n      Incredible.DataStore.Redis\n      Incredible.Puzzle\n    hs-source-dirs:   src\n\nexecutable incredible-server\n    import:           warnings, deps\n    main-is:          Main.hs\n    -- other-modules:\n    -- other-extensions:\n    build-depends: incredible\n    hs-source-dirs:   app\n    ghc-options: -threaded -rtsopts -with-rtsopts=-N\n\nexecutable incredible-docs\n    import:           warnings, deps\n    main-is:          Main.hs\n    hs-source-dirs:   docs\n    build-depends: incredible\n                 , servant-openapi3\n                 , openapi3\n                 , insert-ordered-containers\n\nexecutable incredible-gen\n    import:           warnings, deps\n    main-is:          Main.hs\n    -- other-modules:\n    -- other-extensions:\n    hs-source-dirs:   Gen\n    default-language: Haskell2010\n    ghc-options:      -O2 -threaded -rtsopts -with-rtsopts=-N\n    build-depends: incredible\n\ntest-suite incredible-test\n    import:           warnings, deps\n    type:             exitcode-stdio-1.0\n    hs-source-dirs:   test\n    main-is:          Main.hs\n    build-depends:\n          incredible\n        , async\n        , hedgehog\n        , tasty\n        , tasty-hedgehog\n        , uuid-types\n"
  },
  {
    "path": "nix/deploy.nix",
    "content": "{ pkgs }:\npkgs.writeShellScriptBin \"deployScript\" ''\n    #!/usr/bin/env bash\n    # This script is used to deploy NixOS configurations to multiple targets\n    # It reads a list of deploy targets from a yaml file and builds and deploys\n    export PATH=$PATH:${pkgs.jq}/bin\n\n    while getopts \":ps\" opt; do\n      case $opt in\n        p)\n          export DEPLOY_TARGETS=$(cat $PROD_TARGETS)\n          echo \"Deploying to production\"\n          ;;\n        s)\n          export DEPLOY_TARGETS=$(cat $STAGING_TARGETS)\n          echo \"Deploying to staging\"\n          ;;\n      esac\n    done\n\n    if [[ -z \"$DEPLOY_TARGETS\" ]]; then\n      echo \"No deploy targets found\"\n      exit 1\n    fi\n\n    if ! test -f $DEPLOY_SSH_KEY; then\n      echo \"No deploy test key found at $DEPLOY_SSH_KEY\"\n      exit 1\n    fi\n\n\n    set -x\n\n    # Build each of the deploy targets\n    # If one fails to build, the script will exit 1 and the deployment will not continue\n    jq -c '.[]' <<<\"$DEPLOY_TARGETS\" | while read target; do\n      nix_target=$(echo $target | jq -r '.nix_target')\n      echo \"Building $nix_target\"\n      nixos-rebuild build --flake .#$nix_target\n      build_status=$?\n      if [ $build_status -ne 0 ]; then\n        echo \"Error building $nix_target\"\n        exit 1\n      fi\n    done\n\n    # Attempt to deploy each of the deploy targets\n    # If one fails to deploy, the script will continue to deploy the remaining targets\n    deploy_failed=false\n    jq -c '.[]' <<<\"$DEPLOY_TARGETS\" | while read target; do\n      target_name=$(echo $target | jq -r '.name')\n      nix_target=$(echo $target | jq -r '.nix_target')\n      target_host=$(echo $target | jq -r '.host')\n      build_host=$(echo $target | jq -r '.build_host')\n      user=$(echo $target | jq -r '.user')\n      pub_key_file=$(mktemp)\n      echo \"$target_host $(echo $target | jq -r '.pub_key')\" > $pub_key_file\n      echo \"\" >> $pub_key_file\n\n      echo \"Deploying to $target_name\"\n      export NIX_SSHOPTS=\"-i $DEPLOY_SSH_KEY -o UserKnownHostsFile=$pub_key_file\"\n      echo $nix_target\n\n      # Deploy the target, \n      nixos-rebuild switch --flake .#$nix_target --use-remote-sudo --target-host \"$user@$target_host\"\n      switch_status=$?\n      if [ $switch_status -ne 0 ]; then\n        echo \"Error deploying to $target_name\"\n        deploy_failed=true # Mark that a deploy failed so the script can exit 1 at the end but continue to deploy the remaining targets\n      fi\n      echo \"finished deploying to $target_name\"\n    done\n\n    if [[ $deploy_failed == \"true\" ]]; then\n      echo \"deploy failed\"\n      exit 1\n    else\n      echo \"deploy finished successfully\"\n      exit 0\n    fi\n  ''\n"
  },
  {
    "path": "nix/digital-ocean/digital-ocean-config.nix",
    "content": "{ config, pkgs, lib, modulesPath, ... }:\nwith lib;\n{\n  imports = [\n    (modulesPath + \"/profiles/qemu-guest.nix\")\n    (modulesPath + \"/virtualisation/digital-ocean-init.nix\")\n  ];\n  options.virtualisation.digitalOcean = with types; {\n    setRootPassword = mkOption {\n      type = bool;\n      default = false;\n      example = true;\n      description = \"Whether to set the root password from the Digital Ocean metadata\";\n    };\n    setSshKeys = mkOption {\n      type = bool;\n      default = true;\n      example = true;\n      description = \"Whether to fetch ssh keys from Digital Ocean\";\n    };\n    seedEntropy = mkOption {\n      type = bool;\n      default = true;\n      example = true;\n      description = \"Whether to run the kernel RNG entropy seeding script from the Digital Ocean vendor data\";\n    };\n  };\n  config =\n    let\n      cfg = config.virtualisation.digitalOcean;\n      hostName = config.networking.hostName;\n      doMetadataFile = \"/run/do-metadata/v1.json\";\n    in mkMerge [{\n      boot = {\n        growPartition = true;\n        kernelParams = [ \"console=ttyS0\" \"panic=1\" \"boot.panic_on_fail\" ];\n        supportedFilesystems = [ \"zfs\" ];\n        initrd.kernelModules = [ \"virtio_scsi\" ];\n        kernelModules = [ \"virtio_pci\" \"virtio_net\" ];\n        zfs = {\n          devNodes\t= \"/dev/\";\n          forceImportAll = true;\n        };\n        loader = {\n          grub.device = \"/dev/vda\";\n          timeout = 5;\n          grub.configurationLimit = 0;\n          grub.splashImage = null;\n          grub.zfsSupport = true;\n        };\n      };\n      systemd.services.\"serial-getty@tty0\".enable = true;\n      services.openssh = {\n        enable = mkDefault true;\n        settings.PasswordAuthentication = mkDefault false;\n      };\n      services.do-agent.enable = mkDefault true;\n      networking = {\n        hostName = mkDefault \"\"; # use Digital Ocean metadata server\n      };\n\n\n      /* Check for and wait for the metadata server to become reachable.\n       * This serves as a dependency for all the other metadata services. */\n      systemd.services.digitalocean-metadata = {\n        path = [ pkgs.curl ];\n        description = \"Get host metadata provided by Digitalocean\";\n        script = ''\n          set -eu\n          DO_DELAY_ATTEMPTS=0\n          while ! curl -fsSL -o $RUNTIME_DIRECTORY/v1.json http://169.254.169.254/metadata/v1.json; do\n            DO_DELAY_ATTEMPTS=$((DO_DELAY_ATTEMPTS + 1))\n            if (( $DO_DELAY_ATTEMPTS >= $DO_DELAY_ATTEMPTS_MAX )); then\n              echo \"giving up\"\n              exit 1\n            fi\n\n            echo \"metadata unavailable, trying again in 1s...\"\n            sleep 1\n          done\n          chmod 600 $RUNTIME_DIRECTORY/v1.json\n          '';\n        environment = {\n          DO_DELAY_ATTEMPTS_MAX = \"10\";\n        };\n        serviceConfig = {\n          Type = \"oneshot\";\n          RemainAfterExit = true;\n          RuntimeDirectory = \"do-metadata\";\n          RuntimeDirectoryPreserve = \"yes\";\n        };\n        unitConfig = {\n          ConditionPathExists = \"!${doMetadataFile}\";\n          After = [ \"network-pre.target\" ] ++\n            optional config.networking.dhcpcd.enable \"dhcpcd.service\" ++\n            optional config.systemd.network.enable \"systemd-networkd.service\";\n        };\n      };\n\n      /* Fetch the root password from the digital ocean metadata.\n       * There is no specific route for this, so we use jq to get\n       * it from the One Big JSON metadata blob */\n      systemd.services.digitalocean-set-root-password = mkIf cfg.setRootPassword {\n        path = [ pkgs.shadow pkgs.jq ];\n        description = \"Set root password provided by Digitalocean\";\n        wantedBy = [ \"multi-user.target\" ];\n        script = ''\n          set -eo pipefail\n          ROOT_PASSWORD=$(jq -er '.auth_key' ${doMetadataFile})\n          echo \"root:$ROOT_PASSWORD\" | chpasswd\n          mkdir -p /etc/do-metadata/set-root-password\n          '';\n        unitConfig = {\n          ConditionPathExists = \"!/etc/do-metadata/set-root-password\";\n          Before = optional config.services.openssh.enable \"sshd.service\";\n          After = [ \"digitalocean-metadata.service\" ];\n          Requires = [ \"digitalocean-metadata.service\" ];\n        };\n        serviceConfig = {\n          Type = \"oneshot\";\n        };\n      };\n\n      /* Set the hostname from Digital Ocean, unless the user configured it in\n       * the NixOS configuration. The cached metadata file isn't used here\n       * because the hostname is a mutable part of the droplet. */\n      systemd.services.digitalocean-set-hostname = mkIf (hostName == \"\") {\n        path = [ pkgs.curl pkgs.nettools ];\n        description = \"Set hostname provided by Digitalocean\";\n        wantedBy = [ \"network.target\" ];\n        script = ''\n          set -e\n          DIGITALOCEAN_HOSTNAME=$(curl -fsSL http://169.254.169.254/metadata/v1/hostname)\n          hostname \"$DIGITALOCEAN_HOSTNAME\"\n          if [[ ! -e /etc/hostname || -w /etc/hostname ]]; then\n            printf \"%s\\n\" \"$DIGITALOCEAN_HOSTNAME\" > /etc/hostname\n          fi\n        '';\n        unitConfig = {\n          Before = [ \"network.target\" ];\n          After = [ \"digitalocean-metadata.service\" ];\n          Wants = [ \"digitalocean-metadata.service\" ];\n        };\n        serviceConfig = {\n          Type = \"oneshot\";\n        };\n      };\n\n      /* Fetch the ssh keys for root from Digital Ocean */\n      systemd.services.digitalocean-ssh-keys = mkIf cfg.setSshKeys {\n        description = \"Set root ssh keys provided by Digital Ocean\";\n        wantedBy = [ \"multi-user.target\" ];\n        path = [ pkgs.jq ];\n        script = ''\n          set -e\n          mkdir -m 0700 -p /root/.ssh\n          jq -er '.public_keys[]' ${doMetadataFile} > /root/.ssh/authorized_keys\n          chmod 600 /root/.ssh/authorized_keys\n        '';\n        serviceConfig = {\n          Type = \"oneshot\";\n          RemainAfterExit = true;\n        };\n        unitConfig = {\n          ConditionPathExists = \"!/root/.ssh/authorized_keys\";\n          Before = optional config.services.openssh.enable \"sshd.service\";\n          After = [ \"digitalocean-metadata.service\" ];\n          Requires = [ \"digitalocean-metadata.service\" ];\n        };\n      };\n\n      /* Initialize the RNG by running the entropy-seed script from the\n       * Digital Ocean metadata\n       */\n      systemd.services.digitalocean-entropy-seed = mkIf cfg.seedEntropy {\n        description = \"Run the kernel RNG entropy seeding script from the Digital Ocean vendor data\";\n        wantedBy = [ \"network.target\" ];\n        path = [ pkgs.jq pkgs.mpack ];\n        script = ''\n          set -eo pipefail\n          TEMPDIR=$(mktemp -d)\n          jq -er '.vendor_data' ${doMetadataFile} | munpack -tC $TEMPDIR\n          ENTROPY_SEED=$(grep -rl \"DigitalOcean Entropy Seed script\" $TEMPDIR)\n          ${pkgs.runtimeShell} $ENTROPY_SEED\n          rm -rf $TEMPDIR\n          '';\n        unitConfig = {\n          Before = [ \"network.target\" ];\n          After = [ \"digitalocean-metadata.service\" ];\n          Requires = [ \"digitalocean-metadata.service\" ];\n        };\n        serviceConfig = {\n          Type = \"oneshot\";\n        };\n      };\n\n    }\n  ];\n  meta.maintainers = with maintainers; [ arianvp eamsden ];\n}\n\n"
  },
  {
    "path": "nix/digital-ocean/digital-ocean-custom-image.nix",
    "content": "{ config, lib, pkgs, ... }:\n\nwith lib;\nlet\n  cfg = config.virtualisation.digitalOceanImage;\nin\n{\n\n  imports = [ ./digital-ocean-config.nix ];\n\n  options = {\n    virtualisation.digitalOceanImage.rootSize = mkOption {\n      type = with types; int;\n      default = 8192;\n      example = 8192;\n      description = ''\n        Size of disk image. Unit is MB.\n      '';\n    };\n\n    virtualisation.digitalOceanImage.datasets = mkOption {\n      type = with types; attrs;\n      default = {};\n    };\n    \n    virtualisation.digitalOceanImage.configFile = mkOption {\n      type = with types; nullOr path;\n      default = null;\n      description = ''\n        A path to a configuration file which will be placed at\n        <literal>/etc/nixos/configuration.nix</literal> and be used when switching\n        to a new configuration. If set to <literal>null</literal>, a default\n        configuration is used that imports\n        <literal>(modulesPath + \"/virtualisation/digital-ocean-config.nix\")</literal>.\n      '';\n    };\n\n    virtualisation.digitalOceanImage.compressionMethod = mkOption {\n      type = types.enum [ \"gzip\" \"bzip2\" ];\n      default = \"gzip\";\n      example = \"bzip2\";\n      description = ''\n        Disk image compression method. Choose bzip2 to generate smaller images that\n        take longer to generate but will consume less metered storage space on your\n        Digital Ocean account.\n      '';\n    };\n  };\n\n  #### implementation\n  config = {\n\n    system.build.digitalOceanImage = import ./make-single-disk-zfs-image.nix {\n      name = \"digital-ocean-image\";\n      format = \"qcow2\";\n      postVM = let\n        compress = {\n          \"gzip\" = \"${pkgs.gzip}/bin/gzip\";\n          \"bzip2\" = \"${pkgs.bzip2}/bin/bzip2\";\n        }.${cfg.compressionMethod};\n      in ''\n        ${compress} $rootDiskImage\n      '';\n      configFile = if cfg.configFile == null\n        then config.virtualisation.digitalOcean.defaultConfigFile\n        else cfg.configFile;\n      rootPoolName = \"rpool\";\n      inherit (cfg) rootSize;\n      inherit (cfg) datasets;\n      inherit config lib pkgs;\n    };\n\n  };\n\n  meta.maintainers = with maintainers; [ arianvp eamsden ];\n\n}\n"
  },
  {
    "path": "nix/digital-ocean/make-single-disk-zfs-image.nix",
    "content": "# Note: This is a private API, internal to NixOS. Its interface is subject\n# to change without notice.\n#\n# The result of this builder is a single disk image, partitioned like this:\n#\n#  * partition #1: a very small, 1MiB partition to leave room for Grub.\n#\n#  * partition #2: boot, a partition formatted with FAT to be used for /boot.\n#      FAT is chosen to support EFI.\n#\n#  * partition #3: nixos, a partition dedicated to a zpool.\n#\n# This single-disk approach does not satisfy ZFS's requirements for autoexpand,\n# however automation can expand it anyway. For example, with\n# `services.zfs.expandOnBoot`.\n{ lib\n, pkgs\n, # The NixOS configuration to be installed onto the disk image.\n  config\n\n, # size of the FAT partition, in megabytes.\n  bootSize ? 1024\n\n, # The size of the root partition, in megabytes.\n  rootSize ? 2048\n\n, # The name of the ZFS pool\n  rootPoolName ? \"tank\"\n\n, # zpool properties\n  rootPoolProperties ? {\n    autoexpand = \"on\";\n  }\n, # pool-wide filesystem properties\n  rootPoolFilesystemProperties ? {\n    acltype = \"posixacl\";\n    atime = \"off\";\n    compression = \"on\";\n    mountpoint = \"legacy\";\n    xattr = \"sa\";\n  }\n\n, # datasets, with per-attribute options:\n  # mount: (optional) mount point in the VM\n  # properties: (optional) ZFS properties on the dataset, like filesystemProperties\n  # Notes:\n  # 1. datasets will be created from shorter to longer names as a simple topo-sort\n  # 2. you should define a root's dataset's mount for `/`\n  datasets ? { }\n\n, # The files and directories to be placed in the target file system.\n  # This is a list of attribute sets {source, target} where `source'\n  # is the file system object (regular file or directory) to be\n  # grafted in the file system at path `target'.\n  contents ? [ ]\n\n, # The initial NixOS configuration file to be copied to\n  # /etc/nixos/configuration.nix. This configuration will be embedded\n  # inside a configuration which includes the described ZFS fileSystems.\n  configFile ? null\n\n, # Shell code executed after the VM has finished.\n  postVM ? \"\"\n\n, name ? \"nixos-disk-image\"\n\n, # Disk image format, one of qcow2, qcow2-compressed, vdi, vpc, raw.\n  format ? \"raw\"\n\n, # Include a copy of Nixpkgs in the disk image\n  includeChannel ? true\n}:\nlet\n  formatOpt = if format == \"qcow2-compressed\" then \"qcow2\" else format;\n\n  compress = lib.optionalString (format == \"qcow2-compressed\") \"-c\";\n\n  filenameSuffix = \".\" + {\n    qcow2 = \"qcow2\";\n    vdi = \"vdi\";\n    vpc = \"vhd\";\n    raw = \"img\";\n  }.${formatOpt} or formatOpt;\n  rootFilename = \"nixos.root${filenameSuffix}\";\n\n  # FIXME: merge with channel.nix / make-channel.nix.\n  channelSources =\n    let\n      nixpkgs = lib.cleanSource pkgs.path;\n    in\n    pkgs.runCommand \"nixos-${config.system.nixos.version}\" { } ''\n      mkdir -p $out\n      cp -prd ${nixpkgs.outPath} $out/nixos\n      chmod -R u+w $out/nixos\n      if [ ! -e $out/nixos/nixpkgs ]; then\n        ln -s . $out/nixos/nixpkgs\n      fi\n      rm -rf $out/nixos/.git\n      echo -n ${config.system.nixos.versionSuffix} > $out/nixos/.version-suffix\n    '';\n\n  closureInfo = pkgs.closureInfo {\n    rootPaths = [ config.system.build.toplevel ]\n      ++ (lib.optional includeChannel channelSources);\n  };\n\n  modulesTree = pkgs.aggregateModules\n    (with config.boot.kernelPackages; [ kernel zfs ]);\n\n  tools = lib.makeBinPath (\n    with pkgs; [\n      config.system.build.nixos-enter\n      config.system.build.nixos-install\n      dosfstools\n      e2fsprogs\n      gptfdisk\n      nix\n      parted\n      utillinux\n      zfs\n    ]\n  );\n\n  hasDefinedMount = disk: ((disk.mount or null) != null);\n\n  stringifyProperties = prefix: properties: lib.concatStringsSep \" \\\\\\n\" (\n    lib.mapAttrsToList\n      (\n        property: value: \"${prefix} ${lib.escapeShellArg property}=${lib.escapeShellArg value}\"\n      )\n      properties\n  );\n\n  featuresToProperties = features:\n    lib.listToAttrs\n      (builtins.map\n        (feature: {\n          name = \"feature@${feature}\";\n          value = \"enabled\";\n        })\n        features);\n\n  createDatasets =\n    let\n      datasetlist = lib.mapAttrsToList lib.nameValuePair datasets;\n      sorted = lib.sort (left: right: (lib.stringLength left.name) < (lib.stringLength right.name)) datasetlist;\n      cmd = { name, value }:\n        let\n          properties = stringifyProperties \"-o\" (value.properties or { });\n        in\n        \"zfs create -p ${properties} ${name}\";\n    in\n    lib.concatMapStringsSep \"\\n\" cmd sorted;\n\n  mountDatasets =\n    let\n      datasetlist = lib.mapAttrsToList lib.nameValuePair datasets;\n      mounts = lib.filter ({ value, ... }: hasDefinedMount value) datasetlist;\n      sorted = lib.sort (left: right: (lib.stringLength left.value.mount) < (lib.stringLength right.value.mount)) mounts;\n      cmd = { name, value }:\n        ''\n          mkdir -p /mnt${lib.escapeShellArg value.mount}\n          mount -t zfs ${name} /mnt${lib.escapeShellArg value.mount}\n        '';\n    in\n    lib.concatMapStringsSep \"\\n\" cmd sorted;\n\n  unmountDatasets =\n    let\n      datasetlist = lib.mapAttrsToList lib.nameValuePair datasets;\n      mounts = lib.filter ({ value, ... }: hasDefinedMount value) datasetlist;\n      sorted = lib.sort (left: right: (lib.stringLength left.value.mount) > (lib.stringLength right.value.mount)) mounts;\n      cmd = { name, value }:\n        ''\n          umount /mnt${lib.escapeShellArg value.mount}\n        '';\n    in\n    lib.concatMapStringsSep \"\\n\" cmd sorted;\n\n\n  fileSystemsCfgFile =\n    let\n      mountable = lib.filterAttrs (_: value: hasDefinedMount value) datasets;\n    in\n    pkgs.runCommand \"filesystem-config.nix\"\n      {\n        buildInputs = with pkgs; [ jq nixpkgs-fmt ];\n        filesystems = builtins.toJSON {\n          fileSystems = lib.mapAttrs'\n            (\n              dataset: attrs:\n                {\n                  name = attrs.mount;\n                  value = {\n                    fsType = \"zfs\";\n                    device = \"${dataset}\";\n                  };\n                }\n            )\n            mountable;\n        };\n        passAsFile = [ \"filesystems\" ];\n      } ''\n      (\n        echo \"builtins.fromJSON '''\"\n        jq . < \"$filesystemsPath\"\n        echo \"'''\"\n      ) > $out\n\n      nixpkgs-fmt $out\n    '';\n\n  mergedConfig =\n    if configFile == null\n    then fileSystemsCfgFile\n    else\n      pkgs.runCommand \"configuration.nix\"\n        {\n          buildInputs = with pkgs; [ nixpkgs-fmt ];\n        }\n        ''\n          (\n            echo '{ imports = ['\n            printf \"(%s)\\n\" \"$(cat ${fileSystemsCfgFile})\";\n            printf \"(%s)\\n\" \"$(cat ${configFile})\";\n            echo ']; }'\n          ) > $out\n\n          nixpkgs-fmt $out\n        '';\n\n  image = (\n    pkgs.vmTools.override {\n      rootModules =\n        [ \"zfs\" \"9p\" \"9pnet_virtio\" \"virtio_pci\" \"virtio_blk\" ] ++\n        (pkgs.lib.optional pkgs.stdenv.hostPlatform.isx86 \"rtc_cmos\");\n      kernel = modulesTree;\n    }\n  ).runInLinuxVM ( # nixpkgs/pkgs/build-support/vm/default.nix#L388\n    pkgs.runCommand name\n      {\n        memSize = 8192;\n        QEMU_OPTS = \"-smp 8 -drive file=$rootDiskImage,if=virtio,cache=unsafe,werror=report\";\n        preVM = ''\n          PATH=$PATH:${pkgs.qemu_kvm}/bin\n          mkdir $out\n\n          rootDiskImage=root.raw\n          qemu-img create -f raw $rootDiskImage ${toString (bootSize + rootSize)}M\n        '';\n\n        postVM = ''\n            ${if formatOpt == \"raw\" then ''\n            mv $rootDiskImage $out/${rootFilename}\n          '' else ''\n            ${pkgs.qemu}/bin/qemu-img convert -f raw -O ${formatOpt} ${compress} $rootDiskImage $out/${rootFilename}\n          ''}\n            rootDiskImage=$out/${rootFilename}\n            set -x\n            ${postVM}\n        '';\n      } ''\n      export PATH=${tools}:$PATH\n      set -x\n\n      cp -sv /dev/vda /dev/sda\n      cp -sv /dev/vda /dev/xvda\n\n      parted --script /dev/vda -- \\\n        mklabel gpt \\\n        mkpart no-fs 1MiB 2MiB \\\n        set 1 bios_grub on \\\n        align-check optimal 1 \\\n        mkpart primary fat32 2MiB ${toString bootSize}MiB \\\n        align-check optimal 2 \\\n        mkpart primary fat32 ${toString bootSize}MiB -1MiB \\\n        align-check optimal 3 \\\n        print\n\n      sfdisk --dump /dev/vda\n\n\n      zpool create \\\n        ${stringifyProperties \"  -o\" rootPoolProperties} \\\n        ${stringifyProperties \"  -O\" rootPoolFilesystemProperties} \\\n        ${rootPoolName} /dev/vda3\n      parted --script /dev/vda -- print\n\n      ${createDatasets}\n      ${mountDatasets}\n\n      mkdir -p /mnt/boot\n      mkfs.vfat -n ESP /dev/vda2\n      mount /dev/vda2 /mnt/boot\n\n      mount\n\n      # Install a configuration.nix\n      mkdir -p /mnt/etc/nixos\n      # `cat` so it is mutable on the fs\n      cat ${mergedConfig} > /mnt/etc/nixos/configuration.nix\n\n      export NIX_STATE_DIR=$TMPDIR/state\n      nix-store --load-db < ${closureInfo}/registration\n\n      nixos-install \\\n        --root /mnt \\\n        --no-root-passwd \\\n        --system ${config.system.build.toplevel} \\\n        --substituters \"\" \\\n        ${lib.optionalString includeChannel ''--channel ${channelSources}''}\n\n      df -h\n\n      umount /mnt/boot\n      ${unmountDatasets}\n\n      zpool export ${rootPoolName}\n    ''\n  );\nin\nimage\n"
  },
  {
    "path": "nix/incredible-cfg.nix",
    "content": "{ config, pkgs, ... }:\n{\n  imports = [\n    ./users/deploy.nix\n  ];\n\n  system.stateVersion = \"22.05\";\n\n  networking.hostId = \"a1c232ce\";\n\n  fileSystems.\"/\" = {\n    device = \"rpool/save/root\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/home\" = {\n    device = \"rpool/save/home\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/var/www\" = {\n    device = \"rpool/save/var/www\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/save\" = {\n    device = \"rpool/save\";\n    fsType = \"zfs\";\n    options = [ \"ro\" ];\n  };\n  fileSystems.\"/var\" = {\n    device = \"rpool/save/var\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/var/log/journal\" = {\n    device = \"rpool/local/journal\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/nix\" = {\n    device = \"rpool/local/nix\";\n    fsType = \"zfs\";\n  };\n  fileSystems.\"/boot\" = {\n    # The ZFS image uses a partition labeled ESP whether or not we're\n    # booting with EFI.\n    device = \"/dev/disk/by-label/ESP\";\n    fsType = \"vfat\";\n  };\n\n  services.zfs.trim.enable = true;\n  services.zfs.autoScrub.enable = true;\n  services.zfs.expandOnBoot = [ \"rpool\" ]; #uncommenting this brings in all of X11 via cloud-utils and its dep qemu.\n\n  nix = {\n    package = pkgs.nixVersions.stable;\n    settings = {\n      # so that deploy user can copy into to the nix store\n      trusted-users = [ \"deploy\" ];\n\n      # Haskell.nix cache\n      substituters = [ \"https://cache.iog.io\" ];\n      trusted-public-keys = [ \"hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=\" ];\n    };\n    extraOptions = ''\n      builders-use-substitutes = true\n      experimental-features = nix-command flakes\n    '';\n  };\n\n  # Used for the deploy user to run non-interactive remote nixos-rebuild\n  security.sudo.extraRules= [ {\n    users = [ \"deploy\" ];\n    commands = [ {\n      command = \"ALL\" ;\n      options= [ \"NOPASSWD\" ];\n      }];\n  }];\n\n  environment.systemPackages = with pkgs; [\n    rxvt_unicode.terminfo\n    wget htop dstat ethtool tmux git git-lfs emacs-nox\n    rsync rrsync\n  ];\n\n  sound.enable = false;\n  services.xserver.enable = false;\n\n  users.mutableUsers = false;\n  users.groups.site_deploy = {};\n\n  networking.firewall = {\n    enable = true;\n    allowedTCPPorts = [ 80 443 ];\n    allowedUDPPorts = [    443 ];\n  };\n  \n  services.openssh.enable = true;\n}\n"
  },
  {
    "path": "nix/incredible-digital-ocean.nix",
    "content": "{ config, pkgs, ... }:\n{\n  imports = [\n    ./digital-ocean/digital-ocean-custom-image.nix\n  ];\n\n  virtualisation = {\n    digitalOceanImage = {\n      rootSize = 8192; # might need to be bigger\n      # configFile = ./basicly-blank-digital-ocean-cfg.nix;\n      datasets = {\n        \"rpool/save/root\".mount = \"/\";\n        \"rpool/save/home\".mount = \"/home\";\n        \"rpool/save/var\".mount = \"/var\";\n        \"rpool/save\".mount = \"/save\";\n        \"rpool/save/var/www\".mount = \"/var/www\";\n        \"rpool/local/journal\".mount = \"/var/log/journal\";\n        \"rpool/local/nix\".mount = \"/nix\";\n      };\n    };\n  };\n}\n"
  },
  {
    "path": "nix/incredible-frontend.nix",
    "content": "incredible-frontend: { config, pkgs, lib, ... }:\nwith lib;\nlet cfg = config.services.incredible-frontend; in\n{\n  options = {\n    services.incredible-frontend = {\n      enable = mkEnableOption \"incredible frontend\";\n    };\n  };\n\n  config = mkIf cfg.enable {\n\n    networking.firewall = {\n      allowedTCPPorts = [ 8889 ];\n    };\n\n    services.nginx = {\n      enable = true;\n      recommendedOptimisation = true;\n      recommendedTlsSettings = true;\n      recommendedGzipSettings = true;\n      virtualHosts = {\n        localhost = {\n          default = true;\n          extraConfig = ''\n            charset UTF-8;\n          '';\n          locations.\"/\" = {\n            root = \"${incredible-frontend}/lib/node_modules/incredible/built/\";\n            extraConfig = ''\n              add_header Access-Control-Allow-Origin *;\n              '';\n          };\n          listen = [\n            {\n              addr = \"0.0.0.0\";\n              port = 8889;\n            }\n          ];\n        };\n      };\n    };\n\n  };\n}\n"
  },
  {
    "path": "nix/incredible-qemu.nix",
    "content": "{ config, modulesPath, pkgs, lib,... }:\n{\n  imports = [\n    \"${modulesPath}/profiles/qemu-guest.nix\"\n    \"${modulesPath}/virtualisation/qemu-vm.nix\"\n  ];\n\n  services.qemuGuest.enable = true;\n\n  services.getty.autologinUser = config.users.users.deploy.name;\n  users.mutableUsers = lib.mkForce true;\n\n  virtualisation = {\n    memorySize = 2048; # MB\n    diskSize = 8000; # MB\n    cores = 2;\n    forwardPorts = [\n      { from = \"host\"; host.port = 8889; guest.port = 8889; }\n      { from = \"host\"; host.port = 8888; guest.port = 8888; }\n    ];\n  };\n}\n"
  },
  {
    "path": "nix/incredible-server.nix",
    "content": "incredible-server: { config, pkgs, lib, ... }:\n\nwith lib;\nlet cfg = config.services.incredible-server; in\n{\n  options = {\n    services.incredible-server = {\n      enable = mkEnableOption \"incredible web server\";\n\n      config = mkOption {\n        type = types.path;\n        description = \"Path to incredible.toml\";\n      };\n\n      machine = mkOption {\n        type = types.path;\n        description = \"Path to machine.json\";\n      };\n    };\n  };\n\n  config = mkIf cfg.enable {\n    networking.firewall = {\n      enable = true;\n      allowedTCPPorts = [ 8888 ];\n    };\n\n    services.redis.servers.\"incredible\" = {\n      enable = true;\n      port = 6379;\n    };\n\n    systemd.services.incredible = {\n      description = \"Incredible web server\";\n      after = [ \"network.target\" \"redis-incredible.service\" ];\n      wantedBy = [ \"multi-user.target\" ];\n      serviceConfig = {\n        Type = \"simple\";\n        ExecStart = \"${incredible-server}/bin/incredible-server --config ${cfg.config} --machine ${cfg.machine}\";\n        LimitNOFILE = 1000000;\n        WorkingDirectory = \"/home/incredible\";\n        Restart = \"always\";\n        RestartSec = 5;\n        User = \"incredible\";\n        Group = \"incredible\";\n      };\n    };\n\n    users.users.incredible = {\n      isSystemUser = true;\n      home = \"/home/incredible\";\n      group = \"incredible\";\n      createHome = true;\n    };\n\n    users.groups.incredible = {\n      members = [ \"incredible\" ];\n    };\n\n  };\n}\n"
  },
  {
    "path": "nix/profiles/staging.nix",
    "content": "{ config, lib, pkgs, ... }:\n\n{\n  services.incredible-server = {\n    enable = true;\n    config = ../../config/incredible.toml;\n    machine = ../../config/machine.json;\n  };\n\n  services.incredible-frontend = {\n    enable = true;\n  };\n}\n"
  },
  {
    "path": "nix/users/deploy.nix",
    "content": "{ pkgs, ... }:\n{\n  programs.zsh.enable = true;\n  users.users.deploy = {\n     isNormalUser = true;\n     extraGroups = [ \"wheel\" ];\n     shell = pkgs.zsh;\n     openssh.authorizedKeys.keys =\n       [\n        # Add a public key here\n       ];\n   };\n}\n"
  },
  {
    "path": "src/Incredible/API.hs",
    "content": "{-# LANGUAGE DataKinds #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE GeneralizedNewtypeDeriving #-}\n{-# LANGUAGE LambdaCase #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAGE TupleSections #-}\n{-# LANGUAGE TypeOperators #-}\nmodule Incredible.API where\n\nimport           Control.Concurrent\nimport           Control.DeepSeq\nimport           Control.Monad\nimport           Control.Monad.Error.Class\nimport           Control.Monad.IO.Class\nimport           Control.Monad.Reader\nimport qualified Data.Array as Array\nimport           Data.Cache.LRU.IO (AtomicLRU)\nimport qualified Data.Cache.LRU.IO as LRU\nimport           Data.HashMap.Strict (HashMap)\nimport qualified Data.HashMap.Strict as HashMap\nimport qualified Data.Ix as Ix\nimport           Data.Maybe\nimport qualified Data.Set as Set\nimport qualified Data.Text as T\nimport qualified Data.Text.Encoding as T\nimport           Data.Time\nimport qualified Data.Vector as V\nimport qualified Data.Vector.Unboxed as UV\nimport           Incredible.AntiEvil\nimport           Incredible.Data\nimport           Incredible.DataStore\nimport           Servant\nimport           System.Random\nimport           System.IO\n\ntype CacheHeader = Header \"Cache-Control\" String\ntype WorkOrderHeader = Header \"X-WorkOrder\" T.Text\n\ntype BlueprintApi = \"blueprint\" :> \"file\" :> WorkOrderHeader :> ReqBody '[JSON] Blueprint :> Post '[JSON] (BlueprintID, (X, Y))\n\n-- Only caches if it has a snapshot\ntype FolioApi = \"folio\" :> Capture \"blueprintid\" BlueprintID :> Get '[JSON] (Headers '[CacheHeader] Folio)\n\n-- | Redirect to current Machine\n--   If just the current version, Blueprint IDs or Full, want If-Modified\n\ntype MachineApi = \"machine\" :> (\n                         \"current\" :> (\n                                 Get '[JSON] (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID))) -- ^ Redirects to ve[((X, Y), rsioned version which is cachable.\n                            :<|> \"version\" :> Get '[JSON] (Headers '[CacheHeader] MachineVersion))\n                    :<|> Capture \"version\" MachineVersion :> Get '[JSON] (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID)))\n                  )\n\ntype DeltaApi =\n       \"machine\" :> \"delta\" :> (\n               Capture \"startVersion\" MachineVersion :> \"current\" :> Get '[JSON] (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID)) -- ^ Redirects to two version version which is cachable.\n          :<|> Capture \"startVersion\" MachineVersion :> Capture \"endVersion\" MachineVersion :> Get '[JSON] (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID))\n        )\n\ntype PuzzleAPI = \"puzzle\" :> Get '[JSON] (Headers '[CacheHeader, WorkOrderHeader] (HashMap PuzzleID Puzzle))\n\n-- | Upload a machine\n-- type \"submit\"\n\n-- Moderation\ntype ModAPI = \"moderate\" :> BasicAuth \"incredible mod api\" UserName :> (\n         \"puzzle\" :> Capture \"puzzleid\" PuzzleID :> \"blueprintid\" :> Get '[JSON] (Headers '[CacheHeader] [BlueprintID]) -- JSONL?\n    :<|> \"puzzle\" :> Capture \"puzzleid\" PuzzleID :> \"blueprint\" :> Get '[JSON] (Headers '[CacheHeader] [(BlueprintID, Blueprint)]) -- JSONL?\n    :<|> \"puzzle\" :> Capture \"puzzleid\" PuzzleID :> Get '[JSON] (Headers '[CacheHeader] Puzzle)\n    :<|> \"puzzle\" :> Capture \"puzzleid\" PuzzleID :> \"reissue\" :> PostNoContent\n    :<|> \"build\"  :> Capture \"X\" X :> Capture \"Y\" Y :> ReqBody '[JSON] InspectionReport :> PostNoContent\n    :<|> \"burn\"   :> Capture \"blueprintid\" BlueprintID :> PostNoContent -- Just removed from the mod queue, delete if never added to any GameState?\n    :<|> \"machine\" :> \"current\" :> Get '[JSON] (Headers '[CacheHeader] (VersionedMachine ModData))\n  )\n\ntype IncredibleAPI =\n         BlueprintApi\n    :<|> FolioApi\n    :<|> MachineApi\n    :<|> DeltaApi\n    :<|> PuzzleAPI\n    :<|> ModAPI\n\nincredibleAPI :: Proxy IncredibleAPI\nincredibleAPI = Proxy\n\nserver :: forall s. IncredibleData s => ServerT IncredibleAPI (IncredibleHandler s)\nserver =\n       blueprintHandlers\n  :<|> folioHandlers\n  :<|> machineHandlers\n  :<|> deltaHandlers\n  :<|> puzzleHandlers\n  :<|> modHandlers\n  where\n    blueprintHandlers = handleAddBlueprint\n    folioHandlers = handleGetFolio\n    machineHandlers =\n           (handleMachineCurrentBlueprintID :<|> handleCurrentVersion) :<|> handleMachineBlueprintID\n    deltaHandlers :: IncredibleData s => ServerT DeltaApi (IncredibleHandler s)\n    deltaHandlers = handleGetCurrentDelta :<|> handleGetDelta\n    puzzleHandlers = handleGetPuzzles\n    modHandlers user = handlePuzzleBlueprintID user :<|> handlePuzzleBlueprint user :<|> handlePuzzle user :<|> handleReissue user :<|> handleBuild user :<|> handleBurn user :<|> handleModMachine user\n\nnewtype IncredibleHandler s a = IncredibleHandler { unIncredibleHandler :: ReaderT (MachineShop s) Handler a }\n  deriving (Functor, Applicative, Monad, MonadIO, MonadReader (MachineShop s), MonadError ServerError)\n\nrunIncredibleHandler :: MachineShop s -> IncredibleHandler s a -> Handler a\nrunIncredibleHandler config = flip runReaderT config . unIncredibleHandler\n\ncacheForever :: AddHeader \"Cache-Control\" String orig new => IncredibleHandler s orig -> IncredibleHandler s new\ncacheForever hdlr = addHeader \"max-age=86400\" <$> hdlr\n\nnoCache :: AddHeader \"Cache-Control\" String orig new => IncredibleHandler s orig -> IncredibleHandler s new\nnoCache hdlr = addHeader \"no-store\" <$> hdlr\n\nhandleGetFolio :: IncredibleData s => BlueprintID -> IncredibleHandler s (Headers '[CacheHeader] Folio)\nhandleGetFolio bpID = do\n  blueprint <- maybe blueprint404 pure =<< getBlueprint bpID\n  puzzle    <- maybe (throwError $ err500 { errBody = \"Puzzle not found\" }) pure =<< asks (HashMap.lookup (bPuzzleID blueprint) . sdPuzzles)\n  msnapshot <- getSnapshot bpID\n  (if isJust msnapshot then cacheForever else noCache) $ pure $\n    Folio puzzle blueprint msnapshot\n\nhandleAddBlueprint :: IncredibleData s => Maybe T.Text -> Blueprint -> IncredibleHandler s (BlueprintID, (X, Y))\nhandleAddBlueprint woidt bp' = do\n  now <- liftIO $ getCurrentTime\n  let bp = bp' { bSubmittedAt = Just now }\n  doAdd <- checkWorkOrder woidt bp\n  pzlLocCache <- asks sdPuzzleLocCache\n  let pzlLoc = HashMap.findWithDefault mempty (bPuzzleID bp) pzlLocCache\n  liftIO $ print (doAdd, bp)\n  liftIO $ print pzlLoc\n  liftIO $ hFlush stdout\n  if doAdd\n    then queueModeration bp\n    else pure () -- TODO: Sleep for average queueModeration time\n  -- Sleep 50 to 150ms to make timing attacks less trivial.\n  liftIO $ randomRIO (50*1000, 150*1000) >>= threadDelay\n  pure (blueprintID bp, fromMaybe (0,0) $ fmap fst $ UV.uncons pzlLoc)\n\nhandleMachineCurrentBlueprintID :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID)))\nhandleMachineCurrentBlueprintID = noCache $ do\n  VersionedMachine v _ <- getCurrentMachine\n  burl <- asks sdBaseUrl\n  throwError err307 { errHeaders = [(\"Location\", T.encodeUtf8 $ burl<>\"machine/\"<>T.pack (show v))]}\n\nhandleMachineBlueprintID :: IncredibleData s => MachineVersion -> IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID)))\nhandleMachineBlueprintID mv = cacheForever $ do\n  VersionedMachine mv . fmap (either (const Nothing) Just) <$> lookupMachine mv\n\nhandleCurrentVersion :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader] MachineVersion)\nhandleCurrentVersion = noCache $ vmVersion <$> getCurrentMachine\n\nhandleGetDelta :: IncredibleData s => MachineVersion -> MachineVersion -> IncredibleHandler s (Headers '[CacheHeader]  (MachineVersion, MachineUpdates BlueprintID))\nhandleGetDelta startVersion endVersion = cacheForever $ fmap (endVersion,) $\n  (lruGenerate (machineDeltaHandler startVersion endVersion) (startVersion, endVersion)) =<< asks sdDeltaCache\n\nhandleGetCurrentDelta :: IncredibleData s => MachineVersion -> IncredibleHandler s (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID))\nhandleGetCurrentDelta startVersion = noCache $ do\n  VersionedMachine v _ <- getCurrentMachine\n  burl <- asks sdBaseUrl\n  throwError err307 { errHeaders = [(\"Location\", T.encodeUtf8 $ burl<>\"machine/delta/\"<>T.pack (show startVersion)<>\"/\"<>T.pack (show v))]}\n\nlookupMachine :: IncredibleData s => MachineVersion -> IncredibleHandler s GameState\nlookupMachine version = maybe machineNotFound pure =<< getMachine version\n  where\n    machineNotFound = throwError $ err404 { errBody = \"Machine not found\" }\n\nmachineDeltaHandler :: IncredibleData s =>  MachineVersion -> MachineVersion -> IncredibleHandler s (MachineUpdates BlueprintID)\nmachineDeltaHandler startVersion endVersion = do\n  start <- lookupMachine startVersion\n  end   <- lookupMachine endVersion\n  let delta = stripNothings $ deltaMachine (toBlueprintID <$> start) (toBlueprintID <$> end)\n  pure delta\n  where\n    stripNothings :: MachineUpdates (Maybe a) -> MachineUpdates a\n    stripNothings d = d { muConstruction = V.mapMaybe (\\(i, ma) -> (i,) <$> ma) $ muConstruction d }\n    toBlueprintID :: Either a BlueprintID -> Maybe BlueprintID\n    toBlueprintID = either (const Nothing) Just\n\nlruGenerate :: (Ord key, MonadIO m, NFData val) => m val -> key -> AtomicLRU key val -> m val\nlruGenerate gen k lru = do\n   mr <- liftIO $ LRU.lookup k lru\n   case mr of\n    (Just val) -> pure val\n    Nothing -> do\n      newVal <- gen\n      newVal `deepseq` liftIO (LRU.insert k newVal lru)\n      pure newVal\n\nreadyPuzzles :: IncredibleData s => VersionedGameState -> IncredibleHandler s (HashMap PuzzleID Puzzle)\nreadyPuzzles (VersionedMachine v m) = do\n  lruGenerate (asks (HashMap.fromList . (`findReadEnoughPuzzles` m) . sdPuzzles)) v =<< asks sdReadyPuzzlesByVersion\n\nfindReadEnoughPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)]\nfindReadEnoughPuzzles pzls m = --findReadyPuzzles pzls m\n  let rdy = prioPuzzles <> findReadyPuzzles pzls m\n      fpw  = firstPuzzles pzls m\n      prioPuzzles = mapMaybe (\\pid -> (pid,) <$> HashMap.lookup pid pzls) $ V.toList $ mmPrioPuzzles m\n  in Set.toList $ Set.fromList (rdy <> (take 50 fpw))\n  --if not (null rdy) then rdy else take (10-length rdy) fpw\n\nhandleGetPuzzles :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader, WorkOrderHeader] (HashMap PuzzleID Puzzle))\nhandleGetPuzzles = noCache $ do\n  cm <- getCurrentMachine\n  pzlmp <- readyPuzzles cm\n  woid <- issueWorkOrder $ fmap fst $ HashMap.toList pzlmp\n  pure $ addHeader woid $ pzlmp\n\nhandlePuzzleBlueprintID :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] [BlueprintID])\nhandlePuzzleBlueprintID _user = noCache . listModerationQueue -- TODO limit number of results?\n\nhandlePuzzleBlueprint :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] [(BlueprintID, Blueprint)])\nhandlePuzzleBlueprint _user pid = noCache $ do\n  bids <- listModerationQueue pid\n  catMaybes <$> traverse (\\bpid -> (fmap (bpid,)) <$> getBlueprint bpid) bids -- TODO this silently drops errors, should it?\n\nhandleReissue :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s NoContent\nhandleReissue _user pid = do\n  pzlMap <- asks sdPuzzleLocCache\n  editCurrentMachine (\\(VersionedMachine _ mm) -> do\n    -- Only prioritize it if it is in the current machine, also clear out old prioritized ones.\n    ((), mm {mmPrioPuzzles = V.filter (`HashMap.member` pzlMap) $ (mmPrioPuzzles mm) `V.snoc` pid}))\n  pure NoContent\n\nhandlePuzzle :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] Puzzle)\nhandlePuzzle _user pid = noCache $ do\n  maybe (throwError $ err404 { errBody = \"Puzzle not found\" }) pure =<< asks (HashMap.lookup pid . sdPuzzles)\n\nblueprint404 :: IncredibleHandler s a\nblueprint404  = throwError $ err404 { errBody = \"Blueprint not found\" }\n\nhandleBuild :: IncredibleData s => UserName -> X -> Y -> InspectionReport -> IncredibleHandler s NoContent\nhandleBuild _user x y (InspectionReport bpid snap) = do\n  design <- mmGrid <$> asks sdMachineDesign\n  let loc = (x, y)\n  unless (Ix.inRange (Array.bounds design) loc) $ throwError $ err400 { errBody = \"Not in bounds\" }\n  bp <- maybe blueprint404 pure =<< getBlueprint bpid\n  () <- if (design Array.! (x, y))==bPuzzleID bp\n        then do\n          dequeueModeration bpid\n          addSnapshot bpid snap\n          wasThere <- editCurrentMachine (\\(VersionedMachine _ mm) ->\n                        if bpid `elem` (bpidsInMachine mm)\n                          then (True, mm)\n                          else (False, mm { mmPrioPuzzles = V.filter (/=(bPuzzleID bp)) $ mmPrioPuzzles mm, mmGrid = mmGrid mm Array.// [((x,y), Right bpid)]})\n                       )\n          when wasThere $ throwError $ err409 { errBody = \"Blueprint already in use in the machine.\" }\n        else throwError $ err400 { errBody = \"Blueprint not a solution for that puzzle\" }\n  pure NoContent\n\nhandleBurn :: IncredibleData s => UserName -> BlueprintID -> IncredibleHandler s NoContent\nhandleBurn _user bpid = dequeueModeration bpid >> pure NoContent\n\nhandleModMachine :: IncredibleData s => UserName -> IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine ModData))\nhandleModMachine _user = noCache $ do\n  cm <- getCurrentMachine\n  worthModerating <- HashMap.keys <$> readyPuzzles cm\n  modLengthMap <- HashMap.fromList <$> modQueueLength worthModerating\n  VersionedMachine (vmVersion cm) <$> (`translateMachine` vmMachine cm) (\\_xy tileContent -> do\n    let mbpid = either (const Nothing) Just tileContent\n    pid  <- either (pure)\n                   (\\bpid -> maybe blueprint404 pure =<< fetchBPPuzzleID bpid)\n                   tileContent\n    pure $ ModData mbpid pid (HashMap.lookup pid modLengthMap)\n    )\n\nfetchBPPuzzleID :: IncredibleData s => BlueprintID -> IncredibleHandler s (Maybe PuzzleID)\nfetchBPPuzzleID bpid = lruFetch (fmap (fmap bPuzzleID) . getBlueprint) bpid =<< asks sdBlueprint2PuzzleCache\n"
  },
  {
    "path": "src/Incredible/AntiEvil.hs",
    "content": "{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE OverloadedStrings #-}\n-- | Certainly not good.\nmodule Incredible.AntiEvil\n( issueWorkOrder\n, checkWorkOrder\n) where\n\nimport           Control.Monad.IO.Class\nimport           Control.Monad.Reader\nimport qualified Data.Text as T\nimport           Incredible.Data\nimport           Incredible.DataStore\n\nissueWorkOrder :: (MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => [PuzzleID] -> m T.Text\nissueWorkOrder _ = pure \"test-work-order\"\n\ncheckWorkOrder :: (MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => Maybe T.Text -> Blueprint -> m Bool\ncheckWorkOrder _ _ = pure True\n"
  },
  {
    "path": "src/Incredible/App.hs",
    "content": "{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\nmodule Incredible.App\n( openMachineShop\n, factoryRecall\n, incredibleApp\n, loadPuzzleMachine\n, writePuzzleMachine\n) where\n\nimport           Control.Applicative\nimport           Control.Monad\nimport           Control.Monad.IO.Class\nimport           Control.Monad.Reader\nimport qualified Crypto.BCrypt as BCrypt\nimport qualified Data.Aeson as JS\nimport qualified Data.Cache.LRU.IO as LRU\nimport           Data.HashMap.Strict (HashMap)\nimport qualified Data.HashMap.Strict as HashMap\nimport qualified Data.Text.Encoding as Text\nimport           Incredible.API\nimport           Incredible.Config\nimport           Incredible.Data\nimport           Incredible.DataStore\nimport qualified Network.HTTP.Types.Header as HTTP\nimport qualified Network.Wai as WAI\nimport           Network.Wai.Middleware.AddHeaders\nimport           Network.Wai.Middleware.Cors\nimport           Network.Wai.Parse (defaultParseRequestBodyOptions, setMaxRequestFileSize)\nimport           Servant\nimport qualified Servant as Auth\nimport           Servant.Multipart (MultipartOptions(generalOptions) , defaultMultipartOptions, Mem)\n\n-- | We load the puzzles from the machine, so structurally we have all the puzzles.\n--   The puzzles might change over time though so we'll have to eject any Blueprints\n--   that don't match the current puzzles. \nloadPuzzleMachine :: MonadIO m => FilePath -> m (MetaMachine Puzzle)\nloadPuzzleMachine flnm = liftIO $ do\n  ePzl <- JS.eitherDecodeFileStrict' flnm\n  case ePzl of\n    Left err -> fail $ \"Could not load \\\"\"<>flnm<>\"\\\": \"<>err\n    Right pzl -> pure pzl\n\n-- | Write a generate machine to a file\nwritePuzzleMachine :: MonadIO m => FilePath -> MetaMachine Puzzle -> m ()\nwritePuzzleMachine flnm pzl = liftIO $ JS.encodeFile flnm pzl\n\n-- | Looks at a datastore, and if the machine there has any out dated\n--   puzzles or blueprints for outdated puzzles, updates them to the new spec.\nfactoryRecall :: forall s m . (Alternative m, MonadFail m, MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => MetaMachine Puzzle -> m ()\nfactoryRecall machineDesign = go\n  where\n    go = do\n      cm <- vmMachine <$> getCurrentMachine\n      remanufactured <- remanufacture machineDesign <$> traverse (either (pure . Left) (maybe (fail \"Blueprint lookup failed!\") (pure . Right) <=< getBlueprint) ) cm\n      -- If we made no changes, we're done.\n      when (cm /= remanufactured) $ do\n        -- If we did an update, check if the machine hasn't changed and if it hasn't replace it and be done.\n        -- If it has changed, leave it the same and try again.\n        updated <- editCurrentMachine (\\(VersionedMachine _ ngs) -> if ngs==cm then (True, remanufactured) else (False, ngs))\n        if updated then pure () else go\n\nopenMachineShop :: (MonadIO m, IncredibleData s) => IncredibleConfig -> MetaMachine Puzzle -> (IncredibleConfig -> MetaMachine Puzzle -> IO (s, HashMap PuzzleID Puzzle)) -> m (MachineShop s)\nopenMachineShop ic pzlm store = liftIO $ do\n  (s, pzlMap) <- store ic pzlm\n  mbv  <- LRU.newAtomicLRU $ incredibleMachineByVersion   $ incredibleCacheConfig ic\n  pbv  <- LRU.newAtomicLRU $ incrediblePuzzlesByVersion   $ incredibleCacheConfig ic\n  bc   <- LRU.newAtomicLRU $ incredibleBlueprintByID      $ incredibleCacheConfig ic\n  dc   <- LRU.newAtomicLRU $ incredibleDeltaCache         $ incredibleCacheConfig ic\n  sc   <- LRU.newAtomicLRU $ incredibleSnapshotByID       $ incredibleCacheConfig ic\n  b2pc <- LRU.newAtomicLRU $ incredibleBlueprint2Snapshot $ incredibleCacheConfig ic\n  let ms = MachineShop\n             { sdPuzzles       = pzlMap\n             , sdMachineDesign = fmap puzzleID pzlm\n             , sdModLogins     = incredibleMods ic\n             , sdOrigins       = incredibleOrigins $ incredibleWebConfig ic\n             , sdStore         = s\n             , sdBaseUrl       = incredibleBaseUrl $ incredibleWebConfig ic\n             , sdMachineByVersion      = mbv\n             , sdReadyPuzzlesByVersion = pbv\n             , sdBlueprintCache        = bc\n             , sdDeltaCache            = dc\n             , sdSnapshotCache         = sc\n             , sdPuzzleLocCache        = cachePuzzleLoc $ fmap puzzleID pzlm\n             , sdBlueprint2PuzzleCache = b2pc\n             }\n  (`runReaderT` ms) $ do\n    factoryRecall pzlm\n    cm <- getCurrentMachine\n    forM_ (bpidsInMachine $ vmMachine cm) dequeueModeration\n  pure ms\n\nincredibleApp :: IncredibleData s => MachineShop s -> Application\nincredibleApp icontext = addHeaders [(\"Vary\", \"Origin\")] $ cors policy $ serveWithContextT (Proxy :: Proxy IncredibleAPI) context hoist server\n  where\n    size128k = 128*1024\n    multipartOpts :: MultipartOptions Mem\n    multipartOpts = (defaultMultipartOptions (Proxy :: Proxy Mem))\n      { generalOptions = setMaxRequestFileSize size128k defaultParseRequestBodyOptions\n      }\n    -- Servant context\n    context = multipartOpts :. authCheck :. EmptyContext\n    -- Run handler with incredible context\n    hoist = runIncredibleHandler icontext\n    -- verify encrypted input = Scrypt.verifyPass' (Pass pass) (EncryptedPass encrypted)\n    authCheck = Auth.BasicAuthCheck $ \\auth -> do\n      let user = Text.decodeUtf8 $ Auth.basicAuthUsername auth\n      case HashMap.lookup user (sdModLogins icontext) of\n        Just digest -> do\n          let valid = BCrypt.validatePassword (Text.encodeUtf8 digest) (Auth.basicAuthPassword auth)\n          pure $ if valid then Auth.Authorized user else Auth.BadPassword\n        Nothing -> pure Auth.NoSuchUser\n    policy req = Just CorsResourcePolicy\n        { corsOrigins = if (lookup HTTP.hOrigin $ WAI.requestHeaders req) `elem` (fmap Just $ sdOrigins icontext) then Just (sdOrigins icontext, True) else Nothing\n        , corsMethods = [\"GET\"]\n        , corsRequestHeaders = [\"authorization\", \"content-type\", \"X-WorkOrder\"]\n        , corsExposedHeaders = Just [\"X-WorkOrder\"]\n        , corsMaxAge = Just $ 60*60*24 -- one day\n        , corsVaryOrigin = False\n        , corsRequireOrigin = False\n        , corsIgnoreFailures = False\n      }\n"
  },
  {
    "path": "src/Incredible/Config.hs",
    "content": "{-# LANGUAGE ImportQualifiedPost #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAGE InstanceSigs #-}\nmodule Incredible.Config where\n\nimport Data.HashMap.Strict (HashMap)\nimport qualified Data.HashMap.Strict as HashMap\nimport qualified Data.Map as Map\nimport Data.Maybe\nimport Data.Text (Text)\nimport Data.Text.Encoding qualified as TE\nimport Data.Text.IO qualified as Text\nimport Database.Redis qualified as Redis\nimport System.Exit (exitFailure)\nimport Toml qualified\nimport Toml.Schema qualified as Toml\nimport Incredible.Data\nimport qualified Data.ByteString as BS\n\nreadIncredibleConfig :: FilePath -> IO IncredibleConfig\nreadIncredibleConfig configFp = do\n  tomlContents <- Text.readFile configFp\n  case Toml.decode' tomlContents of\n    Toml.Failure errors -> do\n      putStrLn $ \"Errors decoding toml config file \" <> configFp <> \" \" <> prettyShowTomlError errors\n      exitFailure\n    Toml.Success warnings res -> do\n      putStrLn $ \"Warnings decoding toml config file \" <> configFp <> \" \" <> prettyShowTomlError warnings\n      pure res\n  where\n    prettyShowTomlError = show . fmap Toml.prettyDecodeError\n\ndata IncredibleConfig\n  = IncredibleConfig\n    { incredibleWebConfig :: IncredibleWebConfig\n    , incredibleCacheConfig :: IncredibleCacheConfig\n    , incredibleRedis :: Maybe IncredibleRedisConfig\n    , incredibleMods :: HashMap UserName UserDigest\n    }\n  deriving (Show)\n\ninstance Toml.FromValue IncredibleConfig where\n  fromValue = Toml.parseTableFromValue $\n    IncredibleConfig\n      <$> Toml.reqKey \"web\"\n      <*> Toml.reqKey \"cache\"\n      <*> Toml.optKey \"redis\"\n      <*> (fmap (HashMap.fromList . Map.toList) $ Toml.reqKey \"mods\")\n\ndata IncredibleWebConfig\n  = IncredibleWebConfig\n    { incredibleWebPort :: Int\n    , incredibleBaseUrl :: Text\n    , incredibleOrigins :: [BS.ByteString]\n    } deriving (Show)\n\ninstance Toml.FromValue IncredibleWebConfig where\n  fromValue = Toml.parseTableFromValue $ IncredibleWebConfig <$> Toml.reqKey \"port\" <*> Toml.reqKey \"base_url\" <*> fmap (fmap TE.encodeUtf8) (Toml.reqKey \"origins\")\n\ndata IncredibleCacheConfig = IncredibleCacheConfig {\n  incredibleMachineByVersion  :: Maybe Integer\n, incrediblePuzzlesByVersion  :: Maybe Integer\n, incredibleBlueprintByID     :: Maybe Integer\n, incredibleDeltaCache        :: Maybe Integer\n, incredibleSnapshotByID      :: Maybe Integer\n, incredibleBlueprint2Snapshot :: Maybe Integer\n} deriving (Show)\n\ninstance Toml.FromValue IncredibleCacheConfig where\n  fromValue = Toml.parseTableFromValue $\n    IncredibleCacheConfig\n      <$> Toml.optKey \"machines\"\n      <*> Toml.optKey \"puzzles\"\n      <*> Toml.optKey \"blueprints\"\n      <*> Toml.optKey \"deltas\"\n      <*> Toml.optKey \"snapshots\"\n      <*> Toml.optKey \"blueprint2puzzle\"\n\n-- This contains all of the components of a redis connection because\n-- the certificate store has to be fetched to create the tls connection\n-- and that can't happen when parsing the config file\ndata IncredibleRedisConfig\n  = IncredibleRedisConfig\n    { incredibleRedisHostName :: String\n    , incredibleRedisPort :: Redis.PortID\n    , incredibleRedisDatabase :: Integer\n    , incredibleRedisMaxConnections :: Int\n    , incredibleRedisMaxIdleTimeout :: Integer\n    , incredibleRedisPassword :: Maybe BS.ByteString\n    , incredibleRedisRetryCount :: Int\n    , incredibleWorkOrderTTL :: Integer\n    , incredibleOrderBookTTL :: Integer\n    , incredibleRedisUseTLS :: Bool\n    }\n  deriving (Show)\n\ninstance Toml.FromValue IncredibleRedisConfig where\n  fromValue :: Toml.Value' l -> Toml.Matcher l IncredibleRedisConfig\n  fromValue = Toml.parseTableFromValue $ do\n    (host, port) <- lookupHostPort\n    database <- Toml.reqKey \"database\"\n    maxConnections <- Toml.reqKey \"maxConnections\"\n    maxIdleTimeout <- Toml.reqKey \"maxIdleTimeout\"\n    pass <- fmap TE.encodeUtf8 <$> Toml.optKey \"password\"\n    useTLS <- fromMaybe False <$> Toml.optKey \"tls\"\n    rcnt <- Toml.reqKey \"retry_count\"\n    wottl <- Toml.reqKey \"workorder_ttl\"\n    obttl <- Toml.reqKey \"orderbook_ttl\"\n    pure $ IncredibleRedisConfig {\n        incredibleRedisHostName = host\n      , incredibleRedisPort = port\n      , incredibleRedisDatabase = database\n      , incredibleRedisMaxConnections = maxConnections\n      , incredibleRedisMaxIdleTimeout = maxIdleTimeout\n      , incredibleRedisPassword = pass\n      , incredibleRedisRetryCount = rcnt\n      , incredibleWorkOrderTTL = wottl\n      , incredibleOrderBookTTL = obttl\n      , incredibleRedisUseTLS = useTLS\n      }\n    where\n      -- Lookup the port or unix socket\n      lookupHostPort = do\n        socket <- Toml.optKey \"socket\"\n        case socket of\n          Just sock -> pure (\"\", Redis.UnixSocket sock)\n          Nothing -> do\n            host <- Toml.reqKey \"host\"\n            port <- Toml.reqKey \"port\"\n            pure (host, Redis.PortNumber $ fromInteger port)\n"
  },
  {
    "path": "src/Incredible/Data.hs",
    "content": "{-# LANGUAGE DeriveAnyClass #-}\n{-# LANGUAGE DeriveTraversable #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# language ImportQualifiedPost #-}\n{-# LANGUAGE LambdaCase #-}\n{-# language OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAGE TupleSections #-}\n{-# LANGUAGE DeriveGeneric #-}\n{-# LANGUAGE TypeFamilies #-}\nmodule Incredible.Data\n ( type PuzzleID\n , type BlueprintID\n , type X\n , type Y\n , type UserDigest\n , type UserName\n , type GameState\n , type VersionedGameState\n , type Position\n , puzzleID, blueprintID\n , RelativeCell(..)\n , Puzzle(..)\n , MetaMachine(..)\n , Blueprint(..)\n , MachineVersion\n , VersionedMachine(..)\n , MachineShop(..)\n , ModData(..)\n , BallTypes(..)\n , Gateway(..)\n , GatewayBall(..)\n , InspectionReport(..)\n , Snapshot\n , Folio(..)\n , MachineUpdates(..)\n , TileSize(..)\n , WorkOrderID(..)\n , WorkOrder(..)\n , WidgetSignature(..)\n-- , ServerSharedState(..)\n , deltaMachine\n , applyDelta\n , translateMachine\n , findReadyPuzzles\n , gridizeArray\n , remanufacture\n , cachePuzzleLoc\n , isSolved\n , isSolvable\n , firstPuzzles\n , bpidsInMachine\n ) where\n\nimport Control.Applicative\nimport Control.DeepSeq\nimport Control.Monad\nimport Data.Aeson qualified as JS\nimport Data.Aeson.Types qualified as JS\nimport Data.Array (Array)\nimport Data.Array qualified as Array\nimport Data.Bifunctor (bimap, first)\nimport Data.Bytes.Serial qualified as S\nimport Data.ByteString qualified as BS\nimport Data.ByteString.Lazy qualified as BSL\nimport Data.Cache.LRU.IO (AtomicLRU)\nimport Data.Data (Typeable)\nimport Data.Either\nimport Data.Functor.Identity\nimport Data.HashMap.Strict (HashMap)\nimport Data.HashMap.Strict qualified as HashMap\nimport Data.HashSet qualified as HashSet\nimport Data.List\nimport Data.Ix (Ix)\nimport Data.Ix qualified as Ix\nimport Data.Maybe\nimport Data.Text (Text)\nimport Data.Time (UTCTime)\nimport Data.Tuple\nimport Data.UUID (UUID)\nimport Data.UUID.V5 qualified as UUIDV5\nimport Data.Vector qualified as V\nimport Data.Vector.Unboxed qualified as UV\nimport GHC.Generics\n\ntype X = Int\ntype Y = Int\ntype PuzzleID = UUID\ntype BlueprintID = UUID\ntype MachineVersion = Integer\ntype Position = (Double, Double)\n\ntype GameState = MetaMachine (Either PuzzleID BlueprintID)\ntype VersionedGameState = VersionedMachine (Either PuzzleID BlueprintID)\n\ntype UserName = Text\ntype UserDigest = Text\n\nnewtype WorkOrderID\n  = WorkOrderID UUID\n  deriving (Show, Eq, Ord, Typeable, Generic)\n\ninstance JS.ToJSON   WorkOrderID\ninstance JS.FromJSON WorkOrderID\n\ndata WorkOrder\n  = WorkOrder\n  deriving (Show, Eq, Ord, NFData, Typeable, Generic)\n\ninstance JS.ToJSON   WorkOrder\ninstance JS.FromJSON WorkOrder\n\ndata RelativeCell\n = RCUpLeft\n | RCUp\n | RCUpRight\n | RCLeft\n | RCRight\n | RCDownLeft\n | RCDown\n | RCDownRight\n deriving (Read, Show, Eq, Ord, Bounded, Enum, NFData, Generic, Typeable)\n\ninstance JS.ToJSON RelativeCell where\n  toJSON RCUpLeft    = \"UpLeft\"\n  toJSON RCUp        = \"Up\"\n  toJSON RCUpRight   = \"UpRight\"\n  toJSON RCLeft      = \"Left\"\n  toJSON RCRight     = \"Right\"\n  toJSON RCDownLeft  = \"DownLeft\"\n  toJSON RCDown      = \"Down\"\n  toJSON RCDownRight = \"DownRight\"\n  toEncoding RCUpLeft    = \"UpLeft\"\n  toEncoding RCUp        = \"Up\"\n  toEncoding RCUpRight   = \"UpRight\"\n  toEncoding RCLeft      = \"Left\"\n  toEncoding RCRight     = \"Right\"\n  toEncoding RCDownLeft  = \"DownLeft\"\n  toEncoding RCDown      = \"Down\"\n  toEncoding RCDownRight = \"DownRight\"\n\ninstance JS.FromJSON RelativeCell where\n  parseJSON = JS.withText \"RelativeCell\" $ \\case\n    \"UpLeft\"    -> pure RCUpLeft\n    \"Up\"        -> pure RCUp\n    \"UpRight\"   -> pure RCUpRight\n    \"Left\"      -> pure RCLeft\n    \"Right\"     -> pure RCRight\n    \"DownLeft\"  -> pure RCDownLeft\n    \"Down\"      -> pure RCDown\n    \"DownRight\" -> pure RCDownRight\n    _ -> fail \"Unknown RelativeCell\"\n\npuzzleID :: Puzzle -> PuzzleID\npuzzleID pzl =\n    -- We should specificly give a canonical output for this datastructure,\n    -- say alphabetical pretty printed key names, sorted lists (sorting lists depends on JSON data allowances).\n    UUIDV5.generateNamed puzzleNS $ BSL.unpack $ JS.encode pzl\n  where\n    puzzleNS :: UUID\n    puzzleNS = read \"1238b96a-5c4f-40d5-a980-a3212d128af6\"\n\nblueprintID :: Blueprint -> BlueprintID\nblueprintID bpnt =\n    -- We should specificly give a canonical output for this datastructure,\n    -- say alphabetical pretty printed key names, sorted lists (sorting lists depends on JSON data allowances).\n    UUIDV5.generateNamed blueprintNS $ BSL.unpack $ JS.encode bpnt\n  where\n    blueprintNS :: UUID\n    blueprintNS = read \"e5f4ae68-da49-458c-969e-231c61d53a26\"\n\ndata Folio\n  = Folio\n    { fPuzzle :: Puzzle\n    , fBlueprint :: Blueprint\n    , fSnapshot :: Maybe Snapshot\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ninstance JS.ToJSON Folio where\n  toJSON (Folio p b ms) =\n    JS.object [\"puzzle\" JS..= p, \"blueprint\" JS..= b, \"snapshot\" JS..= ms]\n  toEncoding (Folio p b ms) =\n    JS.pairs (\"puzzle\" JS..= p <> \"blueprint\" JS..= b <> \"snapshot\" JS..= ms)\n\ninstance JS.FromJSON Folio where\n  parseJSON = JS.withObject \"Folio\" $ \\o ->\n    Folio\n      <$> o JS..: \"puzzle\"\n      <*> o JS..: \"blueprint\"\n      <*> o JS..: \"snapshot\"\n\ntype Rate = Double\n\ndata GatewayBall\n  = GatewayBall\n    { gbType :: {-# UNPACK #-} !Int\n    , gbRate :: {-# UNPACK #-} !Rate\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ninstance JS.ToJSON GatewayBall where\n  toJSON (GatewayBall typ rt) =\n    JS.object [\"type\" JS..= typ, \"rate\" JS..= rt]\n  toEncoding (GatewayBall typ rt) =\n    JS.pairs (\"type\" JS..= typ <> \"rate\" JS..= rt)\n\ninstance JS.FromJSON GatewayBall where\n  parseJSON = JS.withObject \"GatewayBall\" $ \\o ->\n    GatewayBall\n      <$> o JS..: \"type\"\n      <*> o JS..: \"rate\"\n\ndata Gateway\n  = Gateway\n    { gPos :: {-# UNPACK #-} !Position\n    , gBalls :: {-# UNPACK #-} !(V.Vector GatewayBall)\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ninstance JS.ToJSON Gateway where\n  toJSON (Gateway (x,y) typ) =\n    JS.object [\"x\" JS..= x, \"y\" JS..= y, \"balls\" JS..= typ]\n  toEncoding (Gateway (x,y) typ) =\n    JS.pairs (\"x\" JS..= x <> \"y\" JS..= y <> \"balls\" JS..= typ)\n\ninstance JS.FromJSON Gateway where\n  parseJSON = JS.withObject \"Gateway\" $ \\o ->\n    Gateway\n      <$> ((,) <$> o JS..: \"x\" <*> o JS..: \"y\")\n      <*> o JS..: \"balls\"\n\ndata Puzzle\n  = Puzzle\n    { pReqTiles :: V.Vector RelativeCell -- ^ Which tiles of the machine need to be completed for this puzzle to be ready to be solved.\n    , pInputs   :: V.Vector Gateway\n    , pOutputs  :: V.Vector Gateway\n    , pSpec   :: JS.Object\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ninstance JS.ToJSON Puzzle where\n  toJSON (Puzzle rqtls is os pzl) =\n    JS.object [\"reqTiles\" JS..= rqtls, \"inputs\" JS..= is, \"outputs\" JS..= os, \"spec\" JS..= pzl]\n  toEncoding (Puzzle rqtls is os pzl) =\n    JS.pairs (\"reqTiles\" JS..= rqtls <> \"inputs\" JS..= is <> \"outputs\" JS..= os <> \"spec\" JS..= pzl)\n\ninstance JS.FromJSON Puzzle where\n  parseJSON = JS.withObject \"Puzzle\" $ \\o ->\n    Puzzle\n      <$> o JS..: \"reqTiles\"\n      <*> o JS..: \"inputs\"\n      <*> o JS..: \"outputs\"\n      <*> o JS..: \"spec\"\n\ndata TileSize\n  = TileSize {-# UNPACK #-} !Int {-# UNPACK #-} !Int\n  deriving (Show, Eq, Ord, NFData, Typeable, Generic)\n\ninstance JS.ToJSON TileSize where\n  toJSON (TileSize x y) = JS.object [\"x\" JS..= x, \"y\" JS..= y]\n  toEncoding (TileSize x y) = JS.pairs $ (\"x\" JS..= x)<>(\"y\" JS..= y)\n\ninstance JS.FromJSON TileSize where\n  parseJSON = JS.withObject \"tile_size pos\" $ \\o ->\n    TileSize\n      <$> o JS..: \"x\"\n      <*> o JS..: \"y\"\n\n-- | The overall machine.\ndata MetaMachine tile\n  = MetaMachine\n    { mmGrid        :: {-# UNPACK #-} !(Array (X, Y) tile)\n    , mmTileSize    :: {-# UNPACK #-} !TileSize\n    , mmMillisecondPerBall :: {-# UNPACK #-} !Double\n    , mmPrioPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID)\n    }\n  deriving (Show, Functor, Eq, Ord, Traversable, Foldable, NFData, Generic, Typeable)\n\ndata VersionedMachine tile\n  = VersionedMachine\n    { vmVersion :: {-# UNPACK #-} !MachineVersion -- ^ Incrimented version of which edit of the metamachine this is\n    , vmMachine :: MetaMachine tile\n    }\n  deriving (Show, Functor, Eq, Ord, Traversable, Foldable, NFData, Generic, Typeable)\n\ndata ModData\n  = ModData\n    { mdBlueprint :: Maybe BlueprintID\n    , mdPuzzleID  :: PuzzleID\n    , mdToMod     :: Maybe Integer\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\nbpidsInMachine :: GameState -> [BlueprintID]\nbpidsInMachine = rights . Array.elems .  mmGrid\n\ninstance JS.ToJSON ModData where\n  toJSON (ModData mbpid pid tm) =\n    JS.object $ (\"puzzle\" JS..= pid):catMaybes [fmap (\"to_mod\" JS..=) tm, fmap (\"blueprint\" JS..=) mbpid]\n  toEncoding (ModData mbpid pid tm) =\n    JS.pairs $ (\"puzzle\" JS..= pid) <> maybe mempty (\"to_mod\" JS..=) tm <> maybe mempty (\"blueprint\" JS..=) mbpid\n\ninstance JS.FromJSON ModData where\n  parseJSON = JS.withObject \"Puzzle\" $ \\o ->\n    ModData\n      <$> o JS..:? \"blueprint\"\n      <*> o JS..:  \"puzzle\"\n      <*> o JS..:?  \"to_mod\"\n\ngridizeArray :: (Ix x, Ix y) => Array (x, y) a -> [[a]]\ngridizeArray a =\n  let ((lbx, lby), (hbx, hby)) = Array.bounds a\n  in (`map` Ix.range (lby, hby)) $ \\y  ->\n        (`map` Ix.range (lbx, hbx)) $ \\x ->\n           a Array.! (x, y)\n\njsonizeMetaMachine :: JS.ToJSON tile => MetaMachine tile -> [JS.Pair]\njsonizeMetaMachine (MetaMachine g ts rt pp) =\n    [ \"tile_size\" JS..= ts\n    , \"ms_per_ball\" JS..= rt\n    , \"prio_puzzles\" JS..= pp\n    , \"grid\" JS..= gridizeArray g\n    ]\n{-# INLINE jsonizeMetaMachine #-}\n\njsonizeVersionedMachine :: JS.ToJSON tile => VersionedMachine tile -> [JS.Pair]\njsonizeVersionedMachine (VersionedMachine v mm) =\n    (\"version\" JS..= v):jsonizeMetaMachine mm\n{-# INLINE jsonizeVersionedMachine #-}\n\njsonEncodeMetaMachine :: JS.ToJSON tile => MetaMachine tile -> JS.Series\njsonEncodeMetaMachine (MetaMachine g ts rt pp) =\n       (\"tile_size\" JS..= ts)\n    <> (\"ms_per_ball\" JS..= rt)\n    <> (\"prio_puzzles\" JS..= pp)\n    <> (\"grid\" JS..= gridizeArray g)\n{-# INLINE jsonEncodeMetaMachine #-}\n\njsonEncodeVersionedMachine :: JS.ToJSON tile => VersionedMachine tile -> JS.Series\njsonEncodeVersionedMachine (VersionedMachine v mm) =\n    (\"version\" JS..= v) <> jsonEncodeMetaMachine mm\n{-# INLINE jsonEncodeVersionedMachine #-}\n\nminimalEitherPuzzleBlueprint :: Either PuzzleID BlueprintID -> JS.Value\nminimalEitherPuzzleBlueprint = JS.object . (\\case { Left pid -> [\"puzzle\" JS..= pid]; Right tid -> [\"blueprint\" JS..= tid]})\n\ninstance {-# OVERLAPPING  #-} JS.ToJSON GameState where\n  toJSON = JS.object . jsonizeMetaMachine . fmap minimalEitherPuzzleBlueprint\n  {-# INLINE toJSON #-}\n  toEncoding = JS.pairs . jsonEncodeMetaMachine . fmap minimalEitherPuzzleBlueprint\n  {-# INLINE toEncoding #-}\n\ninstance {-# OVERLAPPABLE #-} JS.ToJSON tile => JS.ToJSON (MetaMachine tile) where\n  toJSON = JS.object . jsonizeMetaMachine\n  {-# INLINE toJSON #-}\n  toEncoding = JS.pairs . jsonEncodeMetaMachine\n  {-# INLINE toEncoding #-}\n\ninstance {-# OVERLAPPING  #-} JS.ToJSON VersionedGameState where\n  toJSON = JS.object . jsonizeVersionedMachine . fmap minimalEitherPuzzleBlueprint\n  toEncoding = JS.pairs . jsonEncodeVersionedMachine . fmap minimalEitherPuzzleBlueprint\n  {-# INLINE toEncoding #-}\n\ninstance {-# OVERLAPPABLE #-} JS.ToJSON tile => JS.ToJSON (VersionedMachine tile) where\n  toJSON = JS.object . jsonizeVersionedMachine\n  toEncoding = JS.pairs . jsonEncodeVersionedMachine\n  {-# INLINE toEncoding #-}\n\nparseIDObj :: JS.FromJSON a => JS.Key -> JS.Value -> JS.Parser a\nparseIDObj k = JS.withObject (show k) $ \\o -> o JS..: k\n\ninstance {-# OVERLAPPING  #-} JS.FromJSON GameState where\n  parseJSON =\n    JS.withObject \"MetaMachine\" $ \\o -> do\n      gridList <- (o JS..: \"grid\") >>=\n                     JS.withArray \"Grid-Y\" (traverse $\n                        JS.withArray \"Grid-X\" $ traverse $ \\v ->\n                          Left <$> parseIDObj \"puzzle\" v <|> Right <$> parseIDObj \"blueprint\" v)\n      let bndy = length gridList\n      guard (bndy > 0)\n      let bndx = length $ V.head gridList\n      guard (bndx > 0)\n      let grid = concatMap (\\(y, xl) -> zipWith (\\ x v -> ((x, y), v)) [0..] xl) $ zip [0..] $ V.toList (V.toList <$> gridList)\n      p <- o JS..: \"tile_size\"\n      rt <- o JS..: \"ms_per_ball\"\n      pp <- (o JS..: \"prio_puzzles\") <|> pure mempty\n      pure $ MetaMachine (Array.array ((0,0), (bndx-1, bndy-1)) grid) p rt pp\n\ninstance {-# OVERLAPPABLE #-} JS.FromJSON tile => JS.FromJSON (MetaMachine tile) where\n  parseJSON =\n    JS.withObject \"MetaMachine\" $ \\o -> do\n      gridList <- o JS..: \"grid\"\n      let bndy = length gridList\n      let bndx = length $ head gridList\n      let grid = concatMap (\\(y, xl) -> zipWith (\\ x v -> ((x, y), v)) [0..] xl) $ zip [0..] gridList\n      p <- o JS..: \"tile_size\"\n      rt <- o JS..: \"ms_per_ball\"\n      pp <- (o JS..: \"prio_puzzles\") <|> pure mempty\n      pure $ MetaMachine (Array.array ((0,0), (bndx-1, bndy-1)) grid) p rt pp\n\ninstance {-# OVERLAPPING  #-} JS.FromJSON VersionedGameState where\n  parseJSON v =\n    (\\f -> JS.withObject \"VersionedMachine\" f v) $ \\o -> do\n      version <- o JS..: \"version\"\n      machine <- JS.parseJSON v\n      pure $ VersionedMachine version machine\n\ninstance {-# OVERLAPPABLE #-} JS.FromJSON tile => JS.FromJSON (VersionedMachine tile) where\n  parseJSON v =\n    (\\f -> JS.withObject \"VersionedMachine\" f v) $ \\o -> do\n      version <- o JS..: \"version\"\n      machine <- JS.parseJSON v\n      pure $ VersionedMachine version machine\n\ndata Blueprint\n  = Blueprint\n    { bPuzzleID    :: {-# UNPACK #-} !PuzzleID -- ^ which puzzle this is supposed to solve.\n    -- Title Block stuff\n    , bTitle       :: {-# UNPACK #-} !Text\n    , bSubmittedAt :: {-# UNPACK #-} !(Maybe UTCTime) -- Client can't send us the submitted date\n    -- Actual submission\n    , bWidgets     :: {-# UNPACK #-} !(HashMap Int JS.Object)\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ndata WidgetSignature\n  = WidgetSignature\n    { wName   :: {-# UNPACK #-} !Text\n    , wX      :: {-# UNPACK #-} !Double\n    , wY      :: {-# UNPACK #-} !Double\n    , wAngle  :: {-# UNPACK #-} !(Maybe Double)\n    , wWidth  :: {-# UNPACK #-} !(Maybe Double)\n    , wHeight :: {-# UNPACK #-} !(Maybe Double)\n    , wRadius :: {-# UNPACK #-} !(Maybe Double)\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ninstance S.Serial WidgetSignature\n\ninstance JS.FromJSON WidgetSignature where\n  parseJSON = JS.withObject \"WidgetSignature\" $ \\o -> WidgetSignature\n        <$> o JS..: \"type\"\n        <*> o JS..: \"x\"\n        <*> o JS..: \"y\"\n        <*> o JS..:? \"angle\"\n        <*> o JS..:? \"width\"\n        <*> o JS..:? \"height\"\n        <*> o JS..:? \"radius\"\n\ninstance JS.ToJSON WidgetSignature where\n  toJSON (WidgetSignature ty x y a w h r) = JS.object $ catMaybes [ Just (\"type\"  JS..= ty)\n    , Just (\"x\"     JS..= x)\n    , Just (\"y\"     JS..= y)\n    , fmap (\"angle\" JS..= ) a\n    , fmap (\"width\" JS..= ) w\n    , fmap (\"height\" JS..= ) h\n    , fmap (\"radius\" JS..= ) r\n    ]\n  toEncoding (WidgetSignature ty x y a w h r) = JS.pairs $ mconcat $ catMaybes [ Just (\"type\"  JS..= ty)\n    , Just (\"x\"     JS..= x)\n    , Just (\"y\"     JS..= y)\n    , fmap (\"angle\" JS..= ) a\n    , fmap (\"width\" JS..= ) w\n    , fmap (\"height\" JS..= ) h\n    , fmap (\"radius\" JS..= ) r\n    ]\n\ninstance JS.FromJSON Blueprint where\n  parseJSON = JS.withObject \"Blueprint\" $ \\o -> Blueprint\n        <$> o JS..: \"puzzle\"\n        <*> o JS..: \"title\"\n        <*> o JS..:? \"submittedAt\"\n        <*> o JS..: \"widgets\"\n\ninstance JS.ToJSON Blueprint where\n  toJSON (Blueprint p a t s) = JS.object\n    [ \"puzzle\" JS..= p\n    , \"title\" JS..= a\n    , \"submittedAt\" JS..= t\n    , \"widgets\" JS..= s\n    ]\n  toEncoding (Blueprint p a t s) = JS.pairs $\n        (\"puzzle\" JS..= p)\n     <> (\"title\" JS..= a)\n     <> (\"submittedAt\" JS..= t)\n     <> (\"widgets\" JS..= s)\n\ndata InspectionReport\n  = InspectionReport\n    { irBlueprint :: {-# UNPACK #-} !BlueprintID\n    , irSnapshot :: {-# UNPACK #-} !Snapshot\n    }\n  deriving (Show, Eq, Ord, NFData, Generic, Typeable)\n\ntype Snapshot = JS.Object\n\ninstance JS.FromJSON InspectionReport where\n  parseJSON = JS.withObject \"InspectionReport\" $ \\o -> InspectionReport\n        <$> o JS..: \"blueprint\"\n        <*> o JS..: \"snapshot\"\n\ninstance JS.ToJSON InspectionReport where\n  toJSON (InspectionReport bp ss) = JS.object\n    [ \"blueprint\" JS..= bp\n    , \"snapshot\" JS..= ss\n    ]\n  toEncoding (InspectionReport bp ss) = JS.pairs $\n       (\"blueprint\" JS..= bp)\n    <> (\"snapshot\" JS..= ss)\n\ndata MachineShop s\n  = MachineShop\n    { sdPuzzles :: {-# UNPACK #-} !(HashMap PuzzleID Puzzle)\n    , sdMachineDesign :: {-# UNPACK #-} !(MetaMachine PuzzleID)\n    , sdModLogins :: {-# UNPACK #-} !(HashMap UserName UserDigest)\n    , sdStore :: !s\n    , sdBaseUrl :: {-# UNPACK #-} !Text\n    , sdOrigins :: {-# UNPACK #-} ![BS.ByteString]\n    -- | Caches\n    , sdMachineByVersion :: {-# UNPACK #-} !(AtomicLRU MachineVersion GameState)\n    , sdReadyPuzzlesByVersion :: {-# UNPACK #-} !(AtomicLRU MachineVersion (HashMap PuzzleID Puzzle))\n    , sdBlueprintCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID Blueprint)\n    , sdSnapshotCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID Snapshot)\n    , sdDeltaCache :: {-# UNPACK #-} !(AtomicLRU (MachineVersion, MachineVersion) (MachineUpdates BlueprintID))\n    , sdPuzzleLocCache :: {-# UNPACK #-} !(HashMap PuzzleID (UV.Vector (X,Y)))\n    , sdBlueprint2PuzzleCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID PuzzleID)\n    }\n\ncachePuzzleLoc :: MetaMachine PuzzleID -> HashMap PuzzleID (UV.Vector (X, Y))\ncachePuzzleLoc = fmap UV.fromList . HashMap.fromListWith (<>) . map (fmap pure . swap) . Array.assocs . mmGrid\n\n{-\ndata ServerSharedState\n   = ServerSharedState\n     { sssForcedPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID)\n     }\n  deriving (Show, Eq, Generic, Typeable)\n\ninstance JS.ToJSON ServerSharedState\ninstance JS.FromJSON ServerSharedState\n\ninstance Semigroup ServerSharedState where\n  (ServerSharedState fp0) <> (ServerSharedState fp1) = ServerSharedState (fp0<>fp1)\n\ninstance Monoid ServerSharedState where\n  mempty = ServerSharedState mempty\n-}\n\ndata MachineUpdates a\n  = MachineUpdates\n    { muTileSize   :: {-# UNPACK #-} !TileSize\n    , muGridSize :: {-# UNPACK #-} !(X, Y)\n    , muMillisecondPerBall :: {-# UNPACK #-} !Double\n    , muPriorityPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID)\n    , muConstruction :: {-# UNPACK #-} !(V.Vector ((X,Y), a))\n    }\n  deriving (Show, Eq, Ord, Functor, Traversable, Foldable, NFData, Generic, Typeable)\n\ninstance JS.FromJSON a => JS.FromJSON (MachineUpdates a) where\n  parseJSON = JS.withObject \"MachineUpdates\" $ \\o -> MachineUpdates\n        <$> o JS..: \"tile_size\"\n        <*> o JS..: \"grid_size\"\n        <*> o JS..: \"ms_per_ball\"\n        <*> o JS..: \"prio_puzzle\"\n        <*> o JS..: \"construction\"\n\ninstance JS.ToJSON a => JS.ToJSON (MachineUpdates a) where\n  toJSON (MachineUpdates ts gs r pp c) = JS.object\n    [ \"tile_size\" JS..= ts\n    , \"grid_size\" JS..= gs\n    , \"ms_per_ball\" JS..= r\n    , \"prio_puzzle\" JS..= pp\n    , \"construction\" JS..= c\n    ]\n  toEncoding (MachineUpdates ts gs r pp c) = JS.pairs $\n       (\"tile_size\" JS..= ts)\n    <> (\"grid_size\" JS..= gs)\n    <> (\"ms_per_ball\" JS..= r)\n    <> (\"prio_puzzle\" JS..= pp)\n    <> (\"construction\" JS..= c)\n\n-- | Finds the thing to set in a first machine to generate the second machine.\ndeltaMachine :: Eq a => MetaMachine a -> MetaMachine a -> MachineUpdates a\ndeltaMachine (MetaMachine { mmGrid = g0 }) (MetaMachine { mmTileSize=ts, mmMillisecondPerBall=rt, mmPrioPuzzles=pp, mmGrid = g1 }) =\n  MachineUpdates\n    ts\n    (snd $ Array.bounds g1) -- Safe because we require a (0,0) start of grid.\n    rt\n    pp $\n    V.fromList $ mapMaybe (\\(i, a1) ->\n      let a0 = if Ix.inRange (Array.bounds g0) i then Just (g0 Array.! i) else Nothing\n      in if a0==Just a1 then Nothing else Just (i, a1)\n      ) $ Array.assocs g1\n\napplyDelta :: MetaMachine a -> MachineUpdates a -> MetaMachine a\napplyDelta oldMachine updates =\n  MetaMachine newGrid (muTileSize updates) (muMillisecondPerBall updates) (muPriorityPuzzles updates)\n  where\n    newGrid =\n      let oldAssocs = HashMap.fromList $ Array.assocs $ mmGrid oldMachine\n          newAssocs = HashMap.fromList $ V.toList $ muConstruction updates\n      in Array.array ((0,0), muGridSize updates) $ (`map` Ix.range ((0,0), muGridSize updates)) $ \\gl ->\n        maybe (error \"incomplete information for applyDelta\") (gl,)$ HashMap.lookup gl newAssocs <|> HashMap.lookup gl oldAssocs\n\ntranslateMachine :: Monad m => ((X, Y) -> a -> m b) -> MetaMachine a -> m (MetaMachine b)\ntranslateMachine act m = do\n  g1 <- fmap (Array.array (Array.bounds (mmGrid m))) $ mapM (\\(i, a) -> (i,) <$> act i a) $ Array.assocs $ mmGrid m\n  pure (m { mmGrid = g1 })\n\nfirstPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)]\nfirstPuzzles pzls m = mapMaybe (\\case {(_, Right _) -> Nothing; (_, Left pid) -> (pid,) <$> HashMap.lookup pid pzls}) $ sort $ fmap (first snd) $ Array.assocs $ mmGrid m\n\nfindReadyPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)]\nfindReadyPuzzles pzls m =\n  mapMaybe puzzleReady $ Array.assocs $ mmGrid m\n  where\n    puzzleReady :: ((X, Y), Either PuzzleID BlueprintID) -> Maybe (PuzzleID, Puzzle)\n    puzzleReady (_, Right _)  = Nothing -- If its a finished blueprint, it can't be a ready puzzle\n    puzzleReady (p, Left pid) = do\n      pzl <- HashMap.lookup pid pzls -- Uh, a non existant puzzle can't be ready, but we probably want to be loud about this?\n      let rdy = all (maybe True isRight . getRelative (mmGrid m) p) $ pReqTiles pzl -- All required cells are Blueprints?\n      if rdy then pure (pid, pzl) else Nothing\n\nisSolved :: GameState -> Bool\nisSolved = all isRight\n\nisSolvable :: HashMap PuzzleID Puzzle -> GameState -> Bool\nisSolvable pm gs | not (isSolved gs) && null (findReadyPuzzles pm gs) = False\nisSolvable _ gs | isSolved gs = True\nisSolvable pm gs = isSolvable pm setSolvedReady\n  where\n    rdySet = fst <$> findReadyPuzzles pm gs\n    setSolvedReady = gs { mmGrid = (\\case\n                                            r@(Right _) -> r\n                                            Left pzlID | pzlID `elem` rdySet -> Right (read \"00000000-0000-0000-0000-000000000000\")\n                                            l@(Left _) -> l\n                                        ) <$> mmGrid gs\n                        }\n\nremanufacture :: MetaMachine Puzzle -> MetaMachine (Either PuzzleID Blueprint) -> GameState\nremanufacture machineDesign oldMachine =\n  runIdentity $ translateMachine repairPart $\n    machineDesign { mmPrioPuzzles = cleanPrio }\n  where\n    cleanPrio = V.filter (`HashSet.member` (HashSet.fromList $ fmap puzzleID $ Array.elems $ mmGrid machineDesign)) $ mmPrioPuzzles oldMachine\n    repairPart :: (X, Y) -> Puzzle -> Identity (Either PuzzleID BlueprintID)\n    -- If it is a puzzle, always the new puzzle because its the same or updated.\n    repairPart xy pzl = do\n      let pid = puzzleID pzl\n      case if Ix.inRange (Array.bounds $ mmGrid oldMachine) xy then Just (mmGrid oldMachine Array.! xy) else Nothing of\n        Nothing -> pure $ Left pid\n        Just (Left _) -> pure $ Left pid\n        Just (Right bp) | bPuzzleID bp /= pid -> pure $ Left pid\n        Just (Right bp) -> pure $ Right $ blueprintID bp\n\n--data VertShift = ...\n--data HoriShift = ...\n\n-- | Gets the value in a cell at a relative position to a given cell, or Nothing if that is outside the grid.\ngetRelative :: Array (X,Y) a -> (X, Y) -> RelativeCell -> Maybe a\ngetRelative g (x0, y0) r = do\n    let rp = bimap (x0 +) (y0 +) $ relMap r\n    if Ix.inRange (Array.bounds g) rp then Just (g Array.! rp) else Nothing\n  where\n    -- This is the only thing that orients the grid I guess ... orientation ...\n    -- 4th quadrant grid.\n    relMap :: RelativeCell -> (X, Y)\n    relMap RCUpLeft    = (-1, -1)\n    relMap RCUp        = ( 0, -1)\n    relMap RCUpRight   = ( 1, -1)\n    relMap RCLeft      = (-1,  0)\n    relMap RCRight     = ( 1,  0)\n    relMap RCDownLeft  = (-1,  1)\n    relMap RCDown      = ( 0,  1)\n    relMap RCDownRight = ( 1,  1)\n\n-- TODO stub for bullshit nonsense\nnewtype BallTypes\n   = BallType\n     { _ballType :: Char\n     }\n   deriving Show\n"
  },
  {
    "path": "src/Incredible/DataStore/Memory.hs",
    "content": "{-# language ImportQualifiedPost #-}\n{-# LANGUAGE TypeFamilies #-}\nmodule Incredible.DataStore.Memory where\n\nimport Control.Monad\nimport Control.Monad.IO.Class\nimport Control.Concurrent.STM\nimport Data.Foldable\nimport Data.HashMap.Strict (HashMap)\nimport Data.HashMap.Strict qualified  as HashMap\nimport Data.Int\nimport Data.Map (Map)\nimport Data.Map qualified as Map\nimport Data.Set qualified as Set\nimport Incredible.Data\nimport Incredible.DataStore\n\ndata IncredibleState = IncredibleState {\n  machines :: Map MachineVersion GameState\n, blueprints :: Map BlueprintID Blueprint\n, snapshots :: Map BlueprintID Snapshot\n, moderation :: Map PuzzleID (Set.Set BlueprintID)\n, workorders :: Map WorkOrderID WorkOrder\n, widgetOrders :: Map WidgetSignature Int64\n}\n\nnewtype IncredibleStore = IncredibleStore {\n  getIncredibleStore :: TVar IncredibleState\n}\n\ninitIncredibleState :: MonadIO m\n                    => a\n                    -> MetaMachine Puzzle\n                    -> m (IncredibleStore, HashMap PuzzleID Puzzle)\ninitIncredibleState _ machine = liftIO $ do\n  let state = IncredibleState (Map.singleton 0 $ fmap (Left . puzzleID) machine) mempty mempty mempty mempty mempty\n  store <- newTVarIO state\n  pure $ (IncredibleStore store, HashMap.fromList $ map (\\p -> (puzzleID p, p)) $ toList machine)\n\ninstance IncredibleData IncredibleStore where\n\n  getCurrentMachine' store = liftIO $ do\n    state <- readTVarIO $ getIncredibleStore store\n    -- Safe because the map is forced to contain the initial machine.\n    maybe (fail \"initial machine missing from map\") (\\(v, m) -> pure (VersionedMachine v m)) $ Map.lookupMax $ machines state\n  getMachine' store version = liftIO $ do\n    state <- readTVarIO $ getIncredibleStore store\n    pure $ Map.lookup version $ machines state\n  editCurrentMachine' store f = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    case Map.lookupMax (machines state) of\n      Nothing -> error \"initial machine missing from map\"\n      Just (version, current) -> do\n        let (r, nv) = f $ VersionedMachine version current\n        let newVer = succ version\n        when (current /= nv) $\n          writeTVar (getIncredibleStore store) $ state { machines = Map.insert newVer nv $ machines state }\n        pure r\n\n  addBlueprint' store blueprint = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    writeTVar (getIncredibleStore store) $ state { blueprints = Map.insert (blueprintID blueprint) blueprint $ blueprints state }\n  getBlueprint' store bpID = liftIO $ do\n    state <- readTVarIO $ getIncredibleStore store\n    pure $ Map.lookup bpID $ blueprints state\n\n  addSnapshot' store blueprint snapshot = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    writeTVar (getIncredibleStore store) $ state { snapshots = Map.insert blueprint snapshot $ snapshots state }\n  getSnapshot' store bpID = liftIO $ do\n    state <- readTVarIO $ getIncredibleStore store\n    pure $ Map.lookup bpID $ snapshots state\n\n  queueModeration' store pzlID bpID = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    let insertModeration = Map.insertWith Set.union pzlID (Set.singleton bpID)\n    writeTVar (getIncredibleStore store) $ state { moderation = insertModeration $ moderation state }\n  dequeueModeration' store pzlID bpID = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    let removeModeration = Map.adjust (Set.delete bpID) pzlID\n    writeTVar (getIncredibleStore store) $ state { moderation = removeModeration $ moderation state }\n  listModerationQueue' store pzlID = liftIO $ do\n    state <- readTVarIO $ getIncredibleStore store\n    pure $ maybe [] Set.toList $ Map.lookup pzlID $ moderation state\n\n  addWorkOrder' store wid w = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    writeTVar (getIncredibleStore store) $ state { workorders = Map.insert wid w $ workorders state }\n  pullWorkOrder' store wid = liftIO $ atomically $ do\n    state <- readTVar $ getIncredibleStore store\n    let (mwo, nm) = Map.updateLookupWithKey (\\_ _ -> Nothing) wid $ workorders state\n    writeTVar (getIncredibleStore store) $ state { workorders = nm }\n    pure mwo\n\n  widgetOrderBook' store ws = liftIO $ atomically $ do\n    state <- readTVar (getIncredibleStore store)\n    let nm = Map.insertWith (+) ws 1 $ widgetOrders state\n    writeTVar (getIncredibleStore store) $ state { widgetOrders = nm }\n    pure $ Map.findWithDefault 1 ws nm\n"
  },
  {
    "path": "src/Incredible/DataStore/Redis.hs",
    "content": "{-# language ImportQualifiedPost #-}\n{-# LANGUAGE LambdaCase #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE RankNTypes #-}\n{-# LANGUAGE TupleSections #-}\n{-# LANGUAGE TypeFamilies #-}\nmodule Incredible.DataStore.Redis where\n\nimport Control.DeepSeq\nimport Control.Exception qualified as BE\nimport Control.Monad\nimport Control.Monad.Except\nimport Control.Monad.IO.Class\nimport Control.Monad.Trans\nimport Control.Monad.Trans.Maybe\nimport Data.Aeson qualified as JS\nimport Data.Bytes.Put (runPutS)\nimport Data.Bytes.Serial qualified as S\nimport Data.ByteString qualified as BS\nimport Data.ByteString.Char8 qualified as Char8\nimport Data.ByteString.Lazy qualified as BSL\nimport Data.Either\nimport Data.Foldable\nimport Data.HashMap.Strict (HashMap)\nimport Data.HashMap.Strict qualified as HashMap\nimport Data.Maybe\nimport Data.UUID (UUID)\nimport Data.UUID qualified as UUID\nimport Database.Redis (Redis)\nimport Database.Redis qualified as Redis\nimport Incredible.Config\nimport Incredible.Data\nimport Incredible.DataStore\n--import Toml.Schema qualified as Toml\nimport Type.Reflection\nimport System.X509\nimport qualified Network.TLS as TLS\nimport Data.Default\nimport qualified Network.TLS.Extra.Cipher as Cipher\n\n-- |\n--   machinesKey :: Hash\n--     - Watch machinesKey for transactions.\n--     - HLen-1 machine version.\n--     - machinesKey[version] for machine.\n--     - HSETNX machines into it, shouldn't need a transaction since we're dense monotonic in IDs.\n--     - Existamce of this key means the database is initialized.\n--\n--   blueprintsKey :: Hash\n--     - blueprintsKey[blueprintID] for blueprints.\n--     - HSETNX blueprints into it\n--\n--   snapshotsKey :: Hash\n--     - snapshotsKey[blueprintID] for snapshots.\n--     - HSET snapshots into it because we can change these in a critical situation\n--\n--   puzzlesKey :: Hash\n--     - added at Init.\n--\n--   moderationKey<>puzzleID :: Set\n--     - Data integrity here isn't so important. Just don't add anything that doesn't fit.\n--\n--   workKey<>WorkOrderID :: String\n--     - volatile\n--\n--   widgetOrderKet<>WidgetOrder :: String\n--     - Stores Int64s\n--     - We incr and read\n--     - Transactional integrity is, if anything, a drawback.\n\nmachinesKey :: BS.ByteString\nmachinesKey = \"machines\"\n\nblueprintsKey :: BS.ByteString\nblueprintsKey = \"blueprints\"\n\nsnapshotsKey :: BS.ByteString\nsnapshotsKey = \"snapshots\"\n\npuzzlesKey :: BS.ByteString\npuzzlesKey = \"puzzles\"\n\nmoderationKey :: PuzzleID -> BS.ByteString\nmoderationKey pid = \"moderation:\" <> encodeUUID pid\n\nworkKey :: WorkOrderID -> BS.ByteString\nworkKey (WorkOrderID wid) = \"work:\" <> encodeUUID wid\n\nwidgetOrderKey :: WidgetSignature -> BS.ByteString\nwidgetOrderKey ws = \"widget_\"<>runPutS (S.serialize ws)\n\ndata IncredibleRedisStore\n  = IncredibleRedisStore\n    { incredibleRedisConnection :: Redis.Connection\n    , retryCount :: Int\n    , workOrderTermSeconds :: Integer\n    , widgetOrderBookTermSeconds :: Integer\n    }\n\nlogger :: MonadIO m => Bool -> String -> m ()\nlogger False = void . pure\nlogger True  = liftIO . print\n\ninitConnectInfo :: MonadIO m => IncredibleRedisConfig -> m Redis.ConnectInfo\ninitConnectInfo cfg = do\n  certStore <- liftIO $ getSystemCertificateStore\n  let tlsParams =\n        if incredibleRedisUseTLS cfg\n          then Just $ (TLS.defaultParamsClient (incredibleRedisHostName cfg) \"\") {\n                  TLS.clientSupported = def {TLS.supportedCiphers = Cipher.ciphersuite_default }\n                , TLS.clientShared = def { TLS.sharedCAStore = certStore }\n                }\n          else Nothing\n  pure $ Redis.defaultConnectInfo {\n            Redis.connectHost = incredibleRedisHostName cfg\n          , Redis.connectPort = incredibleRedisPort cfg\n          , Redis.connectAuth = incredibleRedisPassword cfg\n          , Redis.connectDatabase = incredibleRedisDatabase cfg\n          , Redis.connectMaxConnections = incredibleRedisMaxConnections cfg\n          , Redis.connectMaxIdleTime = fromInteger $ incredibleRedisMaxIdleTimeout cfg\n          , Redis.connectTLSParams = tlsParams\n        }\ninitIncredibleState :: MonadIO m => IncredibleConfig -> MetaMachine Puzzle -> m (IncredibleRedisStore, HashMap PuzzleID Puzzle)\ninitIncredibleState (IncredibleConfig {incredibleRedis = Nothing}) _ = liftIO $ fail \"No Redis config\"\ninitIncredibleState (IncredibleConfig {incredibleRedis = Just cfg}) ipzl = do\n  conninfo <- initConnectInfo cfg\n  conn <- liftIO $ Redis.checkedConnect $ conninfo\n  let is = IncredibleRedisStore conn retries wottl wobttl\n  didInit <- raisingException is $ liftRedis $\n    Redis.hsetnx machinesKey (encodeVersion 0) $ encodeMachine (Left . puzzleID <$> ipzl)\n  when didInit $ logger False (\"Initialized DataStore\"::String)\n  let pzlList = map (\\p -> (puzzleID p, p)) $ toList ipzl\n  -- Get all puzzles ever\n  pzlRedisList <- do\n    forM_ pzlList $ \\(pid, p) -> raisingException is $ liftRedis $\n      Redis.hsetnx puzzlesKey (encodeUUID pid) $ encodePuzzle p\n    rpzls <- raisingException is $ liftRedis $ Redis.hgetall puzzlesKey\n    fmap catMaybes . forM rpzls $ \\(bpid, bpzl) -> runMaybeT $ do\n      pid <- maybe (logger True \"couldn't decode PuzzleID\" >> mzero) pure $ decodeUUID bpid\n      p <- maybe (logger True (\"couldn't decode Puzzle: \"<>show bpid) >> mzero) pure $ decodePuzzle bpzl\n      pure (pid, p)\n  pure (is, HashMap.fromList $ pzlList <> pzlRedisList)\n  where\n    retries = incredibleRedisRetryCount cfg\n    wottl = incredibleWorkOrderTTL cfg\n    wobttl = incredibleOrderBookTTL cfg\n\nraisingException :: (MonadIO m, NFData a) => IncredibleRedisStore -> ExceptT RedisError Redis a -> m a\nraisingException store act = fmap force $\n  (either BE.throw pure <=< (liftIO . Redis.runRedis (incredibleRedisConnection store))) $ runExceptT act\n\ndata RedisError =\n    RedisReplyError Redis.Reply\n  | RedisTxError String\n  | RedisTxAborted String\n  | RedisRetryLimitReached String\n  | RedisGetNotFound BS.ByteString\n  | RedisHGetNotFound BS.ByteString BS.ByteString\n  | RedisMachineDecodeError String\n  | RedisVersionDecodeError String\n  | RedisBlueprintDecodeError String\n  | RedisSnapshotDecodeError String\n  | RedisUUIDDecodeError BS.ByteString\n  | RedisMachineVersionNotFound MachineVersion\n  | RedisIntegrityError String\n  deriving (Show, Eq, Typeable)\n\ninstance BE.Exception RedisError\n\nretryTransaction :: Int -> ExceptT RedisError Redis a -> ExceptT RedisError Redis a\nretryTransaction count tx = go count\n  where\n    go c = catchError tx $ \\case\n            RedisTxAborted source -> if c > 0 then go (c - 1) else BE.throw $ RedisRetryLimitReached source\n            err -> BE.throw err\n\nencodeUUID :: UUID -> BS.ByteString\nencodeUUID = BSL.toStrict . UUID.toByteString\n\ndecodeUUID :: BS.ByteString -> Maybe UUID\ndecodeUUID = UUID.fromByteString . BSL.fromStrict\n\nencodeVersion :: MachineVersion -> BS.ByteString\nencodeVersion = Char8.pack . show -- TODO this is not great\n\ndecodePuzzle :: BS.ByteString -> Maybe Puzzle\ndecodePuzzle contents =\n  case JS.eitherDecodeStrict contents of\n    Left _ -> Nothing\n    Right version -> Just version\n\nencodeWorkOrder :: WorkOrder -> Char8.ByteString\nencodeWorkOrder = BSL.toStrict . JS.encode\n\ndecodeWorkOrder :: BS.ByteString -> Maybe WorkOrder\ndecodeWorkOrder contents =\n  case JS.eitherDecodeStrict contents of\n    Left _ -> Nothing\n    Right wo -> pure wo\n\nencodePuzzle :: Puzzle -> Char8.ByteString\nencodePuzzle = BSL.toStrict . JS.encode\n\ndecodeBlueprint :: BS.ByteString -> ExceptT RedisError Redis Blueprint\ndecodeBlueprint contents =\n  case JS.eitherDecodeStrict contents of\n    Left err -> throwError $ RedisBlueprintDecodeError err\n    Right bp -> pure bp\n\nencodeBlueprint :: Blueprint -> Char8.ByteString\nencodeBlueprint = BSL.toStrict . JS.encode\n\ndecodeSnapshot :: BS.ByteString -> ExceptT RedisError Redis Snapshot\ndecodeSnapshot contents =\n  case JS.eitherDecodeStrict contents of\n    Left err -> throwError $ RedisSnapshotDecodeError err\n    Right ss -> pure ss\n\nencodeSnapshot :: Snapshot -> Char8.ByteString\nencodeSnapshot = BSL.toStrict . JS.encode\n\nencodeMachine :: GameState -> Char8.ByteString\nencodeMachine = BSL.toStrict . JS.encode\n\ngetMachineVersion :: (Functor f, Redis.RedisCtx m f) => MachineVersion -> m (f (Either RedisError GameState))\ngetMachineVersion mv = do\n  machineJSON <- Redis.hget machinesKey (encodeVersion mv)\n  pure $ maybe (Left $ RedisMachineVersionNotFound mv) (either (Left . RedisMachineDecodeError) Right . JS.eitherDecodeStrict) <$> machineJSON\n\nexecTrans :: String -> (forall m f . (Functor f, Redis.RedisCtx m f) => m (f (Either RedisError a))) -> ExceptT RedisError Redis a\nexecTrans source txn = do\n  txr <- lift $ Redis.multiExec txn\n  case txr of\n    Redis.TxSuccess (Left err) -> throwError err\n    Redis.TxSuccess (Right r) -> pure r\n    Redis.TxAborted -> throwError $ RedisTxAborted source\n    Redis.TxError err -> throwError $ RedisTxError err\n\nliftRedis :: Redis (Either Redis.Reply a) -> ExceptT RedisError Redis a\nliftRedis act = do\n  resp <- lift act\n  case resp of\n    (Left rep) -> throwError $ RedisReplyError rep\n    (Right r)  -> pure r\n\ninstance IncredibleData IncredibleRedisStore where\n  getCurrentMachine' conn = raisingException conn $ do\n      retryTransaction (retryCount conn) $ do\n        Redis.Ok <- liftRedis $ Redis.watch [machinesKey]\n        numMachines <- liftRedis $ Redis.hlen machinesKey\n        let curMachineVersion = numMachines-1\n        VersionedMachine curMachineVersion <$> execTrans \"getCurrentMachine\" (getMachineVersion curMachineVersion)\n  {-# INLINE getCurrentMachine' #-}\n\n  getMachine' conn version = raisingException conn $ do\n    handleError (\\case {RedisMachineVersionNotFound _ -> pure Nothing; err -> throwError err}) $ do\n      fmap Just $ liftEither =<< liftRedis (getMachineVersion version)\n  {-# INLINE getMachine' #-}\n\n  editCurrentMachine' conn f = raisingException conn $ do\n    retryTransaction (retryCount conn) $ do\n      Redis.Ok <- liftRedis $ Redis.watch [machinesKey]\n      numMachines <- liftRedis $ Redis.hlen machinesKey\n      let curMachineVersion = numMachines-1\n      let newMachineVersion = succ curMachineVersion\n      machine <- liftEither =<< liftRedis (getMachineVersion curMachineVersion)\n      let (r, newMachine) = f (VersionedMachine curMachineVersion machine)\n      when (newMachine /= machine) $ do\n        commited <- execTrans \"editCurrentMachine\" $ fmap (fmap Right) $ Redis.hsetnx machinesKey (encodeVersion newMachineVersion) $ encodeMachine newMachine\n        unless commited $ throwError $ RedisTxAborted \"Raced editing machine\"\n      pure r\n  {-# INLINE editCurrentMachine' #-}\n\n  addBlueprint' conn blueprint = raisingException conn $ void $ liftRedis $\n    Redis.hsetnx blueprintsKey (encodeUUID $ blueprintID blueprint) $ encodeBlueprint blueprint\n  {-# INLINE addBlueprint' #-}\n\n  getBlueprint' conn bid = raisingException conn $ do\n    liftRedis (Redis.hget blueprintsKey (encodeUUID bid)) >>= maybe (pure Nothing) (fmap Just . decodeBlueprint)\n  {-# INLINE getBlueprint' #-}\n\n  addSnapshot' conn blueprint snapshot = raisingException conn $ void $ liftRedis $\n    Redis.hset snapshotsKey (encodeUUID blueprint) $ encodeSnapshot snapshot\n  {-# INLINE addSnapshot' #-}\n\n  getSnapshot' conn bid = raisingException conn $ do\n    liftRedis (Redis.hget snapshotsKey (encodeUUID bid)) >>= maybe (pure Nothing) (fmap Just . decodeSnapshot)\n  {-# INLINE getSnapshot' #-}\n\n  queueModeration' conn pid bid = raisingException conn $ do\n    void $ liftRedis $ Redis.sadd (moderationKey pid) [encodeUUID bid]\n  {-# INLINE queueModeration' #-}\n\n  dequeueModeration' conn pid bid = raisingException conn $ do\n    void $ liftRedis $ Redis.srem (moderationKey pid) [encodeUUID bid]\n  {-# INLINE dequeueModeration' #-}\n\n  listModerationQueue' conn pid = raisingException conn $ do\n    fmap (mapMaybe decodeUUID) $ liftRedis $ Redis.smembers (moderationKey pid)\n  {-# INLINE listModerationQueue' #-}\n\n  modQueueLength' conn pids = raisingException conn $ do\n    liftRedis $ fmap (Right . rights) . forM pids $ \\pid -> fmap (pid,) <$> Redis.scard (moderationKey pid)\n  {-# INLINE modQueueLength' #-}\n\n  addWorkOrder' conn wid w = raisingException conn $ do\n    let wokey = workKey wid\n    void $ execTrans \"addWorkOrder\" $ do\n      void $ Redis.set wokey (encodeWorkOrder w)\n      fmap Right <$> Redis.expire wokey (workOrderTermSeconds conn)\n  {-# INLINE addWorkOrder' #-}\n\n  pullWorkOrder' conn wid = raisingException conn $ do\n    let wokey = workKey wid\n    execTrans \"pullWorkOrder\" $ do\n      mwo <- (fmap (Right . join . fmap decodeWorkOrder)) <$> Redis.get wokey\n      void $ Redis.del [wokey]\n      pure mwo\n  {-# INLINE pullWorkOrder' #-}\n\n  widgetOrderBook' conn ws = raisingException conn $ do\n    let woKey = widgetOrderKey ws\n    execTrans \"addWorkOrder\" $ do\n      wob <- fmap fromInteger <$> Redis.incr woKey\n      void $ Redis.expire woKey (widgetOrderBookTermSeconds conn)\n      pure $ fmap Right wob\n  {-# INLINE widgetOrderBook' #-}\n"
  },
  {
    "path": "src/Incredible/DataStore.hs",
    "content": "{-# LANGUAGE AllowAmbiguousTypes #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE FunctionalDependencies #-}\n{-# LANGUAGE StarIsType #-}\n{-# LANGUAGE TupleSections #-}\n{-# LANGUAGE TypeFamilies #-}\nmodule Incredible.DataStore\n ( IncredibleData(..)\n , HasDataStore(..)\n , getCurrentMachine\n , getMachine\n , editCurrentMachine\n , getBlueprint, addSnapshot, getSnapshot\n , queueModeration\n , dequeueModeration\n , listModerationQueue\n , modQueueLength\n , addWorkOrder\n , pullWorkOrder\n , widgetOrderBook\n , lruFetch\n ) where\n\nimport           Control.DeepSeq\nimport           Control.Monad.IO.Class\nimport           Control.Monad.Reader\nimport           Data.Cache.LRU.IO (AtomicLRU)\nimport qualified Data.Cache.LRU.IO as LRU\nimport           Data.Foldable\nimport           Data.Int\nimport           Data.Traversable\nimport           Incredible.Data\n\nclass IncredibleData s where\n\n  getCurrentMachine' :: MonadIO m => s -> m VersionedGameState\n  getMachine' :: MonadIO m => s -> MachineVersion -> m (Maybe GameState)\n  -- | Must make no edit if the machine is the same as the current machine.\n  editCurrentMachine' :: (MonadIO m, NFData a) => s -> (VersionedGameState -> (a, GameState)) -> m a\n  addBlueprint' :: MonadIO m => s -> Blueprint -> m ()\n  getBlueprint' :: MonadIO m => s -> BlueprintID -> m (Maybe Blueprint)\n\n  addSnapshot' :: MonadIO m => s -> BlueprintID -> Snapshot -> m ()\n  getSnapshot' :: MonadIO m => s -> BlueprintID -> m (Maybe Snapshot)\n\n  queueModeration' :: MonadIO m => s -> PuzzleID -> BlueprintID -> m ()\n  dequeueModeration' :: MonadIO m => s -> PuzzleID -> BlueprintID -> m ()\n  listModerationQueue' :: MonadIO m => s -> PuzzleID -> m [BlueprintID]\n  modQueueLength' ::  MonadIO m => s -> [PuzzleID] -> m [(PuzzleID, Integer)]\n  modQueueLength' s pids = forM pids $ \\pid -> fmap ((pid,) . toInteger . length) $ listModerationQueue' s pid\n\n  addWorkOrder' :: MonadIO m => s -> WorkOrderID -> WorkOrder -> m ()\n  -- | Work orders are single use.\n  pullWorkOrder' :: MonadIO m => s -> WorkOrderID -> m (Maybe WorkOrder)\n\n  -- | Records the use of a widget and provides an estimate of the number of times it has been used.\n  widgetOrderBook' :: MonadIO m => s -> WidgetSignature -> m Int64\n\n--  sharedData :: MonadIO m => s -> (ServerSharedState -> ServerSharedState) -> m ServerSharedState\n\nlruFetch :: (Ord key, MonadIO m, NFData val) => (key -> m (Maybe val)) -> key -> AtomicLRU key val -> m (Maybe val)\nlruFetch fetcher k lru = do\n   mr <- liftIO $ LRU.lookup k lru\n   case mr of\n    val@(Just _) -> pure val\n    Nothing -> do\n      newVal <- fetcher k\n      -- Make sure everything in cache is fully evaluated.\n      deepseq newVal $ liftIO $ traverse_ (\\n -> LRU.insert k n lru) newVal\n      pure newVal\n{-# INLINE lruFetch #-}\n\nclass IncredibleData s => HasDataStore r s | r -> s where\n  getStore :: r -> s\n  getMachineCache :: r ->  AtomicLRU MachineVersion GameState\n  getBlueprintCache :: r -> AtomicLRU BlueprintID Blueprint\n  getSnapshotCache :: r -> AtomicLRU BlueprintID Snapshot\n\ninstance IncredibleData s => HasDataStore (MachineShop s) s where\n  getStore = sdStore\n  getMachineCache = sdMachineByVersion\n  getBlueprintCache = sdBlueprintCache\n  getSnapshotCache = sdSnapshotCache\n\ngetCurrentMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => m VersionedGameState\ngetCurrentMachine = getCurrentMachine' =<< asks getStore\n{-# INLINE getCurrentMachine #-}\n\ngetMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => MachineVersion -> m (Maybe GameState)\ngetMachine mv = lruFetch (\\k -> (`getMachine'` k) =<< asks getStore) mv =<< asks getMachineCache\n{-# INLINE getMachine #-}\n\neditCurrentMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m, NFData a) => (VersionedGameState -> (a, GameState)) -> m a\neditCurrentMachine act = (`editCurrentMachine'` act) =<< asks getStore\n{-# INLINE editCurrentMachine #-}\n\n-- | IMPLICITE TO queueModeration\naddBlueprint :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => Blueprint -> m ()\naddBlueprint b = (`addBlueprint'` b) =<< asks getStore\n{-# INLINE addBlueprint #-}\n\ngetBlueprint :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m (Maybe Blueprint)\ngetBlueprint bid = lruFetch (\\k -> (`getBlueprint'` k) =<< asks getStore) bid =<< asks getBlueprintCache\n{-# INLINE getBlueprint #-}\n\naddSnapshot :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> Snapshot -> m ()\naddSnapshot b s = (\\d -> addSnapshot' d b s) =<< asks getStore\n{-# INLINE addSnapshot #-}\n\ngetSnapshot :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m (Maybe Snapshot)\ngetSnapshot bid = lruFetch (\\k -> (`getSnapshot'` k) =<< asks getStore) bid =<< asks getSnapshotCache\n{-# INLINE getSnapshot #-}\n\nqueueModeration :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => Blueprint -> m ()\nqueueModeration bp = do\n  addBlueprint bp\n  (\\s -> queueModeration' s (bPuzzleID bp) (blueprintID bp)) =<< asks getStore\n{-# INLINE queueModeration #-}\n\ndequeueModeration :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m ()\ndequeueModeration bpid = do\n  maybe (pure ()) (\\bp -> (\\s -> dequeueModeration' s (bPuzzleID bp) bpid) =<< asks getStore) =<< getBlueprint bpid\n{-# INLINE dequeueModeration #-}\n\nlistModerationQueue :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => PuzzleID -> m [BlueprintID]\nlistModerationQueue pid = (`listModerationQueue'` pid) =<< asks getStore\n{-# INLINE listModerationQueue #-}\n\nmodQueueLength :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => [PuzzleID] -> m [(PuzzleID, Integer)]\nmodQueueLength pid = (`modQueueLength'` pid) =<< asks getStore\n{-# INLINE modQueueLength #-}\n\naddWorkOrder :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WorkOrderID -> WorkOrder -> m ()\naddWorkOrder wid w = (\\s -> addWorkOrder' s wid w) =<< asks getStore\n{-# INLINE addWorkOrder #-}\n\npullWorkOrder :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WorkOrderID -> m (Maybe WorkOrder)\npullWorkOrder wid = (`pullWorkOrder'` wid) =<< asks getStore\n{-# INLINE pullWorkOrder #-}\n\nwidgetOrderBook :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WidgetSignature -> m Int64\nwidgetOrderBook ws = (`widgetOrderBook'` ws) =<< asks getStore\n{-# INLINE widgetOrderBook #-}\n"
  },
  {
    "path": "src/Incredible/Puzzle.hs",
    "content": "{-# LANGUAGE BangPatterns        #-}\n{-# LANGUAGE LambdaCase          #-}\n{-# LANGUAGE RankNTypes          #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAGE TupleSections       #-}\nmodule Incredible.Puzzle where\n\nimport           Control.Arrow\nimport           Control.Exception\nimport           Control.Monad\nimport           Control.Monad.ST\nimport qualified Data.Aeson.KeyMap as JS\nimport           Data.Array (Array)\nimport qualified Data.Array as Array\nimport           Data.Array.IO (IOArray)\nimport qualified Data.Array.MArray as MArray\nimport           Data.Bifunctor\nimport           Data.Foldable\nimport qualified Data.Foldable as Foldable\nimport           Data.IORef\nimport qualified Data.Ix as Ix\nimport qualified Data.List as List\nimport           Data.Map (Map)\nimport qualified Data.Map as Map\nimport           Data.Maybe (catMaybes, fromJust)\nimport           Data.Set (Set)\nimport qualified Data.Set as Set\nimport           Data.Vector (Vector)\nimport qualified Data.Vector as Vector\nimport           Data.Vector.Mutable (MVector)\nimport qualified Data.Vector.Mutable as MVector\nimport           GHC.IsList\nimport           Incredible.Data\nimport qualified Incredible.DataStore.Memory as Mem\nimport           System.Random.Stateful\nimport           Text.Printf                 (printf)\n\ndata PuzzleConfig = PuzzleConfig {\n  ballTypes            :: Int -- ^ Number of different types of balls\n, minBallConfiguration :: [Int] -- ^ Minimum ball configuration\n, puzzleLocations      :: Set Double -- ^ Locations of possible connectors\n}\n\ndata PuzzleGen = PuzzleGen {\n  puzzleConfig   :: PuzzleConfig\n, puzzleGenerate :: IOGenM StdGen -> IO [PuzzleRow]\n}\n\ngamePuzzle :: PuzzleGen\ngamePuzzle = PuzzleGen cfg $ \\g -> do\n  applyAll initial $ concat [\n        replicate 10 $ swapBalls g (SwapConfig (1,2) (14,20)) -- >=> towardsBallCount g cfg 16\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (18,20))\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (18,18)) -- >=> towardsBallCount g cfg 18\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (12,12))\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (10,10)) -- >=> towardsBallCount g cfg 16\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16))\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16)) -- >=> towardsBallCount g cfg 14\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16))\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (10,10)) -- >=> towardsBallCount g cfg 10\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10\n      , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10\n      , replicate 7 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10\n      -- , replicate 1 $ pure\n    ]\n  where\n    cfg = PuzzleConfig 4 ballConfiguration (Set.fromList [0.2, 0.35, 0.5, 0.65, 0.8])\n    initial = PuzzleRow $ Vector.fromList $ concat $ fmap mkColumm ballConfiguration\n    ballConfiguration = [2,2,2, 4,4,4, 1,1,1, 3,3,3]\n    -- mkColumm b = [ConnectorEmpty, ConnectorOpen $ BallType' b,  ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty]\n    mkColumm b = [ConnectorEmpty, ConnectorEmpty, ConnectorOpen $ BallType' b,  ConnectorEmpty, ConnectorEmpty]\n\ntestPuzzle :: PuzzleGen\ntestPuzzle = PuzzleGen cfg $ \\g -> do\n  applyAll initial $ concat [\n        replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 5\n      , replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 6\n      , replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 5\n      , replicate 1 $ pure\n    ]\n  where\n    cfg = PuzzleConfig 4 ballConfiguration (Set.fromList [0.2, 0.35, 0.5, 0.65, 0.8])\n    ballConfiguration = [1,2,3,3,4]\n    initial = PuzzleRow $ Vector.fromList $ concat $ fmap mkColumm [1,2,3,3,4]\n    mkColumm b = [ConnectorEmpty, ConnectorOpen $ BallType' b,  ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty]\n\ngeneratePuzzle :: IOGenM StdGen -> PuzzleGen -> IO (Array (X, Y) Puzzle)\ngeneratePuzzle g gen = do\n  retryForever $ do\n    rows <- puzzleGenerate gen g\n    putStrLn \"Rendingering rows to grid\"\n    outputs <- fromOutputRows cfg rows\n    putStrLn \"Routing balls\"\n    routed <- routeBalls g outputs\n    putStrLn \"Placing connecteors\"\n    placed <- routeConnectors g (puzzleLocations cfg) routed\n\n    putStrLn \"Joinging streams\"\n    joined <- joinStreams g 30 placed\n\n    putStrLn \"Calculating rates\"\n    rates <- calculateRates joined\n\n    putStrLn \"Calculating dependencies\"\n    dependencies <- generateDependencies placed\n\n    let puzzle = iMap (toPuzzle' dependencies rates) joined\n    when (any isEmptyPuzzle puzzle) $ fail \"Empty puzzle\"\n    putStrLn (renderRows rows)\n    putStrLn $ prettyDependencyArray' dependencies\n    putStrLn \"--------------------------------\"\n    putStrLn $ prettyPuzzleRates (bimap (round . (*10)) (round . (*10))) puzzle\n    putStrLn \"--------------------------------\"\n    putStrLn $ prettyPuzzleArray' (bimap (round . (*10)) (round . (*10))) $ puzzle\n    pure puzzle\n  where\n    retryForever f =\n      try f >>= \\case\n        Left (e :: SomeException) -> do\n          putStrLn $ \"Error: \" <> show e\n          retryForever f\n        Right r -> pure r\n    cfg = puzzleConfig gen\n    iMap f r = Array.array (Array.bounds r) $ fmap (\\(i, a) -> (i, f i a)) $ Array.assocs r\n\nisEmptyPuzzle :: Puzzle -> Bool\nisEmptyPuzzle p = Vector.null (pInputs p) && Vector.null (pOutputs p)\n\ntoPuzzle'' :: (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved\n          -> PuzzleConnectors (Set Double) -- ^ Connectors for the puzzle, each connector direction has the ratio from the origin of the edge and the type of the connector\n          -> Puzzle\ntoPuzzle'' (_x, y) (PuzzleConnectors i o) =\n  Puzzle\n    (Vector.fromList requirements)\n    (mkGateways inputs)\n    (mkGateways outputs)\n    JS.empty -- TODO add spec\n  where\n    requirements =\n      if y == 0\n        then [] -- no requirements for the top row\n        else (fmap dirToRel $ Map.keys $ Map.filter Set.null $ inputs) -- TODO inputs are required before the puzzle can be solved\n    mkGateways :: Map Direction (Set Double) -> Vector.Vector Gateway\n    mkGateways = Vector.fromList . concat . fmap (\\(dir, rs) -> fmap (toGateway dir) $ Set.toList rs) . Map.toList\n    toGateway :: Direction -> Double -> Gateway\n    toGateway dir r  = Gateway (onEdge dir r) (Vector.fromList [GatewayBall 1 1]) -- TODO add rates\n    inputs = directed i\n    outputs = directed o\n\nmachineIsSolvable :: MetaMachine Puzzle -> IO Bool\nmachineIsSolvable gen = do\n  (_, ps) <- Mem.initIncredibleState () gen\n  pure $ isSolvable ps gs\n  where\n    gs = fmap (Left . puzzleID) gen\n\nratesCount :: IOArray (X,Y) (PuzzleConnectors (Map Double Double)) -> IO Int\nratesCount rates = do\n  bounds <- MArray.getBounds rates\n  fmap sum $ forM (Ix.range bounds) $ \\i -> do\n    p <- MArray.readArray rates i\n    let is = puzzleInputs p\n        isCount :: Int\n        isCount = sum $ Map.elems $ fmap Map.size $ directed is\n        os = puzzleOutputs p\n        osCount = sum $ Map.elems $ fmap Map.size $ directed os\n    pure $ osCount + isCount\n\ncalculateRates :: Array (X,Y) (PuzzleConnectors (Map Double (Set Int)))\n               -> IO (Array (X,Y) (PuzzleConnectors (Map Double  Double)))\ncalculateRates grid' = do\n  rates :: IOArray (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.thaw $ fmap (fmap (fmap (const 0.0))) grid'\n\n  let\n    propagateFrom :: IORef (Set (X,Y)) -> Int -> (X, Y) -> IO ()\n    propagateFrom visited ty loc = do\n      rs <- MArray.readArray rates loc\n      vs <- readIORef visited\n      if loc `Set.member` vs\n      then pure ()\n      else do\n        atomicModifyIORef' visited $ \\v -> (Set.insert loc v, ())\n\n        let\n          connectors = grid' Array.! loc\n          inputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleInputs connectors\n          inputLocations :: [(Direction, Double)]\n          inputLocations = concat $ fmap (\\(dir, m) -> (dir,) <$> Map.keys m) $ Map.toList $ directed inputConnectors\n          outputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleOutputs connectors\n          outputCount = sum $ fmap Set.size $ concat $ fmap Map.elems $ Map.elems $ directed outputConnectors\n          inputRate = sum $ fmap (\\(dir, edgeLocation) -> Map.findWithDefault 0 edgeLocation  $ inDirection dir $ puzzleInputs rs ) inputLocations\n          !outputRate = inputRate / fromIntegral outputCount\n        -- print (loc, \"inputRate\", inputRate, \"outputRate\", outputRate, \"outputCount\", outputCount)\n        res <- fmap (Set.fromList . catMaybes) $ forM (Map.toList $ directed outputConnectors) $ \\(dir, edges) -> do\n          getRelativeM rates loc dir >>= \\case\n              Nothing -> do\n                forM_ (Map.toList edges) $ \\(edgeLocation, _) -> do\n                  MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate))\n                pure Nothing\n              Just (nLoc, _) -> do\n                forM_ (Map.toList edges) $ \\(edgeLocation, _) -> do\n                  -- print (\"propagating\", loc, nLoc, outputRate, dir)\n                  MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate))\n                  MArray.modifyArray' rates nLoc $ modifyInputs (modifyDirection (switchDirection dir) (Map.insert edgeLocation outputRate))\n                pure $ Just nLoc\n        forM_ res $ propagateFrom visited ty\n      -- propagateFrom (Set.insert nLoc visited) ty nLoc\n\n  -- Set all of the top inputs of a rate of 1.0\n  forM_ topInputs $ \\(loc, is) -> do\n    forM_ is $ \\(edgeLocation, _ts) -> do\n      MArray.modifyArray' rates loc $ modifyInputs (modifyDirection DUp (Map.insert edgeLocation 1.0))\n\n  let\n    iterateUntilStable :: Int -> IO () -> IO ()\n    iterateUntilStable 0 _ = error \"Failed to converge\"\n    iterateUntilStable n act = do\n      before :: Array (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.freeze rates\n      act\n      after <- MArray.freeze rates\n      if before == after\n        then pure ()\n        else iterateUntilStable (n - 1) act\n\n  iterateUntilStable 10000 $ forM_ topInputs $ \\(loc, is) -> do\n    forM_ is $ \\(_edgeLocation, ts) -> do\n      forM_ ts $ \\t -> do\n        visited <- newIORef Set.empty\n        propagateFrom visited t loc\n\n  MArray.freeze rates\n  where\n    ((startX, startY), (endX, _endY)) = Array.bounds grid'\n    topInputs = flip fmap [startX .. endX ] $ \\x ->\n      let loc = (x, startY)\n      in (loc, fmap (\\(k, v) -> (k, Set.toList v)) $ Map.toList $ inDirection DUp $ puzzleInputs $ grid' Array.! loc)\n\n-- calculateRates' :: Array (X,Y) (PuzzleConnectors (Map Double (Set Int)))\n--                 -> IO (Array (X,Y) (PuzzleConnectors (Map Double  Double)))\n-- calculateRates' grid' = do\n--   rates :: IOArray (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.thaw $ fmap (fmap (fmap (const 0.0))) grid'\n\n--   let\n--     propagateRates :: (X, Y) -> IO ()\n--     propagateRates loc = do\n--       rs <- MArray.readArray rates loc\n\n--       forM_ (Set.toList tys) $ \\ty -> do\n          \n--         let\n--           connectors = grid' Array.! loc\n--           inputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleInputs connectors\n--           inputLocations :: [(Direction, Double)]\n--           inputLocations = concat $ fmap (\\(dir, m) -> (dir,) <$> Map.keys m) $ Map.toList $ directed inputConnectors\n--           outputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleOutputs connectors\n--           outputCount = sum $ fmap Set.size $ concat $ fmap Map.elems $ Map.elems $ directed $ outputConnectors\n--           inputRate = sum $ fmap (\\(dir, edgeLocation) -> Map.findWithDefault 0 edgeLocation  $ inDirection dir $ puzzleInputs rs ) $ inputLocations\n--           outputRate = inputRate / fromIntegral outputCount\n--         forM_ (Map.toList $ directed outputConnectors) $ \\(dir, cs) -> \n--           forM_ (Map.toList cs) $ \\(edgeLocation, _) -> do\n--             getRelativeM rates loc dir >>= \\case\n--               Nothing -> do\n--                 MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate))\n--               Just (nLoc, _) -> do\n--                   MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate))\n--                   MArray.modifyArray' rates nLoc $ modifyInputs (modifyDirection (switchDirection dir) (Map.insert edgeLocation outputRate))\n--         pure ()\n\n\n  -- Set all of the top inputs of a rate of 1.0\n  -- forM_ topInputs $ \\(loc, is) -> do\n  --   forM_ is $ \\(edgeLocation, _ts) -> do\n  --     MArray.modifyArray' rates loc $ modifyInputs (modifyDirection DUp (Map.insert edgeLocation 1.0))\n\n  -- let\n  --   iterateUntilStable :: Int -> IO () -> IO ()\n  --   iterateUntilStable 0 _ = pure ()\n  --   iterateUntilStable n act = do\n  --     act\n  --     iterateUntilStable (n - 1) act\n\n  -- iterateUntilStable 50 $\n  --   forM_ [startY .. endY] $ \\y ->\n  --     forM_ [startX, endX] $ \\x -> do\n  --       let loc = (x, y)\n  --       -- putStrLn $ \"Propagating rates from \" <> show loc\n  --       propagateRates loc\n\n  -- MArray.freeze rates\n  -- where\n  --   tys :: Set Int\n  --   tys = Set.unions $ fmap (Set.unions . concat . fmap Map.elems . Map.elems . directed . puzzleInputs) $ Array.elems grid'\n  --   ((startX, startY), (endX, endY)) = Array.bounds grid'\n  --   topInputs = flip fmap [startX .. endX ] $ \\x ->\n  --     let loc = (x, startY)\n  --     in (loc, fmap (\\(k, v) -> (k, Set.toList v)) $ Map.toList $ inDirection DUp $ puzzleInputs $ grid' Array.! loc)\n\njoinStreams :: IOGenM StdGen -> Int -> Array (X, Y) (PuzzleConnectors (Map Double Int)) -> IO (Array (X, Y) (PuzzleConnectors (Map Double (Set Int))))\njoinStreams g den puzzle' = do\n  puzzle :: (IOArray (X, Y) (PuzzleConnectors (Map Double (Set Int)))) <- MArray.thaw $ fmap (fmap Set.singleton) <$> puzzle'\n\n  forM_ (Ix.range $ Array.bounds puzzle') $ \\i -> do\n    res <- randomRM (1, den) g\n    p <- MArray.readArray puzzle  i\n    if res == 1\n      then do\n        let validJoins = Map.toList $ Map.filter (\\cs -> Map.size cs > 1) $ directed $ puzzleOutputs p\n        picked <- pickOne g validJoins\n        case picked of\n          Nothing -> pure ()\n          Just (dir, pick1) -> do\n            pick2 <- pickOne' g pick1\n            case pick2 of\n              Nothing -> pure ()\n              Just (rest1, (chosenLoc, cs)) -> do\n                pick3 <- pickOne' g rest1\n                case pick3 of\n                  Nothing -> pure ()\n                  Just (rest2, (_chosenLoc2, cs2)) -> do\n                    getRelativeM puzzle i dir >>= \\case\n                      Nothing -> pure ()\n                      Just (nLoc, neighorP) -> do\n                        -- putStrLn $ \"Joining \" <> show i <> \" \" <> show nLoc <> \" \" <> show dir\n                        let\n                          new' :: Map Double (Set Int)\n                          new' = Map.insertWith Set.union chosenLoc (Set.union cs cs2) rest2\n                        MArray.writeArray puzzle i $ modifyOutputs (modifyDirection dir (const new')) p\n                        MArray.writeArray puzzle nLoc $ modifyInputs (modifyDirection (switchDirection dir) (const new')) neighorP\n      else pure ()\n  MArray.freeze puzzle\n\n\n-- bubbleSortStep :: PuzzleRow -> IO PuzzleRow\n-- bubbleSortStep row = do\n--   let\n--     balls = rowBalls row\n--     swaps = Vector.ifoldl' (\\acc i (i', b) -> if i /= i' then (i, i') : acc else acc) [] $ Vector.indexed balls\n--   pure $ modifyPuzzleRow row $ \\v -> do\n--     forM_ swaps $ \\(i, j) -> do\n--       let\n--         (iB, _) = balls Vector.! i\n--         (jB, _) = balls Vector.! j\n--       MVector.swap v iB jB\n\n\n\nprettyDependencyArray' :: Array (X, Y) (Set Direction) -> String\nprettyDependencyArray' a =\n  unlines $ concat $ flip fmap [startY..maxY] $ \\y -> concatRows $ flip fmap [startX..maxX] $ \\x -> prettyDependency $ a Array.! (x, y)\n  where\n    ((startX, startY), (maxX, maxY)) = Array.bounds a\n    concatRows :: [[String]] -> [String]\n    concatRows = fmap concat . List.transpose\n\nprettyDependency :: Set Direction -> [String]\nprettyDependency dirs = top ++ res ++ bottom\n  where\n    top = [replicate (length $ head res) '-']\n    bottom = [replicate (length $ head res) '-']\n    res = [\n        ['|', '.', depAt DUp, '.','|']\n      , ['|', depAt DLeft, '.', depAt DRight, '|']\n      , ['|', '.', depAt DDown, '.', '|']\n      ]\n    depAt dir = if dir `Set.member` dirs then 'X' else '.'\n\n-- | Gets the value in a cell at a relative position to a given cell, or Nothing if that is outside the grid.\ngetRelativeM :: IOArray (X,Y) a -> (X, Y) -> Direction -> IO (Maybe ((X,Y), a))\ngetRelativeM g (x0, y0) r = do\n  bounds <- MArray.getBounds g\n  if Ix.inRange bounds rp\n    then do\n      (Just . (rp,)) <$> MArray.readArray g rp\n    else pure Nothing\n  where\n    rp = bimap (x0 +) (y0 +) $ relMap r\n\ngenerateDependencies :: Array (X, Y) (PuzzleConnectors (Map Double Int)) -> IO (Array (X,Y) (Set Direction))\ngenerateDependencies routed = do\n  -- Track the dependencies for each puzzle\n  depencdencies :: IOArray (X, Y) (Set Direction) <- MArray.newGenArray bounds $ const $ pure mempty\n\n  let\n    go loc = do\n      let\n        outputs = directed $ puzzleOutputs $ routed Array.! loc\n        outputs' = Map.filter (not . Map.null) outputs\n      flip mapM_ (Map.keys outputs') $ \\dir -> do\n        localDeps <- MArray.readArray depencdencies loc\n        if dir `Set.member` localDeps\n          then pure ()\n          else do\n            getRelativeM depencdencies loc dir >>= \\case\n              Nothing -> pure ()\n              Just (nLoc, neighborDependencies)\n                | (switchDirection dir) `Set.member` neighborDependencies -> pure ()\n                | otherwise -> do\n                  MArray.writeArray depencdencies nLoc $ Set.insert (switchDirection dir) neighborDependencies\n                  go nLoc\n\n  flip mapM_ [xStart .. xEnd] $ \\x -> do\n    go (x, yStart)\n\n  MArray.freeze depencdencies\n  where\n    bounds@((xStart, yStart), (xEnd, _yEnd)) = Array.bounds routed\n\n\nprettyPuzzleRates :: ((Double, Double) -> (Int, Int)) -> Array (X, Y) Puzzle -> String\nprettyPuzzleRates connIndex a =\n  unlines $ concat $ flip fmap [startY..maxY] $ \\y -> concatRows $ flip fmap [startX..maxX] $ \\x -> prettyPuzzleRates' connIndex $ a Array.! (x, y)\n  where\n    ((startX, startY), (maxX, maxY)) = Array.bounds a\n    concatRows :: [[String]] -> [String]\n    concatRows = fmap concat . List.transpose\n\nprettyPuzzleArray' :: ((Double, Double) -> (Int, Int)) -> Array (X, Y) Puzzle -> String\nprettyPuzzleArray' connIndex a =\n  unlines $ concat $ flip fmap [startY..maxY] $ \\y -> concatRows $ flip fmap [startX..maxX] $ \\x -> prettyPuzzle' connIndex $ a Array.! (x, y)\n  where\n    ((startX, startY), (maxX, maxY)) = Array.bounds a\n    concatRows :: [[String]] -> [String]\n    concatRows = fmap concat . List.transpose\n\nprettyPuzzle' :: (Position -> (Int, Int)) -> Puzzle -> [String]\nprettyPuzzle' connIndex p = top ++ res ++ bottom\n  where\n    top = [replicate (length $ head res) '-']\n    bottom = [replicate (length $ head res) '-']\n    res =\n      fmap ((\"|\" ++) . (++ \"|\")) $\n        flip fmap [0..height ] $ \\y ->\n          concat $ flip fmap [0..width] $ \\xp ->\n            case Map.lookup (xp, y) is of\n              Nothing ->\n                case Map.lookup (xp, y) os of\n                  Nothing -> \"...\"\n                  Just o  -> show $ Vector.toList $ gBalls o\n              Just _i -> \"III\" -- show $ UVector.head $ gType i\n            -- if (xp, y) `Set.member` is then \"I\" else if (xp, y) `Set.member` os then \"O\" else \".\"\n    os = Map.fromList $ Vector.toList $ fmap ((connIndex .  gPos) &&& id) $ pOutputs p\n    is = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pInputs p\n    (width, height) = connIndex (1, 1)\n\nprettyPuzzleRates' :: (Position -> (Int, Int)) -> Puzzle -> [String]\nprettyPuzzleRates' connIndex p = top ++ res ++ bottom\n  where\n    top = [replicate (length $ head res) '-']\n    bottom = [replicate (length $ head res) '-']\n    res =\n      fmap ((\"|\" ++) . (++ \"|\")) $\n        flip fmap [0..height ] $ \\y ->\n          concat $ flip fmap [0..width] $ \\xp ->\n            case Map.lookup (xp, y) is of\n              Nothing ->\n                case Map.lookup (xp, y) os of\n                  Nothing -> \"....\"\n                  Just o  -> showDec o\n              Just i -> showDec i -- show $ UVector.head $ gType i\n            -- if (xp, y) `Set.member` is then \"I\" else if (xp, y) `Set.member` os then \"O\" else \".\"\n    os = Map.fromList $ Vector.toList $ fmap ((connIndex .  gPos) &&& id) $ pOutputs p\n    is = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pInputs p\n    (width, height) = connIndex (1, 1)\n    showDec = printf \"%.2f\" . sum . fmap gbRate . Vector.toList . gBalls\n\nnewtype BallType' = BallType' {\n  unBallType :: Int\n} deriving (Eq, Ord, Show)\n\n-- | Create a puzzle from connectors\ntoPuzzle :: Array (X, Y) (Set Direction) -- ^ Dependencies for each puzzle\n         -> (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved\n         -> PuzzleConnectors (Map Double Int) -- ^ Connectors for the puzzle, each connector direction has the ratio from the origin of the edge and the type of the connector\n         -> Puzzle\ntoPuzzle dependencies loc@(_x, y) (PuzzleConnectors i o) =\n  Puzzle\n    (Vector.fromList requirements)\n    (mkGateways inputs)\n    (mkGateways outputs)\n    JS.empty -- TODO add spec\n  where\n    requirements =\n      if y == 0\n        then [] -- no requirements for the top row\n        else fmap dirToRel $ Set.toList $ dependencies Array.! loc\n    mkGateways :: Map Direction (Map Double Int) -> Vector.Vector Gateway\n    mkGateways = Vector.fromList . concat . fmap (\\(dir, rs) -> fmap (uncurry $ toGateway dir) $ Map.toList rs) . Map.toList\n    toGateway :: Direction -> Double -> Int -> Gateway\n    toGateway dir r t = Gateway (onEdge dir r) (Vector.fromList [GatewayBall t 1]) -- TODO add rates\n    inputs = directed i\n    outputs = directed o\n\n-- | Create a puzzle from connectors\ntoPuzzle' :: Array (X, Y) (Set Direction) -- ^ Dependencies for each puzzle\n          -> Array (X, Y) (PuzzleConnectors (Map Double Double))\n         -> (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved\n         -> PuzzleConnectors (Map Double (Set Int)) -- ^ Connectors for the puzzle, each connector direction has the ratio from the origin of the edge and the type of the connector\n         -> Puzzle\ntoPuzzle' dependencies allRates loc@(_x, y) (PuzzleConnectors i o) =\n  Puzzle\n    (Vector.fromList requirements)\n    (mkGateways inputRates inputs)\n    (mkGateways outputRates outputs)\n    JS.empty -- TODO add spec\n  where\n    requirements =\n      if y == 0\n        then [] -- no requirements for the top row\n        else fmap dirToRel $ Set.toList $ dependencies Array.! loc\n    mkGateways :: DirectedConnectors (Map Double Double) -> Map Direction (Map Double (Set Int)) -> Vector.Vector Gateway\n    mkGateways rates = Vector.fromList . concat . fmap (\\(dir, rs) -> fmap (uncurry $ toGateway rates dir) $ Map.toList rs) . Map.toList\n    toGateway :: DirectedConnectors (Map Double Double) -> Direction -> Double -> Set Int -> Gateway\n    toGateway rates dir r t = Gateway (onEdge dir r) (Vector.fromList $ fmap (`GatewayBall` (lookupRate rates dir r)) $ Set.toList t) -- TODO add rates\n    lookupRate rates d r = fromJust $ Map.lookup r $ inDirection d rates\n    inputRates = puzzleInputs $ allRates Array.! loc\n    outputRates = puzzleOutputs $ allRates Array.! loc\n    inputs = directed i\n    outputs = directed o\n\nrandomBallType :: IOGenM StdGen -> PuzzleConfig -> IO BallType'\nrandomBallType gen cfg = do\n  i <- randomRM (1, ballTypes cfg) gen\n  pure $ BallType' i\n\ndata PuzzleConnector =\n    ConnectorEmpty\n  | ConnectorOpen BallType'\n  deriving (Eq, Ord, Show)\n\ntoBallType :: PuzzleConnector -> Maybe BallType'\ntoBallType ConnectorEmpty    = Nothing\ntoBallType (ConnectorOpen i) = Just i\n\nnewtype PuzzleRow = PuzzleRow {\n  unPuzzleRow :: Vector PuzzleConnector -- ^ The row of outputs for a puzzle\n} deriving (Eq, Ord, Show)\n\nnewtype OutputColumn = OutputColumn {\n  unOutputColumn :: PuzzleConnectors (Set BallType')\n} deriving (Eq, Ord, Show)\n\npathsTo :: IOGenM StdGen -> [(X, BallType')] -> [(X, BallType')] -> IO (Map BallType' [(X, Int)])\npathsTo g from' to' = do\n  fmap (Map.fromListWith (<>)) $ flip mapM tys $ \\ty -> do\n    let xByType l = fmap fst $ List.filter ((== ty) . snd) l\n    (from, to) <- bimap List.sort List.sort <$> matchLists g (\"\\n\" <> show tys <> \"\\n\" <> show from' <> \"\\n\" <> show to') 0 (xByType from') (xByType to')\n    pure $ (ty, zipWith go from to)\n  where\n    go x1 x2 = (x1, x2 - x1)\n    tys = Set.toList $ Set.fromList $ (snd <$> from') <> (snd <$> to')\n\nmatchLists :: Show a => IOGenM StdGen -> String -> Int -> [a] -> [a] -> IO ([a], [a])\nmatchLists g deb n xs ys\n  | length xs == 0 || length ys == 0 = error $ \"Empty lists \" <> deb <> show n <> \" - \" <> show (xs, ys)\n  | length xs == length ys = pure (xs, ys)\n  | length xs > length ys = do\n    ys' <- (:ys) . (ys !!) <$> randomRM (0, length ys - 1) g\n    matchLists g deb (n + 1) xs ys'\n  | length xs < length ys = do\n    xs' <- (:xs) . (xs !!) <$> randomRM (0, length xs - 1) g\n    matchLists g deb (n + 1) xs' ys\n\n  | otherwise = pure (xs, ys)\n\nrouteConnectors :: IOGenM StdGen\n                -> Set Double\n                -> Array (X, Y) (PuzzleConnectors (Vector Int))\n                -> IO (Array (X, Y) (PuzzleConnectors (Map Double Int)))\nrouteConnectors g locations placed' = do\n  placed <- MArray.thaw placed'\n\n  routed :: IOArray (X, Y) (PuzzleConnectors (Map Double Int)) <- MArray.newGenArray bounds $ const $ pure emptyConnectors\n  -- TODO add crossing complexities\n  forM_ (reverse [0..yMax]) $ \\y -> do\n    forM_ ([0..xMax]) $ \\x -> do\n      let loc = (x,y)\n      p <- MArray.readArray placed loc\n      let inputs = directed $ puzzleInputs p\n          outputs = directed $ puzzleOutputs p\n      MArray.modifyArray placed loc $ modifyInputs (const $ DirectedConnectors mempty mempty mempty mempty) . modifyOutputs (const $ DirectedConnectors mempty mempty mempty mempty)\n      forM_ directions $ \\dir -> do\n        (leftoverLocations, is) <- pickN' g locations $ Vector.toList $ Map.findWithDefault Vector.empty dir inputs\n        MArray.modifyArray routed loc $ modifyInputs (modifyDirection dir ((Map.union $ Map.fromList is)))\n        (_, os) <- pickN' g leftoverLocations $ Vector.toList $ Map.findWithDefault Vector.empty dir outputs\n        MArray.modifyArray routed loc $ modifyOutputs (modifyDirection dir ((Map.union $ Map.fromList os)))\n        getRelativeM placed loc dir >>= \\case\n          Nothing -> pure () -- no neighbor\n          Just (nLoc, _) -> do\n            MArray.modifyArray routed nLoc $ modifyInputs (modifyDirection (switchDirection dir) ((Map.union $ Map.fromList os)))\n            MArray.modifyArray placed nLoc $ modifyInputs (modifyDirection (switchDirection dir) $ const mempty)\n            MArray.modifyArray routed nLoc $ modifyOutputs (modifyDirection (switchDirection dir) ((Map.union $ Map.fromList is)))\n            MArray.modifyArray placed nLoc $ modifyOutputs (modifyDirection (switchDirection dir) $ const mempty)\n        pure (is, os)\n  MArray.freeze routed\n  where\n    bounds@(_, (xMax, yMax)) = Array.bounds placed'\n    emptyConnectors = PuzzleConnectors emptyDirected emptyDirected\n    emptyDirected = DirectedConnectors mempty mempty mempty mempty\n\n-- | Pick n elements from a set\npickN' :: (Ord a, Show a, Show i) => IOGenM StdGen -> Set a -> [i] -> IO (Set a, [(a, i)])\npickN' _ s [] = pure (s, mempty)\npickN' g s (x:xs)\n  | Set.null s = pure (mempty, [])\n  | otherwise = do\n    i <- randomRM (0, Set.size s - 1) g\n    let selected = Set.elemAt i s\n    (s', rest) <- pickN' g (Set.deleteAt i s) xs\n    pure $ (s', (selected,x):rest)\n\n-- | Pick n elements from a set\npickN :: Ord a => IOGenM StdGen -> Set a -> Int-> IO (Set a)\npickN g s n\n  | n <= 0 = pure mempty\n  | Set.null s = pure mempty\n  | otherwise = do\n    i <- randomRM (0, Set.size s - 1) g\n    let selected = Set.elemAt i s\n    rest <- pickN g (Set.deleteAt i s) (n - 1)\n    pure $ Set.insert selected rest\n\n-- | Pick n elements from a set\npickOne :: IOGenM StdGen -> [i] -> IO (Maybe i)\npickOne g xs\n  | List.null xs = pure Nothing\n  | otherwise = do\n    i <- randomRM (0, length xs - 1) g\n    pure $ Just $ xs List.!! i\n\npickOne' :: IOGenM StdGen -> Map a b -> IO (Maybe (Map a b, (a, b)))\npickOne' g m\n  | Map.null m = pure Nothing\n  | otherwise = do\n    i <- randomRM (0, Map.size m - 1) g\n    let (k, v) = Map.elemAt i m\n    pure $ Just (Map.deleteAt i m, (k, v))\n\nrouteBalls :: IOGenM StdGen\n           -> Array (X, Y) (Vector BallType')\n           -> IO (Array (X, Y) (PuzzleConnectors (Vector Int)))\nrouteBalls g grid = do\n  routed :: IOArray (X, Y) (PuzzleConnectors (Vector Int)) <- MArray.newGenArray bounds $ const $ pure emptyConnectors\n  forM_ (reverse [1..yMax]) $ \\y -> do\n    let\n      local = concatMap (\\(x, tys) -> (x,) <$> tys) $ flip fmap [0..xMax] $ \\x -> (x, Vector.toList $ grid Array.! (x,y))\n      above = concatMap (\\(x, tys) -> (x,) <$> tys) $ flip fmap [0..xMax] $ \\x -> (x, Vector.toList $ grid Array.! (x,y-1))\n    paths <- pathsTo g local above\n    forM_ (Map.toList paths) $ \\(ty, ps) -> do\n      forM_ ps $ \\(start, diff) -> do\n        writePath routed y ty (start, diff)\n      pure ()\n    pure ()\n  forM_ [0..xMax] $ \\x -> do\n    MArray.modifyArray routed (x,yMax) $ modifyOutputs (modifyDirection DDown ((unBallType <$> grid Array.! (x,yMax)) <> ))\n    MArray.modifyArray routed (x,0) $ modifyInputs (modifyDirection DUp ((unBallType <$> grid Array.! (x,0)) <> ))\n  MArray.freeze routed\n  where\n    bounds@(_, (xMax, yMax)) = Array.bounds grid\n    emptyConnectors = PuzzleConnectors emptyDirected emptyDirected\n    emptyDirected = DirectedConnectors mempty mempty mempty mempty\n    writePath :: IOArray (X, Y) (PuzzleConnectors (Vector Int))\n                  -> Y -> BallType' -> (X, Int) -> IO ()\n    writePath routed y ty (start, diff)\n      | diff < 0 = do\n          -- Target is on the left\n          MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DLeft (Vector.singleton (unBallType ty) <> ))\n          MArray.modifyArray routed (start - 1, y) $  modifyOutputs (modifyDirection DRight (Vector.singleton (unBallType ty) <> ))\n          writePath routed y ty (start - 1, diff + 1)\n      | diff > 0 = do\n          -- Target is on the right\n          MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DRight (Vector.singleton (unBallType ty) <> ))\n          MArray.modifyArray routed (start + 1, y) $ modifyOutputs (modifyDirection DLeft (Vector.singleton (unBallType ty) <> ))\n          writePath routed y ty (start + 1, diff - 1)\n      | otherwise = do\n        MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DUp (Vector.singleton (unBallType ty) <> ))\n        MArray.modifyArray routed (start, y - 1) $ modifyOutputs (modifyDirection DDown (Vector.singleton (unBallType ty) <> ))\n\nfromOutputRows :: PuzzleConfig -> [PuzzleRow] -> IO (Array (X, Y) (Vector BallType'))\nfromOutputRows cfg rows' = do\n  connections :: IOArray (X, Y) (Vector BallType') <- MArray.newGenArray ((0,0), (Vector.length firstRow - 1, Vector.length cells - 1)) $ const $ pure Vector.empty\n  Vector.forM_ (Vector.indexed cells) $ \\(y, row) -> do\n    Vector.forM_ (Vector.indexed row) $ \\(x, cell) -> do\n      MArray.writeArray connections (x, y) cell\n  MArray.freeze connections\n  where\n    rows = Vector.fromList rows'\n    firstRow = cells Vector.! 0\n    cells = fmap (toCell cfg) rows\n\ntoCell :: PuzzleConfig -> PuzzleRow -> Vector (Vector BallType')\ntoCell cfg row = os\n  where\n    os :: Vector (Vector BallType')\n    os = Vector.fromList $ fmap (Vector.mapMaybe toBallType) $ chunked $ unPuzzleRow row\n    chunked xs\n      | Vector.null xs = []\n      | otherwise =\n          let (a, b) = Vector.splitAt chunkSize xs\n          in a : chunked b\n    chunkSize = Set.size $ puzzleLocations cfg\n\n-- connectColumns :: PuzzleConfig -> OutputColumn -> OutputColumn ->\n\ngetRelative' :: Array (X, Y) a -> (X, Y) -> Direction -> Maybe ((X, Y), a)\ngetRelative' g (x0, y0) r\n  | Ix.inRange bounds rp = Just (rp,g Array.! rp)\n  | otherwise = Nothing\n  where\n    rp = bimap (x0 +) (y0 +) $ relMap r\n    bounds = Array.bounds g\n\nrelMap :: Direction -> (X, Y)\nrelMap DUp    = ( 0, -1)\nrelMap DLeft  = (-1,  0)\nrelMap DRight = ( 1,  0)\nrelMap DDown  = ( 0,  1)\n\nrowIndex :: PuzzleRow -> Int -> PuzzleConnector\nrowIndex p = (unPuzzleRow p Vector.!)\n\nrowLength :: PuzzleRow -> Int\nrowLength = Vector.length . unPuzzleRow\n\nmodifyPuzzleRow :: PuzzleRow -> (forall s. MVector s PuzzleConnector -> ST s ()) -> PuzzleRow\nmodifyPuzzleRow row f =\n  PuzzleRow $ Vector.create $ do\n    v <- Vector.thaw (unPuzzleRow row)\n    _ <- f v\n    pure v\n\nupdateRow :: PuzzleRow -> [(Int,PuzzleConnector )] -> PuzzleRow\nupdateRow row updates = PuzzleRow $ unPuzzleRow row Vector.// updates\n\nfindBalls :: PuzzleRow -> BallType' -> Vector Int\nfindBalls row b = Vector.findIndices (== ConnectorOpen b) (unPuzzleRow row)\n\nfindEmpty :: PuzzleRow -> Vector Int\nfindEmpty row = Vector.findIndices (== ConnectorEmpty) (unPuzzleRow row)\n\nrowBalls :: PuzzleRow -> Vector BallType'\nrowBalls = Vector.mapMaybe toBallType . unPuzzleRow\n\nrowBallsIndexed :: PuzzleRow -> Vector (Int, BallType')\nrowBallsIndexed = Vector.mapMaybe (\\(i, r) -> (i, ) <$> toBallType r) . Vector.indexed . unPuzzleRow\n\nballCounts :: PuzzleRow -> Map BallType' Int\nballCounts row = Map.fromListWith (+) $ fmap (,1) $ Vector.toList $ rowBalls row\n\naddBallType :: IOGenM StdGen -> PuzzleConfig -> PuzzleRow -> IO PuzzleRow\naddBallType g cfg row = do\n  ball <- randomBallType g cfg\n  sampleOne g emptySlots >>= \\case\n    Nothing -> pure row\n    Just i -> pure $ updateRow row [(i, ConnectorOpen ball)]\n  where\n    emptySlots = findEmpty row\n\n  -- TODO this could do a weighted sample\nremoveRandomBall :: IOGenM StdGen -> PuzzleConfig -> PuzzleRow -> IO PuzzleRow\nremoveRandomBall g cfg row =\n  pickOne g extras >>= \\case\n    Nothing -> pure row\n    (Just bt) -> do\n      sampleOne g (findBalls row bt) >>= \\case\n        Nothing -> pure row\n        Just i -> pure $ updateRow row [(i, ConnectorEmpty)]\n  where\n    r = Vector.toList $ Vector.mapMaybe toBallType $ unPuzzleRow row\n    extras =  r List.\\\\ (BallType' <$> minBallConfiguration cfg)\n\nsampleOne :: IOGenM StdGen -> Vector a -> IO (Maybe a)\nsampleOne g v = do\n  i <- randomRM (0, Vector.length v - 1) g\n  pure $ v Vector.!? i\n\ntowardsBallCount :: IOGenM StdGen -> PuzzleConfig -> Int -> PuzzleRow -> IO PuzzleRow\ntowardsBallCount g cfg ballCount row\n  | Vector.length balls < ballCount = do\n    putStrLn \"Adding ball\"\n    addBallType g cfg row\n  | Vector.length balls > ballCount = do\n    putStrLn \"Removing ball\"\n    removeRandomBall g cfg row\n  | otherwise = pure row\n  where\n    balls = rowBalls row\n\nspaceOutputs :: PuzzleConfig -> PuzzleRow -> IO PuzzleRow\nspaceOutputs cfg row = do\n  -- (0,10,50,5,10)\n  print (outsideSpace, ballSpace, rowLen, locationCount, ballCount)\n\n  pure $ PuzzleRow $ Vector.create $ do\n    v <- MVector.generate rowLen $ const ConnectorEmpty\n    forM_ [0 .. ballCount - 1] $ \\i -> do\n      MVector.write v ((locationCount`div` 2) + i * ballSpace) $ ConnectorOpen $ balls Vector.! i\n    pure v\n  where\n    outsideSpace = (rowLen `mod` locationCount) `div` 2\n    ballSpace = rowLen `div` ballCount\n    locationCount = Set.size $ puzzleLocations cfg\n    rowLen = rowLength row\n    ballCount = Vector.length balls\n    balls = Vector.mapMaybe toBallType $ unPuzzleRow row\n\ndata SwapConfig = SwapConfig {\n    swapRadius :: (Int, Int)\n  , ballSwaps  :: (Int, Int)\n} deriving (Eq, Ord, Show)\n\nswapBalls :: IOGenM StdGen -> SwapConfig -> PuzzleRow -> IO PuzzleRow\nswapBalls g swaps row = do\n  swapCount <- randomRM (ballSwaps swaps) g\n  toSwap <- pickN g (Set.fromList [0.. len -1]) swapCount\n  shuffles <- forM (Set.toList toSwap) $ \\start -> do\n    radius <- randomRM (swapRadius swaps) g\n    r <- randomRM (-radius, radius) g\n    let i = min (len - 1) $ max 0 $ start + r\n    pure $ (start,i)\n  pure $ modifyPuzzleRow row $ \\v -> do\n    forM_ shuffles $ \\(i, j) -> do\n      let\n        (iB, _) = balls Vector.! i\n        (jB, _) = balls Vector.! j\n      MVector.swap v iB jB\n  where\n    len = Vector.length balls\n    balls = rowBallsIndexed row\n\nrenderRows :: [PuzzleRow] -> String\nrenderRows = unlines . fmap (List.intersperse ' ' . Vector.toList . Vector.map renderConnector . unPuzzleRow)\n\nrenderConnector :: PuzzleConnector -> Char\nrenderConnector ConnectorEmpty                = ' '\nrenderConnector (ConnectorOpen (BallType' i)) = head $ show i\n\napplyAll :: forall a. a -> [a -> IO a] -> IO [a]\napplyAll a fs =\n  fmap (uncurry (:)) $ Foldable.foldlM (\\(x,xs) f -> f x >>= \\x' -> pure (x',x:xs)) (a, []) fs\n\n-- swapBalls ::\n\n-- sortTo :: PuzzleRow -> PuzzleRow -> IO PuzzleRow\n-- sortTo target row = do\n--   let targetBalls = rowBalls target\n--       rowBalls' = rowBalls row\n--       targetBalls' = Vector.filter (`notElem` rowBalls') targetBalls\n--   pure $ foldl' (\\r b -> updateRow r [(i, ConnectorOpen b)]) row $ Vector.toList targetBalls'\n--   where\n--     i = Vector.length (unPuzzleRow row) - 1\n\n-- max swaps is the number of locations\n\ndata Direction = DUp | DDown | DLeft | DRight\n  deriving (Eq, Ord, Show)\n\ndirections :: [Direction]\ndirections = [DUp, DDown, DLeft, DRight]\n\ndirToRel :: Direction -> RelativeCell\ndirToRel DUp    = RCUp\ndirToRel DDown  = RCDown\ndirToRel DLeft  = RCLeft\ndirToRel DRight = RCRight\n\nonEdge :: Direction -> Double -> Position\nonEdge DUp x    = (x, 0)\nonEdge DDown x  = (x, 1)\nonEdge DLeft y  = (0, y)\nonEdge DRight y = (1, y)\n\nswitchDirection :: Direction -> Direction\nswitchDirection DUp    = DDown\nswitchDirection DDown  = DUp\nswitchDirection DLeft  = DRight\nswitchDirection DRight = DLeft\n\n-- | Connectors for a puzzle for each edge of the puzzle\ndata DirectedConnectors i = DirectedConnectors {\n  dcUp    :: i\n, dcDown  :: i\n, dcLeft  :: i\n, dcRight :: i\n} deriving (Eq, Ord, Show)\n\ninstance Functor DirectedConnectors where\n  fmap f (DirectedConnectors u d l r) = DirectedConnectors (f u) (f d) (f l) (f r)\n\n-- | Get the connectors in a direction\ninDirection :: Direction -> DirectedConnectors i -> i\ninDirection DUp    = dcUp\ninDirection DDown  = dcDown\ninDirection DLeft  = dcLeft\ninDirection DRight = dcRight\n\n-- | Modify the connectors in a direction\nmodifyDirection :: Direction -> (i -> i) -> DirectedConnectors i -> DirectedConnectors i\nmodifyDirection DUp f (DirectedConnectors u d l r) = DirectedConnectors (f u) d l r\nmodifyDirection DDown f (DirectedConnectors u d l r) = DirectedConnectors u (f d) l r\nmodifyDirection DLeft f (DirectedConnectors u d l r) = DirectedConnectors u d (f l) r\nmodifyDirection DRight f (DirectedConnectors u d l r) = DirectedConnectors u d l (f r)\n\n-- | Turn the connectors into a map of directions to connectors\ndirected :: DirectedConnectors i -> Map Direction i\ndirected d = Map.fromList $ (id &&& flip inDirection d) <$> directions\n\n-- | Sum of connectors in all directions\nconnectorCount :: Num i => DirectedConnectors i -> i\nconnectorCount (DirectedConnectors u d l r) = u + d + l + r\n\n-- | The inputs and outputs of a puzzle\ndata PuzzleConnectors i = PuzzleConnectors {\n  puzzleInputs  :: DirectedConnectors i\n, puzzleOutputs :: DirectedConnectors i\n} deriving (Eq, Ord, Show)\n\ninstance Functor PuzzleConnectors where\n  fmap f (PuzzleConnectors i o) = PuzzleConnectors (fmap f i) (fmap f o)\n\nusedSides :: PuzzleConnectors Int -> Int\nusedSides (PuzzleConnectors i o) = Map.size $ Map.filter (> 0) $ Map.unionWith (+) (directed i) (directed o)\n\n-- | Modify the input connectors of a puzzle\nmodifyInputs :: (DirectedConnectors i -> DirectedConnectors i) -> PuzzleConnectors i -> PuzzleConnectors i\nmodifyInputs f (PuzzleConnectors i o) = PuzzleConnectors (f i) o\n\n-- | Modify the output connectors of a puzzle\nmodifyOutputs :: (DirectedConnectors i -> DirectedConnectors i) -> PuzzleConnectors i -> PuzzleConnectors i\nmodifyOutputs f (PuzzleConnectors i o) = PuzzleConnectors i (f o)\n\ngenPuzzleMachine :: [BallTypes] -> X -> Y -> MetaMachine Puzzle\ngenPuzzleMachine btys xdim ydim = do\n    let\n      (ballPit, _, _) =\n        let\n          l = length btys\n          (q, r) = l `quotRem` xdim\n        in foldl'\n           (\\(m, a, btys') i ->\n              let (h, t)\n                    | a == 0 = splitAt q btys'\n                    | otherwise = splitAt (q+1) btys'\n              in (Map.insert i h m, if a == 0 then 0 else a-1, t))\n           (Map.empty, r, btys)\n           [0..xbound]\n\n      arr = Array.array ((0,0), (xbound, ybound))\n        [( (x,y)\n         , let\n             -- invariant: if an entry exists in the ballpit, then bls is at least 1.\n             -- If not, this is an error in the quotient code (qMap).\n             xbls = case Map.lookup x ballPit of\n               Nothing -> error \"genPuzzleMachine: Ball entry empty\"\n               Just bs -> length bs\n\n             -- invariant: normalize the ball position before inserting\n             -- into puzzles so that the relative position is biased towards\n             -- the start of the cell (not the ball)\n             ds = fromList $\n               [ (fromIntegral i + 0.5) / fromIntegral xbls\n               | i <- [0..xbls - 1]\n               ]\n\n             -- invariant: fill in the first row with the simplest machine:\n             -- a straight down path req.\n             reqTiles = fromList [RCUp | y /= 0]\n           in Puzzle reqTiles (fmap (\\d -> Gateway (d,0) (fromList [GatewayBall 1 1]) ) ds) (fmap (\\d -> Gateway (d,1) (fromList [GatewayBall 1 1])) ds) mempty\n         )\n\n        | x <- [0..xbound]\n        , y <- [0..ybound]\n        ]\n\n    MetaMachine arr (TileSize 700 700) 0.001 mempty\n  where\n    xbound :: X\n    xbound = xdim - 1\n\n    ybound :: Y\n    ybound = ydim - 1\n"
  },
  {
    "path": "test/Main.hs",
    "content": "{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE OverloadedLists #-}\n{-# LANGUAGE MultiParamTypeClasses #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE RankNTypes #-}\n{-# LANGUAGE ScopedTypeVariables #-}\nmodule Main (main) where\n\nimport           Control.Concurrent.Async\nimport           Control.Monad\nimport qualified Control.Monad.Catch as E\nimport           Control.Monad.Extra (whileM)\nimport           Control.Monad.IO.Class\nimport           Control.Monad.Reader\nimport qualified Data.Aeson as JS\nimport qualified Data.Array as Array\nimport           Data.Cache.LRU.IO (AtomicLRU)\nimport qualified Data.Cache.LRU.IO as LRU\nimport           Data.Foldable\nimport           Data.HashMap.Strict (HashMap)\nimport qualified Data.HashMap.Strict as HashMap\nimport           Data.List (nub, sort, isSubsequenceOf)\nimport           Data.Time\nimport           Data.Time.Calendar.OrdinalDate\nimport           Data.UUID\nimport qualified Data.UUID.Types.Internal as UUID\nimport qualified Database.Redis as Redis\nimport           GHC.IsList\nimport           Hedgehog\nimport qualified Hedgehog.Gen as Gen\nimport qualified Hedgehog.Range as Range\nimport           Incredible.Config\nimport           Incredible.Data\nimport           Incredible.DataStore\nimport qualified Incredible.DataStore.Memory as MemStore\nimport qualified Incredible.DataStore.Redis as IRedis\nimport           Incredible.Puzzle\nimport           Test.Tasty\nimport qualified Test.Tasty.Hedgehog as H\nimport           System.Directory\nimport           System.IO (hGetLine)\nimport           System.IO.Temp (emptySystemTempFile)\nimport qualified System.Process as Process\n\nmain :: IO ()\nmain = defaultMain tests\n\ntests :: TestTree\ntests = testGroup \"Incredible\"\n  [ toFromJSONTests\n  , genPuzzleTests\n  , checkReady\n  , checkRemanufacture\n  , deltaMachineTest\n  , checkDataStore \"Memory Store\" (\\mp act -> act =<< fst <$> MemStore.initIncredibleState () mp)\n  , checkDataStore \"Redis Store\" testingRedis\n  ]\n\ntoJSObject :: JS.ToJSON a => a -> JS.Object\ntoJSObject a = case JS.toJSON a of\n  JS.Object o -> o\n  _ -> error \"Wasn't actually a JSON Object.\"\n\ngenPosition :: MonadGen m => m Position\ngenPosition = (,) <$> Gen.realFloat (Range.constant 0 1) <*> Gen.realFloat (Range.constant 0 1)\n\ngenGateway :: MonadGen m => m Gateway\ngenGateway = Gateway <$> genPosition <*> (fromList <$> Gen.list (Range.constant 0 5) (GatewayBall <$> (Gen.integral (Range.constant 1 4)) <*> Gen.realFloat (Range.constant 0 1)))\n\ngenPuzzle :: (MonadGen m, JS.ToJSON a) => a -> m Puzzle\ngenPuzzle p =\n  Puzzle\n    <$> (fromList <$> Gen.list (Range.constant 0 5) Gen.enumBounded)\n    <*> (fromList <$> Gen.list (Range.constant 0 5) genGateway)\n    <*> (fromList <$> Gen.list (Range.constant 0 5) genGateway)\n    <*> pure (toJSObject p)\n\ngenUUID :: MonadGen m => m UUID\ngenUUID = do\n  b0 <- Gen.word8 Range.constantBounded\n  b1 <- Gen.word8 Range.constantBounded\n  b2 <- Gen.word8 Range.constantBounded\n  b3 <- Gen.word8 Range.constantBounded\n  b4 <- Gen.word8 Range.constantBounded\n  b5 <- Gen.word8 Range.constantBounded\n  b6 <- Gen.word8 Range.constantBounded\n  b7 <- Gen.word8 Range.constantBounded\n  b8 <- Gen.word8 Range.constantBounded\n  b9 <- Gen.word8 Range.constantBounded\n  ba <- Gen.word8 Range.constantBounded\n  bb <- Gen.word8 Range.constantBounded\n  bc <- Gen.word8 Range.constantBounded\n  bd <- Gen.word8 Range.constantBounded\n  be <- Gen.word8 Range.constantBounded\n  bf <- Gen.word8 Range.constantBounded\n  pure $ UUID.buildFromBytes 4 b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 ba bb bc bd be bf\n\ngenTime :: MonadGen m => m UTCTime\ngenTime =\n  UTCTime\n    <$> (fromOrdinalDate <$> Gen.integral (Range.linearFrom 2024 (-100) 3000)\n    <*> Gen.int (Range.constant 1 366))\n    <*> Gen.realFrac_ (Range.constant 0 86401)\n\ngenBlueprint :: (MonadGen m, JS.ToJSON a) => HashMap Int a -> m Blueprint\ngenBlueprint s =\n  Blueprint\n    <$> genUUID\n    <*> Gen.text (Range.constant 0 50) Gen.unicode\n    <*> Gen.choice [Just <$> genTime, pure Nothing]\n    <*> pure (fmap toJSObject s)\n\ngenMetaMachine :: (MonadGen m, JS.ToJSON tile) => (MonadGen m => (X, Y) -> m tile) -> m (MetaMachine tile)\ngenMetaMachine tileGen = do\n  xsize <- Gen.integral (Range.exponential 1 100)\n  ysize <- Gen.integral (Range.exponential 1 100)\n  genMetaMachineSized xsize ysize tileGen\n\ngenMetaMachineSized :: (MonadGen m, JS.ToJSON tile) => X -> Y -> (MonadGen m => (X, Y) -> m tile) -> m (MetaMachine tile)\ngenMetaMachineSized xsize ysize tileGen = do\n  ts <- TileSize <$> Gen.int (Range.constant 10 1000) <*> Gen.int (Range.constant 10 1000)\n  r <- Gen.realFrac_ (Range.constant 0 1)\n  pp <- (fromList <$> Gen.list (Range.constant 0 5) genUUID)\n  translateMachine (\\xy _ -> tileGen xy) $ MetaMachine (Array.listArray ((0,0), (xsize-1, ysize-1)) [()..]) ts r pp\n\ntoFromJSONTests :: TestTree\ntoFromJSONTests = testGroup \"toFromJSON\"\n  [ toFromRelativeCellTest\n  , toFromPuzzleTest\n  , toFromBlueprint\n  , toFromMetaMachinePuzzleID\n  , toFromGameState\n  ]\n  where\n    checkJSON :: (JS.ToJSON a, JS.FromJSON a, Eq a, Show a, MonadTest m) => a -> m ()\n    checkJSON v = do\n      JS.Success v === JS.fromJSON (JS.toJSON v)\n      Just v === JS.decode (JS.encode v)\n    toFromRelativeCellTest = H.testProperty \"RelativeCell To/From Inverse\" $ property $ do\n      rc::RelativeCell <- forAll Gen.enumBounded\n      checkJSON rc\n    toFromPuzzleTest = H.testProperty \"Puzzle To/From Inverse\" $ property $ do\n      unitPzl <- forAll $ genPuzzle $ JS.object []\n      strPzl  <- forAll $ genPuzzle $ JS.object [\"str\" JS..= (\"test string\"::String)]\n      arrPzl  <- forAll $ genPuzzle $ JS.object [\"one\" JS..= (1::Int), \"two\" JS..= (2::Int)]\n      checkJSON unitPzl\n      checkJSON strPzl\n      checkJSON arrPzl\n    toFromBlueprint = H.testProperty \"Blueprint To/From Inverse\" $ property $ do\n      unitBp <- forAll $ genBlueprint $ (mempty::HashMap Int JS.Object)\n      strBp  <- forAll $ genBlueprint $ HashMap.singleton 1 $ JS.object [\"str\" JS..= (\"test string\"::String)]\n      arrBp  <- forAll $ genBlueprint $ HashMap.singleton 1 $ JS.object [\"one\" JS..= (1::Int), \"two\" JS..= (2::Int)]\n      checkJSON unitBp\n      checkJSON strBp\n      checkJSON arrBp\n    toFromMetaMachinePuzzleID = H.testProperty \"MetaMachine PuzzleID To/From Inverse\" $ property $ do\n      mm::MetaMachine PuzzleID <- forAll $ genMetaMachine (\\_ -> fmap puzzleID $ genPuzzle $ JS.object [])\n      checkJSON mm\n    toFromGameState = H.testProperty \"GameState PuzzleID To/From Inverse\" $ property $ do\n      mm::GameState <- forAll $ genMetaMachine (\\_ -> Gen.choice [ fmap (Left . puzzleID)     $ genPuzzle    $ JS.object []\n                                                                 , fmap (Right . blueprintID) $ genBlueprint $ HashMap.singleton 1 $ JS.object []\n                                                                 ])\n      checkJSON mm\n\nbaseMeta :: Array.Array (Int, Int) a -> MetaMachine a\nbaseMeta a = MetaMachine a (TileSize 700 700) 0.001 mempty\n\ngenPuzzleTests :: TestTree\ngenPuzzleTests = H.testProperty \"Puzzle Generator\" $ property $ test $ do\n      let m0 = genPuzzleMachine [BallType '0'] 1 1\n      m0 === baseMeta (Array.array ((0,0), (0,0)) [((0, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)])\n      let m1 = genPuzzleMachine [BallType '0', BallType '1'] 1 1\n      m1 === baseMeta (Array.array ((0,0), (0,0)) [((0, 0), Puzzle [] [Gateway  (0.25, 0) [GatewayBall 1 1], Gateway (0.75, 0) [GatewayBall 1 1]] [Gateway (0.25, 1) [GatewayBall 1 1], Gateway  (0.75, 1) [GatewayBall 1 1]] mempty)])\n      let m2 = genPuzzleMachine [BallType '0', BallType '1'] 2 1\n      m2 === baseMeta (Array.array ((0,0), (1,0)) [ ((0, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway  (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       ])\n      let m3 = genPuzzleMachine [BallType '0', BallType '1'] 2 1\n      m3 === baseMeta (Array.array ((0,0), (1,0)) [ ((0, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       ])\n      let m4 = genPuzzleMachine [BallType '0', BallType '1'] 2 2\n      m4 === baseMeta (Array.array ((0,0), (1,1)) [ ((0, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((0, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((1, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       ])\n      let m5 = genPuzzleMachine [BallType '0', BallType '1', BallType '2'] 2 1\n      m5 === baseMeta (Array.array ((0,0), (1,0)) [ ((0, 0), Puzzle [] [Gateway (0.25, 0) [GatewayBall 1 1], Gateway (0.75,0) [GatewayBall 1 1]] [Gateway  (0.25, 1) [GatewayBall 1 1], Gateway (0.75, 1) [GatewayBall 1 1]] mempty)\n                                                       , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty)\n                                                       ])\n\n      let m6 = genPuzzleMachine [BallType '0', BallType '1'] 1 2\n      m6 === baseMeta (Array.array ((0,0), (0,1)) [ ((0, 0), Puzzle [] [Gateway (0.25,0) [GatewayBall 1 1], Gateway (0.75,0) [GatewayBall 1 1]] [Gateway (0.25,1) [GatewayBall 1 1], Gateway (0.75,1) [GatewayBall 1 1]] mempty)\n                                                       , ((0, 1), Puzzle [RCUp] [Gateway (0.25,0) [GatewayBall 1 1], Gateway (0.75,0) [GatewayBall 1 1]] [Gateway (0.25,1) [GatewayBall 1 1], Gateway (0.75,1) [GatewayBall 1 1]] mempty)\n                                                       ])\ncheckReady :: TestTree\ncheckReady = H.testProperty \"Check Ready Puzzles\" $ property $ test $ do\n  let pzl0 = Puzzle [] [Gateway  (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty\n  let pzl1 = Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty\n  let pzlMap = HashMap.fromList [(puzzleID pzl0, pzl0), (puzzleID pzl1, pzl1)]\n  let m0 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0)\n                                                    , ((1, 0), Left $ puzzleID pzl0)\n                                                    , ((0, 1), Left $ puzzleID pzl1)\n                                                    , ((1, 1), Left $ puzzleID pzl1)\n                                                    ]\n  [pWithID pzl0, pWithID  pzl0] === findReadyPuzzles pzlMap m0\n  let bp0 = Blueprint (puzzleID pzl0) \"\" Nothing mempty\n  let m1 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0)\n                                                 , ((1, 0), Right $ blueprintID bp0)\n                                                 , ((0, 1), Left $ puzzleID pzl1)\n                                                 , ((1, 1), Left $ puzzleID pzl1)\n                                                 ]\n  [pWithID pzl0, pWithID pzl1] === findReadyPuzzles pzlMap m1\n  where\n    pWithID pzl = (puzzleID pzl, pzl)\n\ncheckRemanufacture :: TestTree\ncheckRemanufacture = H.testProperty \"remanufacture\" $ property $ test $ do\n  let pzl0 = Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty\n  let pzl1 = Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty\n  let m0 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), pzl0)\n                                                    , ((1, 0), pzl0)\n                                                    , ((0, 1), pzl1)\n                                                    , ((1, 1), pzl1)\n                                                    ]\n  fmap (Left . puzzleID) m0 === remanufacture m0 (fmap (Left . puzzleID) m0)\n  let m1 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), pzl0)\n                                                    , ((1, 0), pzl0)\n                                                    , ((0, 1), pzl1)\n                                                    , ((1, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (1, 1) [GatewayBall 1 1]] mempty)\n                                                    ]\n  fmap (Left . puzzleID) m1 === remanufacture m1 (fmap (Left . puzzleID) m0)\n  let m2 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0)\n                                                    , ((1, 0), Left $ puzzleID pzl0)\n                                                    , ((0, 1), Left $ puzzleID pzl1)\n                                                    , ((1, 1), Right $ Blueprint (puzzleID pzl1) \"\" Nothing mempty)\n                                                    ]\n  fmap (Left . puzzleID) m1 === remanufacture m1 m2\n  let bp1 = Blueprint (puzzleID pzl1) \"\" Nothing mempty\n  let m3 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0)\n                                                    , ((1, 0), Left $ puzzleID pzl0)\n                                                    , ((0, 1), Right bp1)\n                                                    , ((1, 1), Right $ Blueprint (puzzleID pzl1) \"\" Nothing mempty)\n                                                    ]\n  baseMeta (fmap (Left . puzzleID) (mmGrid m1) Array.// [((0,1), Right $ blueprintID bp1)]) === remanufacture m1 m3\n  let m4 = baseMeta $ Array.array ((0,0), (2,1)) [ ((0, 0), pzl0)\n                                                 , ((1, 0), pzl0)\n                                                 , ((2, 0), pzl0)\n                                                 , ((0, 1), pzl1)\n                                                 , ((1, 1), pzl1)\n                                                 , ((2, 1), pzl1)\n                                                 ]\n  (fmap (Left . puzzleID) m4) === remanufacture m4 (fmap (Left . puzzleID) m0)\n  let m5 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0)\n                                                 , ((1, 0), Left $ puzzleID pzl0)\n                                                 , ((0, 1), Right bp1)\n                                                 , ((1, 1), Left $ puzzleID pzl1)\n                                                 ]\n  let m5r' = fmap (Left . puzzleID) m4\n  let m5r = m5r' { mmGrid = (mmGrid m5r') Array.// [((0,1), Right $ blueprintID bp1)] }\n  m5r === remanufacture m4 m5\n\ndeltaMachineTest :: TestTree\ndeltaMachineTest = H.testProperty \"deltaMachine\" $ property $ do\n  -- xsize <- forAll $ Gen.integral (Range.exponential 1 100)\n  -- ysize <- forAll $ Gen.integral (Range.exponential 1 100)\n  ma <- forAll $ genMetaMachine $ const $ Gen.word8 Range.constantBounded\n  mb <- forAll $ genMetaMachine $ const $ Gen.word8 Range.constantBounded\n  let da2b = ma `deltaMachine` mb\n  let db2a = mb `deltaMachine` ma\n  mb === applyDelta ma da2b\n  ma === applyDelta mb db2a\n\ndata StoreReader s\n  = StoreReader\n    { srStore :: s\n    , srMC :: AtomicLRU MachineVersion GameState\n    , srBC :: AtomicLRU BlueprintID Blueprint\n    , srSC :: AtomicLRU BlueprintID Snapshot\n    }\n\ninstance IncredibleData s => HasDataStore (StoreReader s) s where\n  getStore = srStore\n  getMachineCache = srMC\n  getBlueprintCache = srBC\n  getSnapshotCache = srSC\n\ntestingRedis :: (MonadFail m', MonadIO m', E.MonadCatch m') => MetaMachine Puzzle -> (IRedis.IncredibleRedisStore -> m' a) -> m' a\ntestingRedis mp act = do\n  -- PropertyT is not MonadMask\n  unixSocketPath <- liftIO $ emptySystemTempFile \"redis.socket\"\n  liftIO $ removeFile unixSocketPath\n  let c = (Process.proc \"redis-server\"\n                        [ \"--unixsocket\", unixSocketPath\n                        , \"--port\", \"0\"\n                        , \"--bind\", \"127.0.0.1\"\n                        , \"--unixsocketperm\", \"700\"\n                        , \"--maxmemory-policy\", \"volatile-ttl\"\n                        , \"--maxmemory\", \"100mb\"\n                        , \"--maxclients\", \"100\"\n                        , \"--appendonly\", \"no\"\n                        , \"--save\", \"\"\n                        , \"--loglevel\", \"notice\"\n                        ])\n            { Process.std_in = Process.Inherit\n            , Process.std_out = Process.CreatePipe\n            , Process.std_err = Process.Inherit\n            , Process.close_fds = True\n            }\n  p@(_, Just hout, _, _) <- liftIO $ Process.createProcess c\n  (`E.onException` (liftIO $ Process.cleanupProcess p)) $ do\n    -- Wait for redis to say it is ready\n    whileM $ liftIO $ do\n        rol <- hGetLine hout\n        pure $ not $ \"Ready to accept connections unix\" `isSubsequenceOf` rol\n    -- Might need to keep consuming the stdout to avoid blocking.\n    let rc = Just $ IncredibleRedisConfig {\n                    incredibleRedisHostName = \"\"\n                  , incredibleRedisPort = Redis.UnixSocket unixSocketPath\n                  , incredibleRedisDatabase = 0\n                  , incredibleRedisMaxConnections = 100\n                  , incredibleRedisMaxIdleTimeout = 10\n                  , incredibleRedisPassword = Nothing\n                  , incredibleRedisRetryCount = 10\n                  , incredibleWorkOrderTTL = 1\n                  , incredibleOrderBookTTL = 1\n                  , incredibleRedisUseTLS = False\n                }\n    r' <- act =<< fst <$> IRedis.initIncredibleState (IncredibleConfig undefined undefined rc undefined) mp\n    liftIO $ Process.cleanupProcess p\n    pure r'\n\n-- The storer needs to be seperated to different datasets.\ncheckDataStore :: forall s . IncredibleData s => String -> (forall m' a . (MonadFail m', MonadIO m', E.MonadCatch m') => MetaMachine Puzzle -> (s -> m' a) -> m' a) -> TestTree\ncheckDataStore storeName storer = testGroup storeName\n  [ checkDBStart\n  , checkEdit\n  , checkConcurrent\n  , checkAddBlueprint\n  , checkMod\n  ]\n  where\n    mkStore :: (MonadFail m, MonadIO m, E.MonadCatch m) => MetaMachine Puzzle -> (StoreReader s -> m a) -> m a\n    mkStore mp act = storer mp $ \\s ->\n      (StoreReader s <$> liftIO (LRU.newAtomicLRU (Just 1))) <*> liftIO (LRU.newAtomicLRU (Just 1)) <*> liftIO (LRU.newAtomicLRU (Just 1)) >>= act\n    checkDBStart = H.testProperty \"DB Start\" $ property $ do\n      mm0 <- forAll $ genMetaMachine (const $ genPuzzle $ JS.object [])\n      mkStore mm0 $ \\sr -> do\n        sm0 <- runReaderT getCurrentMachine sr\n        VersionedMachine 0 (fmap (Left . puzzleID) mm0) === sm0\n        sm1 <- runReaderT (getMachine 1) sr\n        Nothing === sm1\n    checkEdit = H.testProperty \"Edit\" $ property $ do\n      xsize <- forAll $ Gen.integral (Range.linear 1 10)\n      ysize <- forAll $ Gen.integral (Range.linear 1 10)\n      ma <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object [\"value\" JS..= 'a']\n      mb <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object [\"value\" JS..= 'b']\n      assert $ ma /= mb\n      mkStore ma $ \\sr -> (`runReaderT` sr) $ do\n        me0 <- getCurrentMachine\n        VersionedMachine 0 (fmap (Left . puzzleID) ma) === me0\n        editCurrentMachine (const ((), fmap (Left . puzzleID) ma))\n        me1 <- getCurrentMachine\n        VersionedMachine 0 (fmap (Left . puzzleID) ma) === me1\n        editCurrentMachine (const ((), fmap (Left . puzzleID) mb))\n        me2 <- getCurrentMachine\n        VersionedMachine 1 (fmap (Left . puzzleID) mb) === me2\n        editCurrentMachine (const ((), fmap (Left . puzzleID) mb))\n        me3 <- getCurrentMachine\n        VersionedMachine 1 (fmap (Left . puzzleID) mb) === me3\n        editCurrentMachine (const ((), fmap (Left . puzzleID) ma))\n        me4 <- getCurrentMachine\n        VersionedMachine 2 (fmap (Left . puzzleID) ma) === me4\n    checkConcurrent = H.testProperty \"Concurrent\" $ property $ do\n      xsize <- forAll $ Gen.integral (Range.linear 1 10)\n      ysize <- forAll $ Gen.integral (Range.linear 1 10)\n      ma <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object [\"value\" JS..= (0::Int)]\n      let raceSize = (10::Int)\n      mbs <- mapM (\\mv -> forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object [\"value\" JS..= mv]) [1..raceSize]\n      assert $ length mbs == raceSize\n      assert $ notElem ma mbs\n      assert $ length (nub mbs) == raceSize\n      mkStore ma $ \\sr -> do\n        let machineList = fmap (fmap (Left . puzzleID)) $ ma:mbs\n        let updateMap::HashMap MachineVersion GameState = HashMap.fromList $ zip [0..] $ tail machineList\n        updates <- liftIO $ forM mbs $ \\_ -> async $ (`runReaderT` sr) $ editCurrentMachine $ \\gs -> ((), updateMap HashMap.! vmVersion gs)\n        mapM_ (liftIO . wait) updates\n        forM_ (zip [0..] machineList) $ \\(i, mm) -> do\n          mmi <- (`runReaderT` sr) $ getMachine i\n          Just mm === mmi\n        lm <- (`runReaderT` sr) getCurrentMachine\n        VersionedMachine (fromIntegral raceSize) (last machineList) === lm\n    checkAddBlueprint = H.testProperty \"Add Blueprint\" $ property $ do\n      let mm = genPuzzleMachine [BallType '0'] 1 1\n      bpCount <- forAll $ Gen.int (Range.linear 1 20)\n      bps <- replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object)\n      mkStore mm $ \\sr -> do\n        (`runReaderT` sr) $ traverse_ queueModeration bps\n        (`runReaderT` sr) $ forM_ bps $ \\bp -> do\n          gbp <- getBlueprint $ blueprintID bp\n          Just bp === gbp\n        (`runReaderT` sr) $ do\n          gbp0 <- getBlueprint $ blueprintID $ head bps\n          Just (head bps) === gbp0\n          gbp1 <- getBlueprint $ blueprintID $ head bps\n          Just (head bps) === gbp1\n    checkMod = H.testProperty \"Mod Ops\" $ property $ do\n      let mm = genPuzzleMachine [BallType '0'] 1 2\n      bpCount <- forAll $ Gen.int (Range.linear 1 20)\n      let pzlID0 = puzzleID $ mmGrid mm Array.! (0,0)\n      let pzlID1 = puzzleID $ mmGrid mm Array.! (0,1)\n      bps0 <- fmap (nub . fmap (\\bp -> bp {bPuzzleID=pzlID0})) $ replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object)\n      bps1 <- fmap (nub . fmap (\\bp -> bp {bPuzzleID=pzlID1})) $ replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object)\n      mkStore mm $ \\sr -> do\n        (`runReaderT` sr) $ forM_ ([pzlID0, pzlID1]::[PuzzleID]) $ \\pid -> do\n          mq <- listModerationQueue pid\n          [] === mq\n        (`runReaderT` sr) $ forM_ (bps0<>bps1) $ \\bp -> queueModeration bp\n        (`runReaderT` sr) $ forM_ ([(pzlID0, bps0), (pzlID1, bps1)]::[(PuzzleID, [Blueprint])]) $ \\(pid, bps) -> do\n          mq <- listModerationQueue pid\n          sort (fmap blueprintID bps) === sort mq\n        (`runReaderT` sr) $ forM_ (bps0<>bps1) $ \\bp -> dequeueModeration (blueprintID bp)\n        (`runReaderT` sr) $ forM_ ([pzlID0, pzlID1]::[PuzzleID]) $ \\pid -> do\n          mq <- listModerationQueue pid\n          [] === mq\n"
  }
]