26 Commits

Author SHA1 Message Date
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
7 changed files with 460 additions and 15 deletions

3
.gitignore vendored
View File

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

104
README.md
View File

@@ -4,10 +4,112 @@ 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. 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 ## Environment variables
``` ```fish
set -x WORD_LIST /path/to/alfagok_wordlist.txt set -x WORD_LIST /path/to/alfagok_wordlist.txt
set -x START_DATE 2024-11-02 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'
``` ```

View File

@@ -1,11 +1,13 @@
"""Main alfagok API application.""" """Main alfagok API application."""
import logging import logging
from datetime import date from datetime import date
from typing import Union from typing import Union
from fastapi import FastAPI from fastapi import FastAPI, Request
from pydantic import FilePath 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_settings import BaseSettings
@@ -19,11 +21,17 @@ class Settings(BaseSettings):
# Date of first game so we can calculate the game ID we're on # Date of first game so we can calculate the game ID we're on
start_date: date start_date: date
static_dir: DirectoryPath = 'static'
template_dir: DirectoryPath = 'templates'
debug: bool = False debug: bool = False
app = FastAPI() app = FastAPI()
settings = Settings() settings = Settings()
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
templates = Jinja2Templates(directory=settings.template_dir)
with open(settings.word_list, 'r', encoding='utf-8') as word_file: with open(settings.word_list, 'r', encoding='utf-8') as word_file:
# Load the game words # Load the game words
@@ -50,13 +58,15 @@ def get_game_id():
def is_valid_dictionary_word(word: str) -> bool: def is_valid_dictionary_word(word: str) -> bool:
"""Verify if `word` is in the dictionary provided.""" """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 return f'{word}\n' in dictionary
@app.get("/") @app.get("/", response_class=HTMLResponse)
def read_root(): async def index(request: Request):
"""Ohai.""" """Generate the main HTML page of the game."""
return {"Hello": "World"} language = 'nl'
return templates.TemplateResponse(request=request, name='index.html', context={'language': language})
@app.get('/api/game') @app.get('/api/game')
@@ -65,7 +75,7 @@ def what_game():
return {'game': get_game_id()} return {'game': get_game_id()}
@app.get('/api/guess') @app.get('/api/guess/{word}')
def handle_guess(word: Union[str, None] = None): def handle_guess(word: Union[str, None] = None):
"""Handle incoming guess.""" """Handle incoming guess."""
current_game_id = get_game_id() current_game_id = get_game_id()
@@ -87,8 +97,8 @@ def handle_guess(word: Union[str, None] = None):
return {'game': current_game_id, 'hint': hint} return {'game': current_game_id, 'hint': hint}
@app.get("/api/answer/{item_id}") @app.get('/api/answer/{item_id}')
def read_item(item_id: int, guess: Union[str, None] = None): def read_item(item_id: int, guess: Union[str, None] = None):
"""Get the word item.""" """Get the word for the current game."""
word = words[item_id].strip() word = words[item_id].strip()
return {"item_id": item_id, "guess": guess, 'word': word} return {'item_id': item_id, 'guess': guess, 'word': word}

View File

@@ -0,0 +1,78 @@
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;;
}
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;
}
.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;
}

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

@@ -0,0 +1,179 @@
document.addEventListener('alpine:init', () => {
Alpine.store('alfagok', {
/* Main alfagok application, state etc */
gameID: 0,
loading: false,
winTime: null,
startTime: null,
nrGuesses: 0,
guessesBefore: [],
guessesAfter: [],
guessValue: '',
guessError: '',
resultGameID: '',
resultGuesses: '',
resultTimeTaken: '',
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) {
return this.gameID = result.game;
}
},
async doGuess() {
this.guessError = null;
this.guessValue = this.guessValue.toLowerCase();
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;
}
this.nrGuesses++;
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;
}
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';
this.resultTimeTaken = '⏱️ ' + getFormattedTime(this.winTime - this.startTime);
}
}
}),
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
}
})
});
/* Time formatting **/
function getFormattedTime(milliseconds) {
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';
}
/* Clipboard stuff **/
var clip = new Clipboard('.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>';
});
/* Game timer, original from alphaguess.com **/
function go() {
window.timerID = window.setInterval(timer, 0);
}
function timer(){
var nextgame = document.getElementById('nextgame');
var now = new Date();
var midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()+1, 0, 0, 0);
var diff = Math.floor((midnight - now)/1000);
var hoursRemain = Math.floor(diff/(60*60));
var minutesRemain = Math.floor((diff-hoursRemain*60*60)/60);
var secondsRemain = Math.floor(diff%60);
nextgame.innerHTML = '<span class="nextgame">'+addZero(hoursRemain)+':'+addZero(minutesRemain)+':'+addZero(secondsRemain)+' over</span>';
}
function addZero(num){
if(num <=9) return '0'+num;
else return num;
}
go();
/* Get current gameID etc **/
document.addEventListener('alpine:initialized', () => {
/* On AlpineJS completely loaded, do all this */
Alpine.store('alfagok').getGameID();
})

View File

@@ -0,0 +1,73 @@
<!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">
<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 id="nextgame"></span></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>
<center>
<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 style="color:var(--blue)">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>
</center>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.5.12/clipboard.min.js" rel=preload></script>
<script src="/static/game.js"></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,6 +1,6 @@
import random import random
MIN_LENGTH = 5 MIN_LENGTH = 4
MAX_LENGTH = 10 MAX_LENGTH = 10
NUMBER_DAYS = 5 * 365 NUMBER_DAYS = 5 * 365
@@ -16,12 +16,12 @@ with open('basiswoorden-gekeurd.txt', 'r', encoding='utf-8') as wordfile:
if word.isalpha() and word.lower() == word: if word.isalpha() and word.lower() == word:
# Word is valid for our dictionary # Word is valid for our dictionary
dictionary_list.append(f'{word}\n') dictionary_list.append(f'{word}\n')
if word.isalpha() and word.lower() == word and len(word) > MIN_LENGTH and len(word) <= MAX_LENGTH: if word.isalpha() and word.lower() == word and len(word) >= MIN_LENGTH and len(word) <= MAX_LENGTH:
# Word is 'fit' for our game # Word is 'fit' for our game
result_list.append(f'{word}\n') result_list.append(f'{word}\n')
# print(result_list) # print(result_list)
print(f'words filtered: {len(result_list)} with length > {MIN_LENGTH} and <= {MAX_LENGTH}') print(f'words filtered: {len(result_list)} with length >= {MIN_LENGTH} and <= {MAX_LENGTH}')
print(f'words in dictionary: {len(dictionary_list)}') print(f'words in dictionary: {len(dictionary_list)}')
with open('filtered.txt', 'w', encoding='utf-8') as f: with open('filtered.txt', 'w', encoding='utf-8') as f: