From 4cf523bf6da6501c6a72288992bc180bd2532d90 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 18 Mar 2026 22:54:45 +0100 Subject: [PATCH] refactor: move Python packages under python_pkg/ - Move puzzle_solver/, poker_modifier_app/, articles/, tests/ into python_pkg/ - Move moviepy_showcase.py and _moviepy_*.py into python_pkg/moviepy_showcase/ - Update all imports to use python_pkg. prefix - Update pyproject.toml per-file-ignores and pytest testpaths - Add pre-commit hook to enforce Python files under python_pkg/ --- python_pkg/articles/.clang-format | 16 + python_pkg/articles/.gitignore | 2 + python_pkg/articles/Makefile | 61 + python_pkg/articles/README.md | 17 + python_pkg/articles/__init__.py | 1 + python_pkg/articles/cppcheck.txt | 478 +++++ python_pkg/articles/data/articles.json | 24 + python_pkg/articles/index.html | 233 +++ python_pkg/articles/run.sh | 33 + python_pkg/articles/run_tests.sh | 19 + python_pkg/articles/server_c.c | 1640 +++++++++++++++++ python_pkg/articles/sw.js | 31 + python_pkg/articles/test_server_api.py | 109 ++ python_pkg/articles/test_site_size.py | 20 + python_pkg/articles/tools/funcsize.awk | 31 + python_pkg/moviepy_showcase/__init__.py | 1 + .../moviepy_showcase/_moviepy_audio_output.py | 357 ++++ .../moviepy_showcase/_moviepy_clip_types.py | 282 +++ .../_moviepy_video_effects.py | 336 ++++ .../moviepy_showcase/moviepy_showcase.py | 306 +++ python_pkg/poker_modifier_app/README.md | 58 + .../poker_modifier_app/README_python.md | 138 ++ python_pkg/poker_modifier_app/__init__.py | 1 + python_pkg/poker_modifier_app/_poker_gui.py | 303 +++ .../poker_modifier_app/_poker_modifiers.py | 465 +++++ python_pkg/poker_modifier_app/index.html | 45 + .../poker_modifier_app/poker_modifier_app.py | 266 +++ python_pkg/poker_modifier_app/script.js | 175 ++ python_pkg/poker_modifier_app/style.css | 234 +++ python_pkg/puzzle_solver/README.md | 55 + python_pkg/puzzle_solver/__init__.py | 1 + python_pkg/puzzle_solver/__main__.py | 5 + python_pkg/puzzle_solver/main.py | 109 ++ python_pkg/puzzle_solver/parse_image.py | 438 +++++ python_pkg/puzzle_solver/solver.py | 330 ++++ 35 files changed, 6620 insertions(+) create mode 100644 python_pkg/articles/.clang-format create mode 100644 python_pkg/articles/.gitignore create mode 100644 python_pkg/articles/Makefile create mode 100644 python_pkg/articles/README.md create mode 100644 python_pkg/articles/__init__.py create mode 100644 python_pkg/articles/cppcheck.txt create mode 100644 python_pkg/articles/data/articles.json create mode 100644 python_pkg/articles/index.html create mode 100755 python_pkg/articles/run.sh create mode 100755 python_pkg/articles/run_tests.sh create mode 100644 python_pkg/articles/server_c.c create mode 100644 python_pkg/articles/sw.js create mode 100644 python_pkg/articles/test_server_api.py create mode 100644 python_pkg/articles/test_site_size.py create mode 100644 python_pkg/articles/tools/funcsize.awk create mode 100644 python_pkg/moviepy_showcase/__init__.py create mode 100644 python_pkg/moviepy_showcase/_moviepy_audio_output.py create mode 100644 python_pkg/moviepy_showcase/_moviepy_clip_types.py create mode 100644 python_pkg/moviepy_showcase/_moviepy_video_effects.py create mode 100644 python_pkg/moviepy_showcase/moviepy_showcase.py create mode 100644 python_pkg/poker_modifier_app/README.md create mode 100644 python_pkg/poker_modifier_app/README_python.md create mode 100644 python_pkg/poker_modifier_app/__init__.py create mode 100644 python_pkg/poker_modifier_app/_poker_gui.py create mode 100644 python_pkg/poker_modifier_app/_poker_modifiers.py create mode 100644 python_pkg/poker_modifier_app/index.html create mode 100644 python_pkg/poker_modifier_app/poker_modifier_app.py create mode 100644 python_pkg/poker_modifier_app/script.js create mode 100644 python_pkg/poker_modifier_app/style.css create mode 100644 python_pkg/puzzle_solver/README.md create mode 100644 python_pkg/puzzle_solver/__init__.py create mode 100644 python_pkg/puzzle_solver/__main__.py create mode 100644 python_pkg/puzzle_solver/main.py create mode 100644 python_pkg/puzzle_solver/parse_image.py create mode 100644 python_pkg/puzzle_solver/solver.py diff --git a/python_pkg/articles/.clang-format b/python_pkg/articles/.clang-format new file mode 100644 index 0000000..dcfa22e --- /dev/null +++ b/python_pkg/articles/.clang-format @@ -0,0 +1,16 @@ +BasedOnStyle: LLVM +Language: Cpp +ColumnLimit: 100 +ReflowComments: true +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: AllIfsAndElse +AllowShortLoopsOnASingleLine: true +AllowShortCaseLabelsOnASingleLine: true +BinPackArguments: true +BinPackParameters: true +BreakBeforeBraces: Attach +PointerAlignment: Left +IndentWidth: 2 +TabWidth: 2 +UseTab: Never +SortIncludes: false diff --git a/python_pkg/articles/.gitignore b/python_pkg/articles/.gitignore new file mode 100644 index 0000000..b99cebb --- /dev/null +++ b/python_pkg/articles/.gitignore @@ -0,0 +1,2 @@ +data/* +uploads/* diff --git a/python_pkg/articles/Makefile b/python_pkg/articles/Makefile new file mode 100644 index 0000000..c7da790 --- /dev/null +++ b/python_pkg/articles/Makefile @@ -0,0 +1,61 @@ +CC ?= cc +CFLAGS ?= -O2 -Wall -Wextra -pedantic +LDFLAGS ?= + +.ONESHELL: + +all: server_c + +server_c: server_c.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +format: + @command -v clang-format >/dev/null 2>&1 || { echo "clang-format not found"; exit 1; } + @echo "Running clang-format (ColumnLimit=100)..." + @clang-format -i -style=file server_c.c + +format-check: + @command -v clang-format >/dev/null 2>&1 || { echo "clang-format not found"; exit 1; } + @echo "Checking formatting with clang-format..." + @# Use clang-format dry-run so this works without git and doesn't depend on commit state + @if clang-format --dry-run -Werror -style=file server_c.c >/dev/null 2>&1; then \ + echo "Format check passed"; \ + else \ + echo "Formatting changes needed (run 'make format')"; \ + exit 1; \ + fi + +# fail if any function exceeds 20 non-empty lines between '{' and matching '}' +funcsize-check: + @echo "Checking function sizes (<= 20 lines)..." + @command -v clang-tidy >/dev/null 2>&1 || { echo "clang-tidy not found"; exit 1; } + @clang-tidy \ + -checks=readability-function-size \ + -warnings-as-errors=readability-function-size \ + -config='{ "CheckOptions": [ { "key": "readability-function-size.LineThreshold", "value": "20" } ] }' \ + server_c.c -- -std=gnu11 $(CFLAGS) $(LDFLAGS) + +lint: + @echo "Running clang-tidy..." + @clang-tidy \ + -checks=clang-analyzer-*,clang-diagnostic-*,readability-function-size,-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling \ + -config='{ "CheckOptions": [ { "key": "readability-function-size.LineThreshold", "value": "20" } ] }' \ + server_c.c -- -std=gnu11 $(CFLAGS) $(LDFLAGS) || true + @echo "Running cppcheck..." + @cppcheck --enable=all --inconclusive --std=c11 --language=c --quiet --inline-suppr --check-level=exhaustive --suppress=missingIncludeSystem server_c.c || true + @echo "Checking line length (<= 100 chars) in server_c.c..." + @awk 'length($$0)>100{print "server_c.c:" NR ": line too long (" length($$0) " > 100)"; err=1} END{if(err){print "Line length check FAILED"; exit 1} else {print "Line length check passed"}}' server_c.c + +build: minify + +minify: + npx -y html-minifier-terser \ + --collapse-whitespace --remove-comments --remove-attribute-quotes \ + --remove-redundant-attributes --minify-css true --minify-js true \ + -o index.min.html index.html + npx -y terser sw.js -o sw.min.js -c -m + +clean: + rm -f server_c + +.PHONY: all clean diff --git a/python_pkg/articles/README.md b/python_pkg/articles/README.md new file mode 100644 index 0000000..eb6aff2 --- /dev/null +++ b/python_pkg/articles/README.md @@ -0,0 +1,17 @@ +Mini Articles (<=14KB) + +- Single-file site: `index.html` with inline CSS & JS +- Features: + - List of articles with thumbnails (cards) + - Read view: thumbnail, title, body (supports inline images/videos) + - Create view: title, thumbnail picker/drag-drop, rich body via contenteditable + - Drag/drop or choose images/videos anywhere in the body + - Local persistence via localStorage (no server required) + +How to open + +- Open `site/index.html` in a browser. + +Tests + +- `pytest` includes a test to enforce the 14KB budget for `index.html`. diff --git a/python_pkg/articles/__init__.py b/python_pkg/articles/__init__.py new file mode 100644 index 0000000..ba620f7 --- /dev/null +++ b/python_pkg/articles/__init__.py @@ -0,0 +1 @@ +"""Articles site tests package.""" diff --git a/python_pkg/articles/cppcheck.txt b/python_pkg/articles/cppcheck.txt new file mode 100644 index 0000000..60b6990 --- /dev/null +++ b/python_pkg/articles/cppcheck.txt @@ -0,0 +1,478 @@ +server_c.c:2:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:3:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:4:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:5:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:6:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:7:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:8:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:9:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:10:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:11:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:12:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:13:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:14:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:15:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:16:0: information: Include file: not found. Please note: Cppcheck does not need standard library headers to get proper results. [missingIncludeSystem] +#include +^ +server_c.c:0:0: information: Limiting analysis of branches. Use --check-level=exhaustive to analyze all branches. [normalCheckLevelMaxBranches] + +^ +server_c.c:411:77: warning: Either the condition 'elen<8' is redundant or the array 'ext[4]' is accessed at index 7, which is out of bounds. [arrayIndexOutOfBoundsCond] + size_t elen=0; while(ext[elen] && elen<8 && isalnum((unsigned char)ext[elen])) elen++; char* ext_safe=strndup_local(ext,elen?elen:3); if(!ext_safe){ send_response(c,500,"Internal Server Error","application/json","",0,true); return; } + ^ +server_c.c:411:45: note: Assuming that condition 'elen<8' is not redundant + size_t elen=0; while(ext[elen] && elen<8 && isalnum((unsigned char)ext[elen])) elen++; char* ext_safe=strndup_local(ext,elen?elen:3); if(!ext_safe){ send_response(c,500,"Internal Server Error","application/json","",0,true); return; } + ^ +server_c.c:411:77: note: Array index out of bounds + size_t elen=0; while(ext[elen] && elen<8 && isalnum((unsigned char)ext[elen])) elen++; char* ext_safe=strndup_local(ext,elen?elen:3); if(!ext_safe){ send_response(c,500,"Internal Server Error","application/json","",0,true); return; } + ^ +server_c.c:411:31: style: Array index 'elen' is used before limits check. [arrayIndexThenCheck] + size_t elen=0; while(ext[elen] && elen<8 && isalnum((unsigned char)ext[elen])) elen++; char* ext_safe=strndup_local(ext,elen?elen:3); if(!ext_safe){ send_response(c,500,"Internal Server Error","application/json","",0,true); return; } + ^ +server_c.c:314:129: style: Condition 'tcontent[0]=='['' is always true [knownConditionTrueFalse] + char* tcontent=ltrim_dup(content); if(tcontent && tcontent[0]=='['){ size_t clen=strlen(tcontent); if(clen>=2 && tcontent[0]=='[' && tcontent[clen-1]==']'){ + ^ +server_c.c:314:66: note: Assuming that condition 'tcontent[0]=='['' is not redundant + char* tcontent=ltrim_dup(content); if(tcontent && tcontent[0]=='['){ size_t clen=strlen(tcontent); if(clen>=2 && tcontent[0]=='[' && tcontent[clen-1]==']'){ + ^ +server_c.c:314:129: note: Condition 'tcontent[0]=='['' is always true + char* tcontent=ltrim_dup(content); if(tcontent && tcontent[0]=='['){ size_t clen=strlen(tcontent); if(clen>=2 && tcontent[0]=='[' && tcontent[clen-1]==']'){ + ^ +server_c.c:463:6: warning: inconclusive: Width 1023 given in format string (no. 2) is smaller than destination buffer 'path[4096]'. [invalidScanfFormatWidth_smaller] + if(sscanf(buf, "%15s %1023s", method, path) < 2){ close(c); return; } + ^ +server_c.c:289:37: error: Common realloc mistake: 'objs' nulled but not freed upon failure [memleakOnRealloc] + if(count==cap){ cap*=2; objs=realloc(objs, cap*sizeof(char*)); } + ^ +server_c.c:369:39: error: Common realloc mistake: 'objs' nulled but not freed upon failure [memleakOnRealloc] + if(count==cap){ cap*=2; objs=realloc(objs, cap*sizeof(char*)); } + ^ +server_c.c:42:136: warning: If memory allocation fails, then there is a possible null pointer dereference: data [nullPointerOutOfMemory] +static int write_file_all(const char* path, const char* data, size_t len){ FILE* f=fopen(path,"wb"); if(!f) return -1; size_t n=fwrite(data,1,len,f); fclose(f); return n==len?0:-1; } + ^ +server_c.c:312:59: note: Assuming allocation function fails + size_t L=strlen(obj); size_t tot=L+2; char* arr=malloc(tot+1); size_t w=0; arr[w++]='['; memcpy(arr+w,obj,L); w+=L; arr[w++]=']'; arr[w]='\0'; write_file_all(file, arr, w); free(arr); + ^ +server_c.c:312:59: note: Assignment 'arr=malloc(tot+1)', assigned value is 0 + size_t L=strlen(obj); size_t tot=L+2; char* arr=malloc(tot+1); size_t w=0; arr[w++]='['; memcpy(arr+w,obj,L); w+=L; arr[w++]=']'; arr[w]='\0'; write_file_all(file, arr, w); free(arr); + ^ +server_c.c:312:169: note: Calling function 'write_file_all', 2nd argument 'arr' value is 0 + size_t L=strlen(obj); size_t tot=L+2; char* arr=malloc(tot+1); size_t w=0; arr[w++]='['; memcpy(arr+w,obj,L); w+=L; arr[w++]=']'; arr[w]='\0'; write_file_all(file, arr, w); free(arr); + ^ +server_c.c:42:105: note: Assuming condition is false +static int write_file_all(const char* path, const char* data, size_t len){ FILE* f=fopen(path,"wb"); if(!f) return -1; size_t n=fwrite(data,1,len,f); fclose(f); return n==len?0:-1; } + ^ +server_c.c:42:136: note: Null pointer dereference +static int write_file_all(const char* path, const char* data, size_t len){ FILE* f=fopen(path,"wb"); if(!f) return -1; size_t n=fwrite(data,1,len,f); fclose(f); return n==len?0:-1; } + ^ +server_c.c:64:21: warning: If memory allocation fails, then there is a possible null pointer dereference: p [nullPointerOutOfMemory] + while((p = strstr(p, qkey))){ + ^ +server_c.c:358:187: note: Assuming allocation function fails + for(; i (ssize_t)header_bytes ? (size_t)total - header_bytes : 0; + ^ +server_c.c:355:47: style: Variable 'L2' is assigned a value that is never used. [unreadVariable] + if(t[0] != '['){ free(file); size_t L2=2; send_response(c,200,"OK","application/json","[]",2,true); free(t); return; } + ^ +server_c.c:355:45: style: Variable 'L2' is assigned a value that is never used. [unreadVariable] + if(t[0] != '['){ free(file); size_t L2=2; send_response(c,200,"OK","application/json","[]",2,true); free(t); return; } + ^ +server_c.c:76:93: warning: If memory allocation fails, then there is a possible null pointer dereference: json [ctunullpointerOutOfMemory] +static long long json_get_number(const char* json, const char* key){ const char* p = strstr(json, key); if(!p) return 0; const char* colon=strchr(p,':'); if(!colon) return 0; const char* v=colon+1; while(*v==' '||*v=='\t') v++; return atoll(v); } + ^ +server_c.c:358:187: note: Assuming allocation function fails + for(; i=0?0:-1; } + ^ +nofile:0:0: information: Active checkers: 117/966 (use --checkers-report= to see details) [checkersReport] diff --git a/python_pkg/articles/data/articles.json b/python_pkg/articles/data/articles.json new file mode 100644 index 0000000..35d8357 --- /dev/null +++ b/python_pkg/articles/data/articles.json @@ -0,0 +1,24 @@ +[ + { + "id": "29176c917f1a66c3", + "title": "full featuresd article", + "author": "author", + "body": "\"image\"

This is an important image of course :)

and nother:
\"image\"
", + "thumb": "/uploads/29176c9e7818ee30.jpg", + "createdAt": 1757331025041 + }, + { + "id": "199259f41b8933b8", + "title": "Whats heveier", + "body": "A kilogrem of stel or kilogrem of fethers", + "thumb": "/uploads/27d6402e78a09d65.jpg", + "createdAt": 1757272818104 + }, + { + "id": "19925965dead21e7", + "title": "UwU its my first article", + "body": ":)))

", + "thumb": "/uploads/27d640310e163446.jpg", + "createdAt": 1757272235498 + } +] diff --git a/python_pkg/articles/index.html b/python_pkg/articles/index.html new file mode 100644 index 0000000..7660e69 --- /dev/null +++ b/python_pkg/articles/index.html @@ -0,0 +1,233 @@ + + + + +Mini Articles + + +
+

Mini Articles

+ +
+
+ +
+ + +
+ + + diff --git a/python_pkg/articles/run.sh b/python_pkg/articles/run.sh new file mode 100755 index 0000000..41d1060 --- /dev/null +++ b/python_pkg/articles/run.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run Mini Articles (backend + frontend) using the C server only +# Options (env): HOST (default 127.0.0.1), PORT (default 8000), ARTICLES_DATA_DIR + +DIR=$(cd -- "$(dirname -- "$0")" && pwd) +SITE_DIR="$DIR" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8000}" + +make -s -C "$SITE_DIR" server_c + +# Start C server in background +export HOST PORT ARTICLES_DATA_DIR +"$SITE_DIR/server_c" & +SRV_PID=$! +trap 'kill $SRV_PID 2>/dev/null || true' EXIT INT TERM + +# Give it a moment to start +sleep 0.5 +URL="http://$HOST:$PORT/" + +# Try to open browser on Linux +if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$URL" >/dev/null 2>&1 || true +fi + +echo "Mini Articles running at $URL" + +echo "Press Ctrl+C to stop." +# Wait on server +wait "$SRV_PID" diff --git a/python_pkg/articles/run_tests.sh b/python_pkg/articles/run_tests.sh new file mode 100755 index 0000000..eed213c --- /dev/null +++ b/python_pkg/articles/run_tests.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run only the website tests from this directory +DIR=$(cd -- "$(dirname -- "$0")" && pwd) +cd "$DIR" + +PYTHON_BIN="${PYTHON:-}" +if [[ -z "${PYTHON_BIN}" ]]; then + if command -v python >/dev/null 2>&1; then PYTHON_BIN=python + elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3 + else + echo "Python is required but not found in PATH." >&2 + exit 1 + fi +fi + +# Be explicit to avoid collecting tests from other repo paths +"$PYTHON_BIN" -m pytest -q test_site_size.py test_server_api.py diff --git a/python_pkg/articles/server_c.c b/python_pkg/articles/server_c.c new file mode 100644 index 0000000..cb56c37 --- /dev/null +++ b/python_pkg/articles/server_c.c @@ -0,0 +1,1640 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define RECV_BUF 65536 +#define SMALL_BUF 4096 + +static volatile sig_atomic_t g_stop = 0; +static const char* DOC_ROOT = NULL; // current working directory + +static void on_sigint(int sig) { + (void)sig; + g_stop = 1; +} + +static long long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static const char* getenv_default(const char* k, const char* def) { + const char* v = getenv(k); + return v && *v ? v : def; +} + +// ---- file helpers ---- +static int ensure_dir(const char* path) { + struct stat st; + if (stat(path, &st) == 0) { + if (S_ISDIR(st.st_mode)) return 0; + errno = ENOTDIR; + return -1; + } + if (mkdir(path, 0775) == 0) return 0; + return -1; +} + +static char* path_join(const char* a, const char* b) { + size_t la = strlen(a), lb = strlen(b); + size_t n = la + 1 + lb + 1; + char* r = malloc(n); + if (!r) return NULL; + snprintf(r, n, "%s/%s", a, b); + return r; +} + +static char* data_dir(void) { + const char* env = getenv("ARTICLES_DATA_DIR"); + if (env && *env) { + ensure_dir(env); + return strdup(env); + } + char* d = path_join(DOC_ROOT ? DOC_ROOT : ".", "data"); + if (d) { + ensure_dir(d); + } + return d; +} + +static char* data_file(void) { + char* d = data_dir(); + if (!d) return NULL; + char* f = path_join(d, "articles.json"); + free(d); + return f; +} + +// Read entire file into memory (NUL-terminated). Caller frees. +static char* read_file_all(const char* path, size_t* out_len) { + FILE* f = fopen(path, "rb"); + if (!f) { + if (out_len) *out_len = 0; + return NULL; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + if (sz < 0) sz = 0; + fseek(f, 0, SEEK_SET); + char* buf = malloc((size_t)sz + 1); + if (!buf) { + fclose(f); + return NULL; + } + size_t n = fread(buf, 1, (size_t)sz, f); + fclose(f); + buf[n] = '\0'; + if (out_len) *out_len = n; + return buf; +} + +static int write_file_all(const char* path, const char* data, size_t len) { + FILE* f = fopen(path, "wb"); + if (!f) return -1; + size_t n = fwrite(data, 1, len, f); + fclose(f); + return n == len ? 0 : -1; +} + +// removed unused append_file_line (lint) + +// ---- JSON helpers (minimal) ---- +static size_t json_escaped_len(const char* s) { + size_t n = 0; + for (const char* p = s; *p; ++p) { + if (*p == '"' || *p == '\\' || *p == '\n' || *p == '\r' || *p == '\t') n += 2; + else n++; + } + return n; +} + +static inline char* json_append_escaped(char* w, char c) { + if (c == '"' || c == '\\') { + *w++ = '\\'; + *w++ = c; + return w; + } + if (c == '\n') { + *w++ = '\\'; + *w++ = 'n'; + return w; + } + if (c == '\r') { + *w++ = '\\'; + *w++ = 'r'; + return w; + } + if (c == '\t') { + *w++ = '\\'; + *w++ = 't'; + return w; + } + *w++ = c; + return w; +} + +static void json_escape_into(char* out, const char* s) { + char* w = out; + for (const char* p = s; *p; ++p) w = json_append_escaped(w, *p); + *w = '\0'; +} + +static char* json_escape(const char* s) { + size_t n = json_escaped_len(s); + char* out = (char*)malloc(n + 1); + if (!out) return NULL; + json_escape_into(out, s); + return out; +} + +static const char* skip_ws_commas(const char* p) { + while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',') p++; + return p; +} + +// Parse a JSON string starting at v (after the opening quote). +// Returns malloc'd string and sets *after_end to the char after closing quote. +static inline char json_unescape_char(char c) { + switch (c) { + case '"': + case '\\': + case '/': return c; + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + default: return c; + } +} + +static void parse_json_string_core(const char* v, char* out, size_t* w, const char** after_end) { + bool esc = false; + const char* p = v; + for (; *p; ++p) { + char c = *p; + if (esc) { + out[(*w)++] = json_unescape_char(c); + esc = false; + continue; + } + if (c == '\\') { + esc = true; + continue; + } + if (c == '"') { + *after_end = p + 1; + break; + } + out[(*w)++] = c; + } + if (!*after_end) *after_end = p; +} + +static char* parse_json_string_value(const char* v, const char** after_end) { + char* out = (char*)malloc(strlen(v) + 1); + if (!out) { + *after_end = v; + return NULL; + } + size_t w = 0; + *after_end = NULL; + parse_json_string_core(v, out, &w, after_end); + out[w] = '\0'; + return out; +} + +// NOLINTBEGIN(readability-function-size) +static const char* ensure_quoted_key(const char* key, char** to_free, size_t* out_len) { + *to_free = NULL; + size_t klen = strlen(key); + if (klen == 0) { + *out_len = 0; + return key; + } + if (key[0] == '"') { + *out_len = klen; + return key; + } + char* tmp = malloc(klen + 3); + if (!tmp) { + *out_len = klen; + return key; + } + tmp[0] = '"'; + memcpy(tmp + 1, key, klen); + tmp[klen + 1] = '"'; + tmp[klen + 2] = '\0'; + *to_free = tmp; + *out_len = klen + 2; + return tmp; +} +// NOLINTEND(readability-function-size) + +// NOLINTBEGIN(readability-function-size) +static char* json_get_string(const char* obj, const char* key) { + char* free_key = NULL; + size_t qlen = 0; + const char* qkey = ensure_quoted_key(key, &free_key, &qlen); + const char* p = strchr(obj, '{'); + if (!p) { + free(free_key); + return strdup(""); + } + p++; + while (*p) { + p = skip_ws_commas(p); + if (*p == '}' || !*p) break; + if (*p != '"') { + while (*p && *p != ',' && *p != '}') p++; + continue; + } + const char* ks = p + 1; // start of key text + size_t klen = 0; + bool esc = false; + const char* x = ks; + for (; *x; ++x) { + char c = *x; + if (esc) { + esc = false; + continue; + } + if (c == '\\') { + esc = true; + continue; + } + if (c == '"') break; + klen++; + } + if (*x != '"') break; // malformed + int match = (qlen >= 2) && (klen == qlen - 2) && strncmp(ks, qkey + 1, klen) == 0; + p = x + 1; + while (*p == ' ' || *p == '\t') p++; + if (*p != ':') { + while (*p && *p != ',' && *p != '}') p++; + if (*p == ',') p++; + continue; + } + p++; + while (*p == ' ' || *p == '\t') p++; + if (*p == '"') { + p++; + const char* after = NULL; + char* val = parse_json_string_value(p, &after); + p = after; + if (match) { + free(free_key); + return val ? val : strdup(""); + } + free(val); + } else { + int depth = 0; // skip non-string value + while (*p) { + char c = *p; + if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') { + if (depth == 0) break; + depth--; + } else if (c == ',' && depth == 0) break; + p++; + } + } + if (*p == ',') p++; + } + free(free_key); + return strdup(""); +} +// NOLINTEND(readability-function-size) + +static long long json_get_number(const char* obj, const char* key) { + char* free_key = NULL; + size_t qlen = 0; + const char* qkey = ensure_quoted_key(key, &free_key, &qlen); + const char* p = strstr(obj, qkey); + long long val = 0; + if (p) { + p += qlen; + while (*p && *p != ':') p++; + if (*p == ':') { + p++; + while (*p == ' ' || *p == '\t') p++; + char* endp = NULL; + val = strtoll(p, &endp, 10); + } + } + free(free_key); + return val; +} + +static char* json_get_top_string(const char* obj, const char* key) { + // Top-level object string getter is same as generic one for our simple objects + return json_get_string(obj, key); +} + +// Build object JSON string; caller frees +// NOLINTBEGIN(readability-function-size) +static char* build_article_json(const char* id, const char* title, const char* author, + const char* body, const char* thumb, long long createdAt, + long long updatedAt) { + char* et = json_escape(title ? title : ""); + char* eau = json_escape(author ? author : ""); + char* eb = json_escape(body ? body : ""); + char* eth = json_escape(thumb ? thumb : ""); + if (!et || !eau || !eb || !eth) { + free(et); + free(eau); + free(eb); + free(eth); + return NULL; + } + char createdBuf[64]; + snprintf(createdBuf, sizeof(createdBuf), "%lld", createdAt); + char updated[96] = ""; + if (updatedAt > 0) { + snprintf(updated, sizeof(updated), ",\"updatedAt\":%lld", updatedAt); + } + size_t need = strlen(id) + strlen(et) + strlen(eau) + strlen(eb) + strlen(eth) + + strlen(createdBuf) + strlen(updated) + 80; + char* out = malloc(need); + if (!out) { + free(et); + free(eau); + free(eb); + free(eth); + return NULL; + } + snprintf(out, need, + "{\"id\":\"%s\",\"title\":\"%s\",\"author\":\"%s\",\"body\":\"%s\"" + " ,\"thumb\":\"%s\",\"createdAt\":%s%s}", + id, et, eau, eb, eth, createdBuf, updated); + free(et); + free(eau); + free(eb); + free(eth); + return out; +} +// NOLINTEND(readability-function-size) + +static char* gen_id(void) { + char* out = malloc(17); + if (!out) return NULL; + unsigned int r = (unsigned int)rand(); + long long t = now_ms(); + snprintf(out, 17, "%08x%08x", (unsigned int)(t & 0xffffffff), r); + return out; +} + +// ---- data URL handling ---- +static int b64val(int c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} +// NOLINTBEGIN(readability-function-size) +static unsigned char* base64_decode(const char* s, size_t len, size_t* out_len) { + size_t pad = 0; + if (len >= 1 && s[len - 1] == '=') pad++; + if (len >= 2 && s[len - 2] == '=') pad++; + size_t groups = len / 4; + size_t outcap = groups * 3; + if (pad <= outcap) outcap -= pad; + else outcap = 0; + if (outcap == 0) outcap = 1; // avoid 0-byte malloc and make room for NUL + unsigned char* out = malloc(outcap + 1); + if (!out) return NULL; + size_t w = 0; + int val = 0, valb = -8; + for (size_t i = 0; i < len; i++) { + int c = s[i]; + if (c == '=' || c == '\r' || c == '\n' || c == ' ' || c == '\t') continue; + int d = b64val(c); + if (d < 0) { + free(out); + return NULL; + } + val = (val << 6) + d; + valb += 6; + if (valb >= 0) { + out[w++] = (unsigned char)((val >> valb) & 0xFF); + valb -= 8; + } + } + if (out_len) *out_len = w; + return out; +} +// NOLINTEND(readability-function-size) +static int data_url_header(const char* data_url, char** mime_out, int* is_b64_out, + const char** payload_out) { + if (strncmp(data_url, "data:", 5) != 0) return -1; + const char* p = data_url + 5; + const char* semi = strchr(p, ';'); + const char* comma = strchr(p, ','); + if (!comma) return -1; + char* mime = NULL; + int is_b64 = 0; + if (semi && semi < comma) { + mime = strndup(p, (size_t)(semi - p)); + if (!strncmp(semi, ";base64", 7)) is_b64 = 1; + } else { + mime = strndup(p, (size_t)(comma - p)); + } + *mime_out = mime; + *is_b64_out = is_b64; + *payload_out = comma + 1; + return 0; +} + +static unsigned char* percent_decode_alloc(const char* s, size_t* out_len) { + size_t L = strlen(s); + unsigned char* bytes = (unsigned char*)malloc(L + 1); + if (!bytes) return NULL; + size_t w = 0; + for (size_t i = 0; i < L; i++) { + if (s[i] == '%' && i + 2 < L) { + const char h[3] = {s[i + 1], s[i + 2], '\0'}; + bytes[w++] = (unsigned char)strtol(h, NULL, 16); + i += 2; + } else if (s[i] == '+') { + bytes[w++] = ' '; + } else { + bytes[w++] = (unsigned char)s[i]; + } + } + if (out_len) *out_len = w; + return bytes; +} + +static int parse_data_url(const char* data_url, char** out_mime, unsigned char** out_bytes, + size_t* out_len) { + char* mime = NULL; + int is_b64 = 0; + const char* payload = NULL; + if (data_url_header(data_url, &mime, &is_b64, &payload) != 0) return -1; + unsigned char* bytes = NULL; + size_t blen = 0; + if (is_b64) { + bytes = base64_decode(payload, strlen(payload), &blen); + } else { + bytes = percent_decode_alloc(payload, &blen); + } + if (!bytes) { + free(mime); + return -1; + } + *out_mime = mime; + *out_bytes = bytes; + if (out_len) *out_len = blen; + return 0; +} +static const char* ext_from_mime(const char* mime) { + if (!mime) return NULL; + if (strstr(mime, "image/png")) return "png"; + if (strstr(mime, "image/jpeg")) return "jpg"; + if (strstr(mime, "image/webp")) return "webp"; + if (strstr(mime, "image/gif")) return "gif"; + return NULL; +} + +// ---- JSON array utilities ---- +// NOLINTBEGIN(readability-function-size) +static int find_next_json_object(const char* t, size_t len, size_t* idx, char** out) { + size_t i = *idx; + int depth = 0; + size_t start = 0; + for (; i < len; ++i) { + char c = t[i]; + if (c == '{') { + if (depth == 0) start = i; + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + size_t end = i; + size_t L = end - start + 1; + char* obj = malloc(L + 1); + if (!obj) return -1; + memcpy(obj, t + start, L); + obj[L] = '\0'; + *out = obj; + *idx = i + 1; + return 1; + } + } + } + return 0; +} +// NOLINTEND(readability-function-size) + +static int push_str(char*** arr, size_t* cap, size_t* count, char* s) { + if (*count == *cap) { + size_t ncap = (*cap == 0) ? 8 : (*cap * 2); + void* tmp = realloc(*arr, ncap * sizeof(char*)); + if (!tmp) return -1; + *arr = (char**)tmp; + *cap = ncap; + } + (*arr)[(*count)++] = s; + return 0; +} + +// NOLINTBEGIN(readability-function-size) +static int assemble_array(char** objs, size_t count, char** out, size_t* out_len) { + size_t total = 2; + for (size_t k = 0; k < count; ++k) { + total += strlen(objs[k]); + if (k + 1 < count) total++; + } + char* buf = (char*)malloc(total + 1); + if (!buf) return -1; + size_t w = 0; + buf[w++] = '['; + for (size_t k = 0; k < count; ++k) { + size_t L = strlen(objs[k]); + memcpy(buf + w, objs[k], L); + w += L; + if (k + 1 < count) buf[w++] = ','; + } + buf[w++] = ']'; + buf[w] = '\0'; + *out = buf; + if (out_len) *out_len = w; + return 0; +} +// NOLINTEND(readability-function-size) + +static int write_array_to_file(const char* file, char** objs, size_t count) { + char* out = NULL; + size_t w = 0; + if (assemble_array(objs, count, &out, &w)) return -1; + int rc = write_file_all((char*)file, out, w); + free(out); + return rc; +} + +static char* save_bytes_with_ext(const unsigned char* bytes, size_t blen, const char* ext) { + if (!bytes || !blen) return NULL; + const char* updir = "uploads"; + ensure_dir(updir); + char* name = gen_id(); + if (!name) return NULL; + const char* e = (ext && *ext) ? ext : "bin"; + size_t need = strlen(updir) + 1 + strlen(name) + 1 + strlen(e) + 1; + char* path = malloc(need); + if (!path) { + free(name); + return NULL; + } + snprintf(path, need, "%s/%s.%s", updir, name, e); + free(name); + if (write_file_all(path, (const char*)bytes, blen) != 0) { + free(path); + return NULL; + } + return path; +} + +static int ensure_capacity(char** out, size_t* cap, size_t need) { + if (need <= *cap) return 0; + size_t newcap = need + 64; + void* tmp = realloc(*out, newcap); + if (!tmp) return -1; + *out = tmp; + *cap = newcap; + return 0; +} + +static void buf_copy(char* out, size_t* w, const char* src, size_t len) { + memcpy(out + *w, src, len); + *w += len; +} + +static int write_saved_url(char** out, size_t* cap, size_t* w, const char* saved) { + const char* rel = saved; + size_t need = *w + 1 + strlen(rel) + 1; + if (ensure_capacity(out, cap, need)) return -1; + (*out)[(*w)++] = '/'; + memcpy((*out) + *w, rel, strlen(rel)); + *w += strlen(rel); + return 0; +} + +// NOLINTBEGIN(readability-function-size) +static int process_data_url_segment(const char* url_start, size_t url_len, char** out, size_t* cap, + size_t* w, int* did) { + char* mime = NULL; + unsigned char* bytes = NULL; + size_t bl = 0; + char* urlbuf = strndup(url_start, url_len); + if (!urlbuf) return -1; + int rc = 0; + if (parse_data_url(urlbuf, &mime, &bytes, &bl) == 0) { + const char* ext = ext_from_mime(mime); + char* saved = save_bytes_with_ext(bytes, bl, ext); + if (saved) { + if (write_saved_url(out, cap, w, saved) == 0) { + *did = 1; + } else { + rc = -1; + } + free(saved); + } + } + free(bytes); + free(mime); + free(urlbuf); + return rc; +} +// NOLINTEND(readability-function-size) + +static const char* find_src_attr(const char* p, char* quote_out) { + const char* m = strstr(p, "src=\""); + const char* m2 = strstr(p, "src='"); + if (m && (!m2 || m < m2)) { + *quote_out = '"'; + return m; + } + if (m2) { + *quote_out = '\''; + return m2; + } + return NULL; +} + +// NOLINTBEGIN(readability-function-size) +static int handle_src_segment(const char** p_in, const char* hit, char quote, char** out, + size_t* outcap, size_t* w, int* did) { + size_t prefix_len = (size_t)(hit - *p_in); + if (ensure_capacity(out, outcap, *w + prefix_len + 6)) return -1; + buf_copy(*out, w, *p_in, prefix_len); + memcpy(*out + *w, "src=\"", 5); + *w += 5; + const char* url_start = hit + 5; + if (*url_start == '\'' || *url_start == '"') url_start++; + const char endq = (quote == '\'') ? '\'' : '"'; + const char* url_end = strchr(url_start, endq); + if (!url_end) { + size_t L = strlen(url_start); + if (ensure_capacity(out, outcap, *w + L + 1)) return -1; + buf_copy(*out, w, url_start, L); + *p_in = url_start + L; + return 0; + } + size_t url_len = (size_t)(url_end - url_start); + if (url_len > 5 && strncmp(url_start, "data:", 5) == 0) { + if (process_data_url_segment(url_start, url_len, out, outcap, w, did)) return -1; + } else { + if (ensure_capacity(out, outcap, *w + url_len + 1)) return -1; + buf_copy(*out, w, url_start, url_len); + } + if (ensure_capacity(out, outcap, *w + 1)) return -1; + (*out)[(*w)++] = '"'; + *p_in = url_end + 1; + return 0; +} +// NOLINTEND(readability-function-size) + +// NOLINTBEGIN(readability-function-size) +static char* migrate_inline_images_in_body(const char* body, bool* changed) { + if (!body) return NULL; + const char* p = body; + size_t outcap = strlen(body) + 1; + char* out = malloc(outcap); + if (!out) return NULL; + size_t w = 0; + int did = 0; + while (*p) { + char quote = '\0'; + const char* hit = find_src_attr(p, "e); + if (!hit) { + size_t L = strlen(p); + if (ensure_capacity(&out, &outcap, w + L + 1)) { + free(out); + return NULL; + } + buf_copy(out, &w, p, L); + break; + } + if (handle_src_segment(&p, hit, quote, &out, &outcap, &w, &did)) { + free(out); + return NULL; + } + } + out[w] = '\0'; + if (changed) *changed = did; + return out; +} +// NOLINTEND(readability-function-size) + +// ---- HTTP helpers ---- +// removed unused send_str (lint) + +static void send_response(int c, int code, const char* status, const char* ctype, const char* body, + size_t blen, bool cors) { + char head[SMALL_BUF]; + int n = snprintf(head, sizeof(head), + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n%s\r\n", code, + status, ctype ? ctype : "text/plain", blen, + cors ? "Access-Control-Allow-Origin: *\r\n" + "Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS\r\n" + "Access-Control-Allow-Headers: Content-Type\r\n" + : ""); + send(c, head, (size_t)n, 0); + if (body && blen) send(c, body, blen, 0); +} + +static const char* guess_mime(const char* path) { + const char* ext = strrchr(path, '.'); + if (!ext) return "application/octet-stream"; + ext++; + if (!strcmp(ext, "html")) return "text/html; charset=utf-8"; + if (!strcmp(ext, "css")) return "text/css"; + if (!strcmp(ext, "js")) return "application/javascript"; + if (!strcmp(ext, "png")) return "image/png"; + if (!strcmp(ext, "jpg") || !strcmp(ext, "jpeg")) return "image/jpeg"; + if (!strcmp(ext, "webp")) return "image/webp"; + if (!strcmp(ext, "gif")) return "image/gif"; + if (!strcmp(ext, "svg")) return "image/svg+xml"; + if (!strcmp(ext, "mp4")) return "video/mp4"; + if (!strcmp(ext, "webm")) return "video/webm"; + return "application/octet-stream"; +} + +// ---- Data operations (NDJSON-style internal), exposed as JSON array ---- +static char* ltrim_dup(const char* s) { + while (*s && isspace((unsigned char)*s)) s++; + return strdup(s); +} + +// removed unused list_articles_json (lint) + +static bool object_id_matches(const char* obj, const char* id) { + char* got = json_get_string(obj, "\"id\""); + bool ok = got && strcmp(got, id) == 0; + free(got); + return ok; +} + +static char* scan_array_for_id(char* t, const char* id) { + size_t len = strlen(t); + size_t i = 1; + while (1) { + char* obj = NULL; + int r = find_next_json_object(t, len, &i, &obj); + if (r <= 0) break; + if (object_id_matches(obj, id)) { + free(t); + return obj; + } + free(obj); + } + free(t); + return NULL; +} + +static char* find_article_by_id(const char* id) { + char* file = data_file(); + if (!file) return NULL; + size_t n = 0; + char* content = read_file_all(file, &n); + free(file); + if (!content) return NULL; + char* t = ltrim_dup(content); + free(content); + if (!t || t[0] != '[') { + free(t); + return NULL; + } + return scan_array_for_id(t, id); +} + +// NOLINTBEGIN(readability-function-size) +static int rewrite_articles_map(char** out_json_updated, const char* match_id, + const char* patch_json, bool is_delete) { + char* file = data_file(); + if (!file) return -1; + size_t n = 0; + char* content = read_file_all(file, &n); + if (!content) { + free(file); + return -1; + } + char* t = ltrim_dup(content); + free(content); + if (!t) { + free(file); + return -1; + } + if (t[0] != '[') { + FILE* f = fopen(file, "wb"); + if (f) { + fputs("[]", f); + fclose(f); + } + free(file); + free(t); + return -1; + } + size_t i = 1; + size_t len = strlen(t); + char** objs = NULL; + size_t cap = 0, count = 0; + bool found = false; + char* updated_copy = NULL; + while (i < len) { + char* obj = NULL; + int r = find_next_json_object(t, len, &i, &obj); + if (r < 0) { + // free accumulated objs + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + free(updated_copy); + return -1; + } + if (r == 0) break; + char* id = json_get_string(obj, "id"); + bool isMatch = id && strcmp(id, match_id) == 0; + free(id); + if (isMatch) { + found = true; + if (!is_delete) { + char* title = json_get_string(obj, "title"); + char* author = json_get_string(obj, "author"); + char* body = json_get_string(obj, "body"); + char* thumb = json_get_string(obj, "thumb"); + char* ptitle = json_get_top_string(patch_json, "title"); + if (ptitle && *ptitle) { + free(title); + title = ptitle; + } else free(ptitle); + char* pauthor = json_get_top_string(patch_json, "author"); + if (pauthor && *pauthor) { + free(author); + author = pauthor; + } else free(pauthor); + char* pbody = json_get_top_string(patch_json, "body"); + if (pbody && *pbody) { + free(body); + body = pbody; + } else free(pbody); + char* pthumb = json_get_top_string(patch_json, "thumb"); + if (pthumb && *pthumb) { + free(thumb); + thumb = pthumb; + } else free(pthumb); + long long createdAt = json_get_number(obj, "\"createdAt\""); + long long updatedAt = now_ms(); + char* obj2 = build_article_json(match_id, title, author, body, thumb, createdAt, updatedAt); + free(title); + free(author); + free(body); + free(thumb); + free(updated_copy); + updated_copy = strdup(obj2); + free(obj); + obj = obj2; + } + } + if (!(isMatch && is_delete)) { + if (push_str(&objs, &cap, &count, obj)) { + free(obj); + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + free(updated_copy); + return -1; + } + } else { + free(obj); + } + } + int rc = write_array_to_file(file, objs, count); + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + if (found && !is_delete && out_json_updated) + *out_json_updated = updated_copy ? updated_copy : strdup(""); + else free(updated_copy); + return found && rc == 0 ? 0 : -1; +} +// NOLINTEND(readability-function-size) + +// NOLINTBEGIN(readability-function-size) +static char* create_article_from_body(const char* body_json) { + char* title = json_get_top_string(body_json, "title"); + char* author = json_get_top_string(body_json, "author"); + char* b = json_get_top_string(body_json, "body"); + char* th = json_get_top_string(body_json, "thumb"); + char* id = gen_id(); + long long t = now_ms(); + char* obj = build_article_json(id, title, author, b, th, t, 0); + free(title); + free(author); + free(b); + free(th); + if (!id || !obj) { + free(id); + free(obj); + return NULL; + } + char* file = data_file(); + if (!file) { + free(id); + free(obj); + return NULL; + } + size_t n = 0; + char* content = read_file_all(file, &n); + char* out = NULL; + size_t w = 0; + if (!content || n == 0) { + free(content); + char* arr_items[1] = {obj}; + assemble_array(arr_items, 1, &out, &w); + } else { + char* tcontent = ltrim_dup(content); + free(content); + if (tcontent && tcontent[0] == '[') { + // Prepend new object + size_t i = 1, len = strlen(tcontent); + char** items = NULL; + size_t cap = 0, cnt = 0; + { + char* dup = strdup(obj); + if (!dup || push_str(&items, &cap, &cnt, dup)) { + free(dup); + free(tcontent); + free(file); + free(id); + free(obj); + return NULL; + } + } + while (1) { + char* one = NULL; + int r = find_next_json_object(tcontent, len, &i, &one); + if (r <= 0) { + // if negative error, free any allocated 'one' + if (r < 0) free(one); + break; + } + if (push_str(&items, &cap, &cnt, one)) { + free(one); + for (size_t z = 0; z < cnt; ++z) free(items[z]); + free(items); + free(tcontent); + free(file); + free(id); + free(obj); + return NULL; + } + } + assemble_array(items, cnt, &out, &w); + for (size_t z = 0; z < cnt; ++z) free(items[z]); + free(items); + } else { + char* arr_items[1] = {obj}; + assemble_array(arr_items, 1, &out, &w); + } + free(tcontent); + } + if (out) write_file_all(file, out, w); + free(out); + free(file); + free(id); + return obj; +} +// NOLINTEND(readability-function-size) + +// ---- request handling ---- +static const char* ext_from_content_type(const char* ct) { + if (!ct) return NULL; + if (strstr(ct, "image/png")) return "png"; + if (strstr(ct, "image/jpeg")) return "jpg"; + if (strstr(ct, "image/jpg")) return "jpg"; + if (strstr(ct, "image/webp")) return "webp"; + if (strstr(ct, "image/gif")) return "gif"; + return NULL; +} + +static const char* get_qparam(const char* path, const char* key) { + const char* q = strchr(path, '?'); + if (!q) return NULL; + q++; + size_t klen = strlen(key); + while (*q) { + if (!strncmp(q, key, klen) && q[klen] == '=') { + return q + klen + 1; + } + while (*q && *q != '&') q++; + if (*q == '&') q++; + } + return NULL; +} + +static char* strndup_local(const char* s, size_t n) { + char* r = malloc(n + 1); + if (!r) return NULL; + memcpy(r, s, n); + r[n] = '\0'; + return r; +} + +static char* save_upload(const char* body, size_t blen, const char* ext_hint) { + if (!body || blen == 0) return NULL; + const char* updir = "uploads"; + ensure_dir(updir); + char* name = gen_id(); + if (!name) return NULL; + const char* ext = (ext_hint && *ext_hint) ? ext_hint : "bin"; + size_t need = strlen(updir) + 1 + strlen(name) + 1 + strlen(ext) + 1; + char* path = malloc(need); + if (!path) { + free(name); + return NULL; + } + snprintf(path, need, "%s/%s.%s", updir, name, ext); + free(name); + if (write_file_all(path, body, blen) != 0) { + free(path); + return NULL; + } + return path; +} + +// Convert a data: URL into a newly-allocated absolute URL string ("/uploads/...") +// Returns NULL on failure. +static int parse_and_save_data_url(const char* data_url, char** out_saved) { + char* mime = NULL; + unsigned char* bytes = NULL; + size_t bl = 0; + if (parse_data_url(data_url, &mime, &bytes, &bl) != 0) { + free(mime); + free(bytes); + return -1; + } + const char* ext = ext_from_mime(mime); + char* saved = save_bytes_with_ext(bytes, bl, ext); + free(mime); + free(bytes); + if (!saved) return -1; + *out_saved = saved; + return 0; +} + +static char* saved_to_abs_url(char* saved) { + size_t L = strlen(saved) + 2; + char* url = (char*)malloc(L); + if (!url) { + free(saved); + return NULL; + } + snprintf(url, L, "/%s", saved); + free(saved); + return url; +} + +static char* data_url_to_abs_url(const char* data_url) { + char* saved = NULL; + if (parse_and_save_data_url(data_url, &saved) != 0) return NULL; + return saved_to_abs_url(saved); +} + +static int migrate_thumb_if_data_url(char** pthumb) { + char* thumb = *pthumb; + if (!thumb || strncmp(thumb, "data:", 5) != 0) return 0; + char* url = data_url_to_abs_url(thumb); + if (!url) return 0; + free(thumb); + *pthumb = url; + return 1; +} + +// Replace inline images inside body HTML in-place. Returns 1 if changed. +static int migrate_body_inplace(char** pbody) { + bool bchanged = false; + char* new_body = migrate_inline_images_in_body(*pbody, &bchanged); + if (new_body && bchanged) { + free(*pbody); + *pbody = new_body; + return 1; + } + free(new_body); + return 0; +} + +static char* rebuild_updated_article(char* old_obj, const char* id, const char* title, + const char* author, const char* body, const char* thumb, + long long createdAt) { + free(old_obj); + return build_article_json(id ? id : "", title ? title : "", author ? author : "", + body ? body : "", thumb ? thumb : "", createdAt, 0); +} + +static char* migrate_one_obj_if_needed(char* obj, int* changed_any) { + char* id = json_get_string(obj, "id"); + char* title = json_get_string(obj, "title"); + char* author = json_get_string(obj, "author"); + char* body_s = json_get_string(obj, "body"); + char* thumb = json_get_string(obj, "thumb"); + long long createdAt = json_get_number(obj, "\"createdAt\""); + int obj_changed = 0; + obj_changed |= migrate_thumb_if_data_url(&thumb); + obj_changed |= migrate_body_inplace(&body_s); + if (obj_changed) { + *changed_any = 1; + obj = rebuild_updated_article(obj, id, title, author, body_s, thumb, createdAt); + } + free(id); + free(title); + free(author); + free(body_s); + free(thumb); + return obj; +} + +// NOLINTBEGIN(readability-function-size) +static void api_get_articles_array(int c) { + char* file = data_file(); + if (!file) { + send_response(c, 200, "OK", "application/json", "[]", 2, true); + return; + } + size_t n = 0; + char* content = read_file_all(file, &n); + if (!content) { + free(file); + send_response(c, 200, "OK", "application/json", "[]", 2, true); + return; + } + char* t = ltrim_dup(content); + free(content); + if (!t) { + free(file); + send_response(c, 200, "OK", "application/json", "[]", 2, true); + return; + } + if (t[0] != '[') { + free(file); + free(t); + send_response(c, 200, "OK", "application/json", "[]", 2, true); + return; + } + size_t i = 1, len = strlen(t); + char** objs = NULL; + size_t cap = 0, count = 0; + int changed = 0; + while (1) { + char* obj = NULL; + int r = find_next_json_object(t, len, &i, &obj); + if (r < 0) { + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + if (r == 0) break; + obj = migrate_one_obj_if_needed(obj, &changed); + if (push_str(&objs, &cap, &count, obj)) { + free(obj); + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + } + char* out = NULL; + size_t w = 0; + if (assemble_array(objs, count, &out, &w)) { + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(file); + free(t); + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + if (changed) write_file_all(file, out, w); + send_response(c, 200, "OK", "application/json", out, w, true); + for (size_t z = 0; z < count; ++z) free(objs[z]); + free(objs); + free(out); + free(file); + free(t); +} +// NOLINTEND(readability-function-size) + +static void persist_article_update(const char* obj, const char* title, const char* author, + const char* body_s, const char* thumb, long long createdAt) { + char* id_copy = json_get_string(obj, "id"); + char* updated = + build_article_json(id_copy ? id_copy : "", title ? title : "", author ? author : "", + body_s ? body_s : "", thumb ? thumb : "", createdAt, 0); + if (updated) { + rewrite_articles_map(NULL, id_copy, updated, false); + free(updated); + } + free(id_copy); +} + +static int migrate_fields_if_needed(char** obj, char** title, char** author, char** body_s, + char** thumb, long long createdAt) { + int obj_changed = 0; + obj_changed |= migrate_thumb_if_data_url(thumb); + obj_changed |= migrate_body_inplace(body_s); + if (obj_changed) { + persist_article_update(*obj, *title, *author, *body_s, *thumb, createdAt); + free(*obj); + *obj = NULL; + return 1; + } + return 0; +} + +typedef struct { + char* title; + char* author; + char* body; + char* thumb; + long long createdAt; +} ArticleFields; + +static void load_fields(const char* obj, ArticleFields* f) { + f->title = json_get_string(obj, "title"); + f->author = json_get_string(obj, "author"); + f->body = json_get_string(obj, "body"); + f->thumb = json_get_string(obj, "thumb"); + f->createdAt = json_get_number(obj, "\"createdAt\""); +} + +static void free_fields(ArticleFields* f) { + free(f->title); + free(f->author); + free(f->body); + free(f->thumb); +} + +static char* maybe_migrate_and_refresh(const char* id, char* obj) { + ArticleFields f; + load_fields(obj, &f); + if (migrate_fields_if_needed(&obj, &f.title, &f.author, &f.body, &f.thumb, f.createdAt)) { + free_fields(&f); + return find_article_by_id(id); + } + free_fields(&f); + return obj; +} + +static void api_get_article_by_id(int c, const char* id) { + char* obj = find_article_by_id(id); + if (!obj) { + send_response(c, 404, "Not Found", "application/json", "", 0, true); + return; + } + obj = maybe_migrate_and_refresh(id, obj); + send_response(c, 200, "OK", "application/json", obj, strlen(obj), true); + free(obj); +} + +static void api_post_article(int c, const char* body) { + char* obj = create_article_from_body(body ? body : ""); + if (!obj) { + send_response(c, 400, "Bad Request", "application/json", "", 0, true); + return; + } + size_t L = strlen(obj); + send_response(c, 201, "Created", "application/json", obj, L, true); + free(obj); +} +static void api_put_article(int c, const char* id, const char* body) { + char* updated = NULL; + if (rewrite_articles_map(&updated, id, body ? body : "", false) == 0) { + size_t L = strlen(updated); + send_response(c, 200, "OK", "application/json", updated, L, true); + free(updated); + } else { + send_response(c, 404, "Not Found", "application/json", "", 0, true); + } +} +static void api_delete_article(int c, const char* id) { + if (rewrite_articles_map(NULL, id, NULL, true) == 0) { + send_response(c, 204, "No Content", "application/json", "", 0, true); + } else { + send_response(c, 404, "Not Found", "application/json", "", 0, true); + } +} + +static char* choose_ext_from(const char* path, const char* content_type) { + const char* ext_q = get_qparam(path, "ext"); + const char* ext_from_ct = ext_from_content_type(content_type); + const char* ext = ext_from_ct ? ext_from_ct : (ext_q ? ext_q : "bin"); + size_t elen = 0; + while (elen < 4 && ext[elen] && isalnum((unsigned char)ext[elen])) elen++; + return strndup_local(ext, elen ? elen : 3); +} + +static void respond_saved_upload(int c, char* saved) { + size_t L = strlen(saved) + 20; + char* res = (char*)malloc(L); + if (!res) { + free(saved); + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + snprintf(res, L, "{\"url\":\"/%s\"}", saved); + send_response(c, 201, "Created", "application/json", res, strlen(res), true); + free(res); + free(saved); +} + +static void api_post_upload(int c, const char* path, const char* body, size_t blen, + const char* content_type) { + char* ext_safe = choose_ext_from(path, content_type); + if (!ext_safe) { + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + char* saved = save_upload(body, blen, ext_safe); + free(ext_safe); + if (!saved) { + send_response(c, 500, "Internal Server Error", "application/json", "", 0, true); + return; + } + respond_saved_upload(c, saved); +} + +static bool path_has_id_suffix(const char* path, size_t base_len) { + return path[base_len] == '/' && strlen(path) > base_len + 1; +} + +static bool is_method(const char* m, const char* want) { return strcmp(m, want) == 0; } + +static void dispatch_articles_get(int c, const char* path) { + const char* base = "/api/articles"; + size_t bl = strlen(base); + if (strcmp(path, base) == 0) api_get_articles_array(c); + else if (path_has_id_suffix(path, bl)) api_get_article_by_id(c, path + bl + 1); + else send_response(c, 404, "Not Found", "application/json", "", 0, true); +} + +static void dispatch_articles_mut(int c, const char* method, const char* path, const char* body) { + const char* base = "/api/articles"; + size_t bl = strlen(base); + if (is_method(method, "POST") && strcmp(path, base) == 0) api_post_article(c, body); + else if (is_method(method, "PUT") && path_has_id_suffix(path, bl)) + api_put_article(c, path + bl + 1, body); + else if (is_method(method, "DELETE") && path_has_id_suffix(path, bl)) + api_delete_article(c, path + bl + 1); +} + +static void dispatch_upload(int c, const char* method, const char* path, const char* body, + size_t blen, const char* content_type) { + if (strncmp(path, "/api/upload", 12) != 0) return; + if (is_method(method, "POST")) api_post_upload(c, path, body, blen, content_type); +} + +static void handle_api(int c, const char* method, const char* path, const char* body, size_t blen, + const char* content_type) { + if (!strncmp(method, "OPTIONS", 7)) { + send_response(c, 204, "No Content", "application/json", "", 0, true); + return; + } + const char* base = "/api/articles"; + size_t bl = strlen(base); + if (strncmp(path, base, bl) == 0) { + if (is_method(method, "GET")) dispatch_articles_get(c, path); + else dispatch_articles_mut(c, method, path, body); + } + dispatch_upload(c, method, path, body, blen, content_type); + if (strncmp(path, "/api/", 5) == 0) + send_response(c, 404, "Not Found", "text/plain", "Not Found", 9, true); +} + +static bool safe_path(const char* p) { + if (strstr(p, "..")) return false; + return true; +} + +static void normalize_rel_path(const char* path, char* rel, size_t relsz) { + if (!strcmp(path, "/")) snprintf(rel, relsz, "%s", "/index.html"); + else snprintf(rel, relsz, "%s", path); +} +static int read_file_to_buf(const char* full, char** out, size_t* n) { + FILE* f = fopen(full, "rb"); + if (!f) return -1; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + char* buf = malloc((size_t)sz); + if (!buf) { + fclose(f); + return -1; + } + *n = fread(buf, 1, (size_t)sz, f); + fclose(f); + *out = buf; + return 0; +} +static void send_cached_or_plain(int c, const char* rel, const char* mime, const char* buf, + size_t n) { + int is_upload = (strncmp(rel, "/uploads/", 9) == 0); + if (is_upload) { + char head[SMALL_BUF]; + int hlen = snprintf(head, sizeof(head), + "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: " + "%zu\r\nCache-Control: public, max-age=31536000, immutable\r\n\r\n", + mime, (size_t)n); + send(c, head, (size_t)hlen, 0); + if (n) send(c, buf, n, 0); + } else { + send_response(c, 200, "OK", mime, buf, n, false); + } +} +static void handle_static(int c, const char* path) { + char rel[SMALL_BUF]; + normalize_rel_path(path, rel, sizeof(rel)); + if (!safe_path(rel)) { + send_response(c, 403, "Forbidden", "text/plain", "Forbidden", 9, false); + return; + } + char full[SMALL_BUF * 2]; + snprintf(full, sizeof(full), "%s%s", DOC_ROOT ? DOC_ROOT : ".", rel); + char* buf = NULL; + size_t n = 0; + if (read_file_to_buf(full, &buf, &n) != 0) { + send_response(c, 404, "Not Found", "text/plain", "Not Found", 9, false); + return; + } + const char* mime = guess_mime(full); + send_cached_or_plain(c, rel, mime, buf, n); + free(buf); +} + +// removed unused read_headers_into +static void parse_request_line(const char* buf, char* method, char* path) { + if (sscanf(buf, "%15s %4095s", method, path) < 2) { + method[0] = '\0'; + path[0] = '\0'; + } +} +static void parse_headers(const char* buf, size_t* content_length, char* ctype, size_t ctype_sz) { + *content_length = 0; + ctype[0] = '\0'; + const char* cl = strcasestr(buf, "Content-Length:"); + if (cl) *content_length = strtoul(cl + 15, NULL, 10); + const char* ct = strcasestr(buf, "Content-Type:"); + if (ct) { + ct += 13; + while (*ct == ' ' || *ct == '\t') ct++; + size_t i = 0; + while (*ct && *ct != '\r' && *ct != '\n' && i < ctype_sz - 1) { + ctype[i++] = *ct++; + } + ctype[i] = '\0'; + } +} +// ---- small helpers to keep functions compact ---- +static size_t copy_from_buffer(char* dst, const char* src, size_t available, size_t max_n) { + size_t cpy = available > max_n ? max_n : available; + if (cpy) memcpy(dst, src, cpy); + return cpy; +} + +static void recv_remaining_body(int c, char* body, size_t off, size_t total_len) { + size_t remain = total_len - off; + while (remain > 0) { + ssize_t rr = recv(c, body + off, remain, 0); + if (rr <= 0) break; + off += (size_t)rr; + remain -= (size_t)rr; + } +} +static char* read_body_if_needed(int c, const char* buf, size_t total, size_t content_length) { + if (!content_length) return NULL; + const char* hdr_end = strstr(buf, "\r\n\r\n"); + size_t header_bytes = hdr_end ? (size_t)(hdr_end - buf) + 4 : total; + size_t have_body = total > header_bytes ? total - header_bytes : 0; + char* body = (char*)malloc(content_length + 1); + if (!body) return NULL; + size_t off = 0; + if (have_body) off = copy_from_buffer(body, buf + header_bytes, have_body, content_length); + if (off < content_length) recv_remaining_body(c, body, off, content_length); + body[content_length] = '\0'; + return body; +} +static ssize_t read_request_headers(int c, char* buf, size_t bufsz) { + ssize_t total = 0; + while (true) { + ssize_t rcv = recv(c, buf + total, bufsz - 1 - (size_t)total, 0); + if (rcv <= 0) break; + total += rcv; + buf[total] = '\0'; + if (strstr(buf, "\r\n\r\n")) break; + if (total >= (ssize_t)bufsz - 1) break; + } + return total; +} + +static void process_request(int c, const char* buf, ssize_t total) { + char method[16] = {0}, path[SMALL_BUF] = {0}; + parse_request_line(buf, method, path); + if (!method[0]) return; + size_t content_length = 0; + char ctype[128] = {0}; + parse_headers(buf, &content_length, ctype, sizeof(ctype)); + char* body = read_body_if_needed(c, buf, (size_t)total, content_length); + if (!strncmp(path, "/api/", 5)) + handle_api(c, method, path, body, content_length, ctype[0] ? ctype : NULL); + else if (!strcmp(method, "GET")) handle_static(c, path); + else if (!strcmp(method, "OPTIONS")) + send_response(c, 204, "No Content", "text/plain", "", 0, false); + else send_response(c, 405, "Method Not Allowed", "text/plain", "", 0, false); + free(body); +} + +static void handle_client(int c) { + char buf[RECV_BUF]; + ssize_t total = read_request_headers(c, buf, sizeof(buf)); + if (total > 0) process_request(c, buf, total); + close(c); +} + +static int server_socket_init(const char* host, int port) { + int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) return -1; + int opt = 1; + setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + addr.sin_addr.s_addr = inet_addr(host); + if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + close(s); + return -1; + } + if (listen(s, 64) < 0) { + close(s); + return -1; + } + return s; +} + +static void server_loop(int s) { + while (!g_stop) { + struct sockaddr_in ca; + socklen_t calen = sizeof(ca); + int c = accept(s, (struct sockaddr*)&ca, &calen); + if (c < 0) { + if (errno == EINTR) break; + perror("accept"); + continue; + } + handle_client(c); + } +} + +static void init_runtime(void) { + signal(SIGINT, on_sigint); + srand((unsigned int)time(NULL)); + char cwd[SMALL_BUF]; + if (getcwd(cwd, sizeof(cwd))) DOC_ROOT = strdup(cwd); +} + +int main(int argc, char** argv) { + (void)argc; + (void)argv; + init_runtime(); + const char* host = getenv_default("HOST", "127.0.0.1"); + int port = atoi(getenv_default("PORT", "8000")); + if (port <= 0) port = 8000; + int s = server_socket_init(host, port); + if (s < 0) { + perror("server"); + return 1; + } + printf("Serving Mini Articles (C) on http://%s:%d\n", host, port); + server_loop(s); + close(s); + return 0; +} diff --git a/python_pkg/articles/sw.js b/python_pkg/articles/sw.js new file mode 100644 index 0000000..81b95c9 --- /dev/null +++ b/python_pkg/articles/sw.js @@ -0,0 +1,31 @@ +// Minimal image cache-first service worker +const C = 'articles-img-v2'; +const AC = 'articles-json-v1'; +self.addEventListener('install', e => self.skipWaiting()); +self.addEventListener('activate', e => e.waitUntil(self.clients.claim())); +self.addEventListener('fetch', e => { + const req = e.request; + if (req.method !== 'GET') return; + const u = new URL(req.url); + const isImg = req.destination === 'image' || u.pathname.startsWith('/uploads/'); + const isArticle = u.pathname.startsWith('/api/articles/') && u.pathname.length > '/api/articles/'.length; + if (isImg) { + e.respondWith((async () => { + const cache = await caches.open(C); + const hit = await cache.match(req, { ignoreVary: true, ignoreSearch: false }); + if (hit) return hit; + const res = await fetch(req); + if (res && res.ok) cache.put(req, res.clone()); + return res; + })()); + } else if (isArticle) { + e.respondWith((async () => { + const cache = await caches.open(AC); + const hit = await cache.match(req); + if (hit) return hit; + const res = await fetch(req); + if (res && res.ok) cache.put(req, res.clone()); + return res; + })()); + } +}); diff --git a/python_pkg/articles/test_server_api.py b/python_pkg/articles/test_server_api.py new file mode 100644 index 0000000..c27131f --- /dev/null +++ b/python_pkg/articles/test_server_api.py @@ -0,0 +1,109 @@ +"""Integration tests for the articles C server API.""" + +from http import HTTPStatus +import json +import os +from pathlib import Path +import socket +import subprocess +import time +from typing import Any +import urllib.error +import urllib.request + +import pytest + + +def _req( + url: str, method: str = "GET", data: dict[str, Any] | bytes | None = None +) -> tuple[int, bytes]: + """Send an HTTP request and return status code and body.""" + if data is not None and not isinstance(data, bytes | bytearray): + data = json.dumps(data).encode("utf-8") + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read() + return resp.getcode(), body + + +def test_crud_roundtrip(tmp_path: Path) -> None: + """Test full CRUD lifecycle for articles API.""" + # Build C server + here = Path(__file__).resolve().parent + subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here)) + + # Find a free port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + _, port = s.getsockname() + host = "127.0.0.1" + base = f"http://{host}:{port}" + + # Isolate storage and start server + env = os.environ.copy() + env["ARTICLES_DATA_DIR"] = str(tmp_path) + env["HOST"] = host + env["PORT"] = str(port) + srv = subprocess.Popen(["./server_c"], cwd=str(here), env=env) + try: + # wait briefly for server to be ready + for _ in range(30): + try: + with urllib.request.urlopen( + base + "/api/articles", timeout=0.2 + ) as resp: + resp.read() + break + except (OSError, urllib.error.URLError): + time.sleep(0.05) + + # Create + code, body = _req( + base + "/api/articles", + method="POST", + data={ + "title": "T1", + "body": "

Hello

", + "thumb": "data:image/png;base64,xyz", + }, + ) + assert code == HTTPStatus.CREATED + created = json.loads(body) + art_id = created["id"] + + # List + code, body = _req(base + "/api/articles") + assert code == HTTPStatus.OK + items = json.loads(body) + assert any(a["id"] == art_id for a in items) + + # Get one + code, body = _req(base + f"/api/articles/{art_id}") + assert code == HTTPStatus.OK + got = json.loads(body) + assert got["title"] == "T1" + + # Update + code, body = _req( + base + f"/api/articles/{art_id}", method="PUT", data={"title": "T2"} + ) + assert code == HTTPStatus.OK + updated = json.loads(body) + assert updated["title"] == "T2" + + # Delete + code, _ = _req(base + f"/api/articles/{art_id}", method="DELETE") + assert code == HTTPStatus.NO_CONTENT + + # Ensure gone + with pytest.raises(urllib.error.HTTPError) as exc_info: + _req(base + f"/api/articles/{art_id}") + assert exc_info.value.code == HTTPStatus.NOT_FOUND + + finally: + srv.terminate() + try: + srv.wait(timeout=2) + except subprocess.TimeoutExpired: + srv.kill() diff --git a/python_pkg/articles/test_site_size.py b/python_pkg/articles/test_site_size.py new file mode 100644 index 0000000..31b17dd --- /dev/null +++ b/python_pkg/articles/test_site_size.py @@ -0,0 +1,20 @@ +"""Tests to ensure website stays within size budget.""" + +from pathlib import Path + +# Budget for the entire website (single file) in bytes +BUDGET = 14 * 1024 # 14 KiB + +HERE = Path(__file__).parent +SITE_FILE = HERE / "index.html" + + +def test_site_file_exists() -> None: + """Verify the main site HTML file exists.""" + assert SITE_FILE.exists(), f"Missing site file: {SITE_FILE}" + + +def test_site_size_under_budget() -> None: + """Verify site size is under the defined budget.""" + size = SITE_FILE.stat().st_size + assert size <= BUDGET, f"Site size {size} bytes exceeds budget {BUDGET}" diff --git a/python_pkg/articles/tools/funcsize.awk b/python_pkg/articles/tools/funcsize.awk new file mode 100644 index 0000000..ab58c2c --- /dev/null +++ b/python_pkg/articles/tools/funcsize.awk @@ -0,0 +1,31 @@ +BEGIN{ in_func=0; depth=0; start=0; err=0; prev="" } +{ + line=$0 + # track function start when we see an opening brace at top-level and previous non-empty + # line looks like a function signature (ends with ')' and not ';', and not a typedef/struct/enum/union) + for(i=1;i<=length(line);i++){ + c=substr(line,i,1) + if(c=="{"){ + if(depth==0 && !in_func){ + # Heuristic check on previous non-empty trimmed line + t=prev + sub(/^\s+/, "", t); sub(/\s+$/, "", t) + if(t ~ /\)$/ && t !~ /;\s*$/ && t !~ /^(typedef|struct|enum|union)\b/){ + in_func=1; start=NR + } + } + depth++ + } else if(c=="}"){ + depth-- + if(in_func && depth==0){ + lines=NR-start+1 + if(lines>20){ print FILENAME ":" start " function too long: " lines " lines"; err=1 } + in_func=0 + } + } + } + # update previous non-empty line + tmp=line; sub(/^\s+/, "", tmp); sub(/\s+$/, "", tmp) + if(length(tmp)>0){ prev=tmp } +} +END{ if(err) exit 1 } diff --git a/python_pkg/moviepy_showcase/__init__.py b/python_pkg/moviepy_showcase/__init__.py new file mode 100644 index 0000000..b113390 --- /dev/null +++ b/python_pkg/moviepy_showcase/__init__.py @@ -0,0 +1 @@ +"""MoviePy 2.x comprehensive showcase package.""" diff --git a/python_pkg/moviepy_showcase/_moviepy_audio_output.py b/python_pkg/moviepy_showcase/_moviepy_audio_output.py new file mode 100644 index 0000000..b594f87 --- /dev/null +++ b/python_pkg/moviepy_showcase/_moviepy_audio_output.py @@ -0,0 +1,357 @@ +"""MoviePy showcase — Part 4 (Audio), 5 (Composition), 6 (Drawing), 7 (Output).""" + +from __future__ import annotations + +from moviepy import ( + AudioArrayClip, + AudioClip, + ColorClip, + CompositeAudioClip, + CompositeVideoClip, + ImageClip, + TextClip, + VideoClip, + concatenate_audioclips, + concatenate_videoclips, +) +from moviepy.audio.fx import ( + AudioDelay, + AudioFadeIn, + AudioFadeOut, + AudioLoop, + AudioNormalize, + MultiplyStereoVolume, + MultiplyVolume, +) +from moviepy.video.compositing.CompositeVideoClip import clips_array +from moviepy.video.tools.drawing import ( + circle, + color_gradient, + color_split, +) +import numpy as np + +from python_pkg.moviepy_showcase.moviepy_showcase import ( + CLIP_DUR, + FONT_B, + FONT_R, + H, + W, + _base_clip, + _resize_to_canvas, + _section_header, + _titled, +) + + +def _make_sine(freq: float = 440.0, dur: float = CLIP_DUR) -> AudioClip: + """Pure sine-wave AudioClip.""" + + def maker(t: np.ndarray) -> np.ndarray: + t_arr = np.asarray(t) + wave = 0.3 * np.sin(2 * np.pi * freq * t_arr.flatten()) + stereo = np.column_stack([wave, wave]) + # MoviePy probes with scalar t=0 and uses len(list(frame0)) + # for nchannels. A (1,2) array iterates as 1 row → nchannels=1. + # Returning shape (2,) for scalar t lets MoviePy detect 2 channels. + if t_arr.ndim == 0: + return stereo[0] + return stereo + + return AudioClip(maker, duration=dur, fps=44100) + + +def part4_audio() -> list[VideoClip]: + """Demonstrate audio clips and all 7 audio effects.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 4: Audio", + "AudioClip Ā· AudioArrayClip Ā· CompositeAudioClip Ā· 7 Audio Effects", + ), + ] + bg = ColorClip(size=(W, H), color=(20, 30, 50)) + + # AudioClip + a1 = _make_sine(440, CLIP_DUR) + c1 = bg.with_duration(CLIP_DUR).with_audio(a1) + scenes.append(_titled(c1, "AudioClip(sine_440Hz)")) + + # AudioArrayClip + sr = 44100 + t_arr = np.linspace(0, CLIP_DUR, int(sr * CLIP_DUR), endpoint=False) + arr = (0.3 * np.sin(2 * np.pi * 880 * t_arr)).astype(np.float64) + stereo = np.column_stack([arr, arr]) + a2 = AudioArrayClip(stereo, fps=sr) + c2 = bg.with_duration(CLIP_DUR).with_audio(a2) + scenes.append(_titled(c2, "AudioArrayClip(numpy_array, fps=44100) # 880Hz")) + + # CompositeAudioClip + low = _make_sine(220, CLIP_DUR) + high = _make_sine(660, CLIP_DUR) + comp_audio = CompositeAudioClip([low, high]) + c3 = bg.with_duration(CLIP_DUR).with_audio(comp_audio) + scenes.append(_titled(c3, "CompositeAudioClip([220Hz, 660Hz])")) + + # concatenate_audioclips + a_cat = concatenate_audioclips([_make_sine(330, 1.0), _make_sine(550, 1.0)]) + c4 = bg.with_duration(CLIP_DUR).with_audio(a_cat) + scenes.append(_titled(c4, "concatenate_audioclips([330Hz, 550Hz])")) + + # AudioFadeIn + a_fi = _make_sine(440, CLIP_DUR).with_effects([AudioFadeIn(duration=1.5)]) + c5 = bg.with_duration(CLIP_DUR).with_audio(a_fi) + scenes.append(_titled(c5, "AudioFadeIn(duration=1.5)")) + + # AudioFadeOut + a_fo = _make_sine(440, CLIP_DUR).with_effects([AudioFadeOut(duration=1.5)]) + c6 = bg.with_duration(CLIP_DUR).with_audio(a_fo) + scenes.append(_titled(c6, "AudioFadeOut(duration=1.5)")) + + # AudioDelay + a_delay = _make_sine(440, CLIP_DUR).with_effects( + [AudioDelay(offset=0.2, n_repeats=4, decay=1)] + ) + c7 = bg.with_duration(a_delay.duration).with_audio(a_delay) + scenes.append( + _titled( + c7.with_duration(CLIP_DUR), "AudioDelay(offset=0.2, n_repeats=4, decay=1)" + ) + ) + + # AudioLoop + short_a = _make_sine(440, 0.5) + a_loop = short_a.with_effects([AudioLoop(duration=CLIP_DUR)]) + c8 = bg.with_duration(CLIP_DUR).with_audio(a_loop) + scenes.append(_titled(c8, "AudioLoop(duration=2.0)")) + + # AudioNormalize + quiet = _make_sine(440, CLIP_DUR) # already normalized but demonstrates the call + a_norm = quiet.with_effects([AudioNormalize()]) + c9 = bg.with_duration(CLIP_DUR).with_audio(a_norm) + scenes.append(_titled(c9, "AudioNormalize()")) + + # MultiplyStereoVolume + a_stereo = _make_sine(440, CLIP_DUR).with_effects( + [MultiplyStereoVolume(left=1.0, right=0.2)] + ) + c10 = bg.with_duration(CLIP_DUR).with_audio(a_stereo) + scenes.append(_titled(c10, "MultiplyStereoVolume(left=1.0, right=0.2)")) + + # MultiplyVolume + a_vol = _make_sine(440, CLIP_DUR).with_effects([MultiplyVolume(factor=0.3)]) + c11 = bg.with_duration(CLIP_DUR).with_audio(a_vol) + scenes.append(_titled(c11, "MultiplyVolume(factor=0.3)")) + + return scenes + + +def part5_composition() -> list[VideoClip]: + """Demonstrate composition & concatenation.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 5: Composition", + "CompositeVideoClip Ā· concatenate_videoclips Ā· clips_array", + ), + ] + + # CompositeVideoClip with bg_color, use_bgclip + bg = _base_clip() + overlay = ( + ColorClip(size=(400, 400), color=(255, 50, 50)) + .with_duration(CLIP_DUR) + .with_position(("center", "center")) + .with_opacity(0.6) + ) + comp1 = CompositeVideoClip([bg, overlay], size=(W, H), bg_color=(0, 0, 0)) + scenes.append(_titled(comp1, "CompositeVideoClip(clips, bg_color, use_bgclip)")) + + # concatenate_videoclips — method='chain' + c1 = ColorClip(size=(W, H), color=(200, 50, 50)).with_duration(0.7) + c2 = ColorClip(size=(W, H), color=(50, 200, 50)).with_duration(0.7) + c3 = ColorClip(size=(W, H), color=(50, 50, 200)).with_duration(0.6) + cat = concatenate_videoclips([c1, c2, c3], method="chain") + scenes.append(_titled(cat, "concatenate_videoclips(method='chain')")) + + # concatenate_videoclips — method='compose' with padding + cat2 = concatenate_videoclips( + [ + c1.resized((W // 2, H // 2)), + c2.resized((W // 2, H // 2)), + c3.resized((W, H)), + ], + method="compose", + bg_color=(0, 0, 0), + padding=-0.2, + ) + scenes.append( + _titled( + _resize_to_canvas(cat2), + "concatenate_videoclips(method='compose', padding=-0.2)", + ) + ) + + # concatenate_videoclips with transition + cat3 = concatenate_videoclips( + [c1, c2, c3], + padding=-0.3, + method="compose", + ) + scenes.append( + _titled( + cat3.with_duration(CLIP_DUR), + "concatenate_videoclips(padding=-0.3) # overlap", + ) + ) + + # clips_array + a = ColorClip(size=(W // 2, H // 2), color=(200, 50, 50)).with_duration(CLIP_DUR) + b = ColorClip(size=(W // 2, H // 2), color=(50, 200, 50)).with_duration(CLIP_DUR) + c = ColorClip(size=(W // 2, H // 2), color=(50, 50, 200)).with_duration(CLIP_DUR) + d = ColorClip(size=(W // 2, H // 2), color=(200, 200, 50)).with_duration(CLIP_DUR) + grid = clips_array([[a, b], [c, d]]) + scenes.append(_titled(_resize_to_canvas(grid), "clips_array([[a, b], [c, d]])")) + + return scenes + + +def part6_drawing_tools() -> list[VideoClip]: + """Demonstrate moviepy.video.tools.drawing functions.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 6: Drawing Tools", "circle Ā· color_gradient Ā· color_split" + ), + ] + + # circle + circ = circle( + screensize=(W, H), + center=(W // 2, H // 2), + radius=300, + color=1.0, + bg_color=0.0, + blur=30, + ) + circ_rgb = (np.dstack([circ, circ, circ]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(circ_rgb, duration=CLIP_DUR), + "drawing.circle(center, radius=300, blur=30)", + ) + ) + + # color_gradient — linear + grad = color_gradient( + size=(W, H), + p1=(0, 0), + p2=(W, H), + color_1=0.0, + color_2=1.0, + shape="linear", + ) + grad_rgb = (np.dstack([grad, grad, grad]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(grad_rgb, duration=CLIP_DUR), + "drawing.color_gradient(shape='linear')", + ) + ) + + # color_gradient — radial + grad_r = color_gradient( + size=(W, H), + p1=(W // 2, H // 2), + radius=500, + color_1=1.0, + color_2=0.0, + shape="radial", + ) + grad_r_rgb = (np.dstack([grad_r, grad_r, grad_r]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(grad_r_rgb, duration=CLIP_DUR), + "drawing.color_gradient(shape='radial', radius=500)", + ) + ) + + # color_split + split = color_split( + size=(W, H), + x=W // 2, + color_1=0.0, + color_2=1.0, + gradient_width=100, + ) + split_rgb = (np.dstack([split, split, split]) * 255).astype(np.uint8) + scenes.append( + _titled( + ImageClip(split_rgb, duration=CLIP_DUR), + "drawing.color_split(x=W/2, gradient_width=100)", + ) + ) + + return scenes + + +def part7_output() -> list[VideoClip]: + """Label-only slides for output methods + parameters.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 7: Output Methods", + "write_videofile Ā· write_gif Ā· save_frame Ā· write_images_sequence", + ), + ] + + bg = ColorClip(size=(W, H), color=(15, 20, 35)) + + methods = [ + ( + "write_videofile()", + "filename, fps, codec, bitrate, audio, audio_fps,\n" + "preset, audio_nbytes, audio_codec, audio_bitrate,\n" + "audio_bufsize, temp_audiofile, threads,\n" + "ffmpeg_params, logger, pixel_format", + ), + ( + "write_gif()", + "filename, fps, loop, logger", + ), + ( + "save_frame()", + "filename, t, with_mask", + ), + ( + "write_images_sequence()", + "name_format, fps, with_mask, logger", + ), + ( + "write_audiofile()", + "filename, fps, nbytes, buffersize,\ncodec, bitrate, ffmpeg_params, logger", + ), + ] + + for title, params in methods: + t1 = ( + TextClip( + text=title, font_size=56, color="cyan", font=FONT_B, margin=(0, 20) + ) + .with_duration(2.5) + .with_position(("center", 300)) + ) + t2 = ( + TextClip( + text=f"Parameters:\n{params}", + font_size=32, + color="#dddddd", + font=FONT_R, + method="caption", + size=(W - 300, None), + text_align="center", + interline=8, + margin=(0, 15), + ) + .with_duration(2.5) + .with_position(("center", 500)) + ) + scenes.append(CompositeVideoClip([bg.with_duration(2.5), t1, t2], size=(W, H))) + + return scenes diff --git a/python_pkg/moviepy_showcase/_moviepy_clip_types.py b/python_pkg/moviepy_showcase/_moviepy_clip_types.py new file mode 100644 index 0000000..46a2130 --- /dev/null +++ b/python_pkg/moviepy_showcase/_moviepy_clip_types.py @@ -0,0 +1,282 @@ +"""MoviePy showcase — Part 1 (Clip Types) and Part 2 (Clip Methods).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from moviepy import ( + BitmapClip, + ColorClip, + CompositeVideoClip, + DataVideoClip, + ImageClip, + ImageSequenceClip, + TextClip, + VideoClip, +) +from moviepy.video.fx import InvertColors +from moviepy.video.tools.drawing import circle +import numpy as np + +from python_pkg.moviepy_showcase.moviepy_showcase import ( + CLIP_DUR, + FONT_B, + FONT_R, + FPS, + H, + W, + _base_clip, + _gradient, + _resize_to_canvas, + _section_header, + _titled, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + +def part1_clip_types() -> list[VideoClip]: + """Demonstrate every clip creation class.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 1: Clip Types", + "VideoClip Ā· ColorClip Ā· TextClip Ā· ImageClip" + " Ā· BitmapClip Ā· DataVideoClip Ā· ImageSequenceClip", + ), + ] + + # 1. VideoClip — custom frame function + vc = VideoClip(_gradient, duration=CLIP_DUR).with_fps(FPS) + scenes.append(_titled(vc, "VideoClip(frame_function)")) + + # 2. ColorClip + cc = ColorClip(size=(W, H), color=(0, 120, 200)).with_duration(CLIP_DUR) + scenes.append(_titled(cc, "ColorClip(size, color)")) + + # 3. TextClip — label method + tbg = ColorClip(size=(W, H), color=(20, 20, 50)).with_duration(CLIP_DUR) + tc = ( + TextClip( + text="Hello MoviePy!", + font_size=96, + color="yellow", + font=FONT_B, + stroke_color="black", + stroke_width=3, + bg_color=None, + margin=(10, 30), + method="label", + horizontal_align="center", + vertical_align="center", + transparent=True, + ) + .with_duration(CLIP_DUR) + .with_position("center") + ) + scenes.append( + _titled( + CompositeVideoClip([tbg, tc], size=(W, H)), + "TextClip(text, font_size, color, stroke, margin, method='label')", + ) + ) + + # 4. TextClip — caption method (wraps text) + tbg2 = ColorClip(size=(W, H), color=(50, 20, 20)).with_duration(CLIP_DUR) + tc2 = ( + TextClip( + text="This is a longer caption that wraps " + "because we use method='caption' with a fixed size.", + font_size=48, + color="white", + font=FONT_R, + method="caption", + size=(W - 200, None), + text_align="center", + interline=10, + margin=(0, 20), + ) + .with_duration(CLIP_DUR) + .with_position("center") + ) + scenes.append( + _titled( + CompositeVideoClip([tbg2, tc2], size=(W, H)), + "TextClip(method='caption', text_align, interline, size)", + ) + ) + + # 5. ImageClip — from numpy array + img = np.zeros((H, W, 3), dtype=np.uint8) + img[200:880, 400:1520] = [255, 100, 50] # orange rectangle + ic = ImageClip(img, duration=CLIP_DUR) + scenes.append(_titled(ic, "ImageClip(numpy_array)")) + + # 6. BitmapClip — from ASCII-art frames + frames = [ + ["RR__", "RR__", "__BB", "__BB"], + ["__RR", "__RR", "BB__", "BB__"], + ["RR__", "RR__", "__BB", "__BB"], + ["__RR", "__RR", "BB__", "BB__"], + ] + bc = BitmapClip( + frames, + fps=2, + color_dict={"R": (255, 0, 0), "B": (0, 0, 255), "_": (30, 30, 30)}, + ) + bc = _resize_to_canvas(bc) + scenes.append( + _titled(bc.with_duration(CLIP_DUR), "BitmapClip(bitmap_frames, color_dict)") + ) + + # 7. DataVideoClip — data-driven frames + data_list = list(range(60)) + + def data_to_frame(d: int) -> np.ndarray: + frame = np.full((H, W, 3), 30, dtype=np.uint8) + bar_w = int(d / 60 * (W - 100)) + frame[400:680, 50 : 50 + bar_w] = [0, 200, 100] + return frame + + dvc = DataVideoClip(data_list, data_to_frame, fps=FPS).with_duration(CLIP_DUR) + scenes.append(_titled(dvc, "DataVideoClip(data, data_to_frame)")) + + # 8. ImageSequenceClip — from a list of arrays + seq_frames = [] + for i in range(10): + f = np.full((H, W, 3), int(25 * i), dtype=np.uint8) + f[:, :, 0] = int(255 - 25 * i) + seq_frames.append(f) + isc = ImageSequenceClip(seq_frames, fps=5).with_duration(CLIP_DUR) + scenes.append(_titled(isc, "ImageSequenceClip(sequence, fps)")) + + return scenes + + +def part2_clip_methods() -> list[VideoClip]: + """Demonstrate VideoClip methods.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 2: Clip Methods", + "subclipped Ā· cropped Ā· resized Ā· rotated" + " Ā· with_position Ā· with_opacity Ā· …", + ), + ] + + base = _base_clip(3.0) + + # subclipped + sc = base.subclipped(0.5, 2.5) + scenes.append( + _titled(_resize_to_canvas(sc), "subclipped(start_time=0.5, end_time=2.5)") + ) + + # cropped + cr = base.cropped(x1=200, y1=100, x2=1200, y2=700).with_duration(CLIP_DUR) + scenes.append( + _titled(_resize_to_canvas(cr), "cropped(x1=200, y1=100, x2=1200, y2=700)") + ) + + # resized — by factor + rs1 = base.resized(0.5).with_duration(CLIP_DUR) + scenes.append(_titled(_resize_to_canvas(rs1), "resized(0.5) # half size")) + + # resized — by height + rs2 = base.resized(height=400).with_duration(CLIP_DUR) + scenes.append(_titled(_resize_to_canvas(rs2), "resized(height=400)")) + + # rotated + rt = base.rotated(30, expand=False, bg_color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append(_titled(rt, "rotated(angle=30, expand=False)")) + + # with_position + with_opacity in a composite + small = base.resized(0.4).with_duration(CLIP_DUR) + bg = ColorClip(size=(W, H), color=(10, 10, 10)).with_duration(CLIP_DUR) + p1 = small.with_position((50, 50)).with_opacity(1.0) + p2 = small.with_position((500, 300)).with_opacity(0.5) + comp = CompositeVideoClip([bg, p1, p2], size=(W, H)) + scenes.append(_titled(comp, "with_position() + with_opacity(0.5)")) + + # with_mask — circular mask + mask_arr = circle( + screensize=(W, H), + center=(W // 2, H // 2), + radius=300, + color=1.0, + bg_color=0.0, + blur=20, + ) + mask_clip = ImageClip(mask_arr, is_mask=True, duration=CLIP_DUR) + masked = base.with_duration(CLIP_DUR).with_mask(mask_clip) + mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked], size=(W, H)), + "with_mask() — circular mask via drawing.circle()", + ) + ) + + # image_transform + def flip_lr(img: np.ndarray) -> np.ndarray: + return img[:, ::-1] + + it = base.image_transform(flip_lr).with_duration(CLIP_DUR) + scenes.append(_titled(it, "image_transform(flip_lr_func)")) + + # transform + def shift_right(gf: Callable[[float], np.ndarray], t: float) -> np.ndarray: + frame = gf(t) + shift = int(t * 100) + return np.roll(frame, shift, axis=1) + + tf = base.transform(shift_right).with_duration(CLIP_DUR) + scenes.append(_titled(tf, "transform(shift_right_func)")) + + # time_transform + tt = base.time_transform(lambda t: t * 3).with_duration(CLIP_DUR) + scenes.append(_titled(tt, "time_transform(lambda t: t*3) # 3x speed")) + + # with_speed_scaled + ss = base.with_speed_scaled(factor=0.5) + scenes.append(_titled(ss.with_duration(CLIP_DUR), "with_speed_scaled(factor=0.5)")) + + # with_section_cut_out + sco = base.with_section_cut_out(0.5, 1.5) + scenes.append( + _titled( + sco.with_duration(min(sco.duration, CLIP_DUR)), + "with_section_cut_out(0.5, 1.5)", + ) + ) + + # to_ImageClip + still = base.to_ImageClip(t=1.0, duration=CLIP_DUR) + scenes.append(_titled(still, "to_ImageClip(t=1.0) # freeze at t=1")) + + # to_mask + to_RGB + bw = base.to_mask(canal=1).to_RGB().with_duration(CLIP_DUR) + scenes.append(_titled(bw, "to_mask(canal=1).to_RGB()")) + + # with_background_color + small2 = base.resized(0.5).with_duration(CLIP_DUR) + wbg = small2.with_background_color(size=(W, H), color=(80, 0, 120)) + scenes.append(_titled(wbg, "with_background_color(color=(80,0,120))")) + + # with_effects_on_subclip + eos = base.with_effects_on_subclip( + [InvertColors()], start_time=0.5, end_time=1.5 + ).with_duration(CLIP_DUR) + scenes.append(_titled(eos, "with_effects_on_subclip([InvertColors], 0.5, 1.5)")) + + # with_volume_scaled (visual label only — audio effect) + vsc = base.with_duration(CLIP_DUR) + scenes.append(_titled(vsc, "with_volume_scaled(factor) # scales audio amplitude")) + + # with_layer_index + scenes.append( + _titled( + base.with_duration(CLIP_DUR), "with_layer_index(n) # compositing z-order" + ) + ) + + return scenes diff --git a/python_pkg/moviepy_showcase/_moviepy_video_effects.py b/python_pkg/moviepy_showcase/_moviepy_video_effects.py new file mode 100644 index 0000000..50a05fa --- /dev/null +++ b/python_pkg/moviepy_showcase/_moviepy_video_effects.py @@ -0,0 +1,336 @@ +"""MoviePy showcase — Part 3 (all 34 Video Effects).""" + +from __future__ import annotations + +from moviepy import ( + ColorClip, + CompositeVideoClip, + ImageClip, + VideoClip, +) +from moviepy.video.fx import ( + AccelDecel, + BlackAndWhite, + Blink, + Crop, + CrossFadeIn, + CrossFadeOut, + EvenSize, + FadeIn, + FadeOut, + Freeze, + FreezeRegion, + GammaCorrection, + HeadBlur, + InvertColors, + Loop, + LumContrast, + MakeLoopable, + Margin, + MaskColor, + MirrorX, + MirrorY, + MultiplyColor, + MultiplySpeed, + Painting, + Resize, + Rotate, + Scroll, + SlideIn, + SlideOut, + SuperSample, + TimeMirror, + TimeSymmetrize, +) +from moviepy.video.tools.drawing import circle +import numpy as np + +from python_pkg.moviepy_showcase.moviepy_showcase import ( + CLIP_DUR, + H, + W, + _base_clip, + _resize_to_canvas, + _section_header, + _titled, +) + + +def _fx(effect: object, label: str, dur: float = CLIP_DUR) -> VideoClip: + """Apply effect to base clip and label it.""" + b = _base_clip(dur) + try: + result = b.with_effects([effect]) + # Ensure it has a finite duration + if result.duration is None or result.duration <= 0: + result = result.with_duration(dur) + result = result.with_duration(min(result.duration, dur)) + except (ValueError, OSError, AttributeError): + result = b + # Make sure it fits the canvas + if result.size != (W, H): + result = _resize_to_canvas(result) + return _titled(result, label) + + +def _part3_effects_1_to_17() -> list[VideoClip]: + """Video effects 1-17: AccelDecel through MakeLoopable.""" + scenes: list[VideoClip] = [] + + # 1. AccelDecel + scenes.append( + _fx( + AccelDecel(new_duration=CLIP_DUR, abruptness=2.0, soonness=1.0), + "AccelDecel(abruptness=2.0, soonness=1.0)", + ) + ) + + # 2. BlackAndWhite + scenes.append( + _fx( + BlackAndWhite(preserve_luminosity=True), + "BlackAndWhite(preserve_luminosity=True)", + ) + ) + + # 3. Blink + scenes.append( + _fx( + Blink(duration_on=0.3, duration_off=0.3), + "Blink(duration_on=0.3, duration_off=0.3)", + ) + ) + + # 4. Crop + b_crop = _base_clip().with_effects([Crop(x1=200, y1=100, x2=1400, y2=800)]) + scenes.append( + _titled(_resize_to_canvas(b_crop), "Crop(x1=200, y1=100, x2=1400, y2=800)") + ) + + # 5. CrossFadeIn + scenes.append(_fx(CrossFadeIn(duration=1.0), "CrossFadeIn(duration=1.0)")) + + # 6. CrossFadeOut + scenes.append(_fx(CrossFadeOut(duration=1.0), "CrossFadeOut(duration=1.0)")) + + # 7. EvenSize + scenes.append(_fx(EvenSize(), "EvenSize() # ensures even wxh")) + + # 8. FadeIn + scenes.append( + _fx( + FadeIn(duration=1.5, initial_color=[0, 0, 0]), + "FadeIn(duration=1.5, initial_color=[0,0,0])", + ) + ) + + # 9. FadeOut + scenes.append( + _fx( + FadeOut(duration=1.5, final_color=[0, 0, 0]), + "FadeOut(duration=1.5, final_color=[0,0,0])", + ) + ) + + # 10. Freeze + scenes.append( + _fx( + Freeze(t=0.5, freeze_duration=1.0), + "Freeze(t=0.5, freeze_duration=1.0)", + dur=3.0, + ) + ) + + # 11. FreezeRegion + scenes.append( + _fx( + FreezeRegion(t=0.5, region=(200, 100, 1400, 700)), + "FreezeRegion(t=0.5, region=(200,100,1400,700))", + ) + ) + + # 12. GammaCorrection + scenes.append(_fx(GammaCorrection(gamma=2.5), "GammaCorrection(gamma=2.5)")) + + # 13. HeadBlur + scenes.append( + _fx( + HeadBlur( + fx=lambda _: W // 2, + fy=lambda _: H // 2, + radius=100, + intensity=None, + ), + "HeadBlur(fx, fy, radius=100)", + ) + ) + + # 14. InvertColors + scenes.append(_fx(InvertColors(), "InvertColors()")) + + # 15. Loop + short = _base_clip(0.5) + looped = short.with_effects([Loop(n=4)]) + scenes.append(_titled(looped.with_duration(CLIP_DUR), "Loop(n=4)")) + + # 16. LumContrast + scenes.append( + _fx( + LumContrast(lum=30, contrast=50, contrast_threshold=127), + "LumContrast(lum=30, contrast=50)", + ) + ) + + # 17. MakeLoopable + scenes.append( + _fx(MakeLoopable(overlap_duration=0.5), "MakeLoopable(overlap_duration=0.5)") + ) + + return scenes + + +def _part3_effects_18_to_34() -> list[VideoClip]: + """Video effects 18-34: Margin through TimeSymmetrize.""" + scenes: list[VideoClip] = [] + + # 18. Margin + b_margin = _base_clip().with_effects( + [ + Resize(0.7), + Margin( + margin_size=None, + left=40, + right=40, + top=20, + bottom=20, + color=(255, 0, 0), + opacity=1.0, + ), + ] + ) + scenes.append( + _titled( + _resize_to_canvas(b_margin), + "Margin(left=40, right=40, top=20, bottom=20, color=red)", + ) + ) + + # 19. MaskColor + scenes.append( + _fx( + MaskColor(color=(128, 128, 128), threshold=80, stiffness=1), + "MaskColor(color, threshold=80)", + ) + ) + + # 20. MasksAnd + mask1 = circle((W, H), (W // 3, H // 2), 300, 1.0, 0.0, 1) + mask2 = circle((W, H), (2 * W // 3, H // 2), 300, 1.0, 0.0, 1) + combined = np.minimum(mask1, mask2) + m_clip = ImageClip(combined, is_mask=True, duration=CLIP_DUR) + masked_and = _base_clip().with_mask(m_clip) + mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked_and], size=(W, H)), + "MasksAnd — intersection of two circle masks", + ) + ) + + # 21. MasksOr + combined_or = np.maximum(mask1, mask2) + m_clip2 = ImageClip(combined_or, is_mask=True, duration=CLIP_DUR) + masked_or = _base_clip().with_mask(m_clip2) + scenes.append( + _titled( + CompositeVideoClip([mbg, masked_or], size=(W, H)), + "MasksOr — union of two circle masks", + ) + ) + + # 22. MirrorX + scenes.append(_fx(MirrorX(), "MirrorX() # horizontal flip")) + + # 23. MirrorY + scenes.append(_fx(MirrorY(), "MirrorY() # vertical flip")) + + # 24. MultiplyColor + scenes.append(_fx(MultiplyColor(factor=1.8), "MultiplyColor(factor=1.8)")) + + # 25. MultiplySpeed + scenes.append(_fx(MultiplySpeed(factor=3.0), "MultiplySpeed(factor=3.0)", dur=4.0)) + + # 26. Painting + scenes.append( + _fx( + Painting(saturation=1.4, black=0.006), + "Painting(saturation=1.4, black=0.006)", + ) + ) + + # 27. Resize + b_rs = _base_clip().with_effects([Resize(new_size=(960, 540))]) + scenes.append(_titled(_resize_to_canvas(b_rs), "Resize(new_size=(960,540))")) + + # 28. Rotate + scenes.append( + _fx( + Rotate(angle=45, expand=True, bg_color=(0, 0, 0)), + "Rotate(angle=45, expand=True)", + ) + ) + + # 29. Scroll + # Draw bands + tall_arr = np.full((H * 3, W, 3), 40, dtype=np.uint8) + for i in range(6): + y0, y1 = i * H // 2, (i + 1) * H // 2 + tall_arr[y0:y1, :] = [ + (50 * i) % 256, + (100 + 30 * i) % 256, + (200 - 20 * i) % 256, + ] + tall_clip = ImageClip(tall_arr, duration=CLIP_DUR).with_effects( + [ + Scroll(h=H, y_speed=-300, w=W), + ] + ) + scenes.append(_titled(_resize_to_canvas(tall_clip), "Scroll(h, y_speed=-300)")) + + # 30. SlideIn + si = _base_clip().with_effects([SlideIn(duration=1.0, side="left")]) + scenes.append(_titled(si, "SlideIn(duration=1.0, side='left')")) + + # 31. SlideOut + so = _base_clip().with_effects([SlideOut(duration=1.0, side="right")]) + scenes.append(_titled(so, "SlideOut(duration=1.0, side='right')")) + + # 32. SuperSample + scenes.append(_fx(SuperSample(d=0.1, n_frames=3), "SuperSample(d=0.1, n_frames=3)")) + + # 33. TimeMirror + tm = _base_clip().with_effects([TimeMirror()]) + scenes.append( + _titled(tm.with_duration(CLIP_DUR), "TimeMirror() # plays backwards") + ) + + # 34. TimeSymmetrize + ts = _base_clip().with_effects([TimeSymmetrize()]) + scenes.append( + _titled(ts.with_duration(CLIP_DUR), "TimeSymmetrize() # forward then reverse") + ) + + return scenes + + +def part3_video_effects() -> list[VideoClip]: + """Demonstrate all 34 video effects.""" + scenes: list[VideoClip] = [ + _section_header( + "Part 3: Video Effects", + "All 34 effects from moviepy.video.fx", + ), + ] + scenes.extend(_part3_effects_1_to_17()) + scenes.extend(_part3_effects_18_to_34()) + return scenes diff --git a/python_pkg/moviepy_showcase/moviepy_showcase.py b/python_pkg/moviepy_showcase/moviepy_showcase.py new file mode 100644 index 0000000..58163cd --- /dev/null +++ b/python_pkg/moviepy_showcase/moviepy_showcase.py @@ -0,0 +1,306 @@ +"""MoviePy 2.x — Comprehensive Showcase of ALL Features. + +Generates a video demonstrating every MoviePy class, method, effect, +and tool. Organised into sections: + + Part 1: Clip Types (VideoClip, ColorClip, TextClip, ImageClip, + BitmapClip, DataVideoClip, ImageSequenceClip) + Part 2: Clip Methods (subclipped, cropped, resized, rotated, with_position, + with_opacity, with_mask, image_transform, transform, + time_transform, with_speed_scaled, with_section_cut_out, + to_ImageClip, to_mask, to_RGB, with_background_color, + with_effects_on_subclip, with_layer_index) + Part 3: Video Effects (all 34) + Part 4: Audio (AudioClip, AudioArrayClip, CompositeAudioClip, + all 7 audio effects) + Part 5: Composition (CompositeVideoClip, concatenate_videoclips, clips_array) + Part 6: Drawing Tools (circle, color_gradient, color_split) + Part 7: Output (write_videofile params, write_gif, save_frame, + write_images_sequence) +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shutil +import tempfile + +from moviepy import ( + ColorClip, + CompositeVideoClip, + TextClip, + VideoClip, + VideoFileClip, + concatenate_videoclips, +) +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + +logger = logging.getLogger(__name__) + +os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" + +# ── Constants ───────────────────────────────────────────────────── +W, H = 1920, 1080 +FPS = 30 +CLIP_DUR = 2.0 # duration of each demo clip +HEADER_DUR = 1.5 # duration of section headers +OUTPUT = "moviepy_showcase_full.mp4" +FONT_B = "/usr/share/fonts/noto/NotoSans-Bold.ttf" +FONT_R = "/usr/share/fonts/noto/NotoSans-Regular.ttf" + +# ── Pre-computed gradient LUTs ──────────────────────────────────── +_G_CH = ( + np.linspace(0, 255, H, dtype=np.uint8)[:, None] + * np.ones(W, dtype=np.uint8)[None, :] +) +_B_CH = ( + np.ones(H, dtype=np.uint8)[:, None] + * np.linspace(0, 255, W, dtype=np.uint8)[None, :] +) + + +def _gradient(t: float) -> np.ndarray: + f = np.empty((H, W, 3), dtype=np.uint8) + f[:, :, 0] = int(128 + 127 * np.sin(t * 2)) + f[:, :, 1] = _G_CH + f[:, :, 2] = _B_CH + return f + + +def _checkerboard(t: float) -> np.ndarray: + sq = 60 + off = int(t * 40) % sq + xs = np.arange(W, dtype=np.int32)[None, :] + ys = np.arange(H, dtype=np.int32)[:, None] + v = (((xs + off) // sq + (ys + off) // sq) % 2 * 255).astype(np.uint8) + return np.dstack([v, v, v]) + + +# ── Helpers ─────────────────────────────────────────────────────── +def _base_clip(dur: float = CLIP_DUR) -> VideoClip: + """Animated gradient as a reusable base clip.""" + return VideoClip(_gradient, duration=dur).with_fps(FPS) + + +def _label( + text: str, + size: int = 36, + color: str = "white", + pos: tuple[str, int] | tuple[str, str] = ("center", 40), + dur: float = CLIP_DUR, +) -> TextClip: + """Small label overlay (transparent bg).""" + return ( + TextClip( + text=text, + font_size=size, + color=color, + font=FONT_R, + margin=(0, 15), + ) + .with_duration(dur) + .with_position(pos) + ) + + +def _titled(clip: VideoClip, text: str) -> CompositeVideoClip: + """Overlay a label onto a clip.""" + lbl = _label(text, dur=clip.duration) + return CompositeVideoClip( + [clip.with_duration(clip.duration), lbl], + size=(W, H), + ) + + +def _section_header(title: str, subtitle: str = "") -> CompositeVideoClip: + """Dark background with centred title text.""" + bg = ColorClip(size=(W, H), color=(15, 15, 40)).with_duration(HEADER_DUR) + t = ( + TextClip( + text=title, + font_size=72, + color="white", + font=FONT_B, + margin=(0, 30), + ) + .with_duration(HEADER_DUR) + .with_position(("center", 380)) + ) + parts: list[VideoClip] = [bg, t] + if subtitle: + s = ( + TextClip( + text=subtitle, + font_size=32, + color="#aaaaaa", + font=FONT_R, + margin=(0, 15), + ) + .with_duration(HEADER_DUR) + .with_position(("center", 520)) + ) + parts.append(s) + return CompositeVideoClip(parts, size=(W, H)) + + +def _resize_to_canvas(clip: VideoClip) -> VideoClip: + """Resize a clip to fit (W, H) and centre on black background.""" + cw, ch = clip.size + scale = min(W / cw, H / ch) + return clip.resized( + width=int(cw * scale), height=int(ch * scale) + ).with_background_color(size=(W, H), color=(0, 0, 0)) + + +# ══════════════════════════════════════════════════════════════════ +# ASSEMBLY — memory-safe: render each part to a temp file, then +# concatenate via VideoFileClip so only one part is in RAM at a time. +# ══════════════════════════════════════════════════════════════════ +def _render_part( + scenes: list[VideoClip], + path: str, + label: str, +) -> None: + """Concatenate *scenes*, write to *path*, then close all clips.""" + logger.info(" Rendering %s (%d scenes) → %s", label, len(scenes), Path(path).name) + part = concatenate_videoclips(scenes, method="compose", bg_color=(0, 0, 0)) + part.write_videofile( + path, + fps=FPS, + codec="libx264", + preset="ultrafast", + audio=False, + logger=None, + ) + # Free memory + part.close() + for s in scenes: + s.close() + + +def main() -> None: + """Assemble all parts into the final showcase video.""" + logging.basicConfig(level=logging.INFO) + logger.info("Building MoviePy comprehensive showcase…") + + tmpdir = tempfile.mkdtemp(prefix="moviepy_showcase_") + try: + _build(tmpdir) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +def _build(tmpdir: str) -> None: + # ── Lazy imports of moved part builders ─────────────────────── + from moviepy.audio.fx import MultiplyVolume + + from python_pkg.moviepy_showcase._moviepy_audio_output import ( + _make_sine, + part4_audio, + part5_composition, + part6_drawing_tools, + part7_output, + ) + from python_pkg.moviepy_showcase._moviepy_clip_types import ( + part1_clip_types, + part2_clip_methods, + ) + from python_pkg.moviepy_showcase._moviepy_video_effects import part3_video_effects + + # ── Render each part to its own temp file ───────────────────── + # Title card + title_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0) + title_txt = ( + TextClip( + text="MoviePy 2.x\nComplete Feature Showcase", + font_size=80, + color="white", + font=FONT_B, + method="caption", + size=(W - 200, None), + text_align="center", + margin=(0, 40), + ) + .with_duration(3.0) + .with_position("center") + ) + title_card = CompositeVideoClip([title_bg, title_txt], size=(W, H)).with_effects( + [FadeIn(1.0)] + ) + + # Outro + outro_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0) + outro_txt = ( + TextClip( + text="That's all of MoviePy 2.x!\n34 video effects Ā· 7 audio effects\n" + "11 clip types Ā· drawing tools Ā· composition", + font_size=52, + color="white", + font=FONT_B, + method="caption", + size=(W - 200, None), + text_align="center", + margin=(0, 30), + ) + .with_duration(3.0) + .with_position("center") + ) + outro = CompositeVideoClip([outro_bg, outro_txt], size=(W, H)).with_effects( + [FadeOut(1.5)] + ) + + part_builders = [ + ("title", lambda: [title_card]), + ("Part 1: Clip Types", part1_clip_types), + ("Part 2: Clip Methods", part2_clip_methods), + ("Part 3: Video Effects", part3_video_effects), + ("Part 4: Audio", part4_audio), + ("Part 5: Composition", part5_composition), + ("Part 6: Drawing Tools", part6_drawing_tools), + ("Part 7: Output Methods", part7_output), + ("outro", lambda: [outro]), + ] + + part_files: list[str] = [] + for i, (label, builder) in enumerate(part_builders): + path = str(Path(tmpdir) / f"part_{i:02d}.mp4") + scenes = builder() + _render_part(scenes, path, label) + part_files.append(path) + + # ── Load temp files as lightweight VideoFileClips & concat ───── + logger.info("Concatenating all parts…") + file_clips = [VideoFileClip(p) for p in part_files] + final = concatenate_videoclips(file_clips, method="chain") + + # Background audio + audio = _make_sine(330, final.duration).with_effects([MultiplyVolume(factor=0.5)]) + final = final.with_audio(audio) + + logger.info("Total duration: %.1fs", final.duration) + logger.info("Writing %s (NVENC GPU)…", OUTPUT) + + final.write_videofile( + OUTPUT, + fps=FPS, + codec="h264_nvenc", + audio_codec="aac", + threads=os.cpu_count(), + ffmpeg_params=["-preset", "p4", "-rc", "constqp", "-qp", "18", "-b:v", "0"], + logger="bar", + ) + + # Clean up + final.close() + for c in file_clips: + c.close() + + size_mb = Path(OUTPUT).stat().st_size / (1024 * 1024) + logger.info("āœ” Saved %s (%.1f MB)", OUTPUT, size_mb) + + +if __name__ == "__main__": + main() diff --git a/python_pkg/poker_modifier_app/README.md b/python_pkg/poker_modifier_app/README.md new file mode 100644 index 0000000..18f45bc --- /dev/null +++ b/python_pkg/poker_modifier_app/README.md @@ -0,0 +1,58 @@ +# Texas Hold'em Modifier App + +A fun web application that randomly applies modifiers to Texas Hold'em poker games with configurable probability. + +## Features + +- **Configurable Probability**: Adjust the chance of getting a modifier (0-100%) +- **15 Unique Modifiers**: Various game-changing rules like "High Stakes", "Wild Card", "Reverse Psychology", etc. +- **Statistics Tracking**: Keep track of rounds played and modifiers applied +- **Beautiful UI**: Modern, responsive design with poker-themed styling +- **Smooth Animations**: Visual feedback for button clicks and result displays + +## How to Use + +1. Open `index.html` in your web browser +2. Adjust the "Modifier Probability" slider to set the chance of getting a modifier +3. Click "Start Round" to begin a new round +4. The app will randomly decide whether to apply a modifier based on your probability setting +5. If a modifier is chosen, a random modifier will be selected and displayed + +## Modifiers Included + +- **High Stakes**: All bets are doubled +- **Wild Card**: Next card can be used as any card +- **Bluff Master**: See one opponent's card before betting +- **All-In Fever**: If someone goes all-in, everyone must match or fold +- **Lucky Sevens**: Any hand with a 7 beats a pair +- **Reverse Psychology**: Lowest hand wins +- **Split Pot**: Pot split between top 2 hands +- **Texas Twister**: Each player gets an extra hole card +- **Blind Luck**: Play blind until the river +- **Community Boost**: Extra community card revealed +- **Minimum Madness**: Minimum bet tripled +- **Suit Supremacy**: Random suit cards worth +1 rank +- **Quick Draw**: Betting time cut in half +- **Royal Treatment**: Face cards worth double +- **Chip Challenge**: Winner gets extra house chips + +## Files + +- `index.html`: Main HTML structure +- `style.css`: Styling and responsive design +- `script.js`: JavaScript functionality and modifier logic + +## Customization + +You can easily add new modifiers by using the `addModifier()` method: + +```javascript +window.pokerApp.addModifier( + "Your Modifier Name", + "Description of what it does", +); +``` + +## Browser Compatibility + +Works in all modern web browsers (Chrome, Firefox, Safari, Edge). diff --git a/python_pkg/poker_modifier_app/README_python.md b/python_pkg/poker_modifier_app/README_python.md new file mode 100644 index 0000000..a1ce5f9 --- /dev/null +++ b/python_pkg/poker_modifier_app/README_python.md @@ -0,0 +1,138 @@ +# Texas Hold'em Modifier App - Python Version + +A desktop application built with Python and tkinter that randomly applies modifiers to Texas Hold'em poker games with configurable probability. + +## Requirements + +- Python 3.6+ +- tkinter (usually comes with Python) + +## How to Run + +```bash +python poker_modifier_app.py +``` + +## Features + +- **Configurable Probability**: Adjust the chance of getting a modifier (0-100%) with a slider +- **50+ Poker & Drinking Modifiers**: Real poker variations with drinking game twists! +- **Statistics Tracking**: Keep track of rounds played and modifiers applied +- **Modern GUI**: Clean, poker-themed interface with visual feedback +- **Easy to Extend**: Simple methods to add new modifiers + +## How to Use + +1. Run the Python script +2. Adjust the "Modifier Probability" slider to set the chance of getting a modifier +3. Click "Start Round" to begin a new round +4. The app will randomly decide whether to apply a modifier based on your probability setting +5. If a modifier is chosen, a random modifier will be selected and displayed + +## Modifiers Included + +### Classic Poker Modifiers + +- **High Stakes**: All bets are doubled +- **Wild Card**: Next card can be used as any card +- **Bluff Master**: See one opponent's card before betting +- **All-In Fever**: If someone goes all-in, everyone must match or fold +- **Lucky Sevens**: Any hand with a 7 beats a pair +- **Reverse Psychology**: Lowest hand wins +- **Split Pot**: Pot split between top 2 hands +- **Texas Twister**: Each player gets an extra hole card +- **Blind Luck**: Play blind until the river +- **Community Boost**: Extra community card revealed +- **Minimum Madness**: Minimum bet tripled +- **Suit Supremacy**: Random suit cards worth +1 rank +- **Quick Draw**: Betting time cut in half +- **Royal Treatment**: Face cards worth double +- **Chip Challenge**: Winner gets extra house chips + +## Modifiers Included + +### Classic Poker Modifiers + +- **High Stakes**: All bets are doubled +- **Wild Card**: Next card can be used as any card +- **Bluff Master**: See one opponent's card before betting +- **All-In Fever**: If someone goes all-in, everyone must match or fold +- **Lucky Sevens**: Any hand with a 7 beats a pair +- **Reverse Psychology**: Lowest hand wins +- **Split Pot**: Pot split between top 2 hands +- **Texas Twister**: Each player gets an extra hole card +- **Blind Luck**: Play blind until the river +- **Community Boost**: Extra community card revealed +- **Minimum Madness**: Minimum bet tripled +- **Suit Supremacy**: Random suit cards worth +1 rank +- **Quick Draw**: Betting time cut in half +- **Royal Treatment**: Face cards worth double +- **Chip Challenge**: Winner gets extra house chips + +### Drinking Game Modifiers + +- **Red or Black**: Guess community card colors for double winnings +- **Pocket Rockets**: Pocket Aces trigger drinks for everyone else +- **Rainbow Flop**: 3-suit flop boosts flush draws +- **Suited Connectors**: Beat any pocket pair +- **Drink or Fold**: Choose to drink and stay in or fold +- **Shot Clock**: 10 seconds per decision or auto-fold +- **Double Down**: Pay double to see opponent's cards +- **Bad Beat Jackpot**: Losing with full house+ makes others drink +- **Chaser Round**: Previous loser gets bonus stack +- **Face Card Frenzy**: Each face card = take a sip +- **Burn Card Reveal**: Matching burn cards = drinks + chips +- **Pair Tax**: Pocket pairs cost extra or drink +- **Kicker Clash**: Lowest kicker in tie drinks +- **Color Blind**: Red cards +1, black cards -1 +- **Sip and Tell**: Drink and honestly rate your hand +- **Last Call**: Final betting round, no more cards +- **Drink the River**: River helps you = others drink +- **Tipsy Tells**: Must make exaggerated expressions +- **House Rules**: Deuces wild but drink when used +- **Side Bet Madness**: Bet on what flop will contain +- **Fold Penalty**: Folders drink and sit out next hand +- **Straight Shooter**: Complete straight = pick someone to finish drink +- **Flush Rush**: First flush wins side pot from all +- **Ace High Drama**: Ace high wins double but finish drink +- **Bluff Check**: Caught bluffing = drink + penalty +- **Small Ball**: Only minimum bets allowed +- **Position Power**: Button sees everyone's first card +- **Community Chest**: 6 community cards total +- **Heads Up**: Only top 2 hands after flop continue +- **Dealer's Choice**: Dealer picks wild suits +- **Ante Up**: Double ante or take two drinks +- **Showdown Shuffle**: Simultaneous card reveal +- **Lucky Draw**: Extra card, choose best 2 +- **Betting Blind**: First round before looking at cards +- **Chip and a Chair**: Short stack sees early community card +- **All Red**: Red cards boost hand level +- **Mississippi Stud**: Fold after flop for half bet back + +## Code Structure + +- `PokerModifierApp`: Main application class +- `setup_gui()`: Creates the tkinter interface +- `start_round()`: Main game logic for starting rounds +- `apply_random_modifier()`: Selects and displays a random modifier +- `show_no_modifier()`: Displays when no modifier is chosen +- `add_modifier()`: Method to add new modifiers +- `get_stats()`: Returns current statistics + +## Customization + +You can easily add new modifiers programmatically: + +```python +app = PokerModifierApp() +app.add_modifier("Your Modifier Name", "Description of what it does") +app.run() +``` + +## GUI Components + +- **Title**: Application header +- **Settings Panel**: Probability slider +- **Result Display**: Shows modifier or "no modifier" message +- **Start Button**: Triggers new round +- **Statistics**: Displays rounds played and modifiers applied diff --git a/python_pkg/poker_modifier_app/__init__.py b/python_pkg/poker_modifier_app/__init__.py new file mode 100644 index 0000000..d544097 --- /dev/null +++ b/python_pkg/poker_modifier_app/__init__.py @@ -0,0 +1 @@ +"""Poker modifier application package.""" diff --git a/python_pkg/poker_modifier_app/_poker_gui.py b/python_pkg/poker_modifier_app/_poker_gui.py new file mode 100644 index 0000000..07da909 --- /dev/null +++ b/python_pkg/poker_modifier_app/_poker_gui.py @@ -0,0 +1,303 @@ +"""GUI setup methods for the poker modifier application.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from python_pkg.poker_modifier_app.poker_modifier_app import PokerModifierApp + + +class PokerGuiMixin: + """Mixin providing GUI setup methods for PokerModifierApp.""" + + self: PokerModifierApp + + def setup_gui(self) -> None: + """Create and configure the main GUI window.""" + self._setup_main_window() + main_frame = self._create_main_frame() + self._create_title(main_frame) + self._create_settings_frame(main_frame) + self._create_result_display(main_frame) + self._create_buttons(main_frame) + self._create_statistics_frame(main_frame) + + def _setup_main_window(self) -> None: + """Initialize the main Tk window.""" + self.root = tk.Tk() + self.root.title("šŸƒ Texas Hold'em Modifier") + self.root.geometry("650x750") + self.root.configure(bg="#0f4c3a") + self.root.resizable(True, True) + style = ttk.Style() + style.theme_use("clam") + + def _create_main_frame(self) -> tk.Frame: + """Create and return the main container frame.""" + main_frame = tk.Frame(self.root, bg="#0f4c3a", padx=20, pady=20) + main_frame.pack(fill=tk.BOTH, expand=True) + return main_frame + + def _create_title(self, parent: tk.Frame) -> None: + """Create the title label.""" + title_label = tk.Label( + parent, + text="šŸƒ Texas Hold'em Modifier", + font=("Arial", 24, "bold"), + fg="#ffd700", + bg="#0f4c3a", + ) + title_label.pack(pady=(0, 20)) + + def _create_settings_frame(self, parent: tk.Frame) -> None: + """Create the settings frame. + + Includes probability, debug, and game length controls. + """ + settings_frame = tk.LabelFrame( + parent, + text="Settings", + font=("Arial", 12, "bold"), + fg="#ffd700", + bg="#1a6b4d", + relief=tk.RIDGE, + bd=2, + ) + settings_frame.pack(fill=tk.X, pady=(0, 20), padx=10, ipady=10) + + self._create_probability_controls(settings_frame) + self._create_debug_controls(settings_frame) + self._create_length_controls(settings_frame) + + def _create_probability_controls(self, parent: tk.Widget) -> None: + """Create the probability slider and label.""" + prob_frame = tk.Frame(parent, bg="#1a6b4d") + prob_frame.pack(fill=tk.X, padx=10, pady=5) + + tk.Label( + prob_frame, + text="Modifier Probability:", + font=("Arial", 11, "bold"), + fg="white", + bg="#1a6b4d", + ).pack(side=tk.LEFT) + + self.prob_var = tk.IntVar(value=30) + self.prob_scale = tk.Scale( + prob_frame, + from_=0, + to=100, + orient=tk.HORIZONTAL, + variable=self.prob_var, + command=self.update_prob_display, + bg="#1a6b4d", + fg="white", + highlightbackground="#1a6b4d", + troughcolor="#0f4c3a", + activebackground="#ffd700", + ) + self.prob_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 5)) + + self.prob_label = tk.Label( + prob_frame, + text="30%", + font=("Arial", 11, "bold"), + fg="#ffd700", + bg="#1a6b4d", + width=5, + ) + self.prob_label.pack(side=tk.RIGHT) + + def _create_debug_controls(self, parent: tk.Widget) -> None: + """Create the debug mode checkbox and force endgame button.""" + debug_frame = tk.Frame(parent, bg="#1a6b4d") + debug_frame.pack(fill=tk.X, padx=10, pady=5) + + self.debug_var = tk.BooleanVar(value=False) + debug_check = tk.Checkbutton( + debug_frame, + text="Debug Mode", + variable=self.debug_var, + command=self.toggle_debug_mode, + bg="#1a6b4d", + fg="white", + selectcolor="#0f4c3a", + activebackground="#1a6b4d", + activeforeground="#ffd700", + font=("Arial", 10, "bold"), + ) + debug_check.pack(side=tk.LEFT, padx=(0, 15)) + + self.force_endgame_button = tk.Button( + debug_frame, + text="Force Endgame", + command=self.toggle_force_endgame, + bg="#ff6b6b", + fg="white", + font=("Arial", 9, "bold"), + relief=tk.RAISED, + bd=2, + ) + # Initially hidden + + def _create_length_controls(self, parent: tk.Widget) -> None: + """Create the game length slider and label.""" + length_frame = tk.Frame(parent, bg="#1a6b4d") + length_frame.pack(fill=tk.X, padx=10, pady=5) + + tk.Label( + length_frame, + text="Total Game Rounds:", + font=("Arial", 11, "bold"), + fg="white", + bg="#1a6b4d", + ).pack(side=tk.LEFT) + + self.length_var = tk.IntVar(value=20) + self.length_scale = tk.Scale( + length_frame, + from_=5, + to=50, + orient=tk.HORIZONTAL, + variable=self.length_var, + command=self.update_length_display, + bg="#1a6b4d", + fg="white", + highlightbackground="#1a6b4d", + troughcolor="#0f4c3a", + activebackground="#ffd700", + ) + self.length_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 5)) + + self.length_label = tk.Label( + length_frame, + text="20", + font=("Arial", 11, "bold"), + fg="#ffd700", + bg="#1a6b4d", + width=5, + ) + self.length_label.pack(side=tk.RIGHT) + + def _create_result_display(self, parent: tk.Frame) -> None: + """Create the result display frame.""" + self.result_frame = tk.Frame( + parent, bg="#2d2d2d", relief=tk.RIDGE, bd=3, height=150 + ) + self.result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 20), padx=10) + self.result_frame.pack_propagate(False) + + self.result_label = tk.Label( + self.result_frame, + text="Click 'Start Round' to begin!", + font=("Arial", 14), + fg="#cccccc", + bg="#2d2d2d", + wraplength=500, + justify=tk.CENTER, + ) + self.result_label.pack(expand=True, fill=tk.BOTH, padx=20, pady=20) + + def _create_buttons(self, parent: tk.Frame) -> None: + """Create the start and reset buttons.""" + button_frame = tk.Frame(parent, bg="#0f4c3a") + button_frame.pack(fill=tk.X, pady=(0, 20), padx=10) + + self.start_button = tk.Button( + button_frame, + text="Start Round", + font=("Arial", 18, "bold"), + bg="#ffd700", + fg="#0f4c3a", + activebackground="#ffed4e", + activeforeground="#0f4c3a", + relief=tk.RAISED, + bd=3, + command=self.start_round, + cursor="hand2", + ) + self.start_button.pack( + side=tk.LEFT, fill=tk.X, expand=True, ipady=10, padx=(0, 5) + ) + + self.reset_button = tk.Button( + button_frame, + text="Reset Game", + font=("Arial", 14, "bold"), + bg="#ff6b6b", + fg="white", + activebackground="#ff5252", + activeforeground="white", + relief=tk.RAISED, + bd=3, + command=self.reset_game, + cursor="hand2", + ) + self.reset_button.pack(side=tk.RIGHT, ipady=10, padx=(5, 0)) + + def _create_statistics_frame(self, parent: tk.Frame) -> None: + """Create the statistics display frame with rounds, modifiers, and phase.""" + stats_frame = tk.Frame(parent, bg="#0f4c3a") + stats_frame.pack(fill=tk.X, padx=10) + + # Rounds played + rounds_frame = tk.LabelFrame( + stats_frame, + text="Rounds Played", + font=("Arial", 10, "bold"), + fg="#cccccc", + bg="#1a6b4d", + relief=tk.RIDGE, + bd=2, + ) + rounds_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 3)) + + self.rounds_label = tk.Label( + rounds_frame, + text="0", + font=("Arial", 20, "bold"), + fg="#ffd700", + bg="#1a6b4d", + ) + self.rounds_label.pack(pady=10) + + # Modifiers applied + mods_frame = tk.LabelFrame( + stats_frame, + text="Modifiers Applied", + font=("Arial", 10, "bold"), + fg="#cccccc", + bg="#1a6b4d", + relief=tk.RIDGE, + bd=2, + ) + mods_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(3, 3)) + + self.mods_label = tk.Label( + mods_frame, text="0", font=("Arial", 20, "bold"), fg="#ffd700", bg="#1a6b4d" + ) + self.mods_label.pack(pady=10) + + # Game phase indicator + phase_frame = tk.LabelFrame( + stats_frame, + text="Game Phase", + font=("Arial", 10, "bold"), + fg="#cccccc", + bg="#1a6b4d", + relief=tk.RIDGE, + bd=2, + ) + phase_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=(3, 0)) + + self.phase_label = tk.Label( + phase_frame, + text="Early", + font=("Arial", 16, "bold"), + fg="#4CAF50", + bg="#1a6b4d", + ) + self.phase_label.pack(pady=10) diff --git a/python_pkg/poker_modifier_app/_poker_modifiers.py b/python_pkg/poker_modifier_app/_poker_modifiers.py new file mode 100644 index 0000000..6b5e8ce --- /dev/null +++ b/python_pkg/poker_modifier_app/_poker_modifiers.py @@ -0,0 +1,465 @@ +"""Modifier data constants for the poker modifier application.""" + +from __future__ import annotations + +from typing import TypeAlias + +Modifier: TypeAlias = dict[str, str] + +REGULAR_MODIFIERS: list[Modifier] = [ + # Hand Bonus Modifiers (Balatro-inspired) + { + "name": "Pair Bonus", + "description": ( + "Any pocket pair: everyone else pays you 1 chip, " + "even if you lose the hand." + ), + }, + { + "name": "Flush Fever", + "description": ( + "Make a flush: collect 1 chip from each other player " + "(separate from main pot)." + ), + }, + { + "name": "Straight Shot", + "description": ( + "Complete a straight: choose one player " + "to pay you half the current pot size." + ), + }, + { + "name": "Full House Party", + "description": ( + "Make full house: everyone else pays 2 chips + takes 2 drinks." + ), + }, + { + "name": "High Card Hero", + "description": ( + "Win with just high card: collect your normal winnings " + "+ 1 chip from each player." + ), + }, + # Card Enhancement Modifiers + { + "name": "Face Card Power", + "description": "All face cards (J, Q, K) count as Aces for this hand.", + }, + { + "name": "Red Suit Boost", + "description": ( + "Hearts and Diamonds are worth +1 rank (Jack becomes Queen, etc.)" + ), + }, + { + "name": "Black Magic", + "description": ( + "Spades and Clubs can be used as any suit for straights/flushes." + ), + }, + { + "name": "Lucky Sevens", + "description": "All 7s become wild cards that can be any rank.", + }, + { + "name": "Steel Cards", + "description": ( + "Random rank chosen: {steel_rank}. " + "All {steel_rank}s beat everything this hand!" + ), + }, + # Ante-Based Effects (Clear Money Source) + { + "name": "Bonus Pool", + "description": ( + "Everyone puts 2 chips in bonus pool. " + "First person to make any pair wins it all." + ), + }, + # Deck Manipulation (Balatro-style) + { + "name": "Deck Shuffle", + "description": ( + "After dealing hole cards, shuffle deck " "and redeal all community cards." + ), + }, + { + "name": "Extra Draw", + "description": ( + "Deal each player a 3rd hole card. Discard one before the flop." + ), + }, + { + "name": "Phantom Cards", + "description": ( + "Deal 6 community cards, but randomly remove 1 before showdown." + ), + }, + # Special Betting Rules (Realistic Economics) + { + "name": "Escalation", + "description": ( + "Each raise must be at least 2x the previous raise " "(not just matching)." + ), + }, + # Position and Action Modifiers + { + "name": "Button Bonus", + "description": "Dealer button acts last in ALL rounds", + }, + { + "name": "Call Penalty", + "description": ( + "Anyone who only calls (never raises) pays 1 chip penalty to pot." + ), + }, + # Information Warfare + { + "name": "Poker Face", + "description": ( + "No talking, no expressions allowed. Pure silent poker this hand." + ), + }, + { + "name": "Truth or Consequences", + "description": ( + "If asked 'good hand or bad hand?' " + "you must answer truthfully or pay penalty." + ), + }, + { + "name": "Open Book", + "description": "Everyone plays with one hole card face-up.", + }, + # Drinking Game Integration + { + "name": "Liquid Courage", + "description": ( + "Take a drink before betting to get chip bonus to all your bets." + ), + }, + { + "name": "Last Call", + "description": ( + "Everyone must finish their current drink before the river card." + ), + }, + { + "name": "Shot Clock", + "description": "5 seconds to act or take a shot and auto-fold.", + }, + { + "name": "Drink Tax", + "description": ( + "Each red card in your final hand = one sip (reveal after play)." + ), + }, + # Wild and Chaos Effects + { + "name": "Joker's Wild", + "description": ( + "All Jacks become completely wild - any suit, any rank you choose." + ), + }, + { + "name": "Suit Swap", + "description": "Hearts become Spades, Diamonds become Clubs this hand.", + }, + { + "name": "Rank Revolution", + "description": "2s beat Aces this hand. All other ranks stay the same.", + }, + { + "name": "Time Warp", + "description": ( + "Play the hand completely backwards: showdown first, " + "then remove random cards from table!" + ), + }, + # Economic Effects (Clear Money Sources) + { + "name": "Poverty Mode", + "description": "All bets limited to 1 chip maximum this hand.", + }, + { + "name": "High Roller", + "description": "Minimum bet is 5x the entry this hand.", + }, + { + "name": "Charity Case", + "description": ( + "Player with fewest chips gets their ante funded by richest player." + ), + }, + # Penalty-Based Modifiers (Clear Consequences) + { + "name": "Fold Tax", + "description": "Anyone who folds pays 5 chip to the pot immediately.", + }, + { + "name": "Bluff Fine", + "description": "Get caught bluffing = pay 2 chips to next hand's pot.", + }, + { + "name": "Speed Fine", + "description": ("Take longer than 10 seconds to act = pay 1 chip to pot."), + }, + { + "name": "Talk Tax", + "description": ("Every word spoken during betting costs 1 chip to the pot."), + }, + # Skill Challenges (With Clear Rewards/Penalties) + { + "name": "Memory Challenge", + "description": ( + "Dealer names all community cards in order. " + "Success = collect 1 chip from each. " + "Fail = pay 1 chip to each." + ), + }, + { + "name": "Quick Draw", + "description": ( + "Everyone pays 1 chip to quick-draw pot. " + "First to correctly announce their hand wins the pot." + ), + }, + { + "name": "Bluff Bonus", + "description": ( + "Successfully bluff with 7-high or worse " + "= collect 2 chips from each other player." + ), + }, + { + "name": "Prediction Pool", + "description": ( + "Everyone puts 1 chip in pool. " + "Guess the river card exactly = win the pool." + ), + }, + # Partnership Modifiers + { + "name": "Buddy System", + "description": ( + "Each player chooses a partner. " + "Partners share fate - both win or both lose." + ), + }, + { + "name": "Duo Power", + "description": ( + "Partners can combine their hole cards - " + "each player plays with 4 cards total." + ), + }, + { + "name": "Shared Vision", + "description": ( + "Partners can show each other one hole card before betting starts." + ), + }, + { + "name": "Tag Team", + "description": ( + "Partners alternate who plays each betting round " + "(pre-flop, flop, turn, river)." + ), + }, + { + "name": "Power Couple", + "description": ( + "If both partners make it to showdown, they both get +1 chip bonus " + "from other players (revealed at end of round)." + ), + }, +] + +ENDGAME_MODIFIERS: list[Modifier] = [ + # Classic Endgame Modifiers + { + "name": "Final Boss", + "description": ("This is the last hand. Winner takes all remaining chips."), + }, + { + "name": "Sudden Death", + "description": "Anyone who folds is eliminated from the game.", + }, + { + "name": "Comeback Kid", + "description": ( + "Player with the worst hand can't lose chips this round " + "(reveal at the end of round)." + ), + }, + { + "name": "Double or Nothing", + "description": ( + "Winner gets double payout, but everyone else pays double penalty." + ), + }, + # High Stakes Endgame + { + "name": "All In Madness", + "description": ( + "Everyone must go all-in. No calling, no folding allowed this hand." + ), + }, + { + "name": "Chip Volcano", + "description": ( + "Everyone puts half their remaining chips in the center. " + "Winner takes the mountain." + ), + }, + { + "name": "Last Stand", + "description": ( + "Player with fewest chips gets to act last in ALL betting rounds." + ), + }, + # Dramatic Reversals + { + "name": "Underdog Victory", + "description": ("Worst hand wins the pot instead of best hand this round."), + }, + # Winner Takes All Variants + { + "name": "Crown Jewels", + "description": ( + "Winner of this hand becomes the 'King' - " + "all other players pay tribute (2 chips each)." + ), + }, + { + "name": "Championship Belt", + "description": ( + "Winner takes 75% of all chips on the table. " + "Remaining 25% goes for the second best." + ), + }, + # Elimination Mechanics + { + "name": "Battle Royale", + "description": "Lowest hand is eliminated. If tied, both eliminated.", + }, + { + "name": "Survivor", + "description": ( + "Only players who improve their hand from pre-flop to river " + "survive to next round." + ), + }, + # Time Pressure Endgame + { + "name": "Speed Round", + "description": ("3 seconds to act or auto-fold. No exceptions, no delays."), + }, + { + "name": "Auction House", + "description": ( + "Players bid chips to see each other's hole cards before betting." + ), + }, + { + "name": "Lightning Round", + "description": ( + "Deal all 5 community cards at once. " + "Betting happens after each card revealed." + ), + }, + # Psychological Warfare + { + "name": "Confession Booth", + "description": ( + "Each player must truthfully state " "their biggest bluff this session." + ), + }, + { + "name": "Truth Serum", + "description": ( + "Everyone must honestly rate their hand 1-10 before any betting." + ), + }, + { + "name": "Poker Face Off", + "description": ( + "Staring contest: losers must reveal one hole card to the table." + ), + }, + # Endgame Economics + { + "name": "Wealth Redistribution", + "description": ( + "Before the hand, richest player gives 3 chips to poorest player." + ), + }, + { + "name": "Emergency Fund", + "description": ( + "All players with less than 5 chips " "get emergency funding from the pot." + ), + }, + { + "name": "Final Ante", + "description": ( + "Everyone must put in their last 2 chips " + "before seeing cards. No backing out." + ), + }, + # Apocalypse Modifiers + { + "name": "Nuclear Option", + "description": ( + "Dealer burns the top 3 cards. " "Play with whatever's left in the deck." + ), + }, + { + "name": "Meteor Strike", + "description": ("Remove all face cards from the deck for this hand only."), + }, + { + "name": "Solar Flare", + "description": "All suits become the same suit (dealer's choice).", + }, + # Legacy Modifiers + { + "name": "Hall of Fame", + "description": ( + "Winner's name gets written down as 'Champion of the Session'." + ), + }, + { + "name": "Legendary Hand", + "description": ("This hand will be retold as a story. Play like legends."), + }, + { + "name": "Photo Finish", + "description": ( + "Take a photo of the winning hand - " "it goes in the poker hall of fame." + ), + }, + # Chaos Theory + { + "name": "Butterfly Effect", + "description": ( + "One random decision by dealer changes everything: " + "flip a coin for each community card to reverse it." + ), + }, + { + "name": "Time Paradox", + "description": ( + "Play the hand twice with same cards. Best average result wins." + ), + }, + { + "name": "Multiverse", + "description": ( + "Deal 2 separate boards. Players choose " + "which board to play after seeing both." + ), + }, +] diff --git a/python_pkg/poker_modifier_app/index.html b/python_pkg/poker_modifier_app/index.html new file mode 100644 index 0000000..34a1f19 --- /dev/null +++ b/python_pkg/poker_modifier_app/index.html @@ -0,0 +1,45 @@ + + + + + + Texas Hold'em Modifier App + + + +
+

šŸƒ Texas Hold'em Modifier

+
+
+

Settings

+
+ + + 30% +
+
+ +
+
+

Click "Start Round" to begin!

+
+
+ + + +
+
+ Rounds Played: + 0 +
+
+ Modifiers Applied: + 0 +
+
+
+
+ + + + diff --git a/python_pkg/poker_modifier_app/poker_modifier_app.py b/python_pkg/poker_modifier_app/poker_modifier_app.py new file mode 100644 index 0000000..b1942af --- /dev/null +++ b/python_pkg/poker_modifier_app/poker_modifier_app.py @@ -0,0 +1,266 @@ +"""Texas Hold'em poker game modifier application.""" + +import logging +import secrets +import tkinter as tk + +from python_pkg.poker_modifier_app._poker_gui import PokerGuiMixin +from python_pkg.poker_modifier_app._poker_modifiers import ( + ENDGAME_MODIFIERS, + REGULAR_MODIFIERS, +) + +_logger = logging.getLogger(__name__) + +# Use cryptographically secure random number generator +_rng = secrets.SystemRandom() + + +class PokerModifierApp(PokerGuiMixin): + """GUI application for poker game modifiers.""" + + def __init__(self) -> None: + """Initialize the poker modifier app with default settings.""" + self.modifiers = list(REGULAR_MODIFIERS) + self.endgame_modifiers = list(ENDGAME_MODIFIERS) + + # Remove endgame modifiers from regular modifier list + endgame_modifier_names = [mod["name"] for mod in self.endgame_modifiers] + self.modifiers = [ + mod for mod in self.modifiers if mod["name"] not in endgame_modifier_names + ] + + # Game state tracking + self.rounds_played = 0 + self.modifiers_applied = 0 + self.total_game_rounds = 20 # Default game length + self.endgame_threshold = 0.8 # Start endgame modifiers at 80% of total rounds + self.debug_mode = False + self.force_endgame = False + + self.setup_gui() + + def update_prob_display(self, value: str) -> None: + """Update the probability percentage display.""" + self.prob_label.config(text=f"{value}%") + + def update_length_display(self, value: str) -> None: + """Update the game length display.""" + self.length_label.config(text=str(value)) + self.total_game_rounds = int(value) + + def toggle_debug_mode(self) -> None: + """Toggle debug mode and show/hide debug controls.""" + self.debug_mode = self.debug_var.get() + if self.debug_mode: + self.force_endgame_button.pack(side=tk.LEFT, padx=(0, 10)) + _logger.debug("Debug mode enabled") + else: + self.force_endgame_button.pack_forget() + self.force_endgame = False + _logger.debug("Debug mode disabled") + + def toggle_force_endgame(self) -> None: + """Toggle forced endgame mode for testing.""" + self.force_endgame = not self.force_endgame + if self.force_endgame: + self.force_endgame_button.config(text="Stop Force Endgame", bg="#4CAF50") + _logger.debug("Forcing endgame modifiers") + else: + self.force_endgame_button.config(text="Force Endgame", bg="#ff6b6b") + _logger.debug("Normal modifier selection restored") + + def is_endgame(self) -> bool: + """Determine if we're in endgame phase.""" + if self.debug_mode and self.force_endgame: + return True + + endgame_round = int(self.total_game_rounds * self.endgame_threshold) + return self.rounds_played >= endgame_round + + def start_round(self) -> None: + """Start a new poker round and determine if modifier should be applied.""" + # Button animation effect + self.start_button.config(relief=tk.SUNKEN) + self.root.after(100, lambda: self.start_button.config(relief=tk.RAISED)) + + # Update round counter + self.rounds_played += 1 + self.rounds_label.config(text=str(self.rounds_played)) + + # Update game phase indicator + self.update_phase_indicator() + + # Get current probability + modifier_chance = self.prob_var.get() + + # Determine if modifier should be applied + random_value = _rng.random() * 100 + should_apply_modifier = random_value < modifier_chance + + if should_apply_modifier: + self.apply_random_modifier() + else: + self.show_no_modifier() + + def update_phase_indicator(self) -> None: + """Update the game phase indicator based on current round.""" + if self.is_endgame(): + self.phase_label.config(text="Endgame", fg="#ff6b6b") + elif self.rounds_played >= self.total_game_rounds * 0.6: + self.phase_label.config(text="Late", fg="#ffa500") + elif self.rounds_played >= self.total_game_rounds * 0.3: + self.phase_label.config(text="Mid", fg="#ffeb3b") + else: + self.phase_label.config(text="Early", fg="#4CAF50") + + def apply_random_modifier(self) -> None: + """Apply a random modifier and update display.""" + # Update modifier counter + self.modifiers_applied += 1 + self.mods_label.config(text=str(self.modifiers_applied)) + + # Determine which modifier pool to use + if self.is_endgame(): + modifier_pool = self.endgame_modifiers + modifier_type = "šŸ ENDGAME" + bg_color = "#4a2d2d" # Darker red for endgame + else: + modifier_pool = self.modifiers + modifier_type = "šŸŽ²" + bg_color = "#2d4a2d" # Green for normal + + # Select random modifier from appropriate pool + selected_modifier = _rng.choice(modifier_pool).copy() + + # Special handling for Steel Cards - randomize the rank + if selected_modifier["name"] == "Steel Cards": + ranks = [ + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "Jack", + "Queen", + "King", + "Ace", + ] + steel_rank = _rng.choice(ranks) + selected_modifier["description"] = selected_modifier["description"].format( + steel_rank=steel_rank + ) + + # Update result frame styling for modifier + self.result_frame.config( + bg=bg_color, highlightbackground="#ffd700", highlightthickness=2 + ) + + # Update display with modifier info + modifier_text = ( + f"{modifier_type} {selected_modifier['name']}\n\n" + f"{selected_modifier['description']}" + ) + + # Add endgame indicator if applicable + if self.is_endgame(): + rounds_left = self.total_game_rounds - self.rounds_played + if rounds_left > 0: + modifier_text += f"\n\nāš ļø Endgame Phase - {rounds_left} rounds left" + else: + modifier_text += "\n\nāš ļø FINAL ROUND!" + + self.result_label.config( + text=modifier_text, fg="#ffd700", bg=bg_color, font=("Arial", 14, "bold") + ) + + def show_no_modifier(self) -> None: + """Show no modifier message.""" + # Update result frame styling for no modifier + self.result_frame.config( + bg="#2d2d2d", highlightbackground="#666666", highlightthickness=1 + ) + + # Update display + self.result_label.config( + text="No modifier this round\n\nPlay normally", + fg="#cccccc", + bg="#2d2d2d", + font=("Arial", 14), + ) + + def reset_game(self) -> None: + """Reset the game to initial state.""" + self.rounds_played = 0 + self.modifiers_applied = 0 + self.force_endgame = False + + # Update displays + self.rounds_label.config(text="0") + self.mods_label.config(text="0") + self.phase_label.config(text="Early", fg="#4CAF50") + + # Reset result frame + self.result_frame.config( + bg="#2d2d2d", highlightbackground="#666666", highlightthickness=1 + ) + self.result_label.config( + text="Click 'Start Round' to begin!", + fg="#cccccc", + bg="#2d2d2d", + font=("Arial", 14), + ) + + # Reset force endgame button if visible + if self.debug_mode: + self.force_endgame_button.config(text="Force Endgame", bg="#ff6b6b") + + _logger.info("Game reset to initial state") + + def add_modifier(self, name: str, description: str) -> None: + """Add a new modifier to the list.""" + self.modifiers.append({"name": name, "description": description}) + + def get_stats(self) -> dict[str, int | float | bool]: + """Get current statistics.""" + modifier_rate = ( + 0 + if self.rounds_played == 0 + else (self.modifiers_applied / self.rounds_played) * 100 + ) + rounds_remaining = max(0, self.total_game_rounds - self.rounds_played) + + return { + "rounds_played": self.rounds_played, + "modifiers_applied": self.modifiers_applied, + "modifier_rate": round(modifier_rate, 1), + "total_game_rounds": self.total_game_rounds, + "rounds_remaining": rounds_remaining, + "is_endgame": self.is_endgame(), + "debug_mode": self.debug_mode, + "force_endgame": self.force_endgame, + } + + def run(self) -> None: + """Start the application.""" + _logger.info("Texas Hold'em Modifier App started!") + _logger.info( + "Available methods: app.get_stats(), app.add_modifier(name, description)" + ) + _logger.info( + "Debug features: Toggle debug mode to access force endgame controls" + ) + _logger.info("Default game length: %s rounds", self.total_game_rounds) + endgame_pct = int(self.endgame_threshold * 100) + endgame_rounds = int(self.total_game_rounds * self.endgame_threshold) + _logger.info("Endgame threshold: %s%% (%s rounds)", endgame_pct, endgame_rounds) + self.root.mainloop() + + +if __name__ == "__main__": + app = PokerModifierApp() + app.run() diff --git a/python_pkg/poker_modifier_app/script.js b/python_pkg/poker_modifier_app/script.js new file mode 100644 index 0000000..30d5bf6 --- /dev/null +++ b/python_pkg/poker_modifier_app/script.js @@ -0,0 +1,175 @@ +class PokerModifierApp { + constructor() { + this.modifiers = [ + { + name: "High Stakes", + description: "All bets are doubled this round!" + }, + { + name: "Wild Card", + description: "The next card revealed can be used as any card!" + }, + { + name: "Bluff Master", + description: "Players can see one opponent's card before betting." + }, + { + name: "All-In Fever", + description: "If someone goes all-in, everyone must match or fold." + }, + { + name: "Lucky Sevens", + description: "Any hand with a 7 beats a pair!" + }, + { + name: "Reverse Psychology", + description: "Lowest hand wins this round!" + }, + { + name: "Split Pot", + description: "The pot is split between the top 2 hands." + }, + { + name: "Texas Twister", + description: "Each player gets an extra hole card this round." + }, + { + name: "Blind Luck", + description: "All players must play blind (no looking at cards) until the river." + }, + { + name: "Community Boost", + description: "An extra community card is revealed (6 total)." + }, + { + name: "Minimum Madness", + description: "Minimum bet is tripled this round." + }, + { + name: "Suit Supremacy", + description: "All cards of the chosen suit (random) are worth +1 rank." + }, + { + name: "Quick Draw", + description: "Betting time is cut in half - make decisions fast!" + }, + { + name: "Royal Treatment", + description: "Face cards (J, Q, K) are worth double." + }, + { + name: "Chip Challenge", + description: "Winner gets extra chips from the house!" + } + ]; + + this.roundsPlayed = 0; + this.modifiersApplied = 0; + + this.initializeElements(); + this.attachEventListeners(); + this.updateChanceDisplay(); + } + + initializeElements() { + this.startButton = document.getElementById('startRoundBtn'); + this.resultDisplay = document.getElementById('resultDisplay'); + this.modifierChanceSlider = document.getElementById('modifierChance'); + this.chanceValueDisplay = document.getElementById('chanceValue'); + this.roundsCountDisplay = document.getElementById('roundsCount'); + this.modifiersCountDisplay = document.getElementById('modifiersCount'); + } + + attachEventListeners() { + this.startButton.addEventListener('click', () => this.startRound()); + this.modifierChanceSlider.addEventListener('input', () => this.updateChanceDisplay()); + } + + updateChanceDisplay() { + const chance = this.modifierChanceSlider.value; + this.chanceValueDisplay.textContent = `${chance}%`; + } + + startRound() { + // Add button animation + this.startButton.style.transform = 'scale(0.95)'; + setTimeout(() => { + this.startButton.style.transform = ''; + }, 150); + + // Update round counter + this.roundsPlayed++; + this.roundsCountDisplay.textContent = this.roundsPlayed; + + // Get current probability + const modifierChance = parseInt(this.modifierChanceSlider.value); + + // Determine if a modifier should be applied + const randomValue = Math.random() * 100; + const shouldApplyModifier = randomValue < modifierChance; + + if (shouldApplyModifier) { + this.applyRandomModifier(); + } else { + this.showNoModifier(); + } + + // Add some visual feedback with animation + this.resultDisplay.style.opacity = '0'; + this.resultDisplay.style.transform = 'scale(0.8)'; + + setTimeout(() => { + this.resultDisplay.style.opacity = '1'; + this.resultDisplay.style.transform = 'scale(1)'; + }, 200); + } + + applyRandomModifier() { + // Update modifier counter + this.modifiersApplied++; + this.modifiersCountDisplay.textContent = this.modifiersApplied; + + // Select random modifier + const randomIndex = Math.floor(Math.random() * this.modifiers.length); + const selectedModifier = this.modifiers[randomIndex]; + + // Update display + this.resultDisplay.className = 'result-display has-modifier'; + this.resultDisplay.innerHTML = ` +
šŸŽ² ${selectedModifier.name}
+
${selectedModifier.description}
+ `; + } + + showNoModifier() { + this.resultDisplay.className = 'result-display no-modifier'; + this.resultDisplay.innerHTML = ` +
No modifier this round
+
Play normally
+ `; + } + + // Method to add new modifiers (for future expansion) + addModifier(name, description) { + this.modifiers.push({ name, description }); + } + + // Method to get statistics + getStats() { + return { + roundsPlayed: this.roundsPlayed, + modifiersApplied: this.modifiersApplied, + modifierRate: this.roundsPlayed > 0 ? (this.modifiersApplied / this.roundsPlayed * 100).toFixed(1) : 0 + }; + } +} + +// Initialize the app when the page loads +document.addEventListener('DOMContentLoaded', () => { + window.pokerApp = new PokerModifierApp(); + + // Add some console info for developers + console.log('šŸƒ Texas Hold\'em Modifier App loaded!'); + console.log('Access the app instance via window.pokerApp'); + console.log('Available methods: getStats(), addModifier(name, description)'); +}); diff --git a/python_pkg/poker_modifier_app/style.css b/python_pkg/poker_modifier_app/style.css new file mode 100644 index 0000000..ddf1eff --- /dev/null +++ b/python_pkg/poker_modifier_app/style.css @@ -0,0 +1,234 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Arial', sans-serif; + background: linear-gradient(135deg, #0f4c3a, #1a6b4d); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: #fff; +} + +.container { + max-width: 600px; + width: 90%; + padding: 2rem; +} + +h1 { + text-align: center; + font-size: 2.5rem; + margin-bottom: 2rem; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} + +.game-area { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 2rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.probability-settings { + margin-bottom: 2rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.probability-settings h3 { + margin-bottom: 1rem; + color: #ffd700; + text-align: center; +} + +.setting { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.setting label { + font-weight: bold; + min-width: 150px; +} + +#modifierChance { + flex: 1; + min-width: 150px; + height: 8px; + background: rgba(255, 255, 255, 0.2); + border-radius: 5px; + outline: none; + appearance: none; +} + +#modifierChance::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + background: #ffd700; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +#modifierChance::-moz-range-thumb { + width: 20px; + height: 20px; + background: #ffd700; + border-radius: 50%; + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +#chanceValue { + font-weight: bold; + color: #ffd700; + min-width: 40px; + text-align: center; +} + +.result-area { + margin: 2rem 0; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + +.result-display { + text-align: center; + padding: 2rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + border: 2px solid rgba(255, 255, 255, 0.1); + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; + transition: all 0.3s ease; +} + +.result-display.no-modifier { + border-color: rgba(128, 128, 128, 0.5); + background: rgba(128, 128, 128, 0.1); +} + +.result-display.has-modifier { + border-color: #ffd700; + background: rgba(255, 215, 0, 0.1); + box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); +} + +.modifier-title { + font-size: 1.5rem; + font-weight: bold; + color: #ffd700; + margin-bottom: 0.5rem; +} + +.modifier-description { + font-size: 1rem; + line-height: 1.4; + color: #fff; +} + +.no-modifier-text { + font-size: 1.2rem; + color: #ccc; +} + +.start-button { + width: 100%; + padding: 1rem 2rem; + font-size: 1.5rem; + font-weight: bold; + background: linear-gradient(45deg, #ffd700, #ffed4e); + color: #0f4c3a; + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3); + margin-bottom: 2rem; +} + +.start-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4); + background: linear-gradient(45deg, #ffed4e, #ffd700); +} + +.start-button:active { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(255, 215, 0, 0.3); +} + +.stats { + display: flex; + justify-content: space-around; + gap: 1rem; + flex-wrap: wrap; +} + +.stat { + background: rgba(255, 255, 255, 0.1); + padding: 1rem; + border-radius: 8px; + text-align: center; + flex: 1; + min-width: 150px; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.stat-label { + display: block; + font-size: 0.9rem; + color: #ccc; + margin-bottom: 0.5rem; +} + +.stat span:last-child { + font-size: 1.5rem; + font-weight: bold; + color: #ffd700; +} + +@media (max-width: 600px) { + .container { + padding: 1rem; + } + + h1 { + font-size: 2rem; + } + + .game-area { + padding: 1.5rem; + } + + .setting { + flex-direction: column; + align-items: stretch; + } + + .setting label { + min-width: auto; + text-align: center; + } + + .stats { + flex-direction: column; + } +} diff --git a/python_pkg/puzzle_solver/README.md b/python_pkg/puzzle_solver/README.md new file mode 100644 index 0000000..cf7ebe4 --- /dev/null +++ b/python_pkg/puzzle_solver/README.md @@ -0,0 +1,55 @@ +## Sliding-Square Puzzle Solver + +Parses a screenshot of a sliding-square puzzle and solves it via BFS. + +### Setup + +```bash +cd puzzle_solver +python -m venv .venv && source .venv/bin/activate +pip install opencv-python-headless numpy +``` + +### Usage + +```bash +# From workspace root, with venv active: + +# Step 1 – Parse screenshot to editable JSON +python -m puzzle_solver parse screenshot.png -o puzzle.json + +# Step 2 – Review & fix any "unknown" squares in puzzle.json +# (set "type" to: normal / portal / teleporter / key / lock) + +# Step 3 – Solve +python -m puzzle_solver solve puzzle.json + +# One-shot (no manual review) +python -m puzzle_solver run screenshot.png + +# Debug overlay (visualise detected squares on image) +python -m puzzle_solver debug screenshot.png -o debug.png +``` + +### Game mechanics + +| Square | JSON type | Description | +| ------------------- | ------------ | ------------------------------------------------- | +| Empty outline | `normal` | Regular landing square | +| Solid fill | `player` | Starting position | +| Ring inside | `goal` | Target destination | +| Inner square offset | `portal` | Pass through from the side marked by `"side"` | +| Antenna line(s) | `teleporter` | Warp to paired teleporter (`"group"` id) | +| Key symbol | `key` | Removes matching lock (`"lock_id"`) | +| Lock symbol | `lock` | Solid until matching key collected, then vanishes | + +### Movement + +You slide in a cardinal direction (up/down/left/right) until you hit +another square. If you slide off the grid without hitting anything, you +die. + +### Algorithm + +BFS over state = `(position, set_of_active_locks)`. Explores all +reachable states and returns the shortest move sequence to the goal. diff --git a/python_pkg/puzzle_solver/__init__.py b/python_pkg/puzzle_solver/__init__.py new file mode 100644 index 0000000..24c9833 --- /dev/null +++ b/python_pkg/puzzle_solver/__init__.py @@ -0,0 +1 @@ +"""Sliding-square puzzle solver package.""" diff --git a/python_pkg/puzzle_solver/__main__.py b/python_pkg/puzzle_solver/__main__.py new file mode 100644 index 0000000..5b670fe --- /dev/null +++ b/python_pkg/puzzle_solver/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m puzzle_solver …`` invocation.""" + +from python_pkg.puzzle_solver.main import main + +main() diff --git a/python_pkg/puzzle_solver/main.py b/python_pkg/puzzle_solver/main.py new file mode 100644 index 0000000..6e7b08e --- /dev/null +++ b/python_pkg/puzzle_solver/main.py @@ -0,0 +1,109 @@ +"""CLI for the sliding-square puzzle solver. + +Usage +----- + # 1) Parse a screenshot → JSON (review & hand-edit if needed) + python puzzle_solver/main.py parse screenshot.png -o puzzle.json + + # 2) Solve from JSON + python puzzle_solver/main.py solve puzzle.json + + # 3) One-shot: parse + solve (skip manual review) + python puzzle_solver/main.py run screenshot.png + + # 4) Draw debug overlay showing detected squares + python puzzle_solver/main.py debug screenshot.png -o debug.png +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +from python_pkg.puzzle_solver.parse_image import draw_debug, parse_image, save_puzzle +from python_pkg.puzzle_solver.solver import Puzzle, print_puzzle, print_solution, solve + + +def cmd_parse(args: argparse.Namespace) -> None: + """Parse a screenshot into editable puzzle JSON.""" + puzzle = parse_image(args.image, threshold=args.threshold) + out = args.output or args.image.rsplit(".", 1)[0] + "_puzzle.json" + save_puzzle(puzzle, out) + if puzzle.get("notes"): + for _n in puzzle["notes"]: + pass + + +def cmd_solve(args: argparse.Namespace) -> None: + """Solve a puzzle from a JSON file.""" + with Path(args.puzzle).open() as f: + data = json.load(f) + puzzle = Puzzle.from_json(data) + print_puzzle(puzzle) + moves = solve(puzzle) + if moves is None: + sys.exit(1) + print_solution(puzzle, moves) + + +def cmd_run(args: argparse.Namespace) -> None: + """Parse a screenshot and solve in one shot.""" + data = parse_image(args.image, threshold=args.threshold) + if data.get("notes"): + for _n in data["notes"]: + pass + + puzzle = Puzzle.from_json(data) + print_puzzle(puzzle) + moves = solve(puzzle) + if moves is None: + out = args.image.rsplit(".", 1)[0] + "_puzzle.json" + save_puzzle(data, out) + sys.exit(1) + print_solution(puzzle, moves) + + +def cmd_debug(args: argparse.Namespace) -> None: + """Draw a debug overlay showing detected square types.""" + data = parse_image(args.image, threshold=args.threshold) + out = args.output or args.image.rsplit(".", 1)[0] + "_debug.png" + draw_debug(args.image, data, out) + from collections import Counter + + counts = Counter(sq["type"] for sq in data["squares"]) + for _t, _n in counts.most_common(): + pass + + +def main() -> None: + """Entry point for the puzzle solver CLI.""" + ap = argparse.ArgumentParser(description="Sliding-square puzzle solver") + sub = ap.add_subparsers(dest="command", required=True) + + p_parse = sub.add_parser("parse", help="Parse screenshot → puzzle JSON") + p_parse.add_argument("image") + p_parse.add_argument("-o", "--output", help="Output JSON path") + p_parse.add_argument("-t", "--threshold", type=int, default=55) + + p_solve = sub.add_parser("solve", help="Solve puzzle from JSON") + p_solve.add_argument("puzzle", help="Puzzle JSON file") + + p_run = sub.add_parser("run", help="Parse + solve in one shot") + p_run.add_argument("image") + p_run.add_argument("-t", "--threshold", type=int, default=55) + + p_debug = sub.add_parser("debug", help="Draw debug overlay on image") + p_debug.add_argument("image") + p_debug.add_argument("-o", "--output", help="Output image path") + p_debug.add_argument("-t", "--threshold", type=int, default=55) + + args = ap.parse_args() + {"parse": cmd_parse, "solve": cmd_solve, "run": cmd_run, "debug": cmd_debug}[ + args.command + ](args) + + +if __name__ == "__main__": + main() diff --git a/python_pkg/puzzle_solver/parse_image.py b/python_pkg/puzzle_solver/parse_image.py new file mode 100644 index 0000000..f66bc69 --- /dev/null +++ b/python_pkg/puzzle_solver/parse_image.py @@ -0,0 +1,438 @@ +"""Parse a puzzle screenshot into a solvable JSON representation. + +Pipeline +-------- +1. Threshold + contour detection → find square bounding boxes +2. Cluster centres into a regular grid → (row, col) for each square +3. Analyse each square's interior → classify type +4. Pair teleporters and key/lock → assign group IDs +5. Export JSON (editable by hand before solving) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import cv2 +import numpy as np + +_MIN_SQUARE_AREA = 80 +_MAX_SQUARE_AREA = 12000 +_MIN_ASPECT_RATIO = 0.45 +_PLAYER_FILL_THRESHOLD = 0.40 +_NORMAL_FILL_CEILING = 0.12 +_MIN_INTERIOR_SIZE = 6 +_RING_CIRCULARITY = 0.65 +_RING_AREA_RATIO = 0.08 + +# ── Public API ─────────────────────────────────────────────────────── + + +def parse_image(image_path: str, *, threshold: int = 55) -> dict: + """Parse a screenshot and return a puzzle dict (ready for solver or JSON).""" + img = cv2.imread(image_path) + if img is None: + msg = f"Cannot load image: {image_path}" + raise FileNotFoundError(msg) + + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + raw = _detect_square_candidates(gray, threshold) + squares = _merge_overlapping(raw) + grid_map = _snap_to_grid(squares) + classified = _classify_all(gray, grid_map) + _assign_teleporter_and_kl_groups(classified) + return _build_output(classified) + + +def save_puzzle(puzzle: dict, path: str) -> None: + """Write puzzle dict to a JSON file.""" + with Path(path).open("w") as f: + json.dump(puzzle, f, indent=2) + + +# ── Square detection ───────────────────────────────────────────────── + + +def _detect_square_candidates( + gray: np.ndarray, thresh: int +) -> list[tuple[int, int, int, int]]: + _, binary = cv2.threshold(gray, thresh, 255, cv2.THRESH_BINARY) + kernel = np.ones((3, 3), np.uint8) + binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + candidates: list[tuple[int, int, int, int]] = [] + for cnt in contours: + x, y, w, h = cv2.boundingRect(cnt) + area = w * h + if area < _MIN_SQUARE_AREA or area > _MAX_SQUARE_AREA: + continue + aspect = min(w, h) / max(w, h) + if aspect < _MIN_ASPECT_RATIO: + continue + candidates.append((x, y, w, h)) + return candidates + + +def _merge_overlapping( + candidates: list[tuple[int, int, int, int]], +) -> list[tuple[int, int, int, int]]: + """Merge bounding boxes whose centres are very close.""" + if not candidates: + return [] + + cands = sorted(candidates, key=lambda c: c[2] * c[3], reverse=True) + used = [False] * len(cands) + merged: list[tuple[int, int, int, int]] = [] + + for i, (x1, y1, w1, h1) in enumerate(cands): + if used[i]: + continue + cx1, cy1 = x1 + w1 // 2, y1 + h1 // 2 + group = [(x1, y1, w1, h1)] + used[i] = True + + for j in range(i + 1, len(cands)): + if used[j]: + continue + x2, y2, w2, h2 = cands[j] + cx2, cy2 = x2 + w2 // 2, y2 + h2 // 2 + if ( + abs(cx1 - cx2) < max(w1, w2) * 0.55 + and abs(cy1 - cy2) < max(h1, h2) * 0.55 + ): + group.append(cands[j]) + used[j] = True + + gx = min(g[0] for g in group) + gy = min(g[1] for g in group) + gx2 = max(g[0] + g[2] for g in group) + gy2 = max(g[1] + g[3] for g in group) + merged.append((gx, gy, gx2 - gx, gy2 - gy)) + + return merged + + +# ── Grid snapping ──────────────────────────────────────────────────── + + +def _cluster_values(vals: list[int], min_gap: int) -> list[int]: + if not vals: + return [] + vals = sorted(vals) + clusters: list[list[int]] = [[vals[0]]] + for v in vals[1:]: + if v - clusters[-1][-1] < min_gap: + clusters[-1].append(v) + else: + clusters.append([v]) + return [int(np.mean(c)) for c in clusters] + + +def _snap_to_grid( + squares: list[tuple[int, int, int, int]], +) -> dict[tuple[int, int], tuple[int, int, int, int]]: + centres = [(x + w // 2, y + h // 2) for x, y, w, h in squares] + xs = [c[0] for c in centres] + ys = [c[1] for c in centres] + + def _median_gap(vals: list[int]) -> int: + s = sorted(set(vals)) + gaps = [s[i + 1] - s[i] for i in range(len(s) - 1)] + return int(np.median(gaps)) if gaps else 30 + + x_gap = max(8, int(_median_gap(xs) * 0.4)) + y_gap = max(8, int(_median_gap(ys) * 0.4)) + + x_clusters = _cluster_values(xs, x_gap) + y_clusters = _cluster_values(ys, y_gap) + + grid: dict[tuple[int, int], tuple[int, int, int, int]] = {} + for sq, (cx, cy) in zip(squares, centres, strict=False): + col = min(range(len(x_clusters)), key=lambda i: abs(x_clusters[i] - cx)) + row = min(range(len(y_clusters)), key=lambda i: abs(y_clusters[i] - cy)) + grid[(row, col)] = sq + return grid + + +# ── Classification ─────────────────────────────────────────────────── + + +def _classify_all( + gray: np.ndarray, + grid_map: dict[tuple[int, int], tuple[int, int, int, int]], +) -> dict[tuple[int, int], dict]: + classified: dict[tuple[int, int], dict] = {} + for (row, col), (x, y, w, h) in grid_map.items(): + sq_type, extra = _classify_one(gray, (x, y, w, h)) + classified[(row, col)] = { + "pos": [row, col], + "type": sq_type, + "pixel_center": [x + w // 2, y + h // 2], + "pixel_bbox": [x, y, w, h], + **extra, + } + return classified + + +Bbox = tuple[int, int, int, int] + + +def _classify_by_fill( + fill: float, + gray: np.ndarray, + bbox: Bbox, + interior: np.ndarray, +) -> tuple[str, dict] | None: + """Try to classify based on fill ratio and feature detectors.""" + if fill > _PLAYER_FILL_THRESHOLD: + return "player", {} + if fill < _NORMAL_FILL_CEILING: + return "normal", {} + + antenna = _detect_antenna(gray, bbox) + if antenna: + return "teleporter", {"antenna_sides": antenna} + if _is_ring_pattern(interior): + return "goal", {} + + return _classify_interior_feature(fill, interior) + + +def _classify_interior_feature( + fill: float, + interior: np.ndarray, +) -> tuple[str, dict] | None: + """Classify portal, key/lock, or return None for unknown.""" + side = _detect_portal_side(interior) + if side: + return "portal", {"side": side} + if _has_interior_feature(interior): + return "key_or_lock", {"fill_ratio": round(fill, 3)} + return None + + +def _classify_one( + gray: np.ndarray, + bbox: Bbox, +) -> tuple[str, dict]: + x, y, w, h = bbox + border = max(3, min(w, h) // 5) + ix1, iy1 = x + border, y + border + ix2, iy2 = x + w - border, y + h - border + if ix2 <= ix1 or iy2 <= iy1: + return "normal", {} + + interior = gray[iy1:iy2, ix1:ix2] + fill = float(np.mean(interior) / 255.0) + + result = _classify_by_fill(fill, gray, bbox, interior) + if result is not None: + return result + return "unknown", {"fill_ratio": round(fill, 3)} + + +# ── Feature detectors ──────────────────────────────────────────────── + + +def _detect_antenna( + gray: np.ndarray, + bbox: Bbox, + margin: int = 8, + thr: float = 0.08, +) -> list[str] | None: + """Check for bright pixels in a narrow strip outside each edge.""" + x, y, w, h = bbox + ih, iw = gray.shape + sides: list[str] = [] + qw, qh = w // 4, h // 4 # quarter-width / height + + # up + if y > margin: + s = gray[y - margin : y - 1, x + qw : x + w - qw] + if s.size and float(np.mean(s) / 255) > thr: + sides.append("up") + # down + if y + h + margin < ih: + s = gray[y + h + 1 : y + h + margin, x + qw : x + w - qw] + if s.size and float(np.mean(s) / 255) > thr: + sides.append("down") + # left + if x > margin: + s = gray[y + qh : y + h - qh, x - margin : x - 1] + if s.size and float(np.mean(s) / 255) > thr: + sides.append("left") + # right + if x + w + margin < iw: + s = gray[y + qh : y + h - qh, x + w + 1 : x + w + margin] + if s.size and float(np.mean(s) / 255) > thr: + sides.append("right") + + return sides or None + + +def _is_ring_pattern(interior: np.ndarray) -> bool: + h, w = interior.shape + if h < _MIN_INTERIOR_SIZE or w < _MIN_INTERIOR_SIZE: + return False + + _, bw = cv2.threshold(interior, 40, 255, cv2.THRESH_BINARY) + contours, _ = cv2.findContours(bw, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + + for cnt in contours: + area = cv2.contourArea(cnt) + peri = cv2.arcLength(cnt, closed=True) + if peri == 0: + continue + circ = 4 * np.pi * area / (peri * peri) + if circ > _RING_CIRCULARITY and area > (h * w) * _RING_AREA_RATIO: + return True + return False + + +def _detect_portal_side(interior: np.ndarray) -> str | None: + h, w = interior.shape + if h < _MIN_INTERIOR_SIZE or w < _MIN_INTERIOR_SIZE: + return None + + thirds_w, thirds_h = w // 3, h // 3 + regions = { + "left": float(np.mean(interior[:, :thirds_w])), + "right": float(np.mean(interior[:, w - thirds_w :])), + "up": float(np.mean(interior[:thirds_h, :])), + "down": float(np.mean(interior[h - thirds_h :, :])), + } + + best = max(regions, key=lambda k: regions[k]) + opposites = {"left": "right", "right": "left", "up": "down", "down": "up"} + opp = regions[opposites[best]] + + if regions[best] > max(opp * 2.5, 8): + return best + return None + + +def _has_interior_feature(interior: np.ndarray) -> bool: + _, bw = cv2.threshold(interior, 40, 255, cv2.THRESH_BINARY) + total_white = int(np.sum(bw > 0)) + return total_white > interior.size * 0.06 + + +# ── Teleporter / key-lock grouping ─────────────────────────────────── + + +def _assign_teleporter_and_kl_groups(classified: dict[tuple[int, int], dict]) -> None: + # ── Teleporters ── + tele = [(p, d) for p, d in classified.items() if d["type"] == "teleporter"] + gid = 1 + used: set[tuple[int, int]] = set() + for i, (p1, d1) in enumerate(tele): + if p1 in used: + continue + s1 = set(d1.get("antenna_sides", [])) + for p2, d2 in tele[i + 1 :]: + if p2 in used: + continue + s2 = set(d2.get("antenna_sides", [])) + if s1 == s2: + d1["group"] = gid + d2["group"] = gid + used |= {p1, p2} + gid += 1 + break + + # pair any remaining teleporters sequentially + unpaired = [ + p + for p, d in classified.items() + if d["type"] == "teleporter" and "group" not in d + ] + for i in range(0, len(unpaired) - 1, 2): + classified[unpaired[i]]["group"] = gid + classified[unpaired[i + 1]]["group"] = gid + gid += 1 + + # ── Key / lock ── + kl = [p for p, d in classified.items() if d["type"] == "key_or_lock"] + lid = 1 + for i in range(0, len(kl) - 1, 2): + classified[kl[i]]["type"] = "key" + classified[kl[i]]["lock_id"] = lid + classified[kl[i + 1]]["type"] = "lock" + classified[kl[i + 1]]["lock_id"] = lid + lid += 1 + # odd one out → mark unknown + if len(kl) % 2: + classified[kl[-1]]["type"] = "unknown" + + +# ── Output builder ─────────────────────────────────────────────────── + + +def _build_output(classified: dict[tuple[int, int], dict]) -> dict: + squares: list[dict] = [] + notes: list[str] = [] + + for pos in sorted(classified): + d = classified[pos] + sq: dict = {"pos": d["pos"], "type": d["type"]} + + if "side" in d: + sq["side"] = d["side"] + if "group" in d: + sq["group"] = d["group"] + if "lock_id" in d: + sq["lock_id"] = d["lock_id"] + + # keep pixel info for debugging (prefixed with _) + sq["_pixel_center"] = d["pixel_center"] + sq["_pixel_bbox"] = d["pixel_bbox"] + + if d["type"] == "unknown": + notes.append( + f"grid {d['pos']} pixel {d['pixel_center']}: " + f"classified 'unknown' (fill={d.get('fill_ratio', '?')}) " + "- edit type manually" + ) + squares.append(sq) + + return {"squares": squares, "notes": notes} + + +# ── Debug visualisation ────────────────────────────────────────────── + +TYPE_COLOURS = { + "normal": (200, 200, 200), + "player": (0, 255, 0), + "goal": (0, 200, 255), + "portal": (255, 100, 0), + "teleporter": (255, 0, 255), + "key": (0, 255, 255), + "lock": (0, 0, 255), + "key_or_lock": (100, 100, 255), + "unknown": (0, 0, 200), +} + + +def draw_debug(image_path: str, puzzle: dict, output_path: str) -> None: + """Draw classified squares on the image and save for visual verification.""" + img = cv2.imread(image_path) + if img is None: + return + + for sq in puzzle["squares"]: + x, y, w, h = sq["_pixel_bbox"] + colour = TYPE_COLOURS.get(sq["type"], (128, 128, 128)) + cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2) + label = sq["type"][0].upper() + if sq["type"] == "portal": + arrows = {"left": "<", "right": ">", "up": "^", "down": "v"} + label = arrows.get(sq.get("side", ""), "O") + cv2.putText( + img, label, (x + 2, y + h - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.4, colour, 1 + ) + + cv2.imwrite(output_path, img) diff --git a/python_pkg/puzzle_solver/solver.py b/python_pkg/puzzle_solver/solver.py new file mode 100644 index 0000000..4a7a564 --- /dev/null +++ b/python_pkg/puzzle_solver/solver.py @@ -0,0 +1,330 @@ +"""BFS puzzle solver for sliding-square puzzles. + +The player slides in one of 4 directions until hitting a square (or dies +if no square is reached). Special square types modify traversal: + - PORTAL: pass-through when approached from the marked side + - TELEPORTER: warp to paired teleporter on landing + - KEY: removes the matching LOCK square from the board + - LOCK: solid until its KEY is collected, then disappears +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from enum import Enum +import json +from pathlib import Path +from typing import Any + +# ── Direction helpers ──────────────────────────────────────────────── +UP = (-1, 0) +DOWN = (1, 0) +LEFT = (0, -1) +RIGHT = (0, 1) + +DIRECTIONS: dict[str, tuple[int, int]] = { + "up": UP, + "down": DOWN, + "left": LEFT, + "right": RIGHT, +} + +# When moving in a direction, which side of the target square do we approach? +DIR_TO_APPROACH_SIDE: dict[tuple[int, int], str] = { + RIGHT: "left", + LEFT: "right", + DOWN: "up", + UP: "down", +} + + +# ── Data model ─────────────────────────────────────────────────────── +class SquareType(Enum): + """Types of squares in the puzzle grid.""" + + NORMAL = "normal" + PLAYER = "player" + GOAL = "goal" + PORTAL = "portal" + TELEPORTER = "teleporter" + KEY = "key" + LOCK = "lock" + + +@dataclass(frozen=True) +class Square: + """A single square on the puzzle board.""" + + pos: tuple[int, int] + square_type: SquareType + portal_side: str | None = None # PORTAL: side with inner square + teleporter_group: int | None = None # TELEPORTER: pair id + lock_id: int | None = None # KEY / LOCK: matching id + + +@dataclass(frozen=True) +class State: + """Immutable snapshot of player position and remaining locks.""" + + pos: tuple[int, int] + active_locks: frozenset[tuple[int, int]] + + +@dataclass +class _ParseMetadata: + """Intermediate bookkeeping collected while parsing squares.""" + + player_start: tuple[int, int] + goal_pos: tuple[int, int] + teleporter_groups: dict[int, list[tuple[int, int]]] + key_map: dict[int, tuple[int, int]] + lock_map: dict[int, tuple[int, int]] + + +def _parse_square_list( + square_dicts: list[dict[str, Any]], +) -> tuple[dict[tuple[int, int], Square], _ParseMetadata]: + """Parse the JSON squares list into Square objects and metadata.""" + squares: dict[tuple[int, int], Square] = {} + player_start: tuple[int, int] | None = None + goal_pos: tuple[int, int] | None = None + teleporter_groups: dict[int, list[tuple[int, int]]] = {} + key_map: dict[int, tuple[int, int]] = {} + lock_map: dict[int, tuple[int, int]] = {} + + for sd in square_dicts: + pos = (int(sd["pos"][0]), int(sd["pos"][1])) + sq_type = SquareType(sd["type"]) + sq = Square( + pos=pos, + square_type=sq_type, + portal_side=sd.get("side"), + teleporter_group=sd.get("group"), + lock_id=sd.get("lock_id"), + ) + squares[pos] = sq + + if sq_type == SquareType.PLAYER: + player_start = pos + elif sq_type == SquareType.GOAL: + goal_pos = pos + elif sq_type == SquareType.TELEPORTER and sq.teleporter_group is not None: + teleporter_groups.setdefault(sq.teleporter_group, []).append(pos) + elif sq_type == SquareType.KEY and sq.lock_id is not None: + key_map[sq.lock_id] = pos + elif sq_type == SquareType.LOCK and sq.lock_id is not None: + lock_map[sq.lock_id] = pos + + if player_start is None: + msg = "No player start position found in puzzle data" + raise ValueError(msg) + if goal_pos is None: + msg = "No goal position found in puzzle data" + raise ValueError(msg) + + metadata = _ParseMetadata( + player_start, goal_pos, teleporter_groups, key_map, lock_map + ) + return squares, metadata + + +def _pair_teleporters( + groups: dict[int, list[tuple[int, int]]], +) -> dict[tuple[int, int], tuple[int, int]]: + """Pair up teleporter squares by group id.""" + pairs: dict[tuple[int, int], tuple[int, int]] = {} + expected_pair_size = 2 + for gid, positions in groups.items(): + if len(positions) != expected_pair_size: + msg = f"Teleporter group {gid} has {len(positions)} members (need 2)" + raise ValueError(msg) + pairs[positions[0]] = positions[1] + pairs[positions[1]] = positions[0] + return pairs + + +def _map_keys_to_locks( + key_map: dict[int, tuple[int, int]], + lock_map: dict[int, tuple[int, int]], +) -> dict[tuple[int, int], tuple[int, int]]: + """Map each key position to its corresponding lock position.""" + key_to_lock: dict[tuple[int, int], tuple[int, int]] = {} + for lid, kpos in key_map.items(): + if lid not in lock_map: + msg = f"Key with lock_id={lid} has no matching lock" + raise ValueError(msg) + key_to_lock[kpos] = lock_map[lid] + return key_to_lock + + +@dataclass +class Puzzle: + """Full puzzle definition with squares, teleporters, and key-lock pairs.""" + + squares: dict[tuple[int, int], Square] + player_start: tuple[int, int] + goal_pos: tuple[int, int] + teleporter_pairs: dict[tuple[int, int], tuple[int, int]] + key_to_lock: dict[tuple[int, int], tuple[int, int]] + grid_bounds: tuple[int, int, int, int] # min_r, max_r, min_c, max_c + + # ── JSON round-trip ────────────────────────────────────────────── + @classmethod + def from_json(cls, data: dict[str, Any]) -> Puzzle: + """Build a Puzzle from a parsed JSON dict.""" + squares, metadata = _parse_square_list(data["squares"]) + teleporter_pairs = _pair_teleporters(metadata.teleporter_groups) + key_to_lock = _map_keys_to_locks(metadata.key_map, metadata.lock_map) + + all_pos = list(squares) + rows = [p[0] for p in all_pos] + cols = [p[1] for p in all_pos] + bounds = (min(rows) - 1, max(rows) + 1, min(cols) - 1, max(cols) + 1) + + return cls( + squares, + metadata.player_start, + metadata.goal_pos, + teleporter_pairs, + key_to_lock, + bounds, + ) + + @classmethod + def from_file(cls, path: str) -> Puzzle: + """Load a Puzzle from a JSON file path.""" + with Path(path).open() as f: + return cls.from_json(json.load(f)) + + +# ── Solver ─────────────────────────────────────────────────────────── + + +def solve(puzzle: Puzzle) -> list[str] | None: + """BFS over (position, active_locks) states. Returns move list or None.""" + initial_locks = frozenset( + sq.pos for sq in puzzle.squares.values() if sq.square_type == SquareType.LOCK + ) + start = State(puzzle.player_start, initial_locks) + + queue: deque[tuple[State, list[str]]] = deque([(start, [])]) + visited: set[State] = {start} + + while queue: + state, path = queue.popleft() + + for dir_name, (dr, dc) in DIRECTIONS.items(): + result = _simulate_move(puzzle, state, dr, dc) + if result is None: + continue + + new_state, reached_goal = result + if reached_goal: + return [*path, dir_name] + if new_state not in visited: + visited.add(new_state) + queue.append((new_state, [*path, dir_name])) + + return None + + +def _simulate_move( + puzzle: Puzzle, + state: State, + dr: int, + dc: int, +) -> tuple[State, bool] | None: + """Slide in (dr, dc). Returns (new_state, is_goal) or None on death.""" + r, c = state.pos + min_r, max_r, min_c, max_c = puzzle.grid_bounds + approach_side = DIR_TO_APPROACH_SIDE[(dr, dc)] + + cr, cc = r + dr, c + dc + while min_r <= cr <= max_r and min_c <= cc <= max_c: + pos = (cr, cc) + + if pos in puzzle.squares: + sq = puzzle.squares[pos] + + # Vanished lock - slide through + if sq.square_type == SquareType.LOCK and pos not in state.active_locks: + cr += dr + cc += dc + continue + + # Portal pass-through when approached from marked side + if sq.square_type == SquareType.PORTAL and sq.portal_side == approach_side: + cr += dr + cc += dc + continue + + # ── Landing ── + if sq.square_type == SquareType.GOAL: + return State(pos, state.active_locks), True + + if ( + sq.square_type == SquareType.TELEPORTER + and pos in puzzle.teleporter_pairs + ): + return State(puzzle.teleporter_pairs[pos], state.active_locks), False + + if sq.square_type == SquareType.KEY and pos in puzzle.key_to_lock: + lock_pos = puzzle.key_to_lock[pos] + return State(pos, state.active_locks - {lock_pos}), False + + # Default: land on square + return State(pos, state.active_locks), False + + cr += dr + cc += dc + + return None # off-grid → death + + +# ── Pretty-print ───────────────────────────────────────────────────── + +_TYPE_CHAR = { + SquareType.NORMAL: ".", + SquareType.PLAYER: "P", + SquareType.GOAL: "G", + SquareType.PORTAL: "O", + SquareType.TELEPORTER: "T", + SquareType.KEY: "K", + SquareType.LOCK: "L", +} + + +def print_puzzle(puzzle: Puzzle) -> None: + """Print an ASCII representation of the puzzle grid.""" + min_r, max_r, min_c, max_c = puzzle.grid_bounds + for r in range(min_r + 1, max_r): + row_chars: list[str] = [] + for c in range(min_c + 1, max_c): + if (r, c) in puzzle.squares: + sq = puzzle.squares[(r, c)] + ch = _TYPE_CHAR.get(sq.square_type, "?") + if sq.square_type == SquareType.PORTAL and sq.portal_side: + arrow = {"left": "<", "right": ">", "up": "^", "down": "v"} + ch = arrow.get(sq.portal_side, "O") + row_chars.append(ch) + else: + row_chars.append(" ") + + +def print_solution(puzzle: Puzzle, moves: list[str]) -> None: + """Print the solution path step by step.""" + state = State( + puzzle.player_start, + frozenset( + sq.pos + for sq in puzzle.squares.values() + if sq.square_type == SquareType.LOCK + ), + ) + for _i, move in enumerate(moves, 1): + dr, dc = DIRECTIONS[move] + result = _simulate_move(puzzle, state, dr, dc) + if result is None: + return + state, _goal = result