├── README.md ├── .gitignore ├── .github └── workflows │ └── puzzles.yml └── generate.py /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This repository generates the static puzzle json files for [Free Bee](https://github.com/ping/freebee/). 4 | 5 | [Index of puzzle files](https://ping.github.io/freebee-static/puzzles/index.json) 6 | 7 | Wordlist from https://github.com/wordnik/wordlist 8 | 9 | [**Play**](https://ping.github.io/freebee/) 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | public/ 2 | 3 | venv/ 4 | venv3/ 5 | .vscode/ 6 | 7 | coverage.sh 8 | cov_html/ 9 | 10 | *.iml 11 | .idea/ 12 | 13 | # OS generated files # 14 | .DS_Store 15 | .DS_Store? 16 | ._* 17 | .Spotlight-V100 18 | .Trashes 19 | ehthumbs.db 20 | Thumbs.db 21 | 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | *$py.class 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | env/ 33 | build/ 34 | develop-eggs/ 35 | dist/ 36 | downloads/ 37 | eggs/ 38 | .eggs/ 39 | lib/ 40 | lib64/ 41 | parts/ 42 | sdist/ 43 | var/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *,cover 67 | .hypothesis/ 68 | 69 | # Translations 70 | *.mo 71 | *.pot 72 | 73 | # Django stuff: 74 | *.log 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | #Ipython Notebook 83 | .ipynb_checkpoints 84 | -------------------------------------------------------------------------------- /.github/workflows/puzzles.yml: -------------------------------------------------------------------------------- 1 | name: "Generate Puzzles" 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | schedule: 8 | - cron: "0 0,12 * * *" 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | generate_puzzles: 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: ubuntu-latest 28 | if: github.event_name == 'schedule' || github.ref_name == github.event.repository.default_branch 29 | timeout-minutes: 30 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 1 34 | 35 | - uses: actions/setup-python@v4 36 | with: 37 | python-version: '3.10' 38 | 39 | - name: Download puzzle artifacts 40 | id: download-puzzle-artifact 41 | uses: dawidd6/action-download-artifact@v2 42 | with: 43 | name: puzzles-artifacts 44 | path: ./ 45 | search_artifacts: true 46 | if_no_artifact_found: warn 47 | 48 | - name: Setup Pages 49 | id: setup_pages 50 | uses: actions/configure-pages@v3 51 | 52 | - name: Generate puzzles 53 | run: | 54 | mkdir -p public/puzzles 55 | if [[ -f 'puzzles.zip' ]]; then unzip -q puzzles.zip -d public/puzzles/; fi 56 | rm -f puzzles.zip 57 | python generate.py wordlist-20210729.txt '2020-01-01' 'public/puzzles' 58 | zip --quiet --junk-paths puzzles.zip public/puzzles/*.json 59 | 60 | # Ref: https://github.com/actions/starter-workflows/blob/main/pages/static.yml 61 | - name: Upload artifact 62 | uses: actions/upload-pages-artifact@v2 63 | with: 64 | path: ./public 65 | 66 | - name: Deploy to GitHub Pages 67 | id: deployment 68 | uses: actions/deploy-pages@v2 69 | 70 | - uses: actions/upload-artifact@v3 71 | with: 72 | name: puzzles-artifacts 73 | path: puzzles.zip 74 | if-no-files-found: error 75 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import random 5 | import sys 6 | from dataclasses import dataclass 7 | from datetime import datetime, timedelta, timezone 8 | from pathlib import Path 9 | from typing import Dict, List 10 | 11 | logger = logging.getLogger(__file__) 12 | ch = logging.StreamHandler(sys.stdout) 13 | ch.setLevel(logging.DEBUG) 14 | logger.addHandler(ch) 15 | logger.setLevel(logging.INFO) 16 | 17 | 18 | @dataclass 19 | class PuzzleWord: 20 | word: str 21 | letters: List[str] 22 | 23 | 24 | def get_filtered_words(wordlist_file) -> List[PuzzleWord]: 25 | filtered_words: List[PuzzleWord] = [] 26 | with open(wordlist_file, "r", encoding="utf-8") as f: 27 | while True: 28 | word = f.readline().strip() 29 | if not word: 30 | break 31 | word = word[1:-1] 32 | if len(word) > 15 or len(word) < 4: 33 | continue 34 | puzzle_word = PuzzleWord( 35 | word=word, letters=sorted(list(set([a for a in word]))) 36 | ) 37 | filtered_words.append(puzzle_word) 38 | return filtered_words 39 | 40 | 41 | def get_puzzle_words(filtered_words: List[PuzzleWord]) -> List[PuzzleWord]: 42 | puzzle_words: List[PuzzleWord] = [] 43 | for fw in filtered_words: 44 | if len(fw.letters) != 7: 45 | continue 46 | if fw.word.endswith("s") and fw.word[-2:-1] not in "aeiou": 47 | # stupid filter for plurals 48 | continue 49 | puzzle_words.append(fw) 50 | return puzzle_words 51 | 52 | 53 | def score(puzzleword: PuzzleWord, letters: List[str]): 54 | if len(puzzleword.word) == 4: 55 | return 1 56 | if len(puzzleword.letters) == len(letters): 57 | return len(puzzleword.word) + 7 58 | return len(puzzleword.word) 59 | 60 | 61 | def generate_puzzle_word( 62 | puzzle_words: List[PuzzleWord], filtered_words: List[PuzzleWord] 63 | ) -> Dict: 64 | attempt_count = 0 65 | while True: 66 | attempt_count += 1 67 | valid_puzzle_guesses: List[PuzzleWord] = [] 68 | puzzle_word: PuzzleWord = random.choice(puzzle_words) 69 | centre_letter: str = random.choice(puzzle_word.letters) 70 | for w in filtered_words: 71 | if centre_letter not in w.letters: 72 | continue 73 | if [a for a in w.letters if (a not in puzzle_word.letters)]: 74 | continue 75 | valid_puzzle_guesses.append(w) 76 | total_score = sum( 77 | [score(guess, puzzle_word.letters) for guess in valid_puzzle_guesses] 78 | ) 79 | if total_score <= 300 and 20 <= len(valid_puzzle_guesses) <= 200: 80 | # limit guess count and maximum possible score 81 | # to favour "short" games 82 | break 83 | if attempt_count >= 50: 84 | return {} 85 | 86 | return { 87 | "letters": "".join([a for a in puzzle_word.letters if a != centre_letter]), 88 | "center": centre_letter, 89 | "words": len(valid_puzzle_guesses), 90 | "total": total_score, 91 | "wordlist": [w.word for w in valid_puzzle_guesses], 92 | } 93 | 94 | 95 | def valid_day(value: str) -> str: 96 | """ 97 | Validate a day argument from the cli. 98 | 99 | :param value: 100 | :return: 101 | """ 102 | 103 | try: 104 | date_value = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc) 105 | except ValueError: 106 | raise argparse.ArgumentTypeError(f'"{value}" is not a valid date format') 107 | 108 | now_utc = datetime.now(tz=timezone.utc) 109 | if date_value > now_utc.replace(hour=0, minute=0, second=0, microsecond=0): 110 | raise argparse.ArgumentTypeError(f'"{value}" is in the future') 111 | 112 | return value 113 | 114 | 115 | if __name__ == "__main__": 116 | 117 | parser = argparse.ArgumentParser() 118 | parser.add_argument("wordlist", type=str, help="Path to wordlist file") 119 | parser.add_argument("start_date", type=valid_day, help="Start date") 120 | parser.add_argument("output_folder", type=str, help="Output folder path") 121 | 122 | args = parser.parse_args() 123 | 124 | start_date = datetime.strptime(args.start_date, "%Y-%m-%d").replace( 125 | tzinfo=timezone.utc 126 | ) 127 | output_folder = Path(args.output_folder) 128 | if not output_folder.exists(): 129 | output_folder.mkdir(parents=True, exist_ok=True) 130 | 131 | fw = get_filtered_words(args.wordlist) 132 | pz = get_puzzle_words(fw) 133 | 134 | index = [] 135 | now = datetime.now(tz=timezone.utc) 136 | for d in range((now - start_date).days + 2): 137 | gen_date = start_date + timedelta(days=d) 138 | output_filename = output_folder.joinpath(f'{gen_date.strftime("%Y%m%d")}.json') 139 | if output_filename.exists(): 140 | index.append(output_filename.name) 141 | continue 142 | output = generate_puzzle_word(pz, fw) 143 | if not output: 144 | logger.warning(f"Unable to generate a puzzle for {output_filename}") 145 | continue 146 | logger.info(f"Generating {output_filename}") 147 | with output_filename.open("w", encoding="utf-8") as fp: 148 | json.dump(output, fp, separators=(",", ":")) 149 | index.append(output_filename.name) 150 | 151 | index_filename = output_folder.joinpath("index.json") 152 | with index_filename.open("w", encoding="utf-8") as fp: 153 | json.dump(sorted(index, reverse=True), fp, separators=(",", ":")) 154 | --------------------------------------------------------------------------------