58 Commits

Author SHA1 Message Date
ba53552f40 Better async at the start 2024-11-24 21:05:39 +01:00
bb78abe50b Use the server-provided UTC-based countdown timer 2024-11-24 20:59:49 +01:00
8185ca21aa Game version as cache buster for CSS and game JS 2024-11-24 17:06:11 +01:00
9757c27cc9 Do not allow to peak ahead at future words 2024-11-24 17:02:23 +01:00
eab6605c83 Merge pull request #1 from aquatix/savegames
Savegames using Alpine.js persistence
2024-11-24 16:54:09 +01:00
0ae7b8618f Reset game state when new/different game has started 2024-11-24 16:49:00 +01:00
f52ae3cb8f Cleaned up non-Alpine.js persistence code 2024-11-24 16:45:23 +01:00
bbfa172e7a Persist the important game data in localStorage 2024-11-24 16:32:13 +01:00
795ee7aa16 Started work on saving game state 2024-11-23 22:16:47 +01:00
48ccb99492 Restored accidentally removed test-for-localStorage functions 2024-11-23 21:38:42 +01:00
4058dc3a7a Merge branch 'main' into savegames 2024-11-23 21:20:18 +01:00
e7a39becfc Latest requirements 2024-11-23 21:18:18 +01:00
cc20e80bf5 Initial support for server-sourced deadline 2024-11-23 14:05:46 +01:00
622d3d9681 Latest clipboard.js 2024-11-23 13:36:35 +01:00
28ff9368bb var-b-let 2024-11-23 13:24:45 +01:00
1ce340853f Use CSS to style centering the guess lists 2024-11-22 13:05:56 +01:00
20321f0bb4 Use UTC for calculating the current game 2024-11-22 13:05:52 +01:00
f8a3561d9a Use localStorage for restoring saveGame 2024-11-19 22:18:56 +01:00
6d12d3bbc5 Fixed paths to the favicons 2024-11-19 22:01:21 +01:00
f0deb1a6f3 Latest requirements 2024-11-19 21:20:53 +01:00
f3117ce121 Added favicon in various formats 2024-11-19 21:19:57 +01:00
b3c6da59b8 Only use Scrabble words when available 2024-11-19 15:20:20 +01:00
49e40201dc 20241108: Scrabblewoordenboek as source 2024-11-19 15:17:21 +01:00
c0c0ecb28b Make sure there's no accidental space before or after the word 2024-11-09 18:46:13 +01:00
4afa475d63 Use Wiktionary WikiWoordenboek list as source 2024-11-07 15:33:17 +01:00
5ca91ef955 No indentation in the guesses lists 2024-11-07 15:32:52 +01:00
ce6e2ea1bd Do not count rejected guesses, and show number of guesses done 2024-11-07 14:11:49 +01:00
ff7e19062e Include flexies, filter away (some?) English words 2024-11-07 14:06:07 +01:00
a81788d172 Lowered the length of words from 6-10 to 4-10 chars 2024-11-06 07:40:23 +01:00
4bacc75a42 example.com 2024-11-05 16:43:55 +01:00
948267359a Reset input when guess was successful filed 2024-11-05 15:50:19 +01:00
b942200833 Better readable heading 2024-11-05 15:49:18 +01:00
370ce60b32 Do not blip uninitialised content on load 2024-11-05 15:29:07 +01:00
6ff55f4878 Replace input form on correct guess 2024-11-05 15:25:53 +01:00
898362db72 Komt voor/na 2024-11-05 15:22:45 +01:00
a7499d4e74 Generate results when word is found 2024-11-05 15:10:20 +01:00
43dbf54e53 Style the result block 2024-11-05 14:35:12 +01:00
8eb0f5728d More styling, nicely red feedback block 2024-11-05 14:19:52 +01:00
e172166886 Styling 2024-11-05 14:12:16 +01:00
066a99657e Use the guess string from the store 2024-11-05 13:54:35 +01:00
cabd695e49 Do not use autofill or autocomplete 2024-11-05 13:44:54 +01:00
ed813f6828 Moved alfagok AlpineJS store to JS file 2024-11-05 13:31:21 +01:00
7ebd52e825 Example config files 2024-11-05 12:01:16 +01:00
3484cd1c07 Better feedback 2024-11-04 22:35:36 +01:00
e15fb46db6 x-data is apparently needed for scope :) 2024-11-04 22:28:52 +01:00
67188ced8b Moved gameID loading into Alpine store function 2024-11-04 22:22:44 +01:00
2a7300f126 Making the game interactive, getting results from backend 2024-11-04 22:06:09 +01:00
0e965b8e3b Removed old JS files 2024-11-04 21:04:00 +01:00
8fd16a6e42 Hooking up the API 2024-11-04 21:02:12 +01:00
528c7f87ea Ignore generated word list files 2024-11-04 19:41:19 +01:00
a178cbd8b7 Translation 2024-11-04 17:09:12 +01:00
b2bf55935a Translation 2024-11-03 21:34:48 +01:00
cdd0085f26 Using Alpine.js to make the game page dynamic 2024-11-03 21:28:50 +01:00
ff7ad42e32 Serve static HTML game page 2024-11-03 20:14:03 +01:00
144ac314f7 Tell what game we are on 2024-11-02 23:04:23 +01:00
82d8926e0d Generate dictionary source 2024-11-02 22:51:27 +01:00
4f3e0e4c19 Implement guessing with hint answers 2024-11-02 22:44:45 +01:00
ca14d59400 Requirements to run on server 2024-11-02 21:49:59 +01:00
17 changed files with 786 additions and 39 deletions

3
.gitignore vendored
View File

@@ -81,3 +81,6 @@ TAGS
# settings
settings.py
rq_settings.py
# Generated word lists
wordlist/*.txt

110
README.md
View File

@@ -3,3 +3,113 @@
Omdat Nederlanders ook [alphaguess](https://alphaguess.com) willen spelen :)
My own implementation of an alphaguess like game with custom word lists, tailored to be used with a Dutch source.
Using Alpine.js on the frontend and FastAPI as backend.
## Environment variables
```fish
set -x WORD_LIST /path/to/alfagok_wordlist.txt
set -x START_DATE 2024-11-02
set -x DICTIONARY_LIST /path/to/dictionary.txt
```
In `src/alfagok`, with an active virtualenv with packages installed (`uv pip sync requirements.txt`), run:
`fastapi dev main.py`
## Example configs
### nginx
/etc/nginx/sites-enabled/alfagok.example.com.conf
```nginx
server {
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
server_name alfagok.example.com;
real_ip_header X-Forwarded-For;
access_log /var/log/nginx/access_alfagok.example.com.log;
error_log /var/log/nginx/error_alfagok.example.com.log warn;
location / {
proxy_pass http://127.0.0.1:8889;
proxy_read_timeout 60;
proxy_connect_timeout 60;
proxy_redirect off;
# Allow the use of websockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
ssl_certificate /etc/letsencrypt/live/alfagok.example.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/alfagok.example.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = alfagok.example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen [::]:80;
listen 80;
server_name alfagok.example.com;
return 404; # managed by Certbot
}
```
### systemd unit file
/etc/systemd/system/alfagok.service
```ini
[Unit]
Description=Gunicorn Daemon for alfagok game FastAPI
After=network.target
[Service]
User=USERNAME (fill in!)
Group=USERGROUP (fill in!)
WorkingDirectory=/srv/www/alfagok.example.com/alfagok/src
Environment="START_DATE=2024-11-02"
Environment="WORD_LIST=/srv/www_data/alfagok.example.com/wordlist.txt"
Environment="DICTIONARY_LIST=/srv/www_data/alfagok.example.com/dictionary.txt"
Environment="STATIC_DIR=/srv/www/alfagok.example.com/alfagok/src/alfagok/static"
Environment="TEMPLATE_DIR=/srv/www/alfagok.example.com/alfagok/src/alfagok/templates"
ExecStart=/srv/www/alfagok.example.com/venv/bin/gunicorn -c /srv/www/_webconfig/sites/alfagok.example.com/gunicorn_alfagok_conf.py alfagok.main:app
[Install]
WantedBy=multi-user.target
```
### gunicorn config file
```python
# gunicorn_conf.py
from multiprocessing import cpu_count
bind = "127.0.0.1:8889"
# Worker Options
#workers = cpu_count() + 1
workers = 1
worker_class = 'uvicorn.workers.UvicornWorker'
# Logging Options, dir should be writable for User in systemd unit file
loglevel = 'debug'
accesslog = '/var/log/alfagok/access_log'
errorlog = '/var/log/alfagok/error_log'
```

3
requirements-server.in Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.in
gunicorn

98
requirements-server.txt Normal file
View File

@@ -0,0 +1,98 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-server.in
annotated-types==0.7.0
# via pydantic
anyio==4.6.2.post1
# via
# httpx
# starlette
# watchfiles
certifi==2024.8.30
# via
# httpcore
# httpx
click==8.1.7
# via
# typer
# uvicorn
dnspython==2.7.0
# via email-validator
email-validator==2.2.0
# via fastapi
fastapi==0.115.5
# via -r requirements.in
fastapi-cli==0.0.5
# via fastapi
gunicorn==23.0.0
# via -r requirements-server.in
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==1.0.7
# via httpx
httptools==0.6.4
# via uvicorn
httpx==0.27.2
# via fastapi
idna==3.10
# via
# anyio
# email-validator
# httpx
jinja2==3.1.4
# via fastapi
markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via jinja2
mdurl==0.1.2
# via markdown-it-py
packaging==24.2
# via gunicorn
pydantic==2.10.1
# via
# fastapi
# pydantic-settings
pydantic-core==2.27.1
# via pydantic
pydantic-settings==2.6.1
# via -r requirements.in
pygments==2.18.0
# via rich
python-dotenv==1.0.1
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.17
# via fastapi
pyyaml==6.0.2
# via uvicorn
rich==13.9.4
# via typer
shellingham==1.5.4
# via typer
sniffio==1.3.1
# via
# anyio
# httpx
starlette==0.41.3
# via fastapi
typer==0.13.1
# via fastapi-cli
typing-extensions==4.12.2
# via
# fastapi
# pydantic
# pydantic-core
# typer
uvicorn==0.32.1
# via
# fastapi
# fastapi-cli
uvloop==0.21.0
# via uvicorn
watchfiles==0.24.0
# via uvicorn
websockets==14.1
# via uvicorn

View File

@@ -19,7 +19,7 @@ dnspython==2.7.0
# via email-validator
email-validator==2.2.0
# via fastapi
fastapi==0.115.4
fastapi==0.115.5
# via -r requirements.in
fastapi-cli==0.0.5
# via fastapi
@@ -27,7 +27,7 @@ h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==1.0.6
httpcore==1.0.7
# via httpx
httptools==0.6.4
# via uvicorn
@@ -46,11 +46,11 @@ markupsafe==3.0.2
# via jinja2
mdurl==0.1.2
# via markdown-it-py
pydantic==2.9.2
pydantic==2.10.1
# via
# fastapi
# pydantic-settings
pydantic-core==2.23.4
pydantic-core==2.27.1
# via pydantic
pydantic-settings==2.6.1
# via -r requirements.in
@@ -72,9 +72,9 @@ sniffio==1.3.1
# via
# anyio
# httpx
starlette==0.41.2
starlette==0.41.3
# via fastapi
typer==0.12.5
typer==0.13.1
# via fastapi-cli
typing-extensions==4.12.2
# via
@@ -82,7 +82,7 @@ typing-extensions==4.12.2
# pydantic
# pydantic-core
# typer
uvicorn==0.32.0
uvicorn==0.32.1
# via
# fastapi
# fastapi-cli
@@ -90,5 +90,5 @@ uvloop==0.21.0
# via uvicorn
watchfiles==0.24.0
# via uvicorn
websockets==13.1
websockets==14.1
# via uvicorn

View File

@@ -1,23 +1,118 @@
"""Main alfagok API application."""
import logging
from datetime import date, datetime, timezone
from typing import Union
from fastapi import FastAPI
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import DirectoryPath, FilePath
from pydantic_settings import BaseSettings
from pydantic import FilePath
VERSION = '0.3.0'
class Settings(BaseSettings):
"""Configuration needed for alfagok to find its word list, using environment variables."""
# Game words
word_list: FilePath
# All valid words
dictionary_list: FilePath
# Date of first game so we can calculate the game ID we're on
start_date: date
static_dir: DirectoryPath = 'static'
template_dir: DirectoryPath = 'templates'
debug: bool = False
app = FastAPI()
settings = Settings()
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
templates = Jinja2Templates(directory=settings.template_dir)
@app.get("/")
def read_root():
return {"Hello": "World"}
with open(settings.word_list, 'r', encoding='utf-8') as word_file:
# Load the game words
words = word_file.readlines()
@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
return {"item_id": item_id, "q": q}
with open(settings.dictionary_list, 'r', encoding='utf-8') as word_file:
# Load the list of valid words
dictionary = word_file.readlines()
logger = logging.getLogger('uvicorn.error')
logger.setLevel(logging.INFO)
if settings.debug:
logger.setLevel(logging.DEBUG)
def get_game_id():
"""Calculate the index for the game/word we are handling today."""
today = datetime.now(timezone.utc).date()
# Calculate the amount of days since the start of the games so we know which word is used today
return (today - settings.start_date).days
def get_game_deadline():
"""Calculate the amount of time left for the current game."""
this_moment = datetime.now(timezone.utc)
midnight = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=0)
# Calculate the amount of time left till midnight (and the start of the next game)
return midnight - this_moment
def is_valid_dictionary_word(word: str) -> bool:
"""Verify if `word` is in the dictionary provided."""
# Either we: [ ] strip all the endlines during file load, or [x] use the endline to search here
return f'{word}\n' in dictionary
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Generate the main HTML page of the game."""
language = 'nl'
return templates.TemplateResponse(request=request, name='index.html', context={'language': language, 'version': VERSION})
@app.get('/api/game')
def what_game():
"""Which game is currently on?"""
return {'game': get_game_id(), 'deadline': get_game_deadline()}
@app.get('/api/guess/{word}')
def handle_guess(word: Union[str, None] = None):
"""Handle incoming guess."""
current_game_id = get_game_id()
word_of_the_day = words[current_game_id].strip()
if not is_valid_dictionary_word(word):
logger.info('Guess: %s for game %d (%s), word not found in dictionary', word, current_game_id, word_of_the_day)
return {'error': 'Word not in dictionary'}
hint = 'it'
if word_of_the_day < word:
hint = 'before'
if word_of_the_day > word:
hint = 'after'
logger.info('Guess: %s for game %d (%s), goal is %s', word, current_game_id, word_of_the_day, hint)
# before, after, it
return {'game': current_game_id, 'hint': hint}
@app.get('/api/answer/{item_id}')
def read_item(item_id: int):
"""Get the word for the game with ID `item_id`."""
current_game_id = get_game_id()
if item_id > current_game_id:
raise HTTPException(status_code=403, detail='No peaking!')
word = words[item_id].strip()
return {'item_id': item_id, 'word': word}

View File

@@ -0,0 +1,83 @@
body {
background-color: #333;
color: #FFF;
max-width: 600px;
margin: 0 auto;
padding: 2rem 1rem;
}
[x-cloak] { display: none !important; }
a.title {
color: #FF9800;
font-weight: bold;
text-decoration: none;
font-size: 1.5rem;
}
.puzzleno {
border: 1px solid grey;
color: grey;
padding: .2em .5em;
border-radius: 1em;
text-align: right;
float: right;
}
#container center {
padding-top: 2rem;
}
.instructions {
padding: 2rem 0 0 0;
}
.guessesheading {
color: #CCC;;
}
.guessesheading, .guessesbefore, .guessesafter {
text-align: center;
}
input[type="text"] {
box-sizing: border-box;
width: 100%;
}
button {
background-color: #FF9800;
color: #333;
border: 0;
margin-top: .5rem;
cursor: pointer;
width: 100%;
}
ul {
list-style-type: none;
padding-left: 0;
}
.error {
background-color: #F00;
color: #FFF;
}
input[type="text"], button, .error {
padding: .5rem;
font-size: 1.5rem;
}
.win {
background-color: rgb(42 73 54);
padding: 1rem;
}
.results {
background-color: #333;
}

213
src/alfagok/static/game.js Normal file
View File

@@ -0,0 +1,213 @@
document.addEventListener('alpine:init', () => {
Alpine.store('alfagok', {
/** Main alfagok application, state etc */
gameID: Alpine.$persist(0).as('gameID'),
countingDown: '',
nextGameIn: 0,
gameFetchedAt: null,
loading: false,
winTime: Alpine.$persist(null).as('winTime'),
startTime: Alpine.$persist(null).as('startTime'),
gaveUpTime: Alpine.$persist(null).as('gaveUpTime'), // not implemented yet
nrGuesses: Alpine.$persist(0).as('nrGuesses'),
guessesBefore: Alpine.$persist([]).as('guessesBefore'),
guessesAfter: Alpine.$persist([]).as('guessesAfter'),
guessValue: Alpine.$persist('').as('guessValue'),
guessError: '',
resultGameID: Alpine.$persist('').as('resultGameID'),
resultGuesses: Alpine.$persist('').as('resultGuesses'),
resultTimeTaken: Alpine.$persist('').as('resultTimeTaken'),
async init() {
/** Initialise the application after loading */
await this.getGameID();
setInterval(() => {
// Update counter to next game (midnight UTC, fetched from API) every second
this.countDownTimer();
}, 1000);
},
async getGameID() {
/** Get the game number from the backend */
this.loading = true;
console.log('Loading gameID...');
let response = await fetch('/api/game');
let result = await response.json();
console.log(result);
this.loading = false;
if (result.game) {
if (this.gameID !== result.game) {
this.setEmptyGameState();
}
this.nextGameIn = result.deadline;
this.gameFetchedAt = new Date();
if (this.countingDown === '') {
this.countDownTimer();
}
return this.gameID = result.game;
}
},
async doGuess() {
/** Handle the newly entered guess */
this.guessError = null;
/* Normalise on lowercase, and strip whitespace from begin and end, just in case */
this.guessValue = this.guessValue.toLowerCase().trim();
if (this.guessValue === '') {
console.log('Nothing filled in');
this.guessError = 'Vul een woord in';
return;
}
if (this.guessesBefore.includes(this.guessValue) || this.guessesAfter.includes(this.guessValue)) {
this.guessError = 'Woord is al gebruikt';
return;
}
if (this.startTime === null) {
console.log('Setting startTime to now');
this.startTime = new Date();
}
/* Check guess against server */
this.loading = true;
let response = await fetch('/api/guess/' + this.guessValue);
let result = await response.json();
console.log(result);
this.loading = false;
if (result.error) {
console.log('Error occurred during guess');
if (result.error === 'Word not in dictionary') {
this.guessError = 'Woord komt niet in de woordenlijst voor';
}
return;
}
this.nrGuesses++;
if (result.hint && result.hint === 'after') {
this.guessesBefore.push(this.guessValue);
this.guessesBefore.sort();
this.guessValue = '';
}
if (result.hint && result.hint === 'before') {
this.guessesAfter.push(this.guessValue);
this.guessesAfter.sort();
this.guessValue = '';
}
if (result.hint && result.hint === 'it') {
console.log('gevonden!');
this.winTime = new Date();
this.resultGameID = '🧩 Puzzel #' + this.gameID;
this.resultGuesses = '🤔 '+ this.nrGuesses + ' gokken';
let winTimeDate = new Date(this.winTime);
let startTimeDate = new Date(this.startTime);
// this.resultTimeTaken = '⏱️ ' + getFormattedTime(this.winTime - this.startTime);
this.resultTimeTaken = '⏱️ ' + this.getFormattedTime(winTimeDate - startTimeDate);
}
},
setEmptyGameState() {
/** Clean slate for new game */
this.winTime = null;
this.startTime = null;
this.gaveUpTime = null;
this.nrGuesses = 0;
this.guessesBefore = [];
this.guessesAfter = [];
this.guessValue = '';
this.guessError = '';
this.resultGameID = '';
this.resultGuesses = '';
this.resultTimeTaken = '';
},
getFormattedTime(milliseconds) {
/** Nicely format time for 'time played' */
if (!Number.isInteger(milliseconds)) {
return '';
}
let seconds = Math.round((milliseconds) / 1000);
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds %= 60;
const formattedTime = [];
if (hours) {
formattedTime.push(`${hours}u`);
}
if (minutes) {
formattedTime.push(`${minutes}m`);
}
if (seconds) {
formattedTime.push(`${seconds}s`);
}
return formattedTime.join(' ') || '0s';
},
// # Countdown timer
addZero(num){
/** Pad with 0 if needed */
if (num <= 9) return '0' + num;
else return num;
},
countDownTimer(){
/** Update counter to next game (midnight UTC, fetched from API) */
if (this.gameFetchedAt === null) { return; }
let now = new Date();
let gameDataFetched = new Date(this.gameFetchedAt);
let nextGameStart = gameDataFetched.setSeconds(gameDataFetched.getSeconds() + this.nextGameIn);
let diff = Math.floor((nextGameStart - now) / 1000);
let hoursRemain = Math.floor(diff / (60*60));
let minutesRemain = Math.floor((diff - hoursRemain*60*60) / 60);
let secondsRemain = Math.floor(diff % 60);
this.countingDown = this.addZero(hoursRemain) + ':' + this.addZero(minutesRemain) + ':' + this.addZero(secondsRemain) + ' over';
}
})
Alpine.store('darkMode', {
/* Different Alpine app, dark mode settings for the game */
init() {
this.on = window.matchMedia('(prefers-color-scheme: dark)').matches
},
on: false,
toggle() {
this.on = ! this.on
}
})
});
/* Clipboard stuff **/
let clip = new ClipboardJS('.copy');
clip.on("success", function(e) {
document.getElementById('copyresults').innerHTML = '<p style="font-size:var(--small);opacity:50%">Gekopieerd! Deel je resultaat.</p>';
e.clearSelection();
});
clip.on("error", function() {
document.getElementById('copyresults').innerHTML = '<p style="font-size:var(--small);opacity:50%">Fout. Graag handmatig kopi&euml;ren...</p>';
});
/* Get current gameID etc **/
// document.addEventListener('alpine:initialized', () => {
/* On AlpineJS completely loaded, do all this */
// Alpine.store('alfagok').getGameID();
// })

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"alfagok","short_name":"alfagok","icons":[{"src":"/static/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images//android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="{{ language }}">
<head>
<title>alfagok</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Raad het woord van de dag. Elke gok geeft een hint over waar het woord zich in het alfabet bevindt. Iedereen speelt hetzelfde woord.">
<link rel="stylesheet" href="/static/game.css?v={{ version }}">
<link id="favicon" rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="manifest" href="/static/images/site.webmanifest">
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div id="container" x-data="">
<a href="/" x-cloak class="title">alfagok</a> <span class="puzzleno">puzzel #<span x-text="$store.alfagok.gameID"></span><span x-text="$store.alfagok.countingDown"></span><span x-text="$store.alfagok.nrGuesses"></span> gokken</span>
<div x-cloak class="instructions" x-show="$store.alfagok.guessesBefore.length === 0 && $store.alfagok.guessesAfter.length === 0">
<p>Raad het woord van de dag. Elke gok geeft een hint over waar het woord zich in het alfabet bevindt.</p>
</div>
<p class="guessesheading" x-cloak x-show="$store.alfagok.guessesBefore.length">Het woord van de dag komt <em>na</em>:</p>
<ul class="guessesbefore">
<template x-for="item in $store.alfagok.guessesBefore" :key="item">
<li x-text="item"></li>
</template>
</ul>
<div x-show="!$store.alfagok.winTime">
<input type="text" autocomplete="new-password" autocorrect="off" x-model="$store.alfagok.guessValue" @keyup.enter="$store.alfagok.doGuess()">
{# <p x-cloak>Je huidige gok is: <span x-text="$store.alfagok.guessValue"></span></p>#}
<button @click="$store.alfagok.doGuess()">Doe een gok</button>
<p class="error" x-cloak x-show="$store.alfagok.guessError" x-text="$store.alfagok.guessError"></p>
</div>
<div x-cloak x-show="$store.alfagok.winTime" class="win">
<h3><b>Je hebt hem! 🎉</b></h3>
<p>Het woord van vandaag was <b x-text="$store.alfagok.guessValue"></b>.</p>
<div id="stats">
<div id="results">
<p><b x-text="$store.alfagok.resultGameID"></b></p>
<p x-text="$store.alfagok.resultGuesses"></p>
<p x-text="$store.alfagok.resultTimeTaken"></p>
<p>🔗 <span class="link">alfagok.diginaut.net</span></p>
</div>
</div>
<div id="copyresults"></div>
<button class="copy" data-clipboard-target="#results">
Tik om te kopi&euml;ren en te delen ❤️
</button>
</div>
<p class="guessesheading" x-cloak x-show="$store.alfagok.guessesAfter.length">Het woord van de dag komt <em>voor</em>:</p>
<ul class="guessesafter">
<template x-for="item in $store.alfagok.guessesAfter" :key="item">
<li x-text="item"></li>
</template>
</ul>
</div>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
<script src="/static/game.js?v={{ version }}"></script>
{#
<button x-data @click="$store.darkMode.toggle()">Toggle Dark Mode</button>
<div x-data :class="$store.darkMode.on && 'bg-black'">...</div>
#}
</body>
</html>

View File

@@ -1,33 +1,99 @@
import os.path
import random
MIN_LENGTH = 5
MIN_LENGTH = 4
MAX_LENGTH = 10
NUMBER_DAYS = 5 * 365
with open('basiswoorden-gekeurd.txt', 'r', encoding='utf-8') as wordfile:
all_words = wordfile.readlines()
print(f'original list contains {len(all_words)} words')
# Set to True if you want to use the big but difficult OpenTaal list
USE_OPENTAAL = False
with open('wikiwoordenboek_basiswoorden.lst', 'r', encoding='utf-8') as wordfile:
wikiwoorden_words = wordfile.readlines()
print(f'wikiwoorden basic list contains {len(wikiwoorden_words)} words')
scrabble_words = []
if os.path.isfile('scrabblewoorden.txt'):
with open('scrabblewoorden.txt', 'r', encoding='utf-8') as wordfile:
scrabble_words = wordfile.readlines()
print(f'scrabblewoorden list contains {len(scrabble_words)} words')
else:
print('scrabblewoorden.txt not found, skipped')
with open('basiswoorden-gekeurd.txt', 'r', encoding='utf-8') as wordfile:
basis_words = wordfile.readlines()
print(f'opentaal basic list contains {len(basis_words)} words')
with open('flexies-ongekeurd.txt', 'r', encoding='utf-8') as wordfile:
# Vervoegingen and such, see https://nl.wikipedia.org/wiki/Flexie_(taalkunde)
flexies_words = wordfile.readlines()
print(f'flexies list contains {len(flexies_words)} words')
with open('/usr/share/dict/american-english', 'r', encoding='utf-8') as wordfile:
# English words we want to filter from the list; don't really care if there's accidental overlap with Dutch words
english_words = wordfile.readlines()
print(f'english list contains {len(english_words)} words')
print()
print('merging and filtering...')
print()
all_words_count = 0
dictionary_list = []
result_list = []
for word in all_words:
for word in wikiwoorden_words + scrabble_words + basis_words + flexies_words:
all_words_count += 1
word = word.strip()
if word.isalpha() and word.lower() == word and len(word) > MIN_LENGTH and len(word) <= MAX_LENGTH:
if word.isalpha() and word.lower() == word:
# Word is valid for our dictionary
dictionary_list.append(f'{word}\n')
# Deduplicate dictionary
dictionary_list = sorted(list(set(dictionary_list)), key=str.casefold)
if USE_OPENTAAL:
# Use basis_words if you want to use the big but difficult OpenTaal list
source_words = basis_words
else:
# Combine the basic words and the Scrabble word lists
source_words = wikiwoorden_words + scrabble_words
for word in source_words:
word = word.strip()
if word.isalpha() and word.lower() == word and len(word) >= MIN_LENGTH and len(word) <= MAX_LENGTH:
# Word is 'fit' for our game
result_list.append(f'{word}\n')
# print(result_list)
print(f'words filtered: {len(result_list)} with length > {MIN_LENGTH} and <= {MAX_LENGTH}')
if USE_OPENTAAL:
nl_set = set(result_list)
en_set = set(english_words)
# Only keep the words that are not found in the English set
filtered_set = nl_set.difference(en_set)
filtered_list = sorted(list(filtered_set), key=str.casefold)
else:
filtered_list = sorted(list(set(result_list)), key=str.casefold)
print(f'words total: {all_words_count}')
print(f'words in dictionary: {len(dictionary_list)}')
print(f'words initially filtered: {len(result_list)} with length >= {MIN_LENGTH} and <= {MAX_LENGTH}')
if USE_OPENTAAL:
print(f'words after filtering english: {len(filtered_list)}')
with open('filtered.txt', 'w', encoding='utf-8') as f:
f.writelines(result_list)
f.writelines(filtered_list)
with open('dictionary.txt', 'w', encoding='utf-8') as f:
f.writelines(dictionary_list)
selection_list = []
# Randomly select words for each day
while len(selection_list) < NUMBER_DAYS:
# Use index - 1 because lists start at index 0
word_index = random.randrange(0, len(result_list) - 1)
selection_list.append(result_list[word_index])
word_index = random.randrange(0, len(filtered_list) - 1)
selection_list.append(filtered_list[word_index])
# Save the result
with open('word_of_the_day.txt', 'w', encoding='utf-8') as f: