├── .eslintrc.json ├── logs.sh ├── src ├── components │ └── App │ │ ├── index.js │ │ └── App.js ├── index.js ├── hooks │ └── use-click-tracker.js ├── utils.js ├── global-styles.css ├── bitset.js └── randomizedColors.js ├── public ├── favicon.png ├── favicon.png.old ├── favicon.svg └── index.html ├── Makefile ├── hosts ├── static └── fonts │ ├── Sunset-Demi.woff │ ├── apercu-bold-pro.ttf │ ├── apercu-bold-pro.woff │ ├── apercu-bold-pro.woff2 │ ├── apercu-italic-pro.ttf │ ├── apercu-italic-pro.woff │ ├── apercu-italic-pro.woff2 │ ├── apercu-regular-pro.ttf │ └── apercu-regular-pro.woff ├── .parcelrc ├── start_gunicorn.sh ├── cleanup.sh ├── .gitignore ├── TODO ├── requirements.txt ├── gunicorn_restart.sh ├── cleanup_old_logs.py ├── LICENSE ├── compute-stats.sh ├── README.md ├── package.json ├── go.mod ├── deploy.sh ├── freeze_bits_and_compute_stats.py ├── server.py ├── go.sum └── main.go /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cat hosts | xargs -n 1 -P 10 -I'{}' ssh root@{} "journalctl -f" 3 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | export * from "./App"; 2 | export { default } from "./App"; 3 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/public/favicon.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | GOOS=linux GOARCH=amd64 go build -o /tmp/checkbox main.go 3 | run: 4 | go run main.go 5 | 6 | -------------------------------------------------------------------------------- /public/favicon.png.old: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/public/favicon.png.old -------------------------------------------------------------------------------- /hosts: -------------------------------------------------------------------------------- 1 | bak.onemil 2 | 2bak.onemil 3 | 3bak.onemil 4 | 4bak.onemil 5 | 5bak.onemil 6 | 6bak.onemil 7 | 7bak.onemil 8 | 8bak.onemil -------------------------------------------------------------------------------- /static/fonts/Sunset-Demi.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/Sunset-Demi.woff -------------------------------------------------------------------------------- /static/fonts/apercu-bold-pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-bold-pro.ttf -------------------------------------------------------------------------------- /static/fonts/apercu-bold-pro.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-bold-pro.woff -------------------------------------------------------------------------------- /static/fonts/apercu-bold-pro.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-bold-pro.woff2 -------------------------------------------------------------------------------- /static/fonts/apercu-italic-pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-italic-pro.ttf -------------------------------------------------------------------------------- /static/fonts/apercu-italic-pro.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-italic-pro.woff -------------------------------------------------------------------------------- /static/fonts/apercu-italic-pro.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-italic-pro.woff2 -------------------------------------------------------------------------------- /static/fonts/apercu-regular-pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-regular-pro.ttf -------------------------------------------------------------------------------- /static/fonts/apercu-regular-pro.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nolenroyalty/one-million-checkboxes/HEAD/static/fonts/apercu-regular-pro.woff -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@parcel/config-default" 4 | ], 5 | "reporters": [ 6 | "...", 7 | "parcel-reporter-static-files-copy" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import './global-styles.css'; 5 | import App from './components/App'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render(); 9 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /start_gunicorn.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BASE_PORT=5001 4 | 5 | for i in 0 1 2 3 6 | do 7 | PORT=$((BASE_PORT + i)) 8 | gunicorn --worker-class eventlet --workers 1 --threads 3 --bind 0.0.0.0:$PORT server:app & 9 | done 10 | 11 | PROCESS_JOBS='true' gunicorn --worker-class eventlet --workers 1 server:app & 12 | 13 | wait 14 | -------------------------------------------------------------------------------- /cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set up the environment 4 | export HOME=/home/ubuntu 5 | export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 6 | 7 | source /home/ubuntu/venv/bin/activate 8 | source /home/ubuntu/.redis-creds 9 | export PYTHONPATH=/home/ubuntu:$PYTHONPATH 10 | python /home/ubuntu/cleanup_old_logs.py 11 | 12 | # Deactivate the virtual environment 13 | deactivate 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # parcel-generated files 7 | dist 8 | .parcel-cache 9 | 10 | # misc 11 | .DS_Store 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | *.swp 22 | venv/ 23 | 24 | checkbox 25 | some-numbers.html 26 | 27 | __pycache__/ 28 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * set up logging 2 | * set up a job to disable shit randomly 3 | 4 | * ADD BASIC DEBOUNCING 5 | * issue an alert if you've clicked more than 10 boxes in a second 6 | * disable if you've clicked more than 100 boxes in 60 seconds (until you haven't) 7 | * server-side, do something similar but less extreme by IP 8 | * add a UUID generated client-side for this 9 | * uncheck boxes randomly IF lots have been checked recently 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | APScheduler==3.10.4 2 | bidict==0.23.1 3 | bitarray==2.9.2 4 | blinker==1.8.2 5 | click==8.1.7 6 | dnspython==2.6.1 7 | eventlet==0.36.1 8 | Flask==3.0.3 9 | Flask-Cors==4.0.1 10 | Flask-SocketIO==5.3.6 11 | Flask-WTF==1.2.1 12 | gevent==24.2.1 13 | greenlet==3.0.3 14 | gunicorn==22.0.0 15 | h11==0.14.0 16 | itsdangerous==2.2.0 17 | Jinja2==3.1.4 18 | MarkupSafe==2.1.5 19 | packaging==24.1 20 | python-engineio==4.9.1 21 | python-socketio==5.11.3 22 | pytz==2024.1 23 | redis==5.0.6 24 | setuptools==70.1.0 25 | simple-websocket==1.0.0 26 | six==1.16.0 27 | tzlocal==5.2 28 | Werkzeug==3.0.3 29 | wsproto==1.2.0 30 | WTForms==3.1.2 31 | zope.event==5.0 32 | zope.interface==6.4.post2 33 | -------------------------------------------------------------------------------- /gunicorn_restart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # jitter 4 | x=$((RANDOM % 300)) 5 | echo "sleeping for $x" 6 | sleep "$x" 7 | 8 | count_running_servers() { 9 | ps aux | grep gunicorn | grep -oP 'bind \K0\.0\.0\.0:\d+' | sort | uniq | wc -l 10 | } 11 | 12 | running_servers=$(count_running_servers) 13 | 14 | if [ $running_servers -lt 3 ]; then 15 | echo "Less than 3 servers running. Restarting all servers..." 16 | 17 | sudo systemctl stop one-million-checkboxes.service 18 | 19 | sleep 10 20 | 21 | sudo systemctl start one-million-checkboxes.service 22 | 23 | echo "Servers restarted." 24 | else 25 | echo "At least 3 servers are running. No action needed." 26 | fi 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | One Million Checkboxes 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /cleanup_old_logs.py: -------------------------------------------------------------------------------- 1 | import redis 2 | from datetime import datetime, timedelta 3 | import os 4 | 5 | print(os.environ.get("REDIS_HOST")) 6 | redis_client = redis.Redis( 7 | host=os.environ.get('REDIS_HOST', 'localhost'), 8 | port=int(os.environ.get('REDIS_PORT', 6379)), 9 | username=os.environ.get('REDIS_USERNAME', 'default'), 10 | password=os.environ.get('REDIS_PASSWORD', ''), 11 | db=0, 12 | ssl=True 13 | ) 14 | print("connected to redis") 15 | 16 | def cleanup_old_logs(days_to_keep=30): 17 | today = datetime.now().date() 18 | cutoff_date = today - timedelta(days=days_to_keep) 19 | 20 | print("before") 21 | keys = redis_client.keys("checkbox_logs:*") 22 | print("here") 23 | 24 | for key in keys: 25 | key_date = datetime.strptime(key.decode().split(':')[1], '%Y-%m-%d').date() 26 | if key_date < cutoff_date: 27 | redis_client.delete(key) 28 | else: 29 | # Keys are sorted, so if we find a key that's not old enough, we can stop 30 | break 31 | 32 | # Run this script daily 33 | cleanup_old_logs() 34 | -------------------------------------------------------------------------------- /src/hooks/use-click-tracker.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | function useClickTracker() { 4 | const clickCounts = useRef({ 5 | oneSecond: 0, 6 | fifteenSeconds: 0, 7 | sixtySeconds: 0, 8 | }); 9 | 10 | const clickTimestamps = useRef([]); 11 | 12 | const trackClick = useCallback(() => { 13 | const now = Date.now(); 14 | clickTimestamps.current.push(now); 15 | 16 | // Remove timestamps older than 60 seconds 17 | const sixtySecondsAgo = now - 60000; 18 | clickTimestamps.current = clickTimestamps.current.filter( 19 | (timestamp) => timestamp > sixtySecondsAgo 20 | ); 21 | clickCounts.current.sixtySeconds = clickTimestamps.current.length; 22 | clickCounts.current.fifteenSeconds = clickTimestamps.current.filter( 23 | (timestamp) => timestamp > now - 15000 24 | ).length; 25 | clickCounts.current.oneSecond = clickTimestamps.current.filter( 26 | (timestamp) => timestamp > now - 1000 27 | ).length; 28 | }, []); 29 | 30 | return [clickCounts, trackClick]; 31 | } 32 | 33 | export default useClickTracker; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nolen Royalty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compute-stats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | PYTHON_SCRIPT="/home/ubuntu/freeze_bits_and_compute_stats.py" 7 | TEMP_OUTPUT="/tmp/some-numbers.html" 8 | FINAL_OUTPUT="/var/www/generated-content/some-numbers.html" 9 | CONTENT_DIR="/var/www/generated-content" 10 | STAGING_OUTPUT="/tmp/some-numbers-staging.html" 11 | UBUNTU_USER="ubuntu" 12 | WWW_USER="www-data" 13 | WWW_GROUP="www-data" 14 | VENV_PATH="/home/ubuntu/venv/bin/activate" 15 | 16 | if [ "$(id -u)" -ne 0 ]; then 17 | echo "This script must be run as root" >&2 18 | exit 1 19 | fi 20 | 21 | cleanup() { 22 | rm -f "$TEMP_OUTPUT" "$STAGING_OUTPUT" 23 | } 24 | trap cleanup EXIT 25 | 26 | mkdir -p "$CONTENT_DIR" 27 | chown "$WWW_USER:$WWW_GROUP" "$CONTENT_DIR" 28 | chmod 775 "$CONTENT_DIR" 29 | 30 | sudo -u "$UBUNTU_USER" bash -c " 31 | source $VENV_PATH 32 | source /home/ubuntu/.redis-creds 33 | python $PYTHON_SCRIPT 34 | " 35 | 36 | if [ ! -f "$TEMP_OUTPUT" ]; then 37 | echo "Error: Python script did not generate the output file." >&2 38 | exit 1 39 | fi 40 | 41 | cp "$TEMP_OUTPUT" "$STAGING_OUTPUT" 42 | 43 | chown "$WWW_USER:$WWW_GROUP" "$STAGING_OUTPUT" 44 | chmod 644 "$STAGING_OUTPUT" 45 | 46 | mv "$STAGING_OUTPUT" "$FINAL_OUTPUT" 47 | 48 | echo "Stats file updated successfully at $FINAL_OUTPUT" 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # One Million Checkboxes 2 | The code that powered [One Million Checkboxes](https://en.wikipedia.org/wiki/One_Million_Checkboxes) 3 | 4 | ## THIS CODE IS MESSY 5 | I have made no attempt to clean up this code. My git hygiene was really bad. At many points I wrote awful hacky code. Sorry. 6 | 7 | If you're interested in reading this code, I recommend that you begin with [the code as it existed when I launched the site](https://github.com/nolenroyalty/one-million-checkboxes/tree/71b296ec74adbd941431559ff1d7358d817a03cb) and then observe how it evolved over time. I think that's a much better way of learning what I did than simply looking at the end result. There are a lot of problems with the end result! It's not good code. 8 | 9 | See [this blog post](https://eieio.games/essays/scaling-one-million-checkboxes/) for some details on the changes that I made to the site to scale it up. 10 | 11 | ## Want the data? 12 | The data from One Million Checkboxes is available [on the internet archive](https://archive.org/details/one-million-checkboxes-data). And a [separate repo of mine](https://github.com/nolenroyalty/one-million-checkboxes-data-scripts) provides scripts for working with the data. 13 | 14 | 15 | ## Thanks for everything 16 | It was really special to get to run this site. Thanks for playing :) 17 | 18 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function abbrNum(number, decPlaces) { 2 | let negate = false; 3 | if (number < 0) { 4 | negate = true; 5 | number = -1 * number; 6 | } 7 | // 2 decimal places => 100, 3 => 1000, etc 8 | decPlaces = Math.pow(10, decPlaces); 9 | 10 | // Enumerate number abbreviations 11 | var abbrev = ["K", "M", "B", "T"]; 12 | 13 | // Go through the array backwards, so we do the largest first 14 | for (var i = abbrev.length - 1; i >= 0; i--) { 15 | // Convert array index to "1000", "1000000", etc 16 | var size = Math.pow(10, (i + 1) * 3); 17 | 18 | // If the number is bigger or equal do the abbreviation 19 | if (size <= number) { 20 | // Here, we multiply by decPlaces, round, and then divide by decPlaces. 21 | // This gives us nice rounding to a particular decimal place. 22 | 23 | number = Math.floor((number * decPlaces) / size) / decPlaces; 24 | 25 | // Handle special case where we round up to the next abbreviation 26 | if (number === 1000 && i < abbrev.length - 1) { 27 | number = 1; 28 | i++; 29 | } 30 | 31 | // Add the letter for the abbreviation 32 | number += abbrev[i]; 33 | 34 | // We are done... stop 35 | break; 36 | } 37 | } 38 | 39 | return (negate ? "-" : "") + number; 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "one-million-checkboxes", 3 | "version": "1.0.0", 4 | "description": "One million checkboxes, synced for everyone.", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "predev": "rimraf .parcel-cache dist", 8 | "dev": "parcel public/index.html", 9 | "prebuild": "rimraf .parcel-cache dist", 10 | "build": "parcel build public/index.html", 11 | "new-component": "new-component" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "eslint": "^8.57.0", 18 | "eslint-config-react-app": "^7.0.1", 19 | "new-component": "^5.0.2", 20 | "parcel": "^2.12.0", 21 | "parcel-reporter-static-files-copy": "^1.5.3", 22 | "prettier": "^3.3.2", 23 | "process": "^0.11.10", 24 | "react": "^18.3.1", 25 | "react-dom": "^18.3.1", 26 | "react-feather": "^2.0.10", 27 | "react-use": "^17.5.0", 28 | "react-window": "^1.8.10", 29 | "rimraf": "^5.0.7", 30 | "socket.io-client": "^4.7.5", 31 | "styled-components": "^6.1.11" 32 | }, 33 | "devDependencies": { 34 | "assert": "^2.1.0", 35 | "browserify-zlib": "^0.2.0", 36 | "buffer": "^6.0.3", 37 | "crypto-browserify": "^3.12.0", 38 | "events": "^3.3.0", 39 | "path-browserify": "^1.0.1", 40 | "punycode": "^1.4.1", 41 | "querystring-es3": "^0.2.1", 42 | "stream-browserify": "^3.0.0", 43 | "stream-http": "^3.2.0", 44 | "timers-browserify": "^2.0.12", 45 | "url": "^0.11.3", 46 | "util": "^0.12.5", 47 | "vm-browserify": "^1.1.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/global-styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | Stolen from Josh's Custom CSS Reset 3 | https://www.joshwcomeau.com/css/custom-css-reset/ 4 | */ 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: border-box; 9 | } 10 | * { 11 | margin: 0; 12 | } 13 | html, 14 | body { 15 | height: 100%; 16 | --blue: #3777ff; 17 | --dark: #181a21; 18 | /* --red: hsla(0, 80%, 70%, 1); 19 | */ 20 | --red: hsla(6, 59%, 50%, 1); 21 | /* --gold: hsla(52, 80%, 70%, 1); */ 22 | /* --gold: #c2b043; */ 23 | --gold: hsla(47, 65%, 45%, 1); 24 | /* --green: hsla(103, 80%, 70%, 1); */ 25 | /* --green: hsla(113, 65%, 45%, 1); */ 26 | --green: hsla(128, 59%, 40%, 1); 27 | /* --blue: hsla(166, 80%, 70%, 1); */ 28 | /* --purple: hsla(256, 80%, 70%, 1); */ 29 | --purple: hsla(279, 59%, 50%, 1); 30 | /* --orange: hsla(24, 80%, 70%, 1); */ 31 | --orange: hsla(28, 59%, 50%, 1); 32 | 33 | background-color: #ebeaea; 34 | } 35 | body { 36 | line-height: 1.5; 37 | -webkit-font-smoothing: antialiased; 38 | font-family: "Lato", sans-serif; 39 | color: var(--dark); 40 | } 41 | img, 42 | picture, 43 | video, 44 | canvas, 45 | svg { 46 | display: block; 47 | max-width: 100%; 48 | } 49 | input, 50 | button, 51 | textarea, 52 | select { 53 | font: inherit; 54 | } 55 | p, 56 | h1, 57 | h2, 58 | h3, 59 | h4, 60 | h5, 61 | h6 { 62 | overflow-wrap: break-word; 63 | } 64 | #root, 65 | #__next { 66 | isolation: isolate; 67 | } 68 | 69 | @font-face { 70 | font-family: "Sunset Demi"; 71 | src: url("/static/fonts/Sunset-Demi.woff") format("woff"); 72 | font-weight: normal; 73 | font-style: normal; 74 | } 75 | 76 | @font-face { 77 | font-family: "Apercu Regular Pro"; 78 | src: 79 | url("/static/fonts/apercu-regular-pro.woff") format("woff"), 80 | url("/static/fonts/apercu-regular-pro.ttf") format("truetype"); 81 | font-weight: normal; 82 | font-style: normal; 83 | } 84 | 85 | @font-face { 86 | font-family: "Apercu Italic Pro"; 87 | src: 88 | url("/static/fonts/apercu-italic-pro.woff") format("woff"), 89 | url("/static/fonts/apercu-italic-pro.woff2") format("woff2"), 90 | url("/static/fonts/apercu-italic-pro.ttf") format("truetype"); 91 | font-weight: normal; 92 | font-style: normal; 93 | } 94 | -------------------------------------------------------------------------------- /src/bitset.js: -------------------------------------------------------------------------------- 1 | class BitSet { 2 | constructor({ base64String, count }) { 3 | const binaryString = atob(base64String); 4 | this.bytes = new Uint8Array(binaryString.length); 5 | this.checkCount = count; 6 | for (let i = 0; i < binaryString.length; i++) { 7 | this.bytes[i] = binaryString.charCodeAt(i); 8 | } 9 | } 10 | 11 | get(index) { 12 | const byteIndex = Math.floor(index / 8); 13 | const bitOffset = 7 - (index % 8); 14 | return (this.bytes[byteIndex] & (1 << bitOffset)) !== 0; 15 | } 16 | 17 | set(index, value) { 18 | if (typeof value === "boolean") { 19 | value = value ? 1 : 0; 20 | } 21 | const byteIndex = Math.floor(index / 8); 22 | const bitOffset = 7 - (index % 8); 23 | const current = this.bytes[byteIndex] & (1 << bitOffset); 24 | if (value) { 25 | this.bytes[byteIndex] |= 1 << bitOffset; 26 | if (current === 0) { 27 | this.checkCount++; 28 | } 29 | } else { 30 | this.bytes[byteIndex] &= ~(1 << bitOffset); 31 | if (current !== 0) { 32 | this.checkCount--; 33 | } 34 | } 35 | } 36 | 37 | toJSON() { 38 | return { 39 | base64String: this._toBase64String(), 40 | count: this.checkCount, 41 | }; 42 | } 43 | 44 | static makeEmpty() { 45 | const bytes = new Uint8Array(125000); 46 | return new BitSet({ 47 | base64String: BitSet._makeBase64String(bytes), 48 | count: 0, 49 | }); 50 | } 51 | 52 | static makeFull() { 53 | const bytes = new Uint8Array(125000).fill(255); 54 | return new BitSet({ 55 | base64String: BitSet._makeBase64String(bytes), 56 | count: 1000000, 57 | }); 58 | } 59 | 60 | static _makeBase64String(bytes) { 61 | let binary = ""; 62 | // const bytes = new Uint8Array(this.bytes.buffer); 63 | const len = bytes.byteLength; 64 | for (let i = 0; i < len; i++) { 65 | binary += String.fromCharCode(bytes[i]); 66 | } 67 | return btoa(binary); 68 | } 69 | 70 | _toBase64String() { 71 | return BitSet._makeBase64String(this.bytes); 72 | } 73 | 74 | static fromJSON({ base64String, count }) { 75 | return new BitSet({ base64String, count }); 76 | } 77 | 78 | count() { 79 | return this.checkCount; 80 | } 81 | 82 | toggle(index) { 83 | if (this.get(index)) { 84 | this.set(index, 0); 85 | } else { 86 | this.set(index, 1); 87 | } 88 | } 89 | } 90 | 91 | export default BitSet; 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nolenroyalty/one-million-checkboxes 2 | 3 | go 1.22.2 4 | 5 | toolchain go1.22.4 6 | 7 | require ( 8 | github.com/alicebob/miniredis/v2 v2.33.0 9 | github.com/gin-contrib/static v1.1.2 10 | github.com/gin-gonic/gin v1.10.0 11 | github.com/googollee/go-socket.io v1.7.0 12 | github.com/puzpuzpuz/xsync v1.5.2 13 | github.com/redis/go-redis/v9 v9.5.3 14 | github.com/zishang520/socket.io/v2 v2.2.0 15 | ) 16 | 17 | require ( 18 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 19 | github.com/andybalholm/brotli v1.1.0 // indirect 20 | github.com/bytedance/sonic v1.11.6 // indirect 21 | github.com/bytedance/sonic/loader v0.1.1 // indirect 22 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 23 | github.com/cloudwego/base64x v0.1.4 // indirect 24 | github.com/cloudwego/iasm v0.2.0 // indirect 25 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 26 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 27 | github.com/gin-contrib/sse v0.1.0 // indirect 28 | github.com/go-playground/locales v0.14.1 // indirect 29 | github.com/go-playground/universal-translator v0.18.1 // indirect 30 | github.com/go-playground/validator/v10 v10.20.0 // indirect 31 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 32 | github.com/goccy/go-json v0.10.2 // indirect 33 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect 34 | github.com/gookit/color v1.5.4 // indirect 35 | github.com/gorilla/websocket v1.5.3 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 38 | github.com/kr/text v0.1.0 // indirect 39 | github.com/leodido/go-urn v1.4.0 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/mitchellh/mapstructure v1.5.0 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/onsi/ginkgo/v2 v2.12.0 // indirect 45 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 46 | github.com/quic-go/qpack v0.4.0 // indirect 47 | github.com/quic-go/quic-go v0.44.0 // indirect 48 | github.com/quic-go/webtransport-go v0.8.0 // indirect 49 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 50 | github.com/ugorji/go/codec v1.2.12 // indirect 51 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 52 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 53 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 54 | github.com/yuin/gopher-lua v1.1.1 // indirect 55 | github.com/zishang520/engine.io-go-parser v1.2.5 // indirect 56 | github.com/zishang520/engine.io/v2 v2.1.1 // indirect 57 | github.com/zishang520/socket.io-go-parser/v2 v2.1.0 // indirect 58 | go.uber.org/mock v0.4.0 // indirect 59 | golang.org/x/arch v0.8.0 // indirect 60 | golang.org/x/crypto v0.23.0 // indirect 61 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 62 | golang.org/x/mod v0.17.0 // indirect 63 | golang.org/x/net v0.25.0 // indirect 64 | golang.org/x/sys v0.20.0 // indirect 65 | golang.org/x/text v0.15.0 // indirect 66 | golang.org/x/tools v0.21.0 // indirect 67 | google.golang.org/protobuf v1.34.1 // indirect 68 | gopkg.in/yaml.v3 v3.0.1 // indirect 69 | ) 70 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Configuration 4 | REMOTE_USER="ubuntu" 5 | #REMOTE_HOST="142.93.76.97" 6 | REMOTE_DIR="/home/ubuntu/" 7 | WWW_DIR="/var/www/one-million-checkboxes" 8 | SSH_KEY="~/.ssh/id_rsa" # Path to your SSH key 9 | 10 | # Local directories and files to sync 11 | LOCAL_DIST="./dist" 12 | LOCAL_SERVER="./server.py" 13 | LOCAL_GUNICORN="./start_gunicorn.sh" 14 | LOCAL_REQUIREMENTS="./requirements.txt" 15 | LOCAL_CLEANUPSH="./cleanup.sh" 16 | LOCAL_CLEANUPPY="./cleanup_old_logs.py" 17 | CHECKBOX_BIN="/tmp/checkbox" 18 | echo "building..." 19 | GOOS=linux GOARCH=amd64 go build -o $CHECKBOX_BIN main.go 20 | echo $(md5sum $CHECKBOX_BIN) 21 | # Rsync options 22 | RSYNC_OPTS="-avz --delete" 23 | GO_SYS_UNIT="go-one-million.service" 24 | 25 | #for REMOTE_HOST in bak.onemil 2bak.onemil 3bak.onemil 4bak.onemil 5bak.onemil 6bak.onemil 7bak.onemil 8bak.onemil 26 | for REMOTE_HOST in onemil 27 | #for REMOTE_HOST in 2bak.onemil 28 | do 29 | echo $REMOTE_HOST 30 | 31 | #REMOTE_HOST="8bak.onemil" 32 | 33 | # Sync dist directory 34 | echo "Syncing dist directory..." 35 | rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_DIST/" "root@$REMOTE_HOST:$WWW_DIR/" 36 | ssh -i $SSH_KEY root@${REMOTE_HOST} -- chown -R www-data:www-data ${WWW_DIR} 37 | ssh -i $SSH_KEY root@${REMOTE_HOST} -- chmod -R 755 ${WWW_DIR} 38 | 39 | echo "syncing new server binary..." 40 | 41 | #ssh root@$REMOTE_HOST "systemctl stop $GO_SYS_UNIT" 42 | #rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$CHECKBOX_BIN" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 43 | #ssh root@$REMOTE_HOST "systemctl start $GO_SYS_UNIT" 44 | 45 | ### Sync freeze bit script 46 | #echo "syncing freeze_bits_and_compute_stats.py" 47 | #rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "freeze_bits_and_compute_stats.py" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 48 | 49 | #### Sync compute-stats script 50 | #echo "syncing compute_stats.sh" 51 | #rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "compute-stats.sh" "root@$REMOTE_HOST:/root/" 52 | 53 | ##Sync server.py 54 | #echo "Syncing server.py..." 55 | #rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_SERVER" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 56 | 57 | # ##Sync server.py 58 | # echo "Syncing server.py..." 59 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_SERVER" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 60 | 61 | # ### Sync cleanup.sh 62 | # echo "Syncing cleanup.sh..." 63 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_CLEANUPSH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 64 | 65 | # #### Sync cleanup_old_logs.py 66 | # echo "Syncing cleanup_old_logs.py..." 67 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_CLEANUPPY" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 68 | 69 | # #### Sync start_gunicorn.sh 70 | # echo "Syncing start_gunicorn.sh..." 71 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_GUNICORN" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 72 | 73 | # #### Sync requirements.txt 74 | # echo "Syncing requirements.txt..." 75 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "$LOCAL_REQUIREMENTS" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 76 | 77 | # #### Sync gunicorn_restart 78 | # echo "Syncing gunicorn_restart..." 79 | # rsync $RSYNC_OPTS -e "ssh -i $SSH_KEY" "./gunicorn_restart.sh" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" 80 | 81 | # echo "Sync completed!" 82 | # echo "Deployment finished!" 83 | done 84 | -------------------------------------------------------------------------------- /freeze_bits_and_compute_stats.py: -------------------------------------------------------------------------------- 1 | import redis 2 | from redis import ConnectionPool 3 | import time 4 | import json 5 | from collections import defaultdict 6 | import os 7 | 8 | testing = not all(os.environ.get(var) for var in ['REDIS_HOST', 'REDIS_PORT', 'REDIS_USERNAME', 'REDIS_PASSWORD']) 9 | 10 | atomic_flip_script = """ 11 | local frozen_bitset = KEYS[1] 12 | local frozen_count_key = KEYS[2] 13 | local index = tonumber(ARGV[1]) 14 | 15 | local was_already_frozen = redis.call('GETBIT', frozen_bitset, index) 16 | if was_already_frozen == 0 then 17 | redis.call('SETBIT', frozen_bitset, index, 1) 18 | redis.call('INCR', frozen_count_key) 19 | return 1 20 | else 21 | return 0 22 | end 23 | """ 24 | 25 | def get_redis_client(): 26 | pool = None 27 | if testing: 28 | pool = ConnectionPool( 29 | host="127.0.0.1", 30 | port=58218, 31 | #username=os.environ.get('REDIS_USERNAME', 'default'), 32 | #password=os.environ.get('REDIS_PASSWORD', ''), 33 | db=0, 34 | #connection_class=redis.SSLConnection, 35 | #decode_responses=True, 36 | max_connections=1 37 | ) 38 | else: 39 | pool = ConnectionPool( 40 | host=os.environ.get('REDIS_HOST', 'localhost'), 41 | port=int(os.environ.get('REDIS_PORT', 6379)), 42 | username=os.environ.get('REDIS_USERNAME', 'default'), 43 | password=os.environ.get('REDIS_PASSWORD', ''), 44 | db=0, 45 | connection_class=redis.SSLConnection, 46 | max_connections=1 47 | ) 48 | 49 | return redis.Redis(connection_pool=pool) 50 | 51 | 52 | def get_time(r): 53 | seconds, microseconds = r.time() 54 | return seconds * 1000 + microseconds // 1000 55 | 56 | def get_freeze_time(r): 57 | t = r.get("freeze_time_ms") 58 | return int(t) 59 | 60 | def bytes_to_bits(byte_string): 61 | return ''.join(format(byte, '08b') for byte in byte_string) 62 | 63 | def find_dense_regions(bitstring, bit_kind="0", region_size=10000, top_n=3): 64 | regions = defaultdict(int) 65 | for i, bit in enumerate(bitstring): 66 | if bit == bit_kind: # Counting unfrozen/unchecked boxes 67 | regions[i // region_size] += 1 68 | return sorted(regions.items(), key=lambda x: x[1], reverse=True)[:top_n] 69 | 70 | def format_dense_regions(region): 71 | return ", ".join([":".join(map(str, x)) for x in region]) 72 | 73 | def find_longest_streaks(bitstring): 74 | longest_0 = 0 75 | longest_1 = 0 76 | longest_0_idx = 0 77 | longest_1_idx = 0 78 | current_0 = 0 79 | current_1 = 0 80 | current_0_idx = 0 81 | current_1_idx = 0 82 | 83 | on_a_zero = True 84 | 85 | for idx, bit in enumerate(bitstring): 86 | if bit == '0': 87 | if not on_a_zero: 88 | on_a_zero = True 89 | current_0_idx = idx 90 | 91 | current_0 += 1 92 | current_1 = 0 93 | if current_0 > longest_0: 94 | longest_0 = current_0 95 | longest_0_idx = current_0_idx 96 | else: 97 | if on_a_zero: 98 | on_a_zero = False 99 | current_1_idx = idx 100 | current_1 += 1 101 | current_0 = 0 102 | if current_1 > longest_1: 103 | longest_1 = current_1 104 | longest_1_idx = current_1_idx 105 | return [[longest_0, longest_0_idx], [longest_1, longest_1_idx]] 106 | 107 | def freeze_bits(r, atomic_flip_hash): 108 | current_time = get_time(r) 109 | freeze_time = get_freeze_time(r) 110 | safety_buffer = freeze_time * 0.1 111 | threshold = current_time - freeze_time - safety_buffer 112 | 113 | chunk_size = 5000 114 | cursor = -1 115 | stats = { 116 | "total_checked": 0, 117 | "eligible_for_freezing": 0, 118 | "newly_frozen": 0, 119 | "already_frozen": 0 120 | } 121 | 122 | while cursor != 0: 123 | if cursor == -1: cursor = 0 124 | cursor, chunk = r.hscan("last_checked", cursor, count=chunk_size) 125 | 126 | for index, last_checked in chunk.items(): 127 | index = int(index.decode("utf-8")) 128 | last_checked = int(last_checked.decode("utf-8")) 129 | stats["total_checked"] += 1 130 | 131 | if last_checked != 0 and last_checked < threshold: 132 | stats["eligible_for_freezing"] += 1 133 | 134 | did_freeze = r.evalsha(atomic_flip_hash, 2, "frozen_bitset", "frozen_count", index) 135 | 136 | if did_freeze == 1: 137 | stats["newly_frozen"] += 1 138 | r.publish("frozen_bit_channel", json.dumps([index])) 139 | else: 140 | stats["already_frozen"] += 1 141 | 142 | stats["frozen_count"] = stats["newly_frozen"] + stats["already_frozen"] 143 | stats["freeze_time_ms"] = freeze_time 144 | return stats 145 | 146 | if __name__ == "__main__": 147 | r = get_redis_client() 148 | atomic_flip_hash = r.script_load(atomic_flip_script) 149 | stats = freeze_bits(r, atomic_flip_hash) 150 | sunset_bitset = bytes_to_bits(r.get("sunset_bitset")) 151 | sunset_streaks = find_longest_streaks(sunset_bitset) 152 | 153 | frozen_bitset = bytes_to_bits(r.get("frozen_bitset")) 154 | frozen_streaks = find_longest_streaks(frozen_bitset) 155 | 156 | f = format_dense_regions 157 | dense_frozen_1s = f(find_dense_regions(frozen_bitset, bit_kind="1")) 158 | dense_frozen_0s = f(find_dense_regions(frozen_bitset, bit_kind="0")) 159 | dense_unchecked = f(find_dense_regions(sunset_bitset, bit_kind="0")) 160 | dense_checked = f(find_dense_regions(sunset_bitset, bit_kind="1")) 161 | print("0" in sunset_bitset) 162 | print(len(list(x for x in sunset_bitset if x == "0"))) 163 | print(r.get("sunset_count")) 164 | 165 | html_content = f""" 166 | 167 | 168 | 182 | 183 | 184 |
185 |

some numbers, updated sometimes

186 | 196 |
197 | 198 | 199 | """ 200 | 201 | output_path = "/tmp/some-numbers.html" if not testing else "some-numbers.html" 202 | 203 | with open(output_path, "w") as f: 204 | f.write(html_content) 205 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import eventlet 2 | eventlet.monkey_patch(thread=True, time=True) 3 | 4 | from flask import Flask, render_template, jsonify, request, send_from_directory, send_file 5 | from flask_socketio import SocketIO 6 | from flask_cors import CORS 7 | import os 8 | from apscheduler.schedulers.background import BackgroundScheduler 9 | from bitarray import bitarray 10 | import base64 11 | import json 12 | import time 13 | from datetime import datetime 14 | from contextlib import contextmanager 15 | 16 | 17 | MAX_LOGS_PER_DAY = 400_000_000 18 | TOTAL_CHECKBOXES = 1_000_000 19 | REACT_BUILD_DIRECTORY = os.path.abspath(os.path.join(os.path.dirname(__file__), 'dist')) 20 | # I found this by portscanning my own VPC because the DNS record wouldn't work lmfao 21 | REDIS_REPLICA_IP="10.108.0.13" 22 | 23 | app = Flask(__name__, static_folder=REACT_BUILD_DIRECTORY) 24 | CORS(app) 25 | socketio = SocketIO(app, cors_allowed_origins="*") 26 | scheduler = BackgroundScheduler() 27 | 28 | # Configuration 29 | USE_REDIS = os.environ.get('USE_REDIS', 'false').lower() == 'true' 30 | 31 | class RedisRateLimiter: 32 | def __init__(self, pool, limit, window): 33 | self.pool = pool 34 | self.limit = limit 35 | self.window = window 36 | 37 | def is_allowed(self, key: str) -> bool: 38 | with get_redis_connection(self.pool) as redis_client: 39 | pipe = redis_client.pipeline() 40 | now = int(time.time()) 41 | key = f'rate_limit:{key}:{self.window}' # Include window in the key 42 | 43 | pipe.zadd(key, {now: now}) 44 | pipe.zremrangebyscore(key, 0, now - self.window) 45 | pipe.zcard(key) 46 | pipe.expire(key, self.window) 47 | 48 | _, _, count, _ = pipe.execute() 49 | 50 | return count <= self.limit 51 | 52 | if USE_REDIS: 53 | import redis 54 | from redis import ConnectionPool 55 | # Create a connection pool 56 | pool = ConnectionPool( 57 | host=os.environ.get('REDIS_HOST', 'localhost'), 58 | port=int(os.environ.get('REDIS_PORT', 6379)), 59 | username=os.environ.get('REDIS_USERNAME', 'default'), 60 | password=os.environ.get('REDIS_PASSWORD', ''), 61 | db=0, 62 | connection_class=redis.SSLConnection, 63 | max_connections=425 # Adjust this number based on your needs 64 | ) 65 | 66 | replica_pool = ConnectionPool( 67 | host=REDIS_REPLICA_IP, 68 | port=int(os.environ.get('REDIS_PORT', 6379)), 69 | username=os.environ.get('REDIS_USERNAME', 'default'), 70 | password=os.environ.get('REDIS_PASSWORD', ''), 71 | db=0, 72 | connection_class=redis.SSLConnection, 73 | max_connections=425 # Adjust this number based on your needs 74 | ) 75 | 76 | @contextmanager 77 | def get_redis_connection(pool): 78 | connection = redis.Redis(connection_pool=pool) 79 | try: 80 | yield connection 81 | finally: 82 | connection.close() 83 | 84 | # redis_client = redis.Redis(connection_pool=pool) 85 | # print("connected to redis") 86 | # replica_client = redis.Redis(connection_pool=replica_pool) 87 | # print("connected to replica") 88 | 89 | 90 | def initialize_redis(): 91 | with get_redis_connection(pool) as redis_client: 92 | if not redis_client.exists('truncated_bitset'): 93 | redis_client.set('truncated_bitset', b'\x00' * (TOTAL_CHECKBOXES // 8)) 94 | if not redis_client.exists('count'): 95 | redis_client.set('count', '0') 96 | 97 | initialize_redis() 98 | 99 | # pubsub = replica_client.pubsub(ignore_subscribe_messages=True) 100 | pubsub = redis.Redis(connection_pool=replica_pool).pubsub(ignore_subscribe_messages=True) 101 | pubsub.subscribe('bit_toggle_channel') 102 | 103 | # Lua script for atomic bit setting and count update 104 | set_bit_script = """ 105 | local key = KEYS[1] 106 | local index = tonumber(ARGV[1]) 107 | local value = tonumber(ARGV[2]) 108 | local current = redis.call('getbit', key, index) 109 | local diff = value - current 110 | redis.call('setbit', key, index, value) 111 | redis.call('incrby', 'count', diff) 112 | return diff""" 113 | 114 | new_set_bit_script=""" 115 | local key = KEYS[1] 116 | local count_key = KEYS[2] 117 | local index = tonumber(ARGV[1]) 118 | local max_count = tonumber(ARGV[2]) 119 | 120 | local current_count = tonumber(redis.call('get', count_key) or "0") 121 | if current_count >= max_count then 122 | return {redis.call('getbit', key, index), 0} -- Return current count, current bit value, and 0 to indicate no change 123 | end 124 | 125 | local current_bit = redis.call('getbit', key, index) 126 | local new_bit = 1 - current_bit -- Toggle the bit 127 | local diff = new_bit - current_bit 128 | 129 | if diff > 0 and current_count + diff > max_count then 130 | return { current_bit, 0} -- Return current count, current bit value, and 0 to indicate no change 131 | end 132 | 133 | redis.call('setbit', key, index, new_bit) 134 | local new_count = current_count + diff 135 | redis.call('set', count_key, new_count) 136 | 137 | return {new_bit, diff} -- new bit value, and the change (1, 0, or -1)""" 138 | 139 | # set_bit_sha = redis_client.script_load(set_bit_script) 140 | # new_set_bit_sha = redis_client.script_load(new_set_bit_script) 141 | with get_redis_connection(pool) as redis_client: 142 | new_set_bit_sha = redis_client.script_load(new_set_bit_script) 143 | 144 | def get_bit(index): 145 | with get_redis_connection(pool) as redis_client: 146 | return bool(redis_client.getbit('truncated_bitset', index)) 147 | 148 | def set_bit(index, value): 149 | with get_redis_connection(pool) as redis_client: 150 | [_count, diff] = redis_client.evalsha(new_set_bit_sha, 1, 'truncated_bitset', index, int(value)) 151 | return diff != 0 152 | 153 | def _toggle_internal(index): 154 | with get_redis_connection(pool) as redis_client: 155 | result = redis_client.evalsha( 156 | new_set_bit_sha, 157 | 2, # number of keys 158 | 'truncated_bitset', # key for bitset 159 | 'count', # key for count 160 | index, # index to toggle 161 | TOTAL_CHECKBOXES # max count 162 | ) 163 | new_bit_value, diff = result 164 | if diff == 0: 165 | return [False, None] 166 | return [True, new_bit_value] 167 | 168 | def get_full_state(): 169 | with get_redis_connection(replica_pool) as replica_client: 170 | raw_data = replica_client.get("truncated_bitset") 171 | return base64.b64encode(raw_data).decode('utf-8') 172 | 173 | def get_count(): 174 | with get_redis_connection(replica_pool) as replica_client: 175 | return int(replica_client.get('count') or 0) 176 | 177 | def emit_toggle(index, new_value, timestamp): 178 | with get_redis_connection(pool) as redis_client: 179 | redis_client.publish('bit_toggle_channel', json.dumps([index, new_value, timestamp])) 180 | 181 | one_second_limiter = RedisRateLimiter(pool, limit=7, window=1) 182 | fifteen_second_limiter = RedisRateLimiter(pool, limit=80, window=15) 183 | sixty_second_limiter = RedisRateLimiter(pool, limit=240, window=60) 184 | 185 | connection_limiter = RedisRateLimiter(pool, limit=20, window=15) 186 | 187 | limiters = [one_second_limiter, fifteen_second_limiter, sixty_second_limiter] 188 | 189 | def allow_toggle(key): 190 | return all(limiter.is_allowed(key) for limiter in limiters) 191 | 192 | def allow_connection(key): 193 | return connection_limiter.is_allowed(key) 194 | 195 | def log_checkbox_toggle(remote_ip, checkbox_index, checked_state): 196 | timestamp = datetime.now().isoformat() 197 | log_entry = f"{timestamp}|{remote_ip}|{checkbox_index}|{checked_state}" 198 | 199 | # Use the current date as part of the key 200 | key = f"checkbox_logs:{datetime.now().strftime('%Y-%m-%d')}" 201 | with get_redis_connection(pool) as redis_client: 202 | pipeline = redis_client.pipeline() 203 | pipeline.rpush(key, log_entry) 204 | pipeline.ltrim(key, 0, MAX_LOGS_PER_DAY - 1) 205 | pipeline.execute() 206 | 207 | else: 208 | # In-memory storage 209 | in_memory_storage = {'bitset': bitarray('0' * TOTAL_CHECKBOXES), 'count': 0} 210 | 211 | def get_bit(index): 212 | return bool(in_memory_storage['bitset'][index]) 213 | 214 | def set_bit(index, value): 215 | current = in_memory_storage['bitset'][index] 216 | count = in_memory_storage['count'] 217 | if count >= TOTAL_CHECKBOXES: 218 | return False 219 | 220 | in_memory_storage['bitset'][index] = value 221 | in_memory_storage['count'] += value - current 222 | return True 223 | 224 | def _toggle_internal(index): 225 | print("here") 226 | current = in_memory_storage['bitset'][index] 227 | count = in_memory_storage['count'] 228 | if count >= TOTAL_CHECKBOXES: 229 | return [False, None] 230 | 231 | new_value = not current 232 | in_memory_storage['bitset'][index] = new_value 233 | in_memory_storage['count'] += 1 if new_value else -1 234 | return [True, new_value] 235 | 236 | def get_full_state(): 237 | return base64.b64encode(in_memory_storage['bitset'].tobytes()).decode('utf-8') 238 | 239 | def get_count(): 240 | return in_memory_storage['count'] 241 | 242 | def emit_toggle(index, new_value, timestamp): 243 | update = [[index], [], timestamp] if new_value else [[], [index], timestamp] 244 | socketio.emit('batched_bit_toggles', update) 245 | 246 | limiters = [] 247 | 248 | def allow_toggle(key): 249 | return True 250 | 251 | def allow_connection(key): 252 | return True 253 | 254 | def log_checkbox_toggle(remote_ip, checkbox_index, checked_state): 255 | pass 256 | 257 | def state_snapshot(): 258 | full_state = get_full_state() 259 | count = get_count() 260 | timestamp = int(time.time() * 1000) # Current time in milliseconds 261 | return {'full_state': full_state, 'count': count, "timestamp": timestamp} 262 | 263 | @app.route('/api/initial-state') 264 | def get_initial_state(): 265 | return jsonify(state_snapshot()) 266 | 267 | def emit_full_state(): 268 | print("Emitting full state") 269 | socketio.emit('full_state', state_snapshot()) 270 | 271 | 272 | @socketio.on('toggle_bit') 273 | def handle_toggle(data): 274 | if not allow_toggle(request.sid): 275 | print(f"Rate limiting toggle request for {request.sid}") 276 | return False 277 | 278 | try: 279 | index = int(data['index']) 280 | except: 281 | return False 282 | 283 | if index >= TOTAL_CHECKBOXES: 284 | return False 285 | 286 | did_toggle, new_value = _toggle_internal(index) 287 | timestamp = int(time.time() * 1000) # Current time in milliseconds 288 | 289 | if did_toggle != 0: 290 | forwarded_for = request.headers.get('X-Forwarded-For') or "UNKNOWN_IP" 291 | log_checkbox_toggle(forwarded_for, index, new_value) 292 | emit_toggle(index, new_value, timestamp) 293 | 294 | @app.route('/', defaults={'path': ''}) 295 | @app.route('/') 296 | def serve(path): 297 | if path != "" and os.path.exists(os.path.join(app.static_folder, path)): 298 | return send_from_directory(app.static_folder, path) 299 | else: 300 | return send_file(os.path.join(app.static_folder, 'index.html')) 301 | 302 | 303 | #@socketio.on('connect') 304 | #def handle_connect(): 305 | #forwarded_for = request.headers.get('X-Forwarded-For') or request.remote_addr 306 | #if forwarded_for and allow_connection(forwarded_for): 307 | #return True 308 | #else: 309 | #print(f"Rate limiting connection for {forwarded_for}") 310 | #return False 311 | 312 | def emit_state_updates(): 313 | scheduler.add_job(emit_full_state, 'interval', seconds=45) 314 | scheduler.start() 315 | 316 | emit_state_updates() 317 | 318 | def handle_redis_messages(): 319 | if USE_REDIS: 320 | message_count = 0 321 | updates = [] 322 | while True: 323 | message = pubsub.get_message(timeout=0.01) 324 | if message is None: 325 | # No more messages available 326 | break 327 | 328 | if message['type'] == 'message': 329 | try: 330 | data = json.loads(message['data']) 331 | updates.append(data) 332 | message_count += 1 333 | except json.JSONDecodeError: 334 | print(f"Failed to decode message: {message['data']}") 335 | 336 | if message_count >= 600: 337 | break 338 | 339 | if message_count > 0: 340 | true_updates = [] 341 | false_updates = [] 342 | max_timestamp = 0 343 | for update in updates: 344 | if len(update) != 3: # backwards compatibility 345 | continue 346 | else: 347 | index, value, timestamp = update 348 | max_timestamp = max(max_timestamp, timestamp) 349 | if value: 350 | true_updates.append(index) 351 | else: 352 | false_updates.append(index) 353 | to_broadcast = [true_updates, false_updates, max_timestamp] 354 | 355 | socketio.emit('batched_bit_toggles', to_broadcast) 356 | # print(f"Processed {message_count} messages") 357 | 358 | def setup_redis_listener(): 359 | if USE_REDIS: 360 | print("Redis listener job added to scheduler") 361 | scheduler.add_job(handle_redis_messages, 'interval', seconds=0.2) 362 | 363 | setup_redis_listener() 364 | 365 | if __name__ == '__main__': 366 | set_bit(0, True) 367 | set_bit(1, True) 368 | set_bit(100, True) 369 | set_bit(101, True) 370 | 371 | socketio.run(app, host="0.0.0.0", port=5001) 372 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= 2 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= 3 | github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= 4 | github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= 5 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 6 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 7 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 8 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 9 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 10 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 11 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 12 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 13 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 14 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 15 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 16 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 17 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 18 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 19 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 20 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 25 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 26 | github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= 27 | github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 28 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 29 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 30 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 31 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 32 | github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4= 33 | github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw= 34 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 35 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 36 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 37 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 38 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 39 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 40 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 41 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 42 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 43 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 44 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 45 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 46 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 47 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 48 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 49 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 50 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 51 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 52 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 53 | github.com/gomodule/redigo v1.8.4/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= 54 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 55 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 56 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 57 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f h1:pDhu5sgp8yJlEF/g6osliIIpF9K4F5jvkULXa4daRDQ= 58 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= 59 | github.com/googollee/go-socket.io v1.7.0 h1:ODcQSAvVIPvKozXtUGuJDV3pLwdpBLDs1Uoq/QHIlY8= 60 | github.com/googollee/go-socket.io v1.7.0/go.mod h1:0vGP8/dXR9SZUMMD4+xxaGo/lohOw3YWMh2WRiWeKxg= 61 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 62 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 63 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 64 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 65 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 66 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 67 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 68 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 69 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 70 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 71 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 72 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 73 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 74 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 75 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 76 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 77 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 78 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 79 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 80 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 81 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 82 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 83 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 84 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 86 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 87 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 88 | github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI= 89 | github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ= 90 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 91 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 92 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 93 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 94 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/puzpuzpuz/xsync v1.5.2 h1:yRAP4wqSOZG+/4pxJ08fPTwrfL0IzE/LKQ/cw509qGY= 97 | github.com/puzpuzpuz/xsync v1.5.2/go.mod h1:K98BYhX3k1dQ2M63t1YNVDanbwUPmBCAhNmVrrxfiGg= 98 | github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= 99 | github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= 100 | github.com/quic-go/quic-go v0.44.0 h1:So5wOr7jyO4vzL2sd8/pD9Kesciv91zSk8BoFngItQ0= 101 | github.com/quic-go/quic-go v0.44.0/go.mod h1:z4cx/9Ny9UtGITIPzmPTXh1ULfOyWh4qGQlpnPcWmek= 102 | github.com/quic-go/webtransport-go v0.8.0 h1:HxSrwun11U+LlmwpgM1kEqIqH90IT4N8auv/cD7QFJg= 103 | github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM= 104 | github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= 105 | github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 106 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 107 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 108 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 109 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 110 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 111 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 112 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 113 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 114 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 115 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 116 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 117 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 118 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 119 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 120 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 121 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 122 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 123 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 124 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 125 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 126 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 127 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 128 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 129 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 130 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= 131 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= 132 | github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= 133 | github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= 134 | github.com/zishang520/engine.io-go-parser v1.2.5 h1:Disf4rvNQzDsgoC+3yuwuFx5A7JNWlPp+QLUW32WDtc= 135 | github.com/zishang520/engine.io-go-parser v1.2.5/go.mod h1:G1DciRIGH4/S7x01DIdZQaXrk09ZeRgEw5e/Z9ms4Is= 136 | github.com/zishang520/engine.io/v2 v2.1.1 h1:PZYi3/XW6jJh6yzVoF19JifgmMVggbLskSDNpXtaOFk= 137 | github.com/zishang520/engine.io/v2 v2.1.1/go.mod h1:FnXtT+k/6g2uOb9MpqY71DhV7COwlCH5DCbczn6Q3K8= 138 | github.com/zishang520/socket.io-go-parser/v2 v2.1.0 h1:YaTul861UxdTtq/v7XKmF52gWmDOqwugKBlFyiifKCE= 139 | github.com/zishang520/socket.io-go-parser/v2 v2.1.0/go.mod h1:zmToGML+lCjSjyGZMuVtnvgnFOnDuAxJZKwfDDDHiqI= 140 | github.com/zishang520/socket.io/v2 v2.2.0 h1:zm+6JFkI+ZmBCNjHXHHPRescqB+qaiFEYY4WvEgH60Q= 141 | github.com/zishang520/socket.io/v2 v2.2.0/go.mod h1:sYHxrHgsK83ylNDEoezfbq1lbpM2mnajc4e2IKAqyvY= 142 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 143 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 144 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 145 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 146 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 147 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 148 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 149 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 150 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 151 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 152 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 153 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 154 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 155 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 156 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 157 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 160 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 161 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 162 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 163 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 164 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 165 | golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= 166 | golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 167 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 168 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 169 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 170 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 171 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 172 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 173 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 174 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 175 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 176 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 177 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 178 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 179 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useRef, useEffect } from "react"; 2 | import { FixedSizeGrid as Grid } from "react-window"; 3 | import { useWindowSize } from "react-use"; 4 | import styled, { keyframes } from "styled-components"; 5 | import BitSet from "../../bitset"; 6 | import INDICES from "../../randomizedColors"; 7 | import { abbrNum } from "../../utils"; 8 | import useClickTracker from "../../hooks/use-click-tracker"; 9 | 10 | const TOTAL_CHECKBOXES = 1000000; 11 | const CHECKBOX_SIZE = 35; 12 | const OVERSCAN_COUNT = 5; 13 | const ONE_SECOND_THRESHOLD = 7; 14 | const FIFTEEN_SECOND_THRESHOLD = 80; 15 | const SIXTY_SECOND_THRESHOLD = 240; 16 | 17 | const useForceUpdate = ({ bitSetRef, setCheckCount }) => { 18 | const [, setTick] = useState(0); 19 | return useCallback(() => { 20 | setTick((tick) => tick + 1); 21 | setCheckCount(bitSetRef?.current?.count() || 0); 22 | }, [bitSetRef, setCheckCount]); 23 | }; 24 | 25 | const Checkbox = React.memo( 26 | ({ index, style, isChecked, handleChange, disabled }) => { 27 | let backgroundColor = null; 28 | if (INDICES[index]) { 29 | backgroundColor = `var(--${INDICES[index]}`; 30 | } 31 | 32 | return ( 33 | 34 | 41 | 44 | 45 | ); 46 | } 47 | ); 48 | 49 | const isDesktopSafari = () => { 50 | const ua = navigator.userAgent; 51 | return /^((?!chrome|android).)*safari/i.test(ua) && !/mobile/i.test(ua); 52 | }; 53 | 54 | const StyledCheckbox = styled.input` 55 | margin: 0; 56 | padding: 0; 57 | width: 25px; 58 | height: 25px; 59 | box-shadow: none; 60 | /* transform: translate(10px, 10px); */ 61 | 62 | transform: ${isDesktopSafari() ? "translate(3px, 0px)" : "none"}; 63 | `; 64 | 65 | const MaybeColoredDiv = styled.div` 66 | position: absolute; 67 | pointer-events: none; 68 | border: 5px solid var(--background-color); 69 | height: 29px; 70 | width: 29px; 71 | border-radius: 2px; 72 | `; 73 | 74 | const fadeIn = keyframes` 75 | from { 76 | opacity: 0; 77 | } 78 | 79 | to { 80 | opacity: 1; 81 | } 82 | `; 83 | 84 | const CheckboxWrapper = styled.div` 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | width: ${CHECKBOX_SIZE}px; 89 | height: ${CHECKBOX_SIZE}px; 90 | opacity: var(--opacity); 91 | transition: opacity 0.5s; 92 | animation: ${fadeIn} 0.4s; 93 | `; 94 | 95 | const initialSelfCheckboxState = () => ({ 96 | total: 0, 97 | totalGold: 0, 98 | totalRed: 0, 99 | totalGreen: 0, 100 | totalPurple: 0, 101 | totalOrange: 0, 102 | recentlyChecked: false, 103 | }); 104 | 105 | const scoreString = ({ selfCheckboxState, allChecked }) => { 106 | const colors = ["gold", "red", "green", "purple"]; 107 | const colorsToInclude = colors 108 | .map((color) => { 109 | const count = 110 | selfCheckboxState[ 111 | `total${color.charAt(0).toUpperCase()}${color.slice(1)}` 112 | ]; 113 | if (count !== 0) { 114 | return [color, count]; 115 | } 116 | return null; 117 | }) 118 | .filter((el) => el !== null); 119 | 120 | return ( 121 |

122 | You {allChecked ? "" : "have "}checked {selfCheckboxState.total}{" "} 123 | {colorsToInclude.length > 0 ? "(" : ""} 124 | {colorsToInclude.map(([color, count]) => { 125 | return ( 126 | 127 | {abbrNum(count, 2)} 128 | 129 | ); 130 | })} 131 | {colorsToInclude.length > 0 ? ") " : " "} 132 | boxes 133 |

134 | ); 135 | }; 136 | 137 | const MailIcon = () => ( 138 | 149 | 150 | 151 | 152 | ); 153 | 154 | const DollarIcon = () => ( 155 | 166 | 167 | 168 | 169 | ); 170 | 171 | const App = () => { 172 | const { width, height } = useWindowSize(); 173 | const gridRef = useRef(); 174 | const [jumpToIndex, setJumpToIndex] = useState(""); 175 | 176 | const gridWidth = Math.floor(width * 0.95); 177 | const columnCount = Math.floor(gridWidth / CHECKBOX_SIZE); 178 | const rowCount = Math.ceil(TOTAL_CHECKBOXES / columnCount); 179 | const bitSetRef = useRef(null); 180 | const frozenBitsetRef = useRef(null); 181 | const [checkCount, setCheckCount] = React.useState(0); 182 | const forceUpdate = useForceUpdate({ bitSetRef, setCheckCount }); 183 | const [isLoading, setIsLoading] = useState(true); 184 | const recentlyCheckedClientSide = useRef({}); 185 | const [clickCounts, trackClick] = useClickTracker(); 186 | const [disabled, setDisabled] = useState(false); 187 | const [allChecked, setAllChecked] = useState(false); 188 | const clickTimeout = React.useRef(); 189 | const [singlePlayerMode, setSinglePlayerMode] = useState(false); 190 | const doAlert = React.useCallback((s) => { 191 | window.alert(s); 192 | }, []); 193 | 194 | const [selfCheckboxState, setSelfCheckboxState] = useState(() => { 195 | const fromLocal = localStorage.getItem("selfCheckboxState"); 196 | try { 197 | return fromLocal ? JSON.parse(fromLocal) : initialSelfCheckboxState(); 198 | } catch (error) { 199 | console.error( 200 | "Failed to parse selfCheckboxState from localStorage:", 201 | error 202 | ); 203 | const initial = initialSelfCheckboxState(); 204 | localStorage.setItem("selfCheckboxState", JSON.stringify(initial)); 205 | return initial; 206 | } 207 | }); 208 | 209 | React.useEffect(() => { 210 | console.log(JSON.stringify(selfCheckboxState)); 211 | localStorage.setItem( 212 | "selfCheckboxState", 213 | JSON.stringify(selfCheckboxState) 214 | ); 215 | }, [selfCheckboxState]); 216 | 217 | useEffect(() => { 218 | const fetchInitialState = async () => { 219 | try { 220 | const bitset = BitSet.makeFull(); 221 | const frozenBitset = BitSet.makeFull(); 222 | setDisabled(true); 223 | setAllChecked(true); 224 | bitSetRef.current = bitset; 225 | frozenBitsetRef.current = frozenBitset; 226 | forceUpdate(); 227 | setIsLoading(false); 228 | } catch (error) { 229 | console.error("Failed to create initial state:", error); 230 | setIsLoading(false); 231 | } 232 | }; 233 | 234 | fetchInitialState(); 235 | }, [forceUpdate]); 236 | 237 | const toggleBit = useCallback( 238 | async (index) => { 239 | trackClick(); 240 | if ( 241 | clickCounts.current.oneSecond > ONE_SECOND_THRESHOLD || 242 | clickCounts.current.fifteenSeconds > FIFTEEN_SECOND_THRESHOLD || 243 | clickCounts.current.sixtySeconds > SIXTY_SECOND_THRESHOLD 244 | ) { 245 | doAlert("CHILL LOL"); 246 | setDisabled(true); 247 | clickTimeout.current && clearTimeout(clickTimeout.current); 248 | clickTimeout.current = setTimeout(() => { 249 | setDisabled(false); 250 | }, 2500); 251 | } else { 252 | try { 253 | bitSetRef.current?.toggle(index); 254 | const count = bitSetRef.current.count(); 255 | setCheckCount(count); 256 | if (count >= 1000000) { 257 | setDisabled(true); 258 | setAllChecked(true); 259 | } 260 | forceUpdate(); 261 | const isChecked = bitSetRef.current?.get(index); 262 | setSelfCheckboxState((prev) => { 263 | const newState = { ...prev }; 264 | newState.total += isChecked ? 1 : -1; 265 | if (INDICES[index]) { 266 | const color = INDICES[index]; 267 | newState[ 268 | `total${color.charAt(0).toUpperCase()}${color.slice(1)}` 269 | ] += isChecked ? 1 : -1; 270 | } 271 | return newState; 272 | }); 273 | if (singlePlayerMode) { 274 | localStorage.setItem( 275 | "localBitset", 276 | JSON.stringify(bitSetRef.current) 277 | ); 278 | } else { 279 | // socketRef.current?.emit("toggle_bit", { index }); 280 | } 281 | } catch (error) { 282 | console.error("Failed to toggle bit:", error); 283 | } finally { 284 | } 285 | } 286 | }, 287 | [trackClick, clickCounts, doAlert, forceUpdate, singlePlayerMode] 288 | ); 289 | 290 | const Cell = React.useCallback( 291 | ({ columnIndex, rowIndex, style, isLoading }) => { 292 | const index = rowIndex * columnCount + columnIndex; 293 | if (index >= TOTAL_CHECKBOXES) return null; 294 | 295 | let isFrozen = false; 296 | let isChecked = false; 297 | if (singlePlayerMode) { 298 | isChecked = bitSetRef.current?.get(index); 299 | } else { 300 | isFrozen = frozenBitsetRef.current.get(index); 301 | isChecked = isFrozen || bitSetRef.current?.get(index); 302 | } 303 | 304 | const handleChange = () => { 305 | toggleBit(index); 306 | 307 | const timeout = setTimeout(() => { 308 | delete recentlyCheckedClientSide.current[index]; 309 | }, 1000); 310 | 311 | if (recentlyCheckedClientSide.current[index]) { 312 | clearTimeout(recentlyCheckedClientSide.current[index].timeout); 313 | } 314 | 315 | recentlyCheckedClientSide.current[index] = { 316 | value: !isChecked, 317 | timeout, 318 | }; 319 | }; 320 | 321 | return ( 322 | 330 | ); 331 | }, 332 | [columnCount, disabled, toggleBit, singlePlayerMode] 333 | ); 334 | 335 | const handleJumpToCheckbox = (e) => { 336 | e.preventDefault(); 337 | const index = parseInt(jumpToIndex, 10) - 1; // Subtract 1 because array is 0-indexed 338 | if (index >= 0 && index < TOTAL_CHECKBOXES) { 339 | const rowIndex = Math.floor(index / columnCount); 340 | const columnIndex = index % columnCount; 341 | gridRef.current.scrollTo({ 342 | scrollTop: rowIndex * CHECKBOX_SIZE, 343 | scrollLeft: columnIndex * CHECKBOX_SIZE, 344 | }); 345 | } 346 | setJumpToIndex(""); 347 | }; 348 | 349 | const youHaveChecked = scoreString({ selfCheckboxState, allChecked }); 350 | const cappedCheckCount = Math.min(1000000, checkCount).toLocaleString(); 351 | 352 | const enableSinglePlayer = React.useCallback( 353 | (e) => { 354 | console.log("enabling single player"); 355 | e.preventDefault(); 356 | setSinglePlayerMode(true); 357 | const localJson = localStorage.getItem("localBitset"); 358 | setDisabled(false); 359 | 360 | if (localJson) { 361 | try { 362 | const parsed = JSON.parse(localJson); 363 | bitSetRef.current = new BitSet(parsed); 364 | setCheckCount(bitSetRef.current.count()); 365 | console.log( 366 | `Loaded single player state, count: ${bitSetRef.current.count()}` 367 | ); 368 | } catch (error) { 369 | console.error("Failed to load local bitset:", error); 370 | const empty = BitSet.makeEmpty(); 371 | bitSetRef.current = empty; 372 | setCheckCount(0); 373 | localStorage.setItem("localBitset", JSON.stringify(empty)); 374 | forceUpdate(); 375 | } 376 | forceUpdate(); 377 | } else { 378 | const empty = BitSet.makeEmpty(); 379 | bitSetRef.current = empty; 380 | setCheckCount(0); 381 | localStorage.setItem("localBitset", JSON.stringify(empty)); 382 | forceUpdate(); 383 | } 384 | }, 385 | [forceUpdate] 386 | ); 387 | 388 | return ( 389 | 390 | 391 | 394 | 395 | a website by eieio{" "} 396 | 397 | 398 | 399 | {MailIcon()} 400 | 401 | 402 | {DollarIcon()} 403 | 404 | 405 | 406 | One Million Checkboxes 407 | 414 | {cappedCheckCount} boxes are ✅ 415 | 416 | 417 | 420 | 421 | a website by eieio{" "} 422 | 423 | 424 | 425 | {MailIcon()} 426 | 427 | 428 | {DollarIcon()} 429 | 430 | 431 | 432 | 437 | {cappedCheckCount} boxes are ✅ 438 | 439 | 440 | 441 | {allChecked ? ( 442 | 443 |

🎉 we checked every box! 🎉

444 | {singlePlayerMode ? ( 445 |

you're playing alone now

446 | ) : ( 447 |

448 | but you can still{" "} 449 | play alone{" "} 450 | if you'd like 451 |

452 | )} 453 |
454 | ) : ( 455 | 456 |

checking a box checks it for everyone!

457 |

boxes freeze if they've been checked for a while

458 |
459 | )} 460 | {youHaveChecked} 461 |
462 |
472 | setJumpToIndex(e.target.value)} 476 | placeholder="checkbox number" 477 | min="1" 478 | max={TOTAL_CHECKBOXES} 479 | /> 480 | Jump! 481 | 482 | {isLoading ? ( 483 |

Loading...

484 | ) : ( 485 | 503 | {Cell} 504 | 505 | )} 506 |
507 | ); 508 | }; 509 | 510 | const Heading = styled.div` 511 | display: grid; 512 | justify-content: space-between; 513 | 514 | align-items: baseline; 515 | width: var(--width); 516 | margin: -4px auto 0; 517 | grid-template-columns: 1fr auto 1fr; 518 | grid-template-rows: auto auto auto; 519 | grid-template-areas: 520 | "site title count" 521 | ". sub ." 522 | "you you you"; 523 | 524 | padding-bottom: 10px; 525 | border-bottom: 2px solid var(--dark); 526 | 527 | @media (max-width: 850px) { 528 | grid-template-columns: 1fr auto auto 1fr; 529 | grid-template-rows: auto auto auto auto; 530 | grid-template-areas: 531 | "title title title title" 532 | ". sub sub ." 533 | "sitecount sitecount sitecount sitecount" 534 | "you you you you"; 535 | } 536 | `; 537 | 538 | const DonoLinks = styled.span` 539 | @media (max-width: 550px) { 540 | margin-top: -6px; 541 | } 542 | `; 543 | 544 | const MailIconLink = styled.a` 545 | display: inline-flex; 546 | vertical-align: middle; 547 | color: var(--blue); 548 | text-decoration: none; 549 | border-radius: 5px; 550 | transition: background-color 0.3s ease; 551 | 552 | &:hover { 553 | color: var(--dark); 554 | } 555 | `; 556 | 557 | const DollarIconLink = styled.a` 558 | display: inline-flex; 559 | vertical-align: middle; 560 | color: var(--green) !important; 561 | text-decoration: none; 562 | border-radius: 5px; 563 | transition: background-color 0.3s ease; 564 | margin-left: 2px; 565 | 566 | &:hover { 567 | color: var(--dark) !important; 568 | } 569 | `; 570 | 571 | const JumpInput = styled.input` 572 | margin: 0; 573 | padding: 8px; 574 | height: 40px; 575 | font-size: 1rem; 576 | width: 160px; 577 | border: 2px solid var(--blue); 578 | border-radius: 0; 579 | `; 580 | 581 | const JumpButton = styled.button` 582 | margin: 0; 583 | padding: 8px; 584 | height: 40px; 585 | font-size: 1rem; 586 | background-color: var(--blue); 587 | border: none; 588 | color: white; 589 | cursor: pointer; 590 | transition: background-color 0.3s; 591 | `; 592 | 593 | const Title = styled.h1` 594 | margin: 0; 595 | padding: 8px 0 0 0; 596 | font-size: clamp(1.75rem, 2vw + 1rem, 3.5rem); 597 | font-family: "Sunset Demi", serif; 598 | text-align: center; 599 | grid-area: title; 600 | `; 601 | 602 | const SubHead = styled.h2` 603 | margin: 0; 604 | padding: 4px 0 0 0; 605 | flex: 1; 606 | font-size: clamp(1rem, 0.15vw + 1rem, 2.5rem); 607 | font-family: "Apercu Regular Pro", sans-serif; 608 | 609 | & a { 610 | color: var(--blue); 611 | text-decoration: underline; 612 | // dotted underline 613 | text-decoration-style: dashed; 614 | // move underline a little further down 615 | text-underline-offset: 0.12em; 616 | transition: color 0.3s; 617 | } 618 | 619 | & a:hover { 620 | color: var(--dark); 621 | } 622 | `; 623 | 624 | const Explanation = styled.p` 625 | font-size: 1rem; 626 | text-align: center; 627 | grid-area: sub; 628 | font-family: "Apercu Italic Pro", sans-serif; 629 | // italicize 630 | font-style: italic; 631 | margin-top: -10px; 632 | `; 633 | 634 | const PlayAlone = styled.button` 635 | margin: 0; 636 | padding: 0; 637 | border: none; 638 | outline: none; 639 | display: inline; 640 | color: var(--blue); 641 | text-decoration: underline; 642 | text-decoration-style: dashed; 643 | // move underline a little further down 644 | text-underline-offset: 0.12em; 645 | transition: color 0.3s; 646 | cursor: pointer; 647 | 648 | &:hover { 649 | color: var(--dark); 650 | } 651 | `; 652 | 653 | const YouHaveChecked = styled.div` 654 | font-size: 1rem; 655 | font-family: "Apercu Bold Pro", sans-serif; 656 | text-align: right; 657 | grid-area: you; 658 | margin-top: 10px; 659 | display: flex; 660 | flex-direction: column; 661 | `; 662 | 663 | const SiteHead = styled(SubHead)` 664 | text-align: left; 665 | grid-area: site; 666 | /* display: flex; */ 667 | gap: 6px; 668 | align-items: baseline; 669 | 670 | display: var(--desktop-display); 671 | @media (max-width: 850px) { 672 | display: var(--mobile-display); 673 | } 674 | @media (max-width: 550px) { 675 | flex-direction: column; 676 | gap: 0px; 677 | width: fit-content; 678 | flex-grow: 0; 679 | flex-basis: fit-content; 680 | 681 | /* flex: 0; */ 682 | } 683 | `; 684 | 685 | const CountHead = styled(SubHead)` 686 | text-align: right; 687 | grid-area: count; 688 | opacity: var(--opacity); 689 | transition: opacity 0.5s; 690 | display: var(--desktop-display); 691 | @media (max-width: 850px) { 692 | display: var(--mobile-display); 693 | flex-grow: 1; 694 | } 695 | `; 696 | 697 | const SiteCountMobile = styled.div` 698 | grid-area: sitecount; 699 | display: flex; 700 | justify-content: space-between; 701 | display: none; 702 | @media (max-width: 850px) { 703 | display: flex; 704 | } 705 | `; 706 | 707 | const Wrapper = styled.div` 708 | display: flex; 709 | align-items: center; 710 | flex-direction: column; 711 | height: 100dvh; 712 | `; 713 | 714 | const ColorSpan = styled.span` 715 | color: var(--color); 716 | 717 | &:not(:last-of-type):after { 718 | content: " "; 719 | } 720 | `; 721 | 722 | export default App; 723 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "embed" 7 | "encoding/base64" 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "log/slog" 13 | "math" 14 | "math/rand" 15 | "net" 16 | "net/http" 17 | "os" 18 | "runtime/debug" 19 | "strconv" 20 | "sync/atomic" 21 | "time" 22 | 23 | "github.com/gin-contrib/static" 24 | "github.com/redis/go-redis/v9" 25 | "github.com/zishang520/socket.io/v2/socket" 26 | 27 | "github.com/alicebob/miniredis/v2" 28 | "github.com/gin-gonic/gin" 29 | "github.com/puzpuzpuz/xsync" 30 | ) 31 | 32 | var ( 33 | background = context.Background() 34 | mini *miniredis.Miniredis 35 | primaryRedisClient *redis.Client 36 | secondaryRedisClient *redis.Client 37 | port = flag.Int("port", 5001, "http port to listen to") 38 | logChannel = make(chan *toggleLogEntry, 20) 39 | activeConns atomic.Int64 40 | REDIS_SECONDARY_IP = flag.String( 41 | "redis-secondary", 42 | "10.108.0.15", 43 | "", 44 | ) 45 | forceStateSnapshot = flag.Duration( 46 | "force-snapshot-interval", 47 | time.Second*57, 48 | "", 49 | ) 50 | maxLogInterval = flag.Duration("max-log-interval", time.Second*5, "") 51 | maxLogBatchSize = flag.Int("max-log-batch", 200, "") 52 | mercyRatio = flag.Int64( 53 | "mercy-ratio", 54 | 2, 55 | "how quickly we should forgive the bad guys", 56 | ) 57 | ) 58 | 59 | //go:embed dist 60 | var distFolder embed.FS 61 | 62 | const ( 63 | MAX_LOGS_PER_DAY = 400_000_000 64 | TOTAL_CHECKBOXES = 1_000_000 65 | ) 66 | 67 | func initializeCustomBitset() []byte { 68 | // Calculate the sizes 69 | emptyFrontBytes := 2 70 | fullBytes := 125000 - 3 71 | emptyEndBytes := 1 72 | 73 | // Create the byte slice 74 | bitset := make([]byte, emptyFrontBytes+fullBytes+emptyEndBytes) 75 | 76 | // Fill the middle part with 1s (0xFF is a byte with all bits set to 1) 77 | for i := emptyFrontBytes; i < emptyFrontBytes+fullBytes; i++ { 78 | bitset[i] = 0xFF 79 | } 80 | 81 | return bitset 82 | } 83 | 84 | func initRedis() { 85 | log := slog.With("scope", "redis") 86 | if os.Getenv("REDIS_HOST") == "" { 87 | primaryRedisClient = miniClient() 88 | secondaryRedisClient = primaryRedisClient 89 | } else { 90 | p, err := primaryRedis() 91 | if err != nil { 92 | log.Error("Unable to talk to primary redis", "err", err) 93 | } 94 | s, err := replicaRedis() 95 | if err != nil { 96 | log.Error("Unable to talk to secondary redis", "err", err) 97 | } 98 | primaryRedisClient = p 99 | secondaryRedisClient = s 100 | } 101 | err := newSetBitScript.Load(background, primaryRedisClient).Err() 102 | if err != nil { 103 | log.Error("Unable to load scripts into primary redis, %s", err) 104 | } 105 | err = newSetBitScript.Load(background, secondaryRedisClient).Err() 106 | if err != nil { 107 | log.Error("Unable to load scripts into secondary redis, %s", err) 108 | } 109 | // if err := primaryRedisClient.SetNX( 110 | // background, 111 | // "truncated_bitset", 112 | // string(make([]byte, TOTAL_CHECKBOXES)), 113 | // 0, 114 | // ).Err(); err != nil { 115 | // log.Error("Unable to initialize bitset %s", err) 116 | // } 117 | if err := primaryRedisClient.SetNX( 118 | background, 119 | "sunset_bitset", 120 | // string(initializeCustomBitset()), 121 | string(make([]byte, TOTAL_CHECKBOXES / 8)), 122 | 0, 123 | ).Err(); err != nil { 124 | log.Error("Unable to initialize sunset bitset %s", err) 125 | } 126 | if err := primaryRedisClient.SetNX( 127 | background, 128 | "sunset_count", 129 | // "999976", 130 | "0", 131 | 0, 132 | ).Err(); err != nil { 133 | log.Error("Unable to initialize sunset count %s", err) 134 | } 135 | if err := primaryRedisClient.SetNX( 136 | background, 137 | "frozen_bitset", 138 | string(make([]byte, TOTAL_CHECKBOXES / 8)), 139 | 0, 140 | ).Err(); err != nil { 141 | log.Error("Unable to initialize frozen bitset %s", err) 142 | } 143 | if err := primaryRedisClient.SetNX( 144 | background, 145 | "freeze_time_ms", 146 | "22015", 147 | // "1000", 148 | 0, 149 | ).Err(); err != nil { 150 | log.Error("Unable to initialize freeze time %s", err) 151 | } 152 | if err := primaryRedisClient.SetNX( 153 | background, 154 | "frozen_count", 155 | "0", 156 | 0, 157 | ).Err(); err != nil { 158 | log.Error("Unable to initialize frozen count %s", err) 159 | } 160 | } 161 | 162 | type stateSnapshot struct { 163 | FullState string `json:"full_state"` 164 | FrozenState string `json:"frozen_state"` 165 | Count int `json:"count"` 166 | FrozenCount int `json:"frozen_count"` 167 | Timestamp int `json:"timestamp"` 168 | } 169 | 170 | func JSON(v any) string { 171 | buff, err := json.Marshal(v) 172 | if err != nil { 173 | slog.Error("Bad json ", "err", err) 174 | return "{}" 175 | } 176 | return string(buff) 177 | } 178 | 179 | func getStateSnapshot() *stateSnapshot { 180 | count, _ := secondaryRedisClient.Get(background, "sunset_count").Int() 181 | frozenCount, _ := secondaryRedisClient.Get(background, "frozen_count").Int() 182 | return &stateSnapshot{ 183 | FullState: getFullState(), 184 | FrozenState: getFrozenState(), 185 | Count: count, 186 | FrozenCount: frozenCount, 187 | Timestamp: int(time.Now().UnixMilli()), 188 | } 189 | } 190 | 191 | type toggleLogEntry struct { 192 | ip string 193 | index int 194 | state bool 195 | } 196 | 197 | func logToggles(logs []*toggleLogEntry) { 198 | now := time.Now() 199 | key := fmt.Sprintf( 200 | "checkbox_logs:%s", 201 | now.Format(time.DateOnly), 202 | ) 203 | pipeline := primaryRedisClient.Pipeline() 204 | t := "True" 205 | f := "False" 206 | for _, l := range logs { 207 | state := t 208 | if !l.state { 209 | state = f 210 | } 211 | 212 | entry := fmt.Sprintf( 213 | "%s|%s|%d|%s|s", 214 | now.Format(time.DateTime), 215 | l.ip, 216 | l.index, 217 | state, 218 | ) 219 | pipeline.RPush(background, key, entry) 220 | } 221 | 222 | pipeline.LTrim(background, key, 0, int64(MAX_LOGS_PER_DAY-1)) 223 | _, err := pipeline.Exec(background) 224 | if err != nil { 225 | slog.Error("error during redis log rotation", "err", err) 226 | } 227 | } 228 | func catch(f func(), cleanup ...func()) { 229 | defer func() { 230 | if msg := recover(); msg != nil { 231 | slog.Error("Recovered from panic %v", msg) 232 | debug.PrintStack() 233 | for _, c := range cleanup { 234 | catch(c) 235 | } 236 | } 237 | }() 238 | f() 239 | } 240 | 241 | func tryForever(f func()) { 242 | 243 | for { 244 | catch(f) 245 | } 246 | } 247 | 248 | func try(f func(args ...any)) func(...any) { 249 | return func(args ...any) { 250 | catch(func() { 251 | f(args...) 252 | }) 253 | } 254 | } 255 | 256 | func handleLogs() { 257 | tryForever(func() { 258 | t := time.NewTicker(*maxLogInterval) 259 | var buff []*toggleLogEntry 260 | for { 261 | select { 262 | case <-t.C: 263 | if len(buff) == 0 { 264 | continue 265 | } 266 | case msg := <-logChannel: 267 | buff = append(buff, msg) 268 | if len(buff) < *maxLogBatchSize { 269 | continue 270 | } 271 | } 272 | abuseCount := 0 273 | activeClientCount := 0 274 | abuseMap.Range(func(key string, value *atomic.Int64) bool { 275 | abuseCount += int(value.Load()) 276 | activeClientCount += 1 277 | return true 278 | }) 279 | slog.Info( 280 | "submitting logs", 281 | "count", 282 | len(buff), 283 | "clients", 284 | activeClientCount, 285 | "totalAbuse", 286 | abuseCount, 287 | ) 288 | logToggles(buff) 289 | buff = buff[:0] 290 | } 291 | }) 292 | } 293 | 294 | var ( 295 | abuseMap = xsync.NewMapOf[*atomic.Int64]() 296 | ) 297 | 298 | var ( 299 | maxAbuseRequests = flag.Int64( 300 | "max-abuse-requests", 301 | 400, 302 | "maximum nubmer of requests a client can make before we consider it abuse", 303 | ) 304 | abuseResetInterval = flag.Duration( 305 | "abuse-reset", 306 | time.Minute, 307 | "reset the abuse pentaly after this time", 308 | ) 309 | ) 310 | 311 | func groupIPv6(ip string) (string, bool) { 312 | 313 | parsedIP := net.ParseIP(ip) 314 | if parsedIP == nil || parsedIP.To4() != nil { 315 | return ip, false // Return as-is if it's not a valid IPv6 address 316 | } 317 | 318 | ipv6Addr := parsedIP.To16() 319 | if ipv6Addr == nil { 320 | return ip, false // Shouldn't happen, but just in case 321 | } 322 | 323 | // Keep the first 48 bits (6 bytes) and zero out the rest 324 | // maybe 64? 325 | for i := 6; i < 16; i++ { 326 | ipv6Addr[i] = 0 327 | } 328 | 329 | return ipv6Addr.String(), true 330 | } 331 | 332 | func socketIP(s *socket.Socket) (string, bool) { 333 | // Check Cloudflare-specific header first 334 | log := slog.With("socketIP") 335 | NOLEN_IP := s.Request().Request().Header.Get("NOLEN-IP") 336 | 337 | cfIP := s.Request().Request().Header.Get("CF-Connecting-IP") 338 | 339 | if NOLEN_IP != "" { 340 | // check if it begins with "10." 341 | if len(NOLEN_IP) < 3 || NOLEN_IP[:3] == "10." { 342 | log.Info("SKIP NOLEN IP ITS PRIVATE") 343 | } else { 344 | log.Info("Using NOLEN IP", "ip", NOLEN_IP) 345 | return groupIPv6(NOLEN_IP) 346 | } 347 | } 348 | 349 | if cfIP != "" { 350 | log.Info("Using Cloudflare IP", "ip", cfIP) 351 | return groupIPv6(cfIP) 352 | } 353 | forwarded := s.Request().Request().Header.Get("X-Forwarded-For") 354 | if forwarded != "" { 355 | log.Info("Using forwarded IP", "ip", forwarded) 356 | return groupIPv6(forwarded) 357 | 358 | } 359 | 360 | addr, _ := net.ResolveTCPAddr("tcp", s.Conn().RemoteAddress()) 361 | z := addr.IP.String() 362 | log.Info("Using remote IP", "ip", z) 363 | return groupIPv6(z) 364 | } 365 | 366 | func resetAbuseCounters() { 367 | tryForever(func() { 368 | t := time.NewTicker(*abuseResetInterval) 369 | zeros := []string{} 370 | for range t.C { 371 | abuseMap.Range(func(key string, value *atomic.Int64) bool { 372 | tmp := value.Load() 373 | tmp -= (*maxAbuseRequests * *mercyRatio) 374 | if tmp < 0 { 375 | zeros = append(zeros, key) 376 | value.Store(0) 377 | return true 378 | } 379 | value.Store(tmp) 380 | return true 381 | }) 382 | } 383 | 384 | for _, name := range zeros { 385 | abuseMap.Delete(name) 386 | } 387 | }) 388 | } 389 | 390 | func detectAbuse(ip string, isIPV6 bool) bool { 391 | count, _ := abuseMap.LoadOrCompute(ip, func() *atomic.Int64 { 392 | v := new(atomic.Int64) 393 | v.Store(0) 394 | return v 395 | }) 396 | if isIPV6 { 397 | count.Add(1) // at some point I added 10 instead here 398 | } else { 399 | count.Add(1) 400 | } 401 | if count.Load() < *maxAbuseRequests { 402 | return false 403 | } 404 | // reducing this a bit to trim load a little more 405 | thousands := float64(count.Load()) / 700 406 | chance := math.Pow(0.5, thousands) 407 | return chance > rand.Float64() 408 | } 409 | 410 | func dumpHashsetState(rdb *redis.Client, log *slog.Logger) { 411 | result, err := rdb.HGetAll(context.Background(), "last_checked").Result() 412 | if err != nil { 413 | log.Error("Failed to get hashset state", "error", err) 414 | return 415 | } 416 | 417 | state := make(map[string]int64) 418 | for k, v := range result { 419 | timestamp, err := strconv.ParseInt(v, 10, 64) 420 | if err != nil { 421 | log.Error("Failed to parse timestamp", "key", k, "value", v, "error", err) 422 | continue 423 | } 424 | state[k] = timestamp 425 | } 426 | 427 | stateJSON, err := json.MarshalIndent(state, "", " ") 428 | if err != nil { 429 | log.Error("Failed to marshal hashset state", "error", err) 430 | return 431 | } 432 | 433 | log.Info("Current hashset state", "state", string(stateJSON)) 434 | } 435 | 436 | func main() { 437 | flag.Parse() 438 | initRedis() 439 | r := gin.Default() 440 | r.Group("/api").GET("/initial-state", func(ctx *gin.Context) { 441 | ctx.JSON(200, 442 | getStateSnapshot(), 443 | ) 444 | }) 445 | go resetAbuseCounters() 446 | defer primaryRedisClient.Close() 447 | defer secondaryRedisClient.Close() 448 | go handleLogs() 449 | 450 | ws := socket.NewServer(nil, nil) 451 | ws.On("connection", func(a ...any) { 452 | client := a[0].(*socket.Socket) 453 | catch(func() { 454 | ip, isIPV6 := socketIP(client) 455 | activeConns.Add(1) 456 | log := slog.With( 457 | "client", 458 | client.Id(), 459 | "ip", 460 | ip, 461 | "isIPV6", 462 | isIPV6, 463 | ) 464 | if isIPV6 { 465 | // add socket to "ipv6" room 466 | client.Join("ipv6") 467 | } 468 | client.On("disconnect", try(func(a ...any) { 469 | activeConns.Add(-1) 470 | log.Debug("leaving") 471 | })) 472 | if detectAbuse(ip, isIPV6) { 473 | log.Info("rejecting connection from suspected abuse ip") 474 | client.Conn().Close(true) 475 | return 476 | } 477 | 478 | client.On("unsubscribe", try(func(a ...any) { 479 | log.Info("client unsubbed") 480 | client.Join("nomessage") 481 | client.Emit("unsubscribed", "unsubscribed") 482 | })) 483 | 484 | client.On("toggle_bit", try(func(a ...any) { 485 | if detectAbuse(ip, isIPV6) { 486 | client.Conn().Close(true) 487 | log.Info("rejecting toggle from suspected abuse ip") 488 | return 489 | } 490 | data := a[0].(map[string]any) 491 | index := int(data["index"].(float64)) 492 | tlg := log.WithGroup("toggle_bit").With("index", index) 493 | if index >= TOTAL_CHECKBOXES || index < 0 { 494 | log.Error("attmepted to toggle bad index") 495 | return 496 | } 497 | res := frozenSetBitScript.Run( 498 | background, 499 | primaryRedisClient, 500 | []string{ 501 | "sunset_bitset", 502 | "sunset_count", 503 | "frozen_bitset", 504 | "frozen_count", 505 | "freeze_time_ms", 506 | }, 507 | int(index), 508 | TOTAL_CHECKBOXES, 509 | ) 510 | if res.Err() != nil { 511 | tlg.Error("Unable to toggle bit", "err", res.Err()) 512 | return 513 | } 514 | ts := time.Now().UnixMilli() 515 | nv, _ := res.Int64Slice() 516 | nbv, diff, newly_frozen := nv[0], nv[1], nv[2] 517 | if diff != 0 { 518 | tlg.Debug("toggled bit") 519 | 520 | logChannel <- &toggleLogEntry{ 521 | ip: ip, 522 | index: index, 523 | state: nbv > 0, 524 | } 525 | primaryRedisClient.Publish( 526 | background, 527 | "bit_toggle_channel", 528 | JSON([]any{ 529 | index, int(nbv), ts, 530 | }), 531 | ) 532 | } 533 | if newly_frozen == 1 { 534 | tlg.Debug("frozen bit") 535 | primaryRedisClient.Publish( 536 | background, 537 | "frozen_bit_channel", 538 | JSON([]any{ 539 | index, 540 | }), 541 | ) 542 | } 543 | })) 544 | slog.Debug("New connection", "socket", a) 545 | }, func() { 546 | client.Disconnect(true) 547 | }) 548 | }) 549 | 550 | go func() { 551 | tryForever(func() { 552 | t := time.NewTicker(*forceStateSnapshot) 553 | log := slog.With("scope", "forceStateSnapshot") 554 | for range t.C { 555 | log.Debug("starting snapshot send") 556 | ws.Except("ipv6").Except("nomessage").Emit("full_state", getStateSnapshot()) 557 | log.Debug("compete snapshot send") 558 | } 559 | }) 560 | }() 561 | 562 | go func() { 563 | tryForever(func() { 564 | maxBatchSize := 400 565 | ticker := time.NewTicker(time.Second / 10) 566 | subscriber := secondaryRedisClient.Subscribe( 567 | background, 568 | "bit_toggle_channel", 569 | ) 570 | defer subscriber.Close() 571 | log := slog.With("subscriber", subscriber) 572 | 573 | messages := subscriber.Channel() 574 | changed := make(map[int]bool, maxBatchSize) 575 | maxTs := 0 576 | tmp := make([]int, 3) 577 | 578 | emitAll := func() { 579 | on := make([]int, 0, len(changed)/2) 580 | off := make([]int, 0, len(changed)/2) 581 | for k, v := range changed { 582 | if v { 583 | on = append(on, k) 584 | } else { 585 | off = append(off, k) 586 | } 587 | } 588 | ws.Except("nomessage").Emit("batched_bit_toggles", []any{on, off, maxTs}) 589 | log.Debug("emmitting", "on", on, "off", off) 590 | changed = make(map[int]bool, maxBatchSize) 591 | maxTs = 0 592 | } 593 | for { 594 | select { 595 | case msg := <-messages: 596 | json.Unmarshal([]byte(msg.Payload), &tmp) 597 | index, nbv, ts := tmp[0], tmp[1], tmp[2] 598 | changed[index] = nbv > 0 599 | maxTs = max(ts, maxTs) 600 | if len(changed) < maxBatchSize { 601 | continue 602 | } 603 | case <-ticker.C: 604 | if len(changed) == 0 { 605 | continue 606 | } 607 | } 608 | 609 | catch(emitAll) 610 | } 611 | }) 612 | }() 613 | 614 | go func() { 615 | tryForever(func() { 616 | maxBatchSize := 400 617 | ticker := time.NewTicker(time.Second / 7) 618 | subscriber := secondaryRedisClient.Subscribe( 619 | background, 620 | "frozen_bit_channel", 621 | ) 622 | defer subscriber.Close() 623 | log := slog.With("subscriber", subscriber) 624 | 625 | messages := subscriber.Channel() 626 | frozen := make([]int, 0, maxBatchSize) 627 | tmp := make([]int, 1) 628 | emitAll := func() { 629 | ws.Except().Emit("batched_frozen_bits", frozen) 630 | log.Debug("emmitting", "frozen", frozen) 631 | frozen = make([]int, 0, maxBatchSize) 632 | } 633 | for { 634 | select { 635 | case msg := <-messages: 636 | json.Unmarshal([]byte(msg.Payload), &tmp) 637 | index := tmp[0] 638 | frozen = append(frozen, index) 639 | if len(frozen) < maxBatchSize { 640 | continue 641 | } 642 | case <-ticker.C: 643 | if len(frozen) == 0 { 644 | continue 645 | } 646 | } 647 | 648 | catch(emitAll) 649 | } 650 | }) 651 | }() 652 | 653 | wss := ws.ServeHandler(nil) 654 | gin.WrapF(func(w http.ResponseWriter, r *http.Request) { 655 | ip := "" 656 | NOLEN_IP := r.Header.Get("NOLEN-IP") 657 | cfIP := r.Header.Get("CF-Connecting-IP") 658 | forwarded := r.Header.Get("X-Forwarded-For") 659 | if NOLEN_IP != "" { 660 | ip = NOLEN_IP 661 | } else if cfIP != "" { 662 | ip = cfIP 663 | } else if forwarded != "" { 664 | ip = forwarded 665 | } else { 666 | ip = "10.0.0.1" 667 | } 668 | parsedIp, isIPV6 := groupIPv6(ip) 669 | 670 | if detectAbuse(parsedIp, isIPV6) { 671 | slog.With("ip", forwarded). 672 | Info("Rejecting http reqeust from suspected abuse ip") 673 | w.WriteHeader(400) 674 | return 675 | } 676 | 677 | wss.ServeHTTP(w, r) 678 | }) 679 | 680 | h := gin.WrapH(ws.ServeHandler(nil)) 681 | r.GET("/socket.io/", h) 682 | r.POST("/socket.io/", h) 683 | r.NoRoute(static.Serve("/", static.EmbedFolder(distFolder, "dist"))) 684 | go r.Run( 685 | fmt.Sprintf(":%d", *port), 686 | ) 687 | go r.Run( 688 | fmt.Sprintf(":%d", *port+1), 689 | ) 690 | go r.Run( 691 | fmt.Sprintf(":%d", *port+2), 692 | ) 693 | r.Run( 694 | fmt.Sprintf(":%d", *port+3), 695 | ) 696 | } 697 | 698 | func envOr(name, def string) string { 699 | s := os.Getenv(name) 700 | if s == "" { 701 | return def 702 | } 703 | return s 704 | } 705 | func miniClient() *redis.Client { 706 | if mini == nil { 707 | mini = miniredis.NewMiniRedis() 708 | mini.Start() 709 | } 710 | client := redis.NewClient(&redis.Options{ 711 | Addr: mini.Addr(), 712 | }) 713 | l := slog.With("scope", "miniredis") 714 | l.Info("Using miniredis", "addr", mini.Addr(), "port", mini.Port()) 715 | if ping := client.Ping(background); ping.Err() != nil { 716 | log.Fatalf("Unable to estable connection to miniredis %s", ping.Err()) 717 | } 718 | return client 719 | 720 | } 721 | func primaryRedis() (*redis.Client, error) { 722 | return redisClient( 723 | envOr("REDIS_HOST", "localhost"), 724 | envOr("REDIS_PORT", "6379"), 725 | envOr("REDIS_USERNAME", "default"), 726 | envOr("REDIS_PASSWORD", ""), 727 | ) 728 | } 729 | func replicaRedis() (*redis.Client, error) { 730 | return redisClient( 731 | *REDIS_SECONDARY_IP, 732 | envOr("REDIS_PORT", "6379"), 733 | envOr("REDIS_USERNAME", "default"), 734 | envOr("REDIS_PASSWORD", ""), 735 | ) 736 | } 737 | 738 | func getFullState() string { 739 | 740 | buff, err := secondaryRedisClient.Get(background, "sunset_bitset"). 741 | Bytes() 742 | if err != nil { 743 | log.Panicf("Unable to read bitset from redis", err) 744 | } 745 | return base64.RawStdEncoding.EncodeToString(buff) 746 | } 747 | 748 | func getFrozenState() string { 749 | buff, err := secondaryRedisClient.Get(background, "frozen_bitset"). 750 | Bytes() 751 | if err != nil { 752 | log.Panicf("Unable to read frozen bitset from redis", err) 753 | } 754 | return base64.StdEncoding.EncodeToString(buff) 755 | } 756 | 757 | func redisClient(host, port, user, pass string) (*redis.Client, error) { 758 | rdb := redis.NewClient(&redis.Options{ 759 | Addr: fmt.Sprintf( 760 | "%s:%s", 761 | host, port, 762 | ), 763 | Username: user, 764 | Password: pass, // no password set 765 | DB: 0, // use default DB 766 | MaxIdleConns: 20, 767 | MaxActiveConns: 40, 768 | DialTimeout: time.Second * 10, 769 | ContextTimeoutEnabled: true, 770 | // PoolTimeout: time.Second * 1, 771 | TLSConfig: &tls.Config{ 772 | InsecureSkipVerify: true, 773 | }, 774 | }) 775 | return rdb, rdb.Ping(background).Err() 776 | } 777 | 778 | // redis scripts 779 | var ( 780 | setBitScript = redis.NewScript(` 781 | local key = KEYS[1] 782 | local index = tonumber(ARGV[1]) 783 | local value = tonumber(ARGV[2]) 784 | local current = redis.call('getbit', key, index) 785 | local diff = value - current 786 | redis.call('setbit', key, index, value) 787 | redis.call('incrby', 'count', diff) 788 | return diff`) 789 | 790 | newSetBitScript = redis.NewScript(` 791 | local key = KEYS[1] 792 | local count_key = KEYS[2] 793 | local index = tonumber(ARGV[1]) 794 | local max_count = tonumber(ARGV[2]) 795 | 796 | local current_count = tonumber(redis.call('get', count_key) or "0") 797 | if current_count >= max_count then 798 | return {redis.call('getbit', key, index), 0} -- Return current count, current bit value, and 0 to indicate no change 799 | end 800 | 801 | local current_bit = redis.call('getbit', key, index) 802 | local new_bit = 1 - current_bit -- Toggle the bit 803 | local diff = new_bit - current_bit 804 | 805 | if diff > 0 and current_count + diff > max_count then 806 | return { current_bit, 0} -- Return current count, current bit value, and 0 to indicate no change 807 | end 808 | 809 | redis.call('setbit', key, index, new_bit) 810 | local new_count = current_count + diff 811 | redis.call('set', count_key, new_count) 812 | 813 | return {new_bit, diff} -- new bit value, and the change (1, 0, or -1)`) 814 | 815 | frozenSetBitScript = redis.NewScript(` 816 | local bitset_key = KEYS[1] 817 | local count_key = KEYS[2] 818 | local frozen_bitset_key = KEYS[3] 819 | local frozen_count_key = KEYS[4] 820 | local freeze_time_key = KEYS[5] 821 | local index = tonumber(ARGV[1]) 822 | local max_count = tonumber(ARGV[2]) 823 | 824 | -- Sentinel value for unchecked boxes (0 is a good choice as it's falsy in Lua) 825 | local UNCHECKED_SENTINEL = 0 826 | 827 | -- Get current Redis time in milliseconds 828 | local redis_time = redis.call('TIME') 829 | local current_time = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000) 830 | 831 | -- Get freeze_time from Redis (in milliseconds) 832 | local freeze_time = tonumber(redis.call('get', freeze_time_key) or "0") 833 | 834 | -- Get current state 835 | local current_count = tonumber(redis.call('get', count_key) or "0") 836 | local current_bit = redis.call('getbit', bitset_key, index) 837 | local frozen_bit = redis.call('getbit', frozen_bitset_key, index) 838 | 839 | -- Check if the box is already frozen 840 | if frozen_bit == 1 then 841 | return {current_bit, 0, 0} -- Return current bit value, 0 for no change, and 0 to indicate not newly frozen 842 | end 843 | 844 | -- If we're at max count, no changes allowed 845 | if current_count >= max_count then 846 | return {current_bit, 0, 0} 847 | end 848 | 849 | -- Toggle the bit 850 | local new_bit = 1 - current_bit 851 | local diff = new_bit - current_bit 852 | 853 | -- If we're unchecking (new_bit == 0), check the freeze_time 854 | if new_bit == 0 then 855 | local last_checked = tonumber(redis.call('hget', 'last_checked', index) or UNCHECKED_SENTINEL) 856 | if last_checked ~= UNCHECKED_SENTINEL and current_time - last_checked >= freeze_time then 857 | -- Box is frozen, update frozen bitset and count 858 | redis.call('setbit', frozen_bitset_key, index, 1) 859 | redis.call('incr', frozen_count_key) 860 | return {1, 0, 1} -- Return 1 (checked), 0 for no change, and 1 to indicate newly frozen 861 | else 862 | -- Set the sentinel value instead of deleting 863 | redis.call('hset', 'last_checked', index, UNCHECKED_SENTINEL) 864 | end 865 | else 866 | -- We're checking the box, update last_checked time 867 | redis.call('hset', 'last_checked', index, current_time) 868 | end 869 | 870 | -- Proceed with the change 871 | redis.call('setbit', bitset_key, index, new_bit) 872 | local new_count = current_count + diff 873 | redis.call('set', count_key, new_count) 874 | 875 | return {new_bit, diff, 0} -- new bit value, the change (-1, 0, or 1), and 0 to indicate not frozen`) 876 | ) 877 | -------------------------------------------------------------------------------- /src/randomizedColors.js: -------------------------------------------------------------------------------- 1 | const INDICES = { 2 | 45: "gold", 3 | 192: "red", 4 | 339: "green", 5 | 782: "red", 6 | 1073: "gold", 7 | 1317: "green", 8 | 1744: "red", 9 | 2104: "red", 10 | 2473: "green", 11 | 2793: "green", 12 | 3094: "red", 13 | 3363: "purple", 14 | 3700: "green", 15 | 4012: "red", 16 | 4466: "gold", 17 | 4884: "red", 18 | 5279: "green", 19 | 5685: "gold", 20 | 5999: "green", 21 | 6378: "gold", 22 | 6846: "green", 23 | 7294: "red", 24 | 7597: "purple", 25 | 7945: "gold", 26 | 8335: "green", 27 | 8751: "gold", 28 | 9076: "green", 29 | 9343: "red", 30 | 9586: "green", 31 | 9932: "purple", 32 | 10225: "gold", 33 | 10510: "purple", 34 | 10766: "green", 35 | 11040: "red", 36 | 11368: "green", 37 | 11635: "green", 38 | 11972: "green", 39 | 12232: "green", 40 | 12633: "green", 41 | 13065: "gold", 42 | 13332: "green", 43 | 13794: "red", 44 | 14132: "gold", 45 | 14532: "purple", 46 | 14988: "green", 47 | 15279: "red", 48 | 15596: "purple", 49 | 16069: "green", 50 | 16498: "green", 51 | 16922: "purple", 52 | 17194: "gold", 53 | 17528: "gold", 54 | 17816: "red", 55 | 18060: "green", 56 | 18446: "green", 57 | 18857: "green", 58 | 19229: "red", 59 | 19649: "red", 60 | 20104: "purple", 61 | 20436: "green", 62 | 20681: "gold", 63 | 21103: "red", 64 | 21549: "green", 65 | 21806: "purple", 66 | 22047: "gold", 67 | 22457: "purple", 68 | 22876: "red", 69 | 23206: "gold", 70 | 23604: "purple", 71 | 23935: "gold", 72 | 24278: "red", 73 | 24563: "gold", 74 | 24965: "purple", 75 | 25328: "purple", 76 | 25622: "purple", 77 | 26086: "purple", 78 | 26356: "purple", 79 | 26691: "red", 80 | 27035: "purple", 81 | 27331: "gold", 82 | 27743: "red", 83 | 28105: "red", 84 | 28378: "gold", 85 | 28797: "gold", 86 | 29167: "red", 87 | 29631: "red", 88 | 29996: "purple", 89 | 30291: "red", 90 | 30656: "red", 91 | 30963: "red", 92 | 31328: "purple", 93 | 31734: "red", 94 | 32193: "green", 95 | 32602: "gold", 96 | 33022: "red", 97 | 33471: "purple", 98 | 33737: "red", 99 | 34072: "red", 100 | 34537: "green", 101 | 34969: "red", 102 | 35372: "gold", 103 | 35638: "gold", 104 | 36081: "purple", 105 | 36425: "gold", 106 | 36700: "green", 107 | 37147: "red", 108 | 37428: "gold", 109 | 37843: "red", 110 | 38223: "green", 111 | 38654: "purple", 112 | 39075: "purple", 113 | 39369: "green", 114 | 39686: "red", 115 | 40053: "red", 116 | 40378: "purple", 117 | 40651: "green", 118 | 41067: "red", 119 | 41399: "red", 120 | 41861: "red", 121 | 42163: "gold", 122 | 42409: "purple", 123 | 42704: "red", 124 | 42972: "green", 125 | 43229: "purple", 126 | 43682: "green", 127 | 44150: "purple", 128 | 44548: "purple", 129 | 45002: "red", 130 | 45444: "gold", 131 | 45780: "gold", 132 | 46127: "purple", 133 | 46531: "gold", 134 | 46994: "gold", 135 | 47234: "green", 136 | 47669: "red", 137 | 48101: "red", 138 | 48549: "purple", 139 | 48825: "green", 140 | 49295: "purple", 141 | 49738: "purple", 142 | 49977: "red", 143 | 50380: "red", 144 | 50692: "purple", 145 | 51108: "green", 146 | 51550: "green", 147 | 51958: "purple", 148 | 52433: "purple", 149 | 52791: "green", 150 | 53052: "red", 151 | 53524: "purple", 152 | 53993: "gold", 153 | 54274: "gold", 154 | 54527: "green", 155 | 54884: "purple", 156 | 55241: "red", 157 | 55714: "green", 158 | 56101: "green", 159 | 56417: "green", 160 | 56840: "red", 161 | 57094: "green", 162 | 57385: "green", 163 | 57763: "purple", 164 | 58016: "green", 165 | 58342: "green", 166 | 58726: "purple", 167 | 59010: "red", 168 | 59406: "purple", 169 | 59871: "gold", 170 | 60309: "red", 171 | 60672: "red", 172 | 60998: "red", 173 | 61347: "green", 174 | 61745: "purple", 175 | 62211: "red", 176 | 62536: "purple", 177 | 62791: "purple", 178 | 63119: "purple", 179 | 63489: "purple", 180 | 63894: "gold", 181 | 64294: "green", 182 | 64555: "red", 183 | 64871: "gold", 184 | 65276: "gold", 185 | 65734: "red", 186 | 66074: "purple", 187 | 66429: "gold", 188 | 66836: "green", 189 | 67097: "red", 190 | 67360: "gold", 191 | 67835: "purple", 192 | 68160: "red", 193 | 68492: "gold", 194 | 68871: "red", 195 | 69186: "purple", 196 | 69647: "red", 197 | 69987: "green", 198 | 70324: "gold", 199 | 70574: "red", 200 | 70995: "gold", 201 | 71250: "red", 202 | 71671: "purple", 203 | 72059: "gold", 204 | 72346: "purple", 205 | 72601: "gold", 206 | 73007: "green", 207 | 73354: "green", 208 | 73627: "green", 209 | 73980: "gold", 210 | 74351: "purple", 211 | 74602: "green", 212 | 74906: "gold", 213 | 75369: "green", 214 | 75735: "gold", 215 | 76047: "red", 216 | 76344: "red", 217 | 76585: "purple", 218 | 76948: "gold", 219 | 77198: "gold", 220 | 77636: "gold", 221 | 77873: "purple", 222 | 78120: "green", 223 | 78432: "purple", 224 | 78770: "green", 225 | 79219: "red", 226 | 79612: "gold", 227 | 79935: "green", 228 | 80225: "green", 229 | 80587: "red", 230 | 81040: "purple", 231 | 81500: "red", 232 | 81958: "purple", 233 | 82214: "purple", 234 | 82451: "green", 235 | 82713: "purple", 236 | 82959: "purple", 237 | 83378: "green", 238 | 83701: "red", 239 | 84046: "purple", 240 | 84383: "green", 241 | 84652: "gold", 242 | 85060: "gold", 243 | 85524: "gold", 244 | 85813: "purple", 245 | 86119: "gold", 246 | 86592: "red", 247 | 86881: "gold", 248 | 87139: "red", 249 | 87583: "gold", 250 | 88002: "gold", 251 | 88281: "purple", 252 | 88602: "green", 253 | 89041: "red", 254 | 89400: "purple", 255 | 89832: "red", 256 | 90279: "gold", 257 | 90712: "gold", 258 | 91088: "red", 259 | 91402: "red", 260 | 91730: "green", 261 | 92012: "red", 262 | 92311: "red", 263 | 92607: "gold", 264 | 92905: "purple", 265 | 93189: "red", 266 | 93630: "purple", 267 | 93873: "red", 268 | 94332: "purple", 269 | 94789: "green", 270 | 95207: "gold", 271 | 95583: "gold", 272 | 95995: "gold", 273 | 96377: "green", 274 | 96843: "purple", 275 | 97189: "red", 276 | 97593: "red", 277 | 97860: "green", 278 | 98100: "green", 279 | 98479: "gold", 280 | 98731: "purple", 281 | 98987: "red", 282 | 99391: "gold", 283 | 99658: "red", 284 | 100011: "green", 285 | 100304: "purple", 286 | 100724: "red", 287 | 101107: "green", 288 | 101467: "gold", 289 | 101893: "red", 290 | 102229: "green", 291 | 102622: "green", 292 | 102864: "gold", 293 | 103205: "green", 294 | 103587: "purple", 295 | 104000: "purple", 296 | 104386: "green", 297 | 104830: "purple", 298 | 105122: "red", 299 | 105484: "gold", 300 | 105959: "purple", 301 | 106428: "red", 302 | 106717: "green", 303 | 107173: "gold", 304 | 107629: "green", 305 | 108031: "purple", 306 | 108315: "red", 307 | 108756: "green", 308 | 109098: "purple", 309 | 109559: "purple", 310 | 110012: "purple", 311 | 110256: "gold", 312 | 110640: "gold", 313 | 110993: "purple", 314 | 111467: "gold", 315 | 111745: "gold", 316 | 112171: "gold", 317 | 112439: "green", 318 | 112727: "purple", 319 | 113018: "red", 320 | 113277: "green", 321 | 113556: "purple", 322 | 113866: "gold", 323 | 114254: "red", 324 | 114561: "purple", 325 | 114814: "gold", 326 | 115200: "red", 327 | 115589: "red", 328 | 115830: "green", 329 | 116084: "red", 330 | 116450: "gold", 331 | 116829: "gold", 332 | 117278: "purple", 333 | 117669: "green", 334 | 118126: "gold", 335 | 118524: "green", 336 | 118917: "purple", 337 | 119276: "purple", 338 | 119674: "gold", 339 | 120040: "red", 340 | 120514: "green", 341 | 120812: "purple", 342 | 121109: "green", 343 | 121387: "purple", 344 | 121760: "red", 345 | 122124: "green", 346 | 122479: "red", 347 | 122799: "gold", 348 | 123235: "gold", 349 | 123534: "purple", 350 | 123881: "purple", 351 | 124187: "purple", 352 | 124451: "red", 353 | 124872: "purple", 354 | 125316: "green", 355 | 125726: "red", 356 | 126008: "gold", 357 | 126345: "gold", 358 | 126658: "green", 359 | 127023: "gold", 360 | 127340: "gold", 361 | 127685: "gold", 362 | 128149: "green", 363 | 128620: "green", 364 | 129022: "gold", 365 | 129285: "red", 366 | 129532: "green", 367 | 129809: "purple", 368 | 130209: "gold", 369 | 130478: "purple", 370 | 130828: "green", 371 | 131151: "red", 372 | 131568: "green", 373 | 131945: "red", 374 | 132329: "red", 375 | 132769: "purple", 376 | 133169: "purple", 377 | 133498: "green", 378 | 133867: "purple", 379 | 134226: "green", 380 | 134487: "gold", 381 | 134897: "gold", 382 | 135149: "purple", 383 | 135542: "gold", 384 | 135819: "gold", 385 | 136165: "green", 386 | 136451: "green", 387 | 136721: "gold", 388 | 136963: "purple", 389 | 137222: "gold", 390 | 137665: "purple", 391 | 137962: "red", 392 | 138238: "red", 393 | 138591: "green", 394 | 138918: "gold", 395 | 139249: "gold", 396 | 139598: "green", 397 | 140013: "purple", 398 | 140455: "gold", 399 | 140707: "green", 400 | 140949: "green", 401 | 141294: "gold", 402 | 141642: "red", 403 | 142016: "green", 404 | 142350: "purple", 405 | 142652: "purple", 406 | 142917: "gold", 407 | 143289: "red", 408 | 143762: "green", 409 | 144198: "purple", 410 | 144554: "red", 411 | 144852: "gold", 412 | 145095: "green", 413 | 145465: "purple", 414 | 145860: "red", 415 | 146314: "gold", 416 | 146726: "purple", 417 | 147095: "purple", 418 | 147342: "green", 419 | 147769: "red", 420 | 148208: "green", 421 | 148657: "purple", 422 | 149102: "red", 423 | 149386: "red", 424 | 149648: "red", 425 | 149887: "gold", 426 | 150170: "purple", 427 | 150533: "gold", 428 | 150819: "purple", 429 | 151150: "purple", 430 | 151462: "red", 431 | 151821: "green", 432 | 152293: "purple", 433 | 152575: "purple", 434 | 153049: "gold", 435 | 153298: "purple", 436 | 153722: "green", 437 | 154022: "purple", 438 | 154366: "green", 439 | 154780: "gold", 440 | 155235: "red", 441 | 155657: "red", 442 | 156080: "gold", 443 | 156485: "green", 444 | 156749: "red", 445 | 157219: "green", 446 | 157627: "purple", 447 | 158041: "red", 448 | 158320: "gold", 449 | 158795: "red", 450 | 159174: "green", 451 | 159444: "gold", 452 | 159794: "green", 453 | 160085: "purple", 454 | 160512: "red", 455 | 160910: "green", 456 | 161291: "gold", 457 | 161761: "gold", 458 | 162109: "gold", 459 | 162574: "red", 460 | 162879: "red", 461 | 163122: "purple", 462 | 163489: "red", 463 | 163938: "purple", 464 | 164337: "purple", 465 | 164722: "green", 466 | 165061: "green", 467 | 165415: "purple", 468 | 165748: "purple", 469 | 166176: "red", 470 | 166521: "green", 471 | 166980: "green", 472 | 167316: "gold", 473 | 167601: "green", 474 | 167866: "gold", 475 | 168264: "gold", 476 | 168639: "red", 477 | 168882: "red", 478 | 169239: "green", 479 | 169698: "purple", 480 | 169988: "gold", 481 | 170382: "green", 482 | 170770: "red", 483 | 171240: "purple", 484 | 171599: "green", 485 | 171973: "purple", 486 | 172431: "gold", 487 | 172867: "purple", 488 | 173170: "gold", 489 | 173409: "gold", 490 | 173783: "gold", 491 | 174162: "red", 492 | 174447: "purple", 493 | 174686: "red", 494 | 174986: "red", 495 | 175445: "gold", 496 | 175711: "green", 497 | 176147: "red", 498 | 176451: "green", 499 | 176772: "purple", 500 | 177102: "gold", 501 | 177341: "green", 502 | 177654: "gold", 503 | 178063: "purple", 504 | 178369: "purple", 505 | 178835: "red", 506 | 179240: "gold", 507 | 179547: "gold", 508 | 179872: "purple", 509 | 180168: "green", 510 | 180526: "purple", 511 | 180782: "gold", 512 | 181192: "red", 513 | 181481: "gold", 514 | 181810: "red", 515 | 182204: "purple", 516 | 182677: "gold", 517 | 183081: "red", 518 | 183545: "purple", 519 | 183922: "purple", 520 | 184235: "purple", 521 | 184509: "purple", 522 | 184762: "purple", 523 | 185140: "purple", 524 | 185517: "green", 525 | 185866: "purple", 526 | 186202: "purple", 527 | 186513: "gold", 528 | 186809: "green", 529 | 187160: "red", 530 | 187463: "red", 531 | 187774: "gold", 532 | 188136: "gold", 533 | 188378: "green", 534 | 188772: "green", 535 | 189077: "purple", 536 | 189479: "gold", 537 | 189894: "red", 538 | 190286: "purple", 539 | 190612: "purple", 540 | 191001: "red", 541 | 191404: "gold", 542 | 191780: "green", 543 | 192089: "gold", 544 | 192409: "gold", 545 | 192760: "gold", 546 | 193016: "green", 547 | 193304: "red", 548 | 193600: "purple", 549 | 194049: "red", 550 | 194360: "red", 551 | 194732: "gold", 552 | 195060: "green", 553 | 195324: "purple", 554 | 195690: "green", 555 | 196038: "green", 556 | 196335: "red", 557 | 196634: "green", 558 | 197104: "red", 559 | 197556: "red", 560 | 198000: "red", 561 | 198402: "purple", 562 | 198855: "red", 563 | 199164: "gold", 564 | 199453: "green", 565 | 199898: "green", 566 | 200210: "red", 567 | 200476: "purple", 568 | 200923: "green", 569 | 201396: "red", 570 | 201693: "gold", 571 | 202098: "green", 572 | 202336: "green", 573 | 202763: "purple", 574 | 203230: "red", 575 | 203608: "gold", 576 | 203884: "red", 577 | 204265: "gold", 578 | 204601: "purple", 579 | 204968: "purple", 580 | 205227: "purple", 581 | 205509: "green", 582 | 205800: "gold", 583 | 206198: "green", 584 | 206472: "purple", 585 | 206862: "green", 586 | 207236: "red", 587 | 207698: "green", 588 | 207955: "purple", 589 | 208195: "gold", 590 | 208598: "purple", 591 | 208957: "gold", 592 | 209250: "gold", 593 | 209658: "red", 594 | 210007: "gold", 595 | 210257: "green", 596 | 210550: "red", 597 | 210841: "gold", 598 | 211169: "gold", 599 | 211505: "green", 600 | 211819: "red", 601 | 212238: "green", 602 | 212631: "red", 603 | 213079: "red", 604 | 213545: "gold", 605 | 213982: "green", 606 | 214231: "purple", 607 | 214557: "green", 608 | 214944: "red", 609 | 215293: "purple", 610 | 215615: "green", 611 | 215999: "gold", 612 | 216262: "gold", 613 | 216724: "purple", 614 | 217087: "purple", 615 | 217558: "red", 616 | 217828: "gold", 617 | 218143: "gold", 618 | 218569: "purple", 619 | 218897: "purple", 620 | 219298: "red", 621 | 219662: "green", 622 | 219961: "green", 623 | 220401: "gold", 624 | 220741: "gold", 625 | 221105: "gold", 626 | 221551: "red", 627 | 221990: "red", 628 | 222380: "red", 629 | 222730: "gold", 630 | 222974: "purple", 631 | 223232: "green", 632 | 223616: "purple", 633 | 223898: "purple", 634 | 224248: "green", 635 | 224528: "red", 636 | 224845: "gold", 637 | 225296: "purple", 638 | 225541: "gold", 639 | 226015: "green", 640 | 226435: "red", 641 | 226855: "red", 642 | 227298: "gold", 643 | 227612: "red", 644 | 228024: "purple", 645 | 228466: "purple", 646 | 228775: "red", 647 | 229163: "gold", 648 | 229487: "red", 649 | 229886: "gold", 650 | 230174: "gold", 651 | 230455: "gold", 652 | 230830: "red", 653 | 231229: "red", 654 | 231539: "red", 655 | 231955: "red", 656 | 232294: "gold", 657 | 232612: "gold", 658 | 233079: "red", 659 | 233398: "green", 660 | 233759: "green", 661 | 234009: "red", 662 | 234453: "green", 663 | 234920: "gold", 664 | 235277: "green", 665 | 235699: "gold", 666 | 236043: "red", 667 | 236498: "gold", 668 | 236780: "gold", 669 | 237098: "green", 670 | 237540: "red", 671 | 237994: "green", 672 | 238339: "red", 673 | 238691: "gold", 674 | 238931: "purple", 675 | 239184: "green", 676 | 239567: "gold", 677 | 239956: "purple", 678 | 240379: "green", 679 | 240741: "gold", 680 | 241087: "purple", 681 | 241460: "purple", 682 | 241733: "red", 683 | 242004: "green", 684 | 242446: "purple", 685 | 242896: "green", 686 | 243298: "green", 687 | 243676: "green", 688 | 244046: "green", 689 | 244452: "green", 690 | 244764: "green", 691 | 245028: "purple", 692 | 245276: "gold", 693 | 245632: "purple", 694 | 245933: "green", 695 | 246270: "gold", 696 | 246511: "green", 697 | 246972: "purple", 698 | 247410: "purple", 699 | 247764: "red", 700 | 248060: "gold", 701 | 248375: "gold", 702 | 248702: "gold", 703 | 249024: "gold", 704 | 249338: "gold", 705 | 249700: "red", 706 | 250084: "red", 707 | 250443: "purple", 708 | 250684: "gold", 709 | 251098: "green", 710 | 251473: "red", 711 | 251899: "green", 712 | 252331: "gold", 713 | 252671: "gold", 714 | 253098: "gold", 715 | 253353: "green", 716 | 253634: "green", 717 | 253970: "gold", 718 | 254230: "gold", 719 | 254646: "red", 720 | 255049: "gold", 721 | 255349: "red", 722 | 255664: "red", 723 | 256074: "green", 724 | 256477: "green", 725 | 256793: "purple", 726 | 257217: "red", 727 | 257681: "red", 728 | 258100: "red", 729 | 258441: "green", 730 | 258682: "gold", 731 | 259072: "purple", 732 | 259408: "red", 733 | 259653: "purple", 734 | 260004: "red", 735 | 260330: "red", 736 | 260672: "gold", 737 | 261028: "purple", 738 | 261295: "purple", 739 | 261658: "red", 740 | 262027: "gold", 741 | 262466: "purple", 742 | 262740: "gold", 743 | 263131: "purple", 744 | 263544: "green", 745 | 263969: "red", 746 | 264331: "purple", 747 | 264693: "purple", 748 | 265147: "green", 749 | 265559: "purple", 750 | 265935: "gold", 751 | 266262: "green", 752 | 266615: "red", 753 | 266895: "green", 754 | 267146: "red", 755 | 267418: "gold", 756 | 267831: "red", 757 | 268280: "purple", 758 | 268575: "purple", 759 | 268988: "red", 760 | 269403: "purple", 761 | 269717: "red", 762 | 270063: "gold", 763 | 270328: "gold", 764 | 270679: "green", 765 | 271049: "purple", 766 | 271466: "red", 767 | 271740: "green", 768 | 271993: "gold", 769 | 272274: "red", 770 | 272736: "gold", 771 | 273035: "purple", 772 | 273481: "gold", 773 | 273722: "gold", 774 | 274135: "green", 775 | 274379: "purple", 776 | 274797: "green", 777 | 275107: "red", 778 | 275387: "red", 779 | 275645: "gold", 780 | 276078: "green", 781 | 276326: "gold", 782 | 276716: "gold", 783 | 277074: "gold", 784 | 277523: "red", 785 | 277966: "red", 786 | 278344: "green", 787 | 278753: "gold", 788 | 279090: "green", 789 | 279460: "green", 790 | 279758: "purple", 791 | 280135: "gold", 792 | 280573: "purple", 793 | 280975: "red", 794 | 281406: "purple", 795 | 281756: "gold", 796 | 282138: "gold", 797 | 282403: "green", 798 | 282796: "green", 799 | 283076: "purple", 800 | 283463: "green", 801 | 283797: "green", 802 | 284205: "gold", 803 | 284495: "gold", 804 | 284775: "gold", 805 | 285178: "green", 806 | 285517: "gold", 807 | 285918: "red", 808 | 286201: "purple", 809 | 286453: "green", 810 | 286706: "purple", 811 | 287103: "gold", 812 | 287391: "purple", 813 | 287751: "purple", 814 | 287991: "purple", 815 | 288400: "red", 816 | 288753: "green", 817 | 289191: "gold", 818 | 289630: "green", 819 | 289988: "green", 820 | 290281: "red", 821 | 290657: "gold", 822 | 291099: "red", 823 | 291451: "red", 824 | 291900: "green", 825 | 292229: "purple", 826 | 292683: "green", 827 | 293114: "purple", 828 | 293382: "purple", 829 | 293634: "purple", 830 | 294027: "green", 831 | 294479: "purple", 832 | 294899: "purple", 833 | 295159: "purple", 834 | 295436: "gold", 835 | 295687: "purple", 836 | 295969: "green", 837 | 296356: "gold", 838 | 296663: "green", 839 | 297055: "red", 840 | 297383: "gold", 841 | 297713: "green", 842 | 298026: "purple", 843 | 298356: "red", 844 | 298758: "red", 845 | 299120: "red", 846 | 299464: "red", 847 | 299735: "gold", 848 | 300064: "gold", 849 | 300468: "purple", 850 | 300779: "red", 851 | 301143: "gold", 852 | 301612: "green", 853 | 302051: "purple", 854 | 302470: "purple", 855 | 302817: "green", 856 | 303184: "gold", 857 | 303535: "green", 858 | 303787: "red", 859 | 304259: "red", 860 | 304644: "gold", 861 | 304981: "gold", 862 | 305387: "purple", 863 | 305734: "green", 864 | 306178: "purple", 865 | 306487: "green", 866 | 306856: "green", 867 | 307182: "green", 868 | 307425: "purple", 869 | 307756: "purple", 870 | 308050: "gold", 871 | 308376: "gold", 872 | 308677: "gold", 873 | 308988: "green", 874 | 309372: "gold", 875 | 309810: "red", 876 | 310183: "green", 877 | 310575: "red", 878 | 310923: "red", 879 | 311294: "green", 880 | 311696: "purple", 881 | 311951: "red", 882 | 312224: "gold", 883 | 312699: "purple", 884 | 313017: "red", 885 | 313332: "gold", 886 | 313570: "purple", 887 | 314019: "green", 888 | 314471: "red", 889 | 314826: "gold", 890 | 315213: "purple", 891 | 315633: "red", 892 | 315998: "gold", 893 | 316362: "gold", 894 | 316696: "purple", 895 | 316984: "purple", 896 | 317295: "green", 897 | 317687: "purple", 898 | 318028: "purple", 899 | 318346: "red", 900 | 318711: "red", 901 | 319125: "gold", 902 | 319581: "gold", 903 | 320005: "purple", 904 | 320345: "purple", 905 | 320608: "purple", 906 | 320955: "red", 907 | 321192: "green", 908 | 321500: "green", 909 | 321961: "red", 910 | 322427: "gold", 911 | 322758: "red", 912 | 323174: "gold", 913 | 323463: "purple", 914 | 323834: "red", 915 | 324105: "purple", 916 | 324399: "gold", 917 | 324833: "green", 918 | 325259: "red", 919 | 325689: "gold", 920 | 326004: "green", 921 | 326393: "purple", 922 | 326865: "green", 923 | 327306: "gold", 924 | 327745: "gold", 925 | 328049: "gold", 926 | 328383: "gold", 927 | 328772: "green", 928 | 329137: "gold", 929 | 329447: "gold", 930 | 329817: "purple", 931 | 330094: "red", 932 | 330411: "green", 933 | 330864: "gold", 934 | 331314: "purple", 935 | 331617: "red", 936 | 331941: "green", 937 | 332337: "purple", 938 | 332675: "gold", 939 | 333098: "gold", 940 | 333406: "gold", 941 | 333703: "purple", 942 | 334040: "gold", 943 | 334417: "purple", 944 | 334654: "red", 945 | 335067: "gold", 946 | 335417: "purple", 947 | 335806: "gold", 948 | 336190: "green", 949 | 336507: "purple", 950 | 336849: "green", 951 | 337167: "green", 952 | 337549: "gold", 953 | 337937: "red", 954 | 338258: "gold", 955 | 338626: "red", 956 | 339039: "gold", 957 | 339286: "purple", 958 | 339689: "green", 959 | 340118: "purple", 960 | 340581: "green", 961 | 340994: "gold", 962 | 341294: "gold", 963 | 341551: "green", 964 | 341876: "purple", 965 | 342264: "purple", 966 | 342633: "green", 967 | 342961: "purple", 968 | 343298: "purple", 969 | 343556: "purple", 970 | 343820: "red", 971 | 344143: "red", 972 | 344576: "green", 973 | 344925: "purple", 974 | 345305: "green", 975 | 345589: "red", 976 | 346022: "red", 977 | 346312: "red", 978 | 346732: "red", 979 | 347018: "green", 980 | 347378: "green", 981 | 347647: "gold", 982 | 348006: "gold", 983 | 348414: "red", 984 | 348669: "purple", 985 | 348930: "gold", 986 | 349337: "gold", 987 | 349622: "green", 988 | 349970: "red", 989 | 350325: "red", 990 | 350619: "red", 991 | 350860: "purple", 992 | 351233: "gold", 993 | 351611: "purple", 994 | 352000: "gold", 995 | 352243: "red", 996 | 352666: "gold", 997 | 353136: "purple", 998 | 353505: "red", 999 | 353904: "gold", 1000 | 354364: "red", 1001 | 354673: "red", 1002 | 355131: "green", 1003 | 355548: "green", 1004 | 355916: "purple", 1005 | 356377: "green", 1006 | 356702: "red", 1007 | 357024: "purple", 1008 | 357429: "red", 1009 | 357871: "gold", 1010 | 358124: "purple", 1011 | 358533: "purple", 1012 | 358778: "gold", 1013 | 359096: "purple", 1014 | 359501: "purple", 1015 | 359892: "green", 1016 | 360194: "red", 1017 | 360611: "green", 1018 | 360895: "red", 1019 | 361200: "purple", 1020 | 361563: "green", 1021 | 361992: "green", 1022 | 362414: "gold", 1023 | 362702: "green", 1024 | 363083: "red", 1025 | 363505: "purple", 1026 | 363761: "green", 1027 | 364125: "green", 1028 | 364458: "purple", 1029 | 364881: "purple", 1030 | 365214: "green", 1031 | 365564: "gold", 1032 | 365992: "green", 1033 | 366338: "red", 1034 | 366794: "gold", 1035 | 367179: "green", 1036 | 367458: "green", 1037 | 367696: "gold", 1038 | 367970: "purple", 1039 | 368394: "purple", 1040 | 368816: "gold", 1041 | 369269: "green", 1042 | 369703: "green", 1043 | 370133: "red", 1044 | 370379: "gold", 1045 | 370723: "green", 1046 | 371043: "purple", 1047 | 371435: "green", 1048 | 371757: "green", 1049 | 372056: "red", 1050 | 372381: "red", 1051 | 372659: "green", 1052 | 373089: "purple", 1053 | 373463: "red", 1054 | 373767: "purple", 1055 | 374094: "green", 1056 | 374374: "gold", 1057 | 374838: "gold", 1058 | 375249: "green", 1059 | 375692: "red", 1060 | 376077: "red", 1061 | 376358: "red", 1062 | 376666: "green", 1063 | 376996: "green", 1064 | 377444: "red", 1065 | 377695: "red", 1066 | 377973: "purple", 1067 | 378335: "green", 1068 | 378748: "green", 1069 | 379170: "red", 1070 | 379570: "gold", 1071 | 380036: "gold", 1072 | 380385: "green", 1073 | 380719: "red", 1074 | 381023: "purple", 1075 | 381464: "gold", 1076 | 381781: "red", 1077 | 382247: "green", 1078 | 382612: "red", 1079 | 382896: "purple", 1080 | 383223: "purple", 1081 | 383535: "purple", 1082 | 383902: "purple", 1083 | 384284: "green", 1084 | 384741: "red", 1085 | 385082: "red", 1086 | 385350: "purple", 1087 | 385755: "red", 1088 | 386197: "purple", 1089 | 386651: "purple", 1090 | 386941: "gold", 1091 | 387365: "purple", 1092 | 387821: "gold", 1093 | 388084: "gold", 1094 | 388370: "gold", 1095 | 388710: "gold", 1096 | 389165: "green", 1097 | 389540: "red", 1098 | 390008: "gold", 1099 | 390475: "green", 1100 | 390857: "purple", 1101 | 391187: "red", 1102 | 391587: "purple", 1103 | 391831: "gold", 1104 | 392078: "red", 1105 | 392530: "gold", 1106 | 392936: "gold", 1107 | 393282: "purple", 1108 | 393571: "gold", 1109 | 393965: "green", 1110 | 394227: "purple", 1111 | 394628: "gold", 1112 | 394961: "purple", 1113 | 395259: "red", 1114 | 395677: "green", 1115 | 395948: "green", 1116 | 396406: "red", 1117 | 396799: "gold", 1118 | 397166: "green", 1119 | 397458: "gold", 1120 | 397883: "gold", 1121 | 398243: "gold", 1122 | 398687: "green", 1123 | 399108: "purple", 1124 | 399457: "gold", 1125 | 399765: "green", 1126 | 400051: "gold", 1127 | 400380: "red", 1128 | 400851: "purple", 1129 | 401173: "green", 1130 | 401619: "red", 1131 | 402014: "gold", 1132 | 402403: "gold", 1133 | 402667: "purple", 1134 | 403064: "purple", 1135 | 403454: "gold", 1136 | 403893: "green", 1137 | 404137: "green", 1138 | 404513: "red", 1139 | 404794: "red", 1140 | 405153: "red", 1141 | 405426: "gold", 1142 | 405773: "green", 1143 | 406081: "gold", 1144 | 406376: "green", 1145 | 406676: "green", 1146 | 407097: "purple", 1147 | 407523: "green", 1148 | 407915: "red", 1149 | 408253: "red", 1150 | 408572: "purple", 1151 | 409013: "red", 1152 | 409450: "green", 1153 | 409806: "purple", 1154 | 410261: "gold", 1155 | 410586: "purple", 1156 | 411038: "purple", 1157 | 411405: "red", 1158 | 411735: "red", 1159 | 412094: "red", 1160 | 412450: "gold", 1161 | 412874: "purple", 1162 | 413260: "purple", 1163 | 413582: "purple", 1164 | 413950: "purple", 1165 | 414331: "gold", 1166 | 414606: "green", 1167 | 414977: "purple", 1168 | 415376: "gold", 1169 | 415838: "green", 1170 | 416183: "red", 1171 | 416514: "green", 1172 | 416982: "gold", 1173 | 417339: "gold", 1174 | 417782: "red", 1175 | 418104: "green", 1176 | 418442: "gold", 1177 | 418724: "green", 1178 | 419161: "red", 1179 | 419611: "red", 1180 | 419904: "green", 1181 | 420233: "red", 1182 | 420490: "gold", 1183 | 420731: "purple", 1184 | 420994: "purple", 1185 | 421358: "purple", 1186 | 421758: "gold", 1187 | 422023: "red", 1188 | 422387: "red", 1189 | 422661: "green", 1190 | 423008: "gold", 1191 | 423291: "green", 1192 | 423561: "gold", 1193 | 423994: "purple", 1194 | 424250: "green", 1195 | 424559: "gold", 1196 | 424860: "red", 1197 | 425290: "red", 1198 | 425635: "gold", 1199 | 426041: "purple", 1200 | 426459: "red", 1201 | 426757: "gold", 1202 | 427087: "red", 1203 | 427537: "purple", 1204 | 427958: "gold", 1205 | 428322: "green", 1206 | 428727: "green", 1207 | 429049: "green", 1208 | 429389: "green", 1209 | 429861: "green", 1210 | 430258: "red", 1211 | 430592: "red", 1212 | 430888: "green", 1213 | 431231: "gold", 1214 | 431579: "gold", 1215 | 431909: "purple", 1216 | 432332: "purple", 1217 | 432623: "gold", 1218 | 432887: "gold", 1219 | 433301: "green", 1220 | 433647: "purple", 1221 | 433971: "purple", 1222 | 434256: "green", 1223 | 434716: "purple", 1224 | 434978: "green", 1225 | 435258: "gold", 1226 | 435613: "green", 1227 | 435998: "purple", 1228 | 436463: "purple", 1229 | 436830: "green", 1230 | 437212: "gold", 1231 | 437647: "purple", 1232 | 438022: "green", 1233 | 438321: "red", 1234 | 438740: "purple", 1235 | 439181: "purple", 1236 | 439476: "purple", 1237 | 439893: "gold", 1238 | 440311: "gold", 1239 | 440626: "red", 1240 | 440909: "red", 1241 | 441245: "green", 1242 | 441544: "red", 1243 | 441839: "purple", 1244 | 442094: "green", 1245 | 442420: "purple", 1246 | 442669: "red", 1247 | 442978: "red", 1248 | 443309: "red", 1249 | 443553: "gold", 1250 | 443864: "purple", 1251 | 444144: "green", 1252 | 444520: "red", 1253 | 444783: "purple", 1254 | 445208: "gold", 1255 | 445527: "red", 1256 | 445970: "purple", 1257 | 446333: "gold", 1258 | 446683: "green", 1259 | 446934: "purple", 1260 | 447235: "purple", 1261 | 447509: "purple", 1262 | 447815: "gold", 1263 | 448221: "purple", 1264 | 448535: "red", 1265 | 448859: "purple", 1266 | 449189: "purple", 1267 | 449505: "gold", 1268 | 449776: "purple", 1269 | 450237: "green", 1270 | 450582: "gold", 1271 | 450828: "red", 1272 | 451198: "red", 1273 | 451477: "purple", 1274 | 451809: "gold", 1275 | 452153: "green", 1276 | 452610: "gold", 1277 | 452942: "purple", 1278 | 453393: "purple", 1279 | 453740: "purple", 1280 | 454135: "red", 1281 | 454463: "red", 1282 | 454737: "purple", 1283 | 455069: "purple", 1284 | 455533: "purple", 1285 | 455964: "green", 1286 | 456238: "green", 1287 | 456544: "green", 1288 | 456852: "red", 1289 | 457286: "purple", 1290 | 457591: "gold", 1291 | 457934: "gold", 1292 | 458200: "green", 1293 | 458483: "green", 1294 | 458860: "purple", 1295 | 459155: "gold", 1296 | 459445: "purple", 1297 | 459790: "gold", 1298 | 460095: "gold", 1299 | 460556: "gold", 1300 | 460803: "purple", 1301 | 461138: "red", 1302 | 461567: "red", 1303 | 461969: "purple", 1304 | 462358: "green", 1305 | 462602: "purple", 1306 | 462902: "red", 1307 | 463227: "gold", 1308 | 463658: "gold", 1309 | 463928: "gold", 1310 | 464238: "gold", 1311 | 464661: "green", 1312 | 465039: "gold", 1313 | 465380: "purple", 1314 | 465639: "purple", 1315 | 465918: "gold", 1316 | 466207: "gold", 1317 | 466561: "purple", 1318 | 466984: "purple", 1319 | 467379: "green", 1320 | 467674: "purple", 1321 | 468140: "gold", 1322 | 468553: "green", 1323 | 468919: "purple", 1324 | 469196: "gold", 1325 | 469535: "green", 1326 | 469902: "purple", 1327 | 470249: "green", 1328 | 470641: "gold", 1329 | 471032: "purple", 1330 | 471317: "red", 1331 | 471681: "red", 1332 | 471940: "gold", 1333 | 472275: "purple", 1334 | 472572: "gold", 1335 | 472858: "gold", 1336 | 473247: "purple", 1337 | 473546: "red", 1338 | 473973: "purple", 1339 | 474397: "purple", 1340 | 474698: "purple", 1341 | 474993: "purple", 1342 | 475274: "red", 1343 | 475647: "gold", 1344 | 476049: "gold", 1345 | 476404: "gold", 1346 | 476861: "purple", 1347 | 477242: "green", 1348 | 477640: "purple", 1349 | 478005: "purple", 1350 | 478428: "purple", 1351 | 478765: "red", 1352 | 479136: "gold", 1353 | 479526: "purple", 1354 | 479855: "purple", 1355 | 480245: "green", 1356 | 480558: "purple", 1357 | 481015: "purple", 1358 | 481343: "red", 1359 | 481657: "purple", 1360 | 481971: "red", 1361 | 482394: "green", 1362 | 482773: "green", 1363 | 483139: "red", 1364 | 483607: "gold", 1365 | 483884: "red", 1366 | 484158: "green", 1367 | 484574: "gold", 1368 | 485030: "purple", 1369 | 485378: "red", 1370 | 485849: "red", 1371 | 486086: "gold", 1372 | 486446: "gold", 1373 | 486758: "purple", 1374 | 487031: "red", 1375 | 487384: "red", 1376 | 487782: "purple", 1377 | 488101: "purple", 1378 | 488481: "green", 1379 | 488730: "gold", 1380 | 489165: "gold", 1381 | 489525: "gold", 1382 | 489811: "gold", 1383 | 490270: "purple", 1384 | 490538: "green", 1385 | 490907: "purple", 1386 | 491324: "gold", 1387 | 491721: "green", 1388 | 492188: "gold", 1389 | 492580: "purple", 1390 | 492879: "gold", 1391 | 493282: "purple", 1392 | 493686: "purple", 1393 | 494120: "purple", 1394 | 494366: "purple", 1395 | 494733: "red", 1396 | 495091: "purple", 1397 | 495459: "red", 1398 | 495858: "gold", 1399 | 496116: "purple", 1400 | 496500: "red", 1401 | 496902: "red", 1402 | 497311: "green", 1403 | 497632: "gold", 1404 | 498056: "red", 1405 | 498443: "purple", 1406 | 498833: "red", 1407 | 499122: "green", 1408 | 499378: "purple", 1409 | 499755: "green", 1410 | 500065: "green", 1411 | 500303: "green", 1412 | 500596: "gold", 1413 | 500919: "red", 1414 | 501304: "red", 1415 | 501741: "purple", 1416 | 501991: "red", 1417 | 502409: "purple", 1418 | 502835: "red", 1419 | 503262: "gold", 1420 | 503559: "green", 1421 | 504026: "green", 1422 | 504354: "green", 1423 | 504688: "gold", 1424 | 505147: "gold", 1425 | 505440: "gold", 1426 | 505720: "purple", 1427 | 505974: "green", 1428 | 506230: "gold", 1429 | 506563: "gold", 1430 | 506837: "green", 1431 | 507120: "purple", 1432 | 507438: "red", 1433 | 507719: "green", 1434 | 508036: "red", 1435 | 508281: "gold", 1436 | 508537: "gold", 1437 | 508859: "red", 1438 | 509176: "gold", 1439 | 509619: "gold", 1440 | 509898: "red", 1441 | 510302: "red", 1442 | 510760: "green", 1443 | 511114: "green", 1444 | 511426: "purple", 1445 | 511761: "red", 1446 | 512112: "gold", 1447 | 512501: "red", 1448 | 512925: "gold", 1449 | 513332: "green", 1450 | 513586: "green", 1451 | 513866: "green", 1452 | 514299: "green", 1453 | 514672: "purple", 1454 | 514923: "red", 1455 | 515272: "purple", 1456 | 515548: "gold", 1457 | 515862: "red", 1458 | 516208: "gold", 1459 | 516616: "red", 1460 | 517002: "purple", 1461 | 517404: "red", 1462 | 517753: "green", 1463 | 518203: "red", 1464 | 518674: "purple", 1465 | 518938: "gold", 1466 | 519375: "red", 1467 | 519763: "green", 1468 | 520020: "gold", 1469 | 520347: "red", 1470 | 520718: "green", 1471 | 521034: "red", 1472 | 521442: "purple", 1473 | 521895: "red", 1474 | 522252: "gold", 1475 | 522674: "purple", 1476 | 522936: "purple", 1477 | 523342: "red", 1478 | 523817: "red", 1479 | 524105: "gold", 1480 | 524570: "purple", 1481 | 524973: "red", 1482 | 525282: "green", 1483 | 525648: "green", 1484 | 525989: "gold", 1485 | 526255: "red", 1486 | 526715: "green", 1487 | 527018: "gold", 1488 | 527314: "purple", 1489 | 527586: "green", 1490 | 527866: "green", 1491 | 528124: "purple", 1492 | 528406: "green", 1493 | 528859: "purple", 1494 | 529134: "green", 1495 | 529527: "gold", 1496 | 529810: "purple", 1497 | 530213: "red", 1498 | 530548: "purple", 1499 | 530843: "gold", 1500 | 531123: "purple", 1501 | 531473: "purple", 1502 | 531915: "gold", 1503 | 532356: "green", 1504 | 532829: "green", 1505 | 533170: "purple", 1506 | 533477: "red", 1507 | 533778: "gold", 1508 | 534240: "green", 1509 | 534647: "purple", 1510 | 534937: "gold", 1511 | 535392: "red", 1512 | 535743: "green", 1513 | 536207: "purple", 1514 | 536537: "green", 1515 | 536810: "green", 1516 | 537171: "green", 1517 | 537451: "red", 1518 | 537743: "green", 1519 | 538053: "red", 1520 | 538439: "red", 1521 | 538756: "gold", 1522 | 539023: "red", 1523 | 539270: "purple", 1524 | 539554: "red", 1525 | 539914: "gold", 1526 | 540220: "purple", 1527 | 540528: "red", 1528 | 540792: "red", 1529 | 541202: "green", 1530 | 541578: "gold", 1531 | 541879: "red", 1532 | 542314: "gold", 1533 | 542607: "purple", 1534 | 542875: "green", 1535 | 543257: "gold", 1536 | 543715: "green", 1537 | 543983: "red", 1538 | 544360: "red", 1539 | 544697: "red", 1540 | 544973: "green", 1541 | 545358: "gold", 1542 | 545714: "purple", 1543 | 545979: "green", 1544 | 546244: "green", 1545 | 546571: "purple", 1546 | 547001: "purple", 1547 | 547473: "green", 1548 | 547751: "green", 1549 | 548165: "purple", 1550 | 548500: "green", 1551 | 548909: "red", 1552 | 549205: "gold", 1553 | 549566: "gold", 1554 | 550020: "red", 1555 | 550371: "purple", 1556 | 550728: "purple", 1557 | 551029: "green", 1558 | 551408: "red", 1559 | 551805: "green", 1560 | 552257: "red", 1561 | 552705: "red", 1562 | 552969: "gold", 1563 | 553280: "red", 1564 | 553602: "green", 1565 | 553912: "purple", 1566 | 554329: "green", 1567 | 554687: "gold", 1568 | 555011: "green", 1569 | 555368: "green", 1570 | 555834: "gold", 1571 | 556104: "gold", 1572 | 556385: "gold", 1573 | 556827: "purple", 1574 | 557235: "red", 1575 | 557662: "green", 1576 | 557903: "gold", 1577 | 558295: "gold", 1578 | 558589: "purple", 1579 | 559019: "gold", 1580 | 559489: "red", 1581 | 559933: "red", 1582 | 560316: "red", 1583 | 560717: "green", 1584 | 560969: "green", 1585 | 561341: "green", 1586 | 561680: "gold", 1587 | 562033: "gold", 1588 | 562502: "purple", 1589 | 562789: "red", 1590 | 563106: "purple", 1591 | 563464: "red", 1592 | 563764: "green", 1593 | 564217: "green", 1594 | 564512: "red", 1595 | 564918: "red", 1596 | 565157: "gold", 1597 | 565436: "purple", 1598 | 565805: "green", 1599 | 566280: "green", 1600 | 566520: "green", 1601 | 566923: "gold", 1602 | 567324: "gold", 1603 | 567621: "red", 1604 | 567938: "green", 1605 | 568347: "purple", 1606 | 568677: "green", 1607 | 569114: "purple", 1608 | 569544: "gold", 1609 | 569871: "purple", 1610 | 570334: "purple", 1611 | 570635: "green", 1612 | 570952: "gold", 1613 | 571375: "green", 1614 | 571777: "red", 1615 | 572051: "gold", 1616 | 572347: "purple", 1617 | 572597: "green", 1618 | 572835: "purple", 1619 | 573258: "purple", 1620 | 573586: "red", 1621 | 574001: "gold", 1622 | 574452: "purple", 1623 | 574857: "red", 1624 | 575166: "gold", 1625 | 575445: "green", 1626 | 575828: "red", 1627 | 576205: "green", 1628 | 576517: "purple", 1629 | 576879: "gold", 1630 | 577347: "purple", 1631 | 577810: "green", 1632 | 578188: "gold", 1633 | 578634: "purple", 1634 | 579011: "red", 1635 | 579485: "purple", 1636 | 579759: "green", 1637 | 580199: "purple", 1638 | 580566: "purple", 1639 | 580907: "purple", 1640 | 581293: "green", 1641 | 581635: "purple", 1642 | 582026: "purple", 1643 | 582495: "gold", 1644 | 582770: "red", 1645 | 583041: "purple", 1646 | 583442: "purple", 1647 | 583801: "green", 1648 | 584112: "red", 1649 | 584390: "purple", 1650 | 584701: "gold", 1651 | 584949: "purple", 1652 | 585309: "green", 1653 | 585592: "gold", 1654 | 585895: "gold", 1655 | 586358: "purple", 1656 | 586645: "gold", 1657 | 586906: "red", 1658 | 587145: "green", 1659 | 587504: "purple", 1660 | 587920: "purple", 1661 | 588290: "green", 1662 | 588696: "green", 1663 | 588997: "purple", 1664 | 589238: "purple", 1665 | 589608: "green", 1666 | 589918: "purple", 1667 | 590349: "gold", 1668 | 590658: "red", 1669 | 591060: "purple", 1670 | 591376: "gold", 1671 | 591686: "red", 1672 | 591978: "red", 1673 | 592334: "purple", 1674 | 592629: "purple", 1675 | 592882: "gold", 1676 | 593153: "red", 1677 | 593449: "green", 1678 | 593740: "green", 1679 | 594142: "green", 1680 | 594486: "red", 1681 | 594842: "purple", 1682 | 595219: "red", 1683 | 595560: "green", 1684 | 595936: "purple", 1685 | 596256: "purple", 1686 | 596621: "purple", 1687 | 596938: "green", 1688 | 597348: "purple", 1689 | 597808: "purple", 1690 | 598142: "gold", 1691 | 598473: "gold", 1692 | 598787: "green", 1693 | 599064: "green", 1694 | 599503: "purple", 1695 | 599830: "purple", 1696 | 600072: "green", 1697 | 600547: "green", 1698 | 600896: "red", 1699 | 601324: "green", 1700 | 601773: "green", 1701 | 602016: "gold", 1702 | 602292: "gold", 1703 | 602748: "gold", 1704 | 603036: "red", 1705 | 603352: "gold", 1706 | 603613: "purple", 1707 | 603912: "gold", 1708 | 604269: "purple", 1709 | 604686: "gold", 1710 | 605157: "gold", 1711 | 605442: "red", 1712 | 605805: "red", 1713 | 606243: "gold", 1714 | 606693: "green", 1715 | 606953: "red", 1716 | 607306: "gold", 1717 | 607768: "green", 1718 | 608167: "purple", 1719 | 608553: "purple", 1720 | 608792: "purple", 1721 | 609077: "red", 1722 | 609339: "red", 1723 | 609756: "green", 1724 | 610096: "green", 1725 | 610564: "gold", 1726 | 610946: "gold", 1727 | 611376: "red", 1728 | 611638: "purple", 1729 | 611942: "red", 1730 | 612183: "green", 1731 | 612658: "green", 1732 | 612929: "purple", 1733 | 613232: "green", 1734 | 613590: "green", 1735 | 613956: "purple", 1736 | 614223: "red", 1737 | 614464: "green", 1738 | 614814: "green", 1739 | 615193: "purple", 1740 | 615515: "red", 1741 | 615941: "gold", 1742 | 616268: "gold", 1743 | 616632: "red", 1744 | 616934: "gold", 1745 | 617288: "purple", 1746 | 617760: "purple", 1747 | 618130: "green", 1748 | 618545: "purple", 1749 | 618808: "purple", 1750 | 619197: "purple", 1751 | 619543: "red", 1752 | 619965: "green", 1753 | 620222: "gold", 1754 | 620587: "green", 1755 | 620936: "purple", 1756 | 621262: "red", 1757 | 621502: "gold", 1758 | 621820: "red", 1759 | 622270: "purple", 1760 | 622530: "red", 1761 | 622882: "purple", 1762 | 623191: "purple", 1763 | 623520: "green", 1764 | 623815: "purple", 1765 | 624176: "gold", 1766 | 624565: "red", 1767 | 624992: "red", 1768 | 625373: "green", 1769 | 625658: "purple", 1770 | 626050: "green", 1771 | 626510: "red", 1772 | 626843: "purple", 1773 | 627262: "red", 1774 | 627501: "red", 1775 | 627896: "gold", 1776 | 628176: "red", 1777 | 628529: "green", 1778 | 628951: "purple", 1779 | 629262: "red", 1780 | 629545: "red", 1781 | 629878: "green", 1782 | 630192: "gold", 1783 | 630525: "purple", 1784 | 630909: "green", 1785 | 631349: "green", 1786 | 631620: "gold", 1787 | 632085: "purple", 1788 | 632380: "purple", 1789 | 632624: "purple", 1790 | 633044: "gold", 1791 | 633488: "red", 1792 | 633907: "green", 1793 | 634153: "purple", 1794 | 634570: "red", 1795 | 634911: "red", 1796 | 635372: "gold", 1797 | 635687: "green", 1798 | 636149: "purple", 1799 | 636449: "green", 1800 | 636728: "purple", 1801 | 637099: "red", 1802 | 637486: "red", 1803 | 637771: "red", 1804 | 638196: "gold", 1805 | 638479: "red", 1806 | 638848: "green", 1807 | 639125: "red", 1808 | 639376: "red", 1809 | 639716: "purple", 1810 | 640097: "purple", 1811 | 640339: "green", 1812 | 640693: "gold", 1813 | 640987: "gold", 1814 | 641416: "green", 1815 | 641757: "purple", 1816 | 642015: "red", 1817 | 642294: "red", 1818 | 642570: "green", 1819 | 642896: "green", 1820 | 643337: "purple", 1821 | 643688: "red", 1822 | 644056: "purple", 1823 | 644448: "green", 1824 | 644739: "purple", 1825 | 645185: "red", 1826 | 645548: "red", 1827 | 645991: "gold", 1828 | 646342: "red", 1829 | 646725: "green", 1830 | 647165: "green", 1831 | 647557: "gold", 1832 | 647903: "red", 1833 | 648201: "green", 1834 | 648468: "purple", 1835 | 648816: "red", 1836 | 649285: "gold", 1837 | 649549: "red", 1838 | 649900: "purple", 1839 | 650353: "purple", 1840 | 650710: "red", 1841 | 651164: "red", 1842 | 651558: "green", 1843 | 651841: "purple", 1844 | 652286: "purple", 1845 | 652673: "green", 1846 | 653059: "green", 1847 | 653301: "green", 1848 | 653614: "gold", 1849 | 653857: "green", 1850 | 654209: "purple", 1851 | 654671: "purple", 1852 | 655104: "red", 1853 | 655479: "purple", 1854 | 655717: "green", 1855 | 656009: "red", 1856 | 656268: "red", 1857 | 656620: "green", 1858 | 657049: "red", 1859 | 657397: "red", 1860 | 657708: "gold", 1861 | 658023: "purple", 1862 | 658479: "green", 1863 | 658824: "purple", 1864 | 659069: "green", 1865 | 659518: "gold", 1866 | 659895: "green", 1867 | 660227: "red", 1868 | 660678: "green", 1869 | 660942: "gold", 1870 | 661359: "red", 1871 | 661779: "red", 1872 | 662169: "red", 1873 | 662641: "gold", 1874 | 663064: "red", 1875 | 663311: "green", 1876 | 663625: "red", 1877 | 663879: "purple", 1878 | 664131: "green", 1879 | 664408: "red", 1880 | 664742: "red", 1881 | 664981: "gold", 1882 | 665260: "purple", 1883 | 665690: "green", 1884 | 666049: "gold", 1885 | 666339: "gold", 1886 | 666773: "red", 1887 | 667168: "green", 1888 | 667642: "red", 1889 | 668058: "gold", 1890 | 668502: "purple", 1891 | 668811: "purple", 1892 | 669229: "gold", 1893 | 669703: "purple", 1894 | 669985: "red", 1895 | 670309: "green", 1896 | 670692: "green", 1897 | 670999: "green", 1898 | 671436: "purple", 1899 | 671860: "gold", 1900 | 672198: "green", 1901 | 672602: "purple", 1902 | 672911: "gold", 1903 | 673328: "green", 1904 | 673594: "gold", 1905 | 673835: "green", 1906 | 674246: "red", 1907 | 674703: "green", 1908 | 675013: "gold", 1909 | 675330: "purple", 1910 | 675632: "green", 1911 | 675983: "purple", 1912 | 676324: "purple", 1913 | 676700: "red", 1914 | 676961: "gold", 1915 | 677206: "red", 1916 | 677654: "gold", 1917 | 678025: "purple", 1918 | 678337: "green", 1919 | 678585: "red", 1920 | 678991: "purple", 1921 | 679263: "gold", 1922 | 679715: "green", 1923 | 680081: "gold", 1924 | 680490: "red", 1925 | 680739: "red", 1926 | 681167: "purple", 1927 | 681619: "green", 1928 | 682044: "purple", 1929 | 682341: "gold", 1930 | 682653: "green", 1931 | 683128: "gold", 1932 | 683537: "purple", 1933 | 683978: "green", 1934 | 684222: "purple", 1935 | 684592: "red", 1936 | 684935: "purple", 1937 | 685365: "red", 1938 | 685767: "green", 1939 | 686027: "purple", 1940 | 686438: "red", 1941 | 686717: "red", 1942 | 687133: "gold", 1943 | 687446: "gold", 1944 | 687879: "purple", 1945 | 688346: "green", 1946 | 688618: "gold", 1947 | 689049: "gold", 1948 | 689462: "gold", 1949 | 689914: "gold", 1950 | 690195: "red", 1951 | 690470: "purple", 1952 | 690806: "gold", 1953 | 691066: "purple", 1954 | 691317: "purple", 1955 | 691569: "red", 1956 | 691893: "green", 1957 | 692355: "green", 1958 | 692678: "green", 1959 | 693091: "green", 1960 | 693501: "red", 1961 | 693791: "gold", 1962 | 694123: "purple", 1963 | 694534: "red", 1964 | 694794: "red", 1965 | 695221: "green", 1966 | 695458: "purple", 1967 | 695721: "purple", 1968 | 696026: "green", 1969 | 696441: "purple", 1970 | 696848: "purple", 1971 | 697316: "green", 1972 | 697712: "gold", 1973 | 697988: "red", 1974 | 698227: "purple", 1975 | 698680: "red", 1976 | 699113: "gold", 1977 | 699565: "purple", 1978 | 699904: "purple", 1979 | 700193: "green", 1980 | 700599: "purple", 1981 | 700963: "gold", 1982 | 701203: "red", 1983 | 701644: "purple", 1984 | 702082: "gold", 1985 | 702343: "green", 1986 | 702803: "red", 1987 | 703241: "gold", 1988 | 703697: "red", 1989 | 704139: "red", 1990 | 704450: "red", 1991 | 704845: "purple", 1992 | 705289: "gold", 1993 | 705608: "green", 1994 | 705918: "red", 1995 | 706319: "purple", 1996 | 706560: "gold", 1997 | 706923: "purple", 1998 | 707248: "purple", 1999 | 707722: "purple", 2000 | 708050: "gold", 2001 | 708459: "purple", 2002 | 708761: "red", 2003 | 709175: "gold", 2004 | 709603: "gold", 2005 | 709918: "gold", 2006 | 710245: "gold", 2007 | 710641: "green", 2008 | 711023: "gold", 2009 | 711437: "red", 2010 | 711848: "gold", 2011 | 712092: "red", 2012 | 712341: "gold", 2013 | 712645: "red", 2014 | 713101: "red", 2015 | 713528: "gold", 2016 | 713974: "purple", 2017 | 714367: "red", 2018 | 714781: "green", 2019 | 715171: "gold", 2020 | 715585: "purple", 2021 | 715971: "purple", 2022 | 716438: "purple", 2023 | 716697: "red", 2024 | 717064: "purple", 2025 | 717439: "green", 2026 | 717789: "red", 2027 | 718218: "gold", 2028 | 718692: "gold", 2029 | 719124: "purple", 2030 | 719550: "red", 2031 | 719947: "green", 2032 | 720319: "gold", 2033 | 720581: "red", 2034 | 721005: "green", 2035 | 721247: "gold", 2036 | 721621: "purple", 2037 | 721914: "red", 2038 | 722337: "gold", 2039 | 722698: "green", 2040 | 723055: "purple", 2041 | 723474: "purple", 2042 | 723882: "red", 2043 | 724251: "red", 2044 | 724625: "purple", 2045 | 724928: "green", 2046 | 725247: "gold", 2047 | 725611: "red", 2048 | 726059: "gold", 2049 | 726301: "green", 2050 | 726678: "gold", 2051 | 726964: "red", 2052 | 727205: "gold", 2053 | 727454: "gold", 2054 | 727915: "gold", 2055 | 728330: "red", 2056 | 728646: "gold", 2057 | 729036: "purple", 2058 | 729294: "green", 2059 | 729588: "red", 2060 | 730050: "gold", 2061 | 730290: "gold", 2062 | 730644: "green", 2063 | 730940: "green", 2064 | 731319: "green", 2065 | 731556: "red", 2066 | 731941: "green", 2067 | 732199: "gold", 2068 | 732554: "green", 2069 | 733021: "red", 2070 | 733286: "red", 2071 | 733600: "red", 2072 | 733928: "gold", 2073 | 734294: "purple", 2074 | 734769: "purple", 2075 | 735103: "purple", 2076 | 735530: "gold", 2077 | 735849: "red", 2078 | 736212: "gold", 2079 | 736452: "purple", 2080 | 736744: "purple", 2081 | 736993: "purple", 2082 | 737367: "purple", 2083 | 737777: "purple", 2084 | 738189: "green", 2085 | 738463: "green", 2086 | 738906: "purple", 2087 | 739347: "red", 2088 | 739705: "purple", 2089 | 740159: "gold", 2090 | 740619: "purple", 2091 | 740935: "green", 2092 | 741398: "red", 2093 | 741642: "gold", 2094 | 741927: "green", 2095 | 742222: "gold", 2096 | 742689: "purple", 2097 | 742948: "green", 2098 | 743313: "purple", 2099 | 743742: "red", 2100 | 744049: "purple", 2101 | 744368: "green", 2102 | 744662: "green", 2103 | 744904: "green", 2104 | 745159: "red", 2105 | 745597: "red", 2106 | 745871: "gold", 2107 | 746277: "green", 2108 | 746645: "purple", 2109 | 746924: "gold", 2110 | 747226: "red", 2111 | 747478: "gold", 2112 | 747763: "purple", 2113 | 748157: "red", 2114 | 748500: "purple", 2115 | 748851: "gold", 2116 | 749276: "red", 2117 | 749662: "red", 2118 | 749972: "purple", 2119 | 750227: "green", 2120 | 750555: "red", 2121 | 750992: "green", 2122 | 751423: "purple", 2123 | 751821: "gold", 2124 | 752181: "green", 2125 | 752607: "green", 2126 | 752889: "green", 2127 | 753312: "gold", 2128 | 753760: "green", 2129 | 754072: "red", 2130 | 754481: "gold", 2131 | 754731: "red", 2132 | 755063: "red", 2133 | 755303: "red", 2134 | 755669: "purple", 2135 | 755911: "red", 2136 | 756299: "purple", 2137 | 756714: "red", 2138 | 757066: "gold", 2139 | 757510: "red", 2140 | 757975: "green", 2141 | 758273: "purple", 2142 | 758577: "purple", 2143 | 758873: "gold", 2144 | 759306: "green", 2145 | 759762: "red", 2146 | 760170: "red", 2147 | 760510: "purple", 2148 | 760939: "red", 2149 | 761385: "green", 2150 | 761681: "red", 2151 | 762112: "red", 2152 | 762547: "green", 2153 | 763001: "green", 2154 | 763374: "gold", 2155 | 763792: "green", 2156 | 764109: "gold", 2157 | 764555: "gold", 2158 | 764983: "gold", 2159 | 765371: "red", 2160 | 765677: "purple", 2161 | 766022: "red", 2162 | 766379: "gold", 2163 | 766735: "purple", 2164 | 766995: "green", 2165 | 767465: "green", 2166 | 767869: "gold", 2167 | 768342: "purple", 2168 | 768739: "red", 2169 | 768977: "gold", 2170 | 769252: "gold", 2171 | 769531: "gold", 2172 | 769931: "red", 2173 | 770322: "red", 2174 | 770598: "green", 2175 | 770950: "green", 2176 | 771187: "green", 2177 | 771651: "red", 2178 | 772080: "purple", 2179 | 772331: "purple", 2180 | 772700: "gold", 2181 | 773081: "gold", 2182 | 773358: "gold", 2183 | 773683: "green", 2184 | 774102: "purple", 2185 | 774486: "purple", 2186 | 774934: "green", 2187 | 775372: "red", 2188 | 775784: "gold", 2189 | 776164: "red", 2190 | 776408: "purple", 2191 | 776716: "gold", 2192 | 777016: "red", 2193 | 777459: "green", 2194 | 777734: "purple", 2195 | 778126: "purple", 2196 | 778531: "gold", 2197 | 779004: "green", 2198 | 779416: "purple", 2199 | 779869: "gold", 2200 | 780244: "red", 2201 | 780534: "green", 2202 | 780968: "gold", 2203 | 781338: "purple", 2204 | 781768: "green", 2205 | 782098: "green", 2206 | 782531: "purple", 2207 | 782890: "green", 2208 | 783351: "gold", 2209 | 783780: "purple", 2210 | 784240: "green", 2211 | 784524: "green", 2212 | 784932: "green", 2213 | 785212: "purple", 2214 | 785552: "red", 2215 | 785874: "red", 2216 | 786338: "green", 2217 | 786660: "purple", 2218 | 786957: "red", 2219 | 787324: "red", 2220 | 787732: "gold", 2221 | 788015: "green", 2222 | 788417: "green", 2223 | 788663: "gold", 2224 | 789059: "purple", 2225 | 789513: "gold", 2226 | 789760: "red", 2227 | 790191: "purple", 2228 | 790501: "green", 2229 | 790964: "purple", 2230 | 791210: "red", 2231 | 791607: "red", 2232 | 791932: "purple", 2233 | 792291: "gold", 2234 | 792564: "green", 2235 | 792820: "red", 2236 | 793268: "red", 2237 | 793633: "red", 2238 | 793933: "gold", 2239 | 794183: "purple", 2240 | 794511: "purple", 2241 | 794932: "purple", 2242 | 795353: "gold", 2243 | 795725: "gold", 2244 | 796009: "gold", 2245 | 796456: "purple", 2246 | 796843: "purple", 2247 | 797129: "green", 2248 | 797443: "green", 2249 | 797722: "green", 2250 | 797999: "green", 2251 | 798302: "green", 2252 | 798721: "red", 2253 | 799119: "red", 2254 | 799501: "purple", 2255 | 799844: "gold", 2256 | 800214: "red", 2257 | 800629: "red", 2258 | 800958: "purple", 2259 | 801238: "red", 2260 | 801590: "green", 2261 | 801984: "green", 2262 | 802451: "red", 2263 | 802802: "red", 2264 | 803195: "purple", 2265 | 803528: "green", 2266 | 803983: "purple", 2267 | 804221: "green", 2268 | 804479: "gold", 2269 | 804852: "red", 2270 | 805157: "purple", 2271 | 805488: "purple", 2272 | 805901: "green", 2273 | 806141: "red", 2274 | 806420: "purple", 2275 | 806703: "green", 2276 | 806952: "gold", 2277 | 807263: "purple", 2278 | 807589: "red", 2279 | 807952: "gold", 2280 | 808274: "gold", 2281 | 808527: "green", 2282 | 808771: "green", 2283 | 809091: "green", 2284 | 809563: "green", 2285 | 809845: "green", 2286 | 810299: "gold", 2287 | 810669: "red", 2288 | 810943: "gold", 2289 | 811186: "green", 2290 | 811530: "gold", 2291 | 811829: "gold", 2292 | 812097: "red", 2293 | 812443: "gold", 2294 | 812867: "red", 2295 | 813266: "green", 2296 | 813677: "gold", 2297 | 813967: "green", 2298 | 814215: "green", 2299 | 814551: "green", 2300 | 814807: "green", 2301 | 815274: "green", 2302 | 815539: "gold", 2303 | 815860: "green", 2304 | 816248: "green", 2305 | 816561: "red", 2306 | 817018: "red", 2307 | 817308: "green", 2308 | 817617: "purple", 2309 | 817868: "green", 2310 | 818212: "gold", 2311 | 818683: "green", 2312 | 819092: "gold", 2313 | 819394: "purple", 2314 | 819796: "red", 2315 | 820233: "gold", 2316 | 820628: "gold", 2317 | 821048: "green", 2318 | 821335: "green", 2319 | 821640: "green", 2320 | 821891: "gold", 2321 | 822215: "red", 2322 | 822514: "purple", 2323 | 822929: "purple", 2324 | 823295: "red", 2325 | 823754: "green", 2326 | 824014: "purple", 2327 | 824424: "gold", 2328 | 824711: "purple", 2329 | 825130: "gold", 2330 | 825559: "purple", 2331 | 825962: "gold", 2332 | 826219: "gold", 2333 | 826552: "purple", 2334 | 826973: "green", 2335 | 827246: "red", 2336 | 827675: "green", 2337 | 827967: "green", 2338 | 828247: "green", 2339 | 828501: "red", 2340 | 828962: "gold", 2341 | 829359: "purple", 2342 | 829607: "gold", 2343 | 829974: "gold", 2344 | 830442: "purple", 2345 | 830877: "purple", 2346 | 831283: "red", 2347 | 831627: "red", 2348 | 832070: "red", 2349 | 832527: "green", 2350 | 832924: "green", 2351 | 833379: "gold", 2352 | 833760: "purple", 2353 | 834122: "purple", 2354 | 834406: "green", 2355 | 834670: "red", 2356 | 835014: "gold", 2357 | 835399: "purple", 2358 | 835661: "red", 2359 | 836068: "red", 2360 | 836466: "green", 2361 | 836911: "gold", 2362 | 837381: "green", 2363 | 837748: "gold", 2364 | 838176: "purple", 2365 | 838545: "gold", 2366 | 838878: "green", 2367 | 839192: "red", 2368 | 839452: "gold", 2369 | 839887: "purple", 2370 | 840268: "red", 2371 | 840735: "green", 2372 | 840996: "red", 2373 | 841471: "red", 2374 | 841719: "purple", 2375 | 842188: "gold", 2376 | 842434: "purple", 2377 | 842690: "gold", 2378 | 842996: "purple", 2379 | 843373: "gold", 2380 | 843670: "red", 2381 | 844131: "purple", 2382 | 844572: "gold", 2383 | 844826: "red", 2384 | 845277: "purple", 2385 | 845681: "gold", 2386 | 846125: "red", 2387 | 846378: "green", 2388 | 846773: "gold", 2389 | 847205: "purple", 2390 | 847669: "red", 2391 | 848130: "green", 2392 | 848498: "green", 2393 | 848761: "red", 2394 | 849128: "red", 2395 | 849456: "red", 2396 | 849697: "red", 2397 | 849955: "green", 2398 | 850345: "green", 2399 | 850584: "purple", 2400 | 850963: "red", 2401 | 851236: "red", 2402 | 851509: "red", 2403 | 851917: "purple", 2404 | 852384: "purple", 2405 | 852804: "red", 2406 | 853256: "red", 2407 | 853575: "green", 2408 | 853877: "red", 2409 | 854338: "green", 2410 | 854618: "red", 2411 | 855092: "gold", 2412 | 855333: "green", 2413 | 855799: "gold", 2414 | 856191: "purple", 2415 | 856665: "gold", 2416 | 856960: "red", 2417 | 857411: "green", 2418 | 857751: "green", 2419 | 858129: "gold", 2420 | 858543: "red", 2421 | 858818: "green", 2422 | 859074: "gold", 2423 | 859434: "red", 2424 | 859708: "red", 2425 | 860176: "red", 2426 | 860578: "gold", 2427 | 860888: "green", 2428 | 861339: "green", 2429 | 861737: "purple", 2430 | 862176: "purple", 2431 | 862622: "purple", 2432 | 863058: "red", 2433 | 863414: "green", 2434 | 863880: "red", 2435 | 864193: "purple", 2436 | 864449: "green", 2437 | 864859: "red", 2438 | 865170: "gold", 2439 | 865429: "gold", 2440 | 865852: "purple", 2441 | 866322: "green", 2442 | 866560: "purple", 2443 | 866916: "gold", 2444 | 867381: "red", 2445 | 867833: "green", 2446 | 868242: "gold", 2447 | 868663: "red", 2448 | 868991: "red", 2449 | 869365: "green", 2450 | 869723: "purple", 2451 | 870017: "purple", 2452 | 870295: "gold", 2453 | 870651: "red", 2454 | 870895: "gold", 2455 | 871261: "purple", 2456 | 871562: "gold", 2457 | 871967: "purple", 2458 | 872395: "purple", 2459 | 872763: "green", 2460 | 873060: "purple", 2461 | 873399: "purple", 2462 | 873656: "purple", 2463 | 873959: "gold", 2464 | 874258: "gold", 2465 | 874660: "red", 2466 | 875022: "green", 2467 | 875359: "purple", 2468 | 875665: "green", 2469 | 876099: "purple", 2470 | 876339: "gold", 2471 | 876793: "gold", 2472 | 877170: "red", 2473 | 877519: "green", 2474 | 877968: "red", 2475 | 878209: "gold", 2476 | 878606: "red", 2477 | 878873: "gold", 2478 | 879319: "red", 2479 | 879592: "purple", 2480 | 879838: "purple", 2481 | 880232: "red", 2482 | 880471: "red", 2483 | 880878: "purple", 2484 | 881273: "gold", 2485 | 881730: "green", 2486 | 882113: "gold", 2487 | 882362: "red", 2488 | 882815: "green", 2489 | 883279: "green", 2490 | 883524: "purple", 2491 | 883780: "red", 2492 | 884039: "gold", 2493 | 884509: "red", 2494 | 884877: "purple", 2495 | 885238: "red", 2496 | 885661: "gold", 2497 | 886042: "green", 2498 | 886406: "red", 2499 | 886805: "green", 2500 | 887067: "green", 2501 | 887378: "purple", 2502 | 887827: "purple", 2503 | 888201: "red", 2504 | 888620: "green", 2505 | 888867: "red", 2506 | 889148: "gold", 2507 | 889416: "gold", 2508 | 889775: "purple", 2509 | 890146: "purple", 2510 | 890505: "green", 2511 | 890766: "purple", 2512 | 891122: "purple", 2513 | 891393: "red", 2514 | 891710: "green", 2515 | 892040: "gold", 2516 | 892501: "red", 2517 | 892769: "red", 2518 | 893048: "red", 2519 | 893358: "green", 2520 | 893665: "green", 2521 | 894099: "gold", 2522 | 894433: "purple", 2523 | 894884: "green", 2524 | 895287: "purple", 2525 | 895691: "red", 2526 | 896081: "green", 2527 | 896464: "purple", 2528 | 896934: "red", 2529 | 897189: "green", 2530 | 897548: "green", 2531 | 897838: "purple", 2532 | 898295: "green", 2533 | 898573: "purple", 2534 | 898929: "gold", 2535 | 899375: "red", 2536 | 899847: "gold", 2537 | 900101: "gold", 2538 | 900364: "green", 2539 | 900624: "red", 2540 | 901046: "red", 2541 | 901318: "gold", 2542 | 901774: "purple", 2543 | 902121: "gold", 2544 | 902398: "green", 2545 | 902753: "green", 2546 | 903082: "gold", 2547 | 903486: "gold", 2548 | 903783: "green", 2549 | 904193: "red", 2550 | 904487: "green", 2551 | 904727: "purple", 2552 | 905190: "gold", 2553 | 905485: "purple", 2554 | 905907: "green", 2555 | 906219: "green", 2556 | 906633: "purple", 2557 | 906971: "gold", 2558 | 907366: "red", 2559 | 907781: "red", 2560 | 908160: "red", 2561 | 908448: "gold", 2562 | 908882: "gold", 2563 | 909343: "red", 2564 | 909674: "gold", 2565 | 910125: "green", 2566 | 910529: "green", 2567 | 910823: "purple", 2568 | 911183: "purple", 2569 | 911619: "gold", 2570 | 912080: "green", 2571 | 912449: "gold", 2572 | 912898: "gold", 2573 | 913250: "green", 2574 | 913557: "red", 2575 | 914024: "purple", 2576 | 914417: "purple", 2577 | 914726: "green", 2578 | 915098: "purple", 2579 | 915500: "gold", 2580 | 915752: "purple", 2581 | 916151: "purple", 2582 | 916432: "red", 2583 | 916803: "gold", 2584 | 917081: "gold", 2585 | 917483: "purple", 2586 | 917834: "green", 2587 | 918181: "gold", 2588 | 918598: "purple", 2589 | 918908: "green", 2590 | 919266: "red", 2591 | 919708: "green", 2592 | 919958: "purple", 2593 | 920389: "green", 2594 | 920699: "green", 2595 | 921109: "gold", 2596 | 921519: "red", 2597 | 921926: "green", 2598 | 922282: "green", 2599 | 922623: "gold", 2600 | 923020: "purple", 2601 | 923485: "purple", 2602 | 923908: "red", 2603 | 924370: "red", 2604 | 924729: "gold", 2605 | 925033: "gold", 2606 | 925309: "green", 2607 | 925729: "red", 2608 | 926111: "purple", 2609 | 926573: "green", 2610 | 926934: "green", 2611 | 927193: "gold", 2612 | 927576: "gold", 2613 | 928006: "red", 2614 | 928300: "green", 2615 | 928566: "gold", 2616 | 928958: "green", 2617 | 929241: "gold", 2618 | 929562: "gold", 2619 | 929836: "purple", 2620 | 930230: "red", 2621 | 930702: "purple", 2622 | 931033: "gold", 2623 | 931387: "purple", 2624 | 931771: "green", 2625 | 932078: "green", 2626 | 932535: "gold", 2627 | 932915: "gold", 2628 | 933264: "red", 2629 | 933579: "purple", 2630 | 934031: "gold", 2631 | 934339: "green", 2632 | 934692: "gold", 2633 | 934966: "green", 2634 | 935248: "red", 2635 | 935656: "gold", 2636 | 935917: "red", 2637 | 936184: "purple", 2638 | 936505: "gold", 2639 | 936925: "gold", 2640 | 937281: "red", 2641 | 937678: "gold", 2642 | 938089: "purple", 2643 | 938380: "red", 2644 | 938702: "green", 2645 | 939034: "red", 2646 | 939437: "green", 2647 | 939779: "purple", 2648 | 940248: "purple", 2649 | 940507: "gold", 2650 | 940944: "purple", 2651 | 941347: "red", 2652 | 941641: "red", 2653 | 941881: "gold", 2654 | 942130: "red", 2655 | 942558: "gold", 2656 | 942934: "green", 2657 | 943215: "green", 2658 | 943645: "gold", 2659 | 944033: "green", 2660 | 944382: "gold", 2661 | 944767: "green", 2662 | 945055: "purple", 2663 | 945408: "red", 2664 | 945655: "purple", 2665 | 946041: "gold", 2666 | 946491: "purple", 2667 | 946827: "green", 2668 | 947190: "green", 2669 | 947611: "green", 2670 | 947942: "green", 2671 | 948366: "red", 2672 | 948732: "red", 2673 | 949000: "green", 2674 | 949448: "purple", 2675 | 949781: "red", 2676 | 950130: "green", 2677 | 950394: "green", 2678 | 950818: "gold", 2679 | 951236: "gold", 2680 | 951488: "red", 2681 | 951921: "red", 2682 | 952174: "green", 2683 | 952603: "purple", 2684 | 952989: "gold", 2685 | 953294: "red", 2686 | 953734: "gold", 2687 | 954146: "red", 2688 | 954501: "purple", 2689 | 954923: "gold", 2690 | 955228: "red", 2691 | 955544: "purple", 2692 | 955811: "green", 2693 | 956258: "red", 2694 | 956686: "gold", 2695 | 957125: "gold", 2696 | 957592: "red", 2697 | 958039: "green", 2698 | 958435: "purple", 2699 | 958823: "purple", 2700 | 959231: "gold", 2701 | 959477: "gold", 2702 | 959914: "purple", 2703 | 960165: "green", 2704 | 960575: "gold", 2705 | 960966: "purple", 2706 | 961215: "gold", 2707 | 961512: "green", 2708 | 961985: "gold", 2709 | 962289: "green", 2710 | 962616: "red", 2711 | 963081: "gold", 2712 | 963329: "red", 2713 | 963804: "gold", 2714 | 964255: "purple", 2715 | 964492: "gold", 2716 | 964906: "green", 2717 | 965240: "red", 2718 | 965605: "red", 2719 | 966025: "red", 2720 | 966401: "green", 2721 | 966698: "purple", 2722 | 967033: "gold", 2723 | 967479: "gold", 2724 | 967731: "green", 2725 | 968191: "purple", 2726 | 968546: "red", 2727 | 968972: "red", 2728 | 969396: "gold", 2729 | 969640: "green", 2730 | 969998: "gold", 2731 | 970276: "gold", 2732 | 970742: "green", 2733 | 971112: "gold", 2734 | 971449: "gold", 2735 | 971917: "purple", 2736 | 972154: "purple", 2737 | 972544: "green", 2738 | 972925: "gold", 2739 | 973201: "red", 2740 | 973486: "gold", 2741 | 973806: "green", 2742 | 974144: "green", 2743 | 974576: "green", 2744 | 974831: "purple", 2745 | 975133: "green", 2746 | 975452: "green", 2747 | 975771: "red", 2748 | 976233: "purple", 2749 | 976675: "green", 2750 | 976998: "purple", 2751 | 977262: "purple", 2752 | 977512: "gold", 2753 | 977868: "purple", 2754 | 978263: "gold", 2755 | 978542: "red", 2756 | 978889: "red", 2757 | 979194: "gold", 2758 | 979469: "purple", 2759 | 979822: "gold", 2760 | 980209: "purple", 2761 | 980545: "purple", 2762 | 980799: "purple", 2763 | 981107: "gold", 2764 | 981485: "gold", 2765 | 981801: "green", 2766 | 982142: "green", 2767 | 982395: "green", 2768 | 982829: "gold", 2769 | 983170: "purple", 2770 | 983558: "purple", 2771 | 983839: "purple", 2772 | 984292: "purple", 2773 | 984539: "red", 2774 | 984830: "green", 2775 | 985095: "gold", 2776 | 985461: "green", 2777 | 985925: "green", 2778 | 986379: "red", 2779 | 986712: "purple", 2780 | 987079: "gold", 2781 | 987388: "purple", 2782 | 987808: "gold", 2783 | 988144: "red", 2784 | 988475: "purple", 2785 | 988936: "red", 2786 | 989327: "gold", 2787 | 989575: "green", 2788 | 989860: "green", 2789 | 990219: "green", 2790 | 990685: "gold", 2791 | 990945: "red", 2792 | 991229: "purple", 2793 | 991670: "red", 2794 | 992127: "red", 2795 | 992538: "purple", 2796 | 992931: "red", 2797 | 993334: "red", 2798 | 993782: "gold", 2799 | 994179: "gold", 2800 | 994428: "purple", 2801 | 994900: "gold", 2802 | 995302: "red", 2803 | 995752: "red", 2804 | 996164: "red", 2805 | 996514: "purple", 2806 | 996961: "green", 2807 | 997353: "purple", 2808 | 997748: "purple", 2809 | 998174: "green", 2810 | 998444: "red", 2811 | 998732: "green", 2812 | 999066: "gold", 2813 | 999444: "green", 2814 | 999748: "gold", 2815 | }; 2816 | 2817 | export default INDICES; 2818 | --------------------------------------------------------------------------------