Compare commits

...
Author SHA1 Message Date
adamandClaude Opus 5.5 eccee4d3f1 always remove the SSH key after a deploy
The clean up step was skipped whenever a transfer failed, leaving the
deploy key on the runner. Run it even when an earlier step fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:50 +00:00
adamandClaude Opus 5.5 45de1fd982 deploy website pages with the game
Copy the pages in www/html/wordle to /var/www/html/wordle on the server,
where nginx serves them from, on every deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:41 +00:00
adamandClaude Opus 5.5 7a95e50fe7 lowercase usernames on the website
The game saves usernames in lowercase, but the search box and the
user/board URL parameters were used as typed, so searching "Adam"
reported that the user had never played. Lowercase them before use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:50 +00:00
adamandClaude Opus 5.5 3d78ff7863 show board dates in UTC on the website
Board days are UTC epoch days (midnight UTC), but toLocaleDateString
formatted them in the viewer's timezone, so anyone west of UTC saw
every game dated the day before. Format them in UTC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:37 +00:00
adamandClaude Opus 5.5 4c6c53ff13 add website pages
Copies of the pages served from /var/www/html/wordle on the server:
the user search page (index.html), stats.html, games.html and
game.html. Browsers are sent here by nginx while curl gets the raw
board and stats files. These aren't deployed by the workflow yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:22 +00:00
adamandClaude Opus 5.5 8e62094a6f treat Ctrl-D at the guess prompt like Enter
Ctrl-D doesn't move the cursor to a new line, but the retry prompts
move up a line assuming Enter was pressed, so each Ctrl-D climbed up
the screen over the keyboard and board. It also quit the game, losing
an offline game to a stray key press.

On a terminal, print the missing newline and carry on as if Enter was
pressed. Only exit when input has really ended (not a terminal), so a
closed pipe still can't spin the retry loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:39:02 +00:00
adamandClaude Opus 5.5 0f8640e9e8 explain hard mode errors in guess prompt
Replace "GREEN Issue", "YELLOW Issue" and "POOL Issue" with messages
that say which letter broke which rule:

  3rd letter must be A
  Must contain R
  1st letter can't be R   (was yellow there before)
  S is not in the word    (strict mode, grey letter reused)

hard_mode_check now records the failing position, and a yellow letter
reused in the same spot gets its own code (6, previously the unused
"NEW Issue"). Drop the unused "Not A Valid Guess" code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:35:38 +00:00
adamandClaude Opus 5.5 6b07093d68 write the same answer line to both board files
On a loss the answer line was written to RESULT_FILE with the border
indent and bold, but CUR_RESULT_FILE only got the letters. Move the
answer line into render_answer and use it for the terminal and both
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:32:28 +00:00
adamandClaude Opus 5.5 a3f9ae6a17 quit when input ends at the guess prompt
At end of input (Ctrl-D, or stdin closed) read returns straight away
with an empty guess, so the "Must be 5 letters" loop spun forever at
full CPU. If the game's parent process was killed first, the subshell
was left orphaned and spinning.

Exit retrieve_guess when read hits EOF, pass that back through the
retry prompts with a single recursive call, and quit from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:31:21 +00:00
adamandClaude Opus 5.5 d34d98c15d only update stats for the daily game
update_stats ran for every online game, so practice games played after
the daily counted towards played/wins/losses and a practice loss reset
the streak. Move it inside the DAILY_DONE check with the board files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:24 +00:00
adamandClaude Opus 5.5 8dfebaa9f8 zero pad decimal part of average guesses
An average of 3.04 was shown as 3.4 because the hundredths weren't
padded. Pad with parameter expansion to keep the script bash-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:24:24 +00:00
adamandClaude Opus 5.5 f29d81e932 keep hard mode hints when resuming a game
hard_mode_check uses the previous row's greens and yellows, but these
weren't written to the progress file, so after resuming a game the next
guess could drop known green and yellow letters. Save them alongside
the rest of the game state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:19:24 +00:00
adamandClaude Opus 5.5 73f403693a save progress after every guess
Progress was only saved at the start of a game and on Ctrl-C, so a
dropped connection lost every guess made so far. Ctrl-C during the
reveal also saved the scored row without advancing ROW, which gave the
player that guess back on resume.

Save straight after each guess is scored (before the reveal) with the
next row, record the result before the final reveal, and make the
SIGINT trap only tidy up the terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:17:20 +00:00
abrown38andClaude Opus 5.5 1f6a5b4a44 validate username in online mode
Usernames are used in file names and URLs, so a '/' could write files
outside the game directory and a space broke the share links. Only
allow a-z, 0-9 and _ (max 32 chars) and re-prompt otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:12:44 +00:00
abrown38 437d46e975 add colour to keyboard 2026-09-29 17:07:46 +00:00
adam 7621938f04 set program name in help message
Deploy game to server / deploy (push) Successful in 8s
2026-02-11 21:43:00 +00:00
adam 1a9a440509 fix duplicate path in URL
Deploy game to server / deploy (push) Successful in 6s
2026-01-21 12:48:48 +00:00
adam d9a7c94125 add link to website
Deploy game to server / deploy (push) Successful in 7s
2025-11-29 03:50:26 +00:00
6 changed files with 1019 additions and 41 deletions

No files matched your search

+5
View File
@@ -26,5 +26,10 @@ jobs:
run: | run: |
scp -i ~/.ssh/id_rsa wordle ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:${{ vars.REMOTE_DEST }} scp -i ~/.ssh/id_rsa wordle ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:${{ vars.REMOTE_DEST }}
- name: Transfer web pages via SCP
run: |
scp -i ~/.ssh/id_rsa www/html/wordle/* ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:/var/www/html/wordle/
- name: Clean up SSH key - name: Clean up SSH key
if: always() # remove the key even if a transfer failed
run: rm -rf ~/.ssh run: rm -rf ~/.ssh
+91 -41
View File
@@ -33,6 +33,7 @@ function main() {
declare -g ANSWER declare -g ANSWER
declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM' declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
declare -Ag YELLOWS declare -Ag YELLOWS
declare -Ag KEYS
ANSWER=$(select_random_word) ANSWER=$(select_random_word)
set_options "$@" set_options "$@"
@@ -50,24 +51,31 @@ function main() {
init_board init_board
fi fi
[[ "${ONLINE}" == 1 ]] && save_game "NO_EXIT" [[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
render_board render_board
for (( ROW; ROW<HEIGHT; ROW++ )); do for (( ROW; ROW<HEIGHT; ROW++ )); do
declare GUESS declare GUESS
[[ "${KEYBOARD}" == 1 ]] && render_keyboard [[ "${KEYBOARD}" == 1 ]] && render_keyboard
GUESS=$(retrieve_guess) GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
check_guess "${GUESS}" "${ROW}" ; WIN=$? check_guess "${GUESS}" "${ROW}" ; WIN=$?
# Save before the reveal so quitting or disconnecting can't undo a guess
if [[ $WIN == 5 ]] || (( ROW+1 == HEIGHT )); then
save_result
elif [[ "${ONLINE}" == 1 ]]; then
save_game "$(( ROW+1 ))"
fi
reveal_row "${ROW}" reveal_row "${ROW}"
if [[ $WIN == 5 ]]; then if [[ $WIN == 5 ]]; then
save_result
echo -e "${CLEAR_DOWN}YOU WIN" echo -e "${CLEAR_DOWN}YOU WIN"
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\"" echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\"" echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\""
echo "Check out the new homepage in your browser -> ${URL_BASE}"
while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}" while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}"
fi fi
echo "Guess - $(( ROW+1 ))/6" echo "Guess - $(( ROW+1 ))/6"
@@ -75,17 +83,12 @@ function main() {
fi fi
done done
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" render_answer
echo -en "${CLEAR_LINE}${BOLD}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} "
done
echo -en "${NEWLINE}${RESET}"
save_result if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\"" echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\"" echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\""
echo "Check out the new homepage in your browser -> ${URL_BASE}"
while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}" while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}"
fi fi
exit 0 exit 0
@@ -137,9 +140,15 @@ function set_options() {
echo -en "${CLEAR_SCREEN}" echo -en "${CLEAR_SCREEN}"
declare -g USER declare -g USER
read -r -p "Username: " USER read -r -p "Username: " USER || exit 1
USER="${USER,,}" USER="${USER,,}"
# Username is used in file names and URLs so only allow safe characters
while [[ -z "${USER}" || "${USER}" == *[!a-z0-9_]* || ${#USER} -gt 32 ]]; do
read -r -p "${CLEAR_UP1}Username (letters, numbers and _ only): " USER || exit 1
USER="${USER,,}"
done
declare -g DAY declare -g DAY
DAY=$(( EPOCHSECONDS / 86400 )) DAY=$(( EPOCHSECONDS / 86400 ))
@@ -173,14 +182,15 @@ function set_options() {
fi fi
fi fi
trap save_game SIGINT # Progress is saved after every guess so Ctrl-C only needs to tidy the terminal
trap 'echo -en "${SHOW_CURSOR}${RESET}"; exit 0' SIGINT
;; ;;
d) d)
DELAY=0 DELAY=0
;; ;;
h) h)
echo -e \ echo -e \
"Usage: <program_name> [OPTIONS] "Usage: wordle [OPTIONS]
Play a game for Wordle Play a game for Wordle
Options: Options:
@@ -228,29 +238,54 @@ function render_board() {
[[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘" [[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘"
} }
function render_answer() {
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
echo -en "${CLEAR_LINE}${BOLD}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} "
done
echo -en "${NEWLINE}${RESET}"
}
# Exits non-zero when input has ended so the caller can quit
function retrieve_guess() { function retrieve_guess() {
if [[ -n $1 ]]; then if [[ -n $1 ]]; then
read -r -p "${CLEAR_UP1}$1: " GUESS read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
else else
read -r -p "${CLEAR_2END}Enter Guess: " GUESS read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
fi fi
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
done done
declare valid_guess declare valid_guess
validate_guess "${GUESS}" ; valid_guess=$? validate_guess "${GUESS}" ; valid_guess=$?
[[ ${valid_guess} == 1 ]] && GUESS=$(retrieve_guess "Not A Valid Word") declare message
[[ ${valid_guess} == 2 ]] && GUESS=$(retrieve_guess "Not A Valid Guess") declare -r pos="${HARD_MODE_POS}"
[[ ${valid_guess} == 3 ]] && GUESS=$(retrieve_guess "GREEN Issue") declare -r letter="${GUESS:pos:1}"
[[ ${valid_guess} == 4 ]] && GUESS=$(retrieve_guess "YELLOW Issue") declare -ra ordinals=(1st 2nd 3rd 4th 5th)
[[ ${valid_guess} == 5 ]] && GUESS=$(retrieve_guess "POOL Issue") [[ ${valid_guess} == 1 ]] && message="Not A Valid Word"
[[ ${valid_guess} == 6 ]] && GUESS=$(retrieve_guess "NEW Issue") [[ ${valid_guess} == 3 ]] && message="${ordinals[pos]} letter must be ${greens[pos]}"
[[ ${valid_guess} == 4 ]] && message="Must contain ${yellows[pos]}"
[[ ${valid_guess} == 5 ]] && message="${letter^^} is not in the word"
[[ ${valid_guess} == 6 ]] && message="${ordinals[pos]} letter can't be ${letter^^}"
if [[ -n "${message}" ]]; then
GUESS=$(retrieve_guess "${message}") || exit 1
fi
echo -n "${GUESS^^}" echo -n "${GUESS^^}"
} }
# Called when read gets Ctrl-D. On a terminal treat it like Enter, but if
# input has really ended (closed pipe) exit, or the retry loop spins forever
function end_of_input() {
[[ -t 0 ]] || exit 1
# Ctrl-D doesn't move to a new line like Enter, which the prompts expect
echo -en "${NEWLINE}" >&2
}
function render_keyboard() { function render_keyboard() {
declare pos=$(( BORDER*2+7 )) declare pos=$(( BORDER*2+7 ))
echo -en "${RESET}\e[$pos;0H" echo -en "${RESET}\e[$pos;0H"
@@ -260,7 +295,12 @@ function render_keyboard() {
elif [[ $i == 19 ]]; then elif [[ $i == 19 ]]; then
echo -en "${NEWLINE} " echo -en "${NEWLINE} "
fi fi
echo -n "${POOL:$i:1} " declare key="${POOL:$i:1}"
if [[ -n "${KEYS[$key]}" ]]; then
echo -n "${KEYS[$key]}${BOLD}${LETTER}${key}${RESET} "
else
echo -n "${key} "
fi
done done
echo -en "${NEWLINE}" echo -en "${NEWLINE}"
} }
@@ -286,6 +326,8 @@ function hard_mode_check() {
declare GUESS=$1 declare GUESS=$1
for (( i=0; i<WIDTH; i++ )); do for (( i=0; i<WIDTH; i++ )); do
HARD_MODE_POS=$i # letter position that failed, for the error message
if [[ "${STRICT_HARD_MODE}" == 1 ]]; then if [[ "${STRICT_HARD_MODE}" == 1 ]]; then
[[ "${POOL}" == *${GUESS:i:1}* ]] || return 5 [[ "${POOL}" == *${GUESS:i:1}* ]] || return 5
fi fi
@@ -301,7 +343,7 @@ function hard_mode_check() {
for (( r=ROW-1; r>=0; r-- )); do for (( r=ROW-1; r>=0; r-- )); do
CHAR="${GUESS:i:1}" CHAR="${GUESS:i:1}"
PREV_CHAR="${YELLOWS[$r,$i]}" PREV_CHAR="${YELLOWS[$r,$i]}"
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 4 [[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6
done done
done done
@@ -331,6 +373,7 @@ function check_guess() {
if [[ "${_char}" == "${_answer:$c:1}" ]]; then if [[ "${_char}" == "${_answer:$c:1}" ]]; then
greens[c]="${_char}" greens[c]="${_char}"
KEYS[$_char]="${GREEN}"
BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}" BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}"
continue continue
fi fi
@@ -339,6 +382,7 @@ function check_guess() {
chars="${chars/${_char}/}" chars="${chars/${_char}/}"
yellows[c]="${_char}" # volatile - stores previous row yellows yellows[c]="${_char}" # volatile - stores previous row yellows
YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds
[[ "${KEYS[$_char]}" != "${GREEN}" ]] && KEYS[$_char]="${YELLOW}"
BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}" BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}"
fi fi
[[ "${ANSWER}" =~ ${_char} ]] && continue [[ "${ANSWER}" =~ ${_char} ]] && continue
@@ -370,8 +414,9 @@ function load_game() {
source "${PROGRESS_FILE}" source "${PROGRESS_FILE}"
} }
# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function # Takes the row the game should resume from
function save_game() { function save_game() {
declare -r row=$1
: > "${PROGRESS_FILE}" : > "${PROGRESS_FILE}"
for key in "${!BOARD[@]}"; do for key in "${!BOARD[@]}"; do
@@ -382,36 +427,40 @@ function save_game() {
echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}" echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}"
done done
for key in "${!KEYS[@]}"; do
echo "KEYS[$key]='${KEYS[$key]}'" >> "${PROGRESS_FILE}"
done
# Previous row's hints, needed by hard_mode_check after a resume
for key in "${!greens[@]}"; do
echo "greens[$key]='${greens[$key]}'" >> "${PROGRESS_FILE}"
done
for key in "${!yellows[@]}"; do
echo "yellows[$key]='${yellows[$key]}'" >> "${PROGRESS_FILE}"
done
{ {
echo "ROW=${ROW}" echo "ROW=${row}"
echo "POOL=${POOL}" echo "POOL=${POOL}"
echo "HARD_MODE=${HARD_MODE}" echo "HARD_MODE=${HARD_MODE}"
echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}" echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}"
echo "WIN=${WIN}" echo "WIN=${WIN}"
echo "ANSWER=${ANSWER}" echo "ANSWER=${ANSWER}"
echo "IN_PROGRESS=1" echo "IN_PROGRESS=1"
[[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1' [[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1'
} >> "${PROGRESS_FILE}" } >> "${PROGRESS_FILE}"
echo -en "${RESET}"
[[ -z $1 ]] && exit 0
} }
function save_result() { function save_result() {
[[ "${ONLINE}" == 0 ]] && return 0 [[ "${ONLINE}" == 0 ]] && return 0
[[ "${ONLINE}" == 1 ]] && update_stats
if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then
update_stats
render_board > "${RESULT_FILE}" render_board > "${RESULT_FILE}"
render_board > "${CUR_RESULT_FILE}" render_board > "${CUR_RESULT_FILE}"
if [[ $WIN != 5 ]]; then if [[ $WIN != 5 ]]; then
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" >> "${RESULT_FILE}" render_answer >> "${RESULT_FILE}"
echo -en "${CLEAR_LINE}${BOLD}" >> "${RESULT_FILE}" render_answer >> "${CUR_RESULT_FILE}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} " >> "${RESULT_FILE}"
echo -en " ${ANSWER:$i:1} " >> "${CUR_RESULT_FILE}"
done
echo -en "${NEWLINE}${RESET}" >> "${RESULT_FILE}"
echo -en "${NEWLINE}${RESET}" >> "${CUR_RESULT_FILE}"
fi fi
fi fi
echo 'PLAYED=1' > "${PROGRESS_FILE}" echo 'PLAYED=1' > "${PROGRESS_FILE}"
@@ -451,6 +500,7 @@ function update_stats() {
integer_part=$(( result / 100 )) integer_part=$(( result / 100 ))
decimal_part=$(( result % 100 )) decimal_part=$(( result % 100 ))
[[ ${decimal_part} -lt 10 ]] && decimal_part="0${decimal_part}"
AVERAGE="${integer_part}.${decimal_part}" AVERAGE="${integer_part}.${decimal_part}"
fi fi
+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Board Viewer</title>
<style>
body {
background: #111;
color: #eee;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
margin: 0;
min-height: 100vh;
}
h2 {
font-size: 26px;
margin-bottom: 6px;
color: white;
text-align: center;
}
.date-label {
margin-bottom: 16px;
font-size: 15px;
color: #ccc;
}
.nav {
margin-bottom: 18px;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
text-decoration: none;
padding: 0.35rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
/* --- grid --- */
.grid {
display: grid;
grid-template-columns: repeat(5, 50px);
grid-gap: 6px;
margin-bottom: 20px;
}
.tile {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
color: white;
border-radius: 6px;
box-shadow: 0 0 4px rgba(0,0,0,0.5);
transition: color 0.3s ease;
}
.empty {
background: #333 !important;
color: #666;
}
.hidden {
color: transparent !important;
}
/* Reveal button */
#reveal {
padding: 10px 18px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #4CAF50;
color: white;
cursor: pointer;
margin-bottom: 25px;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
#reveal:hover {
background: #45A047;
}
#reveal.pressed {
transform: translateY(2px);
box-shadow: inset 0 0 10px rgba(0,0,0,0.4);
}
/* Raw ANSI box (hidden) */
pre {
background: #222;
color: #ccc;
padding: 10px;
border-radius: 6px;
width: 90%;
max-width: 600px;
overflow-x: auto;
display: none;
}
/* --- Error box (nice green glow) --- */
.error-box {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
margin-top: 25px;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
max-width: 600px;
text-align: center;
font-size: 1.1rem;
color: #ccc;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<h2 id="title">Wordle Board</h2>
<div id="date" class="date-label"></div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
<a href="#" id="games-link">📅 Recent games</a>
</div>
<div id="grid-container"></div>
<button id="reveal">Reveal Letters</button>
<div id="error" class="error-box" style="display:none;"></div>
<pre id="raw-output"></pre>
<script>
/* Convert xterm colors */
function xtermToRgb(n) {
// Custom overrides for Wordle colors
if (n === 10) return "rgb(44, 192, 44)"; // green
if (n === 3) return "rgb(176, 176, 33)"; // yellow
// Default behavior for all other xterm colors
if (n < 16) {
const base = [
'#000000','#800000','#008000','#808000',
'#000080','#800080','#008080','#c0c0c0',
'#808080','#ff0000','#00ff00','#ffff00',
'#0000ff','#ff00ff','#00ffff','#ffffff'
];
return base[n] || '#000';
}
if (n <= 231) {
n -= 16;
const r = Math.floor(n / 36) % 6;
const g = Math.floor(n / 6) % 6;
const b = n % 6;
const f = x => (x === 0 ? 0 : x * 40 + 55);
return `rgb(${f(r)}, ${f(g)}, ${f(b)})`;
}
const c = (n - 232) * 10 + 8;
return `rgb(${c}, ${c}, ${c})`;
}
/* Parse ANSI board text into rows of {char, bg} */
function parseAnsiBoard(text) {
const lines = text.split("\n").filter(l => l.includes("│"));
const board = [];
for (const line of lines) {
const inside = line.replace(/.*│(.*)│.*/, "$1").trim();
let row = [];
let bg = "#000";
let i = 0;
while (i < inside.length) {
if (inside[i] === "\x1b") {
const end = inside.indexOf("m", i);
const seq = inside.substring(i+2, end).split(";").map(Number);
if (seq[0] === 48 && seq[1] === 5) {
bg = xtermToRgb(seq[2]);
} else if (seq[0] === 0) {
bg = "#000";
}
i = end + 1;
continue;
}
if (/[A-Z]/.test(inside[i])) {
row.push({ char: inside[i], bg });
}
i++;
}
if (row.length === 5) board.push(row);
}
return board;
}
/* Fetch an epoch-day-based board (user-<day>.board) */
async function fetchBoard(user, epochDay) {
const boardName = `${user}-${epochDay}.board`;
const url = `/wordle/rawboard/${boardName}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const text = await resp.text();
return { boardName, text };
}
async function loadBoard() {
const params = new URLSearchParams(location.search);
const boardName = params.get("board")?.toLowerCase(); // game saves names in lowercase
const errorBox = document.getElementById("error");
const dateEl = document.getElementById("date");
const titleEl = document.getElementById("title");
const gridContainer = document.getElementById("grid-container");
const rawOutput = document.getElementById("raw-output");
const revealBtn = document.getElementById("reveal");
// Hide button until we know we have a board
revealBtn.style.display = "none";
if (!boardName) {
errorBox.style.display = "block";
errorBox.textContent = "No board specified.";
return;
}
const user = boardName.replace(/-.*$/, "").replace(".board", "");
titleEl.textContent = `Wordle Board for ${user}`;
// Wire nav
document.getElementById("stats-link").href = `/stats.html?user=${encodeURIComponent(user)}`;
document.getElementById("games-link").href = `/games.html?user=${encodeURIComponent(user)}`;
let finalText = "";
let finalEpoch = null;
const idMatch = boardName.match(/-(\d+)\.board$/);
// Case 1: boardName has ID → user-<epochDay>.board
if (idMatch) {
const epochDay = Number(idMatch[1]);
const r = await fetchBoard(user, epochDay);
// If not found OR text-only (no ANSI), this day has no board
if (!r || !r.text.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This board does not exist for that day.";
return;
}
finalText = r.text;
finalEpoch = epochDay;
}
// Case 2: boardName has NO ID → "user.board" semantics
else {
const today = Math.floor(Date.now() / 1000 / 86400);
// First: check today's epoch board (user-<today>.board)
const rToday = await fetchBoard(user, today);
if (rToday && rToday.text.includes("\x1b[")) {
finalText = rToday.text;
finalEpoch = today;
} else {
// Fallback: last board played via /wordle/rawboard/user.board
const latestUrl = `/wordle/rawboard/${encodeURIComponent(user)}.board`;
const latestResp = await fetch(latestUrl);
if (!latestResp.ok) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
const latestText = await latestResp.text();
// If that "latest" file is only text (no ANSI), treat as no boards
if (!latestText.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
finalText = latestText;
finalEpoch = null;
dateEl.textContent = "Last board played";
}
}
// If we know the exact epoch day and haven't set date text yet, show it
if (finalEpoch !== null && !dateEl.textContent) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
dateEl.textContent = new Date(finalEpoch * 86400 * 1000)
.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
});
}
rawOutput.textContent = finalText;
let parsed = parseAnsiBoard(finalText);
// If somehow there is no parsed grid (safety), treat as "no boards"
if (!parsed.length) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
gridContainer.innerHTML = "";
return;
}
// Always fill to 6 rows with empties for a full Wordle grid
while (parsed.length < 6) {
parsed.push([
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true }
]);
}
const grid = document.createElement("div");
grid.className = "grid";
parsed.forEach(row => {
row.forEach(cell => {
const div = document.createElement("div");
div.className = "tile hidden";
div.textContent = cell.char.trim() === "" ? "" : cell.char;
if (cell.empty) {
div.classList.add("empty");
} else {
div.style.backgroundColor = cell.bg;
}
grid.appendChild(div);
});
});
gridContainer.innerHTML = "";
gridContainer.appendChild(grid);
// Now that we have a real board, show reveal button and wire press-to-reveal behavior
revealBtn.style.display = "inline-block";
const tiles = document.querySelectorAll(".tile");
function showLetters() {
tiles.forEach(t => t.classList.remove("hidden"));
revealBtn.classList.add("pressed");
}
function hideLetters() {
tiles.forEach(t => t.classList.add("hidden"));
revealBtn.classList.remove("pressed");
}
// Mouse support
revealBtn.addEventListener("mousedown", showLetters);
document.addEventListener("mouseup", hideLetters);
// Touch support
revealBtn.addEventListener("touchstart", (e) => {
e.preventDefault();
showLetters();
}, { passive: false });
document.addEventListener("touchend", hideLetters);
}
loadBoard();
</script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Recent Wordle Games</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: Arial, sans-serif;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.games-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
}
ul.games {
list-style: none;
padding: 0;
margin: 0;
}
ul.games li {
padding: 0.5rem 0;
border-bottom: 1px solid #252525;
display: flex;
justify-content: space-between;
align-items: center;
}
ul.games li:last-child {
border-bottom: none;
}
.game-date {
color: #e0ffe8;
font-family: monospace;
}
.game-link {
text-decoration: none;
color: #51ff7a;
font-size: 0.9rem;
}
.game-link:hover {
text-decoration: underline;
}
.empty-msg {
color: #aaa;
font-style: italic;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Recent Games for <span id="username">...</span></h1>
<div class="subtitle">Last 30 days (only days actually played)</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
</div>
<div class="games-card">
<ul class="games" id="games-list"></ul>
<div id="empty" class="empty-msg" style="display:none;">No games in the last 30 days.</div>
</div>
</div>
<script>
function formatDateFromEpochDays(epochDays) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
const date = new Date(epochDays * 86400 * 1000);
return date.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "short",
year: "numeric",
month: "short",
day: "numeric"
});
}
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const usernameSpan = document.getElementById("username");
const gamesList = document.getElementById("games-list");
const emptyMsg = document.getElementById("empty");
const statsLink = document.getElementById("stats-link");
if (!user) {
usernameSpan.textContent = "Unknown";
emptyMsg.style.display = "block";
emptyMsg.textContent = "Error: missing ?user=username in URL.";
statsLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
statsLink.href = `/stats.html?user=${encodeURIComponent(user)}`;
const todayEpochDays = Math.floor(Date.now() / 1000 / 86400);
const games = [];
// Look back over last 30 days
for (let d = todayEpochDays; d > todayEpochDays - 30; d--) {
const boardName = `${user}-${d}.board`;
const url = `/wordle/rawboard/${encodeURIComponent(boardName)}`;
try {
const resp = await fetch(url);
if (!resp.ok) continue;
const text = await resp.text();
// A "real" game day has ANSI escape codes
if (text.includes("\x1b[")) {
games.push({ epochDays: d, boardName });
}
} catch (e) {
// ignore errors for individual days
}
}
if (!games.length) {
emptyMsg.style.display = "block";
return;
}
gamesList.innerHTML = "";
games.forEach(game => {
const li = document.createElement("li");
const dateSpan = document.createElement("span");
dateSpan.className = "game-date";
dateSpan.textContent = formatDateFromEpochDays(game.epochDays);
const link = document.createElement("a");
link.className = "game-link";
link.href = `/game.html?board=${encodeURIComponent(game.boardName)}`;
link.textContent = "View board";
li.appendChild(dateSpan);
li.appendChild(link);
gamesList.appendChild(li);
});
})();
</script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Viewer</title>
<style>
body {
background: #0b0b0b;
color: #f2f2f2;
font-family: Arial, sans-serif;
min-height: 100vh;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: #141414;
border-radius: 12px;
padding: 2rem 2.5rem;
box-shadow: 0 0 30px rgba(0,0,0,0.7);
max-width: 420px;
width: 100%;
text-align: center;
}
h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 2rem;
color: #51ff7a;
text-shadow: 0 0 10px rgba(81,255,122,0.6);
}
p {
margin-top: 0;
margin-bottom: 2rem;
color: #aaa;
font-size: 0.95rem;
}
.form-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1;
padding: 0.6rem 0.7rem;
border-radius: 6px;
border: 1px solid #333;
background: #1c1c1c;
color: #f2f2f2;
font-size: 1rem;
}
input[type="text"]:focus {
outline: none;
border-color: #51ff7a;
box-shadow: 0 0 6px rgba(81,255,122,0.6);
}
button {
padding: 0.6rem 1rem;
border-radius: 6px;
border: none;
background: #51ff7a;
color: #061006;
font-weight: bold;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #3ee067;
}
.hint {
margin-top: 1rem;
font-size: 0.85rem;
color: #777;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Wordle Viewer</h1>
<p>Enter a username to view their stats and games.</p>
<form id="search-form">
<div class="form-row">
<input id="user-input" type="text" placeholder="Username (e.g. adam)" autocomplete="off" />
<button type="submit">View stats</button>
</div>
</form>
<div class="hint">
You’ll be taken to <code>stats.html?user=&lt;name&gt;</code>.
</div>
</div>
<script>
const form = document.getElementById("search-form");
const input = document.getElementById("user-input");
form.addEventListener("submit", (e) => {
e.preventDefault();
// The game saves usernames in lowercase
const user = input.value.trim().toLowerCase();
if (!user) return;
window.location.href = `/stats.html?user=${encodeURIComponent(user)}`;
});
</script>
</body>
</html>
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Stats</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: monospace;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.stats-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
white-space: pre-wrap;
}
pre {
font-family: inherit;
margin-top: 1rem;
white-space: pre-wrap;
}
/* ANSI color classes */
.ansi-black { color: #000; }
.ansi-red { color: #ff6b6b; }
.ansi-green { color: #51ff7a; }
.ansi-yellow { color: #ffe66d; }
.ansi-blue { color: #4dc0ff; }
.ansi-magenta { color: #ff70ff; }
.ansi-cyan { color: #63e6ff; }
.ansi-white { color: #ddd; }
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Wordle Stats for <span id="username">...</span></h1>
<div class="subtitle">Daily performance summary</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="games-link">📅 Recent games</a>
<a href="#" id="latest-board-link">🧩 Latest board</a>
</div>
<div class="stats-card">
<pre id="output">Loading…</pre>
</div>
</div>
<script>
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const output = document.getElementById("output");
const usernameSpan = document.getElementById("username");
const gamesLink = document.getElementById("games-link");
const latestBoardLink = document.getElementById("latest-board-link");
if (!user) {
usernameSpan.textContent = "Unknown";
output.textContent = "Error: missing ?user=username in URL.";
gamesLink.removeAttribute("href");
latestBoardLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
// Wire nav links for this user
gamesLink.href = `/games.html?user=${encodeURIComponent(user)}`;
latestBoardLink.href = `/game.html?board=${encodeURIComponent(user)}.board`;
try {
const resp = await fetch(`/wordle/rawstats/${encodeURIComponent(user)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
let text = await resp.text();
const ansiMap = {
'30': 'ansi-black',
'31': 'ansi-red',
'32': 'ansi-green',
'33': 'ansi-yellow',
'34': 'ansi-blue',
'35': 'ansi-magenta',
'36': 'ansi-cyan',
'37': 'ansi-white'
};
text = text.replace(/\x1b\[(\d+)m/g, (match, code) => {
if (code === "0") return "</span>";
const cls = ansiMap[code];
return cls ? `<span class="${cls}">` : "";
});
if ((text.match(/<span/g) || []).length > (text.match(/<\/span>/g) || []).length) {
text += "</span>";
}
output.innerHTML = text;
} catch (err) {
output.textContent = `Error loading stats for ${user}: ${err.message}`;
}
})();
</script>
</body>
</html>