Repository: xkcd/incredible Branch: main Commit: 3f8660708203 Files: 126 Total size: 515.2 KB Directory structure: gitextract_ufpta25o/ ├── .gitignore ├── CHANGELOG.md ├── Gen/ │ └── Main.hs ├── LICENSE ├── README.md ├── api-spec.json ├── app/ │ └── Main.hs ├── client/ │ ├── babel.config.json │ ├── comic.json │ ├── eslint.config.js │ ├── loaders/ │ │ └── comic-image-loader.js │ ├── package.json │ ├── prettier.config.mjs │ ├── src/ │ │ ├── api.ts │ │ ├── components/ │ │ │ ├── BallPitMechanism.tsx │ │ │ ├── CenteredSlippyMap.tsx │ │ │ ├── Comic.tsx │ │ │ ├── ComicBrowseView.tsx │ │ │ ├── ComicImage.tsx │ │ │ ├── ComicPuzzleView.tsx │ │ │ ├── DebugOverlay.tsx │ │ │ ├── EditorTutorials.tsx │ │ │ ├── FullscreenComicContainer.tsx │ │ │ ├── InnerComicBorder.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ ├── MachineContext.tsx │ │ │ ├── MachineTileContext.tsx │ │ │ ├── MachineTileEditor.tsx │ │ │ ├── MachineTilePlaceholder.tsx │ │ │ ├── MetaMachineView.tsx │ │ │ ├── NamePrompt.tsx │ │ │ ├── PhysicsContext.tsx │ │ │ ├── SwooshyDialog.tsx │ │ │ ├── WidgetPalette.tsx │ │ │ ├── constants.tsx │ │ │ ├── moderation/ │ │ │ │ ├── BlueprintButton.tsx │ │ │ │ ├── ContextGridForMachineAt.tsx │ │ │ │ ├── LiveMachinePreview.tsx │ │ │ │ ├── ModMachineTileView.tsx │ │ │ │ ├── ModTileInputOutputView.tsx │ │ │ │ ├── Moderator.tsx │ │ │ │ ├── SelectTileForm.tsx │ │ │ │ ├── interestingWeights.ts │ │ │ │ ├── modTypes.d.ts │ │ │ │ ├── modUtils.ts │ │ │ │ └── moderatorClient.ts │ │ │ ├── positionStyles.ts │ │ │ ├── useLocationHashParams.tsx │ │ │ ├── useMetaMachineClient.ts │ │ │ └── widgets/ │ │ │ ├── Anvil.tsx │ │ │ ├── AttractorRepulsor.tsx │ │ │ ├── BallStand.tsx │ │ │ ├── Balls.tsx │ │ │ ├── Board.tsx │ │ │ ├── Boat.tsx │ │ │ ├── BottomChute.tsx │ │ │ ├── BottomPit.tsx │ │ │ ├── BottomTank.tsx │ │ │ ├── Brick.tsx │ │ │ ├── CatSwat.tsx │ │ │ ├── CircleGauge.tsx │ │ │ ├── Cup.tsx │ │ │ ├── Cushion.tsx │ │ │ ├── Fan.tsx │ │ │ ├── Hammer.tsx │ │ │ ├── Hook.tsx │ │ │ ├── InputOutput.tsx │ │ │ ├── LeftBumper.tsx │ │ │ ├── MachineFrame.tsx │ │ │ ├── OutputValidator.tsx │ │ │ ├── Prism.tsx │ │ │ ├── QuantumGate.tsx │ │ │ ├── RightBumper.tsx │ │ │ ├── RoundBumper.tsx │ │ │ ├── SpawnInput.tsx │ │ │ ├── Sticker.tsx │ │ │ ├── Sword.tsx │ │ │ ├── Wheel.tsx │ │ │ ├── index.tsx │ │ │ └── lib/ │ │ │ ├── ball.ts │ │ │ └── lineCuboid.ts │ │ ├── custom.d.ts │ │ ├── generated/ │ │ │ └── api-spec.d.ts │ │ ├── image.d.ts │ │ ├── index.ejs │ │ ├── index.tsx │ │ ├── lib/ │ │ │ ├── coords.ts │ │ │ ├── snapshot.tsx │ │ │ ├── tiles.tsx │ │ │ └── utils.ts │ │ ├── page/ │ │ │ ├── demo-editor.tsx │ │ │ ├── demo-map.tsx │ │ │ ├── demo-viewer.tsx │ │ │ ├── fixtures/ │ │ │ │ ├── demoMachine.tsx │ │ │ │ └── emptyMachine.tsx │ │ │ ├── moderator.tsx │ │ │ └── page.ejs │ │ └── types.ts │ ├── tsconfig.json │ └── webpack.config.js ├── config/ │ ├── incredible.toml │ └── machine.json ├── docs/ │ └── Main.hs ├── flake.nix ├── incredible.cabal ├── nix/ │ ├── deploy.nix │ ├── digital-ocean/ │ │ ├── digital-ocean-config.nix │ │ ├── digital-ocean-custom-image.nix │ │ └── make-single-disk-zfs-image.nix │ ├── incredible-cfg.nix │ ├── incredible-digital-ocean.nix │ ├── incredible-frontend.nix │ ├── incredible-qemu.nix │ ├── incredible-server.nix │ ├── profiles/ │ │ └── staging.nix │ └── users/ │ └── deploy.nix ├── src/ │ └── Incredible/ │ ├── API.hs │ ├── AntiEvil.hs │ ├── App.hs │ ├── Config.hs │ ├── Data.hs │ ├── DataStore/ │ │ ├── Memory.hs │ │ └── Redis.hs │ ├── DataStore.hs │ └── Puzzle.hs └── test/ └── Main.hs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ dist dist-* cabal-dev *.o *.hi *.hie *.chi *.chs.h *.dyn_o *.dyn_hi .hpc .hsenv .cabal-sandbox/ cabal.sandbox.config *.prof *.aux *.hp *.eventlog .stack-work/ cabal.project.local cabal.project.local~ .HTF/ .ghc.environment.* result node_modules client/built/ .vscode/tasks.json *~ .direnv ================================================ FILE: CHANGELOG.md ================================================ # Revision history for incredible ## 0.1.0.0 -- YYYY-mm-dd * First version. Released on an unsuspecting world. ================================================ FILE: Gen/Main.hs ================================================ module Main where import Control.Monad import Incredible.App (writePuzzleMachine) import Incredible.Data import Incredible.Puzzle import Options.Applicative import System.Random import System.Random.Stateful data GenConfig = GenConfig { genSeed :: Maybe Int , genTest :: Bool } deriving (Eq, Ord, Show) genOpts :: Parser GenConfig genOpts = do GenConfig <$> configSeed <*> configTest where configSeed = option auto $ mconcat [ long "seed" , short 's' , metavar "SEED" , value Nothing , help "Seed for the random number generator" ] configTest = flag False True $ mconcat [ long "test" , short 't' , help "Generate a smaller test puzzle" ] main :: IO () main = do opts <- execParser $ info (genOpts <**> helper) fullDesc seed <- case genSeed opts of Just s -> pure s Nothing -> randomIO putStrLn $ "Using seed: " <> show seed let (label, puzzle) = if genTest opts then ("test-machine-", testPuzzle) else ("machine-", gamePuzzle) fp = label <> show seed <> ".json" gen <- newIOGenM $ mkStdGen seed generated <- generatePuzzle gen puzzle let m = mm generated possible <- machineIsSolvable m when (not possible) $ fail "Machine is not solvable" writePuzzleMachine fp m putStrLn "Machine written" where mm arr = MetaMachine arr (TileSize 740 740) 1000 mempty ================================================ FILE: LICENSE ================================================ Code is licensed as Copyright (c) 2024, xkcd All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of xkcd nor the names of other contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Art is licensed under Creative Commons Attribution-NonCommercial 2.5 License. ================================================ FILE: README.md ================================================ # Incredible [https://xkcd.com/2916](https://xkcd.com/2916) ## Development There 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. ### Frontend The client is built with NodeJS 20. A live development version can be started with `npm run start:dev` in `client/`. Production JS can be built with `nix build .#incredible-client` or `npm run build` in `client/`. ### Backend The server is built with GHC 9.6.4. To build and run the webserver with Cabal, `cabal run incredible-server` or with Nix, `nix run .#incredible:exe:incredible-server`. There is also an executable for generating puzzles, `cabal run incredible-gen` or with Nix, `nix run .#incredible:exe:incredible-gen`. ### VM You can build a Nix VM with the frontend and backend built with the configuration in `/config` with `nixos-rebuild build-vm --flake .#incredible-vm`. The VM can then be run with `./result/bin/run-nixos-vm`. ================================================ FILE: api-spec.json ================================================ {"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"} ================================================ FILE: app/Main.hs ================================================ {-# LANGUAGE OverloadedStrings #-} module Main where import Incredible.Config import qualified Incredible.DataStore.Memory as MemoryStore import qualified Incredible.DataStore.Redis as IRedis import Incredible.App import Network.Wai.Handler.Warp import Options.Applicative data IncredibleOptions = IncredibleOptions { incredibleConfigPath :: FilePath , incredibleMachinePath :: FilePath , incredibleDBMem :: Bool } incredibleOpts :: Parser IncredibleOptions incredibleOpts = do IncredibleOptions <$> configPath <*> machinePath <*> useMem where configPath = strOption $ mconcat [ long "config" , short 'c' , metavar "CONFIG" , value "config/incredible.toml" , help "Path to the incredible config file" ] machinePath = strOption $ mconcat [ long "machine" , short 'm' , metavar "MACHINE" , value "config/machine.json" , help "Path to the incredible machine file" ] useMem = flag False True $ mconcat [ long "mem" , help "if we should use an in-memory datastore." ] main :: IO () main = do putStrLn "Starting Incredible..." opts <- execParser $ info (incredibleOpts <**> helper) fullDesc putStrLn $ "Using config file: " ++ incredibleConfigPath opts cnf <- readIncredibleConfig $ incredibleConfigPath opts putStrLn $ "Using machine file: " ++ incredibleMachinePath opts puzzleMachine <- loadPuzzleMachine $ incredibleMachinePath opts putStrLn $ "Init incredible app..." webApp <- if incredibleDBMem opts then incredibleApp <$> openMachineShop cnf puzzleMachine MemoryStore.initIncredibleState else incredibleApp <$> openMachineShop cnf puzzleMachine IRedis.initIncredibleState let runWeb = run (incredibleWebPort $ incredibleWebConfig cnf) webApp putStrLn $ "Running on port: " ++ show (incredibleWebPort $ incredibleWebConfig cnf) runWeb ================================================ FILE: client/babel.config.json ================================================ { "presets": [ ["@babel/preset-env", { "targets": "defaults" }], "@babel/preset-typescript", [ "@babel/preset-react", { "runtime": "automatic", "importSource": "@emotion/react" } ] ], "plugins": ["@emotion"] } ================================================ FILE: client/comic.json ================================================ { "name": "Incredible", "alt": "The Credible Machine", "url": "/incredible", "publicPath": "/", "width": 740, "height": 740, "apiEndpoint": "http://localhost:8888/" } ================================================ FILE: client/eslint.config.js ================================================ import eslint from '@eslint/js' import * as reactQuery from '@tanstack/eslint-plugin-query' import hooksPlugin from 'eslint-plugin-react-hooks' import jsxRuntime from 'eslint-plugin-react/configs/jsx-runtime.js' import reactRecommended from 'eslint-plugin-react/configs/recommended.js' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: ['*.config.js', 'loaders/*'] }, { files: ['src/*'] }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, reactRecommended, jsxRuntime, { plugins: { '@tanstack/query': reactQuery }, rules: reactQuery.configs.recommended.rules, }, { plugins: { 'react-hooks': hooksPlugin }, rules: hooksPlugin.configs.recommended.rules, }, { rules: { '@typescript-eslint/no-unused-vars': [ 'error', { args: 'all', argsIgnorePattern: '^_', caughtErrors: 'all', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], 'react/no-unknown-property': ['error', { ignore: ['css'] }], 'react-hooks/exhaustive-deps': [ 'warn', { additionalHooks: '(useRapierEffect|useRigidBody|useCollider|useLoopHandler)', }, ], }, }, { languageOptions: { parserOptions: { project: true, tsconfigRootDir: import.meta.dirname, }, }, }, ) ================================================ FILE: client/loaders/comic-image-loader.js ================================================ import loaderUtils from 'loader-utils' import sharp from 'sharp' import { callbackify } from 'util' async function processImage(inputBuffer) { const options = this.getOptions() const img = sharp(inputBuffer) const meta = await img.metadata() const scaleImage = async (scale) => { const { data, info } = await img .resize({ width: Math.floor(meta.width * (scale / options.baseScale)), }) .png({ palette: !!options.quant }) .toBuffer({ resolveWithObject: true }) const interpolatedName = loaderUtils.interpolateName(this, options.name, { content: data, }) this.emitFile(interpolatedName, data) return { scale, data, info, interpolatedName } } const publicPath = options.publicPath ? options.publicPath : '' const scaleResults = await Promise.all(options.scales.map(scaleImage)) const url = {} const srcSetItems = [] for (const result of scaleResults) { const imgURL = publicPath + result.interpolatedName url[`${result.scale}x`] = imgURL srcSetItems.push(`${imgURL} ${result.scale}x`) } const outputData = { width: Math.floor(meta.width * (1 / options.baseScale)), height: Math.floor(meta.height * (1 / options.baseScale)), url, srcSet: srcSetItems.join(', '), } return `module.exports = ${JSON.stringify(outputData)}` } const processImageCb = callbackify(processImage) export default function downscale(inputBuffer) { this.cacheable() const cb = this.async() processImageCb.call(this, inputBuffer, cb) } export const raw = true ================================================ FILE: client/package.json ================================================ { "name": "incredible", "version": "1.0.0", "description": "TBD", "private": true, "main": "src/index.tsx", "type": "module", "scripts": { "build": "webpack --mode production", "profile": "webpack --mode production --profile --json=bundle-stats.json", "build:dev": "webpack --mode development", "build:api-types": "openapi-typescript ../api-spec.json -o ./src/generated/api-spec.d.ts", "start:dev": "webpack serve --mode=development" }, "author": "Max Goodhart ", "license": "MIT", "dependencies": { "@babel/preset-react": "^7.24.1", "@dimforge/rapier2d": "^0.12.0", "@emotion/react": "^11.11.4", "@react-hook/intersection-observer": "^3.1.1", "@react-hook/latest": "^1.0.3", "@react-hook/size": "^2.1.2", "@tanstack/react-query": "^5.28.9", "@types/lodash": "^4.17.0", "assert-never": "^1.2.1", "framer-motion": "^11.0.22", "html-webpack-plugin": "^5.6.0", "lodash-es": "^4.17.21", "mitt": "^3.0.1", "openapi-fetch": "^0.9.3", "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-moveable": "^0.56.0", "terser-webpack-plugin": "^5.3.10", "tiny-invariant": "^1.3.3", "typescript": "^5.4.2", "webpack": "^5.90.3", "webpack-cli": "^5.1.4", "weighted": "^1.0.0" }, "devDependencies": { "@babel/core": "^7.24.3", "@babel/preset-env": "^7.24.3", "@babel/preset-typescript": "^7.24.1", "@emotion/babel-plugin": "^11.11.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@tanstack/eslint-plugin-query": "^5.28.6", "@types/prettier": "^2.7.3", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", "babel-loader": "^9.1.3", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^4.6.0", "loader-utils": "^3.2.1", "openapi-typescript": "^6.7.5", "prettier-plugin-organize-imports": "^3.2.4", "sharp": "^0.33.3", "typescript-eslint": "^7.3.1", "webpack-bundle-analyzer": "^4.10.1", "webpack-dev-server": "^4.15.2" }, "engines": { "node": ">=20.0.0 <21.0.0" } } ================================================ FILE: client/prettier.config.mjs ================================================ export default { trailingComma: 'all', tabWidth: 2, semi: false, singleQuote: true, plugins: ['prettier-plugin-organize-imports'], } ================================================ FILE: client/src/api.ts ================================================ import { QueryClient } from '@tanstack/react-query' import createClient, { Middleware } from 'openapi-fetch' import comic from '../comic.json' import type { paths } from './generated/api-spec' export const apiClient = createClient({ baseUrl: process.env.API_ENDPOINT ?? comic.apiEndpoint, }) const throwOnError: Middleware = { async onResponse(res) { if (res.status >= 400) { const body: string = await res.clone().text() throw new Error(body) } return undefined }, } apiClient.use(throwOnError) export const queryClient = new QueryClient() ================================================ FILE: client/src/components/BallPitMechanism.tsx ================================================ import { useCallback, useMemo, useRef } from 'react' import { coords, vectorAngle } from '../lib/coords' import { inBounds } from '../lib/utils' import { Ball, BallType, Bounds } from '../types' import { MachineContextProvider, MachineContextProviderRef, useMachine, } from './MachineContext' import { GRAVITY, PhysicsContextProvider } from './PhysicsContext' import { BOTTOM_CHUTE_DROP, BOTTOM_CHUTE_EXIT_OFFSET, BOTTOM_MECHANISM_HEIGHT, BOTTOM_PIT_HEIGHT, BOTTOM_TANK_HEIGHT, BOTTOM_TANK_SPACING, BOTTOM_TANK_TARGET_TOP, BOTTOM_TANK_WIDTH, } from './constants' import { useMetaMachineClient } from './useMetaMachineClient' import { BASE_BALL_LIFETIME_TICKS, Balls } from './widgets/Balls' import Boat from './widgets/Boat' import { BottomChute } from './widgets/BottomChute' import { BottomPit } from './widgets/BottomPit' import { BottomTank } from './widgets/BottomTank' import { BallSpawner } from './widgets/SpawnInput' const tankIdxByType = [2, 0, 3, 1] /** * Bottom pool with balls fed by chutes at the bottom of the puzzle. * */ export function BallPitMechanism({ tilesX, tilesY, tileWidth, tileHeight, stepRateMultiplier = 1, }: { tilesX: number tilesY: number tileWidth: number tileHeight: number stepRateMultiplier?: number }) { const { msPerBall, simulationBoundsRef } = useMachine() const subMachineRef = useRef(null) const totalWidth = tilesX * tileWidth const totalHeight = tilesY * tileHeight const pitX = totalWidth / 2 const pitY = totalHeight + BOTTOM_MECHANISM_HEIGHT - BOTTOM_PIT_HEIGHT / 2 - 10 const tankCount = 4 const firstTankCenterX = pitX - (4 * BOTTOM_TANK_WIDTH + 3 * BOTTOM_TANK_SPACING) / 2 + BOTTOM_TANK_WIDTH / 2 const tankTopY = pitY - BOTTOM_TANK_HEIGHT - 400 const lastLineBounds: Bounds = [ 0, (tilesY - 1) * tileHeight, totalWidth, tilesY * tileHeight, ] const bounds: Bounds = useMemo( () => [0, 0, totalWidth, totalHeight + BOTTOM_MECHANISM_HEIGHT], [totalHeight, totalWidth], ) // Load the last row of machines const { metaMachine } = useMetaMachineClient({ viewBounds: lastLineBounds }) /** * Velocity of ball to hit the bool target */ const getBallVx = useCallback( (x: number, y: number, ballType: BallType) => { const tankIdx = tankIdxByType[ballType - 1] const targetX = firstTankCenterX + tankIdx * (BOTTOM_TANK_WIDTH + BOTTOM_TANK_SPACING) const [tx, ty] = coords.toRapier.vector( targetX, tankTopY + BOTTOM_TANK_TARGET_TOP, ) return (tx - x) / Math.sqrt((2 * (y - ty)) / -GRAVITY.y) }, [firstTankCenterX, tankTopY], ) // When balls are received in the chutes in the meta machine, spawn a ball in our machine targeting the pool. const handleReceiveBall = useCallback( (ball: Ball) => { const { current: subMachine } = subMachineRef if (!subMachine) { return } const { x, y } = ball.translation() subMachine.createBall( ...coords.fromRapier.vector(x, y), ball.userData.ballType, { vx: getBallVx(x, y, ball.userData.ballType), overrideDamping: 0, }, ) }, [getBallVx], ) if (!metaMachine) { return null } const tanks = [] for (let tankType = 1; tankType <= tankCount; tankType++) { tanks.push( , ) } const chutes = [] const ballSpawns = [] for (let xt = 0; xt < tilesX; xt++) { const machine = metaMachine.getMachine(xt, tilesY - 1) if (!machine) { continue } for (const output of machine.puzzle.outputs) { if (output.y !== 1) { continue } const x = xt * tileWidth + tileWidth * output.x const y = totalHeight // If the output exists in simulation, put a chute under it, otherwise, proxy it with a spawner. if (inBounds(x, y, simulationBoundsRef.current)) { chutes.push( , ) } else { ballSpawns.push( , ) } } } return ( <> {chutes} {ballSpawns} {tanks} ) } ================================================ FILE: client/src/components/CenteredSlippyMap.tsx ================================================ import { animate, motion, useMotionValue, useTransform } from 'framer-motion' import { ComponentProps, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react' import { Bounds } from '../types' export interface SlippyMapRef { getPosition(): [x: number, y: number] isAnimating(): boolean animateTo(x: number, y: number, zoom?: number): Promise jumpTo(x: number, y: number, zoom?: number): void stop(): void } export interface CenteredSlippyMapProps { width: number height: number totalWidth: number totalHeight: number innerClassName?: string onPosition: (viewBounds: Bounds) => void onDragStart?: ComponentProps['onDragStart'] children: React.ReactElement initialX?: number initialY?: number initialZoom?: number } export const CenteredSlippyMap = forwardRef< SlippyMapRef, CenteredSlippyMapProps >(function CenteredSlippyMap( { width, height, totalWidth, totalHeight, innerClassName, onPosition, onDragStart, children, initialX = 0, initialY = 0, initialZoom = 1, }: CenteredSlippyMapProps, ref, ) { const scaleVal = useMotionValue(initialZoom) const [scale, setScale] = useState(initialZoom) // The input position in unzoomed child screen space, centered in viewport. // We need to scale it by the zoom level into screen space. const centerXVal = useMotionValue(-initialX * initialZoom) const centerYVal = useMotionValue(-initialY * initialZoom) // Translation offset in screen space of drag gesture const dragXVal = useTransform(() => centerXVal.get() + 0.5 * width) const dragYVal = useTransform(() => centerYVal.get() + 0.5 * height) const handleUpdate = useCallback(() => { const x = dragXVal.get() const y = dragYVal.get() const scale = scaleVal.get() const scaleFactor = 1 / scale const viewBounds: Bounds = [ -x * scaleFactor, -y * scaleFactor, (-x + width) * scaleFactor, (-y + height) * scaleFactor, ] onPosition(viewBounds) }, [dragXVal, dragYVal, scaleVal, width, height, onPosition]) // Initial set of bounds on mount useEffect(handleUpdate, [handleUpdate]) const getPosition = useCallback(() => { return [ -centerXVal.get() / scaleVal.get(), -centerYVal.get() / scaleVal.get(), ] as [number, number] }, [centerXVal, centerYVal, scaleVal]) const isAnimating = useCallback(() => { return centerXVal.isAnimating() || centerYVal.isAnimating() }, [centerXVal, centerYVal]) const animateTo = useCallback( async (x: number, y: number, zoom: number = 1) => { await Promise.all([ setScale(zoom), animate(scaleVal, zoom), animate(centerXVal, -x * zoom), animate(centerYVal, -y * zoom), ]) }, [scaleVal, centerXVal, centerYVal], ) const jumpTo = useCallback( (x: number, y: number, zoom: number = 1) => { setScale(zoom) scaleVal.set(zoom) centerXVal.set(-x * zoom) centerYVal.set(-y * zoom) }, [scaleVal, centerXVal, centerYVal], ) const stop = useCallback(() => { scaleVal.stop() centerXVal.stop() centerYVal.stop() }, [scaleVal, centerXVal, centerYVal]) useImperativeHandle(ref, () => ({ getPosition, isAnimating, animateTo, jumpTo, stop, })) const dragConstraints = useMemo(() => { const right = -0.5 * scale * width const left = right - (totalWidth - width) * scale const bottom = -0.5 * scale * height const top = bottom - (totalHeight - height) * scale return { left, right, top, bottom, } }, [height, scale, totalHeight, totalWidth, width]) return (
{children}
) }) ================================================ FILE: client/src/components/Comic.tsx ================================================ import imgEdit from '@art/edit-icon_4x.png' import imgView from '@art/view-icon_4x.png' import { motion } from 'framer-motion' import { isEqual, random, throttle } from 'lodash' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import comic from '../../comic.json' import { Bounds } from '../types' import { ComicBrowseView } from './ComicBrowseView' import { ComicImage } from './ComicImage' import { ComicPuzzleView } from './ComicPuzzleView' import { FullscreenComicContainer } from './FullscreenComicContainer' import LoadingSpinner from './LoadingSpinner' import { ToolButton, comicDropShadow } from './WidgetPalette' import { paramToNumber, useLocationHashParams } from './useLocationHashParams' import { SavedMachine, useGetPuzzles, useMetaMachineClient, } from './useMetaMachineClient' export interface MetaMachineLink { xt: number yt: number v?: number } export function useHashLink(): { hashLink: MetaMachineLink | null updateHashLink: (link: MetaMachineLink) => void } { const { locationHashParams, setLocationHashParams } = useLocationHashParams() const lastRef = useRef(null) const hashLink = useMemo(() => { const xt = paramToNumber(locationHashParams.get('xt')) const yt = paramToNumber(locationHashParams.get('yt')) const v = paramToNumber(locationHashParams.get('v')) if (xt == null || yt == null) { return null } const nextVal = { xt, yt, v, } lastRef.current = nextVal return nextVal }, [locationHashParams]) const updateHashLink = useCallback( (update: MetaMachineLink) => { const nextVal = { ...lastRef.current, ...update } if (isEqual(nextVal, lastRef.current)) { return } lastRef.current = nextVal const { xt, yt, v } = nextVal setLocationHashParams({ xt: String(xt), yt: String(yt), v: v != null ? String(v) : undefined, }) }, [setLocationHashParams], ) return { hashLink, updateHashLink } } export default function Comic() { const { hashLink, updateHashLink } = useHashLink() const [mode, setMode] = useState<'create' | 'browse'>( hashLink ? 'browse' : 'create', ) const [viewBounds, setViewBounds] = useState(() => [ 0, 0, comic.width, comic.height, ]) const throttledSetViewBounds = useMemo(() => throttle(setViewBounds, 250), []) const [lastSubmission, setLastSubmission] = useState() const { metaMachine, allTilesLoaded } = useMetaMachineClient({ viewBounds, lastSubmission, version: hashLink?.v, }) const [isInitialLoading, setInitialLoading] = useState(true) useEffect(() => { if (allTilesLoaded) { setInitialLoading(false) } }, [allTilesLoaded]) const puzzles = useGetPuzzles() // TODO: track seen puzzles const [puzzleIdx, setPuzzleIdx] = useState(0) useEffect(() => { if (!puzzles) { return } setPuzzleIdx(random(puzzles.length - 1)) }, [puzzles]) const handleSubmitBlueprint = useCallback((location: SavedMachine) => { setLastSubmission(location) setMode('browse') }, []) const handleToggleMode = useCallback(() => { setMode((curMode) => (curMode === 'create' ? 'browse' : 'create')) }, []) if (!metaMachine) { return } const puzzle = puzzles ? puzzles[puzzleIdx] : undefined return ( {puzzle && ( )} {puzzle && ( )} ) } ================================================ FILE: client/src/components/ComicBrowseView.tsx ================================================ import imgFollowBall from '@art/follow-ball_4x.png' import imgPermalink from '@art/permalink_4x.png' import { AnimatePresence, motion } from 'framer-motion' import { random } from 'lodash' import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react' import { Bounds } from '../types' import { MetaMachineLink } from './Comic' import { ComicImage } from './ComicImage' import LoadingSpinner from './LoadingSpinner' import { SlippyMetaMachineRef, SlippyMetaMachineView, SlippyMetaMachineViewProps, } from './MetaMachineView' import { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext' import { ToolButton, comicDropShadow } from './WidgetPalette' import { MetaMachineInfo, SavedMachine } from './useMetaMachineClient' function findRandomTile(metaMachine: MetaMachineInfo) { const { tilesX, tilesY } = metaMachine for (let tries = 0; tries < 16; tries++) { const xt = random(0, tilesX - 1) const yt = random(0, tilesY - 1) if (metaMachine.hasBlueprint(xt, yt)) { return { xt, yt } } } // <3, jamslunt interfoggle return { xt: 2, yt: 2 } } export function ComicBrowseView({ lastSubmission, metaMachine, isActive, onPosition, hashLink, updateHashLink, }: { lastSubmission: SavedMachine | undefined metaMachine: MetaMachineInfo isActive: boolean onPosition: SlippyMetaMachineViewProps['onPosition'] hashLink: MetaMachineLink | null updateHashLink: (link: MetaMachineLink) => void }) { const viewRef = useRef(null) const getMap = () => viewRef?.current?.mapRef?.current const getMachine = () => viewRef?.current?.machineRef?.current const { tileWidth, tileHeight, tilesX, tilesY } = metaMachine function centeredScreenCoords({ xt, yt }: { xt: number; yt: number }) { return [xt * tileWidth + tileWidth / 2, yt * tileHeight + tileHeight / 2] } const [initialCenterX, initialCenterY] = centeredScreenCoords( lastSubmission ?? hashLink ?? findRandomTile(metaMachine), ) const isMovingToSubmission = useRef(false) useLayoutEffect(() => { async function doSubmitAnimate() { if (lastSubmission) { isMovingToSubmission.current = true getMachine()?.unfollowBall() getMap()?.jumpTo(initialCenterX, initialCenterY, 1) await new Promise((r) => setTimeout(r, 150)) await getMap()?.animateTo(initialCenterX, initialCenterY, 0.8) updateHashLink({ xt: lastSubmission.xt, yt: lastSubmission.yt, }) isMovingToSubmission.current = false } } void doSubmitAnimate() }, [lastSubmission, initialCenterX, initialCenterY, updateHashLink]) const updateHashLinkFromPosition = useCallback( ({ includeVersion }: { includeVersion?: boolean } = {}) => { const map = getMap() if (!map) { return } const [x, y] = map.getPosition() const xt = Math.floor(x / tileWidth) const yt = Math.floor(y / tileHeight) if (xt < 0 || xt > tilesX - 1 || yt < 0 || yt > tilesY - 1) { return } const nextHashLink: MetaMachineLink = { xt, yt } if (includeVersion) { nextHashLink.v = metaMachine.version } updateHashLink(nextHashLink) }, [ metaMachine.version, tileHeight, tileWidth, tilesX, tilesY, updateHashLink, ], ) const handlePosition = useCallback( (viewBounds: Bounds) => { onPosition?.(viewBounds) if ( getMap()?.isAnimating() || isMovingToSubmission.current || !isActive ) { return } updateHashLinkFromPosition() }, [onPosition, isActive, updateHashLinkFromPosition], ) useEffect(() => { const map = getMap() if (!hashLink || !map) { return } const [x, y] = map.getPosition() const xt = Math.floor(x / tileWidth) const yt = Math.floor(y / tileHeight) if (hashLink.xt === xt && hashLink.yt === yt) { return } getMachine()?.unfollowBall() void map.animateTo( hashLink.xt * tileWidth + tileWidth / 2, hashLink.yt * tileHeight + tileHeight / 2, 0.8, ) }, [hashLink, tileHeight, tileWidth]) const [hasCopied, setHasCopied] = useState(false) const handleClickPermalink = useCallback(() => { if (!hashLink) { return } async function doCopy() { updateHashLinkFromPosition({ includeVersion: true }) await navigator.clipboard?.writeText(window.location.toString()) setHasCopied(true) await new Promise((r) => setTimeout(r, 1000)) setHasCopied(false) } void doCopy() }, [hashLink, updateHashLinkFromPosition]) // Ball follow mode const startFollowingBall = useCallback(() => { const map = getMap() const machine = getMachine() if (!map || !machine) { return } map.stop() let [lastX, lastY] = map.getPosition() machine.followBall((x: number, y: number) => { const nextX = lastX * 0.75 + x * 0.25 const nextY = lastY * 0.75 + y * 0.25 map.jumpTo(nextX, nextY, 0.8) lastX = nextX lastY = nextY }) }, []) const handleDragStart = useCallback(() => { getMachine()?.unfollowBall() }, []) useEffect(() => { function handleKey(ev: KeyboardEvent) { if (ev.key.toLowerCase() === 'b' && ev.ctrlKey && ev.altKey) { startFollowingBall() } } window.addEventListener('keydown', handleKey, false) return () => { window.removeEventListener('keydown', handleKey, false) } }, [startFollowingBall]) return ( <> }> {hasCopied && ( Copied! )} ) } ================================================ FILE: client/src/components/ComicImage.tsx ================================================ import { HTMLAttributes, ImgHTMLAttributes, forwardRef, useCallback, useEffect, useRef, } from 'react' import { TICK_MS, useLoopHandler } from './PhysicsContext' export interface ComicImageProps extends Omit, 'src' | 'srcSet'> { img: typeof import('*.png').default alt?: string } export const ComicImage = forwardRef( function ComicImage({ img, alt = '', ...props }, ref) { return ( {alt} ) }, ) export const ComicImageAnimation = function ComicImageAnimation({ imgs, rateMs = 0, showIdx = null, ...props }: { imgs: Array rateMs?: number showIdx?: number | null animate?: boolean } & HTMLAttributes) { const ref = useRef(null) const lastIdx = useRef(null) const updateIdx = useCallback((idx: number) => { if (idx === lastIdx.current) { return } const { current: el } = ref if (!el) { return } if (lastIdx.current != null) { el.children[lastIdx.current].setAttribute('style', 'opacity: 0') } el.children[idx].setAttribute('style', 'opacity: 1') lastIdx.current = idx }, []) useEffect(() => { if (showIdx != null) { updateIdx(showIdx) return } else if (lastIdx.current == null) { // On first render, always show first frame, even if paused. updateIdx(0) } }, [imgs, rateMs, updateIdx, showIdx]) const ticksPerFrame = rateMs / TICK_MS const isStatic = showIdx != null || rateMs === 0 useLoopHandler( ({ getCurrentTick }) => { if (isStatic) { return } const currentTick = getCurrentTick() updateIdx(Math.floor(currentTick / ticksPerFrame) % imgs.length) }, [imgs.length, isStatic, ticksPerFrame, updateIdx], !isStatic, ) return (
{imgs.map((img, idx) => ( ))}
) } ================================================ FILE: client/src/components/ComicPuzzleView.tsx ================================================ import { AnimatePresence } from 'framer-motion' import { useCallback, useMemo, useRef, useState } from 'react' import invariant from 'tiny-invariant' import { emptyWidgets } from '../page/fixtures/emptyMachine' import { Bounds, PuzzleOrder, WidgetCollection } from '../types' import LoadingSpinner from './LoadingSpinner' import { MachineContextProvider } from './MachineContext' import { MachineTileContextProvider, MachineTileContextProviderRef, } from './MachineTileContext' import MachineTileEditor from './MachineTileEditor' import { NamePrompt } from './NamePrompt' import { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext' import { MetaMachineInfo, SavedMachine, useSubmitBlueprint, } from './useMetaMachineClient' function saveSolution(location: { blueprintId: string xt: number yt: number }) { try { const existingValue = JSON.parse( localStorage.getItem('contraptions') ?? '[]', ) as Array localStorage['contraptions'] = JSON.stringify([...existingValue, location]) } catch (err) { console.warn( `Submitted ${location.blueprintId}, failed to save to localStorage`, err, ) } } export function ComicPuzzleView({ puzzle, metaMachine, isActive, onSubmit, }: { puzzle: PuzzleOrder metaMachine: MetaMachineInfo | null isActive: boolean onSubmit: (location: SavedMachine) => void }) { const machineTileRef = useRef() const submitBlueprint = useSubmitBlueprint() const [isNaming, setNaming] = useState(false) const [widgets, setWidgets] = useState(null) const handleSubmit = useCallback((widgets: WidgetCollection) => { setWidgets(widgets) setNaming(true) }, []) const handleCancelName = useCallback(() => { setNaming(false) }, []) const handleSend = useCallback( (title: string) => { async function doSubmit() { invariant(widgets, 'widgets cannot be null') invariant(puzzle, 'puzzle cannot be null') // TODO: spinner const location = await submitBlueprint.mutateAsync({ puzzleId: puzzle.id, workOrder: puzzle.workOrder, title, widgets, }) if (location) { const snapshot = machineTileRef.current?.snapshot() onSubmit({ ...location, title, widgets, puzzle, snapshot }) saveSolution(location) } } void doSubmit() }, [onSubmit, puzzle, submitBlueprint, widgets], ) const tileBounds: Bounds | null = useMemo( () => metaMachine ? [0, 0, metaMachine.tileWidth, metaMachine.tileHeight] : null, [metaMachine], ) if (!metaMachine || !puzzle || !tileBounds) { return } return ( }> {isNaming ? ( ) : null} ) } ================================================ FILE: client/src/components/DebugOverlay.tsx ================================================ import { useContext, useRef } from 'react' import { coords } from '../lib/coords' import { MachineTileContext } from './MachineTileContext' import { useLoopHandler } from './PhysicsContext' export default function DebugOverlay({ ticksBetweenUpdates = 0, }: { ticksBetweenUpdates?: number }) { const canvasRef = useRef(null) const { width, height } = useContext(MachineTileContext) const ticksRef = useRef(null) useLoopHandler( ({ world }) => { const context = canvasRef.current?.getContext('2d') if (!context) { return } const { current: ticksSinceUpdate } = ticksRef if ( ticksBetweenUpdates > 0 && ticksSinceUpdate != null && ticksSinceUpdate < ticksBetweenUpdates ) { ticksRef.current = ticksSinceUpdate + 1 return } context.clearRect(0, 0, width, height) const { vertices, colors } = world.debugRender() for (let i = 0; i < vertices.length / 4; i += 1) { const red = colors[i * 4 + 0] * 255 const green = colors[i * 4 + 1] * 255 const blue = colors[i * 4 + 2] * 255 const alpha = colors[i * 4 + 3] * 255 context.beginPath() context.strokeStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})` context.lineWidth = 1.5 context.moveTo( ...coords.fromRapier.vector(vertices[i * 4 + 0], vertices[i * 4 + 1]), ) context.lineTo( ...coords.fromRapier.vector(vertices[i * 4 + 2], vertices[i * 4 + 3]), ) context.closePath() context.stroke() } ticksRef.current = 0 }, [width, height, ticksRef, ticksBetweenUpdates], ) return (
) } ================================================ FILE: client/src/components/EditorTutorials.tsx ================================================ import imgTutorialCongrats from '@art/tutorial_congrats_4x.png' import imgTutorialExpiry from '@art/tutorial_expiry_4x.png' import imgTutorialRouted from '@art/tutorial_routed_4x.png' import { AnimatePresence } from 'framer-motion' import { useCallback, useMemo, useState } from 'react' import { ComicImage } from './ComicImage' import { SwooshyDialog } from './SwooshyDialog' const tutorials = { routed: imgTutorialRouted, expiry: imgTutorialExpiry, submit: imgTutorialCongrats, } as const const tutorialKeys = Object.keys(tutorials) type TutorialKey = keyof typeof tutorials type TutorialState = { [K in TutorialKey]: boolean } const STORE_KEY = 'contraptionTips' function loadState(): TutorialState { let data: Record = {} try { const stored = localStorage.getItem(STORE_KEY) data = JSON.parse(stored ?? '{}') as Record } catch { // Use defaults } return Object.fromEntries( tutorialKeys.map((key) => [key, Boolean(data[key]) ?? false]), ) as TutorialState } function saveState(state: TutorialState) { localStorage[STORE_KEY] = JSON.stringify(state) } export function useTutorials() { const [seenTutorials, setSeenTutorials] = useState(loadState) const [visibleTutorial, setVisibleTutorial] = useState( null, ) const showTutorial = useCallback( (key: TutorialKey) => { if (seenTutorials[key] || visibleTutorial != null) { return } setVisibleTutorial(key) }, [seenTutorials, visibleTutorial], ) const dismissTutorial = useCallback( (key: TutorialKey) => { setVisibleTutorial(null) const nextState = { ...seenTutorials, [key]: true } setSeenTutorials(nextState) saveState(nextState) }, [seenTutorials], ) return useMemo( () => ({ seenTutorials, visibleTutorial, showTutorial, dismissTutorial }), [dismissTutorial, seenTutorials, showTutorial, visibleTutorial], ) } export function EditorTutorials({ visibleTutorial, onDismissTutorial, }: { visibleTutorial: TutorialKey | null onDismissTutorial: (key: TutorialKey) => void }) { const handleDismiss = useCallback(() => { if (!visibleTutorial) { return } onDismissTutorial(visibleTutorial) }, [onDismissTutorial, visibleTutorial]) return ( {visibleTutorial ? (
) : null}
) } ================================================ FILE: client/src/components/FullscreenComicContainer.tsx ================================================ import useSize from '@react-hook/size' import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react' import comic from '../../comic.json' import InnerComicBorder from './InnerComicBorder' import { SwooshyDialog } from './SwooshyDialog' function useIsFullscreen() { const [isFullscreen, setFullscreen] = useState(false) useEffect(() => { function handleFullscreenChange() { setFullscreen( Boolean(document.fullscreenElement ?? document.webkitFullscreenElement), ) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange) } }) return isFullscreen } export type DisplayContextType = { isFullscreen: boolean orientation: 'portrait' | 'landscape' } export const DisplayContext = createContext({ isFullscreen: false, orientation: 'portrait', }) export function useDisplayState() { return useContext(DisplayContext) } export function FullscreenComicContainer({ children, }: { children: ReactNode }) { const ref = useRef(null) const [canFullscreen, setCanFullscreen] = useState(false) const [fullscreenFailed, setFullscreenFailed] = useState(false) const [width, height] = useSize(ref) const isFullscreen = useIsFullscreen() const [isTouch] = useState( () => window.matchMedia('(pointer: coarse)').matches, ) useEffect(() => { const { current: el } = ref if (!el) { return } setCanFullscreen( 'requestFullscreen' in el || 'webkitRequestFullscreen' in el, ) }, []) const handleMaybeFullscreen = useCallback(() => { const { current: el } = ref async function tryFullscreen() { if (!el) { return } if ( document.fullscreenElement !== el && document.webkitFullscreenElement !== el ) { try { if ('requestFullscreen' in el) { await el.requestFullscreen?.() } else if ('webkitRequestFullscreen' in el) { await el.webkitRequestFullscreen?.() } } catch { setFullscreenFailed(true) } } } void tryFullscreen() }, []) const comicScale = Math.min(width / comic.width, height / comic.height) return (
{isTouch && !isFullscreen && !fullscreenFailed && canFullscreen && ( )} width ? 'portrait' : 'landscape', }} > {children}
) } ================================================ FILE: client/src/components/InnerComicBorder.tsx ================================================ import { CSSProperties, ReactNode } from 'react' import comic from '../../comic.json' export default function InnerComicBorder({ style, children, }: { style?: CSSProperties children: ReactNode }) { return (
{children}
) } ================================================ FILE: client/src/components/LoadingSpinner.tsx ================================================ import { ComicImage } from './ComicImage' import ballBlueImg from '@art/ball-blue_4x.png' import ballGreenImg from '@art/ball-green_4x.png' import ballRedImg from '@art/ball-red_4x.png' import ballYellowImg from '@art/ball-yellow_4x.png' import { css, keyframes } from '@emotion/react' const rotate = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } ` const fadeIn = keyframes` 0% { opacity: 0; } 50% { opacity: 0; } 100% { opacity: 1; } ` const counterRotateStyle = css({ animation: `${rotate} 1s linear infinite reverse`, }) export default function LoadingSpinner({ className }: { className?: string }) { return (
) } ================================================ FILE: client/src/components/MachineContext.tsx ================================================ import { noop } from 'lodash' import mitt, { Emitter } from 'mitt' import { MutableRefObject, ReactNode, createContext, forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useRef, useState, } from 'react' import type { RigidBody } from '@dimforge/rapier2d' import { coords } from '../lib/coords' import { BallSnapshot, BodySnapshot, snapshotBody } from '../lib/snapshot' import { inBounds, useIdGen } from '../lib/utils' import { BallType, Bounds } from '../types' import { PhysicsContext } from './PhysicsContext' export interface BallData { id: string snapshot: BodySnapshot age: number type: BallType overrideDamping?: number } export type BallDestroyReason = 'expiry' export type BallFollowCallback = (x: number, y: number) => void type MachineEvents = { createBall: BallData destroyBall: string exitBall: string renewBall: string killBall: string followBall: BallFollowCallback unfollowBall: void ballExpired: void } type ActiveBalls = Record< string, { type: BallType body: RigidBody renewTick: number } > export type CreateBallOptions = { vx?: number vy?: number overrideDamping?: number } export type MachineContextType = { msPerBall: number createBall: ( x: number, y: number, type: BallType, opts?: CreateBallOptions, ) => void destroyBall: (id: string, reason?: BallDestroyReason) => void /** Destroy the ball when it leaves the viewable area */ exitBall: (id: string) => void renewBall: (id: string) => void killBall: (id: string) => void clearBalls: () => void followBall: (callback: BallFollowCallback) => void unfollowBall: () => void registerBall: ( id: string, type: BallType, body: RigidBody, renewTick: number, ) => void unregisterBall: (id: string) => void snapshotBalls: (bounds: Bounds) => BallSnapshot[] restoreBalls: (ballSnapshots: BallSnapshot[]) => void simulationBoundsRef: MutableRefObject viewBoundsRef: MutableRefObject events: Emitter } export interface MachineContextProviderRef { createBall: MachineContextType['createBall'] clearBalls: MachineContextType['clearBalls'] followBall: MachineContextType['followBall'] unfollowBall: MachineContextType['unfollowBall'] events: MachineContextType['events'] simulationBoundsRef: MachineContextType['simulationBoundsRef'] viewBoundsRef: MachineContextType['viewBoundsRef'] } const infiniteBounds: Bounds = [-Infinity, -Infinity, Infinity, Infinity] export const MachineContext = createContext({ msPerBall: Infinity, createBall: noop, destroyBall: noop, exitBall: noop, renewBall: noop, killBall: noop, followBall: noop, unfollowBall: noop, clearBalls: noop, registerBall: noop, unregisterBall: noop, snapshotBalls: () => [], restoreBalls: noop, simulationBoundsRef: { current: infiniteBounds }, viewBoundsRef: { current: infiniteBounds }, events: mitt(), }) export const MachineContextProvider = forwardRef( function MachineContextProvider( { msPerBall, initialSimulationBounds = infiniteBounds, initialViewBounds = infiniteBounds, children, }: { msPerBall: number initialSimulationBounds?: Bounds initialViewBounds?: Bounds children: ReactNode }, ref, ) { const physics = useContext(PhysicsContext) const nextBallId = useIdGen() const [events] = useState(() => mitt()) const activeBalls = useRef({}) const createBall = useCallback( ( x: number, y: number, type: BallType, { vx = 0, vy = 0, overrideDamping }: CreateBallOptions = {}, ) => { events.emit('createBall', { id: nextBallId(), snapshot: { x: coords.toRapier.x(x), y: coords.toRapier.y(y), angle: 0, vx, vy, va: 0, }, age: 0, type, overrideDamping, }) }, [events, nextBallId], ) const destroyBall = useCallback( (id: string, reason?: BallDestroyReason) => { events.emit('destroyBall', id) if (reason === 'expiry') { events.emit('ballExpired') } }, [events], ) const exitBall = useCallback( (id: string) => { events.emit('exitBall', id) }, [events], ) const renewBall = useCallback( (id: string) => { events.emit('renewBall', id) }, [events], ) const killBall = useCallback( (id: string) => { events.emit('killBall', id) }, [events], ) const followBall = useCallback( (callback: BallFollowCallback) => { events.emit('followBall', callback) }, [events], ) const unfollowBall = useCallback(() => { events.emit('unfollowBall') }, [events]) const clearBalls = useCallback(() => { Object.keys(activeBalls.current).forEach((id) => destroyBall(id)) }, [destroyBall]) const registerBall = useCallback( (id: string, type: BallType, body: RigidBody, renewTick: number) => { activeBalls.current[id] = { type, body, renewTick } }, [], ) const unregisterBall = useCallback((id: string) => { delete activeBalls.current[id] }, []) const snapshotBalls = useCallback( (bounds: Bounds) => { if (!physics) { return [] } const currentTick = physics.getCurrentTick() return Object.values(activeBalls.current) .filter(({ body }) => { const { x, y } = body.translation() return inBounds(x, y, bounds) }) .map(({ type, body, renewTick }) => ({ ...snapshotBody(body), age: currentTick - renewTick, type, })) }, [physics], ) const restoreBalls = useCallback( (ballSnapshots: BallSnapshot[]) => { for (const { type, age, ...snapshot } of ballSnapshots) { events.emit('createBall', { id: nextBallId(), snapshot, age, type, }) } }, [events, nextBallId], ) const simulationBoundsRef = useRef(initialSimulationBounds) const viewBoundsRef = useRef(initialViewBounds) const contextValue: MachineContextType = useMemo( () => ({ msPerBall, createBall, destroyBall, exitBall, renewBall, killBall, followBall, unfollowBall, clearBalls, registerBall, unregisterBall, snapshotBalls, restoreBalls, simulationBoundsRef, viewBoundsRef, events, }), [ msPerBall, createBall, destroyBall, exitBall, renewBall, killBall, followBall, unfollowBall, clearBalls, registerBall, unregisterBall, snapshotBalls, restoreBalls, events, ], ) useImperativeHandle(ref, () => ({ createBall, followBall, unfollowBall, clearBalls, events, simulationBoundsRef, viewBoundsRef, })) return ( {children} ) }, ) export function useMachine() { return useContext(MachineContext) } ================================================ FILE: client/src/components/MachineTileContext.tsx ================================================ import { mapValues, noop } from 'lodash' import { DependencyList, ReactNode, createContext, forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useRef, } from 'react' import type RAPIER from '@dimforge/rapier2d' import type { Collider, ColliderDesc, RigidBody, RigidBodyDesc, } from '@dimforge/rapier2d' import { coords } from '../lib/coords' import { MachineSnapshot, applySnapshotToBody, offsetSnapshot, snapshotBody, } from '../lib/snapshot' import { inBounds } from '../lib/utils' import { Bounds, isBall } from '../types' import { useMachine } from './MachineContext' import { useLoopHandler, useRapierEffect } from './PhysicsContext' type ActiveBodies = Record export type MachineTileContextType = { bounds: Bounds width: number height: number registerBody: (key: string, body: RigidBody) => void unregisterBody: (key: string) => void snapshot: () => MachineSnapshot loadSnapshot: (snapshot: MachineSnapshot) => void } export interface MachineTileContextProviderRef { snapshot: MachineTileContextType['snapshot'] loadSnapshot: MachineTileContextType['loadSnapshot'] } export const MachineTileContext = createContext({ bounds: [0, 0, 0, 0], width: 0, height: 0, registerBody: noop, unregisterBody: noop, snapshot: () => ({ widgets: {}, balls: [] }), loadSnapshot: noop, }) export const MachineTileContextProvider = forwardRef( function MachineTileContextProvider( { bounds, initialSnapshot, children, }: { bounds: Bounds initialSnapshot?: MachineSnapshot children: ReactNode }, ref, ) { const { snapshotBalls, restoreBalls, destroyBall } = useMachine() const [x1, y1, x2, y2] = bounds const stableBounds = useMemo(() => [x1, y1, x2, y2], [x1, x2, y1, y2]) const width = x2 - x1 const height = y2 - y1 const activeBodies = useRef({}) const registerBody = useCallback((key: string, body: RigidBody) => { activeBodies.current[key] = body }, []) const unregisterBody = useCallback((key: string) => { delete activeBodies.current[key] }, []) const snapshot = useCallback(() => { const [x1, y1, x2, y2] = stableBounds const rapierBounds: Bounds = [ // This is tricky: because rapier's y axis grows in the opposite direction of ours, we swap y1 and y2. ...coords.toRapier.vector(x1, y2), ...coords.toRapier.vector(x2, y1), ] return { widgets: mapValues(activeBodies.current, (body, key) => ({ ...snapshotBody(body), key, })), balls: snapshotBalls(rapierBounds), } }, [snapshotBalls, stableBounds]) const loadSnapshot = useCallback( (snapshot: MachineSnapshot) => { const [x1, y1] = stableBounds // Tile snapshots are based on a zero origin. We must offset the translation coordinates to our tile position in rapier space. const [rapierOffsetX, rapierOffsetY] = coords.toRapier.vector(x1, y1) for (const [id, widgetSnapshot] of Object.entries(snapshot.widgets)) { const body = activeBodies.current[id] if (body) { applySnapshotToBody( offsetSnapshot(rapierOffsetX, rapierOffsetY, widgetSnapshot), body, ) } } restoreBalls( snapshot.balls.map((snapshot) => offsetSnapshot(rapierOffsetX, rapierOffsetY, snapshot), ), ) }, [restoreBalls, stableBounds], ) const contextValue = useMemo( () => ({ bounds, width, height, registerBody, unregisterBody, snapshot, loadSnapshot, }), [ bounds, width, height, registerBody, unregisterBody, snapshot, loadSnapshot, ], ) useImperativeHandle(ref, () => ({ snapshot, loadSnapshot, })) const hasRestoredSnapshot = useRef(false) useRapierEffect( ({ world, rapier: { Cuboid } }) => { if (initialSnapshot && !hasRestoredSnapshot.current) { loadSnapshot(initialSnapshot) hasRestoredSnapshot.current = true } // Clean up balls when tiles removed return () => { world.intersectionsWithShape( coords.toRapier.vectorObject(x1 + width / 2, y1 + height / 2), 0, new Cuboid(...coords.toRapier.lengths(width / 2, height / 2)), (collider) => { const body = collider.parent() if (!body || !isBall(body)) { return true } destroyBall(body.userData.id) return true }, ) } }, [loadSnapshot, initialSnapshot, x1, width, y1, height, destroyBall], ) return ( {children} ) }, ) export function useRigidBody( create: (rapier: typeof RAPIER) => { key: string | null bodyDesc: RigidBodyDesc colliderDescs?: ColliderDesc[] }, deps: DependencyList, ) { const { registerBody, unregisterBody } = useContext(MachineTileContext) const bodyRef = useRef(null) useRapierEffect(({ rapier, world }) => { const { key, bodyDesc, colliderDescs } = create(rapier) const body = world.createRigidBody(bodyDesc) bodyRef.current = body colliderDescs ? colliderDescs.map((colliderDesc) => world.createCollider(colliderDesc, body), ) : null if (key != null) { registerBody(key, body) } return () => { if (key != null) { unregisterBody(key) } setTimeout(() => { world.removeRigidBody(body) }, 0) } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) return bodyRef } export function useSensorInTile( collider: Collider | undefined, handler: (otherCollider: Collider) => void, deps: DependencyList, ) { const { bounds } = useContext(MachineTileContext) useLoopHandler( ({ world }) => { if (!collider) { return } world.intersectionPairsWith(collider, (otherCollider) => { const [x, y] = coords.fromBody.vector(otherCollider) if (inBounds(x, y, bounds)) { handler(otherCollider) } }) }, // eslint-disable-next-line react-hooks/exhaustive-deps [collider, ...deps], ) } export function useMachineTile() { return useContext(MachineTileContext) } ================================================ FILE: client/src/components/MachineTileEditor.tsx ================================================ import imgSubmit from '@art/submit_4x.png' import imgWrench from '@art/wrench_4x.png' import { PropsOf } from '@emotion/react' import useLatest from '@react-hook/latest' import { motion } from 'framer-motion' import { max } from 'lodash' import React, { KeyboardEvent, useCallback, useEffect, useRef, useState, } from 'react' import Moveable, { OnDrag, OnResize, OnResizeStart, OnRotate, } from 'react-moveable' import { px, useIdGen } from '../lib/utils' import { Puzzle, WidgetCollection } from '../types' import { ComicImage } from './ComicImage' import DebugOverlay from './DebugOverlay' import { EditorTutorials, useTutorials } from './EditorTutorials' import { useDisplayState } from './FullscreenComicContainer' import { useMachine } from './MachineContext' import { useMachineTile } from './MachineTileContext' import { useLoopHandler } from './PhysicsContext' import WidgetPalette, { ToolButton, comicDropShadow } from './WidgetPalette' import { MAX_WIDGET_COUNT } from './constants' import { getPositionStyles } from './positionStyles' import { PaletteItem, WidgetData, Widgets, stickerList, widgetList, } from './widgets' import { Balls } from './widgets/Balls' import MachineFrame from './widgets/MachineFrame' import { getNextWheelSpeed } from './widgets/Wheel' const DEG_TO_RAD = Math.PI / 180 export interface EditableWidget { id: string onSelect?: ( ev: React.MouseEvent | React.TouchEvent, id: string, ) => void isSelected?: boolean } interface Selection { id: string el: HTMLDivElement } export function useSelectHandlers( id: EditableWidget['id'], onSelect: EditableWidget['onSelect'], ) { const handleSelect = useCallback( ( ev: React.MouseEvent | React.TouchEvent, ) => { onSelect?.(ev, id) }, [id, onSelect], ) return { onMouseDown: handleSelect, onTouchStart: handleSelect } } type Widgets = Record // use arrow keys for fun and profit // ... but only when a wheel is selected export function useWheelSpeed( setWidgets: (setState: (widgets: Widgets) => Widgets) => void, selection: Selection | undefined, ) { // no advantage to recreating everything each time selection changes imo const selectionRef = useRef(selection) selectionRef.current = selection useEffect(() => { function handleKey(ev: KeyboardEvent) { const key = ev.key.toLowerCase() const { current: selection } = selectionRef if (selection == null) { return } setWidgets((widgets) => { const selectedWheel = widgets[selection.id] if (selectedWheel.type === 'spokedwheel') { const speed = getNextWheelSpeed(selectedWheel, key) if (speed != null) { return { ...widgets, [selection.id]: { ...selectedWheel, speed, }, } } } return widgets }) } // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something window.addEventListener('keydown', handleKey, false) return () => { // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something window.removeEventListener('keydown', handleKey, false) } }, [selectionRef, setWidgets]) } const dragBounds: PropsOf['bounds'] = { left: 0, top: 0, right: 0, bottom: 0, position: 'css', } export default function MachineTileEditor({ puzzle, initialWidgets, onSubmit, }: { puzzle: Puzzle initialWidgets: WidgetCollection onSubmit?: (widgets: WidgetCollection) => void }) { const { clearBalls, events: machineEvents } = useMachine() const { width, height } = useMachineTile() const display = useDisplayState() const isMobilePalette = display.isFullscreen && display.orientation === 'portrait' const moveableRef = useRef(null) const [isShowingPalette, setShowingPalette] = useState(false) const [isManipulating, setManipulating] = useState(false) const [selection, setSelection] = useState() const [isValidOutputs, setValidOutputs] = useState(false) const nextId = useIdGen( () => max(Object.keys(initialWidgets).map(Number)) ?? 0, ) const [widgets, setWidgets] = useState>(initialWidgets) const latestWidgets = useLatest(widgets) const widgetCount = Object.keys(widgets).length const tutorials = useTutorials() const canSubmit = isValidOutputs && widgetCount <= MAX_WIDGET_COUNT useEffect(() => { function showExpiryTutorial() { tutorials.showTutorial('expiry') } function showRoutedTutorial() { tutorials.showTutorial('routed') } machineEvents.on('ballExpired', showExpiryTutorial) machineEvents.on('exitBall', showRoutedTutorial) return () => { machineEvents.off('ballExpired', showExpiryTutorial) machineEvents.off('exitBall', showRoutedTutorial) } }, [machineEvents, tutorials]) useEffect(() => { if (canSubmit) { tutorials.showTutorial('submit') } }, [canSubmit, tutorials]) const handleStartManipulating = useCallback(() => { setManipulating(true) }, []) const handleEndManipulating = useCallback(() => { setManipulating(false) }, []) useLoopHandler(() => { const { current: moveable } = moveableRef if (!moveable) { return } setTimeout(() => { moveable.updateRect() }, 0) }, []) const applyImmediateStyles = useCallback( ( selection: Selection, { x, y, angle }: { x?: number; y?: number; angle?: number }, ) => { const curWidget = latestWidgets.current[selection.id] // Not all widgets have rotation so we have to do some type checks for ts to be happy with this. const curAngle = 'angle' in curWidget ? curWidget.angle : 0 Object.assign( selection.el, getPositionStyles( x ?? curWidget.x, y ?? curWidget.y, angle ?? curAngle, ), ) }, [latestWidgets], ) const handleSelect = useCallback( ( ev: React.MouseEvent | React.TouchEvent, id: string, ) => { async function doSelect() { const { current: moveable } = moveableRef if (!moveable) { return } setSelection({ id, el: ev.currentTarget }) await moveable.waitToChangeTarget() moveable.dragStart(ev.nativeEvent) } void doSelect() }, [setSelection], ) const handleDrag = useCallback( ({ translate: [x, y] }: OnDrag) => { if (!selection) { return } applyImmediateStyles(selection, { x, y }) setWidgets((curWidgets) => { const selectedWidget = curWidgets[selection.id] return { ...curWidgets, [selection.id]: { ...selectedWidget, x, y, }, } }) }, [applyImmediateStyles, selection], ) const handleRotate = useCallback( ({ rotation: degRotation }: OnRotate) => { if (!selection) { return } const angle = degRotation * DEG_TO_RAD applyImmediateStyles(selection, { angle }) setWidgets((curWidgets) => { const selectedWidget = curWidgets[selection.id] return { ...curWidgets, [selection.id]: { ...selectedWidget, angle, }, } }) }, [applyImmediateStyles, selection], ) const handleResize = useCallback( ({ target, width, height, drag: { translate: [x, y], transform, }, }: OnResize) => { if (!selection) { return } // It's necessary to immediately apply the styles, otherwise react-moveable gets stale values and glitches out. target.style.transform = transform target.style.width = px(width) target.style.height = px(height) setWidgets((curWidgets) => { const selectedWidget = curWidgets[selection.id] return { ...curWidgets, [selection.id]: { ...selectedWidget, x, y, width, height, }, } }) }, [selection], ) const handleResizeStart = useCallback((ev: OnResizeStart) => { ev.setMin([25, 25]) setManipulating(true) }, []) const handleDeselect = useCallback((ev: React.MouseEvent) => { if (ev.target === ev.currentTarget) { setSelection(undefined) setShowingPalette(false) } }, []) const handleOpenPalette = useCallback(() => { setShowingPalette(true) }, []) const handleAddWidget = useCallback( (create: PaletteItem['create']) => { setWidgets((curWidgets) => ({ ...curWidgets, [nextId()]: create(width / 2, height / 2), })) }, [height, nextId, width], ) const handleTrashWidget = useCallback(() => { if (!selection) { return } setWidgets(({ [selection.id]: _removed, ...curWidgets }) => curWidgets) setSelection(undefined) }, [selection]) const [widgetsKey, setWidgetsKey] = useState(0) const handleEmergencyStop = useCallback(() => { // Remove and recreate widgets setWidgetsKey((x) => x + 1) clearBalls() }, [clearBalls]) const handleSubmit = useCallback(() => { onSubmit?.(widgets) }, [onSubmit, widgets]) const [showDebugOverlay, setShowDebugOverlay] = useState(false) // Delete key trashes widgets and ctrl+option+shift+d shows the debug overlay useEffect(() => { function handleKey(ev: KeyboardEvent) { if (ev.key === 'Delete') { handleTrashWidget() } else if ( ev.key.toLowerCase() === 'd' && ev.shiftKey && ev.ctrlKey && ev.metaKey ) { setShowDebugOverlay((prev) => !prev) } else if (ev.key === 'Escape') { setShowDebugOverlay(false) } } // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something window.addEventListener('keydown', handleKey, false) return () => { // @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something window.removeEventListener('keydown', handleKey, false) } }, [handleTrashWidget, selection, setShowDebugOverlay]) useWheelSpeed(setWidgets, selection) const selectedWidget = selection ? widgets[selection.id] : null const selectedWidgetInfo = selectedWidget == null ? null : selectedWidget.type === 'sticker' ? stickerList[selectedWidget.sticker] : widgetList[selectedWidget.type] return (
{/* portal so can display outside extents? */} {!isMobilePalette && ( )} {showDebugOverlay && }
) } ================================================ FILE: client/src/components/MachineTilePlaceholder.tsx ================================================ import imgConstruction1 from '@art/construction-1_4x.png' import imgConstruction2 from '@art/construction-2_4x.png' import imgConstruction3 from '@art/construction-3_4x.png' import imgConstruction4 from '@art/construction-4_4x.png' import imgConstruction5 from '@art/construction-5_4x.png' import imgConstruction6 from '@art/construction-6_4x.png' import imgConstruction7 from '@art/construction-7_4x.png' import imgConstruction8 from '@art/construction-8_4x.png' import imgConstruction9 from '@art/construction-9_4x.png' import { random, sampleSize } from 'lodash' import { useMemo } from 'react' import { coords } from '../lib/coords' import { isBall } from '../types' import { ComicImage } from './ComicImage' import { useMachine } from './MachineContext' import { useCollider, useCollisionHandler } from './PhysicsContext' import { BALL_RADIUS } from './constants' const imgConstructionChoices = [ imgConstruction1, imgConstruction2, imgConstruction3, imgConstruction4, imgConstruction5, imgConstruction6, imgConstruction7, imgConstruction8, imgConstruction9, ] export default function MachineTilePlaceholder({ tileWidth, tileHeight, xt, yt, }: { tileWidth: number tileHeight: number xt: number yt: number }) { const { destroyBall } = useMachine() // If balls exit finished tiles into the placeholder, destroy them. const ballCollider = useCollider( ({ ColliderDesc, ActiveEvents }) => ColliderDesc.cuboid( ...coords.toRapier.lengths( tileWidth / 2 - BALL_RADIUS * 2, tileHeight / 2 - BALL_RADIUS * 2, ), ) .setTranslation( ...coords.toRapier.vector( xt * tileWidth + tileWidth / 2, yt * tileHeight + tileHeight / 2, ), ) .setSensor(true) .setActiveEvents(ActiveEvents.COLLISION_EVENTS), [tileHeight, tileWidth, xt, yt], ) useCollisionHandler( 'start', ballCollider, (otherCollider) => { const body = otherCollider.parent() if (!body) { return } if (isBall(body)) { destroyBall(body.userData.id) } }, [], ) const imgs = useMemo( () => sampleSize(imgConstructionChoices, random(2, 3)), [], ) return (
{imgs.map((img, idx) => ( ))}
) } ================================================ FILE: client/src/components/MetaMachineView.tsx ================================================ import { ClassNames } from '@emotion/react' import { throttle } from 'lodash' import React, { RefObject, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { MachineSnapshot } from '../lib/snapshot' import { gridViewBounds, iterTiles, tileKey } from '../lib/tiles' import { Bounds, Puzzle, WidgetCollection } from '../types' import { BallPitMechanism } from './BallPitMechanism' import { CenteredSlippyMap, CenteredSlippyMapProps, SlippyMapRef, } from './CenteredSlippyMap' import { MachineContextProvider, MachineContextProviderRef, } from './MachineContext' import { MachineTileContextProvider } from './MachineTileContext' import MachineTilePlaceholder from './MachineTilePlaceholder' import { deferATick } from './PhysicsContext' import { BOTTOM_MECHANISM_HEIGHT } from './constants' import { MetaMachineInfo } from './useMetaMachineClient' import { Widgets } from './widgets' import { Balls } from './widgets/Balls' import MachineFrame from './widgets/MachineFrame' const MetaMachineTile = React.memo(function MetaMachineTiles({ puzzle, widgets, snapshot, title, tileWidth, tileHeight, xt, yt, spawnBallsTop, spawnBallsLeft, spawnBallsRight, }: { puzzle: Puzzle widgets: WidgetCollection snapshot: MachineSnapshot | undefined title: string | undefined tileWidth: number tileHeight: number xt: number yt: number spawnBallsTop: boolean spawnBallsLeft: boolean spawnBallsRight: boolean }) { // Stagger tile renders so we don't have entire rows or columns mounting and updating the world state at the same time. const [isRendered, setRendered] = useState(false) useEffect(() => { function draw() { setRendered(true) } const timeout = deferATick(draw) return () => { clearTimeout(timeout) } }, []) if (!isRendered) { return } return ( ) }) export interface SlippyMetaMachineViewProps extends MetaMachineInfo { initialX?: number initialY?: number initialZoom?: number simulateOutset?: number onPosition?: CenteredSlippyMapProps['onPosition'] onDragStart?: CenteredSlippyMapProps['onDragStart'] } export interface SlippyMetaMachineRef { mapRef: RefObject machineRef: RefObject } export const SlippyMetaMachineView = forwardRef< SlippyMetaMachineRef, SlippyMetaMachineViewProps >(function SlippyMetaMachineView( { getMachine, tilesX, tilesY, tileWidth, tileHeight, msPerBall, initialX = 0, initialY = 0, initialZoom = 1, simulateOutset = 150, onPosition, onDragStart, }: SlippyMetaMachineViewProps, ref, ) { const mapRef = useRef(null) const machineRef = useRef(null) const [xt1, setXt1] = useState(0) const [yt1, setYt1] = useState(0) const [xt2, setXt2] = useState(1) const [yt2, setYt2] = useState(1) const totalWidth = tileWidth * tilesX const totalHeight = tileHeight * tilesY + BOTTOM_MECHANISM_HEIGHT const [isBottomVisible, setBottomVisible] = useState(false) const updatePosition = useMemo( () => throttle((viewBounds: Bounds) => { const { current: machine } = machineRef if (!machine) { return } machine.viewBoundsRef.current = viewBounds const [xt1, yt1, xt2, yt2] = gridViewBounds( viewBounds, tilesX, tilesY, tileWidth, tileHeight, simulateOutset, ) setXt1(xt1) setYt1(yt1) setXt2(xt2) setYt2(yt2) machine.simulationBoundsRef.current = [ xt1 * tileWidth, yt1 * tileHeight, (xt2 + 1) * tileWidth, (yt2 + 1) * tileHeight, ] onPosition?.(viewBounds) const [, , , y2] = viewBounds setBottomVisible(y2 > totalHeight - BOTTOM_MECHANISM_HEIGHT) }, 1000 / 60), [ tilesX, tilesY, tileWidth, tileHeight, simulateOutset, onPosition, totalHeight, ], ) useImperativeHandle(ref, () => ({ mapRef, machineRef })) return ( {({ css }) => ( {Array.from(iterTiles(xt1, yt1, xt2, yt2), ([xt, yt]) => { const data = getMachine(xt, yt) if (!data) { return ( ) } const { blueprintId, title, puzzle, widgets, snapshot } = data return ( ) })} )} ) }) ================================================ FILE: client/src/components/NamePrompt.tsx ================================================ import { css } from '@emotion/react' import { sample } from 'lodash' import React, { useCallback, useMemo, useState } from 'react' import { SwooshyDialog, dialogStyles } from './SwooshyDialog' const superlatives = [ 'illustrious', 'magnificent', 'peculiar', 'fascinating', 'marvelous', 'confounding', ] const nouns = ['invention', 'device', 'machine', 'contraption'] const namePromptStyles = css({ fontFamily: 'xkcd-Regular-v3', padding: 16, userSelect: 'none', display: 'flex', flexDirection: 'column', gap: 16, width: 350, '.title': { fontSize: '20px', lineHeight: '135%', }, 'button, input': { fontFamily: 'xkcd-Regular-v3', border: '2px solid black', padding: 8, borderRadius: 4, }, input: { fontSize: '20px', background: '#eee', }, button: { fontSize: '16px', background: 'white', boxShadow: '3px 3px 0 rgba(0, 0, 0, 0.5)', cursor: 'pointer', '&:active': { transform: 'translate(3px, 3px)', background: '#eee', boxShadow: 'none', }, '&:disabled': { pointerEvents: 'none', }, }, }) export function NamePrompt({ onSubmit, onCancel, }: { onSubmit: (name: string) => void onCancel: () => void }) { const description = useMemo( () => `${sample(superlatives)} ${sample(nouns)}`, [], ) const [name, setName] = useState('') const handleNameChange = useCallback( (ev: React.ChangeEvent) => { setName(ev.target.value) }, [], ) const handleSubmit = useCallback( (ev: React.FormEvent) => { ev.preventDefault() onSubmit(name) }, [name, onSubmit], ) return (
What will you name this
{description}?
) } ================================================ FILE: client/src/components/PhysicsContext.tsx ================================================ import type RAPIER from '@dimforge/rapier2d' import { EventQueue, type Collider, type ColliderDesc, type ImpulseJoint, type JointData, type RigidBody, type World, } from '@dimforge/rapier2d' import useLatest from '@react-hook/latest' import { random } from 'lodash' import mitt, { Emitter } from 'mitt' import { DependencyList, EffectCallback, ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react' import { ms } from '../lib/utils' type LoopEvents = { step: void } & { [K in `collision_start_${number}` | `collision_end_${number}`]: number } export const GRAVITY = { x: 0.0, y: -9.81 } export const TICK_MS = 1000 / 60 export interface PhysicsContextType { tickMs: number rapier: typeof RAPIER world: World events: Emitter getCurrentTick: () => number } export const PhysicsContext = createContext(null) export function PhysicsContextProvider({ stepRateMultiplier = 1, debug, children, }: { stepRateMultiplier?: number debug?: boolean children: ReactNode }) { const [contextValue, setContextValue] = useState( null, ) const eventQueueRef = useRef() const baseTimeRef = useRef(0) const tickCountRef = useRef(0) const isDebugRef = useLatest(debug) const getCurrentTick = useCallback(() => tickCountRef.current, []) // TODO: pause when page inactive useEffect(() => { let destroyed = false let world: World | undefined async function loadAndInit() { const rapier = await import('@dimforge/rapier2d') if (destroyed) { return } const world = new rapier.World(GRAVITY) world.numSolverIterations = 4 world.timestep = TICK_MS / 1000 const events = mitt() eventQueueRef.current = new rapier.EventQueue(true) setContextValue({ tickMs: TICK_MS, rapier, world, events, getCurrentTick, }) } void loadAndInit() return () => { destroyed = true world?.free() } }, [getCurrentTick, isDebugRef]) const resetBaseTime = useCallback( (now: number, tickMs: number) => { const newBaseTime = now - (tickCountRef.current + 1) * tickMs if (isDebugRef.current) { console.debug(`Skipping ${newBaseTime - baseTimeRef.current}ms`) } baseTimeRef.current = newBaseTime }, [isDebugRef], ) useEffect(() => { if (stepRateMultiplier === 0) { return } let lastTickTime = performance.now() const tickMs = Math.floor(TICK_MS * (1 / stepRateMultiplier)) resetBaseTime(lastTickTime, tickMs) let raf: number = -1 function step() { if (!contextValue) { return } const { current: eventQueue } = eventQueueRef const { world, events } = contextValue if (!eventQueue || !world) { return } const now = performance.now() // Skip forward in case of large pauses. if (now - lastTickTime > 30 * tickMs) { resetBaseTime(now, tickMs) } const neededTicks = Math.ceil((now - baseTimeRef.current) / tickMs) while (tickCountRef.current < neededTicks) { const rapierStart = performance.now() world.step(eventQueue) const rapierEnd = performance.now() const eventQueueCallbackStart = performance.now() eventQueue.drainCollisionEvents((handle1, handle2, started) => { const eventName = started ? 'start' : 'end' events.emit(`collision_${eventName}_${handle1}`, handle2) events.emit(`collision_${eventName}_${handle2}`, handle1) }) const eventQueueCallbackEnd = performance.now() const stepCallbackStart = performance.now() events.emit('step') const stepCallbackEnd = performance.now() if (isDebugRef.current && tickCountRef.current % 60 === 0) { console.debug({ rapier: ms(rapierEnd - rapierStart), eventQueue: ms(eventQueueCallbackEnd - eventQueueCallbackStart), stepCallback: ms(stepCallbackEnd - stepCallbackStart), bodies: world.bodies.len(), }) } tickCountRef.current++ lastTickTime = performance.now() } raf = requestAnimationFrame(step) } raf = requestAnimationFrame(step) return () => { cancelAnimationFrame(raf) } }, [contextValue, isDebugRef, resetBaseTime, stepRateMultiplier]) return ( {children} ) } export function useRapierEffect( rapierEffect: (physics: PhysicsContextType) => ReturnType, deps: DependencyList, ) { const physics = useContext(PhysicsContext) useEffect(() => { if (!physics) { return } return rapierEffect(physics) // eslint-disable-next-line react-hooks/exhaustive-deps }, [physics, ...deps]) } export function useCollider( create: (rapier: typeof RAPIER) => ColliderDesc | null, deps: DependencyList, ) { // 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. const [collider, setCollider] = useState() useRapierEffect(({ rapier, world }) => { const colliderDesc = create(rapier) if (!colliderDesc) { return } const collider = world.createCollider(colliderDesc) setCollider(collider) return () => { setTimeout(() => { world.removeCollider(collider, false) }, 0) } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) return collider } // TODO: Test this! It looks right but I haven't tested it <.< >.> export function useImpulseJoint( create: (rapier: typeof RAPIER) => JointData | null, body1Ref: React.MutableRefObject, body2Ref: React.MutableRefObject, deps: DependencyList, ) { const jointRef = useRef(null) useRapierEffect( ({ rapier, world }) => { const jointDesc = create(rapier) const { current: left } = body1Ref const { current: right } = body2Ref if (!jointDesc || !left || !right) { return } const joint = world.createImpulseJoint(jointDesc, left, right, false) jointRef.current = joint return () => { setTimeout(() => { world.removeImpulseJoint(joint, false) }, 0) } }, // don't support changing 'create' since it'll break the deps array on every render =( // eslint-disable-next-line react-hooks/exhaustive-deps [deps, body1Ref, body2Ref], ) return jointRef } export function useLoopHandler( handler: (physics: PhysicsContextType) => void, deps: DependencyList, enabled: boolean = true, ) { useRapierEffect((physics) => { if (!enabled) { return } const { events } = physics function triggerHandler() { handler(physics) } events.on('step', triggerHandler) return () => { events.off('step', triggerHandler) } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) } export function useCollisionHandler( event: 'start' | 'end', collider: Collider | undefined, handler: (otherCollider: Collider) => void, deps: DependencyList, ) { useRapierEffect( (physics) => { if (!collider) { return } const { world, events } = physics function triggerHandler(handle: number) { const otherCollider = world.getCollider(handle) handler(otherCollider) } const { handle } = collider events.on(`collision_${event}_${handle}`, triggerHandler) return () => { events.off(`collision_${event}_${handle}`, triggerHandler) } }, // eslint-disable-next-line react-hooks/exhaustive-deps [event, collider, ...deps], ) } export function usePhysicsLoaded() { return useContext(PhysicsContext) != null } export function PhysicsLoader({ spinner, children, }: { spinner: ReactNode children: ReactNode }) { const isPhysicsLoaded = usePhysicsLoaded() if (!isPhysicsLoaded) { return spinner } return children } export function deferATick(callback: () => void) { return setTimeout(() => { callback() }, random(TICK_MS)) } ================================================ FILE: client/src/components/SwooshyDialog.tsx ================================================ import { css } from '@emotion/react' import { motion } from 'framer-motion' import React, { ReactNode, useCallback, useRef } from 'react' export const dialogStyles = css({ border: '2px solid black', background: 'white', boxShadow: '5px 5px 0 rgba(0, 0, 0, 0.5)', borderRadius: 4, }) export function SwooshyDialog({ className, onDismiss, children, }: { className?: string onDismiss?: () => void children: ReactNode }) { const ref = useRef(null) const handleClick = useCallback( (ev: React.MouseEvent) => { if (ev.target === ev.currentTarget) { onDismiss?.() } }, [onDismiss], ) return ( {children} ) } ================================================ FILE: client/src/components/WidgetPalette.tsx ================================================ import imgEmergencyStop from '@art/emergency-stop_4x.png' import imgTrash from '@art/trash_4x.png' import { PropsOf, css } from '@emotion/react' import { motion } from 'framer-motion' import { ComicImage } from './ComicImage' import { dialogStyles } from './SwooshyDialog' import { MAX_WIDGET_COUNT } from './constants' import { PaletteItem, WidgetData, stickerList, widgetList } from './widgets' export const comicDropShadow = css({ filter: 'drop-shadow(2px 2px 0 rgba(0, 0, 0, 0.5))', }) export function ToolButton({ disabled, className, 'aria-label': ariaLabel, onClick, children, ...props }: PropsOf) { return ( {children} ) } export default function WidgetPalette({ className, widgetCount, isHorizontal, onAdd, onTrash, onEmergencyStop, }: { className?: string widgetCount: number isHorizontal: boolean onAdd: (create: PaletteItem['create']) => void onTrash: () => void onEmergencyStop: () => void }) { const canAddWidgets = widgetCount < MAX_WIDGET_COUNT function renderList(list: Record>) { return Object.entries(list).map( ([type, { preview: WidgetPreview, create }]) => (
onAdd(create)} css={{ flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', maxWidth: '100%', maxHeight: '100%', aspectRatio: 1, cursor: 'pointer', '@media (pointer: fine)': { ':hover': { background: '#ccc', }, }, }} >
), ) } return (
{renderList(widgetList)}
{renderList(stickerList)}
{widgetCount > 0.75 * MAX_WIDGET_COUNT && (
{widgetCount} / {MAX_WIDGET_COUNT}
)}
) } ================================================ FILE: client/src/components/constants.tsx ================================================ import imgBottomPit from '@art/bottom_pit_4x.png' import imgBottomChute from '@art/chute-0v_4x.png' import imgBottomTank from '@art/tank-blue_4x.png' export const BALL_RADIUS = 8 export const INPUT_SPINNER_SIZE = 42 export const INPUT_SPINNER_SPEED = 2 export const INPUT_TEETH_COUNT = 8 export const INPUT_WIDTH = 2 * INPUT_SPINNER_SIZE + 5 export const MAX_WIDGET_COUNT = 100 export const TRIANGLE_BUMPER_SENSOR_OFFSET = 0.2 export const TRIANGLE_BUMPER_RADIUS_RATIO = 0.14159 export const TRIANGLE_BUMPER_SENSOR_FUDGE = 2.2 export const TRIANGLE_BUMPER_STRENGTH = 35 export const ROUND_BUMPER_STRENGTH = 35 export const BONK_ANIMATION_DELAY_MS = 200 export const TRIANGLE_BUMPER_CONTACT_DISTANCE = 0.1 export const ROAND_BUMPER_RADIUS_RATIO = 0.49 export const ROUND_BUMPER_FALLBACK_RATIO = 0.9 export const BOTTOM_PIT_WIDTH = imgBottomPit.width export const BOTTOM_PIT_HEIGHT = imgBottomPit.height export const BOTTOM_PIT_EDGE_WIDTH = 94 export const BOTTOM_MECHANISM_HEIGHT = 3000 export const BOTTOM_CHUTE_DROP = 50 export const BOTTOM_CHUTE_EXIT_OFFSET = 24 export const BOTTOM_CHUTE_HEIGHT = imgBottomChute.height export const BOTTOM_TANK_WIDTH = imgBottomTank.width export const BOTTOM_TANK_HEIGHT = imgBottomTank.height export const BOTTOM_TANK_SPACING = 100 export const BOTTOM_TANK_TARGET_TOP = 8 ================================================ FILE: client/src/components/moderation/BlueprintButton.tsx ================================================ import useIntersectionObserver from '@react-hook/intersection-observer' import { truncate } from 'lodash' import { useCallback, useRef } from 'react' import { Puzzle } from '../../types' import { default as ModMachineTileView } from './ModMachineTileView' import { ServerBlueprint } from './modTypes' export function BlueprintButton({ blueprintId, blueprint, puzzle, tileWidth, tileHeight, isSelected, isApproved, onSelect, }: { blueprintId: string blueprint: ServerBlueprint puzzle: Puzzle tileWidth: number tileHeight: number isSelected: boolean isApproved: boolean onSelect: (blueprintId: string) => void }) { const ref = useRef(null) const { isIntersecting } = useIntersectionObserver(ref) const handleClick = useCallback(() => { onSelect(blueprintId) }, [blueprintId, onSelect]) return ( ) } ================================================ FILE: client/src/components/moderation/ContextGridForMachineAt.tsx ================================================ import { css } from '@emotion/react' import { gridDimensions, iterTiles, tileKey } from '../../lib/tiles' import { inBounds } from '../../lib/utils' import { Puzzle } from '../../types' import LoadingSpinner from '../LoadingSpinner' import ModMachineTileView from './ModMachineTileView' import { ModTileInputOutputView } from './ModTileInputOutputView' import { ModLocation, ModMachine, ServerBlueprint } from './modTypes' import { useContextBlueprints, useContextPuzzles } from './moderatorClient' const TILE_WIDTH = 100 const tileWrapperStyles = css({ outline: '1px solid rgba(0, 0, 0, .35)', userSelect: 'none', }) const centerTextStyles = css({ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%', }) function OOBTile() { return (
OUT OF
BOUNDS
) } function EmptyTile({ puzzle, toModCount, onClick, }: { puzzle: Puzzle toModCount: number | undefined onClick?: () => void }) { return (
{toModCount}
) } export default function ContextGridForMachineAt({ modMachine, xt: viewXt, yt: viewYt, selectedBlueprint, tileOutset = 2, onSelectLocation, }: { modMachine: ModMachine selectedBlueprint: ServerBlueprint | undefined tileOutset?: number onSelectLocation: (xt: number, yt: number) => void } & ModLocation) { const sideSize: number = tileOutset * 2 + 1 const [tilesX, tilesY] = gridDimensions(modMachine.grid) const contextLocs: Array< | (ModLocation & { oob: false; toModCount: number | undefined }) | { xt: number yt: number oob: true } > = Array.from( iterTiles( viewXt - tileOutset, viewYt - tileOutset, viewXt + tileOutset, viewYt + tileOutset, ), ([xt, yt]) => inBounds(xt, yt, [0, 0, tilesX - 1, tilesY - 1]) ? { xt, yt, oob: false, puzzle: modMachine.grid[yt][xt].puzzle, blueprint: modMachine.grid[yt][xt].blueprint, toModCount: modMachine.grid[yt][xt].to_mod, } : { xt, yt, oob: true }, ) const puzzles = useContextPuzzles( contextLocs.map((l) => (l.oob ? undefined : l.puzzle)), ) const blueprints = useContextBlueprints( contextLocs.map((l) => (l.oob ? undefined : l.blueprint)), ) const contextTiles = contextLocs.map((loc, idx) => { const { xt, yt, oob } = loc const key = tileKey(xt, yt) const blueprint = blueprints[idx].data?.blueprint const puzzle = puzzles[idx].data if (puzzles[idx].isLoading || blueprints[idx].isLoading) { return } // This would be annoying to useCallback so I'm skipping it const handleClick = () => { onSelectLocation(xt, yt) } if (oob) { return } else if (!puzzle) { return } else if (!loc.blueprint) { return ( ) } else if (xt === viewXt && yt === viewYt) { return (
{selectedBlueprint ? ( ) : null}
) } else { return (
) } }) return (
{contextTiles}
) } ================================================ FILE: client/src/components/moderation/LiveMachinePreview.tsx ================================================ import imgCheck from '@art/check-circle_4x.png' import imgWrong from '@art/wrong-circle_4x.png' import { ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { Bounds, Puzzle } from '../../types' import { ComicImage } from '../ComicImage' import LoadingSpinner from '../LoadingSpinner' import { MachineContextProvider, MachineContextProviderRef, } from '../MachineContext' import { MachineTileContextProvider, MachineTileContextProviderRef, } from '../MachineTileContext' import { PhysicsContextProvider } from '../PhysicsContext' import { MAX_WIDGET_COUNT } from '../constants' import { CircleGauge } from '../widgets/CircleGauge' import ModMachineTileView from './ModMachineTileView' import { ModLocation, ModMachine, ServerBlueprint } from './modTypes' import { useApproveBlueprint, useBurnBlueprint, useReissuePuzzle, } from './moderatorClient' const MIN_SECONDS_TO_MOD = 30 function useCountdown(initialSeconds: number) { const [seconds, setSeconds] = useState(initialSeconds) useEffect(() => { const startTime = performance.now() function dec() { const now = performance.now() const remainingSeconds = initialSeconds - (now - startTime) / 1000 setSeconds(remainingSeconds) if (remainingSeconds <= 0) { clearInterval(interval) } } const interval = setInterval(dec, 150) return () => { clearInterval(interval) } }, [initialSeconds]) return seconds } function ValidationLine({ isValid, children, }: { isValid: boolean children: ReactNode }) { return (
{children}
) } export function LiveMachinePreview({ loc, modMachine, puzzle, blueprintId, blueprint, onNextBlueprint, }: { loc: ModLocation modMachine: ModMachine puzzle: Puzzle blueprintId: string | undefined blueprint: ServerBlueprint | undefined onNextBlueprint: () => void }) { const machineRef = useRef(null) const machineTileRef = useRef(null) const approveBlueprint = useApproveBlueprint() const burnBlueprint = useBurnBlueprint() const reissuePuzzle = useReissuePuzzle() const actionCountdown = useCountdown(MIN_SECONDS_TO_MOD) const [isValidOutputs, setValidOutputs] = useState(false) const widgetCount = blueprint ? Object.keys(blueprint.widgets).length : 0 const isWidgetCountValid = widgetCount <= MAX_WIDGET_COUNT const canApprove = actionCountdown <= 0 && isValidOutputs && isWidgetCountValid const handleOutputValidate = useCallback((isValid: boolean) => { setValidOutputs(isValid) }, []) let modStatus = '' const isError = approveBlueprint.isError || burnBlueprint.isError || reissuePuzzle.isError if (approveBlueprint.isPending || burnBlueprint.isPending) { modStatus = 'Working...' } else if (isError) { modStatus = 'Error :(' } else if (approveBlueprint.isSuccess) { modStatus = 'Approved!' } else if (burnBlueprint.isSuccess) { modStatus = 'Burnt!' } else if (reissuePuzzle.isSuccess) { modStatus = 'Reissued!' } const handleApprove = useCallback(() => { const { current: machineTile } = machineTileRef if (!machineTile || !blueprintId) { return } const snapshot = machineTile.snapshot() approveBlueprint.mutate({ xt: loc.xt, yt: loc.yt, blueprintId, snapshot, }) }, [approveBlueprint, blueprintId, loc.xt, loc.yt]) const handleBurn = useCallback(() => { if (!blueprintId) { return } void burnBlueprint.mutate({ puzzleId: loc.puzzle, blueprintId }) onNextBlueprint() }, [blueprintId, burnBlueprint, loc.puzzle, onNextBlueprint]) const handleReissue = useCallback(() => { if (!blueprint) { return } void reissuePuzzle.mutate({ puzzleId: blueprint.puzzle }) }, [blueprint, reissuePuzzle]) const tileBounds: Bounds = useMemo( () => [0, 0, modMachine.tile_size.x, modMachine.tile_size.y], [modMachine.tile_size.x, modMachine.tile_size.y], ) return (
{!puzzle ? ( ) : ( )} {blueprint ? ( <>

"{blueprint.title}"

{isValidOutputs ? 'All outputs are valid' : 'Not all outputs are valid'} {widgetCount} / {MAX_WIDGET_COUNT} widgets used
{modStatus && ( {modStatus} )}
) : null}
) } ================================================ FILE: client/src/components/moderation/ModMachineTileView.tsx ================================================ import { Puzzle, WidgetCollection } from '../../types' import { Widgets } from '../widgets' import { Balls } from '../widgets/Balls' import MachineFrame from '../widgets/MachineFrame' import { ModTileInputOutputView } from './ModTileInputOutputView' import { ServerBlueprint } from './modTypes' export default function ModMachineTileView({ puzzle, blueprint, width, height, tileWidth, tileHeight, onValidate, className, }: { puzzle: Puzzle blueprint?: ServerBlueprint width: number height: number tileWidth: number tileHeight: number onValidate?: (isValid: boolean) => void className?: string }) { return (
{blueprint ? ( ) : null} {puzzle ? ( ) : null}
) } ================================================ FILE: client/src/components/moderation/ModTileInputOutputView.tsx ================================================ import { css } from '@emotion/react' import { percent } from '../../lib/utils' import { Puzzle, PuzzlePosition } from '../../types' const posClassNames = ['blue', 'red', 'green', 'yellow'] const posStyles = css({ width: 6, height: 6, zIndex: 20, '&.input': { borderRadius: '100%', }, '&.blue': { background: 'blue', }, '&.red': { background: 'red', }, '&.green': { background: 'green', }, '&.yellow': { background: 'yellow', }, }) export function TinyInputOutput({ x, y, balls, isInput, style, }: { isInput?: boolean style?: React.CSSProperties } & PuzzlePosition) { return (
{balls.map(({ type }, idx) => (
))}
) } export function ModTileInputOutputView({ puzzle }: { puzzle: Puzzle }) { return ( <> {puzzle.inputs.map((input, idx) => ( ))} {puzzle.outputs.map((input, idx) => ( ))} ) } ================================================ FILE: client/src/components/moderation/Moderator.tsx ================================================ import { Global } from '@emotion/react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { clamp } from 'lodash' import { useCallback, useEffect, useMemo, useState } from 'react' import { components } from '../../generated/api-spec' import { gridDimensions } from '../../lib/tiles' import LoadingSpinner from '../LoadingSpinner' import { paramToNumber, useLocationHashParams } from '../useLocationHashParams' import { BlueprintButton } from './BlueprintButton' import ContextGridForMachineAt from './ContextGridForMachineAt' import { LiveMachinePreview } from './LiveMachinePreview' import SelectTileForm from './SelectTileForm' import { ModLocation } from './modTypes' import { getEmptyTile, locFromPosition, sortCandidateMap } from './modUtils' import { puzzleQueryOptions, useBlueprint, useCandidateBlueprints, useModeratorMachine, } from './moderatorClient' function useModHashParams(): { hashLoc: ModLocation | null setHashLoc: (loc: ModLocation) => void } { const { locationHashParams, setLocationHashParams } = useLocationHashParams() const hashLoc = useMemo(() => { const blueprint = locationHashParams.get('blueprint') ?? undefined const puzzle = locationHashParams.get('puzzle') const xt = paramToNumber(locationHashParams.get('xt')) const yt = paramToNumber(locationHashParams.get('yt')) if (!puzzle || xt == null || yt == null) { return null } return { blueprint, puzzle, xt, yt, } }, [locationHashParams]) const setHashLoc = useCallback( (loc: ModLocation) => { setLocationHashParams({ blueprint: loc.blueprint, puzzle: loc.puzzle, xt: String(loc.xt), yt: String(loc.yt), }) }, [setLocationHashParams], ) return { hashLoc, setHashLoc } } function BlueprintModerator({ modMachine, }: { modMachine: components['schemas']['VersionedMachine ModData'] }) { const { hashLoc, setHashLoc } = useModHashParams() const [loc, setLoc] = useState(() => hashLoc ? locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt) : getEmptyTile(modMachine), ) const queryClient = useQueryClient() const { data: locFolio } = useBlueprint(loc.blueprint) const { data: candidateBlueprints } = useCandidateBlueprints({ puzzleId: loc.puzzle, }) const [selectedBlueprintId, setSelectedBlueprintId] = useState< string | undefined >(hashLoc?.blueprint) useEffect(() => { setHashLoc({ ...loc, blueprint: selectedBlueprintId, }) }, [loc, selectedBlueprintId, setHashLoc]) useEffect(() => { if (!hashLoc) { return } setLoc(locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt)) setSelectedBlueprintId(hashLoc.blueprint) }, [hashLoc, modMachine.grid]) const sortedCandidateBlueprints = useMemo( () => candidateBlueprints ? sortCandidateMap(candidateBlueprints, loc.blueprint) : null, [candidateBlueprints, loc.blueprint], ) const selectedBlueprint = selectedBlueprintId === loc.blueprint ? locFolio?.blueprint : selectedBlueprintId ? candidateBlueprints?.get(selectedBlueprintId) : undefined const { data: locPuzzle } = useQuery(puzzleQueryOptions(loc.puzzle)) const handleGotoLoc = useCallback( (rawXt: number, rawYt: number) => { const [tilesX, tilesY] = gridDimensions(modMachine.grid) const xt = clamp(rawXt, 0, tilesX - 1) const yt = clamp(rawYt, 0, tilesY - 1) setLoc({ xt, yt, ...modMachine.grid[yt][xt], }) void queryClient.invalidateQueries({ queryKey: ['moderator', 'machine'], }) }, [modMachine.grid, queryClient], ) const handleNextEmpty = useCallback(() => { void queryClient .invalidateQueries({ queryKey: ['moderator', 'machine'], }) .then(() => { setLoc(getEmptyTile(modMachine)) }) }, [modMachine, queryClient]) const handleNextBlueprint = useCallback(() => { if (!sortedCandidateBlueprints || !selectedBlueprintId) { return } const currentIdx = sortedCandidateBlueprints.findIndex( ([id]) => id === selectedBlueprintId, ) if (currentIdx === -1) { return } const nextItem = sortedCandidateBlueprints[currentIdx + 1] if (nextItem) { setSelectedBlueprintId(nextItem[0]) } }, [selectedBlueprintId, sortedCandidateBlueprints]) // If the selected blueprint id isn't valid, pick a suitable one useEffect(() => { if ( selectedBlueprintId != null && (selectedBlueprintId === loc.blueprint || !candidateBlueprints || candidateBlueprints.has(selectedBlueprintId)) ) { return } if (loc.blueprint != null) { setSelectedBlueprintId(loc.blueprint) return } if (candidateBlueprints) { const firstId = candidateBlueprints.keys().next() setSelectedBlueprintId(firstId.done === false ? firstId.value : undefined) } }, [selectedBlueprintId, candidateBlueprints, loc.blueprint]) return (

Incredible Modview

div': { display: 'flex', flexDirection: 'column', alignItems: 'center', flex: 1, gap: 16, }, h2: { margin: 0, }, }} >

Context Window

Candidate Machine

{locPuzzle ? ( ) : ( )}

Candidate Machine Options

{
{loc.blueprint && locFolio && ( )} {sortedCandidateBlueprints && locPuzzle ? sortedCandidateBlueprints.map(([blueprintId, blueprint]) => ( )) : null}
}
) } export default function Moderator() { const { data: modMachine } = useModeratorMachine() if (!modMachine) { return } return } ================================================ FILE: client/src/components/moderation/SelectTileForm.tsx ================================================ import React, { useCallback } from 'react' export default function SelectTileForm({ xt, yt, onGotoLoc, onNextEmpty, }: { xt: number yt: number onGotoLoc: (xt: number, yt: number) => void onNextEmpty: () => void }) { const handleChangeX = useCallback( (ev: React.ChangeEvent) => { onGotoLoc(Number(ev.target.value), yt) }, [onGotoLoc, yt], ) const handleChangeY = useCallback( (ev: React.ChangeEvent) => { onGotoLoc(xt, Number(ev.target.value)) }, [onGotoLoc, xt], ) return (
) } ================================================ FILE: client/src/components/moderation/interestingWeights.ts ================================================ import { WidgetType } from '../widgets' export const interestingWeights: Record = { brick: 1, anvil: 3, attractor: 8, repulsor: 9, board: 5, fan: 10, hammer: 3, sword: 6, leftbumper: 5, rightbumper: 5, roundbumper: 5, hook: 7, lefthook: 8, cushion: 5, spokedwheel: 6, prism: 10, sticker: 5, ballstand: 10, cup: 5, catswat: 10, } ================================================ FILE: client/src/components/moderation/modTypes.d.ts ================================================ import { components } from '../../generated/api-spec' export type ModLocation = { puzzle: string blueprint?: string xt: number yt: number } export type ServerBlueprint = components['schemas']['Blueprint'] export type CandidateMap = Map export type ModMachine = components['schemas']['VersionedMachine ModData'] ================================================ FILE: client/src/components/moderation/modUtils.ts ================================================ import { random, sample, sortBy } from 'lodash' import { gridDimensions, iterTiles } from '../../lib/tiles' import { intersectBounds } from '../../lib/utils' import { WidgetCollection } from '../../types' import { interestingWeights } from './interestingWeights' import { CandidateMap, ModLocation, ModMachine, ServerBlueprint, } from './modTypes' export function getRandEmptyNearTile( modMachine: ModMachine, baseXt: number, baseYt: number, contextWindow: number, ): ModLocation | undefined { const [tilesX, tilesY] = gridDimensions(modMachine.grid) const nearbyEmpties: ModLocation[] = [] for (const [xt, yt] of iterTiles( ...intersectBounds( [baseXt, baseYt, baseXt + contextWindow, baseYt + contextWindow], [0, 0, tilesX - 1, tilesY - 1], ), )) { const tile = modMachine.grid[yt][xt] if (!tile.blueprint && tile.to_mod) { nearbyEmpties.push({ ...modMachine.grid[yt][xt], xt, yt }) } } if (nearbyEmpties.length > 0) { return sample(nearbyEmpties) } return undefined } export function getEmptyTile(modMachine: ModMachine): ModLocation { const [tilesX, tilesY] = gridDimensions(modMachine.grid) const emptyTiles: ModLocation[] = [] for (const [xt, yt] of iterTiles(0, 0, tilesX - 1, tilesY - 1)) { const { blueprint } = modMachine.grid[yt][xt] if (blueprint) { const nearbyEmpty = getRandEmptyNearTile(modMachine, xt, yt, 5) if (nearbyEmpty) { return nearbyEmpty } } else { emptyTiles.push({ ...modMachine.grid[yt][xt], xt, yt }) } } if (emptyTiles.length > 0) { return emptyTiles[random(emptyTiles.length)] } const xt = random(tilesX) const yt = random(tilesY) return { ...modMachine.grid[yt][xt], xt, yt } } export function calculateInterest(b: ServerBlueprint): number { const widgets = b.widgets as WidgetCollection let interestingness: number = 0 for (const [_, widget] of Object.entries(widgets)) { interestingness += interestingWeights[widget.type] } return interestingness } export function sortCandidateMap( candidates: CandidateMap, currentBlueprintId: string | undefined, ): Array<[string, ServerBlueprint]> { return sortBy([...candidates.entries()], ([blueprintId, blueprint]) => blueprintId === currentBlueprintId ? -Infinity : -calculateInterest(blueprint), ) } export function locFromPosition( grid: ModMachine['grid'], xt: number, yt: number, ) { return { ...grid[yt][xt], xt: xt, yt: yt, } } ================================================ FILE: client/src/components/moderation/moderatorClient.ts ================================================ import { queryOptions, useMutation, useQueries, useQuery, } from '@tanstack/react-query' import { apiClient, queryClient } from '../../api' import { MachineSnapshot } from '../../lib/snapshot' import { blueprintQueryOptions } from '../useMetaMachineClient' import { CandidateMap } from './modTypes' export function puzzleQueryOptions(puzzleId: string | undefined) { return queryOptions({ enabled: puzzleId != null, queryKey: ['moderator', 'puzzle', puzzleId], queryFn: async ({ signal }) => { const { data } = await apiClient.GET('/moderate/puzzle/{puzzleid}', { params: { path: { puzzleid: puzzleId!, }, }, credentials: 'include', signal, }) return data }, staleTime: Infinity, }) } export function useModeratorMachine() { return useQuery({ queryKey: ['moderator', 'machine'], queryFn: async ({ signal }) => { const { data } = await apiClient.GET('/moderate/machine/current', { credentials: 'include', signal, }) return data }, staleTime: 30 * 1000, }) } export function useCandidateBlueprints({ puzzleId }: { puzzleId: string }) { return useQuery({ queryKey: ['moderator', 'blueprints', puzzleId], queryFn: async ({ signal }) => { const { data } = await apiClient.GET( '/moderate/puzzle/{puzzleid}/blueprint', { params: { path: { puzzleid: puzzleId, }, }, credentials: 'include', signal, }, ) return new Map(data) }, }) } export function useBlueprint(blueprintId: string | undefined) { return useQuery(blueprintQueryOptions(blueprintId)) } export function useContextBlueprints(blueprintIds: Array) { return useQueries({ queries: blueprintIds.map((id) => blueprintQueryOptions(id)), }) } export function useContextPuzzles(puzzleIds: Array) { return useQueries({ queries: puzzleIds.map(puzzleQueryOptions), }) } export function useApproveBlueprint() { return useMutation({ mutationFn: ({ xt, yt, blueprintId, snapshot, }: { xt: number yt: number blueprintId: string snapshot: MachineSnapshot }) => { return apiClient.POST('/moderate/build/{X}/{Y}', { params: { path: { X: xt, Y: yt, }, }, body: { blueprint: blueprintId, snapshot: snapshot as unknown as Record, }, credentials: 'include', }) }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['moderator', 'machine'] }) }, }) } export function useBurnBlueprint() { return useMutation({ mutationFn: ({ blueprintId, }: { puzzleId: string blueprintId: string }) => { return apiClient.POST('/moderate/burn/{blueprintid}', { params: { path: { blueprintid: blueprintId, }, }, credentials: 'include', }) }, onSuccess: (_data, { puzzleId }) => { void queryClient.invalidateQueries({ queryKey: ['moderator', 'blueprints', puzzleId], }) }, }) } export function useReissuePuzzle() { return useMutation({ mutationFn: ({ puzzleId }: { puzzleId: string }) => { return apiClient.POST('/moderate/puzzle/{puzzleid}/reissue', { params: { path: { puzzleid: puzzleId, }, }, credentials: 'include', }) }, }) } ================================================ FILE: client/src/components/positionStyles.ts ================================================ import type { RigidBody } from '@dimforge/rapier2d' import { MutableRefObject, RefObject, useCallback, useEffect, useRef, } from 'react' import { coords } from '../lib/coords' import { inBoundsObject, px } from '../lib/utils' import { useMachine } from './MachineContext' import { useLoopHandler } from './PhysicsContext' export function getPositionStyles(x: number, y: number, angle: number = 0) { return { position: 'absolute' as const, top: 0, left: 0, transform: `translate(${px(x)}, ${px(y)}) translate(-50%, -50%) rotate(${angle}rad)`, } } export function usePositionedBodyRef( bodyRef: RefObject, { width, height, initialX, initialY, initialAngle, xBasis = 0, yBasis = 0, }: { width: number height: number initialX: number initialY: number initialAngle?: number xBasis?: number yBasis?: number }, ): MutableRefObject { const elRef = useRef(null) const { viewBoundsRef } = useMachine() const wasVisibleRef = useRef(true) const updateStyle = useCallback(() => { const { current: el } = elRef const { current: body } = bodyRef if (!el) { return } const [bodyX, bodyY] = body ? coords.fromBody.vector(body) : [] const x = bodyX ?? initialX const y = bodyY ?? initialY if (x == null || y == null) { el.style.display = 'none' wasVisibleRef.current = false return } const isVisible = inBoundsObject(x, y, width, height, viewBoundsRef.current) if (isVisible != wasVisibleRef.current) { el.style.display = isVisible ? 'block' : 'none' } if (isVisible) { Object.assign( el.style, getPositionStyles( x - xBasis, y - yBasis, body ? coords.fromBody.angle(body) : initialAngle, ), ) } wasVisibleRef.current = isVisible }, [ bodyRef, initialX, initialY, initialAngle, width, height, viewBoundsRef, xBasis, yBasis, ]) useEffect(updateStyle) useLoopHandler(updateStyle, [updateStyle]) return elRef } ================================================ FILE: client/src/components/useLocationHashParams.tsx ================================================ import { useCallback, useEffect, useMemo, useState } from 'react' export function paramToNumber(text: string | null | undefined) { if (text == null) { return undefined } const val = Number(text) if (isNaN(val)) { return undefined } return val } export function useLocationHashParams() { const [, triggerUpdate] = useState(0) useEffect(() => { function handleHashChange() { triggerUpdate((x) => x + 1) } window.addEventListener('hashchange', handleHashChange) return () => { window.removeEventListener('hashchange', handleHashChange) } }, []) const params = useMemo( () => new URLSearchParams(location.hash.slice(1)), // eslint-disable-next-line react-hooks/exhaustive-deps [location.hash], ) const setLocationHashParams = useCallback( (data: Record) => { const params = new URLSearchParams() for (const [k, v] of Object.entries(data)) { if (v == null) { continue } params.set(k, v) } const newHash = new URLSearchParams(params).toString() const currentURLWithoutHash = window.location.href.split('#')[0] window.location.replace(`${currentURLWithoutHash}#${newHash}`) }, [], ) return { locationHashParams: params, setLocationHashParams, } } ================================================ FILE: client/src/components/useMetaMachineClient.ts ================================================ import { queryOptions, useMutation, useQueries, useQuery, } from '@tanstack/react-query' import { dropRightWhile, isNull } from 'lodash' import { useCallback, useMemo } from 'react' import invariant from 'tiny-invariant' import { apiClient, queryClient } from '../api' import { MachineSnapshot } from '../lib/snapshot' import { gridDimensions, gridViewBounds, iterTiles, tileKey, } from '../lib/tiles' import { Bounds, Puzzle, PuzzleOrder, WidgetCollection } from '../types' import { WidgetData } from './widgets' export interface TileData { blueprintId: string title: string puzzle: Puzzle widgets: WidgetCollection snapshot?: MachineSnapshot } export type GetMachineFunction = ( xt: number, yt: number, ) => TileData | undefined export interface MetaMachineInfo { version: number tileWidth: number tileHeight: number tilesX: number tilesY: number getMachine: GetMachineFunction hasBlueprint: (xt: number, yt: number) => boolean msPerBall: number } export interface MetaMachineClient { isLoading: boolean allTilesLoaded: boolean metaMachine: MetaMachineInfo | null } export interface SavedBlueprintLocation { blueprintId: string xt: number yt: number } export type SavedMachine = SavedBlueprintLocation & TileData export function blueprintQueryOptions(blueprintid: string | undefined) { return queryOptions({ enabled: blueprintid != null, queryKey: ['folio', blueprintid], queryFn: async ({ signal }) => { invariant(blueprintid, 'blueprintid must be non-null') const { data } = await apiClient.GET('/folio/{blueprintid}', { params: { path: { blueprintid, }, }, signal, }) return data }, staleTime: Infinity, }) } export function useMetaMachineClient({ viewBounds, lastSubmission, version: fetchVersion, fetchOutset = 2000, }: { viewBounds: Bounds lastSubmission?: SavedMachine | undefined version?: number fetchOutset?: number }): MetaMachineClient { const { data: machineData, isLoading } = useQuery({ queryKey: ['machine', fetchVersion ?? 'current'], queryFn: async ({ signal }) => { if (fetchVersion != null) { const { data } = await apiClient.GET('/machine/{version}', { params: { path: { version: fetchVersion } }, signal, }) return data } else { const { data } = await apiClient.GET('/machine/current', { signal }) return data } }, }) const tileWidth = machineData?.tile_size.x ?? 0 const tileHeight = machineData?.tile_size.y ?? 0 const msPerBall = machineData?.ms_per_ball ?? Infinity const version = machineData?.version ?? 0 const trimmedGrid = machineData?.grid ? dropRightWhile(machineData.grid, (row, idx) => { if (idx === 0) { return false } if (lastSubmission && idx <= lastSubmission.yt) { return false } return row.every(isNull) }) : [] const [tilesX, tilesY] = machineData ? gridDimensions(trimmedGrid) : [0, 0] const tileBounds: Bounds = machineData ? gridViewBounds( viewBounds, tilesX, tilesY, tileWidth, tileHeight, fetchOutset, ) : [0, 0, 0, 0] const tiles = [...iterTiles(...tileBounds)] const { sparseTileData, allTilesLoaded } = useQueries({ queries: machineData ? tiles.map(([xt, yt]) => blueprintQueryOptions(trimmedGrid[yt][xt])) : [], combine: (results) => { const sparseTileData: Record = {} let allTilesLoaded = results.length > 0 for (let i = 0; i < results.length; i++) { allTilesLoaded &&= !results[i].isLoading const data = results[i].data const [xt, yt] = tiles[i] if (!data) { continue } sparseTileData[tileKey(xt, yt)] = { blueprintId: trimmedGrid[yt][xt], title: data.blueprint.title, puzzle: data.puzzle, widgets: data.blueprint.widgets as Record, snapshot: data.snapshot as unknown as MachineSnapshot, } } return { sparseTileData, allTilesLoaded } }, }) const getMachine = useCallback( (xt: number, yt: number) => xt === lastSubmission?.xt && yt === lastSubmission?.yt ? lastSubmission : sparseTileData[tileKey(xt, yt)], [lastSubmission, sparseTileData], ) const hasBlueprint = useCallback( (xt: number, yt: number) => machineData?.grid[yt][xt] != null, [machineData?.grid], ) return useMemo( () => ({ isLoading, allTilesLoaded, metaMachine: machineData ? { version, tilesX, tilesY, tileWidth, tileHeight, getMachine, hasBlueprint, msPerBall, } : null, }), [ allTilesLoaded, getMachine, hasBlueprint, isLoading, machineData, msPerBall, tileHeight, tileWidth, tilesX, tilesY, version, ], ) } export function useGetPuzzles(): PuzzleOrder[] | undefined { const { data, isStale } = useQuery({ queryKey: ['puzzle'], queryFn: async ({ signal }) => { const { data, response } = await apiClient.GET('/puzzle', { signal, }) const workOrder = response.headers.get('X-WorkOrder') if (!workOrder) { return } const puzzles = data ? Object.entries(data).map(([puzzleId, puzzleData]) => ({ id: puzzleId, workOrder: workOrder, inputs: puzzleData.inputs, outputs: puzzleData.outputs, })) : undefined return puzzles }, staleTime: Infinity, }) return !isStale ? data : undefined } export function useSubmitBlueprint() { return useMutation({ mutationFn: async ({ puzzleId, workOrder, title, widgets, }: { puzzleId: string workOrder: string title: string widgets: WidgetCollection }) => { const { data } = await apiClient.POST('/blueprint/file', { body: { puzzle: puzzleId, title, widgets }, headers: { 'X-WorkOrder': workOrder }, }) if (!data) { return null } const [blueprintId, [xt, yt]] = data return { blueprintId, xt, yt } }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['puzzle'] }) }, }) } ================================================ FILE: client/src/components/widgets/Anvil.tsx ================================================ import imgAnvil from '@art/anvil_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export interface AnvilWidget extends Vector, Angled { type: 'anvil' } export function AnvilPreview() { return } export function Anvil({ id, onSelect, x, y, angle, }: AnvilWidget & EditableWidget) { const width = imgAnvil.width const height = imgAnvil.height useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Top ColliderDesc.triangle( coords.toRapier.vectorObject(-width / 2, -height / 2), coords.toRapier.vectorObject(width / 2, -height / 2), coords.toRapier.vectorObject(0, 0), ).setRestitution(0.7), // Bottom ColliderDesc.triangle( coords.toRapier.vectorObject(-23, height / 2), coords.toRapier.vectorObject(34, height / 2), coords.toRapier.vectorObject(-14, 0), ).setRestitution(0.7), // Center ColliderDesc.cuboid(...coords.toRapier.lengths(11, 10)) .setTranslation(...coords.toRapier.vector(-6, 0)) .setRestitution(0.7), ], } }, [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/AttractorRepulsor.tsx ================================================ import imgAttractor from '@art/attractor_4x.png' import imgRepulsor from '@art/repulsor_4x.png' import { useState } from 'react' import { coords, vectorAngle, vectorDistance } from '../../lib/coords' import { Sized, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useSensorInTile } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider, useLoopHandler } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface AttractorWidget extends Vector, Sized { type: 'attractor' } export interface RepulsorWidget extends Vector, Sized { type: 'repulsor' } export function AttractorPreview() { return } export function RepulsorPreview() { return } const startTime = performance.now() export function AttractorRepulsor({ id, onSelect, isSelected, className, x, y, width, strength, }: Vector & Sized & EditableWidget & { className?: string; strength: number }) { const fieldSize = width const radius = 10 const img = strength < 0 ? imgAttractor : imgRepulsor const boxCollider = useCollider( ({ ColliderDesc }) => ColliderDesc.ball(coords.toRapier.length(radius)) .setTranslation(...coords.toRapier.vector(x, y)) .setRestitution(0.5), [radius, x, y], ) const fieldCollider = useCollider( ({ ColliderDesc }) => ColliderDesc.ball(coords.toRapier.length(fieldSize)) .setTranslation(...coords.toRapier.vector(x, y)) .setSensor(true), [fieldSize, x, y], ) const falloffDistance = coords.toRapier.length(fieldSize) const [angle, setAngle] = useState(0) const [scale, setScale] = useState(1) useSensorInTile( fieldCollider, (otherCollider) => { const body = otherCollider.parent() if (!boxCollider || !boxCollider.isValid() || !body) { return } const distance = vectorDistance( boxCollider.translation(), body.translation(), ) const angle = vectorAngle(boxCollider.translation(), body.translation()) const falloff = Math.max( 0, Math.pow((falloffDistance - distance) / falloffDistance, 2), ) const forceVector = { x: strength * falloff * Math.cos(angle), y: strength * falloff * Math.sin(angle), } body.applyImpulse(forceVector, true) }, [boxCollider, falloffDistance, strength], ) useLoopHandler(() => { const currentTime = performance.now() if (strength < 0) { setAngle(((360 * (currentTime - startTime)) / 4000) % 360) setScale(1 + 0.08 * Math.sin((Math.PI * (currentTime - startTime)) / 900)) } else { setAngle(360 - (((360 * (currentTime - startTime)) / 20000) % 360)) setScale(1 + 0.05 * Math.sin((Math.PI * (currentTime - startTime)) / 500)) } }, [strength]) return (
) } export function Attractor(props: AttractorWidget & EditableWidget) { return ( ) } export function Repulsor(props: RepulsorWidget & EditableWidget) { return } ================================================ FILE: client/src/components/widgets/BallStand.tsx ================================================ import imgBallStand from '@art/ball-stand_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export interface BallStandWidget extends Vector, Angled { type: 'ballstand' } export function BallStandPreview() { return ( ) } export function BallStand({ id, onSelect, x, y, angle, }: BallStandWidget & EditableWidget) { const width = imgBallStand.width const height = imgBallStand.height useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Stand ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 4, height / 2 - 6), ) .setTranslation(...coords.toRapier.vector(0, 4)) .setRestitution(0.75), // Base ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 2, height / 12), ) .setTranslation( ...coords.toRapier.vector(0, height / 2 - height / 12), ) .setRestitution(0.75), // Top left ColliderDesc.triangle( coords.toRapier.vectorObject(width / 5, -height / 2), coords.toRapier.vectorObject(0, -height / 2 + 10), coords.toRapier.vectorObject(0, -height / 2 + 4), ).setRestitution(0.1), // Top right ColliderDesc.triangle( coords.toRapier.vectorObject(-width / 5, -height / 2), coords.toRapier.vectorObject(0, -height / 2 + 10), coords.toRapier.vectorObject(0, -height / 2 + 4), ).setRestitution(0.1), ], } }, [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/Balls.tsx ================================================ import ballBlueImg from '@art/ball-blue_4x.png' import ballGreenImg from '@art/ball-green_4x.png' import ballRedImg from '@art/ball-red_4x.png' import ballYellowImg from '@art/ball-yellow_4x.png' import type RAPIER from '@dimforge/rapier2d' import type { World } from '@dimforge/rapier2d' import { ClassNames, css } from '@emotion/react' import useLatest from '@react-hook/latest' import { sample } from 'lodash' import { MutableRefObject, useRef } from 'react' import { coords } from '../../lib/coords' import { inBoundsOutset, px } from '../../lib/utils' import { UserData } from '../../types' import { BallData, BallDestroyReason, BallFollowCallback, MachineContextType, useMachine, } from '../MachineContext' import { useRapierEffect } from '../PhysicsContext' import { BALL_RADIUS, BOTTOM_CHUTE_DROP, BOTTOM_CHUTE_HEIGHT, } from '../constants' type BallRecord = BallData & { el: HTMLDivElement } interface BallActor { step: () => void destroy: (reason?: BallDestroyReason) => void exit: () => void renew: () => void die: () => void follow: (callback: BallFollowCallback) => void unfollow: () => void } export const BASE_BALL_LIFETIME_TICKS = 30 * 60 // 30 seconds at 60tps const ballStyles = css({ position: 'absolute', left: 0, top: 0, pointerEvents: 'none', width: 2 * BALL_RADIUS, height: 2 * BALL_RADIUS, backgroundSize: 'contain', zIndex: 5, contain: 'strict', '&.blue': { backgroundImage: `url(${ballBlueImg.url['2x']})`, }, '&.red': { backgroundImage: `url(${ballRedImg.url['2x']})`, }, '&.green': { backgroundImage: `url(${ballGreenImg.url['2x']})`, }, '&.yellow': { backgroundImage: `url(${ballYellowImg.url['2x']})`, }, }) export const ballClassNames = ['blue', 'red', 'green', 'yellow'] function runBall( { id, type, age, snapshot, overrideDamping, el }: BallRecord, ballClassName: string, { RigidBodyDesc, RigidBodyType, ColliderDesc }: typeof RAPIER, world: World, machineRef: MutableRefObject, getCurrentTick: () => number, lifetimeTicks: number, ): BallActor { el.className = ballClassName const bodyDesc = new RigidBodyDesc(RigidBodyType.Dynamic) .setTranslation(snapshot.x, snapshot.y) .setRotation(snapshot.angle) .setLinvel(snapshot.vx, snapshot.vy) .setAngvel(snapshot.va) .setCcdEnabled(true) .setUserData({ type: 'BallData', id, ballType: type } satisfies UserData) const colliderDesc = ColliderDesc.ball(coords.toRapier.length(BALL_RADIUS)) if (type === 2) { colliderDesc.setRestitution(0.8).setDensity(1) } if (type === 3) { colliderDesc.setMass(0.75) } if (type === 4) { bodyDesc.setLinearDamping(2) colliderDesc.setDensity(0.3).setRestitution(0.6) } if (overrideDamping != null) { bodyDesc.setLinearDamping(overrideDamping) } const body = world.createRigidBody(bodyDesc) const collider = world.createCollider(colliderDesc, body) let wasVisible = true let isDying = false let isExiting = false let followCallback: BallFollowCallback | null = null let renewTick = getCurrentTick() - age machineRef.current.registerBall(id, type, body, renewTick) const actor: BallActor = { step() { const { simulationBoundsRef, viewBoundsRef } = machineRef.current const ballAge = getCurrentTick() - renewTick if (ballAge >= lifetimeTicks && !followCallback) { this.die() } const [x, y] = coords.fromBody.vector(body) followCallback?.(x, y) if ( !inBoundsOutset( x, y, BOTTOM_CHUTE_DROP + BOTTOM_CHUTE_HEIGHT, simulationBoundsRef.current, ) ) { machineRef.current?.destroyBall(id) el.style.display = 'none' return } const isVisible = inBoundsOutset(x, y, BALL_RADIUS, viewBoundsRef.current) if (isVisible != wasVisible) { el.style.display = isVisible ? 'block' : 'none' } if (!isVisible && isExiting) { machineRef.current?.destroyBall(id) return } if (isVisible) { el.style.transform = `translate(${px(x - BALL_RADIUS)}, ${px(y - BALL_RADIUS)})` } wasVisible = isVisible }, destroy() { el.remove() machineRef.current.unregisterBall(id) // Immediately destroying the body seems to cause panics when iterating over colliders. setTimeout(() => { world.removeRigidBody(body) }, 0) }, exit() { isExiting = true }, renew() { renewTick = getCurrentTick() machineRef.current.registerBall(id, type, body, renewTick) }, die() { if (isDying) { return } isDying = true el.style.transition = 'opacity 200ms ease-in' el.style.opacity = '0' setTimeout(() => { world.removeCollider(collider, false) }, 0) setTimeout(() => { machineRef.current?.destroyBall(id, 'expiry') }, 200) }, follow(callback: BallFollowCallback) { followCallback = callback }, unfollow() { followCallback = null }, } actor.step() return actor } export function BallsRunner({ lifetimeTicks, ballClassName, }: { lifetimeTicks: number ballClassName: string }) { const machine = useMachine() const parentRef = useRef(null) const { events } = machine const machineRef = useLatest(machine) useRapierEffect( ({ events: worldEvents, rapier, world, getCurrentTick }) => { const actors: Record = {} let followingId: string | undefined = undefined function handleCreateBall(ball: BallData) { const el = document.createElement('div') actors[ball.id] = runBall( { ...ball, el }, `${ballClassName} ${ballClassNames[ball.type - 1]}`, rapier, world, machineRef, getCurrentTick, lifetimeTicks, ) parentRef.current?.appendChild(el) } function handleDestroyBall(id: string) { actors[id]?.destroy() delete actors[id] } function handleExitBall(id: string) { actors[id]?.exit() } function handleRenewBall(id: string) { actors[id]?.renew() } function handleKillBall(id: string) { actors[id]?.die() } function handleFollowBall(callback: BallFollowCallback) { handleUnfollowBall() followingId = sample(Object.keys(actors)) if (!followingId) { return } actors[followingId].follow(callback) } function handleUnfollowBall() { if (followingId && actors[followingId]) { actors[followingId].unfollow() } } function handleStep() { Object.values(actors).forEach((actor) => actor.step()) } events.on('createBall', handleCreateBall) events.on('destroyBall', handleDestroyBall) events.on('exitBall', handleExitBall) events.on('renewBall', handleRenewBall) events.on('killBall', handleKillBall) events.on('followBall', handleFollowBall) events.on('unfollowBall', handleUnfollowBall) worldEvents.on('step', handleStep) return () => { events.off('createBall', handleCreateBall) events.off('destroyBall', handleDestroyBall) events.off('exitBall', handleExitBall) events.off('renewBall', handleRenewBall) events.off('killBall', handleKillBall) events.off('followBall', handleFollowBall) events.off('unfollowBall', handleUnfollowBall) worldEvents.off('step', handleStep) Object.values(actors).forEach((actor) => actor.destroy()) } }, [ballClassName, events, lifetimeTicks, machineRef], ) return
} export function Balls({ lifetimeTicks = BASE_BALL_LIFETIME_TICKS, }: { lifetimeTicks?: number }) { return ( {({ css }) => ( )} ) } ================================================ FILE: client/src/components/widgets/Board.tsx ================================================ import imgBoard from '@art/board_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface BoardWidget extends Vector, Angled { type: 'board' } export function BoardPreview() { return ( ) } export function Board({ id, onSelect, x, y, angle, }: BoardWidget & EditableWidget) { const width = imgBoard.width const height = imgBoard.height useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2)) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setRestitution(0.5), [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/Boat.tsx ================================================ import boatImage from '@art/boat_4x.png' import { type Vector } from '@dimforge/rapier2d' import { coords } from '../../lib/coords' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget } from '../MachineTileEditor' import { useLoopHandler, useRapierEffect } from '../PhysicsContext' import { getPositionStyles, usePositionedBodyRef } from '../positionStyles' export default function Boat({ id, x, y }: Vector & EditableWidget) { const radius = 30 const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: id, bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic) .setLinearDamping(6) .setAngularDamping(2), colliderDescs: [ // Weight ColliderDesc.capsule( coords.toRapier.length(boatImage.width / 3 - radius), coords.toRapier.length(radius), ) .setTranslation(...coords.toRapier.vector(30, 45)) .setRotation(Math.PI / 2) .setMass(12), // Sensor ColliderDesc.cuboid( ...coords.toRapier.lengths( boatImage.width / 3, boatImage.height / 2, ), ) .setTranslation(...coords.toRapier.vector(0, 10)) .setSensor(true), ], } }, [id], ) useRapierEffect(() => { const { current: body } = bodyRef if (!body) { return } body.setTranslation(coords.toRapier.vectorObject(x, y), true) }, [bodyRef, x, y]) useLoopHandler( ({ world }) => { const { current: body } = bodyRef if (!body) { return } const rotation = body.rotation() const springConstant = -1 body.resetTorques(true) body.applyTorqueImpulse(rotation * springConstant, true) let countTouchingWater = 0 world.intersectionPairsWith(body.collider(1), () => { countTouchingWater++ }) if (countTouchingWater) { body.applyImpulse( { x: 0, y: coords.toRapier.y(-6 * countTouchingWater) }, true, ) } }, [bodyRef], ) return ( ) } ================================================ FILE: client/src/components/widgets/BottomChute.tsx ================================================ import imgBottomChute from '@art/chute-0v_4x.png' import imgBottomChute25L from '@art/chute-25l_4x.png' import imgBottomChute25R from '@art/chute-25r_4x.png' import imgBottomChute50L from '@art/chute-50l_4x.png' import imgBottomChute50R from '@art/chute-50r_4x.png' import imgBottomChute75L from '@art/chute-75l_4x.png' import imgBottomChute75R from '@art/chute-75r_4x.png' import { coords } from '../../lib/coords' import { Angled, Ball, Vector, isBall } from '../../types' import { ComicImage } from '../ComicImage' import { useMachine } from '../MachineContext' import { useCollider, useCollisionHandler } from '../PhysicsContext' import { BOTTOM_CHUTE_EXIT_OFFSET } from '../constants' import { getPositionStyles } from '../positionStyles' export function BottomChute({ x, y, angle, onReceiveBall, }: { onReceiveBall: (ball: Ball) => void } & Angled & Vector) { const { destroyBall } = useMachine() const width = imgBottomChute.width const height = imgBottomChute.height const sensorCollider = useCollider( ({ ColliderDesc, ActiveEvents }) => ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 8)) .setTranslation( ...coords.toRapier.vector(x, y + BOTTOM_CHUTE_EXIT_OFFSET), ) .setSensor(true) .setActiveEvents(ActiveEvents.COLLISION_EVENTS), [height, width, x, y], ) useCollisionHandler( 'start', sensorCollider, (otherCollider) => { const body = otherCollider.parent() if (!body || !isBall(body)) { return } onReceiveBall(body) destroyBall(body.userData.id) }, [], ) const angleDeg = angle * (180 / Math.PI) - 90 let img = imgBottomChute if (angleDeg < -40) { img = imgBottomChute75R } else if (angleDeg < -10) { img = imgBottomChute50R } else if (angleDeg < -2) { img = imgBottomChute25R } else if (angleDeg < 2) { img = imgBottomChute } else if (angleDeg < 10) { img = imgBottomChute25L } else if (angleDeg < 40) { img = imgBottomChute50L } else { img = imgBottomChute75L } return ( ) } ================================================ FILE: client/src/components/widgets/BottomPit.tsx ================================================ import imgBottomPit from '@art/bottom_pit_4x.png' import { coords } from '../../lib/coords' import { Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useCollider } from '../PhysicsContext' import { BOTTOM_PIT_EDGE_WIDTH, BOTTOM_PIT_HEIGHT, BOTTOM_PIT_WIDTH, } from '../constants' import { getPositionStyles } from '../positionStyles' export function BottomPit({ x, y }: Vector) { const width = BOTTOM_PIT_WIDTH const height = BOTTOM_PIT_HEIGHT const edgeWidth = BOTTOM_PIT_EDGE_WIDTH const bottomHeight = 92 useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2)) .setTranslation( ...coords.toRapier.vector(x - width / 2 + edgeWidth / 2, y), ) .setRestitution(0.1), [edgeWidth, height, width, x, y], ) useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2)) .setTranslation( ...coords.toRapier.vector(x + width / 2 - edgeWidth / 2, y), ) .setRestitution(0.1), [edgeWidth, height, width, x, y], ) useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 2, bottomHeight / 2), ) .setTranslation( ...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2), ) .setRestitution(0.1), [height, width, x, y], ) useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 2, bottomHeight / 2), ) .setTranslation( ...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2), ) .setRestitution(0.1), [height, width, x, y], ) return } ================================================ FILE: client/src/components/widgets/BottomTank.tsx ================================================ import tankBlueOpenImg from '@art/tank-blue-open_4x.png' import tankBlueImg from '@art/tank-blue_4x.png' import tankGreenOpenImg from '@art/tank-green-open_4x.png' import tankGreenImg from '@art/tank-green_4x.png' import tankRedOpenImg from '@art/tank-red-open_4x.png' import tankRedImg from '@art/tank-red_4x.png' import tankYellowOpenImg from '@art/tank-yellow-open_4x.png' import tankYellowImg from '@art/tank-yellow_4x.png' import { useMemo, useState } from 'react' import { coords } from '../../lib/coords' import { BallType, Vector, isBall } from '../../types' import { ComicImageAnimation } from '../ComicImage' import { useMachine } from '../MachineContext' import { TICK_MS, useCollider, useCollisionHandler, useLoopHandler, } from '../PhysicsContext' import { BOTTOM_TANK_HEIGHT, BOTTOM_TANK_WIDTH } from '../constants' import { getPositionStyles } from '../positionStyles' import { lineCuboid } from './lib/lineCuboid' const tankImages = [ [tankBlueImg, tankBlueOpenImg], [tankRedImg, tankRedOpenImg], [tankGreenImg, tankGreenOpenImg], [tankYellowImg, tankYellowOpenImg], ] const intervalTicks = Math.floor((12 * 1000) / TICK_MS) const openTime = Math.floor(800 / TICK_MS) export function BottomTank({ x, y, type, }: { type: BallType } & Vector) { const { killBall } = useMachine() const [isOpen, setOpen] = useState(false) const basis = useMemo(() => ({ xBasis: -x, yBasis: -y }), [x, y]) // Left top funnel useCollider( ({ ColliderDesc, CoefficientCombineRule }) => lineCuboid( ColliderDesc, { x1: -43, y1: -209, x2: -28, y2: -180, thickness: 2 }, basis, ) .setRestitution(0.05) .setRestitutionCombineRule(CoefficientCombineRule.Min), [basis], ) // Right top funnel useCollider( ({ ColliderDesc, CoefficientCombineRule }) => lineCuboid( ColliderDesc, { x1: 43, y1: -209, x2: 28, y2: -180, thickness: 2 }, basis, ) .setRestitution(0.05) .setRestitutionCombineRule(CoefficientCombineRule.Min), [basis], ) // Left top useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: -85, y1: -164, x2: -32, y2: -190, thickness: 17 }, basis, ), [basis], ) // Right top useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: 85, y1: -164, x2: 32, y2: -190, thickness: 17 }, basis, ), [basis], ) // Left edge useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: -79, y1: -170, x2: -79, y2: 205, thickness: 20 }, basis, ), [basis], ) // Right edge useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: 79, y1: -170, x2: 79, y2: 205, thickness: 20 }, basis, ), [basis], ) // Left lip useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: -86, y1: 172, x2: -86, y2: 205, thickness: 20 }, basis, ), [basis], ) // Right lip useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: 82, y1: 164, x2: 82, y2: 209, thickness: 26 }, basis, ), [basis], ) // Bottom bar useCollider( ({ ColliderDesc }) => lineCuboid( ColliderDesc, { x1: -82, y1: 188, x2: isOpen ? -82 : 82, y2: 188, thickness: 7 }, basis, ), [basis, isOpen], ) const sensorCollider = useCollider( ({ ColliderDesc, ActiveEvents }) => ColliderDesc.cuboid( ...coords.toRapier.lengths( BOTTOM_TANK_WIDTH / 2 - 26, BOTTOM_TANK_HEIGHT / 2 - 26, ), ) .setTranslation(...coords.toRapier.vector(x, y + 10)) .setSensor(true) .setActiveEvents(ActiveEvents.COLLISION_EVENTS), [x, y], ) useCollisionHandler( 'start', sensorCollider, (otherCollider) => { const body = otherCollider.parent() if (!body || !isBall(body)) { return } if (body.userData.ballType !== type) { killBall(body.userData.id) } }, [], ) useLoopHandler(({ getCurrentTick }) => { const currentTick = getCurrentTick() if (currentTick % intervalTicks === 0) { setOpen(true) } if (currentTick % intervalTicks === openTime) { setOpen(false) } }, []) return ( ) } ================================================ FILE: client/src/components/widgets/Brick.tsx ================================================ import imgBrick from '@art/brick_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface BrickWidget extends Vector, Angled { type: 'brick' } export function BrickPreview() { return } export function Brick({ id, onSelect, x, y, angle, }: BrickWidget & EditableWidget) { const width = imgBrick.width const height = imgBrick.height useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2)) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setRestitution(0.75), [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/CatSwat.tsx ================================================ import imgCatNoSwat from '@art/cat-noswat_4x.png' import imgCatSwat from '@art/cat-swat_4x.png' import type { Collider } from '@dimforge/rapier2d' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { coords, vectorScale } from '../../lib/coords' import { RandallPath, rotate } from '../../lib/utils' import { Angled, Vector, isBall } from '../../types' import { ComicImage, ComicImageAnimation } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollisionHandler, useRapierEffect } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' import { pointToVectorObject } from './Prism' import Ball from './lib/ball' import { lineCuboid } from './lib/lineCuboid' export interface CatSwatWidget extends Vector, Angled { type: 'catswat' catMass: number } export function CatSwatPreview() { return (
) } const imgs = [imgCatNoSwat, imgCatSwat] const CAT_REST_KEY = imgs.indexOf(imgCatNoSwat) const CAT_BONK_KEY = imgs.indexOf(imgCatSwat) const MAX_CAT_SWAT_ANGLE = Math.PI / 2.0 const imageScale = 4 const width = imgCatSwat.width * imageScale const height = imgCatSwat.height * imageScale const basis = { xBasis: width / 2.0, yBasis: height / 2.0, scale: imageScale } const CAT_PATH: RandallPath[] = [ // tail { x1: 22, y1: 82, x2: 25, y2: 108, thickness: 2 }, { x1: 25, y1: 108, x2: 13, y2: 139, thickness: 2 }, { x1: 13, y1: 139, x2: 18, y2: 165, thickness: 2 }, { x1: 18, y1: 165, x2: 32, y2: 175, thickness: 2 }, { x1: 32, y1: 175, x2: 58, y2: 172, thickness: 2 }, // body { x1: 88, y1: 100, x2: 145, y2: 100, thickness: 150 }, // ears { x1: 95, y1: 28, x2: 99, y2: 1, thickness: 1 }, { x1: 99, y1: 1, x2: 118, y2: 18, thickness: 1 }, { x1: 118, y1: 18, x2: 138, y2: 18, thickness: 1 }, { x1: 138, y1: 18, x2: 155, y2: 5, thickness: 1 }, { x1: 155, y1: 5, x2: 158, y2: 27, thickness: 1 }, ] function performSwat( swatCollider: Collider | undefined, ballCollider: Collider, inBabyJailRef: React.MutableRefObject, inSwipeModeRef: React.MutableRefObject, setImgKey: Dispatch>, catMass: number, ) { const ball = ballCollider.parent() const cat = swatCollider?.parent() const collision = swatCollider?.contactCollider(ballCollider, 0.2) if ( inBabyJailRef.current != null || !ball || !isBall(ball) || !swatCollider || !cat || !collision ) { return } // illegal to swat behind const catRotation = swatCollider.rotation() const impactNormalr = rotate(-catRotation, collision.normal1) const impactAngler = Math.atan2(impactNormalr.y, impactNormalr.x) if (Math.abs(impactAngler) > MAX_CAT_SWAT_ANGLE) { return } // if want swat and not swat then start swat // keep swat 300ms // chill in baby jail for 200ms // leave baby jail reset image // rdy for swat if (!inSwipeModeRef.current) { setImgKey(CAT_BONK_KEY) inSwipeModeRef.current = setTimeout( () => { // console.log('swipe mode over. jail for babies begins.') inSwipeModeRef.current = undefined inBabyJailRef.current = setTimeout( () => { setImgKey(CAT_REST_KEY) inBabyJailRef.current = undefined }, Math.random() * 300 + 100, ) }, Math.random() * 100 + 100, ) } // swat const adjustment = (Math.random() * Math.PI) / 8 - Math.PI / 16 const adjustedAngle = rotate(adjustment, collision.normal1) const contactPoint = collision.point2 const swipeForce = vectorScale( adjustedAngle, (catMass * 8) / Math.max(ball.invMass(), Number.MIN_VALUE), ) ball.applyImpulseAtPoint(swipeForce, contactPoint, true) } export function CatSwat({ id, onSelect, x, y, angle, catMass, }: CatSwatWidget & EditableWidget) { const [imgKey, setImgKey] = useState(CAT_REST_KEY) const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType, ActiveEvents }) => { return { key: id, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setAdditionalMassProperties( catMass, pointToVectorObject(basis, 106, 116), catMass, ) .setCcdEnabled(true) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // the cats silly little tail and body ...CAT_PATH.map((path) => lineCuboid(ColliderDesc, path, basis).setDensity(0), ), // the cats head - can trigger swat Ball(ColliderDesc.ball.bind(undefined), basis, 32, { x: 129, y: 54, }).setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ), // the cats bbutt - can trigger swat Ball(ColliderDesc.ball.bind(undefined), basis, 45, { x: 107, y: 136, }).setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ), // swat sensor - make sure this is last Ball(ColliderDesc.ball.bind(undefined), basis, 126, { x: 126, y: 109, }) .setSensor(true) .setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ), ], } }, [angle, x, y, catMass, id], ) // grab the collider(s) we need off the body when they are ready for use const [swatCollider, setSwatCollider] = useState() const [swatCollider2, setSwatCollider2] = useState() const [swatCollider3, setSwatCollider3] = useState() useRapierEffect(() => { const numColliders = bodyRef.current?.numColliders() || 0 if (numColliders > 0) { const swat1 = bodyRef.current?.collider(numColliders - 1) const swat2 = bodyRef.current?.collider(numColliders - 2) const swat3 = bodyRef.current?.collider(numColliders - 3) setSwatCollider(swat1) setSwatCollider2(swat2) setSwatCollider3(swat3) } }, [bodyRef, setSwatCollider, x, y, angle]) const inBabyJailRef = useRef() const inSwipeModeRef = useRef(undefined) useEffect(() => { return () => { clearTimeout(inBabyJailRef.current) clearTimeout(inSwipeModeRef.current) } }, []) // way to many refs // and function arguments useCollisionHandler( 'start', swatCollider, (ballCollider) => { performSwat( swatCollider, ballCollider, inBabyJailRef, inSwipeModeRef, setImgKey, catMass, ) }, [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider, catMass, bodyRef], ) useCollisionHandler( 'start', swatCollider2, (ballCollider) => { performSwat( swatCollider2, ballCollider, inBabyJailRef, inSwipeModeRef, setImgKey, catMass, ) }, [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider2, catMass, bodyRef], ) useCollisionHandler( 'start', swatCollider3, (ballCollider) => { performSwat( swatCollider3, ballCollider, inBabyJailRef, inSwipeModeRef, setImgKey, catMass, ) }, [setImgKey, inSwipeModeRef, inBabyJailRef, swatCollider3, catMass, bodyRef], ) return ( ) } ================================================ FILE: client/src/components/widgets/CircleGauge.tsx ================================================ import { clamp } from 'lodash' import { SVGAttributes } from 'react' const PI_2 = 2 * Math.PI export function CircleGauge({ value: rawValue, lineWidth = 0.2, ...props }: { value: number; lineWidth?: number } & SVGAttributes) { const value = clamp(rawValue, 0, 1) const largeArc = value > 0.5 ? 1 : 0 const halfLineWidth = lineWidth / 2 const radius = 1 - halfLineWidth const x = radius * Math.sin(value * PI_2) + 1 const y = 1 - radius * Math.cos(value * PI_2) return ( {value === 1 ? ( ) : ( )} ) } ================================================ FILE: client/src/components/widgets/Cup.tsx ================================================ import imgCup from '@art/cup_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export interface CupWidget extends Vector, Angled { type: 'cup' } export function CupPreview() { return } export function Cup({ id, onSelect, x, y, angle }: CupWidget & EditableWidget) { const width = imgCup.width const height = imgCup.height useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Bottom ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 3, height / 12), ) .setTranslation( ...coords.toRapier.vector(0, height / 2 - height / 12), ) .setRestitution(0.9), // Left ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 16, height / 2 - 2), ) .setTranslation(...coords.toRapier.vector(-width / 3, 0)) .setRotation(coords.toRapier.angle(-Math.PI / 16)) .setRestitution(0.9), // Right ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 16, height / 2 - 2), ) .setTranslation(...coords.toRapier.vector(width / 3, 0)) .setRotation(coords.toRapier.angle(Math.PI / 16)) .setRestitution(0.9), ], } }, [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/Cushion.tsx ================================================ import imgCushion from '@art/cushion_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface CushionWidget extends Vector, Angled { type: 'cushion' } export function CushionPreview() { return } export function Cushion({ id, onSelect, x, y, angle, }: CushionWidget & EditableWidget) { const width = imgCushion.width const height = imgCushion.height const widthRatio = width / 328 const heightRatio = height / 157 useCollider( ({ ColliderDesc, CoefficientCombineRule }) => { const vectorConversion = (xIn: number, yIn: number): [number, number] => coords.toRapier.vector( -0.5 * width + xIn * widthRatio, -0.5 * height + yIn * heightRatio, ) return ColliderDesc.roundConvexHull( new Float32Array([ ...vectorConversion(6, 40), ...vectorConversion(112, 10), ...vectorConversion(214, 10), ...vectorConversion(321, 42), ...vectorConversion(317, 55), ...vectorConversion(320, 76), ...vectorConversion(315, 100), ...vectorConversion(322, 114), ...vectorConversion(220, 146), ...vectorConversion(139, 149), ...vectorConversion(10, 120), ...vectorConversion(13, 101), ...vectorConversion(12, 58), ]), coords.toRapier.length(5.5 * widthRatio), )! .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setRestitutionCombineRule(CoefficientCombineRule.Min) .setFrictionCombineRule(CoefficientCombineRule.Max) .setRestitution(0.0) .setFriction(0.999) }, [angle, width, height, x, y, widthRatio, heightRatio], ) return ( ) } ================================================ FILE: client/src/components/widgets/Fan.tsx ================================================ import img1 from '@art/fan-blade1_4x.png' import img2 from '@art/fan-blade2_4x.png' import img3 from '@art/fan-blade3_4x.png' import img4 from '@art/fan-blade4_4x.png' import { coords, vectorDistance } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage, ComicImageAnimation } from '../ComicImage' import { useRigidBody, useSensorInTile } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { TICK_MS, useCollider, usePhysicsLoaded } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface FanWidget extends Vector, Angled { type: 'fan' } const imgs = [img1, img2, img3, img4] export function FanPreview() { return } export default function Fan({ id, onSelect, isSelected, x, y, angle, }: FanWidget & EditableWidget) { const width = img1.width const height = img1.height const bladesWidth = width / 5 const bodyHeight = height / 3 const airOffset = 20 const radius = 10 const length = 300 const strength = 0.05 const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Body ColliderDesc.roundCuboid( ...coords.toRapier.lengths( width / 2 - radius, bodyHeight / 2 - radius, ), coords.toRapier.length(radius), ).setRestitution(0.5), // Blades ColliderDesc.roundCuboid( ...coords.toRapier.lengths( bladesWidth / 2 - radius, height / 2 - radius, ), coords.toRapier.length(radius), ) .setTranslation( ...coords.toRapier.vector(width / 2 - bladesWidth / 2, 0), ) .setRestitution(2.5), ], } }, [angle, bladesWidth, bodyHeight, height, width, x, y], ) const airCollider = useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid( ...coords.toRapier.lengths((length - airOffset) / 2, height / 2), ) .setTranslation( ...coords.toRapier.vector( x + 0.5 * length * Math.cos(angle), y + 0.5 * length * Math.sin(angle), ), ) .setRotation(coords.toRapier.angle(angle)) .setSensor(true), [angle, height, x, y], ) const xComponent = Math.cos(coords.toRapier.angle(angle)) const yComponent = Math.sin(coords.toRapier.angle(angle)) const falloffDistance = coords.toRapier.length(length) useSensorInTile( airCollider, (otherCollider) => { const { current: body } = bodyRef const otherBody = otherCollider.parent() if (!body || !otherBody) { return } const distance = vectorDistance( otherBody.translation(), body.translation(), ) const falloff = Math.max( 0, Math.pow((falloffDistance - distance) / falloffDistance, 2), ) const forceVector = { x: strength * falloff * xComponent, y: strength * falloff * yComponent, } otherBody.applyImpulse(forceVector, true) }, [bodyRef, falloffDistance, xComponent, yComponent], ) const hasPhysics = usePhysicsLoaded() return (
{isSelected && (
)}
) } ================================================ FILE: client/src/components/widgets/Hammer.tsx ================================================ import imgHammer from '@art/hammer_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export interface HammerWidget extends Vector, Angled { type: 'hammer' } export function HammerPreview() { return ( ) } export function Hammer({ id, onSelect, x, y, angle, }: HammerWidget & EditableWidget) { const width = imgHammer.width const height = imgHammer.height const headWidth = 23 const shaftThickness = 10 const handleLength = 30 const handleThickness = 12 const handleRadius = 2 useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Shaft ColliderDesc.cuboid( ...coords.toRapier.lengths(width / 2, shaftThickness / 2), ).setRestitution(0.7), // Handle ColliderDesc.roundCuboid( ...coords.toRapier.lengths( handleLength / 2 - handleRadius, handleThickness / 2 - handleRadius, handleRadius, ), ) .setTranslation( ...coords.toRapier.vector(-width / 2 + handleLength / 2 + 4, 0), ) .setRestitution(0.7), // Head ColliderDesc.cuboid( ...coords.toRapier.lengths(headWidth / 2, height / 2), ) .setTranslation( ...coords.toRapier.vector(width / 2 - headWidth / 2, 0), ) .setRestitution(0.8), ], } }, [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/Hook.tsx ================================================ import { coords } from '../../lib/coords' import { RandallPath } from '../../lib/utils' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useLoopHandler, useRapierEffect } from '../PhysicsContext' import { usePositionedBodyRef } from '../positionStyles' import { lineCuboid } from './lib/lineCuboid' import imgHook from '@art/hook_4x.png' export interface HookWidgetBase extends Vector, Angled { type: string mass?: number restitution?: number damping?: number disableSpring?: boolean isLeft?: boolean } export interface HookWidget extends HookWidgetBase { type: 'hook' } export interface LeftHookWidget extends HookWidgetBase { type: 'lefthook' } export function HookPreview() { return ( ) } export function FlippedHookPreview() { return ( ) } const paths: RandallPath[] = [ // * handle: 1,9 -> 206,9, width: 12 { x1: 1, y1: 9, x2: 190, y2: 9, thickness: 9 }, // * thickboy: 188,9 -> 206,9, width: 12 { x1: 188, y1: 9, x2: 206, y2: 9, thickness: 12 }, // * hookbuddyPt1: 202,16 -> 222,26, width: 6 { x1: 202, y1: 16, x2: 222, y2: 26, thickness: 6 }, // * hookbuddyPt2: 220,26 -> 237,14, width: 6 { x1: 220, y1: 26, x2: 237, y2: 14, thickness: 6 }, // * hookbuddyPt3: 237,2 -> 237,16, width: 6d { x1: 237, y1: 16, x2: 237, y2: 2, thickness: 6 }, ] const xBasis = imgHook.width / 2 const yBasis = imgHook.height / 2 function horizontalMirrorPaths(pathNodes: RandallPath[]): RandallPath[] { return pathNodes.map((value) => { return { x1: imgHook.width - value.x1, y1: value.y1, x2: imgHook.width - value.x2, y2: value.y2, thickness: value.thickness, } }) } const mirrorPaths: RandallPath[] = horizontalMirrorPaths(paths) export default function Hook({ id, onSelect, x, y, mass = 1.3, restitution = 0.1, damping = 0.999, disableSpring = false, isLeft, }: HookWidgetBase & EditableWidget) { const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: id, bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic) .lockTranslations() .setAdditionalMassProperties( // initial mass mass, // initial center of mass isLeft ? coords.toRapier.vectorObject(-170.5 + xBasis, 9.5 - yBasis) : coords.toRapier.vectorObject(170.5 - xBasis, 9.5 - yBasis), // initial angular inertia mass, ) .setAngularDamping(damping) .setCcdEnabled(true), colliderDescs: [ ...(isLeft ? mirrorPaths : paths).map((path) => lineCuboid(ColliderDesc, path, { xBasis, yBasis }) .setRestitution(restitution) .setDensity(0), ), ], } }, [id, mass, isLeft, damping, restitution], ) useLoopHandler( (_) => { const { current: body } = bodyRef if (body == null) { return } if (disableSpring) { body.resetTorques(false) return } const rotation = body.rotation() if (Math.abs(rotation) > 0.001) { const springConstant = -mass * damping body.resetTorques(true) body.applyTorqueImpulse(rotation * springConstant, false) } }, [bodyRef, damping, disableSpring, mass], ) // Update position without recreating so angular momentum is preserved during edits useRapierEffect(() => { const { current: body } = bodyRef if (!body) { return } body.setTranslation(coords.toRapier.vectorObject(x, y), true) }, [bodyRef, x, y]) return (
) } export function LeftHook(props: LeftHookWidget & EditableWidget) { return } ================================================ FILE: client/src/components/widgets/InputOutput.tsx ================================================ import imgRoller1 from '@art/one-roller-1_4x.png' import imgRoller2 from '@art/one-roller-2_4x.png' import imgRoller3 from '@art/one-roller-3_4x.png' import imgRoller4 from '@art/one-roller-4_4x.png' import imgRoller5 from '@art/one-roller-5_4x.png' import type { ColliderDesc } from '@dimforge/rapier2d' import { sample } from 'lodash' import { useMemo } from 'react' import { coords } from '../../lib/coords' import { BallData, BallTypeRate, PuzzlePosition, Vector, isBall, } from '../../types' import { ComicImage } from '../ComicImage' import { useMachine } from '../MachineContext' import { useRigidBody } from '../MachineTileContext' import { useCollider, useCollisionHandler } from '../PhysicsContext' import { INPUT_SPINNER_SIZE, INPUT_SPINNER_SPEED, INPUT_TEETH_COUNT, INPUT_WIDTH, } from '../constants' import { getPositionStyles, usePositionedBodyRef } from '../positionStyles' import { ballClassNames } from './Balls' const imgRollerChoices = [ imgRoller1, imgRoller2, imgRoller3, imgRoller4, imgRoller5, ] export type InputOutputSide = 'left' | 'top' | 'right' | 'bottom' export function positionToSide( position: PuzzlePosition, ): InputOutputSide | undefined { if (position.y > 0 && position.y < 1) { if (position.x === 0) { return 'left' } else if (position.x === 1) { return 'right' } } else { if (position.y === 0) { return 'top' } else if (position.y === 1) { return 'bottom' } } } function Triangle({ className }: { className?: string }) { // Thanks to https://blog.kalehmann.de/blog/2020/09/10/css-centered-equilateral-triangle.html return ( ) } function TypeIndicators({ x, y, balls, side, }: { x: number y: number balls: BallTypeRate[] side: InputOutputSide }) { const size = 12 const offset = 36 return (
{balls.map(({ type }) => ( ))}
) } function Roller({ x, y, rotationSpeed, spokes, }: Vector & { rotationSpeed: number spokes?: number }) { const innerCircleRadius = 14 const img = useMemo(() => sample(imgRollerChoices)!, []) const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType, CoefficientCombineRule, }) => { const toothColliders: ColliderDesc[] = spokes != null ? Array(spokes) .fill(0) .map((_, index) => { return ColliderDesc.cuboid( ...coords.toRapier.lengths(INPUT_SPINNER_SIZE / 2, 0.5), ) .setRotation((index * Math.PI) / spokes) .setRestitution(0.01) .setFriction(1) }) : [] return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.KinematicVelocityBased) .setTranslation(...coords.toRapier.vector(x, y)) .lockTranslations() .setAngvel(rotationSpeed), colliderDescs: [ ColliderDesc.ball(coords.toRapier.length(innerCircleRadius)) .setRestitution(0.01) .setRestitutionCombineRule(CoefficientCombineRule.Min), ...toothColliders, ], } }, [spokes, x, y, rotationSpeed], ) return ( ) } export default function InputOutput({ x, y, side, balls, isInput, onReceiveBall, }: { side: InputOutputSide isInput: boolean onReceiveBall?: (ballData: BallData) => void } & PuzzlePosition) { const { renewBall } = useMachine() const isVertical = side === 'left' || side === 'right' const sensorCollider = useCollider( ({ ColliderDesc, ActiveEvents }) => ColliderDesc.cuboid(...coords.toRapier.lengths(INPUT_WIDTH / 4, 6)) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(isVertical ? Math.PI / 2 : 0) .setSensor(true) .setActiveEvents(ActiveEvents.COLLISION_EVENTS), [isVertical, x, y], ) // Ramp to help feed vertical inputs useCollider( ({ ColliderDesc }) => isInput && isVertical ? ColliderDesc.cuboid(...coords.toRapier.lengths(INPUT_WIDTH / 2, 2)) .setTranslation( ...coords.toRapier.vector( side === 'left' ? x - INPUT_SPINNER_SIZE * 1.5 : x + INPUT_SPINNER_SIZE * 1.5, y - INPUT_SPINNER_SIZE / 3, ), ) .setRotation(side === 'left' ? -Math.PI / 6 : Math.PI / 6) : null, [isInput, isVertical, side, x, y], ) useCollisionHandler( 'start', sensorCollider, (otherCollider) => { const body = otherCollider.parent() if (!sensorCollider || !body) { return } if (!isBall(body)) { return } body.setLinvel({ x: 0, y: 0 }, true) body.setAngvel(0, true) renewBall(body.userData.id) onReceiveBall?.(body.userData) }, [onReceiveBall, renewBall], ) const offset = INPUT_WIDTH / 2 - INPUT_SPINNER_SIZE / 2 const spinDirection = (isInput ? 1 : -1) * (side === 'left' || side === 'bottom' ? -1 : 1) return ( <> {!isInput ? ( ) : null} ) } ================================================ FILE: client/src/components/widgets/LeftBumper.tsx ================================================ import imgBonkOff from '@art/tribonker-off_4x.png' import imgBonkOn from '@art/tribonker-on_4x.png' import { useState } from 'react' import { coords, vectorScale } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage, ComicImageAnimation } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider, useCollisionHandler } from '../PhysicsContext' import { BONK_ANIMATION_DELAY_MS, TRIANGLE_BUMPER_CONTACT_DISTANCE, TRIANGLE_BUMPER_RADIUS_RATIO, TRIANGLE_BUMPER_SENSOR_FUDGE, TRIANGLE_BUMPER_SENSOR_OFFSET, TRIANGLE_BUMPER_STRENGTH, } from '../constants' import { getPositionStyles } from '../positionStyles' export interface LeftBumperWidget extends Vector, Angled { type: 'leftbumper' } const imgs = [imgBonkOff, imgBonkOn] export function LeftBumperPreview() { return } export default function LeftBumper({ id, onSelect, // eslint-disable-next-line @typescript-eslint/no-unused-vars isSelected, x, y, angle, }: LeftBumperWidget & EditableWidget) { const width = imgBonkOff.width const height = imgBonkOff.height const triangleRadius = width * TRIANGLE_BUMPER_RADIUS_RATIO const sensorOffsetRadius = triangleRadius * TRIANGLE_BUMPER_SENSOR_OFFSET const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setCcdEnabled(true), colliderDescs: [ // Body ColliderDesc.roundTriangle( coords.toRapier.vectorObject( 0.5 * width - triangleRadius, -0.5 * height + triangleRadius, ), coords.toRapier.vectorObject( -0.5 * width + triangleRadius, 0.5 * height - triangleRadius, ), coords.toRapier.vectorObject( 0.5 * width - triangleRadius, 0.5 * height - triangleRadius, ), coords.toRapier.length(triangleRadius), ).setRestitution(0.1), ], } }, [angle, triangleRadius, height, width, x, y], ) const bumperCollider = useCollider( ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) => ColliderDesc.roundConvexPolyline( new Float32Array([ ...coords.toRapier.vector( 0.5 * width - triangleRadius * TRIANGLE_BUMPER_SENSOR_FUDGE, -0.5 * height + triangleRadius, ), ...coords.toRapier.vector( -0.5 * width, 0.5 * height - triangleRadius, ), ]), coords.toRapier.length(sensorOffsetRadius), )! .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setSensor(true) .setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ) .setActiveCollisionTypes(ActiveCollisionTypes.ALL), [angle, triangleRadius, sensorOffsetRadius, width, height, x, y], ) const strength = TRIANGLE_BUMPER_STRENGTH const bonkOnMS = BONK_ANIMATION_DELAY_MS const [isContacting, setContacting] = useState(0) useCollisionHandler( 'start', bumperCollider, (otherCollider) => { const otherBody = otherCollider.parent() if (!bumperCollider || !otherBody) { return } const contact = bumperCollider.contactCollider( otherCollider, TRIANGLE_BUMPER_CONTACT_DISTANCE, ) if (contact == null) { return } const forceVector = vectorScale( contact.normal1, strength / Math.max(otherBody.invMass(), Number.MIN_VALUE), ) otherBody.applyImpulseAtPoint(forceVector, contact.point2, true) setContacting( ( isContacting ) => isContacting + 1) setTimeout(() => setContacting(( isContacting ) => Math.max(isContacting - 1, 0)), bonkOnMS) }, [bodyRef, strength, isContacting, bonkOnMS], ) return ( 0 ? 1 : 0} style={getPositionStyles(x, y, angle)} /> ) } ================================================ FILE: client/src/components/widgets/MachineFrame.tsx ================================================ import { groupBy, sortBy } from 'lodash' import { useCallback, useRef } from 'react' import { coords } from '../../lib/coords' import { Puzzle, PuzzlePosition, Sized } from '../../types' import { useMachineTile } from '../MachineTileContext' import { useCollider } from '../PhysicsContext' import { INPUT_WIDTH } from '../constants' import InputOutput, { InputOutputSide, positionToSide } from './InputOutput' import OutputValidator from './OutputValidator' import SpawnInput from './SpawnInput' function Line({ left, top, width, height, }: { left: number; top: number } & Sized) { useCollider( ({ ColliderDesc }) => ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2)) .setTranslation( ...coords.toRapier.vector(left + width / 2, top + height / 2), ) .setRestitution(0.5), [height, width, left, top], ) return (
) } function hLinesWithBreaks( positions: PuzzlePosition[], offsetX: number, y: number, width: number, ) { const line = [] let left = 0 const sortedPositions = sortBy(positions, (p) => p.x) for (let i = 0; i < sortedPositions.length; i++) { const { x } = sortedPositions[i] line.push( , ) left = x * width + INPUT_WIDTH / 2 } line.push( , ) return line } function vLinesWithBreaks( positions: PuzzlePosition[], x: number, offsetY: number, height: number, ) { const line = [] let top = 0 const sortedPositions = sortBy(positions, (p) => p.y) for (let i = 0; i < sortedPositions.length; i++) { const { y } = sortedPositions[i] line.push( , ) top = y * height + INPUT_WIDTH / 2 } line.push( , ) return line } export default function MachineFrame({ inputs, outputs, title, spawnBallsTop, spawnBallsLeft, spawnBallsRight, validateOutputs, onValidate, }: Pick & { title?: string | undefined spawnBallsTop?: boolean spawnBallsLeft?: boolean spawnBallsRight?: boolean validateOutputs?: boolean onValidate?: (isValid: boolean) => void }) { const { bounds: [offsetX, offsetY], width, height, } = useMachineTile() const outputStateMap = useRef>({}) const prevValid = useRef(false) const handleValidate = useCallback( (id: string, isValid: boolean) => { outputStateMap.current[id] = isValid const isAllValid = Object.values(outputStateMap.current).every(Boolean) if (isAllValid !== prevValid.current) { onValidate?.(isAllValid) prevValid.current = isAllValid } }, [onValidate], ) const OutputComponent = validateOutputs ? OutputValidator : InputOutput const inputGroups = groupBy(inputs, positionToSide) const outputGroups = groupBy(outputs, positionToSide) function renderInputs( side: InputOutputSide, sideInputs: PuzzlePosition[] | undefined, ) { if (!sideInputs) { return null } return sideInputs.map(({ x, y, balls }, idx) => ( )) } return ( <> {title && (
“{title}”
)} {spawnBallsTop && renderInputs('top', inputGroups['top'])} {spawnBallsLeft && renderInputs('left', inputGroups['left'])} {spawnBallsRight && renderInputs('right', inputGroups['right'])} {Object.entries(outputGroups).flatMap(([side, outputs]) => outputs.map(({ x, y, balls }, idx) => ( )), )} {hLinesWithBreaks(inputGroups.top ?? [], offsetX, offsetY, width)} {hLinesWithBreaks( outputGroups.bottom ?? [], offsetX, offsetY + height - 1, width, )} {vLinesWithBreaks( [...(inputGroups.left ?? []), ...(outputGroups.left ?? [])], offsetX, offsetY, height, )} {vLinesWithBreaks( [...(inputGroups.right ?? []), ...(outputGroups.right ?? [])], offsetX + width - 1, offsetY, height, )} ) } ================================================ FILE: client/src/components/widgets/OutputValidator.tsx ================================================ import { useCallback, useEffect, useMemo, useState } from 'react' import { BallData, PuzzlePosition } from '../../types' import { ComicImageAnimation } from '../ComicImage' import { useMachine } from '../MachineContext' import { getPositionStyles } from '../positionStyles' import InputOutput, { InputOutputSide } from './InputOutput' import { BALL_RATE_VARIANCE } from './SpawnInput' import imgCheck from '@art/check-circle_4x.png' import imgWrong from '@art/wrong-circle_4x.png' import { sumBy } from 'lodash' import { CircleGauge } from './CircleGauge' const imgs = [imgCheck, imgWrong] const UPDATE_MS = 1000 / 15 const RATE_WINDOW_MS = 10 * 1000 export default function OutputValidator({ x, y, balls: balls, side, id, onValidate, }: { side: InputOutputSide id: string onValidate: (id: string, isValid: boolean) => void } & PuzzlePosition) { const { msPerBall, exitBall } = useMachine() const [gaugeLevel, setGaugeLevel] = useState(0) const expectedTypes = useMemo( () => new Set(balls.map(({ type }) => type)), [balls], ) const totalRate = sumBy(balls, ({ rate }) => rate) const msPerSpawn = msPerBall / totalRate const maxGauge = RATE_WINDOW_MS / msPerSpawn // Allow the gauge to fill 2 balls (+ expected variance) past 100%, so it doesn't immediately drop between receiving balls. const maxGaugeOverfill = maxGauge + 3 + 0.5 * BALL_RATE_VARIANCE const gaugePercent = gaugeLevel / maxGauge const isValid = gaugePercent >= 1 const handleReceiveBall = useCallback( (ballData: BallData) => { const delta = expectedTypes.has(ballData.ballType) ? 1 : -1 setGaugeLevel((curLevel) => // Add 1 + the expected 1 the RATE_WINDOW_MS will decay per ball. Math.min(curLevel + delta * 2, maxGaugeOverfill), ) exitBall(ballData.id) }, [exitBall, expectedTypes, maxGaugeOverfill], ) useEffect(() => { const interval = setInterval(() => { // The gauge decays fully after RATE_WINDOW_MS. setGaugeLevel((curLevel) => Math.max(0, curLevel - maxGauge * (UPDATE_MS / RATE_WINDOW_MS)), ) }, UPDATE_MS) return () => { clearInterval(interval) } }, [maxGauge, msPerSpawn]) useEffect(() => { onValidate?.(id, isValid) }, [id, isValid, onValidate]) return ( <>
) } ================================================ FILE: client/src/components/widgets/Prism.tsx ================================================ import { useCallback } from 'react' import { coords } from '../../lib/coords' import { Angled, Ball, Vector, isBall } from '../../types' import { ComicImage } from '../ComicImage' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' import imgPrism from '@art/prism_4x.png' import type { Collider, RigidBody } from '@dimforge/rapier2d' import { Basis } from '../../lib/utils' import { useCollider, useCollisionHandler } from '../PhysicsContext' export interface PrismWidget extends Vector, Angled { type: 'prism' } export function PrismPreview() { return } export function pointToVectorObject( { xBasis, yBasis }: Basis, x: number, y: number, ) { return coords.toRapier.vectorObject(x - xBasis, y - yBasis) } const width = imgPrism.width const height = imgPrism.height const MIN_CONTACT_DISTANCE = 0.1 const PRISM_REFRACTION_INDEX = 2.0 function setRefractionVector( // 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 indexRatio: (ball: Ball) => number, // new speed is equal to old speed multiplied by this speedMult: (ball: Ball) => number, // prism prismCollider: Collider | undefined, otherCollider: Collider, // are you joining the prism party? or leaving? headingIn: boolean, // stuff prints when this is non-null logString?: string, ) { const ball: RigidBody | null = otherCollider.parent() if (ball == null || !isBall(ball) || prismCollider == null) { return null } const contact = prismCollider.contactCollider( otherCollider, MIN_CONTACT_DISTANCE, ) if (contact == null) { return null } // I did a lot of holding up two pencils U_U // normal1 points towards the 'outside' of the prism, and normal2 points inside // so when you're coming into the prism you want normal1 and when you're going out you want normal2 // (or the reverse and then you don't invert the velocity vector) const normal = headingIn ? contact.normal1 : contact.normal2 const invNormal = headingIn ? contact.normal2 : contact.normal1 const ballVellocity = ball.linvel() const ballMass = ball.mass() const surfaceAngle = Math.atan2(normal.y, normal.x) // please make sure all vectors are fastened into their seatbelts and pointed in the same direction as the normals const velocityAngle = Math.atan2( -1.0 * ballVellocity.y, -1.0 * ballVellocity.x, ) // convert to surface normal coordinate system const angleInc = velocityAngle - surfaceAngle // sin(incident) / sin(refraction) = index destination / index start const angleRefr = Math.asin( // clamp to the domain of asin *just in case* Math.max(Math.min(Math.sin(angleInc) / indexRatio(ball), 1), -1), ) if (logString) { console.log(`** ${logString} surfaceNormal: ${contact.normal1.x.toFixed(3)}, ${contact.normal1.y.toFixed(3)} surfaceNormal2: ${contact.normal2.x.toFixed(3)}, ${contact.normal2.y.toFixed(3)} prismRot: ${(prismCollider.rotation() / Math.PI).toFixed(3)} surfNorm: x:${normal.x.toFixed(3)}, y:${normal.y.toFixed(3)} surfAngle/pi: ${(surfaceAngle / Math.PI).toFixed(3)} velocityAngle/pi: ${(velocityAngle / Math.PI).toFixed(3)} ballType: ${ball.userData.ballType} ballMass: ${ballMass} angleInc/pi: ${(angleInc / Math.PI).toFixed(3)} angleRefr/pi: ${(angleRefr / Math.PI).toFixed(3)} ballVelocity: ${ballVellocity.x.toFixed(3)}, ${ballVellocity.y.toFixed(3)} indexRatio: ${indexRatio(ball)} `) } const newX = invNormal.x * Math.cos(angleRefr) - invNormal.y * Math.sin(angleRefr) const newY = invNormal.x * Math.sin(angleRefr) + invNormal.y * Math.cos(angleRefr) const speed = Math.sqrt(ballVellocity.x ** 2 + ballVellocity.y ** 2) * speedMult(ball) if (logString) { console.log(`<<${logString} new normal: x:${newX.toFixed(3)}, y:${newY.toFixed(3)} >>`) } ball.setLinvel({ x: newX * speed, y: newY * speed }, true) } export default function Prism({ id, onSelect, x, y, angle, }: PrismWidget & EditableWidget) { const collider = useCollider( ({ ColliderDesc, ActiveEvents }) => { const vector = pointToVectorObject.bind(undefined, { xBasis: width / 2.0, yBasis: height / 2.0, }) return ColliderDesc.triangle( vector(0, height), vector(width, height), vector(width / 2.0, 0), ) .setSensor(true) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ) }, [x, y, angle], ) const collisionStart = useCallback( (otherCollider: Collider) => { setRefractionVector( // TODO: base on ball properties (_) => PRISM_REFRACTION_INDEX, // TODO: idk if we want to be able to speed up or slow down the balls, but it is possible here (_) => 1.0, collider, otherCollider, true, ) }, [collider], ) const collisionEnd = useCallback( (otherCollider: Collider) => { setRefractionVector( // TODO: see above (_) => 1.0 / PRISM_REFRACTION_INDEX, (_) => 1.0, collider, otherCollider, false, ) }, [collider], ) useCollisionHandler('start', collider, collisionStart, []) useCollisionHandler('end', collider, collisionEnd, []) return ( ) } ================================================ FILE: client/src/components/widgets/QuantumGate.tsx ================================================ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { coords, vectorAngle, vectorDistance, vectorMagnitude } from '../../lib/coords' import { Sized, Vector } from '../../types' import { useSensorInTile } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider } from '../PhysicsContext' import { getPositionStyles } from '../positionStyles' export interface QuantumGateSlowWidget extends Vector, Sized { type: 'quantumgateslow' } export interface QuantumGateFastWidget extends Vector, Sized { type: 'quantumgatefast' } const throb1 = ` @keyframes throb1 { 0% { background: radial-gradient(circle at center, purple 0, orange 8px) } 6% { background: radial-gradient(circle at center, purple 0, orange 9px) } 12% { background: radial-gradient(circle at center, purple 0, orange 10px) } 18% { background: radial-gradient(circle at center, purple 0, orange 11px) } 25% { background: radial-gradient(circle at center, purple 0, orange 12px) } 34% { background: radial-gradient(circle at center, purple 0, orange 13px) } 50% { background: radial-gradient(circle at center, purple 0, orange 14px) } 64% { background: radial-gradient(circle at center, purple 0, orange 13px) } 75% { background: radial-gradient(circle at center, purple 0, orange 12px) } 81% { background: radial-gradient(circle at center, purple 0, orange 11px) } 87% { background: radial-gradient(circle at center, purple 0, orange 10px) } 93% { background: radial-gradient(circle at center, purple 0, orange 9px) } 100% { background: radial-gradient(circle at center, purple 0, orange 8px) } } ` const throb2 = ` @keyframes throb2 { 0% { background: radial-gradient(circle at center, cyan 0, red 8px) } 6% { background: radial-gradient(circle at center, cyan 0, red 9px) } 12% { background: radial-gradient(circle at center, cyan 0, red 10px) } 18% { background: radial-gradient(circle at center, cyan 0, red 11px) } 25% { background: radial-gradient(circle at center, cyan 0, red 12px) } 34% { background: radial-gradient(circle at center, cyan 0, red 13px) } 50% { background: radial-gradient(circle at center, cyan 0, red 14px) } 64% { background: radial-gradient(circle at center, cyan 0, red 13px) } 75% { background: radial-gradient(circle at center, cyan 0, red 12px) } 81% { background: radial-gradient(circle at center, cyan 0, red 11px) } 87% { background: radial-gradient(circle at center, cyan 0, red 10px) } 93% { background: radial-gradient(circle at center, cyan 0, red 9px) } 100% { background: radial-gradient(circle at center, cyan 0, red 8px) } } ` export function QuantumGateSlowPreview() { return (
) } export function QuantumGateFastPreview() { return (
) } export function QuantumGate({ id, onSelect, isSelected, className, x, y, width, strength, speedLimit, }: Vector & Sized & EditableWidget & { className?: string; strength: number; speedLimit: number }) { const fieldSize = width const radius = 10 const boxCollider = useCollider( ({ ColliderDesc }) => ColliderDesc.ball(coords.toRapier.length(radius)) .setTranslation(...coords.toRapier.vector(x, y)) .setRestitution(0.5) .setSensor(true), [radius, x, y], ) const fieldCollider = useCollider( ({ ColliderDesc }) => ColliderDesc.ball(coords.toRapier.length(fieldSize)) .setTranslation(...coords.toRapier.vector(x, y)) .setSensor(true), [fieldSize, x, y], ) const falloffDistance = coords.toRapier.length(fieldSize) useSensorInTile( fieldCollider, (otherCollider) => { const body = otherCollider.parent() if (!boxCollider || !body) { return } const speedLimitRapier = coords.toRapier.length( speedLimit ) const speed = vectorMagnitude( body.linvel() ) * Math.sign( speedLimit ) if ( speed < speedLimitRapier ) { const distance = vectorDistance( boxCollider.translation(), body.translation(), ) const angle = vectorAngle(boxCollider.translation(), body.translation()) const falloff = Math.max( 0, Math.pow((falloffDistance - distance) / falloffDistance, 3), ) const forceVector = { x: strength * falloff * Math.cos(angle), y: strength * falloff * Math.sin(angle), } body.applyImpulse(forceVector, true) } }, [boxCollider, falloffDistance, strength, speedLimit], ) return (
) } export function QuantumGateSlow(props: QuantumGateSlowWidget & EditableWidget) { return (
) } export function QuantumGateFast(props: QuantumGateFastWidget & EditableWidget) { return (
) } ================================================ FILE: client/src/components/widgets/RightBumper.tsx ================================================ import imgBonkOff from '@art/mirrortribonker-off_4x.png' import imgBonkOn from '@art/mirrortribonker-on_4x.png' import { useState } from 'react' import { coords, vectorScale } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage, ComicImageAnimation } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider, useCollisionHandler } from '../PhysicsContext' import { BONK_ANIMATION_DELAY_MS, TRIANGLE_BUMPER_CONTACT_DISTANCE, TRIANGLE_BUMPER_RADIUS_RATIO, TRIANGLE_BUMPER_SENSOR_FUDGE, TRIANGLE_BUMPER_SENSOR_OFFSET, TRIANGLE_BUMPER_STRENGTH, } from '../constants' import { getPositionStyles } from '../positionStyles' export interface RightBumperWidget extends Vector, Angled { type: 'rightbumper' } const imgs = [imgBonkOff, imgBonkOn] const strength = TRIANGLE_BUMPER_STRENGTH const bonkOnMs = BONK_ANIMATION_DELAY_MS export function RightBumperPreview() { return } export default function RightBumper({ id, onSelect, // eslint-disable-next-line @typescript-eslint/no-unused-vars isSelected, x, y, angle, }: RightBumperWidget & EditableWidget) { const width = imgBonkOff.width const height = imgBonkOff.height const triangleRadius = width * TRIANGLE_BUMPER_RADIUS_RATIO const sensorOffsetRadius = triangleRadius * TRIANGLE_BUMPER_SENSOR_OFFSET const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setCcdEnabled(true), colliderDescs: [ // Body ColliderDesc.roundTriangle( coords.toRapier.vectorObject( -0.5 * width + triangleRadius, -0.5 * height + triangleRadius, ), coords.toRapier.vectorObject( 0.5 * width - triangleRadius, 0.5 * height - triangleRadius, ), coords.toRapier.vectorObject( -0.5 * width + triangleRadius, 0.5 * height - triangleRadius, ), coords.toRapier.length(triangleRadius), ).setRestitution(0.1), ], } }, [angle, triangleRadius, height, width, x, y], ) const bumperCollider = useCollider( ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) => ColliderDesc.roundConvexPolyline( new Float32Array([ ...coords.toRapier.vector( -0.5 * width + triangleRadius * TRIANGLE_BUMPER_SENSOR_FUDGE, -0.5 * height + triangleRadius, ), ...coords.toRapier.vector(0.5 * width, 0.5 * height - triangleRadius), ]), coords.toRapier.length(sensorOffsetRadius), )! .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)) .setSensor(true) .setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ) .setActiveCollisionTypes(ActiveCollisionTypes.ALL), [angle, triangleRadius, sensorOffsetRadius, width, height, x, y], ) const [isContacting, setContacting] = useState(0) useCollisionHandler( 'start', bumperCollider, (otherCollider) => { const otherBody = otherCollider.parent() if (!bumperCollider || !otherBody) { return } const contact = bumperCollider.contactCollider( otherCollider, TRIANGLE_BUMPER_CONTACT_DISTANCE, ) if (contact == null) { return } const forceVector = vectorScale( contact.normal1, strength / Math.max(otherBody.invMass(), Number.MIN_VALUE), ) otherBody.applyImpulseAtPoint(forceVector, contact.point2, true) setContacting( ( isContacting ) => isContacting + 1) setTimeout(() => setContacting( ( isContacting ) => Math.max(isContacting - 1, 0)), bonkOnMs) }, [bodyRef, strength, isContacting, bonkOnMs], ) return ( 0 ? 1 : 0} style={getPositionStyles(x, y, angle)} /> ) } ================================================ FILE: client/src/components/widgets/RoundBumper.tsx ================================================ import imgBonkOff from '@art/bonker-off_4x.png' import imgBonkOn from '@art/bonker-on_4x.png' import { useState } from 'react' import { coords, vectorDifference, vectorNorm, vectorScale, } from '../../lib/coords' import { Vector, isBall } from '../../types' import { ComicImage, ComicImageAnimation } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useCollider, useCollisionHandler } from '../PhysicsContext' import { BONK_ANIMATION_DELAY_MS, TRIANGLE_BUMPER_CONTACT_DISTANCE as BUMPER_CONTACT_DISTANCE, ROAND_BUMPER_RADIUS_RATIO, ROUND_BUMPER_FALLBACK_RATIO, ROUND_BUMPER_STRENGTH, } from '../constants' import { getPositionStyles } from '../positionStyles' export interface RoundBumperWidget extends Vector { type: 'roundbumper' } const imgs = [imgBonkOff, imgBonkOn] const strength = ROUND_BUMPER_STRENGTH const bonkOnMs = BONK_ANIMATION_DELAY_MS export function RoundBumperPreview() { return } export default function RoundBumper({ id, onSelect, // eslint-disable-next-line @typescript-eslint/no-unused-vars isSelected, x, y, }: RoundBumperWidget & EditableWidget) { const width = imgBonkOff.width const bumperRadius = width * ROAND_BUMPER_RADIUS_RATIO const bodyRef = useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setCcdEnabled(true), colliderDescs: [ ColliderDesc.capsule( 0, coords.toRapier.length(bumperRadius * ROUND_BUMPER_FALLBACK_RATIO), ).setRestitution(1), ], } }, [bumperRadius, x, y], ) const bumperCollider = useCollider( ({ ColliderDesc, ActiveEvents, ActiveCollisionTypes }) => ColliderDesc.capsule(0, coords.toRapier.length(bumperRadius)) .setTranslation(...coords.toRapier.vector(x, y)) .setSensor(true) .setActiveEvents( ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS, ) .setActiveCollisionTypes(ActiveCollisionTypes.ALL), [bumperRadius, x, y], ) const [isContacting, setContacting] = useState(0) useCollisionHandler( 'start', bumperCollider, (otherCollider) => { const { current: body } = bodyRef if (!bumperCollider || !body) { return } const otherBody = otherCollider.parent() if (!otherBody) { return } const contact = bumperCollider.contactCollider( otherCollider, BUMPER_CONTACT_DISTANCE, ) if (contact !== null) { const bodyIsBall = isBall(otherBody) otherBody.applyImpulseAtPoint( vectorScale( vectorNorm( vectorDifference( otherCollider.translation(), bumperCollider.translation(), ), ), strength / Math.max( bodyIsBall ? otherBody.invMass() : otherBody.mass(), Number.MIN_VALUE, ), ), contact?.point2, true, ) setContacting((isContacting) => isContacting + 1) setTimeout( () => setContacting((isContacting) => Math.max(isContacting - 1, 0)), bonkOnMs, ) } }, [bodyRef, strength, isContacting, bonkOnMs], ) return ( 0 ? 1 : 0} style={getPositionStyles(x, y, 0)} /> ) } ================================================ FILE: client/src/components/widgets/SpawnInput.tsx ================================================ import { sumBy } from 'lodash' import { useCallback, useRef } from 'react' import weighted from 'weighted' import { PuzzlePosition } from '../../types' import { CreateBallOptions, useMachine } from '../MachineContext' import { PhysicsContextType, useLoopHandler, useRapierEffect, } from '../PhysicsContext' import { BALL_RADIUS, INPUT_SPINNER_SIZE } from '../constants' import InputOutput, { InputOutputSide } from './InputOutput' export const BALL_RATE_VARIANCE = 1 // 50% either direction export function BallSpawner({ x, y, vx, vy, overrideDamping, balls, }: PuzzlePosition & CreateBallOptions) { const { createBall, msPerBall } = useMachine() const nextBallTick = useRef(0) const scheduleNextBall = useCallback( ({ tickMs, getCurrentTick }: PhysicsContextType) => { const totalRate = sumBy(balls, ({ rate }) => rate) const msPerSpawn = msPerBall / totalRate const periodTicks = msPerSpawn / tickMs const variance = 1 + (0.5 - Math.random()) * BALL_RATE_VARIANCE nextBallTick.current = getCurrentTick() + Math.round(periodTicks * variance) }, [msPerBall, balls], ) useRapierEffect(scheduleNextBall, [scheduleNextBall]) useLoopHandler( (physics) => { const currentTick = physics.getCurrentTick() if (currentTick >= nextBallTick.current) { const { type } = weighted( balls, balls.map(({ rate }) => rate), ) createBall(x, y, type, { vx, vy, overrideDamping }) scheduleNextBall(physics) return } }, [createBall, overrideDamping, scheduleNextBall, balls, vx, vy, x, y], ) return null } export default function SpawnInput({ x, y, balls, side, }: { side: InputOutputSide } & PuzzlePosition) { // Side inputs are tricky since we don't have gravity. We spawn the ball to the side with a line sloping down. const sideSpawnOffset = 0.75 * INPUT_SPINNER_SIZE const xOffset = side === 'left' ? -sideSpawnOffset : side === 'right' ? sideSpawnOffset : 0 const yOffset = side === 'top' ? -BALL_RADIUS : side === 'bottom' ? BALL_RADIUS : -BALL_RADIUS * 2 return ( <> ) } ================================================ FILE: client/src/components/widgets/Sticker.tsx ================================================ import imgCat from '@art/cat_4x.png' import imgDeterminism from '@art/determinism-sign_4x.png' import imgFigure1Happy from '@art/figure1-happy_4x.png' import imgFigure1Sad from '@art/figure1-sad_4x.png' import imgFigure2Happy from '@art/figure2-happy_4x.png' import imgFigure2Sad from '@art/figure2-sad_4x.png' import imgFigure3Happy from '@art/figure3-happy_4x.png' import imgFigure3Sad from '@art/figure3-sad_4x.png' import imgKingbun from '@art/kingbun_4x.png' import imgKnievel from '@art/knievel_4x.png' import imgSquobject from '@art/squobject_4x.png' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export const stickers = { 'figure1-happy': imgFigure1Happy, 'figure1-sad': imgFigure1Sad, 'figure2-happy': imgFigure2Happy, 'figure2-sad': imgFigure2Sad, 'figure3-happy': imgFigure3Happy, 'figure3-sad': imgFigure3Sad, knievel: imgKnievel, squobject: imgSquobject, determinism: imgDeterminism, kingbun: imgKingbun, cat: imgCat, } as const export type StickerName = keyof typeof stickers const smolStickers = new Set(['cat', 'kingbun']) export interface StickerWidget extends Vector, Angled { type: 'sticker' sticker: StickerName } export function StickerPreview({ sticker }: { sticker: StickerName }) { return ( ) } export function Sticker({ id, onSelect, x, y, angle, sticker, }: StickerWidget & EditableWidget) { return ( ) } ================================================ FILE: client/src/components/widgets/Sword.tsx ================================================ import imgSword from '@art/sword_4x.png' import { coords } from '../../lib/coords' import { Angled, Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { getPositionStyles } from '../positionStyles' export interface SwordWidget extends Vector, Angled { type: 'sword' } export function SwordPreview() { return ( ) } export function Sword({ id, onSelect, x, y, angle, }: SwordWidget & EditableWidget) { const width = imgSword.width const height = imgSword.height const guardWidth = 9 const guardOffset = 35 const handleThickness = 10 const tipLength = handleThickness useRigidBody( ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { return { key: null, bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed) .setTranslation(...coords.toRapier.vector(x, y)) .setRotation(coords.toRapier.angle(angle)), colliderDescs: [ // Handle ColliderDesc.cuboid( ...coords.toRapier.lengths( width / 2 - tipLength / 2, handleThickness / 2, ), ) .setTranslation(...coords.toRapier.vector(-tipLength, 0)) .setRestitution(0.9), // Tip ColliderDesc.triangle( coords.toRapier.vectorObject( width / 2 - tipLength, -handleThickness / 2, ), coords.toRapier.vectorObject(width / 2, 0), coords.toRapier.vectorObject( width / 2 - tipLength, handleThickness / 2, ), ).setRestitution(0.9), // Guard ColliderDesc.cuboid( ...coords.toRapier.lengths(guardWidth / 2, height / 2), ) .setTranslation(...coords.toRapier.vector(-guardOffset, 0)) .setRestitution(0.9), ], } }, [angle, height, width, x, y], ) return ( ) } ================================================ FILE: client/src/components/widgets/Wheel.tsx ================================================ import { coords } from '../../lib/coords' import { Vector } from '../../types' import { ComicImage } from '../ComicImage' import { useRigidBody } from '../MachineTileContext' import { EditableWidget, useSelectHandlers } from '../MachineTileEditor' import { useRapierEffect } from '../PhysicsContext' import { usePositionedBodyRef } from '../positionStyles' import imgWheel from '@art/spoked-wheel_4x.png' import type { ColliderDesc } from '@dimforge/rapier2d' import { useRef } from 'react' import { rotate } from '../../lib/utils' import { pointToVectorObject } from './Prism' export interface SpokedWheelWidget extends Vector { type: 'spokedwheel' speed: number } export function SpokedWheelPreview() { return } export function getNextWheelSpeed(wheel: SpokedWheelWidget, key: string) { const currentSpeed = wheel.speed if ( key === 'arrowleft' || (key === 'arrowdown' && currentSpeed < 0) || (key === 'arrowup' && currentSpeed > 0) ) { return currentSpeed + 1 } else if ( key === 'arrowright' || (key === 'arrowup' && currentSpeed < 0) || (key === 'arrowdown' && currentSpeed > 0) ) { return currentSpeed - 1 } else { return null } } // I think this is a really ugly bag of constants and object descriptions const OUTER_WHEEL_RADIUS = 40.0 const WHEEL_TOP = 38 const WHEEL_LEFT = 79 const NUM_SPOKES = 7 const spokeCapsule = { boundRect: { width: 9.0, height: 10.0 }, distanceFromTop: 12.0, } const upperConvexPiece = { x: 76, y: 18, topWidth: 7, bottomWidth: 4.5, height: 11.0, } // typing it liuke this is pretty dumb type Description = { readonly [P in keyof T]: T[P] } type HullDescription = Description const bottomConvexPiece: HullDescription = { x: 77, y: 28, topWidth: 4.5, bottomWidth: 8, height: 10, } /* * the spokes are made from a capsule (the top) and then two convex hulls * looking something like this: * * ^ * | | * v * \ / * / \ * */ function makeConvexHull(hullDesc: HullDescription, rotationAngle: number) { const { x, y, topWidth, bottomWidth, height } = hullDesc rotate.bind(undefined, rotationAngle) const makePoint = (x: number, y: number) => rotate(rotationAngle, pointToVectorObject({ xBasis, yBasis }, x, y)) const bottomXAdjust = (topWidth - bottomWidth) / 2.0 return [ makePoint(x, y), makePoint(x + topWidth, y), makePoint(x + bottomXAdjust, y + height), makePoint(x + bottomXAdjust + bottomWidth, y + height), ].flatMap(({ x, y }) => [x, y]) } function makeSpoke(index: number, colliderDesc: typeof ColliderDesc) { const rotationAngle = (index * 2.0 * Math.PI) / NUM_SPOKES const translationRotation = rotate( rotationAngle, coords.toRapier.vectorObject(0, spokeCapsule.distanceFromTop, { xBasis: 0, yBasis: yBasis, }), ) return [ colliderDesc .capsule( ...coords.toRapier.lengths( spokeCapsule.boundRect.height / 2.0, spokeCapsule.boundRect.width / 2.0, ), ) .setRotation(rotationAngle) .setTranslation(translationRotation.x, translationRotation.y), colliderDesc.convexHull( new Float32Array(makeConvexHull(upperConvexPiece, rotationAngle)), ), colliderDesc.convexHull( new Float32Array(makeConvexHull(bottomConvexPiece, rotationAngle)), ), ] } const xBasis = imgWheel.width / 2 const yBasis = imgWheel.height / 2 export default function SpokedWheel({ id, onSelect, x, y, isSelected, speed, }: SpokedWheelWidget & EditableWidget) { const bodyRef = useRigidBody( // @ts-expect-error mad about possibly null convexHull. dont worrty though im filtering null values out ({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => { const wheelCenter = coords.toRapier.vectorObject( WHEEL_LEFT, WHEEL_TOP + OUTER_WHEEL_RADIUS, { xBasis, yBasis, }, ) return { key: id, bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic) .lockTranslations() .setAdditionalMassProperties(2, wheelCenter, 2) .setAngularDamping(1), colliderDescs: [ ...Array(NUM_SPOKES) .fill(0) .flatMap((_, index) => { return makeSpoke(index, ColliderDesc) }) .filter((v) => v != null), ColliderDesc.ball( coords.toRapier.length(OUTER_WHEEL_RADIUS), ).setTranslation(wheelCenter.x, wheelCenter.y), ], } }, [id], ) // Update position without recreating so angular momentum is preserved during edits useRapierEffect(() => { const { current: body } = bodyRef if (!body) { return } body.setTranslation(coords.toRapier.vectorObject(x, y), true) }, [bodyRef, x, y]) const selectedRef = useRef(isSelected) selectedRef.current = isSelected // separate effect for setting speed so we're not resetting torque when we translate useRapierEffect(() => { const { current: body } = bodyRef if (!body) { return } body.resetTorques(true) body.addTorque(speed, true) }, [bodyRef, speed]) return ( ) } ================================================ FILE: client/src/components/widgets/index.tsx ================================================ import assertNever from 'assert-never' import { mapValues } from 'lodash' import React, { FC } from 'react' import { WidgetCollection } from '../../types' import { useMachineTile } from '../MachineTileContext' import { EditableWidget } from '../MachineTileEditor' import { Anvil, AnvilPreview, AnvilWidget } from './Anvil' import { Attractor, AttractorPreview, AttractorWidget, Repulsor, RepulsorPreview, RepulsorWidget, } from './AttractorRepulsor' import { BallStand, BallStandPreview, BallStandWidget } from './BallStand' import { Board, BoardPreview, BoardWidget } from './Board' import { Brick, BrickPreview, BrickWidget } from './Brick' import { CatSwat, CatSwatPreview, CatSwatWidget } from './CatSwat' import { Cup, CupPreview, CupWidget } from './Cup' import { Cushion, CushionPreview, CushionWidget } from './Cushion' import Fan, { FanPreview, FanWidget } from './Fan' import { Hammer, HammerPreview, HammerWidget } from './Hammer' import Hook, { FlippedHookPreview, HookPreview, HookWidget, LeftHook, LeftHookWidget, } from './Hook' import LeftBumper, { LeftBumperPreview, LeftBumperWidget } from './LeftBumper' import Prism, { PrismPreview, PrismWidget } from './Prism' import RightBumper, { RightBumperPreview, RightBumperWidget, } from './RightBumper' import RoundBumper, { RoundBumperPreview, RoundBumperWidget, } from './RoundBumper' import { Sticker, StickerName, StickerPreview, StickerWidget, stickers, } from './Sticker' import { Sword, SwordPreview, SwordWidget } from './Sword' import SpokedWheel, { SpokedWheelPreview, SpokedWheelWidget } from './Wheel' export type WidgetData = | BoardWidget | BrickWidget | HammerWidget | SwordWidget | AnvilWidget | FanWidget | AttractorWidget | RepulsorWidget | LeftBumperWidget | RightBumperWidget | HookWidget | LeftHookWidget | RoundBumperWidget | CushionWidget | PrismWidget //| QuantumGateSlowWidget //| QuantumGateFastWidget | SpokedWheelWidget | BallStandWidget | CupWidget | StickerWidget | CatSwatWidget export type WidgetType = WidgetData['type'] export interface PaletteItem { preview: FC create: (x: number, y: number) => T canRotate?: boolean canResize?: boolean isSquare?: boolean } export const widgetList: Partial>> = { board: { preview: BoardPreview, create: (x: number, y: number) => ({ type: 'board', x, y, angle: 0, }), canRotate: true, }, hammer: { preview: HammerPreview, create: (x: number, y: number) => ({ type: 'hammer', x, y, angle: 0, }), canRotate: true, }, sword: { preview: SwordPreview, create: (x: number, y: number) => ({ type: 'sword', x, y, angle: 0, }), canRotate: true, }, hook: { preview: HookPreview, create: (x: number, y: number) => ({ type: 'hook', x, y, angle: 0, }), }, lefthook: { preview: FlippedHookPreview, create: (x: number, y: number) => ({ type: 'lefthook', x, y, angle: 0, }), }, anvil: { preview: AnvilPreview, create: (x: number, y: number) => ({ type: 'anvil', x, y, angle: 0, }), canRotate: true, }, brick: { preview: BrickPreview, create: (x: number, y: number) => ({ type: 'brick', x, y, angle: 0, }), canRotate: true, }, fan: { preview: FanPreview, create: (x: number, y: number) => ({ type: 'fan', x, y, angle: 0, }), canRotate: true, }, cushion: { preview: CushionPreview, create: (x: number, y: number) => ({ type: 'cushion', x, y, angle: 0, }), canRotate: true, }, roundbumper: { preview: RoundBumperPreview, create: (x: number, y: number) => ({ type: 'roundbumper', x, y, }), }, rightbumper: { preview: RightBumperPreview, create: (x: number, y: number) => ({ type: 'rightbumper', x, y, angle: 0, }), canRotate: true, }, leftbumper: { preview: LeftBumperPreview, create: (x: number, y: number) => ({ type: 'leftbumper', x, y, angle: 0, }), canRotate: true, }, attractor: { preview: AttractorPreview, create: (x: number, y: number) => ({ type: 'attractor', x, y, width: 150, height: 10, }), canResize: true, isSquare: true, }, repulsor: { preview: RepulsorPreview, create: (x: number, y: number) => ({ type: 'repulsor', x, y, width: 150, height: 10, }), canResize: true, isSquare: true, } /* quantumgateslow: { preview: QuantumGateSlowPreview, create: (x: number, y: number) => ({ type: 'quantumgateslow', x, y, width: 150, height: 10, }), canResize: true, isSquare: true, }, quantumgatefast: { preview: QuantumGateFastPreview, create: (x: number, y: number) => ({ type: 'quantumgatefast', x, y, width: 150, height: 10, }), canResize: true, isSquare: true, }, */, prism: { preview: PrismPreview, create: (x: number, y: number) => ({ type: 'prism', x, y, angle: 0, }), canRotate: true, }, spokedwheel: { preview: SpokedWheelPreview, create: (x: number, y: number) => ({ type: 'spokedwheel', x, y, speed: 25, angle: 0, }), }, ballstand: { preview: BallStandPreview, create: (x: number, y: number) => ({ type: 'ballstand', x, y, angle: 0, }), canRotate: true, }, cup: { preview: CupPreview, create: (x: number, y: number) => ({ type: 'cup', x, y, angle: 0, }), canRotate: true, }, catswat: { preview: CatSwatPreview, create: (x: number, y: number) => ({ type: 'catswat', x, y, angle: 0, catMass: 2, }), canRotate: true, }, } export const stickerList: Record< string, PaletteItem > = mapValues(stickers, (_, sticker) => ({ preview: () => , create: (x: number, y: number) => ({ type: 'sticker' as const, sticker: sticker as StickerName, x, y, angle: 0, }), canRotate: true, })) export function Widget( props: WidgetData & Partial & { id: EditableWidget['id'] }, ) { const { type } = props if (type === 'board') { return } else if (type === 'brick') { return } else if (type === 'hammer') { return } else if (type === 'sword') { return } else if (type === 'anvil') { return } else if (type === 'fan') { return } else if (type === 'leftbumper') { return } else if (type === 'rightbumper') { return } else if (type === 'hook') { return } else if (type === 'lefthook') { return } else if (type === 'roundbumper') { return } else if (type === 'cushion') { return } else if (type === 'prism') { return } else if (type === 'spokedwheel') { return } else if (type === 'attractor') { return } else if (type === 'repulsor') { return } else if (type === 'ballstand') { return } else if (type === 'cup') { return } else if (type === 'catswat') { return } else if (type === 'sticker') { return /* /* } else if (type === 'quantumgateslow') { return } else if (type === 'quantumgatefast') { return */ } else { assertNever(type, true) } } function WidgetsUnmemoized({ widgets, onSelect, selectedId, }: { widgets: WidgetCollection onSelect?: EditableWidget['onSelect'] selectedId?: string | undefined }) { const { bounds: [offsetX, offsetY], } = useMachineTile() return Object.entries(widgets).map(([id, widget]) => { let { x, y } = widget x = x + offsetX y = y + offsetY return ( ) }) } // Separated to work around an eslint react/prop-types false positive. export const Widgets = React.memo(WidgetsUnmemoized) ================================================ FILE: client/src/components/widgets/lib/ball.ts ================================================ import { ColliderDesc } from '@dimforge/rapier2d' import { coords } from '../../../lib/coords' import { Basis } from '../../../lib/utils' type BallFunc = typeof ColliderDesc.ball /** * helpful function for converting paths you got from an image into a ball * @param create function that can create a ball * @param basis how to convert the provided values to a coordinate system in pixels centered at 0, 0 * @param radius radius * @param center center of the ball */ export default function Ball( create: BallFunc, basis: Basis, radius: number, center: { x: number; y: number }, ): ColliderDesc { const scale = basis.scale || 1 //pointToVectorObject(basis, center.x, center.y) return create(coords.toRapier.length(radius / scale)).setTranslation( ...coords.toRapier.vector(center.x, center.y, basis), ) } ================================================ FILE: client/src/components/widgets/lib/lineCuboid.ts ================================================ import type { ColliderDesc } from '@dimforge/rapier2d' import { coords } from '../../../lib/coords' import { Basis, RandallPath } from '../../../lib/utils' export function lineCuboid( c: typeof ColliderDesc, { x1, x2, y1, y2, thickness }: RandallPath, { xBasis, yBasis, scale = 1 }: Basis, ): ColliderDesc { const width = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) / scale const height = thickness / scale const rotation = -Math.atan2(y2 - y1, x2 - x1) return c .cuboid(...coords.toRapier.lengths(width / 2, height / 2)) .setTranslation( ...coords.toRapier.vector( ((x1 + x2) / 2 - xBasis) / scale, ((y1 + y2) / 2 - yBasis) / scale, ), ) .setRotation(rotation) } ================================================ FILE: client/src/custom.d.ts ================================================ import type { ComicGlobal } from '.' declare global { interface Window { Comic: ComicGlobal } interface Document { webkitFullscreenElement?: Element } interface HTMLElement { requestFullscreen?: () => Promise webkitRequestFullscreen?: () => Promise } } ================================================ FILE: client/src/generated/api-spec.d.ts ================================================ /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ export interface paths { "/blueprint/file": { post: { parameters: { header?: { "X-WorkOrder"?: string; }; }; requestBody?: { content: { "application/json;charset=utf-8": components["schemas"]["Blueprint"]; }; }; responses: { 200: { content: { "application/json;charset=utf-8": [components["schemas"]["UUID"], [number, number]]; }; }; /** @description Invalid `body` or `X-WorkOrder` */ 400: { content: never; }; }; }; }; "/folio/{blueprintid}": { get: { parameters: { path: { blueprintid: string; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["Folio"]; }; }; /** @description `blueprintid` not found */ 404: { content: never; }; }; }; }; "/machine/current": { get: { responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["VersionedMachine (Maybe BlueprintID)"]; }; }; }; }; }; "/machine/current/version": { get: { responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": number; }; }; }; }; }; "/machine/{version}": { get: { parameters: { path: { version: number; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["VersionedMachine (Maybe BlueprintID)"]; }; }; /** @description `version` not found */ 404: { content: never; }; }; }; }; "/machine/delta/{startVersion}/current": { get: { parameters: { path: { startVersion: number; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": [number, components["schemas"]["VersionedMachine ModData"]]; }; }; /** @description `startVersion` not found */ 404: { content: never; }; }; }; }; "/machine/delta/{startVersion}/{endVersion}": { get: { parameters: { path: { startVersion: number; endVersion: number; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": [number, components["schemas"]["VersionedMachine ModData"]]; }; }; /** @description `startVersion` or `endVersion` not found */ 404: { content: never; }; }; }; }; "/puzzle": { get: { responses: { 200: { headers: { "Cache-Control"?: string; "X-WorkOrder"?: string; }; content: { "application/json;charset=utf-8": { [key: string]: components["schemas"]["Puzzle"]; }; }; }; }; }; }; "/moderate/puzzle/{puzzleid}/blueprintid": { get: { parameters: { path: { puzzleid: string; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["UUID"][]; }; }; /** @description `puzzleid` not found */ 404: { content: never; }; }; }; }; "/moderate/puzzle/{puzzleid}/blueprint": { get: { parameters: { path: { puzzleid: string; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": [components["schemas"]["UUID"], components["schemas"]["Blueprint"]][]; }; }; /** @description `puzzleid` not found */ 404: { content: never; }; }; }; }; "/moderate/puzzle/{puzzleid}": { get: { parameters: { path: { puzzleid: string; }; }; responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["Puzzle"]; }; }; /** @description `puzzleid` not found */ 404: { content: never; }; }; }; }; "/moderate/puzzle/{puzzleid}/reissue": { post: { parameters: { path: { puzzleid: string; }; }; responses: { 204: { content: never; }; /** @description `puzzleid` not found */ 404: { content: never; }; }; }; }; "/moderate/build/{X}/{Y}": { post: { parameters: { path: { X: number; Y: number; }; }; requestBody?: { content: { "application/json;charset=utf-8": components["schemas"]["InspectionReport"]; }; }; responses: { 204: { content: never; }; /** @description Invalid `body` */ 400: { content: never; }; /** @description `X` or `Y` not found */ 404: { content: never; }; }; }; }; "/moderate/burn/{blueprintid}": { post: { parameters: { path: { blueprintid: string; }; }; responses: { 204: { content: never; }; /** @description `blueprintid` not found */ 404: { content: never; }; }; }; }; "/moderate/machine/current": { get: { responses: { 200: { headers: { "Cache-Control"?: string; }; content: { "application/json;charset=utf-8": components["schemas"]["VersionedMachine ModData"]; }; }; }; }; }; } export type webhooks = Record; export interface components { schemas: { /** * Format: uuid * @example 00000000-0000-0000-0000-000000000000 */ UUID: string; /** * @example { * "puzzle": "00000000-0000-0000-0000-000000000000", * "submittedAt": null, * "title": "Lauren Ipsum", * "widgets": {} * } */ Blueprint: { puzzle: string; submittedAt?: components["schemas"]["UTCTime"]; title: string; widgets: components["schemas"]["Object"]; }; /** * Format: yyyy-mm-ddThh:MM:ssZ * @example 2016-07-22T00:00:00Z */ UTCTime: string; /** @description Arbitrary JSON object. */ Object: { [key: string]: unknown; }; Folio: { blueprint: components["schemas"]["Blueprint"]; puzzle: components["schemas"]["Puzzle"]; snapshot: components["schemas"]["Object"]; }; /** * @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": {} * } */ Puzzle: { inputs: components["schemas"]["Gateway"][]; outputs: components["schemas"]["Gateway"][]; reqTiles: components["schemas"]["RelativeCell"][]; spec: components["schemas"]["Object"]; }; /** @enum {string} */ RelativeCell: "UpLeft" | "Up" | "UpRight" | "Left" | "Right" | "DownLeft" | "Down" | "DownRight"; /** * @example { * "blueprint": "00000000-0000-0000-0000-000000000000", * "puzzle": "00000000-0000-0000-0000-000000000000", * "to_mod": 20 * } */ Gateway: { balls: components["schemas"]["GatewayBall"][]; /** Format: double */ x: number; /** Format: double */ y: number; }; GatewayBall: { /** Format: double */ rate: number; type: number; }; /** * @example { * "grid": [ * [ * "00000000-0000-0000-0000-000000000000", * "00000000-0000-0000-0000-000000000000" * ], * [ * "00000000-0000-0000-0000-000000000000", * null * ] * ], * "ms_per_ball": 0.001, * "prio_puzzles": [], * "tile_size": { * "x": 700, * "y": 700 * }, * "version": 0 * } */ "VersionedMachine (Maybe BlueprintID)": { grid: components["schemas"]["UUID"][][]; /** Format: double */ ms_per_ball: number; prio_puzzle?: components["schemas"]["UUID"][]; tile_size: components["schemas"]["TileSize"]; version: number; }; TileSize: { x: number; y: number; }; /** * @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": 0.001, * "prio_puzzles": [], * "tile_size": { * "x": 700, * "y": 700 * }, * "version": 0 * } */ "VersionedMachine ModData": { grid: components["schemas"]["ModData"][][]; /** Format: double */ ms_per_ball: number; prio_puzzle?: components["schemas"]["UUID"][]; tile_size: components["schemas"]["TileSize"]; version: number; }; InspectionReport: { blueprint: components["schemas"]["UUID"]; snapshot: components["schemas"]["Object"]; }; /** * @example { * "blueprint": "00000000-0000-0000-0000-000000000000", * "puzzle": "00000000-0000-0000-0000-000000000000", * "to_mod": 20 * } */ ModData: { blueprint?: string; puzzle: string; to_mod?: number; }; }; responses: never; parameters: never; requestBodies: never; headers: never; pathItems: never; } export type $defs = Record; export type external = Record; export type operations = Record; ================================================ FILE: client/src/image.d.ts ================================================ declare module '*.png' { interface ComicImage { width: number height: number url: { '4x': string '2x': string } srcSet: string } const content: ComicImage export default content } ================================================ FILE: client/src/index.ejs ================================================ <%= comic.name %>
<%= tags.bodyTags %> ================================================ FILE: client/src/index.tsx ================================================ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClientProvider } from '@tanstack/react-query' import comic from '../comic.json' import { queryClient } from './api' import Comic from './components/Comic' // TODO: comic global export interface ComicGlobal {} export function styleContainer(el: HTMLElement) { el.style.cssText = ` position: relative; width: ${comic.width}px; height: ${comic.height}px; margin: 0 auto; font-variant: normal; ` } function init() { const comicEl = document.getElementById('comic') if (!comicEl) { return } styleContainer(comicEl) const root = createRoot(comicEl) root.render( , ) } init() ================================================ FILE: client/src/lib/coords.ts ================================================ import type { Collider, RigidBody } from '@dimforge/rapier2d' import type { Vector } from '../types' import { Basis } from './utils' export const M_PER_PX = 1 / 50 export const coords = { toRapier: { x(distance: number) { return distance * M_PER_PX }, y(distance: number) { return -distance * M_PER_PX }, length(this: void, length: number) { return length * M_PER_PX }, lengths(...lengths: T): T { return lengths.map(this.length) as T }, vector( x: number, y: number, { xBasis, yBasis, scale = 1 }: Basis = { xBasis: 0, yBasis: 0, scale: 1 }, ): [number, number] { return [this.x((x - xBasis) / scale), this.y((y - yBasis) / scale)] }, vectorObject( x: number, y: number, { xBasis, yBasis, scale = 1 }: Basis = { xBasis: 0, yBasis: 0, scale: 1 }, ): Vector { return { x: this.x((x - xBasis) / scale), y: this.y((y - yBasis) / scale), } }, angle(angle: number) { return -angle }, }, fromRapier: { x(distance: number) { return distance / M_PER_PX }, y(distance: number) { return -distance / M_PER_PX }, length(this: void, length: number) { return length / M_PER_PX }, vector(x: number, y: number): [number, number] { return [this.x(x), this.y(y)] }, angle(angle: number) { return -angle }, }, fromBody: { vector(body: RigidBody | Collider) { const { x, y } = body.translation() return coords.fromRapier.vector(x, y) }, angle(body: RigidBody | Collider) { return coords.fromRapier.angle(body.rotation()) }, }, } export function vectorMagnitude(a: Vector) { return Math.sqrt(a.x * a.x + a.y * a.y) } export function vectorDistance(a: Vector, b: Vector) { return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)) } export function vectorAngle(a: Vector, b: Vector) { return Math.atan2(b.y - a.y, b.x - a.x) } export function vectorDifference(a: Vector, b: Vector): Vector { return { x: a.x - b.x, y: a.y - b.y } } export function vectorNorm(a: Vector): Vector { const invLength = 1.0 / Math.sqrt(a.x * a.x + a.y * a.y) return { x: a.x * invLength, y: a.y * invLength } } export function vectorScale(a: Vector, scale: number): Vector { return { x: a.x * scale, y: a.y * scale } } export function vectorRotate(toRotate: Vector, angleDegrees: number): Vector { const angle = angleDegrees * (Math.PI / 180.0) const cosA = Math.cos(angle) const sinA = Math.cos(angle) const { x, y } = toRotate return { x: cosA * x - sinA * y, y: sinA * x + cosA * y } } ================================================ FILE: client/src/lib/snapshot.tsx ================================================ import type { RigidBody, Vector } from '@dimforge/rapier2d' import { Angled, BallType } from '../types' export interface BodySnapshot extends Vector, Angled { vx: number vy: number va: number } export interface WidgetSnapshot extends BodySnapshot { key: string } export interface BallSnapshot extends BodySnapshot { type: BallType age: number } export interface MachineSnapshot { widgets: Record balls: BallSnapshot[] } export function snapshotBody(body: RigidBody): BodySnapshot { const { x: vx, y: vy } = body.linvel() return { ...body.translation(), angle: body.rotation(), vx, vy, va: body.angvel(), } } export function applySnapshotToBody( snapshot: BodySnapshot, body: RigidBody, offsetX = 0, offsetY = 0, ) { body.setTranslation( { x: snapshot.x + offsetX, y: snapshot.y + offsetY }, true, ) body.setRotation(snapshot.angle, true) body.setLinvel({ x: snapshot.vx, y: snapshot.vy }, true) body.setAngvel(snapshot.va, true) } export function offsetSnapshot( xOffset: number, yOffset: number, { x, y, ...rest }: T, ) { return { ...rest, x: x + xOffset, y: y + yOffset } } ================================================ FILE: client/src/lib/tiles.tsx ================================================ import { Bounds } from '../types' import { intersectBounds } from './utils' export type Grid = T[][] export function tileBounds( x1: number, y1: number, x2: number, y2: number, tileWidth: number, tileHeight: number, outset: number = 0, ): Bounds { const xt1 = Math.floor((x1 - outset) / tileWidth) const yt1 = Math.floor((y1 - outset) / tileHeight) // The bottom right bounds are -1 tile to factor in the last tile's width and height. const xt2 = Math.ceil((x2 + outset) / tileWidth) - 1 const yt2 = Math.ceil((y2 + outset) / tileHeight) - 1 return [xt1, yt1, xt2, yt2] } export function* iterTiles( xt1: number, yt1: number, xt2: number, yt2: number, ): Generator<[xt: number, yt: number]> { for (let yt = yt1; yt <= yt2; yt++) { for (let xt = xt1; xt <= xt2; xt++) { yield [xt, yt] } } } export function gridDimensions(grid: Grid) { const tilesX = grid[0].length const tilesY = grid.length return [tilesX, tilesY] } export function gridViewBounds( viewBounds: Bounds, tilesX: number, tilesY: number, tileWidth: number, tileHeight: number, outset: number, ): Bounds { const rawBounds = tileBounds(...viewBounds, tileWidth, tileHeight, outset) return intersectBounds(rawBounds, [0, 0, tilesX - 1, tilesY - 1]) } export function tileKey(xt: number, yt: number) { return `${xt},${yt}` } ================================================ FILE: client/src/lib/utils.ts ================================================ import { useCallback, useRef } from 'react' import { Bounds } from '../types' export function useIdGen(getInitial?: () => number) { const nextIdRef = useRef(null) if (nextIdRef.current === null) { nextIdRef.current = getInitial ? getInitial() : 0 } return useCallback(() => String(nextIdRef.current!++), []) } export function px(value: number) { return `${value}px` } export function percent(value: number) { return `${(100 * value).toFixed(2)}%` } export function ms(value: number) { return `${value.toFixed(1)}ms` } export function inBounds(x: number, y: number, [x1, y1, x2, y2]: Bounds) { return x >= x1 && x <= x2 && y >= y1 && y <= y2 } export function inBoundsOutset( x: number, y: number, outset: number, [x1, y1, x2, y2]: Bounds, ) { return ( x >= x1 - outset && x <= x2 + outset && y >= y1 - outset && y <= y2 + outset ) } export function inBoundsObject( x: number, y: number, width: number, height: number, [x1, y1, x2, y2]: Bounds, ) { return ( x >= x1 - 0.5 * width && x <= x2 + 0.5 * width && y >= y1 - 0.5 * height && y <= y2 + 0.5 * height ) } export function intersectBounds( [ax1, ay1, ax2, ay2]: Bounds, [bx1, by1, bx2, by2]: Bounds, ): Bounds { return [ Math.max(ax1, bx1), Math.max(ay1, by1), Math.min(ax2, bx2), Math.min(ay2, by2), ] } export function rotate( rotationAngle: number, { x, y }: { x: number; y: number }, ) { return { x: x * Math.cos(rotationAngle) - y * Math.sin(rotationAngle), y: x * Math.sin(rotationAngle) + y * Math.cos(rotationAngle), } } export interface Basis { xBasis: number yBasis: number scale?: number } export interface RandallPath { x1: number y1: number x2: number y2: number thickness: number } ================================================ FILE: client/src/page/demo-editor.tsx ================================================ import { StrictMode, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import comic from '../../comic.json' import InnerComicBorder from '../components/InnerComicBorder' import { MachineContextProvider, MachineContextProviderRef, } from '../components/MachineContext' import { MachineTileContextProvider, MachineTileContextProviderRef, } from '../components/MachineTileContext' import MachineTileEditor from '../components/MachineTileEditor' import { PhysicsContextProvider } from '../components/PhysicsContext' import { MachineSnapshot } from '../lib/snapshot' import { Bounds } from '../types' import { emptyPuzzle, emptyWidgets } from './fixtures/emptyMachine' const comicBounds: Bounds = [0, 0, comic.width, comic.height] function DemoEditor() { const machineRef = useRef() const machineTileRef = useRef() const [snapshot, setSnapshot] = useState() return (
) } const root = createRoot(document.getElementsByTagName('main')[0]) root.render( , ) ================================================ FILE: client/src/page/demo-map.tsx ================================================ import { StrictMode, useEffect, useRef } from 'react' import { createRoot } from 'react-dom/client' import { SlippyMapRef } from '../components/CenteredSlippyMap' import InnerComicBorder from '../components/InnerComicBorder' import { SlippyMetaMachineView } from '../components/MetaMachineView' import { PhysicsContextProvider } from '../components/PhysicsContext' import { emptyPuzzle } from './fixtures/emptyMachine' const getMachine = () => ({ puzzle: emptyPuzzle, widgets: {}, snapshot: { widgets: {}, balls: [] }, }) function DemoMap({ demoPanning }: { demoPanning?: boolean }) { const mapRef = useRef(null) useEffect(() => { if (!demoPanning) { return } const interval = setInterval(() => { void mapRef.current?.animateTo( 1000 + 1000 * Math.random(), 1000 + 1000 * Math.random(), 0.25 + Math.random(), ) }, 1000) return () => { clearInterval(interval) } }, [demoPanning]) return ( ) } const root = createRoot(document.getElementsByTagName('main')[0]) root.render( , ) ================================================ FILE: client/src/page/demo-viewer.tsx ================================================ import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode, useState } from 'react' import { createRoot } from 'react-dom/client' import comic from '../../comic.json' import { queryClient } from '../api' import InnerComicBorder from '../components/InnerComicBorder' import LoadingSpinner from '../components/LoadingSpinner' import { SlippyMetaMachineView } from '../components/MetaMachineView' import { PhysicsContextProvider } from '../components/PhysicsContext' import { useMetaMachineClient } from '../components/useMetaMachineClient' import { Bounds } from '../types' function DemoViewer() { const [viewBounds, setViewBounds] = useState(() => [ 0, 0, comic.width, comic.height, ]) const { metaMachine } = useMetaMachineClient({ viewBounds }) /* const clippedMetaMachine = useMemo( () => metaMachine ? { ...metaMachine, tilesY: 1, } : null, [metaMachine], ) */ if (!metaMachine) { return } return ( ) } const root = createRoot(document.getElementsByTagName('main')[0]) root.render( , ) ================================================ FILE: client/src/page/fixtures/demoMachine.tsx ================================================ import { MachineSnapshot } from '../../lib/snapshot' import { WidgetCollection } from '../../types' export const demoWidgets: WidgetCollection = { '5': { type: 'box', x: 250, y: 250, width: 300, height: 50, radius: 10, angle: 0.5, }, '6': { type: 'box', x: 500, y: 500, width: 300, height: 50, radius: 10, angle: -0.5, }, } export const demoMachineSnapshot = JSON.parse( '{"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"}]}', ) as MachineSnapshot ================================================ FILE: client/src/page/fixtures/emptyMachine.tsx ================================================ import { Puzzle, WidgetCollection } from '../../types' export const emptyPuzzle: Puzzle = { inputs: [ { x: 0.5, y: 0, balls: [{ type: 1, rate: 1 }] }, { x: 0, y: 0.5, balls: [{ type: 2, rate: 1 }] }, { x: 1, y: 0.75, balls: [{ type: 3, rate: 1 }] }, { x: 1, y: 0.25, balls: [{ type: 4, rate: 1 }] }, ], outputs: [ { x: 0.5, y: 1, balls: [{ type: 1, rate: 1 }] }, { x: 1, y: 0.5, balls: [{ type: 2, rate: 1 }] }, { x: 0, y: 0.75, balls: [{ type: 3, rate: 1 }] }, { x: 0, y: 0.25, balls: [{ type: 4, rate: 1 }] }, ], } export const emptyWidgets: WidgetCollection = {} ================================================ FILE: client/src/page/moderator.tsx ================================================ import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { queryClient } from '../api' import Moderator from '../components/moderation/Moderator' const root = createRoot(document.getElementsByTagName('main')[0]) root.render( , ) ================================================ FILE: client/src/page/page.ejs ================================================ <%= comic.name %>
<%= tags.bodyTags %> ================================================ FILE: client/src/types.ts ================================================ import type { RigidBody, Vector } from '@dimforge/rapier2d' import { isNumber, isString } from 'lodash' import { WidgetData } from './components/widgets' export type { Vector } export interface Sized { width: number height: number } export interface Angled { angle: number } export interface OutputLoc { pos: Vector } export type BallType = number export type BallTypeRate = { type: BallType; rate: number } export type PuzzlePosition = { balls: BallTypeRate[] } & Vector export interface Puzzle { inputs: PuzzlePosition[] outputs: PuzzlePosition[] } export interface PuzzleOrder extends Puzzle { id: string workOrder: string } export type WidgetCollection = Record export type Bounds = [x1: number, y1: number, x2: number, y2: number] export type BallData = { type: 'BallData' id: string ballType: BallType } export type UserData = BallData & { type: unknown } export type Ball = RigidBody & { userData: BallData } export function isBall(body: RigidBody): body is Ball { const userData = body.userData if (userData == null) { return false } const ballData = userData as BallData return ( ballData.type === 'BallData' && isNumber(ballData.ballType) && isString(ballData.id) ) } ================================================ FILE: client/tsconfig.json ================================================ { "compilerOptions": { "sourceMap": true, "noImplicitAny": true, "esModuleInterop": true, "module": "es2020", "target": "es6", "jsx": "react-jsx", "jsxImportSource": "@emotion/react", "allowJs": true, "moduleResolution": "node", "resolveJsonModule": true, "strict": true, "noEmit": true, "paths": { "@art/*": ["./art/*"] } }, "include": ["src/**/*"] } ================================================ FILE: client/webpack.config.js ================================================ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin' import HtmlWebpackPlugin from 'html-webpack-plugin' import path from 'path' import reactRefreshBabel from 'react-refresh/babel' import TerserPlugin from 'terser-webpack-plugin' import webpack from 'webpack' import comicData from './comic.json' assert { type: 'json' } function buildComic(_env, argv) { return { name: 'comic', entry: { index: './src/index.tsx', moderator: './src/page/moderator.tsx', 'demo-editor': './src/page/demo-editor.tsx', 'demo-map': './src/page/demo-map.tsx', 'demo-viewer': './src/page/demo-viewer.tsx', }, output: { path: path.resolve(import.meta.dirname, 'built'), filename: '[name].js', publicPath: comicData.publicPath, }, module: { rules: [ { test: /\.tsx?$/, use: { loader: 'babel-loader', options: { plugins: [ argv.mode === 'development' && reactRefreshBabel, ].filter(Boolean), }, }, exclude: /node_modules/, }, { test: /\.png$/, use: { loader: 'comic-image-loader', options: { name: 'static/[contenthash:6].[ext]', publicPath: comicData.publicPath, quant: true, baseScale: 4, scales: [2, 4], }, }, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], alias: { lodash: 'lodash-es', '@art': path.resolve(import.meta.dirname, 'art/'), }, }, resolveLoader: { modules: ['node_modules', path.resolve(import.meta.dirname, 'loaders')], }, cache: argv.mode === 'development' ? { type: 'filesystem' } : false, devtool: argv.mode === 'development' ? 'eval-source-map' : 'source-map', optimization: { minimizer: [ new TerserPlugin({ extractComments: false, }), ], }, plugins: [ new webpack.BannerPlugin( 'by chromako.de, spyhi, oh no, LiraNuna, and DirtyPunk', ), new webpack.EnvironmentPlugin({ API_ENDPOINT: null }), argv.mode === 'development' && new ReactRefreshWebpackPlugin(), ...['index', 'moderator', 'demo-map', 'demo-editor', 'demo-viewer'].map( (name) => new HtmlWebpackPlugin({ inject: false, minify: false, scriptLoading: 'blocking', template: `src/${name === 'index' ? 'index' : 'page/page'}.ejs`, filename: `${name}.html`, chunks: [name], templateParameters: (_compilation, _assets, assetTags) => ({ tags: assetTags, comic: comicData, }), }), ), ].filter(Boolean), experiments: { asyncWebAssembly: true, }, devServer: { hot: true, }, } } export default buildComic ================================================ FILE: config/incredible.toml ================================================ [web] port = 8888 base_url = "/" origins = ["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"] [cache] machines = 1000 puzzles = 10 blueprints = 100000 deltas = 20000 snapshots = 20000 blueprint2puzzle = 20000 [mods] user = "$2b$05$LVPgqAq9laeGKP9QofeRCu/rak.9pRnRCm4cVXwafpETcCLR3R8ta" [redis] database = 0 host = "127.0.0.1" port = 6379 maxConnections = 10000 maxIdleTimeout = 60 retry_count = 5 workorder_ttl = 7200 # 2 hours orderbook_ttl = 1200 # 20 minutes tls = false ================================================ FILE: config/machine.json ================================================ {"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":{}}]]} ================================================ FILE: docs/Main.hs ================================================ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DerivingVia #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LexicalNegation #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fno-warn-orphans #-} {-# LANGUAGE InstanceSigs #-} module Main where import Control.Lens import qualified Data.Aeson as JS import qualified Data.Array as Array import qualified Data.ByteString.Lazy as BSL import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap import Data.OpenApi (OpenApi, SecurityScheme (..), _openApiComponents, _componentsSecuritySchemes, SecurityDefinitions (..), allOperations, security, SecurityRequirement (SecurityRequirement)) import qualified Data.OpenApi as OpenApi import Data.Time (UTCTime) import qualified Data.UUID as UUID import Incredible.API import Incredible.Data import Options.Applicative import Servant.OpenApi (HasOpenApi(toOpenApi)) import Data.Data import qualified Data.Text as T import Servant (BasicAuth) import qualified Servant.API newtype IncredibleDocsOptions = IncredibleDocsOptions { incredibleDocsOutputPath :: FilePath } incredibleOpts :: Parser IncredibleDocsOptions incredibleOpts = do IncredibleDocsOptions <$> configPath where configPath = strOption $ mconcat [ long "output" , short 'o' , metavar "OUTPUT" , help "Path to the incredible docs output" ] main :: IO () main = do opts <- execParser $ info (incredibleOpts <**> helper) fullDesc BSL.writeFile (incredibleDocsOutputPath opts) $ JS.encode incredibleSwagger incredibleSwagger :: OpenApi incredibleSwagger = toOpenApi incredibleAPI & OpenApi.info . OpenApi.title .~ "Incredible API" & OpenApi.info . OpenApi.version .~ "0" & OpenApi.info . OpenApi.description ?~ "Swagger API docs for Incredible" instance OpenApi.ToSchema Folio where declareNamedSchema _ = do puzzleScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Puzzle) blueprintScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Blueprint) msnapshotScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy (Maybe Snapshot)) pure $ OpenApi.NamedSchema (Just "Folio") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("puzzle", puzzleScheme) , ("blueprint", blueprintScheme) , ("snapshot", msnapshotScheme) ] & OpenApi.required .~ ["puzzle", "blueprint", "snapshot"] instance OpenApi.ToSchema TileSize where declareNamedSchema _ = do intScheme <- OpenApi.declareSchemaRef (Proxy :: Proxy Int) pure $ OpenApi.NamedSchema (Just "TileSize") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("x", intScheme) , ("y", intScheme) ] & OpenApi.required .~ ["x", "y"] instance OpenApi.ToSchema (MetaMachine (Maybe BlueprintID)) where declareNamedSchema _ = do mBlueprintIDRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe BlueprintID]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "MetaMachine (Maybe BlueprintID)") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", mBlueprintIDRef) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleMetaMachineMaybeBlueprintID instance OpenApi.ToSchema (MetaMachine (Maybe Blueprint)) where declareNamedSchema _ = do mBlueprintRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe Blueprint]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "MetaMachine (Maybe Blueprint)") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", mBlueprintRef) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleMetaMachineMaybeBlueprint instance OpenApi.ToSchema (MetaMachine ModData) where declareNamedSchema _ = do modDataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[ModData]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "MetaMachine ModData") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", modDataRef) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleMetaMachineModData instance OpenApi.ToSchema (VersionedMachine (Maybe BlueprintID)) where declareNamedSchema _ = do blueprintIDRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe BlueprintID]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "VersionedMachine (Maybe BlueprintID)") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", blueprintIDRef) , ("version", OpenApi.toSchemaRef (Proxy :: Proxy Integer)) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "version", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleVersionedMachineMaybeBlueprintID instance OpenApi.ToSchema (VersionedMachine (Maybe Blueprint)) where declareNamedSchema _ = do mBlueprintRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[Maybe Blueprint]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "VersionedMachine (Maybe Blueprint)") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", mBlueprintRef) , ("version", OpenApi.toSchemaRef (Proxy :: Proxy Integer)) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "version", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleVersionedMachineMaybeBlueprint instance OpenApi.ToSchema (VersionedMachine ModData) where declareNamedSchema _ = do modDataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [[ModData]]) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "VersionedMachine ModData") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("grid", modDataRef) , ("version", OpenApi.toSchemaRef (Proxy :: Proxy Integer)) , ("tile_size", tileSizeSchema) , ("ms_per_ball", doubleSchema) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["grid", "version", "tile_size", "ms_per_ball"] & OpenApi.example ?~ JS.toJSON exampleVersionedMachineModData instance OpenApi.ToSchema a => OpenApi.ToSchema (MachineUpdates a) where declareNamedSchema _ = do dataRef <- OpenApi.declareSchemaRef (Proxy::Proxy [((X, Y), a)]) gridSizeRef <- OpenApi.declareSchemaRef (Proxy::Proxy ((X, Y), (X, Y))) doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) tileSizeSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy TileSize) prioPzlSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy [PuzzleID]) pure $ OpenApi.NamedSchema (Just "VersionedMachine ModData") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("tile_size", tileSizeSchema) , ("grid_size", gridSizeRef) , ("ms_per_ball", doubleSchema) , ("construction", dataRef) , ("prio_puzzle", prioPzlSchema) ] & OpenApi.required .~ ["tile_size", "ms_per_ball", "construction", "grid_size", "prio_puzzle"] instance OpenApi.ToSchema RelativeCell where declareNamedSchema _ = do pure $ OpenApi.NamedSchema (Just "RelativeCell") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiString & OpenApi.enum_ ?~ (map JS.toJSON $ enumFrom (minBound::RelativeCell)) instance OpenApi.ToSchema Puzzle where declareNamedSchema _ = do reqRef <- OpenApi.declareSchemaRef (Proxy::Proxy [RelativeCell]) gatesRef <- OpenApi.declareSchemaRef (Proxy::Proxy [Gateway]) objRef <- OpenApi.declareSchemaRef (Proxy::Proxy JS.Object) pure $ OpenApi.NamedSchema (Just "Puzzle") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("reqTiles", reqRef) , ("inputs", gatesRef) , ("outputs", gatesRef) , ("spec", objRef) ] & OpenApi.required .~ ["reqTiles", "inputs", "outputs", "spec"] & OpenApi.example ?~ JS.toJSON examplePuzzle instance OpenApi.ToSchema Blueprint where declareNamedSchema _ = do utcRef <- OpenApi.declareSchemaRef (Proxy::Proxy (Maybe UTCTime)) objRef <- OpenApi.declareSchemaRef (Proxy::Proxy JS.Object) pure $ OpenApi.NamedSchema (Just "Blueprint") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("puzzle", OpenApi.Inline (mempty & OpenApi.type_ ?~ OpenApi.OpenApiString)) , ("title", OpenApi.Inline (mempty & OpenApi.type_ ?~ OpenApi.OpenApiString)) , ("submittedAt", utcRef) , ("widgets", objRef) ] & OpenApi.required .~ ["puzzle", "title", "widgets"] & OpenApi.example ?~ JS.toJSON exampleBlueprint instance OpenApi.ToSchema InspectionReport where declareNamedSchema _ = do bpRef <- OpenApi.declareSchemaRef (Proxy::Proxy BlueprintID) snapshotRef <- OpenApi.declareSchemaRef (Proxy::Proxy Snapshot) pure $ OpenApi.NamedSchema (Just "InspectionReport") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("blueprint", bpRef) , ("snapshot", snapshotRef) ] & OpenApi.required .~ ["blueprint", "snapshot"] instance OpenApi.ToSchema ModData where declareNamedSchema _ = pure $ OpenApi.NamedSchema (Just "ModData") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("puzzle", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiString) , ("blueprint", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiString) , ("to_mod", OpenApi.Inline $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiInteger) ] & OpenApi.required .~ ["puzzle"] & OpenApi.example ?~ JS.toJSON exampleModData instance OpenApi.ToSchema GatewayBall where declareNamedSchema _ = do doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) intRef <- OpenApi.declareSchemaRef (Proxy :: Proxy Int) pure $ OpenApi.NamedSchema (Just "GatewayBall") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("type", intRef) , ("rate", doubleSchema) ] & OpenApi.required .~ ["type", "rate"] instance OpenApi.ToSchema Gateway where declareNamedSchema _ = do doubleSchema <- OpenApi.declareSchemaRef (Proxy :: Proxy Double) gwBallsRef <- OpenApi.declareSchemaRef (Proxy :: Proxy [GatewayBall]) pure $ OpenApi.NamedSchema (Just "Gateway") $ mempty & OpenApi.type_ ?~ OpenApi.OpenApiObject & OpenApi.properties .~ InsOrdHashMap.fromList [ ("x", doubleSchema) , ("y", doubleSchema) , ("balls", gwBallsRef) ] & OpenApi.required .~ ["x", "y", "balls"] & OpenApi.example ?~ JS.toJSON exampleModData instance HasOpenApi api => HasOpenApi (BasicAuth realm auth Servant.API.:> api) where toOpenApi Proxy = addSecurity $ toOpenApi $ Proxy @api where addSecurity = addSecurityRequirement identifier . addSecurityScheme identifier securityScheme identifier :: T.Text = "BasicAuth" securityScheme = SecurityScheme { _securitySchemeType = OpenApi.SecuritySchemeHttp OpenApi.HttpSchemeBasic , _securitySchemeDescription = Just "Basic Authentication" } addSecurityScheme :: T.Text -> SecurityScheme -> OpenApi -> OpenApi addSecurityScheme securityIdentifier securityScheme openApi = openApi { _openApiComponents = (_openApiComponents openApi) { _componentsSecuritySchemes = _componentsSecuritySchemes (_openApiComponents openApi) <> SecurityDefinitions (InsOrdHashMap.singleton securityIdentifier securityScheme) } } addSecurityRequirement :: T.Text -> OpenApi -> OpenApi addSecurityRequirement securityRequirement = allOperations . security %~ ((SecurityRequirement $ InsOrdHashMap.singleton securityRequirement []) :) exampleBlueprint :: Blueprint exampleBlueprint = Blueprint (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) "Lauren Ipsum" Nothing mempty examplePuzzle :: Puzzle examplePuzzle = Puzzle [RCUpLeft] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty exampleModData :: ModData exampleModData = ModData (Just (read "00000000-0000-0000-0000-000000000000"::UUID.UUID)) (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) (Just 20) exampleVersionedMachineMaybeBlueprintID :: VersionedMachine (Maybe BlueprintID) exampleVersionedMachineMaybeBlueprintID = VersionedMachine 0 exampleMetaMachineMaybeBlueprintID exampleMetaMachineMaybeBlueprintID :: MetaMachine (Maybe BlueprintID) exampleMetaMachineMaybeBlueprintID = MetaMachine (Array.array ((0,0),(1,1)) [ ((0,0), Just (read "00000000-0000-0000-0000-000000000000"::UUID.UUID)) , ((0,1), Just (read "00000000-0000-0000-0000-000000000000"::UUID.UUID)) , ((1,0), Just (read "00000000-0000-0000-0000-000000000000"::UUID.UUID)) , ((1,1), Nothing) ]) (TileSize 700 700) 0.001 mempty exampleVersionedMachineMaybeBlueprint :: VersionedMachine (Maybe Blueprint) exampleVersionedMachineMaybeBlueprint = VersionedMachine 0 exampleMetaMachineMaybeBlueprint exampleMetaMachineMaybeBlueprint :: MetaMachine (Maybe Blueprint) exampleMetaMachineMaybeBlueprint = MetaMachine (Array.array ((0,0),(1,1)) [ ((0,0), Just exampleBlueprint) , ((0,1), Nothing) , ((1,0), Just exampleBlueprint) , ((1,1), Nothing) ]) (TileSize 700 700) 0.001 mempty exampleVersionedMachineModData :: VersionedMachine ModData exampleVersionedMachineModData = VersionedMachine 0 exampleMetaMachineModData exampleMetaMachineModData :: MetaMachine ModData exampleMetaMachineModData = MetaMachine (Array.array ((0,0),(1,1)) [ ((0,0), ModData (Just (read "00000000-0000-0000-0000-000000000000"::UUID.UUID)) (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) Nothing) , ((0,1), ModData Nothing (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) $ Just 11) , ((1,0), ModData Nothing (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) $ Just 20) , ((1,1), ModData Nothing (read "00000000-0000-0000-0000-000000000000"::UUID.UUID) Nothing) ]) (TileSize 700 700) 0.001 mempty ================================================ FILE: flake.nix ================================================ { description = "incredible"; inputs.nixpkgs.follows = "haskellNix/nixpkgs-unstable"; inputs.nixpkgs-unstable.url = "github:NixOS/nixpkgs?ref=09ad7aaffd920d5817fc56f77ab5ddd1628cbe08"; inputs.haskellNix.url = "github:input-output-hk/haskell.nix"; inputs.flake-utils.url = "github:numtide/flake-utils"; nixConfig = { extra-trusted-public-keys = [ "hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=" ]; extra-substituters = [ "https://cache.iog.io" ]; }; outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, haskellNix, ... }: let frontendModule = system: import ./nix/incredible-frontend.nix (self.outputs.packages.${system}.incredible-client); serverModule = system: import ./nix/incredible-server.nix (self.outputs.packages.${system}."incredible:exe:incredible-server"); in flake-utils.lib.eachSystem [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ] (system: let pkgs = import nixpkgs { inherit system overlays; inherit (haskellNix) config; }; pkgs-unstable = import nixpkgs-unstable { inherit system overlays; }; # TODO add prefetch-npm-deps to devShell incredible-client = script: pkgs-unstable.buildNpmPackage { pname = "incredible"; version = "1.0.0"; src = ./client; nodejs = pkgs-unstable.nodejs_20; npmDepsHash = "sha256-IXOXhgf2tpysiOa3B5faHF1IWZ2uwzGzwHS+Z5frqgI="; #nix run nixpkgs#prefetch-npm-deps client/package-lock.json npmBuildScript = script; }; deploy = import ./nix/deploy.nix { inherit pkgs; }; incredible-docs = pkgs.runCommand "incredible-docs-gen" {} '' mkdir $out ${flake.packages."incredible:exe:incredible-docs"}/bin/incredible-docs --output $out/incredible-docs.json ${pkgs.openapi-generator-cli}/bin/openapi-generator-cli generate -i $out/incredible-docs.json -g html --skip-validate-spec cp index.html $out/index.html ''; overlays = [ haskellNix.overlay (final: prev: { incredible = final.haskell-nix.project' { src = ./.; index-state = "2024-03-16T23:00:16Z"; compiler-nix-name = "ghc964"; shell = { tools = { cabal = {}; ghcid = {}; haskell-language-server = {}; }; withHoogle = true; buildInputs = with pkgs-unstable; [ nodejs_20 nodePackages.npm nodePackages.webpack nodePackages.webpack-cli prefetch-npm-deps redis ]; }; modules = [{ enableLibraryProfiling = true; enableProfiling = true; } ]; }; }) ]; flake = pkgs.incredible.flake { }; in nixpkgs.lib.recursiveUpdate flake { packages.incredible-client = incredible-client "build"; packages.incredible-client-dev = incredible-client "build:dev"; packages.incredible-digital-ocean = (pkgs.nixos { imports = [ (serverModule system) (frontendModule system) (import ./nix/incredible-digital-ocean.nix) (import ./nix/incredible-cfg.nix) (import ./nix/profiles/staging.nix) ]; }).digitalOceanImage; packages.incredible-docs = incredible-docs; apps.deploy = { type = "app"; program = "${deploy}/bin/deployScript"; }; }) // { nixosConfigurations = let system = "x86_64-linux"; in { incredible-do-staging = nixpkgs.lib.nixosSystem { inherit system; modules = [ (serverModule system) (frontendModule system) (import ./nix/incredible-digital-ocean.nix) (import ./nix/incredible-cfg.nix) (import ./nix/profiles/staging.nix) ]; }; incredible-vm = nixpkgs.lib.nixosSystem { inherit system; modules = [ (serverModule system) (frontendModule system) (import ./nix/incredible-qemu.nix) (import ./nix/incredible-cfg.nix) (import ./nix/profiles/staging.nix) ]; }; }; }; } ================================================ FILE: incredible.cabal ================================================ cabal-version: 3.0 name: incredible version: 0 -- synopsis: -- description: license: BSD-3-Clause license-file: LICENSE author: tolt maintainer: kevincotrone@gmail.com -- copyright: build-type: Simple extra-doc-files: CHANGELOG.md -- extra-source-files: common warnings ghc-options: -Wall common deps default-language: Haskell2010 ghc-options: -O2 build-depends: , aeson , async , array , base >=4.12 && < 4.20 , bcrypt , binary , bitvec , bytes , bytestring , containers , crypton , crypton-x509-system , data-default , deepseq , directory , exceptions , extra , filepath , hashable , hedis , http-types , indexed-traversable , lens , lrucache , mtl , monad-loops , optparse-applicative , process , random , safe , servant , servant-multipart , servant-server , stm , temporary , text , tls , time , toml-parser , transformers , unordered-containers , uuid , vector , wai , wai-cors , wai-extra , warp library import: warnings, deps exposed-modules: Incredible.AntiEvil Incredible.API Incredible.App Incredible.Config Incredible.Data Incredible.DataStore Incredible.DataStore.Memory Incredible.DataStore.Redis Incredible.Puzzle hs-source-dirs: src executable incredible-server import: warnings, deps main-is: Main.hs -- other-modules: -- other-extensions: build-depends: incredible hs-source-dirs: app ghc-options: -threaded -rtsopts -with-rtsopts=-N executable incredible-docs import: warnings, deps main-is: Main.hs hs-source-dirs: docs build-depends: incredible , servant-openapi3 , openapi3 , insert-ordered-containers executable incredible-gen import: warnings, deps main-is: Main.hs -- other-modules: -- other-extensions: hs-source-dirs: Gen default-language: Haskell2010 ghc-options: -O2 -threaded -rtsopts -with-rtsopts=-N build-depends: incredible test-suite incredible-test import: warnings, deps type: exitcode-stdio-1.0 hs-source-dirs: test main-is: Main.hs build-depends: incredible , async , hedgehog , tasty , tasty-hedgehog , uuid-types ================================================ FILE: nix/deploy.nix ================================================ { pkgs }: pkgs.writeShellScriptBin "deployScript" '' #!/usr/bin/env bash # This script is used to deploy NixOS configurations to multiple targets # It reads a list of deploy targets from a yaml file and builds and deploys export PATH=$PATH:${pkgs.jq}/bin while getopts ":ps" opt; do case $opt in p) export DEPLOY_TARGETS=$(cat $PROD_TARGETS) echo "Deploying to production" ;; s) export DEPLOY_TARGETS=$(cat $STAGING_TARGETS) echo "Deploying to staging" ;; esac done if [[ -z "$DEPLOY_TARGETS" ]]; then echo "No deploy targets found" exit 1 fi if ! test -f $DEPLOY_SSH_KEY; then echo "No deploy test key found at $DEPLOY_SSH_KEY" exit 1 fi set -x # Build each of the deploy targets # If one fails to build, the script will exit 1 and the deployment will not continue jq -c '.[]' <<<"$DEPLOY_TARGETS" | while read target; do nix_target=$(echo $target | jq -r '.nix_target') echo "Building $nix_target" nixos-rebuild build --flake .#$nix_target build_status=$? if [ $build_status -ne 0 ]; then echo "Error building $nix_target" exit 1 fi done # Attempt to deploy each of the deploy targets # If one fails to deploy, the script will continue to deploy the remaining targets deploy_failed=false jq -c '.[]' <<<"$DEPLOY_TARGETS" | while read target; do target_name=$(echo $target | jq -r '.name') nix_target=$(echo $target | jq -r '.nix_target') target_host=$(echo $target | jq -r '.host') build_host=$(echo $target | jq -r '.build_host') user=$(echo $target | jq -r '.user') pub_key_file=$(mktemp) echo "$target_host $(echo $target | jq -r '.pub_key')" > $pub_key_file echo "" >> $pub_key_file echo "Deploying to $target_name" export NIX_SSHOPTS="-i $DEPLOY_SSH_KEY -o UserKnownHostsFile=$pub_key_file" echo $nix_target # Deploy the target, nixos-rebuild switch --flake .#$nix_target --use-remote-sudo --target-host "$user@$target_host" switch_status=$? if [ $switch_status -ne 0 ]; then echo "Error deploying to $target_name" deploy_failed=true # Mark that a deploy failed so the script can exit 1 at the end but continue to deploy the remaining targets fi echo "finished deploying to $target_name" done if [[ $deploy_failed == "true" ]]; then echo "deploy failed" exit 1 else echo "deploy finished successfully" exit 0 fi '' ================================================ FILE: nix/digital-ocean/digital-ocean-config.nix ================================================ { config, pkgs, lib, modulesPath, ... }: with lib; { imports = [ (modulesPath + "/profiles/qemu-guest.nix") (modulesPath + "/virtualisation/digital-ocean-init.nix") ]; options.virtualisation.digitalOcean = with types; { setRootPassword = mkOption { type = bool; default = false; example = true; description = "Whether to set the root password from the Digital Ocean metadata"; }; setSshKeys = mkOption { type = bool; default = true; example = true; description = "Whether to fetch ssh keys from Digital Ocean"; }; seedEntropy = mkOption { type = bool; default = true; example = true; description = "Whether to run the kernel RNG entropy seeding script from the Digital Ocean vendor data"; }; }; config = let cfg = config.virtualisation.digitalOcean; hostName = config.networking.hostName; doMetadataFile = "/run/do-metadata/v1.json"; in mkMerge [{ boot = { growPartition = true; kernelParams = [ "console=ttyS0" "panic=1" "boot.panic_on_fail" ]; supportedFilesystems = [ "zfs" ]; initrd.kernelModules = [ "virtio_scsi" ]; kernelModules = [ "virtio_pci" "virtio_net" ]; zfs = { devNodes = "/dev/"; forceImportAll = true; }; loader = { grub.device = "/dev/vda"; timeout = 5; grub.configurationLimit = 0; grub.splashImage = null; grub.zfsSupport = true; }; }; systemd.services."serial-getty@tty0".enable = true; services.openssh = { enable = mkDefault true; settings.PasswordAuthentication = mkDefault false; }; services.do-agent.enable = mkDefault true; networking = { hostName = mkDefault ""; # use Digital Ocean metadata server }; /* Check for and wait for the metadata server to become reachable. * This serves as a dependency for all the other metadata services. */ systemd.services.digitalocean-metadata = { path = [ pkgs.curl ]; description = "Get host metadata provided by Digitalocean"; script = '' set -eu DO_DELAY_ATTEMPTS=0 while ! curl -fsSL -o $RUNTIME_DIRECTORY/v1.json http://169.254.169.254/metadata/v1.json; do DO_DELAY_ATTEMPTS=$((DO_DELAY_ATTEMPTS + 1)) if (( $DO_DELAY_ATTEMPTS >= $DO_DELAY_ATTEMPTS_MAX )); then echo "giving up" exit 1 fi echo "metadata unavailable, trying again in 1s..." sleep 1 done chmod 600 $RUNTIME_DIRECTORY/v1.json ''; environment = { DO_DELAY_ATTEMPTS_MAX = "10"; }; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; RuntimeDirectory = "do-metadata"; RuntimeDirectoryPreserve = "yes"; }; unitConfig = { ConditionPathExists = "!${doMetadataFile}"; After = [ "network-pre.target" ] ++ optional config.networking.dhcpcd.enable "dhcpcd.service" ++ optional config.systemd.network.enable "systemd-networkd.service"; }; }; /* Fetch the root password from the digital ocean metadata. * There is no specific route for this, so we use jq to get * it from the One Big JSON metadata blob */ systemd.services.digitalocean-set-root-password = mkIf cfg.setRootPassword { path = [ pkgs.shadow pkgs.jq ]; description = "Set root password provided by Digitalocean"; wantedBy = [ "multi-user.target" ]; script = '' set -eo pipefail ROOT_PASSWORD=$(jq -er '.auth_key' ${doMetadataFile}) echo "root:$ROOT_PASSWORD" | chpasswd mkdir -p /etc/do-metadata/set-root-password ''; unitConfig = { ConditionPathExists = "!/etc/do-metadata/set-root-password"; Before = optional config.services.openssh.enable "sshd.service"; After = [ "digitalocean-metadata.service" ]; Requires = [ "digitalocean-metadata.service" ]; }; serviceConfig = { Type = "oneshot"; }; }; /* Set the hostname from Digital Ocean, unless the user configured it in * the NixOS configuration. The cached metadata file isn't used here * because the hostname is a mutable part of the droplet. */ systemd.services.digitalocean-set-hostname = mkIf (hostName == "") { path = [ pkgs.curl pkgs.nettools ]; description = "Set hostname provided by Digitalocean"; wantedBy = [ "network.target" ]; script = '' set -e DIGITALOCEAN_HOSTNAME=$(curl -fsSL http://169.254.169.254/metadata/v1/hostname) hostname "$DIGITALOCEAN_HOSTNAME" if [[ ! -e /etc/hostname || -w /etc/hostname ]]; then printf "%s\n" "$DIGITALOCEAN_HOSTNAME" > /etc/hostname fi ''; unitConfig = { Before = [ "network.target" ]; After = [ "digitalocean-metadata.service" ]; Wants = [ "digitalocean-metadata.service" ]; }; serviceConfig = { Type = "oneshot"; }; }; /* Fetch the ssh keys for root from Digital Ocean */ systemd.services.digitalocean-ssh-keys = mkIf cfg.setSshKeys { description = "Set root ssh keys provided by Digital Ocean"; wantedBy = [ "multi-user.target" ]; path = [ pkgs.jq ]; script = '' set -e mkdir -m 0700 -p /root/.ssh jq -er '.public_keys[]' ${doMetadataFile} > /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys ''; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; unitConfig = { ConditionPathExists = "!/root/.ssh/authorized_keys"; Before = optional config.services.openssh.enable "sshd.service"; After = [ "digitalocean-metadata.service" ]; Requires = [ "digitalocean-metadata.service" ]; }; }; /* Initialize the RNG by running the entropy-seed script from the * Digital Ocean metadata */ systemd.services.digitalocean-entropy-seed = mkIf cfg.seedEntropy { description = "Run the kernel RNG entropy seeding script from the Digital Ocean vendor data"; wantedBy = [ "network.target" ]; path = [ pkgs.jq pkgs.mpack ]; script = '' set -eo pipefail TEMPDIR=$(mktemp -d) jq -er '.vendor_data' ${doMetadataFile} | munpack -tC $TEMPDIR ENTROPY_SEED=$(grep -rl "DigitalOcean Entropy Seed script" $TEMPDIR) ${pkgs.runtimeShell} $ENTROPY_SEED rm -rf $TEMPDIR ''; unitConfig = { Before = [ "network.target" ]; After = [ "digitalocean-metadata.service" ]; Requires = [ "digitalocean-metadata.service" ]; }; serviceConfig = { Type = "oneshot"; }; }; } ]; meta.maintainers = with maintainers; [ arianvp eamsden ]; } ================================================ FILE: nix/digital-ocean/digital-ocean-custom-image.nix ================================================ { config, lib, pkgs, ... }: with lib; let cfg = config.virtualisation.digitalOceanImage; in { imports = [ ./digital-ocean-config.nix ]; options = { virtualisation.digitalOceanImage.rootSize = mkOption { type = with types; int; default = 8192; example = 8192; description = '' Size of disk image. Unit is MB. ''; }; virtualisation.digitalOceanImage.datasets = mkOption { type = with types; attrs; default = {}; }; virtualisation.digitalOceanImage.configFile = mkOption { type = with types; nullOr path; default = null; description = '' A path to a configuration file which will be placed at /etc/nixos/configuration.nix and be used when switching to a new configuration. If set to null, a default configuration is used that imports (modulesPath + "/virtualisation/digital-ocean-config.nix"). ''; }; virtualisation.digitalOceanImage.compressionMethod = mkOption { type = types.enum [ "gzip" "bzip2" ]; default = "gzip"; example = "bzip2"; description = '' Disk image compression method. Choose bzip2 to generate smaller images that take longer to generate but will consume less metered storage space on your Digital Ocean account. ''; }; }; #### implementation config = { system.build.digitalOceanImage = import ./make-single-disk-zfs-image.nix { name = "digital-ocean-image"; format = "qcow2"; postVM = let compress = { "gzip" = "${pkgs.gzip}/bin/gzip"; "bzip2" = "${pkgs.bzip2}/bin/bzip2"; }.${cfg.compressionMethod}; in '' ${compress} $rootDiskImage ''; configFile = if cfg.configFile == null then config.virtualisation.digitalOcean.defaultConfigFile else cfg.configFile; rootPoolName = "rpool"; inherit (cfg) rootSize; inherit (cfg) datasets; inherit config lib pkgs; }; }; meta.maintainers = with maintainers; [ arianvp eamsden ]; } ================================================ FILE: nix/digital-ocean/make-single-disk-zfs-image.nix ================================================ # Note: This is a private API, internal to NixOS. Its interface is subject # to change without notice. # # The result of this builder is a single disk image, partitioned like this: # # * partition #1: a very small, 1MiB partition to leave room for Grub. # # * partition #2: boot, a partition formatted with FAT to be used for /boot. # FAT is chosen to support EFI. # # * partition #3: nixos, a partition dedicated to a zpool. # # This single-disk approach does not satisfy ZFS's requirements for autoexpand, # however automation can expand it anyway. For example, with # `services.zfs.expandOnBoot`. { lib , pkgs , # The NixOS configuration to be installed onto the disk image. config , # size of the FAT partition, in megabytes. bootSize ? 1024 , # The size of the root partition, in megabytes. rootSize ? 2048 , # The name of the ZFS pool rootPoolName ? "tank" , # zpool properties rootPoolProperties ? { autoexpand = "on"; } , # pool-wide filesystem properties rootPoolFilesystemProperties ? { acltype = "posixacl"; atime = "off"; compression = "on"; mountpoint = "legacy"; xattr = "sa"; } , # datasets, with per-attribute options: # mount: (optional) mount point in the VM # properties: (optional) ZFS properties on the dataset, like filesystemProperties # Notes: # 1. datasets will be created from shorter to longer names as a simple topo-sort # 2. you should define a root's dataset's mount for `/` datasets ? { } , # The files and directories to be placed in the target file system. # This is a list of attribute sets {source, target} where `source' # is the file system object (regular file or directory) to be # grafted in the file system at path `target'. contents ? [ ] , # The initial NixOS configuration file to be copied to # /etc/nixos/configuration.nix. This configuration will be embedded # inside a configuration which includes the described ZFS fileSystems. configFile ? null , # Shell code executed after the VM has finished. postVM ? "" , name ? "nixos-disk-image" , # Disk image format, one of qcow2, qcow2-compressed, vdi, vpc, raw. format ? "raw" , # Include a copy of Nixpkgs in the disk image includeChannel ? true }: let formatOpt = if format == "qcow2-compressed" then "qcow2" else format; compress = lib.optionalString (format == "qcow2-compressed") "-c"; filenameSuffix = "." + { qcow2 = "qcow2"; vdi = "vdi"; vpc = "vhd"; raw = "img"; }.${formatOpt} or formatOpt; rootFilename = "nixos.root${filenameSuffix}"; # FIXME: merge with channel.nix / make-channel.nix. channelSources = let nixpkgs = lib.cleanSource pkgs.path; in pkgs.runCommand "nixos-${config.system.nixos.version}" { } '' mkdir -p $out cp -prd ${nixpkgs.outPath} $out/nixos chmod -R u+w $out/nixos if [ ! -e $out/nixos/nixpkgs ]; then ln -s . $out/nixos/nixpkgs fi rm -rf $out/nixos/.git echo -n ${config.system.nixos.versionSuffix} > $out/nixos/.version-suffix ''; closureInfo = pkgs.closureInfo { rootPaths = [ config.system.build.toplevel ] ++ (lib.optional includeChannel channelSources); }; modulesTree = pkgs.aggregateModules (with config.boot.kernelPackages; [ kernel zfs ]); tools = lib.makeBinPath ( with pkgs; [ config.system.build.nixos-enter config.system.build.nixos-install dosfstools e2fsprogs gptfdisk nix parted utillinux zfs ] ); hasDefinedMount = disk: ((disk.mount or null) != null); stringifyProperties = prefix: properties: lib.concatStringsSep " \\\n" ( lib.mapAttrsToList ( property: value: "${prefix} ${lib.escapeShellArg property}=${lib.escapeShellArg value}" ) properties ); featuresToProperties = features: lib.listToAttrs (builtins.map (feature: { name = "feature@${feature}"; value = "enabled"; }) features); createDatasets = let datasetlist = lib.mapAttrsToList lib.nameValuePair datasets; sorted = lib.sort (left: right: (lib.stringLength left.name) < (lib.stringLength right.name)) datasetlist; cmd = { name, value }: let properties = stringifyProperties "-o" (value.properties or { }); in "zfs create -p ${properties} ${name}"; in lib.concatMapStringsSep "\n" cmd sorted; mountDatasets = let datasetlist = lib.mapAttrsToList lib.nameValuePair datasets; mounts = lib.filter ({ value, ... }: hasDefinedMount value) datasetlist; sorted = lib.sort (left: right: (lib.stringLength left.value.mount) < (lib.stringLength right.value.mount)) mounts; cmd = { name, value }: '' mkdir -p /mnt${lib.escapeShellArg value.mount} mount -t zfs ${name} /mnt${lib.escapeShellArg value.mount} ''; in lib.concatMapStringsSep "\n" cmd sorted; unmountDatasets = let datasetlist = lib.mapAttrsToList lib.nameValuePair datasets; mounts = lib.filter ({ value, ... }: hasDefinedMount value) datasetlist; sorted = lib.sort (left: right: (lib.stringLength left.value.mount) > (lib.stringLength right.value.mount)) mounts; cmd = { name, value }: '' umount /mnt${lib.escapeShellArg value.mount} ''; in lib.concatMapStringsSep "\n" cmd sorted; fileSystemsCfgFile = let mountable = lib.filterAttrs (_: value: hasDefinedMount value) datasets; in pkgs.runCommand "filesystem-config.nix" { buildInputs = with pkgs; [ jq nixpkgs-fmt ]; filesystems = builtins.toJSON { fileSystems = lib.mapAttrs' ( dataset: attrs: { name = attrs.mount; value = { fsType = "zfs"; device = "${dataset}"; }; } ) mountable; }; passAsFile = [ "filesystems" ]; } '' ( echo "builtins.fromJSON '''" jq . < "$filesystemsPath" echo "'''" ) > $out nixpkgs-fmt $out ''; mergedConfig = if configFile == null then fileSystemsCfgFile else pkgs.runCommand "configuration.nix" { buildInputs = with pkgs; [ nixpkgs-fmt ]; } '' ( echo '{ imports = [' printf "(%s)\n" "$(cat ${fileSystemsCfgFile})"; printf "(%s)\n" "$(cat ${configFile})"; echo ']; }' ) > $out nixpkgs-fmt $out ''; image = ( pkgs.vmTools.override { rootModules = [ "zfs" "9p" "9pnet_virtio" "virtio_pci" "virtio_blk" ] ++ (pkgs.lib.optional pkgs.stdenv.hostPlatform.isx86 "rtc_cmos"); kernel = modulesTree; } ).runInLinuxVM ( # nixpkgs/pkgs/build-support/vm/default.nix#L388 pkgs.runCommand name { memSize = 8192; QEMU_OPTS = "-smp 8 -drive file=$rootDiskImage,if=virtio,cache=unsafe,werror=report"; preVM = '' PATH=$PATH:${pkgs.qemu_kvm}/bin mkdir $out rootDiskImage=root.raw qemu-img create -f raw $rootDiskImage ${toString (bootSize + rootSize)}M ''; postVM = '' ${if formatOpt == "raw" then '' mv $rootDiskImage $out/${rootFilename} '' else '' ${pkgs.qemu}/bin/qemu-img convert -f raw -O ${formatOpt} ${compress} $rootDiskImage $out/${rootFilename} ''} rootDiskImage=$out/${rootFilename} set -x ${postVM} ''; } '' export PATH=${tools}:$PATH set -x cp -sv /dev/vda /dev/sda cp -sv /dev/vda /dev/xvda parted --script /dev/vda -- \ mklabel gpt \ mkpart no-fs 1MiB 2MiB \ set 1 bios_grub on \ align-check optimal 1 \ mkpart primary fat32 2MiB ${toString bootSize}MiB \ align-check optimal 2 \ mkpart primary fat32 ${toString bootSize}MiB -1MiB \ align-check optimal 3 \ print sfdisk --dump /dev/vda zpool create \ ${stringifyProperties " -o" rootPoolProperties} \ ${stringifyProperties " -O" rootPoolFilesystemProperties} \ ${rootPoolName} /dev/vda3 parted --script /dev/vda -- print ${createDatasets} ${mountDatasets} mkdir -p /mnt/boot mkfs.vfat -n ESP /dev/vda2 mount /dev/vda2 /mnt/boot mount # Install a configuration.nix mkdir -p /mnt/etc/nixos # `cat` so it is mutable on the fs cat ${mergedConfig} > /mnt/etc/nixos/configuration.nix export NIX_STATE_DIR=$TMPDIR/state nix-store --load-db < ${closureInfo}/registration nixos-install \ --root /mnt \ --no-root-passwd \ --system ${config.system.build.toplevel} \ --substituters "" \ ${lib.optionalString includeChannel ''--channel ${channelSources}''} df -h umount /mnt/boot ${unmountDatasets} zpool export ${rootPoolName} '' ); in image ================================================ FILE: nix/incredible-cfg.nix ================================================ { config, pkgs, ... }: { imports = [ ./users/deploy.nix ]; system.stateVersion = "22.05"; networking.hostId = "a1c232ce"; fileSystems."/" = { device = "rpool/save/root"; fsType = "zfs"; }; fileSystems."/home" = { device = "rpool/save/home"; fsType = "zfs"; }; fileSystems."/var/www" = { device = "rpool/save/var/www"; fsType = "zfs"; }; fileSystems."/save" = { device = "rpool/save"; fsType = "zfs"; options = [ "ro" ]; }; fileSystems."/var" = { device = "rpool/save/var"; fsType = "zfs"; }; fileSystems."/var/log/journal" = { device = "rpool/local/journal"; fsType = "zfs"; }; fileSystems."/nix" = { device = "rpool/local/nix"; fsType = "zfs"; }; fileSystems."/boot" = { # The ZFS image uses a partition labeled ESP whether or not we're # booting with EFI. device = "/dev/disk/by-label/ESP"; fsType = "vfat"; }; services.zfs.trim.enable = true; services.zfs.autoScrub.enable = true; services.zfs.expandOnBoot = [ "rpool" ]; #uncommenting this brings in all of X11 via cloud-utils and its dep qemu. nix = { package = pkgs.nixVersions.stable; settings = { # so that deploy user can copy into to the nix store trusted-users = [ "deploy" ]; # Haskell.nix cache substituters = [ "https://cache.iog.io" ]; trusted-public-keys = [ "hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=" ]; }; extraOptions = '' builders-use-substitutes = true experimental-features = nix-command flakes ''; }; # Used for the deploy user to run non-interactive remote nixos-rebuild security.sudo.extraRules= [ { users = [ "deploy" ]; commands = [ { command = "ALL" ; options= [ "NOPASSWD" ]; }]; }]; environment.systemPackages = with pkgs; [ rxvt_unicode.terminfo wget htop dstat ethtool tmux git git-lfs emacs-nox rsync rrsync ]; sound.enable = false; services.xserver.enable = false; users.mutableUsers = false; users.groups.site_deploy = {}; networking.firewall = { enable = true; allowedTCPPorts = [ 80 443 ]; allowedUDPPorts = [ 443 ]; }; services.openssh.enable = true; } ================================================ FILE: nix/incredible-digital-ocean.nix ================================================ { config, pkgs, ... }: { imports = [ ./digital-ocean/digital-ocean-custom-image.nix ]; virtualisation = { digitalOceanImage = { rootSize = 8192; # might need to be bigger # configFile = ./basicly-blank-digital-ocean-cfg.nix; datasets = { "rpool/save/root".mount = "/"; "rpool/save/home".mount = "/home"; "rpool/save/var".mount = "/var"; "rpool/save".mount = "/save"; "rpool/save/var/www".mount = "/var/www"; "rpool/local/journal".mount = "/var/log/journal"; "rpool/local/nix".mount = "/nix"; }; }; }; } ================================================ FILE: nix/incredible-frontend.nix ================================================ incredible-frontend: { config, pkgs, lib, ... }: with lib; let cfg = config.services.incredible-frontend; in { options = { services.incredible-frontend = { enable = mkEnableOption "incredible frontend"; }; }; config = mkIf cfg.enable { networking.firewall = { allowedTCPPorts = [ 8889 ]; }; services.nginx = { enable = true; recommendedOptimisation = true; recommendedTlsSettings = true; recommendedGzipSettings = true; virtualHosts = { localhost = { default = true; extraConfig = '' charset UTF-8; ''; locations."/" = { root = "${incredible-frontend}/lib/node_modules/incredible/built/"; extraConfig = '' add_header Access-Control-Allow-Origin *; ''; }; listen = [ { addr = "0.0.0.0"; port = 8889; } ]; }; }; }; }; } ================================================ FILE: nix/incredible-qemu.nix ================================================ { config, modulesPath, pkgs, lib,... }: { imports = [ "${modulesPath}/profiles/qemu-guest.nix" "${modulesPath}/virtualisation/qemu-vm.nix" ]; services.qemuGuest.enable = true; services.getty.autologinUser = config.users.users.deploy.name; users.mutableUsers = lib.mkForce true; virtualisation = { memorySize = 2048; # MB diskSize = 8000; # MB cores = 2; forwardPorts = [ { from = "host"; host.port = 8889; guest.port = 8889; } { from = "host"; host.port = 8888; guest.port = 8888; } ]; }; } ================================================ FILE: nix/incredible-server.nix ================================================ incredible-server: { config, pkgs, lib, ... }: with lib; let cfg = config.services.incredible-server; in { options = { services.incredible-server = { enable = mkEnableOption "incredible web server"; config = mkOption { type = types.path; description = "Path to incredible.toml"; }; machine = mkOption { type = types.path; description = "Path to machine.json"; }; }; }; config = mkIf cfg.enable { networking.firewall = { enable = true; allowedTCPPorts = [ 8888 ]; }; services.redis.servers."incredible" = { enable = true; port = 6379; }; systemd.services.incredible = { description = "Incredible web server"; after = [ "network.target" "redis-incredible.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; ExecStart = "${incredible-server}/bin/incredible-server --config ${cfg.config} --machine ${cfg.machine}"; LimitNOFILE = 1000000; WorkingDirectory = "/home/incredible"; Restart = "always"; RestartSec = 5; User = "incredible"; Group = "incredible"; }; }; users.users.incredible = { isSystemUser = true; home = "/home/incredible"; group = "incredible"; createHome = true; }; users.groups.incredible = { members = [ "incredible" ]; }; }; } ================================================ FILE: nix/profiles/staging.nix ================================================ { config, lib, pkgs, ... }: { services.incredible-server = { enable = true; config = ../../config/incredible.toml; machine = ../../config/machine.json; }; services.incredible-frontend = { enable = true; }; } ================================================ FILE: nix/users/deploy.nix ================================================ { pkgs, ... }: { programs.zsh.enable = true; users.users.deploy = { isNormalUser = true; extraGroups = [ "wheel" ]; shell = pkgs.zsh; openssh.authorizedKeys.keys = [ # Add a public key here ]; }; } ================================================ FILE: src/Incredible/API.hs ================================================ {-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeOperators #-} module Incredible.API where import Control.Concurrent import Control.DeepSeq import Control.Monad import Control.Monad.Error.Class import Control.Monad.IO.Class import Control.Monad.Reader import qualified Data.Array as Array import Data.Cache.LRU.IO (AtomicLRU) import qualified Data.Cache.LRU.IO as LRU import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import qualified Data.Ix as Ix import Data.Maybe import qualified Data.Set as Set import qualified Data.Text as T import qualified Data.Text.Encoding as T import Data.Time import qualified Data.Vector as V import qualified Data.Vector.Unboxed as UV import Incredible.AntiEvil import Incredible.Data import Incredible.DataStore import Servant import System.Random import System.IO type CacheHeader = Header "Cache-Control" String type WorkOrderHeader = Header "X-WorkOrder" T.Text type BlueprintApi = "blueprint" :> "file" :> WorkOrderHeader :> ReqBody '[JSON] Blueprint :> Post '[JSON] (BlueprintID, (X, Y)) -- Only caches if it has a snapshot type FolioApi = "folio" :> Capture "blueprintid" BlueprintID :> Get '[JSON] (Headers '[CacheHeader] Folio) -- | Redirect to current Machine -- If just the current version, Blueprint IDs or Full, want If-Modified type MachineApi = "machine" :> ( "current" :> ( Get '[JSON] (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID))) -- ^ Redirects to ve[((X, Y), rsioned version which is cachable. :<|> "version" :> Get '[JSON] (Headers '[CacheHeader] MachineVersion)) :<|> Capture "version" MachineVersion :> Get '[JSON] (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID))) ) type DeltaApi = "machine" :> "delta" :> ( Capture "startVersion" MachineVersion :> "current" :> Get '[JSON] (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID)) -- ^ Redirects to two version version which is cachable. :<|> Capture "startVersion" MachineVersion :> Capture "endVersion" MachineVersion :> Get '[JSON] (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID)) ) type PuzzleAPI = "puzzle" :> Get '[JSON] (Headers '[CacheHeader, WorkOrderHeader] (HashMap PuzzleID Puzzle)) -- | Upload a machine -- type "submit" -- Moderation type ModAPI = "moderate" :> BasicAuth "incredible mod api" UserName :> ( "puzzle" :> Capture "puzzleid" PuzzleID :> "blueprintid" :> Get '[JSON] (Headers '[CacheHeader] [BlueprintID]) -- JSONL? :<|> "puzzle" :> Capture "puzzleid" PuzzleID :> "blueprint" :> Get '[JSON] (Headers '[CacheHeader] [(BlueprintID, Blueprint)]) -- JSONL? :<|> "puzzle" :> Capture "puzzleid" PuzzleID :> Get '[JSON] (Headers '[CacheHeader] Puzzle) :<|> "puzzle" :> Capture "puzzleid" PuzzleID :> "reissue" :> PostNoContent :<|> "build" :> Capture "X" X :> Capture "Y" Y :> ReqBody '[JSON] InspectionReport :> PostNoContent :<|> "burn" :> Capture "blueprintid" BlueprintID :> PostNoContent -- Just removed from the mod queue, delete if never added to any GameState? :<|> "machine" :> "current" :> Get '[JSON] (Headers '[CacheHeader] (VersionedMachine ModData)) ) type IncredibleAPI = BlueprintApi :<|> FolioApi :<|> MachineApi :<|> DeltaApi :<|> PuzzleAPI :<|> ModAPI incredibleAPI :: Proxy IncredibleAPI incredibleAPI = Proxy server :: forall s. IncredibleData s => ServerT IncredibleAPI (IncredibleHandler s) server = blueprintHandlers :<|> folioHandlers :<|> machineHandlers :<|> deltaHandlers :<|> puzzleHandlers :<|> modHandlers where blueprintHandlers = handleAddBlueprint folioHandlers = handleGetFolio machineHandlers = (handleMachineCurrentBlueprintID :<|> handleCurrentVersion) :<|> handleMachineBlueprintID deltaHandlers :: IncredibleData s => ServerT DeltaApi (IncredibleHandler s) deltaHandlers = handleGetCurrentDelta :<|> handleGetDelta puzzleHandlers = handleGetPuzzles modHandlers user = handlePuzzleBlueprintID user :<|> handlePuzzleBlueprint user :<|> handlePuzzle user :<|> handleReissue user :<|> handleBuild user :<|> handleBurn user :<|> handleModMachine user newtype IncredibleHandler s a = IncredibleHandler { unIncredibleHandler :: ReaderT (MachineShop s) Handler a } deriving (Functor, Applicative, Monad, MonadIO, MonadReader (MachineShop s), MonadError ServerError) runIncredibleHandler :: MachineShop s -> IncredibleHandler s a -> Handler a runIncredibleHandler config = flip runReaderT config . unIncredibleHandler cacheForever :: AddHeader "Cache-Control" String orig new => IncredibleHandler s orig -> IncredibleHandler s new cacheForever hdlr = addHeader "max-age=86400" <$> hdlr noCache :: AddHeader "Cache-Control" String orig new => IncredibleHandler s orig -> IncredibleHandler s new noCache hdlr = addHeader "no-store" <$> hdlr handleGetFolio :: IncredibleData s => BlueprintID -> IncredibleHandler s (Headers '[CacheHeader] Folio) handleGetFolio bpID = do blueprint <- maybe blueprint404 pure =<< getBlueprint bpID puzzle <- maybe (throwError $ err500 { errBody = "Puzzle not found" }) pure =<< asks (HashMap.lookup (bPuzzleID blueprint) . sdPuzzles) msnapshot <- getSnapshot bpID (if isJust msnapshot then cacheForever else noCache) $ pure $ Folio puzzle blueprint msnapshot handleAddBlueprint :: IncredibleData s => Maybe T.Text -> Blueprint -> IncredibleHandler s (BlueprintID, (X, Y)) handleAddBlueprint woidt bp' = do now <- liftIO $ getCurrentTime let bp = bp' { bSubmittedAt = Just now } doAdd <- checkWorkOrder woidt bp pzlLocCache <- asks sdPuzzleLocCache let pzlLoc = HashMap.findWithDefault mempty (bPuzzleID bp) pzlLocCache liftIO $ print (doAdd, bp) liftIO $ print pzlLoc liftIO $ hFlush stdout if doAdd then queueModeration bp else pure () -- TODO: Sleep for average queueModeration time -- Sleep 50 to 150ms to make timing attacks less trivial. liftIO $ randomRIO (50*1000, 150*1000) >>= threadDelay pure (blueprintID bp, fromMaybe (0,0) $ fmap fst $ UV.uncons pzlLoc) handleMachineCurrentBlueprintID :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID))) handleMachineCurrentBlueprintID = noCache $ do VersionedMachine v _ <- getCurrentMachine burl <- asks sdBaseUrl throwError err307 { errHeaders = [("Location", T.encodeUtf8 $ burl<>"machine/"<>T.pack (show v))]} handleMachineBlueprintID :: IncredibleData s => MachineVersion -> IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine (Maybe BlueprintID))) handleMachineBlueprintID mv = cacheForever $ do VersionedMachine mv . fmap (either (const Nothing) Just) <$> lookupMachine mv handleCurrentVersion :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader] MachineVersion) handleCurrentVersion = noCache $ vmVersion <$> getCurrentMachine handleGetDelta :: IncredibleData s => MachineVersion -> MachineVersion -> IncredibleHandler s (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID)) handleGetDelta startVersion endVersion = cacheForever $ fmap (endVersion,) $ (lruGenerate (machineDeltaHandler startVersion endVersion) (startVersion, endVersion)) =<< asks sdDeltaCache handleGetCurrentDelta :: IncredibleData s => MachineVersion -> IncredibleHandler s (Headers '[CacheHeader] (MachineVersion, MachineUpdates BlueprintID)) handleGetCurrentDelta startVersion = noCache $ do VersionedMachine v _ <- getCurrentMachine burl <- asks sdBaseUrl throwError err307 { errHeaders = [("Location", T.encodeUtf8 $ burl<>"machine/delta/"<>T.pack (show startVersion)<>"/"<>T.pack (show v))]} lookupMachine :: IncredibleData s => MachineVersion -> IncredibleHandler s GameState lookupMachine version = maybe machineNotFound pure =<< getMachine version where machineNotFound = throwError $ err404 { errBody = "Machine not found" } machineDeltaHandler :: IncredibleData s => MachineVersion -> MachineVersion -> IncredibleHandler s (MachineUpdates BlueprintID) machineDeltaHandler startVersion endVersion = do start <- lookupMachine startVersion end <- lookupMachine endVersion let delta = stripNothings $ deltaMachine (toBlueprintID <$> start) (toBlueprintID <$> end) pure delta where stripNothings :: MachineUpdates (Maybe a) -> MachineUpdates a stripNothings d = d { muConstruction = V.mapMaybe (\(i, ma) -> (i,) <$> ma) $ muConstruction d } toBlueprintID :: Either a BlueprintID -> Maybe BlueprintID toBlueprintID = either (const Nothing) Just lruGenerate :: (Ord key, MonadIO m, NFData val) => m val -> key -> AtomicLRU key val -> m val lruGenerate gen k lru = do mr <- liftIO $ LRU.lookup k lru case mr of (Just val) -> pure val Nothing -> do newVal <- gen newVal `deepseq` liftIO (LRU.insert k newVal lru) pure newVal readyPuzzles :: IncredibleData s => VersionedGameState -> IncredibleHandler s (HashMap PuzzleID Puzzle) readyPuzzles (VersionedMachine v m) = do lruGenerate (asks (HashMap.fromList . (`findReadEnoughPuzzles` m) . sdPuzzles)) v =<< asks sdReadyPuzzlesByVersion findReadEnoughPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)] findReadEnoughPuzzles pzls m = --findReadyPuzzles pzls m let rdy = prioPuzzles <> findReadyPuzzles pzls m fpw = firstPuzzles pzls m prioPuzzles = mapMaybe (\pid -> (pid,) <$> HashMap.lookup pid pzls) $ V.toList $ mmPrioPuzzles m in Set.toList $ Set.fromList (rdy <> (take 50 fpw)) --if not (null rdy) then rdy else take (10-length rdy) fpw handleGetPuzzles :: IncredibleData s => IncredibleHandler s (Headers '[CacheHeader, WorkOrderHeader] (HashMap PuzzleID Puzzle)) handleGetPuzzles = noCache $ do cm <- getCurrentMachine pzlmp <- readyPuzzles cm woid <- issueWorkOrder $ fmap fst $ HashMap.toList pzlmp pure $ addHeader woid $ pzlmp handlePuzzleBlueprintID :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] [BlueprintID]) handlePuzzleBlueprintID _user = noCache . listModerationQueue -- TODO limit number of results? handlePuzzleBlueprint :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] [(BlueprintID, Blueprint)]) handlePuzzleBlueprint _user pid = noCache $ do bids <- listModerationQueue pid catMaybes <$> traverse (\bpid -> (fmap (bpid,)) <$> getBlueprint bpid) bids -- TODO this silently drops errors, should it? handleReissue :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s NoContent handleReissue _user pid = do pzlMap <- asks sdPuzzleLocCache editCurrentMachine (\(VersionedMachine _ mm) -> do -- Only prioritize it if it is in the current machine, also clear out old prioritized ones. ((), mm {mmPrioPuzzles = V.filter (`HashMap.member` pzlMap) $ (mmPrioPuzzles mm) `V.snoc` pid})) pure NoContent handlePuzzle :: IncredibleData s => UserName -> PuzzleID -> IncredibleHandler s (Headers '[CacheHeader] Puzzle) handlePuzzle _user pid = noCache $ do maybe (throwError $ err404 { errBody = "Puzzle not found" }) pure =<< asks (HashMap.lookup pid . sdPuzzles) blueprint404 :: IncredibleHandler s a blueprint404 = throwError $ err404 { errBody = "Blueprint not found" } handleBuild :: IncredibleData s => UserName -> X -> Y -> InspectionReport -> IncredibleHandler s NoContent handleBuild _user x y (InspectionReport bpid snap) = do design <- mmGrid <$> asks sdMachineDesign let loc = (x, y) unless (Ix.inRange (Array.bounds design) loc) $ throwError $ err400 { errBody = "Not in bounds" } bp <- maybe blueprint404 pure =<< getBlueprint bpid () <- if (design Array.! (x, y))==bPuzzleID bp then do dequeueModeration bpid addSnapshot bpid snap wasThere <- editCurrentMachine (\(VersionedMachine _ mm) -> if bpid `elem` (bpidsInMachine mm) then (True, mm) else (False, mm { mmPrioPuzzles = V.filter (/=(bPuzzleID bp)) $ mmPrioPuzzles mm, mmGrid = mmGrid mm Array.// [((x,y), Right bpid)]}) ) when wasThere $ throwError $ err409 { errBody = "Blueprint already in use in the machine." } else throwError $ err400 { errBody = "Blueprint not a solution for that puzzle" } pure NoContent handleBurn :: IncredibleData s => UserName -> BlueprintID -> IncredibleHandler s NoContent handleBurn _user bpid = dequeueModeration bpid >> pure NoContent handleModMachine :: IncredibleData s => UserName -> IncredibleHandler s (Headers '[CacheHeader] (VersionedMachine ModData)) handleModMachine _user = noCache $ do cm <- getCurrentMachine worthModerating <- HashMap.keys <$> readyPuzzles cm modLengthMap <- HashMap.fromList <$> modQueueLength worthModerating VersionedMachine (vmVersion cm) <$> (`translateMachine` vmMachine cm) (\_xy tileContent -> do let mbpid = either (const Nothing) Just tileContent pid <- either (pure) (\bpid -> maybe blueprint404 pure =<< fetchBPPuzzleID bpid) tileContent pure $ ModData mbpid pid (HashMap.lookup pid modLengthMap) ) fetchBPPuzzleID :: IncredibleData s => BlueprintID -> IncredibleHandler s (Maybe PuzzleID) fetchBPPuzzleID bpid = lruFetch (fmap (fmap bPuzzleID) . getBlueprint) bpid =<< asks sdBlueprint2PuzzleCache ================================================ FILE: src/Incredible/AntiEvil.hs ================================================ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} -- | Certainly not good. module Incredible.AntiEvil ( issueWorkOrder , checkWorkOrder ) where import Control.Monad.IO.Class import Control.Monad.Reader import qualified Data.Text as T import Incredible.Data import Incredible.DataStore issueWorkOrder :: (MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => [PuzzleID] -> m T.Text issueWorkOrder _ = pure "test-work-order" checkWorkOrder :: (MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => Maybe T.Text -> Blueprint -> m Bool checkWorkOrder _ _ = pure True ================================================ FILE: src/Incredible/App.hs ================================================ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} module Incredible.App ( openMachineShop , factoryRecall , incredibleApp , loadPuzzleMachine , writePuzzleMachine ) where import Control.Applicative import Control.Monad import Control.Monad.IO.Class import Control.Monad.Reader import qualified Crypto.BCrypt as BCrypt import qualified Data.Aeson as JS import qualified Data.Cache.LRU.IO as LRU import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import qualified Data.Text.Encoding as Text import Incredible.API import Incredible.Config import Incredible.Data import Incredible.DataStore import qualified Network.HTTP.Types.Header as HTTP import qualified Network.Wai as WAI import Network.Wai.Middleware.AddHeaders import Network.Wai.Middleware.Cors import Network.Wai.Parse (defaultParseRequestBodyOptions, setMaxRequestFileSize) import Servant import qualified Servant as Auth import Servant.Multipart (MultipartOptions(generalOptions) , defaultMultipartOptions, Mem) -- | We load the puzzles from the machine, so structurally we have all the puzzles. -- The puzzles might change over time though so we'll have to eject any Blueprints -- that don't match the current puzzles. loadPuzzleMachine :: MonadIO m => FilePath -> m (MetaMachine Puzzle) loadPuzzleMachine flnm = liftIO $ do ePzl <- JS.eitherDecodeFileStrict' flnm case ePzl of Left err -> fail $ "Could not load \""<>flnm<>"\": "<>err Right pzl -> pure pzl -- | Write a generate machine to a file writePuzzleMachine :: MonadIO m => FilePath -> MetaMachine Puzzle -> m () writePuzzleMachine flnm pzl = liftIO $ JS.encodeFile flnm pzl -- | Looks at a datastore, and if the machine there has any out dated -- puzzles or blueprints for outdated puzzles, updates them to the new spec. factoryRecall :: forall s m . (Alternative m, MonadFail m, MonadIO m, IncredibleData s, MonadReader (MachineShop s) m) => MetaMachine Puzzle -> m () factoryRecall machineDesign = go where go = do cm <- vmMachine <$> getCurrentMachine remanufactured <- remanufacture machineDesign <$> traverse (either (pure . Left) (maybe (fail "Blueprint lookup failed!") (pure . Right) <=< getBlueprint) ) cm -- If we made no changes, we're done. when (cm /= remanufactured) $ do -- If we did an update, check if the machine hasn't changed and if it hasn't replace it and be done. -- If it has changed, leave it the same and try again. updated <- editCurrentMachine (\(VersionedMachine _ ngs) -> if ngs==cm then (True, remanufactured) else (False, ngs)) if updated then pure () else go openMachineShop :: (MonadIO m, IncredibleData s) => IncredibleConfig -> MetaMachine Puzzle -> (IncredibleConfig -> MetaMachine Puzzle -> IO (s, HashMap PuzzleID Puzzle)) -> m (MachineShop s) openMachineShop ic pzlm store = liftIO $ do (s, pzlMap) <- store ic pzlm mbv <- LRU.newAtomicLRU $ incredibleMachineByVersion $ incredibleCacheConfig ic pbv <- LRU.newAtomicLRU $ incrediblePuzzlesByVersion $ incredibleCacheConfig ic bc <- LRU.newAtomicLRU $ incredibleBlueprintByID $ incredibleCacheConfig ic dc <- LRU.newAtomicLRU $ incredibleDeltaCache $ incredibleCacheConfig ic sc <- LRU.newAtomicLRU $ incredibleSnapshotByID $ incredibleCacheConfig ic b2pc <- LRU.newAtomicLRU $ incredibleBlueprint2Snapshot $ incredibleCacheConfig ic let ms = MachineShop { sdPuzzles = pzlMap , sdMachineDesign = fmap puzzleID pzlm , sdModLogins = incredibleMods ic , sdOrigins = incredibleOrigins $ incredibleWebConfig ic , sdStore = s , sdBaseUrl = incredibleBaseUrl $ incredibleWebConfig ic , sdMachineByVersion = mbv , sdReadyPuzzlesByVersion = pbv , sdBlueprintCache = bc , sdDeltaCache = dc , sdSnapshotCache = sc , sdPuzzleLocCache = cachePuzzleLoc $ fmap puzzleID pzlm , sdBlueprint2PuzzleCache = b2pc } (`runReaderT` ms) $ do factoryRecall pzlm cm <- getCurrentMachine forM_ (bpidsInMachine $ vmMachine cm) dequeueModeration pure ms incredibleApp :: IncredibleData s => MachineShop s -> Application incredibleApp icontext = addHeaders [("Vary", "Origin")] $ cors policy $ serveWithContextT (Proxy :: Proxy IncredibleAPI) context hoist server where size128k = 128*1024 multipartOpts :: MultipartOptions Mem multipartOpts = (defaultMultipartOptions (Proxy :: Proxy Mem)) { generalOptions = setMaxRequestFileSize size128k defaultParseRequestBodyOptions } -- Servant context context = multipartOpts :. authCheck :. EmptyContext -- Run handler with incredible context hoist = runIncredibleHandler icontext -- verify encrypted input = Scrypt.verifyPass' (Pass pass) (EncryptedPass encrypted) authCheck = Auth.BasicAuthCheck $ \auth -> do let user = Text.decodeUtf8 $ Auth.basicAuthUsername auth case HashMap.lookup user (sdModLogins icontext) of Just digest -> do let valid = BCrypt.validatePassword (Text.encodeUtf8 digest) (Auth.basicAuthPassword auth) pure $ if valid then Auth.Authorized user else Auth.BadPassword Nothing -> pure Auth.NoSuchUser policy req = Just CorsResourcePolicy { corsOrigins = if (lookup HTTP.hOrigin $ WAI.requestHeaders req) `elem` (fmap Just $ sdOrigins icontext) then Just (sdOrigins icontext, True) else Nothing , corsMethods = ["GET"] , corsRequestHeaders = ["authorization", "content-type", "X-WorkOrder"] , corsExposedHeaders = Just ["X-WorkOrder"] , corsMaxAge = Just $ 60*60*24 -- one day , corsVaryOrigin = False , corsRequireOrigin = False , corsIgnoreFailures = False } ================================================ FILE: src/Incredible/Config.hs ================================================ {-# LANGUAGE ImportQualifiedPost #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE InstanceSigs #-} module Incredible.Config where import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import qualified Data.Map as Map import Data.Maybe import Data.Text (Text) import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as Text import Database.Redis qualified as Redis import System.Exit (exitFailure) import Toml qualified import Toml.Schema qualified as Toml import Incredible.Data import qualified Data.ByteString as BS readIncredibleConfig :: FilePath -> IO IncredibleConfig readIncredibleConfig configFp = do tomlContents <- Text.readFile configFp case Toml.decode' tomlContents of Toml.Failure errors -> do putStrLn $ "Errors decoding toml config file " <> configFp <> " " <> prettyShowTomlError errors exitFailure Toml.Success warnings res -> do putStrLn $ "Warnings decoding toml config file " <> configFp <> " " <> prettyShowTomlError warnings pure res where prettyShowTomlError = show . fmap Toml.prettyDecodeError data IncredibleConfig = IncredibleConfig { incredibleWebConfig :: IncredibleWebConfig , incredibleCacheConfig :: IncredibleCacheConfig , incredibleRedis :: Maybe IncredibleRedisConfig , incredibleMods :: HashMap UserName UserDigest } deriving (Show) instance Toml.FromValue IncredibleConfig where fromValue = Toml.parseTableFromValue $ IncredibleConfig <$> Toml.reqKey "web" <*> Toml.reqKey "cache" <*> Toml.optKey "redis" <*> (fmap (HashMap.fromList . Map.toList) $ Toml.reqKey "mods") data IncredibleWebConfig = IncredibleWebConfig { incredibleWebPort :: Int , incredibleBaseUrl :: Text , incredibleOrigins :: [BS.ByteString] } deriving (Show) instance Toml.FromValue IncredibleWebConfig where fromValue = Toml.parseTableFromValue $ IncredibleWebConfig <$> Toml.reqKey "port" <*> Toml.reqKey "base_url" <*> fmap (fmap TE.encodeUtf8) (Toml.reqKey "origins") data IncredibleCacheConfig = IncredibleCacheConfig { incredibleMachineByVersion :: Maybe Integer , incrediblePuzzlesByVersion :: Maybe Integer , incredibleBlueprintByID :: Maybe Integer , incredibleDeltaCache :: Maybe Integer , incredibleSnapshotByID :: Maybe Integer , incredibleBlueprint2Snapshot :: Maybe Integer } deriving (Show) instance Toml.FromValue IncredibleCacheConfig where fromValue = Toml.parseTableFromValue $ IncredibleCacheConfig <$> Toml.optKey "machines" <*> Toml.optKey "puzzles" <*> Toml.optKey "blueprints" <*> Toml.optKey "deltas" <*> Toml.optKey "snapshots" <*> Toml.optKey "blueprint2puzzle" -- This contains all of the components of a redis connection because -- the certificate store has to be fetched to create the tls connection -- and that can't happen when parsing the config file data IncredibleRedisConfig = IncredibleRedisConfig { incredibleRedisHostName :: String , incredibleRedisPort :: Redis.PortID , incredibleRedisDatabase :: Integer , incredibleRedisMaxConnections :: Int , incredibleRedisMaxIdleTimeout :: Integer , incredibleRedisPassword :: Maybe BS.ByteString , incredibleRedisRetryCount :: Int , incredibleWorkOrderTTL :: Integer , incredibleOrderBookTTL :: Integer , incredibleRedisUseTLS :: Bool } deriving (Show) instance Toml.FromValue IncredibleRedisConfig where fromValue :: Toml.Value' l -> Toml.Matcher l IncredibleRedisConfig fromValue = Toml.parseTableFromValue $ do (host, port) <- lookupHostPort database <- Toml.reqKey "database" maxConnections <- Toml.reqKey "maxConnections" maxIdleTimeout <- Toml.reqKey "maxIdleTimeout" pass <- fmap TE.encodeUtf8 <$> Toml.optKey "password" useTLS <- fromMaybe False <$> Toml.optKey "tls" rcnt <- Toml.reqKey "retry_count" wottl <- Toml.reqKey "workorder_ttl" obttl <- Toml.reqKey "orderbook_ttl" pure $ IncredibleRedisConfig { incredibleRedisHostName = host , incredibleRedisPort = port , incredibleRedisDatabase = database , incredibleRedisMaxConnections = maxConnections , incredibleRedisMaxIdleTimeout = maxIdleTimeout , incredibleRedisPassword = pass , incredibleRedisRetryCount = rcnt , incredibleWorkOrderTTL = wottl , incredibleOrderBookTTL = obttl , incredibleRedisUseTLS = useTLS } where -- Lookup the port or unix socket lookupHostPort = do socket <- Toml.optKey "socket" case socket of Just sock -> pure ("", Redis.UnixSocket sock) Nothing -> do host <- Toml.reqKey "host" port <- Toml.reqKey "port" pure (host, Redis.PortNumber $ fromInteger port) ================================================ FILE: src/Incredible/Data.hs ================================================ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveTraversable #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# language ImportQualifiedPost #-} {-# LANGUAGE LambdaCase #-} {-# language OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE TypeFamilies #-} module Incredible.Data ( type PuzzleID , type BlueprintID , type X , type Y , type UserDigest , type UserName , type GameState , type VersionedGameState , type Position , puzzleID, blueprintID , RelativeCell(..) , Puzzle(..) , MetaMachine(..) , Blueprint(..) , MachineVersion , VersionedMachine(..) , MachineShop(..) , ModData(..) , BallTypes(..) , Gateway(..) , GatewayBall(..) , InspectionReport(..) , Snapshot , Folio(..) , MachineUpdates(..) , TileSize(..) , WorkOrderID(..) , WorkOrder(..) , WidgetSignature(..) -- , ServerSharedState(..) , deltaMachine , applyDelta , translateMachine , findReadyPuzzles , gridizeArray , remanufacture , cachePuzzleLoc , isSolved , isSolvable , firstPuzzles , bpidsInMachine ) where import Control.Applicative import Control.DeepSeq import Control.Monad import Data.Aeson qualified as JS import Data.Aeson.Types qualified as JS import Data.Array (Array) import Data.Array qualified as Array import Data.Bifunctor (bimap, first) import Data.Bytes.Serial qualified as S import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BSL import Data.Cache.LRU.IO (AtomicLRU) import Data.Data (Typeable) import Data.Either import Data.Functor.Identity import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap import Data.HashSet qualified as HashSet import Data.List import Data.Ix (Ix) import Data.Ix qualified as Ix import Data.Maybe import Data.Text (Text) import Data.Time (UTCTime) import Data.Tuple import Data.UUID (UUID) import Data.UUID.V5 qualified as UUIDV5 import Data.Vector qualified as V import Data.Vector.Unboxed qualified as UV import GHC.Generics type X = Int type Y = Int type PuzzleID = UUID type BlueprintID = UUID type MachineVersion = Integer type Position = (Double, Double) type GameState = MetaMachine (Either PuzzleID BlueprintID) type VersionedGameState = VersionedMachine (Either PuzzleID BlueprintID) type UserName = Text type UserDigest = Text newtype WorkOrderID = WorkOrderID UUID deriving (Show, Eq, Ord, Typeable, Generic) instance JS.ToJSON WorkOrderID instance JS.FromJSON WorkOrderID data WorkOrder = WorkOrder deriving (Show, Eq, Ord, NFData, Typeable, Generic) instance JS.ToJSON WorkOrder instance JS.FromJSON WorkOrder data RelativeCell = RCUpLeft | RCUp | RCUpRight | RCLeft | RCRight | RCDownLeft | RCDown | RCDownRight deriving (Read, Show, Eq, Ord, Bounded, Enum, NFData, Generic, Typeable) instance JS.ToJSON RelativeCell where toJSON RCUpLeft = "UpLeft" toJSON RCUp = "Up" toJSON RCUpRight = "UpRight" toJSON RCLeft = "Left" toJSON RCRight = "Right" toJSON RCDownLeft = "DownLeft" toJSON RCDown = "Down" toJSON RCDownRight = "DownRight" toEncoding RCUpLeft = "UpLeft" toEncoding RCUp = "Up" toEncoding RCUpRight = "UpRight" toEncoding RCLeft = "Left" toEncoding RCRight = "Right" toEncoding RCDownLeft = "DownLeft" toEncoding RCDown = "Down" toEncoding RCDownRight = "DownRight" instance JS.FromJSON RelativeCell where parseJSON = JS.withText "RelativeCell" $ \case "UpLeft" -> pure RCUpLeft "Up" -> pure RCUp "UpRight" -> pure RCUpRight "Left" -> pure RCLeft "Right" -> pure RCRight "DownLeft" -> pure RCDownLeft "Down" -> pure RCDown "DownRight" -> pure RCDownRight _ -> fail "Unknown RelativeCell" puzzleID :: Puzzle -> PuzzleID puzzleID pzl = -- We should specificly give a canonical output for this datastructure, -- say alphabetical pretty printed key names, sorted lists (sorting lists depends on JSON data allowances). UUIDV5.generateNamed puzzleNS $ BSL.unpack $ JS.encode pzl where puzzleNS :: UUID puzzleNS = read "1238b96a-5c4f-40d5-a980-a3212d128af6" blueprintID :: Blueprint -> BlueprintID blueprintID bpnt = -- We should specificly give a canonical output for this datastructure, -- say alphabetical pretty printed key names, sorted lists (sorting lists depends on JSON data allowances). UUIDV5.generateNamed blueprintNS $ BSL.unpack $ JS.encode bpnt where blueprintNS :: UUID blueprintNS = read "e5f4ae68-da49-458c-969e-231c61d53a26" data Folio = Folio { fPuzzle :: Puzzle , fBlueprint :: Blueprint , fSnapshot :: Maybe Snapshot } deriving (Show, Eq, Ord, NFData, Generic, Typeable) instance JS.ToJSON Folio where toJSON (Folio p b ms) = JS.object ["puzzle" JS..= p, "blueprint" JS..= b, "snapshot" JS..= ms] toEncoding (Folio p b ms) = JS.pairs ("puzzle" JS..= p <> "blueprint" JS..= b <> "snapshot" JS..= ms) instance JS.FromJSON Folio where parseJSON = JS.withObject "Folio" $ \o -> Folio <$> o JS..: "puzzle" <*> o JS..: "blueprint" <*> o JS..: "snapshot" type Rate = Double data GatewayBall = GatewayBall { gbType :: {-# UNPACK #-} !Int , gbRate :: {-# UNPACK #-} !Rate } deriving (Show, Eq, Ord, NFData, Generic, Typeable) instance JS.ToJSON GatewayBall where toJSON (GatewayBall typ rt) = JS.object ["type" JS..= typ, "rate" JS..= rt] toEncoding (GatewayBall typ rt) = JS.pairs ("type" JS..= typ <> "rate" JS..= rt) instance JS.FromJSON GatewayBall where parseJSON = JS.withObject "GatewayBall" $ \o -> GatewayBall <$> o JS..: "type" <*> o JS..: "rate" data Gateway = Gateway { gPos :: {-# UNPACK #-} !Position , gBalls :: {-# UNPACK #-} !(V.Vector GatewayBall) } deriving (Show, Eq, Ord, NFData, Generic, Typeable) instance JS.ToJSON Gateway where toJSON (Gateway (x,y) typ) = JS.object ["x" JS..= x, "y" JS..= y, "balls" JS..= typ] toEncoding (Gateway (x,y) typ) = JS.pairs ("x" JS..= x <> "y" JS..= y <> "balls" JS..= typ) instance JS.FromJSON Gateway where parseJSON = JS.withObject "Gateway" $ \o -> Gateway <$> ((,) <$> o JS..: "x" <*> o JS..: "y") <*> o JS..: "balls" data Puzzle = Puzzle { pReqTiles :: V.Vector RelativeCell -- ^ Which tiles of the machine need to be completed for this puzzle to be ready to be solved. , pInputs :: V.Vector Gateway , pOutputs :: V.Vector Gateway , pSpec :: JS.Object } deriving (Show, Eq, Ord, NFData, Generic, Typeable) instance JS.ToJSON Puzzle where toJSON (Puzzle rqtls is os pzl) = JS.object ["reqTiles" JS..= rqtls, "inputs" JS..= is, "outputs" JS..= os, "spec" JS..= pzl] toEncoding (Puzzle rqtls is os pzl) = JS.pairs ("reqTiles" JS..= rqtls <> "inputs" JS..= is <> "outputs" JS..= os <> "spec" JS..= pzl) instance JS.FromJSON Puzzle where parseJSON = JS.withObject "Puzzle" $ \o -> Puzzle <$> o JS..: "reqTiles" <*> o JS..: "inputs" <*> o JS..: "outputs" <*> o JS..: "spec" data TileSize = TileSize {-# UNPACK #-} !Int {-# UNPACK #-} !Int deriving (Show, Eq, Ord, NFData, Typeable, Generic) instance JS.ToJSON TileSize where toJSON (TileSize x y) = JS.object ["x" JS..= x, "y" JS..= y] toEncoding (TileSize x y) = JS.pairs $ ("x" JS..= x)<>("y" JS..= y) instance JS.FromJSON TileSize where parseJSON = JS.withObject "tile_size pos" $ \o -> TileSize <$> o JS..: "x" <*> o JS..: "y" -- | The overall machine. data MetaMachine tile = MetaMachine { mmGrid :: {-# UNPACK #-} !(Array (X, Y) tile) , mmTileSize :: {-# UNPACK #-} !TileSize , mmMillisecondPerBall :: {-# UNPACK #-} !Double , mmPrioPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID) } deriving (Show, Functor, Eq, Ord, Traversable, Foldable, NFData, Generic, Typeable) data VersionedMachine tile = VersionedMachine { vmVersion :: {-# UNPACK #-} !MachineVersion -- ^ Incrimented version of which edit of the metamachine this is , vmMachine :: MetaMachine tile } deriving (Show, Functor, Eq, Ord, Traversable, Foldable, NFData, Generic, Typeable) data ModData = ModData { mdBlueprint :: Maybe BlueprintID , mdPuzzleID :: PuzzleID , mdToMod :: Maybe Integer } deriving (Show, Eq, Ord, NFData, Generic, Typeable) bpidsInMachine :: GameState -> [BlueprintID] bpidsInMachine = rights . Array.elems . mmGrid instance JS.ToJSON ModData where toJSON (ModData mbpid pid tm) = JS.object $ ("puzzle" JS..= pid):catMaybes [fmap ("to_mod" JS..=) tm, fmap ("blueprint" JS..=) mbpid] toEncoding (ModData mbpid pid tm) = JS.pairs $ ("puzzle" JS..= pid) <> maybe mempty ("to_mod" JS..=) tm <> maybe mempty ("blueprint" JS..=) mbpid instance JS.FromJSON ModData where parseJSON = JS.withObject "Puzzle" $ \o -> ModData <$> o JS..:? "blueprint" <*> o JS..: "puzzle" <*> o JS..:? "to_mod" gridizeArray :: (Ix x, Ix y) => Array (x, y) a -> [[a]] gridizeArray a = let ((lbx, lby), (hbx, hby)) = Array.bounds a in (`map` Ix.range (lby, hby)) $ \y -> (`map` Ix.range (lbx, hbx)) $ \x -> a Array.! (x, y) jsonizeMetaMachine :: JS.ToJSON tile => MetaMachine tile -> [JS.Pair] jsonizeMetaMachine (MetaMachine g ts rt pp) = [ "tile_size" JS..= ts , "ms_per_ball" JS..= rt , "prio_puzzles" JS..= pp , "grid" JS..= gridizeArray g ] {-# INLINE jsonizeMetaMachine #-} jsonizeVersionedMachine :: JS.ToJSON tile => VersionedMachine tile -> [JS.Pair] jsonizeVersionedMachine (VersionedMachine v mm) = ("version" JS..= v):jsonizeMetaMachine mm {-# INLINE jsonizeVersionedMachine #-} jsonEncodeMetaMachine :: JS.ToJSON tile => MetaMachine tile -> JS.Series jsonEncodeMetaMachine (MetaMachine g ts rt pp) = ("tile_size" JS..= ts) <> ("ms_per_ball" JS..= rt) <> ("prio_puzzles" JS..= pp) <> ("grid" JS..= gridizeArray g) {-# INLINE jsonEncodeMetaMachine #-} jsonEncodeVersionedMachine :: JS.ToJSON tile => VersionedMachine tile -> JS.Series jsonEncodeVersionedMachine (VersionedMachine v mm) = ("version" JS..= v) <> jsonEncodeMetaMachine mm {-# INLINE jsonEncodeVersionedMachine #-} minimalEitherPuzzleBlueprint :: Either PuzzleID BlueprintID -> JS.Value minimalEitherPuzzleBlueprint = JS.object . (\case { Left pid -> ["puzzle" JS..= pid]; Right tid -> ["blueprint" JS..= tid]}) instance {-# OVERLAPPING #-} JS.ToJSON GameState where toJSON = JS.object . jsonizeMetaMachine . fmap minimalEitherPuzzleBlueprint {-# INLINE toJSON #-} toEncoding = JS.pairs . jsonEncodeMetaMachine . fmap minimalEitherPuzzleBlueprint {-# INLINE toEncoding #-} instance {-# OVERLAPPABLE #-} JS.ToJSON tile => JS.ToJSON (MetaMachine tile) where toJSON = JS.object . jsonizeMetaMachine {-# INLINE toJSON #-} toEncoding = JS.pairs . jsonEncodeMetaMachine {-# INLINE toEncoding #-} instance {-# OVERLAPPING #-} JS.ToJSON VersionedGameState where toJSON = JS.object . jsonizeVersionedMachine . fmap minimalEitherPuzzleBlueprint toEncoding = JS.pairs . jsonEncodeVersionedMachine . fmap minimalEitherPuzzleBlueprint {-# INLINE toEncoding #-} instance {-# OVERLAPPABLE #-} JS.ToJSON tile => JS.ToJSON (VersionedMachine tile) where toJSON = JS.object . jsonizeVersionedMachine toEncoding = JS.pairs . jsonEncodeVersionedMachine {-# INLINE toEncoding #-} parseIDObj :: JS.FromJSON a => JS.Key -> JS.Value -> JS.Parser a parseIDObj k = JS.withObject (show k) $ \o -> o JS..: k instance {-# OVERLAPPING #-} JS.FromJSON GameState where parseJSON = JS.withObject "MetaMachine" $ \o -> do gridList <- (o JS..: "grid") >>= JS.withArray "Grid-Y" (traverse $ JS.withArray "Grid-X" $ traverse $ \v -> Left <$> parseIDObj "puzzle" v <|> Right <$> parseIDObj "blueprint" v) let bndy = length gridList guard (bndy > 0) let bndx = length $ V.head gridList guard (bndx > 0) let grid = concatMap (\(y, xl) -> zipWith (\ x v -> ((x, y), v)) [0..] xl) $ zip [0..] $ V.toList (V.toList <$> gridList) p <- o JS..: "tile_size" rt <- o JS..: "ms_per_ball" pp <- (o JS..: "prio_puzzles") <|> pure mempty pure $ MetaMachine (Array.array ((0,0), (bndx-1, bndy-1)) grid) p rt pp instance {-# OVERLAPPABLE #-} JS.FromJSON tile => JS.FromJSON (MetaMachine tile) where parseJSON = JS.withObject "MetaMachine" $ \o -> do gridList <- o JS..: "grid" let bndy = length gridList let bndx = length $ head gridList let grid = concatMap (\(y, xl) -> zipWith (\ x v -> ((x, y), v)) [0..] xl) $ zip [0..] gridList p <- o JS..: "tile_size" rt <- o JS..: "ms_per_ball" pp <- (o JS..: "prio_puzzles") <|> pure mempty pure $ MetaMachine (Array.array ((0,0), (bndx-1, bndy-1)) grid) p rt pp instance {-# OVERLAPPING #-} JS.FromJSON VersionedGameState where parseJSON v = (\f -> JS.withObject "VersionedMachine" f v) $ \o -> do version <- o JS..: "version" machine <- JS.parseJSON v pure $ VersionedMachine version machine instance {-# OVERLAPPABLE #-} JS.FromJSON tile => JS.FromJSON (VersionedMachine tile) where parseJSON v = (\f -> JS.withObject "VersionedMachine" f v) $ \o -> do version <- o JS..: "version" machine <- JS.parseJSON v pure $ VersionedMachine version machine data Blueprint = Blueprint { bPuzzleID :: {-# UNPACK #-} !PuzzleID -- ^ which puzzle this is supposed to solve. -- Title Block stuff , bTitle :: {-# UNPACK #-} !Text , bSubmittedAt :: {-# UNPACK #-} !(Maybe UTCTime) -- Client can't send us the submitted date -- Actual submission , bWidgets :: {-# UNPACK #-} !(HashMap Int JS.Object) } deriving (Show, Eq, Ord, NFData, Generic, Typeable) data WidgetSignature = WidgetSignature { wName :: {-# UNPACK #-} !Text , wX :: {-# UNPACK #-} !Double , wY :: {-# UNPACK #-} !Double , wAngle :: {-# UNPACK #-} !(Maybe Double) , wWidth :: {-# UNPACK #-} !(Maybe Double) , wHeight :: {-# UNPACK #-} !(Maybe Double) , wRadius :: {-# UNPACK #-} !(Maybe Double) } deriving (Show, Eq, Ord, NFData, Generic, Typeable) instance S.Serial WidgetSignature instance JS.FromJSON WidgetSignature where parseJSON = JS.withObject "WidgetSignature" $ \o -> WidgetSignature <$> o JS..: "type" <*> o JS..: "x" <*> o JS..: "y" <*> o JS..:? "angle" <*> o JS..:? "width" <*> o JS..:? "height" <*> o JS..:? "radius" instance JS.ToJSON WidgetSignature where toJSON (WidgetSignature ty x y a w h r) = JS.object $ catMaybes [ Just ("type" JS..= ty) , Just ("x" JS..= x) , Just ("y" JS..= y) , fmap ("angle" JS..= ) a , fmap ("width" JS..= ) w , fmap ("height" JS..= ) h , fmap ("radius" JS..= ) r ] toEncoding (WidgetSignature ty x y a w h r) = JS.pairs $ mconcat $ catMaybes [ Just ("type" JS..= ty) , Just ("x" JS..= x) , Just ("y" JS..= y) , fmap ("angle" JS..= ) a , fmap ("width" JS..= ) w , fmap ("height" JS..= ) h , fmap ("radius" JS..= ) r ] instance JS.FromJSON Blueprint where parseJSON = JS.withObject "Blueprint" $ \o -> Blueprint <$> o JS..: "puzzle" <*> o JS..: "title" <*> o JS..:? "submittedAt" <*> o JS..: "widgets" instance JS.ToJSON Blueprint where toJSON (Blueprint p a t s) = JS.object [ "puzzle" JS..= p , "title" JS..= a , "submittedAt" JS..= t , "widgets" JS..= s ] toEncoding (Blueprint p a t s) = JS.pairs $ ("puzzle" JS..= p) <> ("title" JS..= a) <> ("submittedAt" JS..= t) <> ("widgets" JS..= s) data InspectionReport = InspectionReport { irBlueprint :: {-# UNPACK #-} !BlueprintID , irSnapshot :: {-# UNPACK #-} !Snapshot } deriving (Show, Eq, Ord, NFData, Generic, Typeable) type Snapshot = JS.Object instance JS.FromJSON InspectionReport where parseJSON = JS.withObject "InspectionReport" $ \o -> InspectionReport <$> o JS..: "blueprint" <*> o JS..: "snapshot" instance JS.ToJSON InspectionReport where toJSON (InspectionReport bp ss) = JS.object [ "blueprint" JS..= bp , "snapshot" JS..= ss ] toEncoding (InspectionReport bp ss) = JS.pairs $ ("blueprint" JS..= bp) <> ("snapshot" JS..= ss) data MachineShop s = MachineShop { sdPuzzles :: {-# UNPACK #-} !(HashMap PuzzleID Puzzle) , sdMachineDesign :: {-# UNPACK #-} !(MetaMachine PuzzleID) , sdModLogins :: {-# UNPACK #-} !(HashMap UserName UserDigest) , sdStore :: !s , sdBaseUrl :: {-# UNPACK #-} !Text , sdOrigins :: {-# UNPACK #-} ![BS.ByteString] -- | Caches , sdMachineByVersion :: {-# UNPACK #-} !(AtomicLRU MachineVersion GameState) , sdReadyPuzzlesByVersion :: {-# UNPACK #-} !(AtomicLRU MachineVersion (HashMap PuzzleID Puzzle)) , sdBlueprintCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID Blueprint) , sdSnapshotCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID Snapshot) , sdDeltaCache :: {-# UNPACK #-} !(AtomicLRU (MachineVersion, MachineVersion) (MachineUpdates BlueprintID)) , sdPuzzleLocCache :: {-# UNPACK #-} !(HashMap PuzzleID (UV.Vector (X,Y))) , sdBlueprint2PuzzleCache :: {-# UNPACK #-} !(AtomicLRU BlueprintID PuzzleID) } cachePuzzleLoc :: MetaMachine PuzzleID -> HashMap PuzzleID (UV.Vector (X, Y)) cachePuzzleLoc = fmap UV.fromList . HashMap.fromListWith (<>) . map (fmap pure . swap) . Array.assocs . mmGrid {- data ServerSharedState = ServerSharedState { sssForcedPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID) } deriving (Show, Eq, Generic, Typeable) instance JS.ToJSON ServerSharedState instance JS.FromJSON ServerSharedState instance Semigroup ServerSharedState where (ServerSharedState fp0) <> (ServerSharedState fp1) = ServerSharedState (fp0<>fp1) instance Monoid ServerSharedState where mempty = ServerSharedState mempty -} data MachineUpdates a = MachineUpdates { muTileSize :: {-# UNPACK #-} !TileSize , muGridSize :: {-# UNPACK #-} !(X, Y) , muMillisecondPerBall :: {-# UNPACK #-} !Double , muPriorityPuzzles :: {-# UNPACK #-} !(V.Vector PuzzleID) , muConstruction :: {-# UNPACK #-} !(V.Vector ((X,Y), a)) } deriving (Show, Eq, Ord, Functor, Traversable, Foldable, NFData, Generic, Typeable) instance JS.FromJSON a => JS.FromJSON (MachineUpdates a) where parseJSON = JS.withObject "MachineUpdates" $ \o -> MachineUpdates <$> o JS..: "tile_size" <*> o JS..: "grid_size" <*> o JS..: "ms_per_ball" <*> o JS..: "prio_puzzle" <*> o JS..: "construction" instance JS.ToJSON a => JS.ToJSON (MachineUpdates a) where toJSON (MachineUpdates ts gs r pp c) = JS.object [ "tile_size" JS..= ts , "grid_size" JS..= gs , "ms_per_ball" JS..= r , "prio_puzzle" JS..= pp , "construction" JS..= c ] toEncoding (MachineUpdates ts gs r pp c) = JS.pairs $ ("tile_size" JS..= ts) <> ("grid_size" JS..= gs) <> ("ms_per_ball" JS..= r) <> ("prio_puzzle" JS..= pp) <> ("construction" JS..= c) -- | Finds the thing to set in a first machine to generate the second machine. deltaMachine :: Eq a => MetaMachine a -> MetaMachine a -> MachineUpdates a deltaMachine (MetaMachine { mmGrid = g0 }) (MetaMachine { mmTileSize=ts, mmMillisecondPerBall=rt, mmPrioPuzzles=pp, mmGrid = g1 }) = MachineUpdates ts (snd $ Array.bounds g1) -- Safe because we require a (0,0) start of grid. rt pp $ V.fromList $ mapMaybe (\(i, a1) -> let a0 = if Ix.inRange (Array.bounds g0) i then Just (g0 Array.! i) else Nothing in if a0==Just a1 then Nothing else Just (i, a1) ) $ Array.assocs g1 applyDelta :: MetaMachine a -> MachineUpdates a -> MetaMachine a applyDelta oldMachine updates = MetaMachine newGrid (muTileSize updates) (muMillisecondPerBall updates) (muPriorityPuzzles updates) where newGrid = let oldAssocs = HashMap.fromList $ Array.assocs $ mmGrid oldMachine newAssocs = HashMap.fromList $ V.toList $ muConstruction updates in Array.array ((0,0), muGridSize updates) $ (`map` Ix.range ((0,0), muGridSize updates)) $ \gl -> maybe (error "incomplete information for applyDelta") (gl,)$ HashMap.lookup gl newAssocs <|> HashMap.lookup gl oldAssocs translateMachine :: Monad m => ((X, Y) -> a -> m b) -> MetaMachine a -> m (MetaMachine b) translateMachine act m = do g1 <- fmap (Array.array (Array.bounds (mmGrid m))) $ mapM (\(i, a) -> (i,) <$> act i a) $ Array.assocs $ mmGrid m pure (m { mmGrid = g1 }) firstPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)] firstPuzzles pzls m = mapMaybe (\case {(_, Right _) -> Nothing; (_, Left pid) -> (pid,) <$> HashMap.lookup pid pzls}) $ sort $ fmap (first snd) $ Array.assocs $ mmGrid m findReadyPuzzles :: HashMap PuzzleID Puzzle -> GameState -> [(PuzzleID, Puzzle)] findReadyPuzzles pzls m = mapMaybe puzzleReady $ Array.assocs $ mmGrid m where puzzleReady :: ((X, Y), Either PuzzleID BlueprintID) -> Maybe (PuzzleID, Puzzle) puzzleReady (_, Right _) = Nothing -- If its a finished blueprint, it can't be a ready puzzle puzzleReady (p, Left pid) = do pzl <- HashMap.lookup pid pzls -- Uh, a non existant puzzle can't be ready, but we probably want to be loud about this? let rdy = all (maybe True isRight . getRelative (mmGrid m) p) $ pReqTiles pzl -- All required cells are Blueprints? if rdy then pure (pid, pzl) else Nothing isSolved :: GameState -> Bool isSolved = all isRight isSolvable :: HashMap PuzzleID Puzzle -> GameState -> Bool isSolvable pm gs | not (isSolved gs) && null (findReadyPuzzles pm gs) = False isSolvable _ gs | isSolved gs = True isSolvable pm gs = isSolvable pm setSolvedReady where rdySet = fst <$> findReadyPuzzles pm gs setSolvedReady = gs { mmGrid = (\case r@(Right _) -> r Left pzlID | pzlID `elem` rdySet -> Right (read "00000000-0000-0000-0000-000000000000") l@(Left _) -> l ) <$> mmGrid gs } remanufacture :: MetaMachine Puzzle -> MetaMachine (Either PuzzleID Blueprint) -> GameState remanufacture machineDesign oldMachine = runIdentity $ translateMachine repairPart $ machineDesign { mmPrioPuzzles = cleanPrio } where cleanPrio = V.filter (`HashSet.member` (HashSet.fromList $ fmap puzzleID $ Array.elems $ mmGrid machineDesign)) $ mmPrioPuzzles oldMachine repairPart :: (X, Y) -> Puzzle -> Identity (Either PuzzleID BlueprintID) -- If it is a puzzle, always the new puzzle because its the same or updated. repairPart xy pzl = do let pid = puzzleID pzl case if Ix.inRange (Array.bounds $ mmGrid oldMachine) xy then Just (mmGrid oldMachine Array.! xy) else Nothing of Nothing -> pure $ Left pid Just (Left _) -> pure $ Left pid Just (Right bp) | bPuzzleID bp /= pid -> pure $ Left pid Just (Right bp) -> pure $ Right $ blueprintID bp --data VertShift = ... --data HoriShift = ... -- | Gets the value in a cell at a relative position to a given cell, or Nothing if that is outside the grid. getRelative :: Array (X,Y) a -> (X, Y) -> RelativeCell -> Maybe a getRelative g (x0, y0) r = do let rp = bimap (x0 +) (y0 +) $ relMap r if Ix.inRange (Array.bounds g) rp then Just (g Array.! rp) else Nothing where -- This is the only thing that orients the grid I guess ... orientation ... -- 4th quadrant grid. relMap :: RelativeCell -> (X, Y) relMap RCUpLeft = (-1, -1) relMap RCUp = ( 0, -1) relMap RCUpRight = ( 1, -1) relMap RCLeft = (-1, 0) relMap RCRight = ( 1, 0) relMap RCDownLeft = (-1, 1) relMap RCDown = ( 0, 1) relMap RCDownRight = ( 1, 1) -- TODO stub for bullshit nonsense newtype BallTypes = BallType { _ballType :: Char } deriving Show ================================================ FILE: src/Incredible/DataStore/Memory.hs ================================================ {-# language ImportQualifiedPost #-} {-# LANGUAGE TypeFamilies #-} module Incredible.DataStore.Memory where import Control.Monad import Control.Monad.IO.Class import Control.Concurrent.STM import Data.Foldable import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap import Data.Int import Data.Map (Map) import Data.Map qualified as Map import Data.Set qualified as Set import Incredible.Data import Incredible.DataStore data IncredibleState = IncredibleState { machines :: Map MachineVersion GameState , blueprints :: Map BlueprintID Blueprint , snapshots :: Map BlueprintID Snapshot , moderation :: Map PuzzleID (Set.Set BlueprintID) , workorders :: Map WorkOrderID WorkOrder , widgetOrders :: Map WidgetSignature Int64 } newtype IncredibleStore = IncredibleStore { getIncredibleStore :: TVar IncredibleState } initIncredibleState :: MonadIO m => a -> MetaMachine Puzzle -> m (IncredibleStore, HashMap PuzzleID Puzzle) initIncredibleState _ machine = liftIO $ do let state = IncredibleState (Map.singleton 0 $ fmap (Left . puzzleID) machine) mempty mempty mempty mempty mempty store <- newTVarIO state pure $ (IncredibleStore store, HashMap.fromList $ map (\p -> (puzzleID p, p)) $ toList machine) instance IncredibleData IncredibleStore where getCurrentMachine' store = liftIO $ do state <- readTVarIO $ getIncredibleStore store -- Safe because the map is forced to contain the initial machine. maybe (fail "initial machine missing from map") (\(v, m) -> pure (VersionedMachine v m)) $ Map.lookupMax $ machines state getMachine' store version = liftIO $ do state <- readTVarIO $ getIncredibleStore store pure $ Map.lookup version $ machines state editCurrentMachine' store f = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) case Map.lookupMax (machines state) of Nothing -> error "initial machine missing from map" Just (version, current) -> do let (r, nv) = f $ VersionedMachine version current let newVer = succ version when (current /= nv) $ writeTVar (getIncredibleStore store) $ state { machines = Map.insert newVer nv $ machines state } pure r addBlueprint' store blueprint = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) writeTVar (getIncredibleStore store) $ state { blueprints = Map.insert (blueprintID blueprint) blueprint $ blueprints state } getBlueprint' store bpID = liftIO $ do state <- readTVarIO $ getIncredibleStore store pure $ Map.lookup bpID $ blueprints state addSnapshot' store blueprint snapshot = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) writeTVar (getIncredibleStore store) $ state { snapshots = Map.insert blueprint snapshot $ snapshots state } getSnapshot' store bpID = liftIO $ do state <- readTVarIO $ getIncredibleStore store pure $ Map.lookup bpID $ snapshots state queueModeration' store pzlID bpID = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) let insertModeration = Map.insertWith Set.union pzlID (Set.singleton bpID) writeTVar (getIncredibleStore store) $ state { moderation = insertModeration $ moderation state } dequeueModeration' store pzlID bpID = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) let removeModeration = Map.adjust (Set.delete bpID) pzlID writeTVar (getIncredibleStore store) $ state { moderation = removeModeration $ moderation state } listModerationQueue' store pzlID = liftIO $ do state <- readTVarIO $ getIncredibleStore store pure $ maybe [] Set.toList $ Map.lookup pzlID $ moderation state addWorkOrder' store wid w = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) writeTVar (getIncredibleStore store) $ state { workorders = Map.insert wid w $ workorders state } pullWorkOrder' store wid = liftIO $ atomically $ do state <- readTVar $ getIncredibleStore store let (mwo, nm) = Map.updateLookupWithKey (\_ _ -> Nothing) wid $ workorders state writeTVar (getIncredibleStore store) $ state { workorders = nm } pure mwo widgetOrderBook' store ws = liftIO $ atomically $ do state <- readTVar (getIncredibleStore store) let nm = Map.insertWith (+) ws 1 $ widgetOrders state writeTVar (getIncredibleStore store) $ state { widgetOrders = nm } pure $ Map.findWithDefault 1 ws nm ================================================ FILE: src/Incredible/DataStore/Redis.hs ================================================ {-# language ImportQualifiedPost #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-} module Incredible.DataStore.Redis where import Control.DeepSeq import Control.Exception qualified as BE import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Control.Monad.Trans import Control.Monad.Trans.Maybe import Data.Aeson qualified as JS import Data.Bytes.Put (runPutS) import Data.Bytes.Serial qualified as S import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as Char8 import Data.ByteString.Lazy qualified as BSL import Data.Either import Data.Foldable import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap import Data.Maybe import Data.UUID (UUID) import Data.UUID qualified as UUID import Database.Redis (Redis) import Database.Redis qualified as Redis import Incredible.Config import Incredible.Data import Incredible.DataStore --import Toml.Schema qualified as Toml import Type.Reflection import System.X509 import qualified Network.TLS as TLS import Data.Default import qualified Network.TLS.Extra.Cipher as Cipher -- | -- machinesKey :: Hash -- - Watch machinesKey for transactions. -- - HLen-1 machine version. -- - machinesKey[version] for machine. -- - HSETNX machines into it, shouldn't need a transaction since we're dense monotonic in IDs. -- - Existamce of this key means the database is initialized. -- -- blueprintsKey :: Hash -- - blueprintsKey[blueprintID] for blueprints. -- - HSETNX blueprints into it -- -- snapshotsKey :: Hash -- - snapshotsKey[blueprintID] for snapshots. -- - HSET snapshots into it because we can change these in a critical situation -- -- puzzlesKey :: Hash -- - added at Init. -- -- moderationKey<>puzzleID :: Set -- - Data integrity here isn't so important. Just don't add anything that doesn't fit. -- -- workKey<>WorkOrderID :: String -- - volatile -- -- widgetOrderKet<>WidgetOrder :: String -- - Stores Int64s -- - We incr and read -- - Transactional integrity is, if anything, a drawback. machinesKey :: BS.ByteString machinesKey = "machines" blueprintsKey :: BS.ByteString blueprintsKey = "blueprints" snapshotsKey :: BS.ByteString snapshotsKey = "snapshots" puzzlesKey :: BS.ByteString puzzlesKey = "puzzles" moderationKey :: PuzzleID -> BS.ByteString moderationKey pid = "moderation:" <> encodeUUID pid workKey :: WorkOrderID -> BS.ByteString workKey (WorkOrderID wid) = "work:" <> encodeUUID wid widgetOrderKey :: WidgetSignature -> BS.ByteString widgetOrderKey ws = "widget_"<>runPutS (S.serialize ws) data IncredibleRedisStore = IncredibleRedisStore { incredibleRedisConnection :: Redis.Connection , retryCount :: Int , workOrderTermSeconds :: Integer , widgetOrderBookTermSeconds :: Integer } logger :: MonadIO m => Bool -> String -> m () logger False = void . pure logger True = liftIO . print initConnectInfo :: MonadIO m => IncredibleRedisConfig -> m Redis.ConnectInfo initConnectInfo cfg = do certStore <- liftIO $ getSystemCertificateStore let tlsParams = if incredibleRedisUseTLS cfg then Just $ (TLS.defaultParamsClient (incredibleRedisHostName cfg) "") { TLS.clientSupported = def {TLS.supportedCiphers = Cipher.ciphersuite_default } , TLS.clientShared = def { TLS.sharedCAStore = certStore } } else Nothing pure $ Redis.defaultConnectInfo { Redis.connectHost = incredibleRedisHostName cfg , Redis.connectPort = incredibleRedisPort cfg , Redis.connectAuth = incredibleRedisPassword cfg , Redis.connectDatabase = incredibleRedisDatabase cfg , Redis.connectMaxConnections = incredibleRedisMaxConnections cfg , Redis.connectMaxIdleTime = fromInteger $ incredibleRedisMaxIdleTimeout cfg , Redis.connectTLSParams = tlsParams } initIncredibleState :: MonadIO m => IncredibleConfig -> MetaMachine Puzzle -> m (IncredibleRedisStore, HashMap PuzzleID Puzzle) initIncredibleState (IncredibleConfig {incredibleRedis = Nothing}) _ = liftIO $ fail "No Redis config" initIncredibleState (IncredibleConfig {incredibleRedis = Just cfg}) ipzl = do conninfo <- initConnectInfo cfg conn <- liftIO $ Redis.checkedConnect $ conninfo let is = IncredibleRedisStore conn retries wottl wobttl didInit <- raisingException is $ liftRedis $ Redis.hsetnx machinesKey (encodeVersion 0) $ encodeMachine (Left . puzzleID <$> ipzl) when didInit $ logger False ("Initialized DataStore"::String) let pzlList = map (\p -> (puzzleID p, p)) $ toList ipzl -- Get all puzzles ever pzlRedisList <- do forM_ pzlList $ \(pid, p) -> raisingException is $ liftRedis $ Redis.hsetnx puzzlesKey (encodeUUID pid) $ encodePuzzle p rpzls <- raisingException is $ liftRedis $ Redis.hgetall puzzlesKey fmap catMaybes . forM rpzls $ \(bpid, bpzl) -> runMaybeT $ do pid <- maybe (logger True "couldn't decode PuzzleID" >> mzero) pure $ decodeUUID bpid p <- maybe (logger True ("couldn't decode Puzzle: "<>show bpid) >> mzero) pure $ decodePuzzle bpzl pure (pid, p) pure (is, HashMap.fromList $ pzlList <> pzlRedisList) where retries = incredibleRedisRetryCount cfg wottl = incredibleWorkOrderTTL cfg wobttl = incredibleOrderBookTTL cfg raisingException :: (MonadIO m, NFData a) => IncredibleRedisStore -> ExceptT RedisError Redis a -> m a raisingException store act = fmap force $ (either BE.throw pure <=< (liftIO . Redis.runRedis (incredibleRedisConnection store))) $ runExceptT act data RedisError = RedisReplyError Redis.Reply | RedisTxError String | RedisTxAborted String | RedisRetryLimitReached String | RedisGetNotFound BS.ByteString | RedisHGetNotFound BS.ByteString BS.ByteString | RedisMachineDecodeError String | RedisVersionDecodeError String | RedisBlueprintDecodeError String | RedisSnapshotDecodeError String | RedisUUIDDecodeError BS.ByteString | RedisMachineVersionNotFound MachineVersion | RedisIntegrityError String deriving (Show, Eq, Typeable) instance BE.Exception RedisError retryTransaction :: Int -> ExceptT RedisError Redis a -> ExceptT RedisError Redis a retryTransaction count tx = go count where go c = catchError tx $ \case RedisTxAborted source -> if c > 0 then go (c - 1) else BE.throw $ RedisRetryLimitReached source err -> BE.throw err encodeUUID :: UUID -> BS.ByteString encodeUUID = BSL.toStrict . UUID.toByteString decodeUUID :: BS.ByteString -> Maybe UUID decodeUUID = UUID.fromByteString . BSL.fromStrict encodeVersion :: MachineVersion -> BS.ByteString encodeVersion = Char8.pack . show -- TODO this is not great decodePuzzle :: BS.ByteString -> Maybe Puzzle decodePuzzle contents = case JS.eitherDecodeStrict contents of Left _ -> Nothing Right version -> Just version encodeWorkOrder :: WorkOrder -> Char8.ByteString encodeWorkOrder = BSL.toStrict . JS.encode decodeWorkOrder :: BS.ByteString -> Maybe WorkOrder decodeWorkOrder contents = case JS.eitherDecodeStrict contents of Left _ -> Nothing Right wo -> pure wo encodePuzzle :: Puzzle -> Char8.ByteString encodePuzzle = BSL.toStrict . JS.encode decodeBlueprint :: BS.ByteString -> ExceptT RedisError Redis Blueprint decodeBlueprint contents = case JS.eitherDecodeStrict contents of Left err -> throwError $ RedisBlueprintDecodeError err Right bp -> pure bp encodeBlueprint :: Blueprint -> Char8.ByteString encodeBlueprint = BSL.toStrict . JS.encode decodeSnapshot :: BS.ByteString -> ExceptT RedisError Redis Snapshot decodeSnapshot contents = case JS.eitherDecodeStrict contents of Left err -> throwError $ RedisSnapshotDecodeError err Right ss -> pure ss encodeSnapshot :: Snapshot -> Char8.ByteString encodeSnapshot = BSL.toStrict . JS.encode encodeMachine :: GameState -> Char8.ByteString encodeMachine = BSL.toStrict . JS.encode getMachineVersion :: (Functor f, Redis.RedisCtx m f) => MachineVersion -> m (f (Either RedisError GameState)) getMachineVersion mv = do machineJSON <- Redis.hget machinesKey (encodeVersion mv) pure $ maybe (Left $ RedisMachineVersionNotFound mv) (either (Left . RedisMachineDecodeError) Right . JS.eitherDecodeStrict) <$> machineJSON execTrans :: String -> (forall m f . (Functor f, Redis.RedisCtx m f) => m (f (Either RedisError a))) -> ExceptT RedisError Redis a execTrans source txn = do txr <- lift $ Redis.multiExec txn case txr of Redis.TxSuccess (Left err) -> throwError err Redis.TxSuccess (Right r) -> pure r Redis.TxAborted -> throwError $ RedisTxAborted source Redis.TxError err -> throwError $ RedisTxError err liftRedis :: Redis (Either Redis.Reply a) -> ExceptT RedisError Redis a liftRedis act = do resp <- lift act case resp of (Left rep) -> throwError $ RedisReplyError rep (Right r) -> pure r instance IncredibleData IncredibleRedisStore where getCurrentMachine' conn = raisingException conn $ do retryTransaction (retryCount conn) $ do Redis.Ok <- liftRedis $ Redis.watch [machinesKey] numMachines <- liftRedis $ Redis.hlen machinesKey let curMachineVersion = numMachines-1 VersionedMachine curMachineVersion <$> execTrans "getCurrentMachine" (getMachineVersion curMachineVersion) {-# INLINE getCurrentMachine' #-} getMachine' conn version = raisingException conn $ do handleError (\case {RedisMachineVersionNotFound _ -> pure Nothing; err -> throwError err}) $ do fmap Just $ liftEither =<< liftRedis (getMachineVersion version) {-# INLINE getMachine' #-} editCurrentMachine' conn f = raisingException conn $ do retryTransaction (retryCount conn) $ do Redis.Ok <- liftRedis $ Redis.watch [machinesKey] numMachines <- liftRedis $ Redis.hlen machinesKey let curMachineVersion = numMachines-1 let newMachineVersion = succ curMachineVersion machine <- liftEither =<< liftRedis (getMachineVersion curMachineVersion) let (r, newMachine) = f (VersionedMachine curMachineVersion machine) when (newMachine /= machine) $ do commited <- execTrans "editCurrentMachine" $ fmap (fmap Right) $ Redis.hsetnx machinesKey (encodeVersion newMachineVersion) $ encodeMachine newMachine unless commited $ throwError $ RedisTxAborted "Raced editing machine" pure r {-# INLINE editCurrentMachine' #-} addBlueprint' conn blueprint = raisingException conn $ void $ liftRedis $ Redis.hsetnx blueprintsKey (encodeUUID $ blueprintID blueprint) $ encodeBlueprint blueprint {-# INLINE addBlueprint' #-} getBlueprint' conn bid = raisingException conn $ do liftRedis (Redis.hget blueprintsKey (encodeUUID bid)) >>= maybe (pure Nothing) (fmap Just . decodeBlueprint) {-# INLINE getBlueprint' #-} addSnapshot' conn blueprint snapshot = raisingException conn $ void $ liftRedis $ Redis.hset snapshotsKey (encodeUUID blueprint) $ encodeSnapshot snapshot {-# INLINE addSnapshot' #-} getSnapshot' conn bid = raisingException conn $ do liftRedis (Redis.hget snapshotsKey (encodeUUID bid)) >>= maybe (pure Nothing) (fmap Just . decodeSnapshot) {-# INLINE getSnapshot' #-} queueModeration' conn pid bid = raisingException conn $ do void $ liftRedis $ Redis.sadd (moderationKey pid) [encodeUUID bid] {-# INLINE queueModeration' #-} dequeueModeration' conn pid bid = raisingException conn $ do void $ liftRedis $ Redis.srem (moderationKey pid) [encodeUUID bid] {-# INLINE dequeueModeration' #-} listModerationQueue' conn pid = raisingException conn $ do fmap (mapMaybe decodeUUID) $ liftRedis $ Redis.smembers (moderationKey pid) {-# INLINE listModerationQueue' #-} modQueueLength' conn pids = raisingException conn $ do liftRedis $ fmap (Right . rights) . forM pids $ \pid -> fmap (pid,) <$> Redis.scard (moderationKey pid) {-# INLINE modQueueLength' #-} addWorkOrder' conn wid w = raisingException conn $ do let wokey = workKey wid void $ execTrans "addWorkOrder" $ do void $ Redis.set wokey (encodeWorkOrder w) fmap Right <$> Redis.expire wokey (workOrderTermSeconds conn) {-# INLINE addWorkOrder' #-} pullWorkOrder' conn wid = raisingException conn $ do let wokey = workKey wid execTrans "pullWorkOrder" $ do mwo <- (fmap (Right . join . fmap decodeWorkOrder)) <$> Redis.get wokey void $ Redis.del [wokey] pure mwo {-# INLINE pullWorkOrder' #-} widgetOrderBook' conn ws = raisingException conn $ do let woKey = widgetOrderKey ws execTrans "addWorkOrder" $ do wob <- fmap fromInteger <$> Redis.incr woKey void $ Redis.expire woKey (widgetOrderBookTermSeconds conn) pure $ fmap Right wob {-# INLINE widgetOrderBook' #-} ================================================ FILE: src/Incredible/DataStore.hs ================================================ {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FunctionalDependencies #-} {-# LANGUAGE StarIsType #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-} module Incredible.DataStore ( IncredibleData(..) , HasDataStore(..) , getCurrentMachine , getMachine , editCurrentMachine , getBlueprint, addSnapshot, getSnapshot , queueModeration , dequeueModeration , listModerationQueue , modQueueLength , addWorkOrder , pullWorkOrder , widgetOrderBook , lruFetch ) where import Control.DeepSeq import Control.Monad.IO.Class import Control.Monad.Reader import Data.Cache.LRU.IO (AtomicLRU) import qualified Data.Cache.LRU.IO as LRU import Data.Foldable import Data.Int import Data.Traversable import Incredible.Data class IncredibleData s where getCurrentMachine' :: MonadIO m => s -> m VersionedGameState getMachine' :: MonadIO m => s -> MachineVersion -> m (Maybe GameState) -- | Must make no edit if the machine is the same as the current machine. editCurrentMachine' :: (MonadIO m, NFData a) => s -> (VersionedGameState -> (a, GameState)) -> m a addBlueprint' :: MonadIO m => s -> Blueprint -> m () getBlueprint' :: MonadIO m => s -> BlueprintID -> m (Maybe Blueprint) addSnapshot' :: MonadIO m => s -> BlueprintID -> Snapshot -> m () getSnapshot' :: MonadIO m => s -> BlueprintID -> m (Maybe Snapshot) queueModeration' :: MonadIO m => s -> PuzzleID -> BlueprintID -> m () dequeueModeration' :: MonadIO m => s -> PuzzleID -> BlueprintID -> m () listModerationQueue' :: MonadIO m => s -> PuzzleID -> m [BlueprintID] modQueueLength' :: MonadIO m => s -> [PuzzleID] -> m [(PuzzleID, Integer)] modQueueLength' s pids = forM pids $ \pid -> fmap ((pid,) . toInteger . length) $ listModerationQueue' s pid addWorkOrder' :: MonadIO m => s -> WorkOrderID -> WorkOrder -> m () -- | Work orders are single use. pullWorkOrder' :: MonadIO m => s -> WorkOrderID -> m (Maybe WorkOrder) -- | Records the use of a widget and provides an estimate of the number of times it has been used. widgetOrderBook' :: MonadIO m => s -> WidgetSignature -> m Int64 -- sharedData :: MonadIO m => s -> (ServerSharedState -> ServerSharedState) -> m ServerSharedState lruFetch :: (Ord key, MonadIO m, NFData val) => (key -> m (Maybe val)) -> key -> AtomicLRU key val -> m (Maybe val) lruFetch fetcher k lru = do mr <- liftIO $ LRU.lookup k lru case mr of val@(Just _) -> pure val Nothing -> do newVal <- fetcher k -- Make sure everything in cache is fully evaluated. deepseq newVal $ liftIO $ traverse_ (\n -> LRU.insert k n lru) newVal pure newVal {-# INLINE lruFetch #-} class IncredibleData s => HasDataStore r s | r -> s where getStore :: r -> s getMachineCache :: r -> AtomicLRU MachineVersion GameState getBlueprintCache :: r -> AtomicLRU BlueprintID Blueprint getSnapshotCache :: r -> AtomicLRU BlueprintID Snapshot instance IncredibleData s => HasDataStore (MachineShop s) s where getStore = sdStore getMachineCache = sdMachineByVersion getBlueprintCache = sdBlueprintCache getSnapshotCache = sdSnapshotCache getCurrentMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => m VersionedGameState getCurrentMachine = getCurrentMachine' =<< asks getStore {-# INLINE getCurrentMachine #-} getMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => MachineVersion -> m (Maybe GameState) getMachine mv = lruFetch (\k -> (`getMachine'` k) =<< asks getStore) mv =<< asks getMachineCache {-# INLINE getMachine #-} editCurrentMachine :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m, NFData a) => (VersionedGameState -> (a, GameState)) -> m a editCurrentMachine act = (`editCurrentMachine'` act) =<< asks getStore {-# INLINE editCurrentMachine #-} -- | IMPLICITE TO queueModeration addBlueprint :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => Blueprint -> m () addBlueprint b = (`addBlueprint'` b) =<< asks getStore {-# INLINE addBlueprint #-} getBlueprint :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m (Maybe Blueprint) getBlueprint bid = lruFetch (\k -> (`getBlueprint'` k) =<< asks getStore) bid =<< asks getBlueprintCache {-# INLINE getBlueprint #-} addSnapshot :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> Snapshot -> m () addSnapshot b s = (\d -> addSnapshot' d b s) =<< asks getStore {-# INLINE addSnapshot #-} getSnapshot :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m (Maybe Snapshot) getSnapshot bid = lruFetch (\k -> (`getSnapshot'` k) =<< asks getStore) bid =<< asks getSnapshotCache {-# INLINE getSnapshot #-} queueModeration :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => Blueprint -> m () queueModeration bp = do addBlueprint bp (\s -> queueModeration' s (bPuzzleID bp) (blueprintID bp)) =<< asks getStore {-# INLINE queueModeration #-} dequeueModeration :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => BlueprintID -> m () dequeueModeration bpid = do maybe (pure ()) (\bp -> (\s -> dequeueModeration' s (bPuzzleID bp) bpid) =<< asks getStore) =<< getBlueprint bpid {-# INLINE dequeueModeration #-} listModerationQueue :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => PuzzleID -> m [BlueprintID] listModerationQueue pid = (`listModerationQueue'` pid) =<< asks getStore {-# INLINE listModerationQueue #-} modQueueLength :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => [PuzzleID] -> m [(PuzzleID, Integer)] modQueueLength pid = (`modQueueLength'` pid) =<< asks getStore {-# INLINE modQueueLength #-} addWorkOrder :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WorkOrderID -> WorkOrder -> m () addWorkOrder wid w = (\s -> addWorkOrder' s wid w) =<< asks getStore {-# INLINE addWorkOrder #-} pullWorkOrder :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WorkOrderID -> m (Maybe WorkOrder) pullWorkOrder wid = (`pullWorkOrder'` wid) =<< asks getStore {-# INLINE pullWorkOrder #-} widgetOrderBook :: (MonadIO m, IncredibleData s, HasDataStore r s, MonadReader r m) => WidgetSignature -> m Int64 widgetOrderBook ws = (`widgetOrderBook'` ws) =<< asks getStore {-# INLINE widgetOrderBook #-} ================================================ FILE: src/Incredible/Puzzle.hs ================================================ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} module Incredible.Puzzle where import Control.Arrow import Control.Exception import Control.Monad import Control.Monad.ST import qualified Data.Aeson.KeyMap as JS import Data.Array (Array) import qualified Data.Array as Array import Data.Array.IO (IOArray) import qualified Data.Array.MArray as MArray import Data.Bifunctor import Data.Foldable import qualified Data.Foldable as Foldable import Data.IORef import qualified Data.Ix as Ix import qualified Data.List as List import Data.Map (Map) import qualified Data.Map as Map import Data.Maybe (catMaybes, fromJust) import Data.Set (Set) import qualified Data.Set as Set import Data.Vector (Vector) import qualified Data.Vector as Vector import Data.Vector.Mutable (MVector) import qualified Data.Vector.Mutable as MVector import GHC.IsList import Incredible.Data import qualified Incredible.DataStore.Memory as Mem import System.Random.Stateful import Text.Printf (printf) data PuzzleConfig = PuzzleConfig { ballTypes :: Int -- ^ Number of different types of balls , minBallConfiguration :: [Int] -- ^ Minimum ball configuration , puzzleLocations :: Set Double -- ^ Locations of possible connectors } data PuzzleGen = PuzzleGen { puzzleConfig :: PuzzleConfig , puzzleGenerate :: IOGenM StdGen -> IO [PuzzleRow] } gamePuzzle :: PuzzleGen gamePuzzle = PuzzleGen cfg $ \g -> do applyAll initial $ concat [ replicate 10 $ swapBalls g (SwapConfig (1,2) (14,20)) -- >=> towardsBallCount g cfg 16 , replicate 10 $ swapBalls g (SwapConfig (1,4) (18,20)) , replicate 10 $ swapBalls g (SwapConfig (1,4) (18,18)) -- >=> towardsBallCount g cfg 18 , replicate 10 $ swapBalls g (SwapConfig (1,4) (12,12)) , replicate 10 $ swapBalls g (SwapConfig (1,4) (10,10)) -- >=> towardsBallCount g cfg 16 , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16)) , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16)) -- >=> towardsBallCount g cfg 14 , replicate 10 $ swapBalls g (SwapConfig (1,4) (16,16)) , replicate 10 $ swapBalls g (SwapConfig (1,4) (10,10)) -- >=> towardsBallCount g cfg 10 , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10 , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10 , replicate 10 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10 , replicate 7 $ swapBalls g (SwapConfig (1,4) (5,5)) -- >=> towardsBallCount g cfg 10 -- , replicate 1 $ pure ] where cfg = PuzzleConfig 4 ballConfiguration (Set.fromList [0.2, 0.35, 0.5, 0.65, 0.8]) initial = PuzzleRow $ Vector.fromList $ concat $ fmap mkColumm ballConfiguration ballConfiguration = [2,2,2, 4,4,4, 1,1,1, 3,3,3] -- mkColumm b = [ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty] mkColumm b = [ConnectorEmpty, ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty, ConnectorEmpty] testPuzzle :: PuzzleGen testPuzzle = PuzzleGen cfg $ \g -> do applyAll initial $ concat [ replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 5 , replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 6 , replicate 3 $ swapBalls g (SwapConfig (1,2) (10,15)) >=> towardsBallCount g cfg 5 , replicate 1 $ pure ] where cfg = PuzzleConfig 4 ballConfiguration (Set.fromList [0.2, 0.35, 0.5, 0.65, 0.8]) ballConfiguration = [1,2,3,3,4] initial = PuzzleRow $ Vector.fromList $ concat $ fmap mkColumm [1,2,3,3,4] mkColumm b = [ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty, ConnectorOpen $ BallType' b, ConnectorEmpty] generatePuzzle :: IOGenM StdGen -> PuzzleGen -> IO (Array (X, Y) Puzzle) generatePuzzle g gen = do retryForever $ do rows <- puzzleGenerate gen g putStrLn "Rendingering rows to grid" outputs <- fromOutputRows cfg rows putStrLn "Routing balls" routed <- routeBalls g outputs putStrLn "Placing connecteors" placed <- routeConnectors g (puzzleLocations cfg) routed putStrLn "Joinging streams" joined <- joinStreams g 30 placed putStrLn "Calculating rates" rates <- calculateRates joined putStrLn "Calculating dependencies" dependencies <- generateDependencies placed let puzzle = iMap (toPuzzle' dependencies rates) joined when (any isEmptyPuzzle puzzle) $ fail "Empty puzzle" putStrLn (renderRows rows) putStrLn $ prettyDependencyArray' dependencies putStrLn "--------------------------------" putStrLn $ prettyPuzzleRates (bimap (round . (*10)) (round . (*10))) puzzle putStrLn "--------------------------------" putStrLn $ prettyPuzzleArray' (bimap (round . (*10)) (round . (*10))) $ puzzle pure puzzle where retryForever f = try f >>= \case Left (e :: SomeException) -> do putStrLn $ "Error: " <> show e retryForever f Right r -> pure r cfg = puzzleConfig gen iMap f r = Array.array (Array.bounds r) $ fmap (\(i, a) -> (i, f i a)) $ Array.assocs r isEmptyPuzzle :: Puzzle -> Bool isEmptyPuzzle p = Vector.null (pInputs p) && Vector.null (pOutputs p) toPuzzle'' :: (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved -> 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 -> Puzzle toPuzzle'' (_x, y) (PuzzleConnectors i o) = Puzzle (Vector.fromList requirements) (mkGateways inputs) (mkGateways outputs) JS.empty -- TODO add spec where requirements = if y == 0 then [] -- no requirements for the top row else (fmap dirToRel $ Map.keys $ Map.filter Set.null $ inputs) -- TODO inputs are required before the puzzle can be solved mkGateways :: Map Direction (Set Double) -> Vector.Vector Gateway mkGateways = Vector.fromList . concat . fmap (\(dir, rs) -> fmap (toGateway dir) $ Set.toList rs) . Map.toList toGateway :: Direction -> Double -> Gateway toGateway dir r = Gateway (onEdge dir r) (Vector.fromList [GatewayBall 1 1]) -- TODO add rates inputs = directed i outputs = directed o machineIsSolvable :: MetaMachine Puzzle -> IO Bool machineIsSolvable gen = do (_, ps) <- Mem.initIncredibleState () gen pure $ isSolvable ps gs where gs = fmap (Left . puzzleID) gen ratesCount :: IOArray (X,Y) (PuzzleConnectors (Map Double Double)) -> IO Int ratesCount rates = do bounds <- MArray.getBounds rates fmap sum $ forM (Ix.range bounds) $ \i -> do p <- MArray.readArray rates i let is = puzzleInputs p isCount :: Int isCount = sum $ Map.elems $ fmap Map.size $ directed is os = puzzleOutputs p osCount = sum $ Map.elems $ fmap Map.size $ directed os pure $ osCount + isCount calculateRates :: Array (X,Y) (PuzzleConnectors (Map Double (Set Int))) -> IO (Array (X,Y) (PuzzleConnectors (Map Double Double))) calculateRates grid' = do rates :: IOArray (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.thaw $ fmap (fmap (fmap (const 0.0))) grid' let propagateFrom :: IORef (Set (X,Y)) -> Int -> (X, Y) -> IO () propagateFrom visited ty loc = do rs <- MArray.readArray rates loc vs <- readIORef visited if loc `Set.member` vs then pure () else do atomicModifyIORef' visited $ \v -> (Set.insert loc v, ()) let connectors = grid' Array.! loc inputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleInputs connectors inputLocations :: [(Direction, Double)] inputLocations = concat $ fmap (\(dir, m) -> (dir,) <$> Map.keys m) $ Map.toList $ directed inputConnectors outputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleOutputs connectors outputCount = sum $ fmap Set.size $ concat $ fmap Map.elems $ Map.elems $ directed outputConnectors inputRate = sum $ fmap (\(dir, edgeLocation) -> Map.findWithDefault 0 edgeLocation $ inDirection dir $ puzzleInputs rs ) inputLocations !outputRate = inputRate / fromIntegral outputCount -- print (loc, "inputRate", inputRate, "outputRate", outputRate, "outputCount", outputCount) res <- fmap (Set.fromList . catMaybes) $ forM (Map.toList $ directed outputConnectors) $ \(dir, edges) -> do getRelativeM rates loc dir >>= \case Nothing -> do forM_ (Map.toList edges) $ \(edgeLocation, _) -> do MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate)) pure Nothing Just (nLoc, _) -> do forM_ (Map.toList edges) $ \(edgeLocation, _) -> do -- print ("propagating", loc, nLoc, outputRate, dir) MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate)) MArray.modifyArray' rates nLoc $ modifyInputs (modifyDirection (switchDirection dir) (Map.insert edgeLocation outputRate)) pure $ Just nLoc forM_ res $ propagateFrom visited ty -- propagateFrom (Set.insert nLoc visited) ty nLoc -- Set all of the top inputs of a rate of 1.0 forM_ topInputs $ \(loc, is) -> do forM_ is $ \(edgeLocation, _ts) -> do MArray.modifyArray' rates loc $ modifyInputs (modifyDirection DUp (Map.insert edgeLocation 1.0)) let iterateUntilStable :: Int -> IO () -> IO () iterateUntilStable 0 _ = error "Failed to converge" iterateUntilStable n act = do before :: Array (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.freeze rates act after <- MArray.freeze rates if before == after then pure () else iterateUntilStable (n - 1) act iterateUntilStable 10000 $ forM_ topInputs $ \(loc, is) -> do forM_ is $ \(_edgeLocation, ts) -> do forM_ ts $ \t -> do visited <- newIORef Set.empty propagateFrom visited t loc MArray.freeze rates where ((startX, startY), (endX, _endY)) = Array.bounds grid' topInputs = flip fmap [startX .. endX ] $ \x -> let loc = (x, startY) in (loc, fmap (\(k, v) -> (k, Set.toList v)) $ Map.toList $ inDirection DUp $ puzzleInputs $ grid' Array.! loc) -- calculateRates' :: Array (X,Y) (PuzzleConnectors (Map Double (Set Int))) -- -> IO (Array (X,Y) (PuzzleConnectors (Map Double Double))) -- calculateRates' grid' = do -- rates :: IOArray (X, Y) (PuzzleConnectors (Map Double Double)) <- MArray.thaw $ fmap (fmap (fmap (const 0.0))) grid' -- let -- propagateRates :: (X, Y) -> IO () -- propagateRates loc = do -- rs <- MArray.readArray rates loc -- forM_ (Set.toList tys) $ \ty -> do -- let -- connectors = grid' Array.! loc -- inputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleInputs connectors -- inputLocations :: [(Direction, Double)] -- inputLocations = concat $ fmap (\(dir, m) -> (dir,) <$> Map.keys m) $ Map.toList $ directed inputConnectors -- outputConnectors = fmap (Map.filter (Set.member ty)) $ puzzleOutputs connectors -- outputCount = sum $ fmap Set.size $ concat $ fmap Map.elems $ Map.elems $ directed $ outputConnectors -- inputRate = sum $ fmap (\(dir, edgeLocation) -> Map.findWithDefault 0 edgeLocation $ inDirection dir $ puzzleInputs rs ) $ inputLocations -- outputRate = inputRate / fromIntegral outputCount -- forM_ (Map.toList $ directed outputConnectors) $ \(dir, cs) -> -- forM_ (Map.toList cs) $ \(edgeLocation, _) -> do -- getRelativeM rates loc dir >>= \case -- Nothing -> do -- MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate)) -- Just (nLoc, _) -> do -- MArray.modifyArray' rates loc $ modifyOutputs (modifyDirection dir (Map.insert edgeLocation outputRate)) -- MArray.modifyArray' rates nLoc $ modifyInputs (modifyDirection (switchDirection dir) (Map.insert edgeLocation outputRate)) -- pure () -- Set all of the top inputs of a rate of 1.0 -- forM_ topInputs $ \(loc, is) -> do -- forM_ is $ \(edgeLocation, _ts) -> do -- MArray.modifyArray' rates loc $ modifyInputs (modifyDirection DUp (Map.insert edgeLocation 1.0)) -- let -- iterateUntilStable :: Int -> IO () -> IO () -- iterateUntilStable 0 _ = pure () -- iterateUntilStable n act = do -- act -- iterateUntilStable (n - 1) act -- iterateUntilStable 50 $ -- forM_ [startY .. endY] $ \y -> -- forM_ [startX, endX] $ \x -> do -- let loc = (x, y) -- -- putStrLn $ "Propagating rates from " <> show loc -- propagateRates loc -- MArray.freeze rates -- where -- tys :: Set Int -- tys = Set.unions $ fmap (Set.unions . concat . fmap Map.elems . Map.elems . directed . puzzleInputs) $ Array.elems grid' -- ((startX, startY), (endX, endY)) = Array.bounds grid' -- topInputs = flip fmap [startX .. endX ] $ \x -> -- let loc = (x, startY) -- in (loc, fmap (\(k, v) -> (k, Set.toList v)) $ Map.toList $ inDirection DUp $ puzzleInputs $ grid' Array.! loc) joinStreams :: IOGenM StdGen -> Int -> Array (X, Y) (PuzzleConnectors (Map Double Int)) -> IO (Array (X, Y) (PuzzleConnectors (Map Double (Set Int)))) joinStreams g den puzzle' = do puzzle :: (IOArray (X, Y) (PuzzleConnectors (Map Double (Set Int)))) <- MArray.thaw $ fmap (fmap Set.singleton) <$> puzzle' forM_ (Ix.range $ Array.bounds puzzle') $ \i -> do res <- randomRM (1, den) g p <- MArray.readArray puzzle i if res == 1 then do let validJoins = Map.toList $ Map.filter (\cs -> Map.size cs > 1) $ directed $ puzzleOutputs p picked <- pickOne g validJoins case picked of Nothing -> pure () Just (dir, pick1) -> do pick2 <- pickOne' g pick1 case pick2 of Nothing -> pure () Just (rest1, (chosenLoc, cs)) -> do pick3 <- pickOne' g rest1 case pick3 of Nothing -> pure () Just (rest2, (_chosenLoc2, cs2)) -> do getRelativeM puzzle i dir >>= \case Nothing -> pure () Just (nLoc, neighorP) -> do -- putStrLn $ "Joining " <> show i <> " " <> show nLoc <> " " <> show dir let new' :: Map Double (Set Int) new' = Map.insertWith Set.union chosenLoc (Set.union cs cs2) rest2 MArray.writeArray puzzle i $ modifyOutputs (modifyDirection dir (const new')) p MArray.writeArray puzzle nLoc $ modifyInputs (modifyDirection (switchDirection dir) (const new')) neighorP else pure () MArray.freeze puzzle -- bubbleSortStep :: PuzzleRow -> IO PuzzleRow -- bubbleSortStep row = do -- let -- balls = rowBalls row -- swaps = Vector.ifoldl' (\acc i (i', b) -> if i /= i' then (i, i') : acc else acc) [] $ Vector.indexed balls -- pure $ modifyPuzzleRow row $ \v -> do -- forM_ swaps $ \(i, j) -> do -- let -- (iB, _) = balls Vector.! i -- (jB, _) = balls Vector.! j -- MVector.swap v iB jB prettyDependencyArray' :: Array (X, Y) (Set Direction) -> String prettyDependencyArray' a = unlines $ concat $ flip fmap [startY..maxY] $ \y -> concatRows $ flip fmap [startX..maxX] $ \x -> prettyDependency $ a Array.! (x, y) where ((startX, startY), (maxX, maxY)) = Array.bounds a concatRows :: [[String]] -> [String] concatRows = fmap concat . List.transpose prettyDependency :: Set Direction -> [String] prettyDependency dirs = top ++ res ++ bottom where top = [replicate (length $ head res) '-'] bottom = [replicate (length $ head res) '-'] res = [ ['|', '.', depAt DUp, '.','|'] , ['|', depAt DLeft, '.', depAt DRight, '|'] , ['|', '.', depAt DDown, '.', '|'] ] depAt dir = if dir `Set.member` dirs then 'X' else '.' -- | Gets the value in a cell at a relative position to a given cell, or Nothing if that is outside the grid. getRelativeM :: IOArray (X,Y) a -> (X, Y) -> Direction -> IO (Maybe ((X,Y), a)) getRelativeM g (x0, y0) r = do bounds <- MArray.getBounds g if Ix.inRange bounds rp then do (Just . (rp,)) <$> MArray.readArray g rp else pure Nothing where rp = bimap (x0 +) (y0 +) $ relMap r generateDependencies :: Array (X, Y) (PuzzleConnectors (Map Double Int)) -> IO (Array (X,Y) (Set Direction)) generateDependencies routed = do -- Track the dependencies for each puzzle depencdencies :: IOArray (X, Y) (Set Direction) <- MArray.newGenArray bounds $ const $ pure mempty let go loc = do let outputs = directed $ puzzleOutputs $ routed Array.! loc outputs' = Map.filter (not . Map.null) outputs flip mapM_ (Map.keys outputs') $ \dir -> do localDeps <- MArray.readArray depencdencies loc if dir `Set.member` localDeps then pure () else do getRelativeM depencdencies loc dir >>= \case Nothing -> pure () Just (nLoc, neighborDependencies) | (switchDirection dir) `Set.member` neighborDependencies -> pure () | otherwise -> do MArray.writeArray depencdencies nLoc $ Set.insert (switchDirection dir) neighborDependencies go nLoc flip mapM_ [xStart .. xEnd] $ \x -> do go (x, yStart) MArray.freeze depencdencies where bounds@((xStart, yStart), (xEnd, _yEnd)) = Array.bounds routed prettyPuzzleRates :: ((Double, Double) -> (Int, Int)) -> Array (X, Y) Puzzle -> String prettyPuzzleRates connIndex a = unlines $ concat $ flip fmap [startY..maxY] $ \y -> concatRows $ flip fmap [startX..maxX] $ \x -> prettyPuzzleRates' connIndex $ a Array.! (x, y) where ((startX, startY), (maxX, maxY)) = Array.bounds a concatRows :: [[String]] -> [String] concatRows = fmap concat . List.transpose prettyPuzzleArray' :: ((Double, Double) -> (Int, Int)) -> Array (X, Y) Puzzle -> String prettyPuzzleArray' connIndex a = unlines $ concat $ flip fmap [startY..maxY] $ \y -> concatRows $ flip fmap [startX..maxX] $ \x -> prettyPuzzle' connIndex $ a Array.! (x, y) where ((startX, startY), (maxX, maxY)) = Array.bounds a concatRows :: [[String]] -> [String] concatRows = fmap concat . List.transpose prettyPuzzle' :: (Position -> (Int, Int)) -> Puzzle -> [String] prettyPuzzle' connIndex p = top ++ res ++ bottom where top = [replicate (length $ head res) '-'] bottom = [replicate (length $ head res) '-'] res = fmap (("|" ++) . (++ "|")) $ flip fmap [0..height ] $ \y -> concat $ flip fmap [0..width] $ \xp -> case Map.lookup (xp, y) is of Nothing -> case Map.lookup (xp, y) os of Nothing -> "..." Just o -> show $ Vector.toList $ gBalls o Just _i -> "III" -- show $ UVector.head $ gType i -- if (xp, y) `Set.member` is then "I" else if (xp, y) `Set.member` os then "O" else "." os = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pOutputs p is = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pInputs p (width, height) = connIndex (1, 1) prettyPuzzleRates' :: (Position -> (Int, Int)) -> Puzzle -> [String] prettyPuzzleRates' connIndex p = top ++ res ++ bottom where top = [replicate (length $ head res) '-'] bottom = [replicate (length $ head res) '-'] res = fmap (("|" ++) . (++ "|")) $ flip fmap [0..height ] $ \y -> concat $ flip fmap [0..width] $ \xp -> case Map.lookup (xp, y) is of Nothing -> case Map.lookup (xp, y) os of Nothing -> "...." Just o -> showDec o Just i -> showDec i -- show $ UVector.head $ gType i -- if (xp, y) `Set.member` is then "I" else if (xp, y) `Set.member` os then "O" else "." os = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pOutputs p is = Map.fromList $ Vector.toList $ fmap ((connIndex . gPos) &&& id) $ pInputs p (width, height) = connIndex (1, 1) showDec = printf "%.2f" . sum . fmap gbRate . Vector.toList . gBalls newtype BallType' = BallType' { unBallType :: Int } deriving (Eq, Ord, Show) -- | Create a puzzle from connectors toPuzzle :: Array (X, Y) (Set Direction) -- ^ Dependencies for each puzzle -> (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved -> 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 -> Puzzle toPuzzle dependencies loc@(_x, y) (PuzzleConnectors i o) = Puzzle (Vector.fromList requirements) (mkGateways inputs) (mkGateways outputs) JS.empty -- TODO add spec where requirements = if y == 0 then [] -- no requirements for the top row else fmap dirToRel $ Set.toList $ dependencies Array.! loc mkGateways :: Map Direction (Map Double Int) -> Vector.Vector Gateway mkGateways = Vector.fromList . concat . fmap (\(dir, rs) -> fmap (uncurry $ toGateway dir) $ Map.toList rs) . Map.toList toGateway :: Direction -> Double -> Int -> Gateway toGateway dir r t = Gateway (onEdge dir r) (Vector.fromList [GatewayBall t 1]) -- TODO add rates inputs = directed i outputs = directed o -- | Create a puzzle from connectors toPuzzle' :: Array (X, Y) (Set Direction) -- ^ Dependencies for each puzzle -> Array (X, Y) (PuzzleConnectors (Map Double Double)) -> (X,Y) -- ^ Location of the puzzle, if the puzzle is on the top row, it has no requirements before it can be solved -> 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 -> Puzzle toPuzzle' dependencies allRates loc@(_x, y) (PuzzleConnectors i o) = Puzzle (Vector.fromList requirements) (mkGateways inputRates inputs) (mkGateways outputRates outputs) JS.empty -- TODO add spec where requirements = if y == 0 then [] -- no requirements for the top row else fmap dirToRel $ Set.toList $ dependencies Array.! loc mkGateways :: DirectedConnectors (Map Double Double) -> Map Direction (Map Double (Set Int)) -> Vector.Vector Gateway mkGateways rates = Vector.fromList . concat . fmap (\(dir, rs) -> fmap (uncurry $ toGateway rates dir) $ Map.toList rs) . Map.toList toGateway :: DirectedConnectors (Map Double Double) -> Direction -> Double -> Set Int -> Gateway toGateway rates dir r t = Gateway (onEdge dir r) (Vector.fromList $ fmap (`GatewayBall` (lookupRate rates dir r)) $ Set.toList t) -- TODO add rates lookupRate rates d r = fromJust $ Map.lookup r $ inDirection d rates inputRates = puzzleInputs $ allRates Array.! loc outputRates = puzzleOutputs $ allRates Array.! loc inputs = directed i outputs = directed o randomBallType :: IOGenM StdGen -> PuzzleConfig -> IO BallType' randomBallType gen cfg = do i <- randomRM (1, ballTypes cfg) gen pure $ BallType' i data PuzzleConnector = ConnectorEmpty | ConnectorOpen BallType' deriving (Eq, Ord, Show) toBallType :: PuzzleConnector -> Maybe BallType' toBallType ConnectorEmpty = Nothing toBallType (ConnectorOpen i) = Just i newtype PuzzleRow = PuzzleRow { unPuzzleRow :: Vector PuzzleConnector -- ^ The row of outputs for a puzzle } deriving (Eq, Ord, Show) newtype OutputColumn = OutputColumn { unOutputColumn :: PuzzleConnectors (Set BallType') } deriving (Eq, Ord, Show) pathsTo :: IOGenM StdGen -> [(X, BallType')] -> [(X, BallType')] -> IO (Map BallType' [(X, Int)]) pathsTo g from' to' = do fmap (Map.fromListWith (<>)) $ flip mapM tys $ \ty -> do let xByType l = fmap fst $ List.filter ((== ty) . snd) l (from, to) <- bimap List.sort List.sort <$> matchLists g ("\n" <> show tys <> "\n" <> show from' <> "\n" <> show to') 0 (xByType from') (xByType to') pure $ (ty, zipWith go from to) where go x1 x2 = (x1, x2 - x1) tys = Set.toList $ Set.fromList $ (snd <$> from') <> (snd <$> to') matchLists :: Show a => IOGenM StdGen -> String -> Int -> [a] -> [a] -> IO ([a], [a]) matchLists g deb n xs ys | length xs == 0 || length ys == 0 = error $ "Empty lists " <> deb <> show n <> " - " <> show (xs, ys) | length xs == length ys = pure (xs, ys) | length xs > length ys = do ys' <- (:ys) . (ys !!) <$> randomRM (0, length ys - 1) g matchLists g deb (n + 1) xs ys' | length xs < length ys = do xs' <- (:xs) . (xs !!) <$> randomRM (0, length xs - 1) g matchLists g deb (n + 1) xs' ys | otherwise = pure (xs, ys) routeConnectors :: IOGenM StdGen -> Set Double -> Array (X, Y) (PuzzleConnectors (Vector Int)) -> IO (Array (X, Y) (PuzzleConnectors (Map Double Int))) routeConnectors g locations placed' = do placed <- MArray.thaw placed' routed :: IOArray (X, Y) (PuzzleConnectors (Map Double Int)) <- MArray.newGenArray bounds $ const $ pure emptyConnectors -- TODO add crossing complexities forM_ (reverse [0..yMax]) $ \y -> do forM_ ([0..xMax]) $ \x -> do let loc = (x,y) p <- MArray.readArray placed loc let inputs = directed $ puzzleInputs p outputs = directed $ puzzleOutputs p MArray.modifyArray placed loc $ modifyInputs (const $ DirectedConnectors mempty mempty mempty mempty) . modifyOutputs (const $ DirectedConnectors mempty mempty mempty mempty) forM_ directions $ \dir -> do (leftoverLocations, is) <- pickN' g locations $ Vector.toList $ Map.findWithDefault Vector.empty dir inputs MArray.modifyArray routed loc $ modifyInputs (modifyDirection dir ((Map.union $ Map.fromList is))) (_, os) <- pickN' g leftoverLocations $ Vector.toList $ Map.findWithDefault Vector.empty dir outputs MArray.modifyArray routed loc $ modifyOutputs (modifyDirection dir ((Map.union $ Map.fromList os))) getRelativeM placed loc dir >>= \case Nothing -> pure () -- no neighbor Just (nLoc, _) -> do MArray.modifyArray routed nLoc $ modifyInputs (modifyDirection (switchDirection dir) ((Map.union $ Map.fromList os))) MArray.modifyArray placed nLoc $ modifyInputs (modifyDirection (switchDirection dir) $ const mempty) MArray.modifyArray routed nLoc $ modifyOutputs (modifyDirection (switchDirection dir) ((Map.union $ Map.fromList is))) MArray.modifyArray placed nLoc $ modifyOutputs (modifyDirection (switchDirection dir) $ const mempty) pure (is, os) MArray.freeze routed where bounds@(_, (xMax, yMax)) = Array.bounds placed' emptyConnectors = PuzzleConnectors emptyDirected emptyDirected emptyDirected = DirectedConnectors mempty mempty mempty mempty -- | Pick n elements from a set pickN' :: (Ord a, Show a, Show i) => IOGenM StdGen -> Set a -> [i] -> IO (Set a, [(a, i)]) pickN' _ s [] = pure (s, mempty) pickN' g s (x:xs) | Set.null s = pure (mempty, []) | otherwise = do i <- randomRM (0, Set.size s - 1) g let selected = Set.elemAt i s (s', rest) <- pickN' g (Set.deleteAt i s) xs pure $ (s', (selected,x):rest) -- | Pick n elements from a set pickN :: Ord a => IOGenM StdGen -> Set a -> Int-> IO (Set a) pickN g s n | n <= 0 = pure mempty | Set.null s = pure mempty | otherwise = do i <- randomRM (0, Set.size s - 1) g let selected = Set.elemAt i s rest <- pickN g (Set.deleteAt i s) (n - 1) pure $ Set.insert selected rest -- | Pick n elements from a set pickOne :: IOGenM StdGen -> [i] -> IO (Maybe i) pickOne g xs | List.null xs = pure Nothing | otherwise = do i <- randomRM (0, length xs - 1) g pure $ Just $ xs List.!! i pickOne' :: IOGenM StdGen -> Map a b -> IO (Maybe (Map a b, (a, b))) pickOne' g m | Map.null m = pure Nothing | otherwise = do i <- randomRM (0, Map.size m - 1) g let (k, v) = Map.elemAt i m pure $ Just (Map.deleteAt i m, (k, v)) routeBalls :: IOGenM StdGen -> Array (X, Y) (Vector BallType') -> IO (Array (X, Y) (PuzzleConnectors (Vector Int))) routeBalls g grid = do routed :: IOArray (X, Y) (PuzzleConnectors (Vector Int)) <- MArray.newGenArray bounds $ const $ pure emptyConnectors forM_ (reverse [1..yMax]) $ \y -> do let local = concatMap (\(x, tys) -> (x,) <$> tys) $ flip fmap [0..xMax] $ \x -> (x, Vector.toList $ grid Array.! (x,y)) above = concatMap (\(x, tys) -> (x,) <$> tys) $ flip fmap [0..xMax] $ \x -> (x, Vector.toList $ grid Array.! (x,y-1)) paths <- pathsTo g local above forM_ (Map.toList paths) $ \(ty, ps) -> do forM_ ps $ \(start, diff) -> do writePath routed y ty (start, diff) pure () pure () forM_ [0..xMax] $ \x -> do MArray.modifyArray routed (x,yMax) $ modifyOutputs (modifyDirection DDown ((unBallType <$> grid Array.! (x,yMax)) <> )) MArray.modifyArray routed (x,0) $ modifyInputs (modifyDirection DUp ((unBallType <$> grid Array.! (x,0)) <> )) MArray.freeze routed where bounds@(_, (xMax, yMax)) = Array.bounds grid emptyConnectors = PuzzleConnectors emptyDirected emptyDirected emptyDirected = DirectedConnectors mempty mempty mempty mempty writePath :: IOArray (X, Y) (PuzzleConnectors (Vector Int)) -> Y -> BallType' -> (X, Int) -> IO () writePath routed y ty (start, diff) | diff < 0 = do -- Target is on the left MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DLeft (Vector.singleton (unBallType ty) <> )) MArray.modifyArray routed (start - 1, y) $ modifyOutputs (modifyDirection DRight (Vector.singleton (unBallType ty) <> )) writePath routed y ty (start - 1, diff + 1) | diff > 0 = do -- Target is on the right MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DRight (Vector.singleton (unBallType ty) <> )) MArray.modifyArray routed (start + 1, y) $ modifyOutputs (modifyDirection DLeft (Vector.singleton (unBallType ty) <> )) writePath routed y ty (start + 1, diff - 1) | otherwise = do MArray.modifyArray routed (start, y) $ modifyInputs (modifyDirection DUp (Vector.singleton (unBallType ty) <> )) MArray.modifyArray routed (start, y - 1) $ modifyOutputs (modifyDirection DDown (Vector.singleton (unBallType ty) <> )) fromOutputRows :: PuzzleConfig -> [PuzzleRow] -> IO (Array (X, Y) (Vector BallType')) fromOutputRows cfg rows' = do connections :: IOArray (X, Y) (Vector BallType') <- MArray.newGenArray ((0,0), (Vector.length firstRow - 1, Vector.length cells - 1)) $ const $ pure Vector.empty Vector.forM_ (Vector.indexed cells) $ \(y, row) -> do Vector.forM_ (Vector.indexed row) $ \(x, cell) -> do MArray.writeArray connections (x, y) cell MArray.freeze connections where rows = Vector.fromList rows' firstRow = cells Vector.! 0 cells = fmap (toCell cfg) rows toCell :: PuzzleConfig -> PuzzleRow -> Vector (Vector BallType') toCell cfg row = os where os :: Vector (Vector BallType') os = Vector.fromList $ fmap (Vector.mapMaybe toBallType) $ chunked $ unPuzzleRow row chunked xs | Vector.null xs = [] | otherwise = let (a, b) = Vector.splitAt chunkSize xs in a : chunked b chunkSize = Set.size $ puzzleLocations cfg -- connectColumns :: PuzzleConfig -> OutputColumn -> OutputColumn -> getRelative' :: Array (X, Y) a -> (X, Y) -> Direction -> Maybe ((X, Y), a) getRelative' g (x0, y0) r | Ix.inRange bounds rp = Just (rp,g Array.! rp) | otherwise = Nothing where rp = bimap (x0 +) (y0 +) $ relMap r bounds = Array.bounds g relMap :: Direction -> (X, Y) relMap DUp = ( 0, -1) relMap DLeft = (-1, 0) relMap DRight = ( 1, 0) relMap DDown = ( 0, 1) rowIndex :: PuzzleRow -> Int -> PuzzleConnector rowIndex p = (unPuzzleRow p Vector.!) rowLength :: PuzzleRow -> Int rowLength = Vector.length . unPuzzleRow modifyPuzzleRow :: PuzzleRow -> (forall s. MVector s PuzzleConnector -> ST s ()) -> PuzzleRow modifyPuzzleRow row f = PuzzleRow $ Vector.create $ do v <- Vector.thaw (unPuzzleRow row) _ <- f v pure v updateRow :: PuzzleRow -> [(Int,PuzzleConnector )] -> PuzzleRow updateRow row updates = PuzzleRow $ unPuzzleRow row Vector.// updates findBalls :: PuzzleRow -> BallType' -> Vector Int findBalls row b = Vector.findIndices (== ConnectorOpen b) (unPuzzleRow row) findEmpty :: PuzzleRow -> Vector Int findEmpty row = Vector.findIndices (== ConnectorEmpty) (unPuzzleRow row) rowBalls :: PuzzleRow -> Vector BallType' rowBalls = Vector.mapMaybe toBallType . unPuzzleRow rowBallsIndexed :: PuzzleRow -> Vector (Int, BallType') rowBallsIndexed = Vector.mapMaybe (\(i, r) -> (i, ) <$> toBallType r) . Vector.indexed . unPuzzleRow ballCounts :: PuzzleRow -> Map BallType' Int ballCounts row = Map.fromListWith (+) $ fmap (,1) $ Vector.toList $ rowBalls row addBallType :: IOGenM StdGen -> PuzzleConfig -> PuzzleRow -> IO PuzzleRow addBallType g cfg row = do ball <- randomBallType g cfg sampleOne g emptySlots >>= \case Nothing -> pure row Just i -> pure $ updateRow row [(i, ConnectorOpen ball)] where emptySlots = findEmpty row -- TODO this could do a weighted sample removeRandomBall :: IOGenM StdGen -> PuzzleConfig -> PuzzleRow -> IO PuzzleRow removeRandomBall g cfg row = pickOne g extras >>= \case Nothing -> pure row (Just bt) -> do sampleOne g (findBalls row bt) >>= \case Nothing -> pure row Just i -> pure $ updateRow row [(i, ConnectorEmpty)] where r = Vector.toList $ Vector.mapMaybe toBallType $ unPuzzleRow row extras = r List.\\ (BallType' <$> minBallConfiguration cfg) sampleOne :: IOGenM StdGen -> Vector a -> IO (Maybe a) sampleOne g v = do i <- randomRM (0, Vector.length v - 1) g pure $ v Vector.!? i towardsBallCount :: IOGenM StdGen -> PuzzleConfig -> Int -> PuzzleRow -> IO PuzzleRow towardsBallCount g cfg ballCount row | Vector.length balls < ballCount = do putStrLn "Adding ball" addBallType g cfg row | Vector.length balls > ballCount = do putStrLn "Removing ball" removeRandomBall g cfg row | otherwise = pure row where balls = rowBalls row spaceOutputs :: PuzzleConfig -> PuzzleRow -> IO PuzzleRow spaceOutputs cfg row = do -- (0,10,50,5,10) print (outsideSpace, ballSpace, rowLen, locationCount, ballCount) pure $ PuzzleRow $ Vector.create $ do v <- MVector.generate rowLen $ const ConnectorEmpty forM_ [0 .. ballCount - 1] $ \i -> do MVector.write v ((locationCount`div` 2) + i * ballSpace) $ ConnectorOpen $ balls Vector.! i pure v where outsideSpace = (rowLen `mod` locationCount) `div` 2 ballSpace = rowLen `div` ballCount locationCount = Set.size $ puzzleLocations cfg rowLen = rowLength row ballCount = Vector.length balls balls = Vector.mapMaybe toBallType $ unPuzzleRow row data SwapConfig = SwapConfig { swapRadius :: (Int, Int) , ballSwaps :: (Int, Int) } deriving (Eq, Ord, Show) swapBalls :: IOGenM StdGen -> SwapConfig -> PuzzleRow -> IO PuzzleRow swapBalls g swaps row = do swapCount <- randomRM (ballSwaps swaps) g toSwap <- pickN g (Set.fromList [0.. len -1]) swapCount shuffles <- forM (Set.toList toSwap) $ \start -> do radius <- randomRM (swapRadius swaps) g r <- randomRM (-radius, radius) g let i = min (len - 1) $ max 0 $ start + r pure $ (start,i) pure $ modifyPuzzleRow row $ \v -> do forM_ shuffles $ \(i, j) -> do let (iB, _) = balls Vector.! i (jB, _) = balls Vector.! j MVector.swap v iB jB where len = Vector.length balls balls = rowBallsIndexed row renderRows :: [PuzzleRow] -> String renderRows = unlines . fmap (List.intersperse ' ' . Vector.toList . Vector.map renderConnector . unPuzzleRow) renderConnector :: PuzzleConnector -> Char renderConnector ConnectorEmpty = ' ' renderConnector (ConnectorOpen (BallType' i)) = head $ show i applyAll :: forall a. a -> [a -> IO a] -> IO [a] applyAll a fs = fmap (uncurry (:)) $ Foldable.foldlM (\(x,xs) f -> f x >>= \x' -> pure (x',x:xs)) (a, []) fs -- swapBalls :: -- sortTo :: PuzzleRow -> PuzzleRow -> IO PuzzleRow -- sortTo target row = do -- let targetBalls = rowBalls target -- rowBalls' = rowBalls row -- targetBalls' = Vector.filter (`notElem` rowBalls') targetBalls -- pure $ foldl' (\r b -> updateRow r [(i, ConnectorOpen b)]) row $ Vector.toList targetBalls' -- where -- i = Vector.length (unPuzzleRow row) - 1 -- max swaps is the number of locations data Direction = DUp | DDown | DLeft | DRight deriving (Eq, Ord, Show) directions :: [Direction] directions = [DUp, DDown, DLeft, DRight] dirToRel :: Direction -> RelativeCell dirToRel DUp = RCUp dirToRel DDown = RCDown dirToRel DLeft = RCLeft dirToRel DRight = RCRight onEdge :: Direction -> Double -> Position onEdge DUp x = (x, 0) onEdge DDown x = (x, 1) onEdge DLeft y = (0, y) onEdge DRight y = (1, y) switchDirection :: Direction -> Direction switchDirection DUp = DDown switchDirection DDown = DUp switchDirection DLeft = DRight switchDirection DRight = DLeft -- | Connectors for a puzzle for each edge of the puzzle data DirectedConnectors i = DirectedConnectors { dcUp :: i , dcDown :: i , dcLeft :: i , dcRight :: i } deriving (Eq, Ord, Show) instance Functor DirectedConnectors where fmap f (DirectedConnectors u d l r) = DirectedConnectors (f u) (f d) (f l) (f r) -- | Get the connectors in a direction inDirection :: Direction -> DirectedConnectors i -> i inDirection DUp = dcUp inDirection DDown = dcDown inDirection DLeft = dcLeft inDirection DRight = dcRight -- | Modify the connectors in a direction modifyDirection :: Direction -> (i -> i) -> DirectedConnectors i -> DirectedConnectors i modifyDirection DUp f (DirectedConnectors u d l r) = DirectedConnectors (f u) d l r modifyDirection DDown f (DirectedConnectors u d l r) = DirectedConnectors u (f d) l r modifyDirection DLeft f (DirectedConnectors u d l r) = DirectedConnectors u d (f l) r modifyDirection DRight f (DirectedConnectors u d l r) = DirectedConnectors u d l (f r) -- | Turn the connectors into a map of directions to connectors directed :: DirectedConnectors i -> Map Direction i directed d = Map.fromList $ (id &&& flip inDirection d) <$> directions -- | Sum of connectors in all directions connectorCount :: Num i => DirectedConnectors i -> i connectorCount (DirectedConnectors u d l r) = u + d + l + r -- | The inputs and outputs of a puzzle data PuzzleConnectors i = PuzzleConnectors { puzzleInputs :: DirectedConnectors i , puzzleOutputs :: DirectedConnectors i } deriving (Eq, Ord, Show) instance Functor PuzzleConnectors where fmap f (PuzzleConnectors i o) = PuzzleConnectors (fmap f i) (fmap f o) usedSides :: PuzzleConnectors Int -> Int usedSides (PuzzleConnectors i o) = Map.size $ Map.filter (> 0) $ Map.unionWith (+) (directed i) (directed o) -- | Modify the input connectors of a puzzle modifyInputs :: (DirectedConnectors i -> DirectedConnectors i) -> PuzzleConnectors i -> PuzzleConnectors i modifyInputs f (PuzzleConnectors i o) = PuzzleConnectors (f i) o -- | Modify the output connectors of a puzzle modifyOutputs :: (DirectedConnectors i -> DirectedConnectors i) -> PuzzleConnectors i -> PuzzleConnectors i modifyOutputs f (PuzzleConnectors i o) = PuzzleConnectors i (f o) genPuzzleMachine :: [BallTypes] -> X -> Y -> MetaMachine Puzzle genPuzzleMachine btys xdim ydim = do let (ballPit, _, _) = let l = length btys (q, r) = l `quotRem` xdim in foldl' (\(m, a, btys') i -> let (h, t) | a == 0 = splitAt q btys' | otherwise = splitAt (q+1) btys' in (Map.insert i h m, if a == 0 then 0 else a-1, t)) (Map.empty, r, btys) [0..xbound] arr = Array.array ((0,0), (xbound, ybound)) [( (x,y) , let -- invariant: if an entry exists in the ballpit, then bls is at least 1. -- If not, this is an error in the quotient code (qMap). xbls = case Map.lookup x ballPit of Nothing -> error "genPuzzleMachine: Ball entry empty" Just bs -> length bs -- invariant: normalize the ball position before inserting -- into puzzles so that the relative position is biased towards -- the start of the cell (not the ball) ds = fromList $ [ (fromIntegral i + 0.5) / fromIntegral xbls | i <- [0..xbls - 1] ] -- invariant: fill in the first row with the simplest machine: -- a straight down path req. reqTiles = fromList [RCUp | y /= 0] in Puzzle reqTiles (fmap (\d -> Gateway (d,0) (fromList [GatewayBall 1 1]) ) ds) (fmap (\d -> Gateway (d,1) (fromList [GatewayBall 1 1])) ds) mempty ) | x <- [0..xbound] , y <- [0..ybound] ] MetaMachine arr (TileSize 700 700) 0.001 mempty where xbound :: X xbound = xdim - 1 ybound :: Y ybound = ydim - 1 ================================================ FILE: test/Main.hs ================================================ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} module Main (main) where import Control.Concurrent.Async import Control.Monad import qualified Control.Monad.Catch as E import Control.Monad.Extra (whileM) import Control.Monad.IO.Class import Control.Monad.Reader import qualified Data.Aeson as JS import qualified Data.Array as Array import Data.Cache.LRU.IO (AtomicLRU) import qualified Data.Cache.LRU.IO as LRU import Data.Foldable import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import Data.List (nub, sort, isSubsequenceOf) import Data.Time import Data.Time.Calendar.OrdinalDate import Data.UUID import qualified Data.UUID.Types.Internal as UUID import qualified Database.Redis as Redis import GHC.IsList import Hedgehog import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range import Incredible.Config import Incredible.Data import Incredible.DataStore import qualified Incredible.DataStore.Memory as MemStore import qualified Incredible.DataStore.Redis as IRedis import Incredible.Puzzle import Test.Tasty import qualified Test.Tasty.Hedgehog as H import System.Directory import System.IO (hGetLine) import System.IO.Temp (emptySystemTempFile) import qualified System.Process as Process main :: IO () main = defaultMain tests tests :: TestTree tests = testGroup "Incredible" [ toFromJSONTests , genPuzzleTests , checkReady , checkRemanufacture , deltaMachineTest , checkDataStore "Memory Store" (\mp act -> act =<< fst <$> MemStore.initIncredibleState () mp) , checkDataStore "Redis Store" testingRedis ] toJSObject :: JS.ToJSON a => a -> JS.Object toJSObject a = case JS.toJSON a of JS.Object o -> o _ -> error "Wasn't actually a JSON Object." genPosition :: MonadGen m => m Position genPosition = (,) <$> Gen.realFloat (Range.constant 0 1) <*> Gen.realFloat (Range.constant 0 1) genGateway :: MonadGen m => m Gateway genGateway = Gateway <$> genPosition <*> (fromList <$> Gen.list (Range.constant 0 5) (GatewayBall <$> (Gen.integral (Range.constant 1 4)) <*> Gen.realFloat (Range.constant 0 1))) genPuzzle :: (MonadGen m, JS.ToJSON a) => a -> m Puzzle genPuzzle p = Puzzle <$> (fromList <$> Gen.list (Range.constant 0 5) Gen.enumBounded) <*> (fromList <$> Gen.list (Range.constant 0 5) genGateway) <*> (fromList <$> Gen.list (Range.constant 0 5) genGateway) <*> pure (toJSObject p) genUUID :: MonadGen m => m UUID genUUID = do b0 <- Gen.word8 Range.constantBounded b1 <- Gen.word8 Range.constantBounded b2 <- Gen.word8 Range.constantBounded b3 <- Gen.word8 Range.constantBounded b4 <- Gen.word8 Range.constantBounded b5 <- Gen.word8 Range.constantBounded b6 <- Gen.word8 Range.constantBounded b7 <- Gen.word8 Range.constantBounded b8 <- Gen.word8 Range.constantBounded b9 <- Gen.word8 Range.constantBounded ba <- Gen.word8 Range.constantBounded bb <- Gen.word8 Range.constantBounded bc <- Gen.word8 Range.constantBounded bd <- Gen.word8 Range.constantBounded be <- Gen.word8 Range.constantBounded bf <- Gen.word8 Range.constantBounded pure $ UUID.buildFromBytes 4 b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 ba bb bc bd be bf genTime :: MonadGen m => m UTCTime genTime = UTCTime <$> (fromOrdinalDate <$> Gen.integral (Range.linearFrom 2024 (-100) 3000) <*> Gen.int (Range.constant 1 366)) <*> Gen.realFrac_ (Range.constant 0 86401) genBlueprint :: (MonadGen m, JS.ToJSON a) => HashMap Int a -> m Blueprint genBlueprint s = Blueprint <$> genUUID <*> Gen.text (Range.constant 0 50) Gen.unicode <*> Gen.choice [Just <$> genTime, pure Nothing] <*> pure (fmap toJSObject s) genMetaMachine :: (MonadGen m, JS.ToJSON tile) => (MonadGen m => (X, Y) -> m tile) -> m (MetaMachine tile) genMetaMachine tileGen = do xsize <- Gen.integral (Range.exponential 1 100) ysize <- Gen.integral (Range.exponential 1 100) genMetaMachineSized xsize ysize tileGen genMetaMachineSized :: (MonadGen m, JS.ToJSON tile) => X -> Y -> (MonadGen m => (X, Y) -> m tile) -> m (MetaMachine tile) genMetaMachineSized xsize ysize tileGen = do ts <- TileSize <$> Gen.int (Range.constant 10 1000) <*> Gen.int (Range.constant 10 1000) r <- Gen.realFrac_ (Range.constant 0 1) pp <- (fromList <$> Gen.list (Range.constant 0 5) genUUID) translateMachine (\xy _ -> tileGen xy) $ MetaMachine (Array.listArray ((0,0), (xsize-1, ysize-1)) [()..]) ts r pp toFromJSONTests :: TestTree toFromJSONTests = testGroup "toFromJSON" [ toFromRelativeCellTest , toFromPuzzleTest , toFromBlueprint , toFromMetaMachinePuzzleID , toFromGameState ] where checkJSON :: (JS.ToJSON a, JS.FromJSON a, Eq a, Show a, MonadTest m) => a -> m () checkJSON v = do JS.Success v === JS.fromJSON (JS.toJSON v) Just v === JS.decode (JS.encode v) toFromRelativeCellTest = H.testProperty "RelativeCell To/From Inverse" $ property $ do rc::RelativeCell <- forAll Gen.enumBounded checkJSON rc toFromPuzzleTest = H.testProperty "Puzzle To/From Inverse" $ property $ do unitPzl <- forAll $ genPuzzle $ JS.object [] strPzl <- forAll $ genPuzzle $ JS.object ["str" JS..= ("test string"::String)] arrPzl <- forAll $ genPuzzle $ JS.object ["one" JS..= (1::Int), "two" JS..= (2::Int)] checkJSON unitPzl checkJSON strPzl checkJSON arrPzl toFromBlueprint = H.testProperty "Blueprint To/From Inverse" $ property $ do unitBp <- forAll $ genBlueprint $ (mempty::HashMap Int JS.Object) strBp <- forAll $ genBlueprint $ HashMap.singleton 1 $ JS.object ["str" JS..= ("test string"::String)] arrBp <- forAll $ genBlueprint $ HashMap.singleton 1 $ JS.object ["one" JS..= (1::Int), "two" JS..= (2::Int)] checkJSON unitBp checkJSON strBp checkJSON arrBp toFromMetaMachinePuzzleID = H.testProperty "MetaMachine PuzzleID To/From Inverse" $ property $ do mm::MetaMachine PuzzleID <- forAll $ genMetaMachine (\_ -> fmap puzzleID $ genPuzzle $ JS.object []) checkJSON mm toFromGameState = H.testProperty "GameState PuzzleID To/From Inverse" $ property $ do mm::GameState <- forAll $ genMetaMachine (\_ -> Gen.choice [ fmap (Left . puzzleID) $ genPuzzle $ JS.object [] , fmap (Right . blueprintID) $ genBlueprint $ HashMap.singleton 1 $ JS.object [] ]) checkJSON mm baseMeta :: Array.Array (Int, Int) a -> MetaMachine a baseMeta a = MetaMachine a (TileSize 700 700) 0.001 mempty genPuzzleTests :: TestTree genPuzzleTests = H.testProperty "Puzzle Generator" $ property $ test $ do let m0 = genPuzzleMachine [BallType '0'] 1 1 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)]) let m1 = genPuzzleMachine [BallType '0', BallType '1'] 1 1 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)]) let m2 = genPuzzleMachine [BallType '0', BallType '1'] 2 1 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) , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) ]) let m3 = genPuzzleMachine [BallType '0', BallType '1'] 2 1 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) , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) ]) let m4 = genPuzzleMachine [BallType '0', BallType '1'] 2 2 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) , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) , ((0, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) , ((1, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) ]) let m5 = genPuzzleMachine [BallType '0', BallType '1', BallType '2'] 2 1 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) , ((1, 0), Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty) ]) let m6 = genPuzzleMachine [BallType '0', BallType '1'] 1 2 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) , ((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) ]) checkReady :: TestTree checkReady = H.testProperty "Check Ready Puzzles" $ property $ test $ do let pzl0 = Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty let pzl1 = Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty let pzlMap = HashMap.fromList [(puzzleID pzl0, pzl0), (puzzleID pzl1, pzl1)] let m0 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0) , ((1, 0), Left $ puzzleID pzl0) , ((0, 1), Left $ puzzleID pzl1) , ((1, 1), Left $ puzzleID pzl1) ] [pWithID pzl0, pWithID pzl0] === findReadyPuzzles pzlMap m0 let bp0 = Blueprint (puzzleID pzl0) "" Nothing mempty let m1 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0) , ((1, 0), Right $ blueprintID bp0) , ((0, 1), Left $ puzzleID pzl1) , ((1, 1), Left $ puzzleID pzl1) ] [pWithID pzl0, pWithID pzl1] === findReadyPuzzles pzlMap m1 where pWithID pzl = (puzzleID pzl, pzl) checkRemanufacture :: TestTree checkRemanufacture = H.testProperty "remanufacture" $ property $ test $ do let pzl0 = Puzzle [] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty let pzl1 = Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (0.5, 1) [GatewayBall 1 1]] mempty let m0 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), pzl0) , ((1, 0), pzl0) , ((0, 1), pzl1) , ((1, 1), pzl1) ] fmap (Left . puzzleID) m0 === remanufacture m0 (fmap (Left . puzzleID) m0) let m1 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), pzl0) , ((1, 0), pzl0) , ((0, 1), pzl1) , ((1, 1), Puzzle [RCUp] [Gateway (0.5, 0) [GatewayBall 1 1]] [Gateway (1, 1) [GatewayBall 1 1]] mempty) ] fmap (Left . puzzleID) m1 === remanufacture m1 (fmap (Left . puzzleID) m0) let m2 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0) , ((1, 0), Left $ puzzleID pzl0) , ((0, 1), Left $ puzzleID pzl1) , ((1, 1), Right $ Blueprint (puzzleID pzl1) "" Nothing mempty) ] fmap (Left . puzzleID) m1 === remanufacture m1 m2 let bp1 = Blueprint (puzzleID pzl1) "" Nothing mempty let m3 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0) , ((1, 0), Left $ puzzleID pzl0) , ((0, 1), Right bp1) , ((1, 1), Right $ Blueprint (puzzleID pzl1) "" Nothing mempty) ] baseMeta (fmap (Left . puzzleID) (mmGrid m1) Array.// [((0,1), Right $ blueprintID bp1)]) === remanufacture m1 m3 let m4 = baseMeta $ Array.array ((0,0), (2,1)) [ ((0, 0), pzl0) , ((1, 0), pzl0) , ((2, 0), pzl0) , ((0, 1), pzl1) , ((1, 1), pzl1) , ((2, 1), pzl1) ] (fmap (Left . puzzleID) m4) === remanufacture m4 (fmap (Left . puzzleID) m0) let m5 = baseMeta $ Array.array ((0,0), (1,1)) [ ((0, 0), Left $ puzzleID pzl0) , ((1, 0), Left $ puzzleID pzl0) , ((0, 1), Right bp1) , ((1, 1), Left $ puzzleID pzl1) ] let m5r' = fmap (Left . puzzleID) m4 let m5r = m5r' { mmGrid = (mmGrid m5r') Array.// [((0,1), Right $ blueprintID bp1)] } m5r === remanufacture m4 m5 deltaMachineTest :: TestTree deltaMachineTest = H.testProperty "deltaMachine" $ property $ do -- xsize <- forAll $ Gen.integral (Range.exponential 1 100) -- ysize <- forAll $ Gen.integral (Range.exponential 1 100) ma <- forAll $ genMetaMachine $ const $ Gen.word8 Range.constantBounded mb <- forAll $ genMetaMachine $ const $ Gen.word8 Range.constantBounded let da2b = ma `deltaMachine` mb let db2a = mb `deltaMachine` ma mb === applyDelta ma da2b ma === applyDelta mb db2a data StoreReader s = StoreReader { srStore :: s , srMC :: AtomicLRU MachineVersion GameState , srBC :: AtomicLRU BlueprintID Blueprint , srSC :: AtomicLRU BlueprintID Snapshot } instance IncredibleData s => HasDataStore (StoreReader s) s where getStore = srStore getMachineCache = srMC getBlueprintCache = srBC getSnapshotCache = srSC testingRedis :: (MonadFail m', MonadIO m', E.MonadCatch m') => MetaMachine Puzzle -> (IRedis.IncredibleRedisStore -> m' a) -> m' a testingRedis mp act = do -- PropertyT is not MonadMask unixSocketPath <- liftIO $ emptySystemTempFile "redis.socket" liftIO $ removeFile unixSocketPath let c = (Process.proc "redis-server" [ "--unixsocket", unixSocketPath , "--port", "0" , "--bind", "127.0.0.1" , "--unixsocketperm", "700" , "--maxmemory-policy", "volatile-ttl" , "--maxmemory", "100mb" , "--maxclients", "100" , "--appendonly", "no" , "--save", "" , "--loglevel", "notice" ]) { Process.std_in = Process.Inherit , Process.std_out = Process.CreatePipe , Process.std_err = Process.Inherit , Process.close_fds = True } p@(_, Just hout, _, _) <- liftIO $ Process.createProcess c (`E.onException` (liftIO $ Process.cleanupProcess p)) $ do -- Wait for redis to say it is ready whileM $ liftIO $ do rol <- hGetLine hout pure $ not $ "Ready to accept connections unix" `isSubsequenceOf` rol -- Might need to keep consuming the stdout to avoid blocking. let rc = Just $ IncredibleRedisConfig { incredibleRedisHostName = "" , incredibleRedisPort = Redis.UnixSocket unixSocketPath , incredibleRedisDatabase = 0 , incredibleRedisMaxConnections = 100 , incredibleRedisMaxIdleTimeout = 10 , incredibleRedisPassword = Nothing , incredibleRedisRetryCount = 10 , incredibleWorkOrderTTL = 1 , incredibleOrderBookTTL = 1 , incredibleRedisUseTLS = False } r' <- act =<< fst <$> IRedis.initIncredibleState (IncredibleConfig undefined undefined rc undefined) mp liftIO $ Process.cleanupProcess p pure r' -- The storer needs to be seperated to different datasets. checkDataStore :: forall s . IncredibleData s => String -> (forall m' a . (MonadFail m', MonadIO m', E.MonadCatch m') => MetaMachine Puzzle -> (s -> m' a) -> m' a) -> TestTree checkDataStore storeName storer = testGroup storeName [ checkDBStart , checkEdit , checkConcurrent , checkAddBlueprint , checkMod ] where mkStore :: (MonadFail m, MonadIO m, E.MonadCatch m) => MetaMachine Puzzle -> (StoreReader s -> m a) -> m a mkStore mp act = storer mp $ \s -> (StoreReader s <$> liftIO (LRU.newAtomicLRU (Just 1))) <*> liftIO (LRU.newAtomicLRU (Just 1)) <*> liftIO (LRU.newAtomicLRU (Just 1)) >>= act checkDBStart = H.testProperty "DB Start" $ property $ do mm0 <- forAll $ genMetaMachine (const $ genPuzzle $ JS.object []) mkStore mm0 $ \sr -> do sm0 <- runReaderT getCurrentMachine sr VersionedMachine 0 (fmap (Left . puzzleID) mm0) === sm0 sm1 <- runReaderT (getMachine 1) sr Nothing === sm1 checkEdit = H.testProperty "Edit" $ property $ do xsize <- forAll $ Gen.integral (Range.linear 1 10) ysize <- forAll $ Gen.integral (Range.linear 1 10) ma <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object ["value" JS..= 'a'] mb <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object ["value" JS..= 'b'] assert $ ma /= mb mkStore ma $ \sr -> (`runReaderT` sr) $ do me0 <- getCurrentMachine VersionedMachine 0 (fmap (Left . puzzleID) ma) === me0 editCurrentMachine (const ((), fmap (Left . puzzleID) ma)) me1 <- getCurrentMachine VersionedMachine 0 (fmap (Left . puzzleID) ma) === me1 editCurrentMachine (const ((), fmap (Left . puzzleID) mb)) me2 <- getCurrentMachine VersionedMachine 1 (fmap (Left . puzzleID) mb) === me2 editCurrentMachine (const ((), fmap (Left . puzzleID) mb)) me3 <- getCurrentMachine VersionedMachine 1 (fmap (Left . puzzleID) mb) === me3 editCurrentMachine (const ((), fmap (Left . puzzleID) ma)) me4 <- getCurrentMachine VersionedMachine 2 (fmap (Left . puzzleID) ma) === me4 checkConcurrent = H.testProperty "Concurrent" $ property $ do xsize <- forAll $ Gen.integral (Range.linear 1 10) ysize <- forAll $ Gen.integral (Range.linear 1 10) ma <- forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object ["value" JS..= (0::Int)] let raceSize = (10::Int) mbs <- mapM (\mv -> forAll $ genMetaMachineSized xsize ysize $ const $ genPuzzle $ JS.object ["value" JS..= mv]) [1..raceSize] assert $ length mbs == raceSize assert $ notElem ma mbs assert $ length (nub mbs) == raceSize mkStore ma $ \sr -> do let machineList = fmap (fmap (Left . puzzleID)) $ ma:mbs let updateMap::HashMap MachineVersion GameState = HashMap.fromList $ zip [0..] $ tail machineList updates <- liftIO $ forM mbs $ \_ -> async $ (`runReaderT` sr) $ editCurrentMachine $ \gs -> ((), updateMap HashMap.! vmVersion gs) mapM_ (liftIO . wait) updates forM_ (zip [0..] machineList) $ \(i, mm) -> do mmi <- (`runReaderT` sr) $ getMachine i Just mm === mmi lm <- (`runReaderT` sr) getCurrentMachine VersionedMachine (fromIntegral raceSize) (last machineList) === lm checkAddBlueprint = H.testProperty "Add Blueprint" $ property $ do let mm = genPuzzleMachine [BallType '0'] 1 1 bpCount <- forAll $ Gen.int (Range.linear 1 20) bps <- replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object) mkStore mm $ \sr -> do (`runReaderT` sr) $ traverse_ queueModeration bps (`runReaderT` sr) $ forM_ bps $ \bp -> do gbp <- getBlueprint $ blueprintID bp Just bp === gbp (`runReaderT` sr) $ do gbp0 <- getBlueprint $ blueprintID $ head bps Just (head bps) === gbp0 gbp1 <- getBlueprint $ blueprintID $ head bps Just (head bps) === gbp1 checkMod = H.testProperty "Mod Ops" $ property $ do let mm = genPuzzleMachine [BallType '0'] 1 2 bpCount <- forAll $ Gen.int (Range.linear 1 20) let pzlID0 = puzzleID $ mmGrid mm Array.! (0,0) let pzlID1 = puzzleID $ mmGrid mm Array.! (0,1) bps0 <- fmap (nub . fmap (\bp -> bp {bPuzzleID=pzlID0})) $ replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object) bps1 <- fmap (nub . fmap (\bp -> bp {bPuzzleID=pzlID1})) $ replicateM bpCount $ forAll $ genBlueprint (mempty::HashMap Int JS.Object) mkStore mm $ \sr -> do (`runReaderT` sr) $ forM_ ([pzlID0, pzlID1]::[PuzzleID]) $ \pid -> do mq <- listModerationQueue pid [] === mq (`runReaderT` sr) $ forM_ (bps0<>bps1) $ \bp -> queueModeration bp (`runReaderT` sr) $ forM_ ([(pzlID0, bps0), (pzlID1, bps1)]::[(PuzzleID, [Blueprint])]) $ \(pid, bps) -> do mq <- listModerationQueue pid sort (fmap blueprintID bps) === sort mq (`runReaderT` sr) $ forM_ (bps0<>bps1) $ \bp -> dequeueModeration (blueprintID bp) (`runReaderT` sr) $ forM_ ([pzlID0, pzlID1]::[PuzzleID]) $ \pid -> do mq <- listModerationQueue pid [] === mq