commit 6f1c61a2adec58acebc27bc80e508edb9fb4c0e0 Author: wheelz Date: Tue Sep 8 14:23:30 2026 +0000 Package OBS overlays for Docker diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44fb392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data/ +__pycache__/ +.pytest_cache/ +*.pyc +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..26d39a0 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# OBS Overlays Docker Stack + +LAN-only browser-source overlays for OBS. Each overlay runs in its own Docker container and keeps editable JSON state in `./data`, outside Git, so Portainer redeploys do not wipe the active stream state. + +## Services + +| Overlay | Container | Port | OBS URL | Editor URL | State file | +|---|---|---:|---|---|---| +| Pokemon Party | `pokemon-party-overlay` | 8765 | `http://SERVER-IP:8765/overlay.html` | `http://SERVER-IP:8765/editor.html` | `./data/pokemon-party/party.json` | +| Gauntlet | `gauntlet-overlay` | 8770 | `http://SERVER-IP:8770/overlay.html` | `http://SERVER-IP:8770/editor.html` | `./data/gauntlet/gauntlet.json` | +| Blackout Bingo | `blackout-bingo-overlay` | 8780 | `http://SERVER-IP:8780/overlay.html` | `http://SERVER-IP:8780/editor.html` | `./data/blackout-bingo/bingo.json` | + +## Portainer Git deployment + +1. Push this repo to Gitea. +2. In Portainer, go to Stacks -> Add stack. +3. Choose Git Repository. +4. Paste the Gitea repo URL. +5. Set Compose path to `docker-compose.yml`. +6. Deploy the stack. + +Redeploy the stack after Git changes to rebuild containers while keeping `./data` state files. + +## Local test + +```bash +docker compose up -d --build +curl http://127.0.0.1:8765/overlay.html +curl http://127.0.0.1:8770/overlay.html +curl http://127.0.0.1:8780/api/bingo +``` + +## LAN-only note + +This stack publishes plain HTTP ports on the Docker host. Do not port-forward these editors to the internet. If remote access is needed later, put them behind VPN or authenticated reverse proxy. diff --git a/blackout-bingo/.dockerignore b/blackout-bingo/.dockerignore new file mode 100644 index 0000000..11ad65d --- /dev/null +++ b/blackout-bingo/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.zip +data/ diff --git a/blackout-bingo/Dockerfile b/blackout-bingo/Dockerfile new file mode 100644 index 0000000..2ef4ea5 --- /dev/null +++ b/blackout-bingo/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY . /app + +EXPOSE 8780 + +CMD ["sh", "-c", "mkdir -p /data && [ -f /data/bingo.json ] || cp /app/defaults/bingo.json /data/bingo.json && ln -sf /data/bingo.json /app/bingo.json && exec python /app/server.py 8780"] diff --git a/blackout-bingo/README.txt b/blackout-bingo/README.txt new file mode 100644 index 0000000..555e612 --- /dev/null +++ b/blackout-bingo/README.txt @@ -0,0 +1,69 @@ +Blackout Bingo OBS Overlay Prototype + +Local Windows run: + +1. Extract the bingo-overlay folder. +2. Double-click start-bingo.bat. +3. Open the editor: + http://localhost:8780/editor.html +4. Add up to 100 tasks, one per line. +5. Click Save Tasks. +6. Click Generate Board to randomly pick 25 unique tasks for the 5x5 card. +7. Add this OBS Browser Source: + http://localhost:8780/overlay.html + +Recommended OBS Browser Source size: + +Width: 1000 +Height: 900 + +Server/Docker run: + +This overlay can also run on a server, Docker host, or Dell20. + +1. Copy the bingo-overlay folder to the Docker host. +2. From inside the folder, run: + docker compose up -d --build +3. Open the editor from another machine: + http://SERVER-IP:8780/editor.html +4. Add this OBS Browser Source on the streaming PC: + http://SERVER-IP:8780/overlay.html + +For Dell20, replace SERVER-IP with Dell20's LAN IP or hostname. If Dell20 resolves on your network, this will usually be one of: + +http://obin-core.wheelz.lab:8780/overlay.html +http://192.168.20.123:8780/overlay.html + +Docker persistence: + +- docker-compose.yml stores the saved bingo state under: + ./data/bingo.json +- This keeps the board and task list through container restarts/rebuilds. +- To reset everything manually, stop the stack and remove data/bingo.json, then start it again. + +How completion works: + +- This is a blackout bingo card. +- Each square can be marked by Player 1, Player 2, or both. +- Player names are editable at the top of editor.html. +- Blue mark = Player 1. +- Pink mark = Player 2. +- Gold/mixed highlight = both players completed that square. + +Files: + +server.py Save/API server +editor.html Task entry, board generation, and completion controls +overlay.html OBS overlay view +bingo.json Default/single-machine saved task pool, player names, and current board +start-bingo.bat Windows launcher +Dockerfile Container image definition +docker-compose.yml Docker deployment on a server + +Notes: + +- If running locally with the BAT file, keep the start-bingo.bat window open while streaming. +- If running with Docker, the container keeps running in the background until stopped. +- Generate Board clears completion marks because it creates a new card. +- Save Tasks preserves the current board and marks. +- The overlay auto-refreshes about every 1.5 seconds. diff --git a/blackout-bingo/defaults/bingo.json b/blackout-bingo/defaults/bingo.json new file mode 100644 index 0000000..df44f14 --- /dev/null +++ b/blackout-bingo/defaults/bingo.json @@ -0,0 +1,160 @@ +{ + "players": [ + "Player 1", + "Player 2" + ], + "tasks": [ + "Win a boss fight", + "Find a hidden item", + "Get a critical hit", + "Survive with 1 HP", + "Defeat a rival", + "Catch a new teammate", + "Lose a teammate", + "Win without healing", + "Find a rare item", + "Complete a side objective", + "Beat a dungeon", + "Unlock a shortcut", + "Get poisoned", + "Learn a new move", + "Buy something useful", + "Win first try", + "Win after a reset", + "Use a status move", + "Defeat an optional enemy", + "Reach a new town", + "Trigger a cutscene", + "Get ambushed", + "Upgrade equipment", + "Finish a challenge room", + "Chat picks a choice" + ], + "board": [ + { + "id": 0, + "task": "Buy something useful", + "completedBy": [] + }, + { + "id": 1, + "task": "Defeat a rival", + "completedBy": [] + }, + { + "id": 2, + "task": "Lose a teammate", + "completedBy": [] + }, + { + "id": 3, + "task": "Reach a new town", + "completedBy": [] + }, + { + "id": 4, + "task": "Defeat an optional enemy", + "completedBy": [] + }, + { + "id": 5, + "task": "Get ambushed", + "completedBy": [] + }, + { + "id": 6, + "task": "Chat picks a choice", + "completedBy": [] + }, + { + "id": 7, + "task": "Finish a challenge room", + "completedBy": [] + }, + { + "id": 8, + "task": "Beat a dungeon", + "completedBy": [] + }, + { + "id": 9, + "task": "Survive with 1 HP", + "completedBy": [] + }, + { + "id": 10, + "task": "Win first try", + "completedBy": [] + }, + { + "id": 11, + "task": "Win without healing", + "completedBy": [] + }, + { + "id": 12, + "task": "Trigger a cutscene", + "completedBy": [] + }, + { + "id": 13, + "task": "Find a hidden item", + "completedBy": [] + }, + { + "id": 14, + "task": "Complete a side objective", + "completedBy": [] + }, + { + "id": 15, + "task": "Upgrade equipment", + "completedBy": [] + }, + { + "id": 16, + "task": "Unlock a shortcut", + "completedBy": [] + }, + { + "id": 17, + "task": "Win after a reset", + "completedBy": [] + }, + { + "id": 18, + "task": "Find a rare item", + "completedBy": [] + }, + { + "id": 19, + "task": "Get a critical hit", + "completedBy": [] + }, + { + "id": 20, + "task": "Catch a new teammate", + "completedBy": [] + }, + { + "id": 21, + "task": "Win a boss fight", + "completedBy": [] + }, + { + "id": 22, + "task": "Learn a new move", + "completedBy": [] + }, + { + "id": 23, + "task": "Use a status move", + "completedBy": [] + }, + { + "id": 24, + "task": "Get poisoned", + "completedBy": [] + } + ] +} diff --git a/blackout-bingo/editor.html b/blackout-bingo/editor.html new file mode 100644 index 0000000..d5e428e --- /dev/null +++ b/blackout-bingo/editor.html @@ -0,0 +1,115 @@ + + + + + + Bingo Overlay Editor + + + +
+
+
+

Bingo Editor

+
Add up to 100 possible tasks, one per line. Generate a 5x5 blackout card from 25 random unique tasks. Use the board controls to mark squares completed by either player.
+
+ +
+ +
+
+
+
+
+ +
+
+ + +
+ + + +
+
+

Tip: Generate Board resets all completion marks. Save Tasks keeps the current board if the board cells still exist.

+
+ +
+
+
Click player buttons under each square to toggle completion.
+ +
+
+
+
+
+ + + + diff --git a/blackout-bingo/overlay.html b/blackout-bingo/overlay.html new file mode 100644 index 0000000..51ee23d --- /dev/null +++ b/blackout-bingo/overlay.html @@ -0,0 +1,221 @@ + + + + + + Blackout Bingo Overlay + + + +
+
+
+
+
Stream Challenge
+

Blackout Bingo

+
+
+
+
+
+
+ + + diff --git a/blackout-bingo/server.py b/blackout-bingo/server.py new file mode 100644 index 0000000..98e14db --- /dev/null +++ b/blackout-bingo/server.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +import json +import os +import random +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +DATA_FILE = Path(os.environ.get("BINGO_DATA_FILE", Path(__file__).with_name("bingo.json"))).expanduser() +MAX_TASKS = 100 +BOARD_SIZE = 25 + + +def default_data(): + return {"players": ["Player 1", "Player 2"], "tasks": [], "board": []} + + +def load_data(): + if not DATA_FILE.exists(): + save_data(default_data()) + with DATA_FILE.open("r", encoding="utf-8") as fh: + data = json.load(fh) + data.setdefault("players", ["Player 1", "Player 2"]) + data.setdefault("tasks", []) + data.setdefault("board", []) + return data + + +def clean_data(data): + tasks = [str(t).strip() for t in data.get("tasks", []) if str(t).strip()] + if len(tasks) > MAX_TASKS: + raise ValueError("You can save up to 100 tasks.") + players = data.get("players") or ["Player 1", "Player 2"] + players = [str(p).strip() or f"Player {i + 1}" for i, p in enumerate(players[:2])] + while len(players) < 2: + players.append(f"Player {len(players) + 1}") + board = [] + for i, cell in enumerate(data.get("board", [])[:BOARD_SIZE]): + task = str(cell.get("task", "")).strip() + if not task: + continue + completed = [] + for player in cell.get("completedBy", []): + player = str(player).strip() + if player and player in players and player not in completed: + completed.append(player) + board.append({"id": int(cell.get("id", i)), "task": task, "completedBy": completed}) + return {"players": players, "tasks": tasks, "board": board} + + +def save_data(data): + clean = clean_data(data) + DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = DATA_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(clean, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(DATA_FILE) + return clean + + +def generate_board(seed=None): + data = load_data() + tasks = list(dict.fromkeys([t.strip() for t in data.get("tasks", []) if t.strip()])) + if len(tasks) < BOARD_SIZE: + raise ValueError("Add at least 25 unique tasks before generating a board.") + rng = random.Random(seed) if seed else random.SystemRandom() + selected = rng.sample(tasks, BOARD_SIZE) + data["board"] = [{"id": i, "task": task, "completedBy": []} for i, task in enumerate(selected)] + return save_data(data) + + +def toggle_mark(cell_id, player): + data = load_data() + players = data.get("players", ["Player 1", "Player 2"]) + if player not in players: + raise ValueError("Unknown player.") + for cell in data.get("board", []): + if int(cell.get("id", -1)) == int(cell_id): + completed = cell.setdefault("completedBy", []) + if player in completed: + completed.remove(player) + else: + completed.append(player) + return save_data(data) + raise ValueError("Unknown board cell.") + + +class Handler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def read_json(self): + length = int(self.headers.get("Content-Length", "0") or 0) + raw = self.rfile.read(length).decode("utf-8") if length else "{}" + return json.loads(raw or "{}") + + def send_json(self, status, payload): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if urlparse(self.path).path == "/api/bingo": + self.send_json(200, {"ok": True, "data": load_data()}) + return + return super().do_GET() + + def do_POST(self): + path = urlparse(self.path).path + try: + payload = self.read_json() + if path == "/api/bingo": + self.send_json(200, {"ok": True, "data": save_data(payload)}) + return + if path == "/api/generate": + self.send_json(200, {"ok": True, "data": generate_board(payload.get("seed"))}) + return + if path == "/api/mark": + self.send_json(200, {"ok": True, "data": toggle_mark(payload.get("cellId"), payload.get("player"))}) + return + self.send_json(404, {"ok": False, "error": "Not found"}) + except Exception as exc: + self.send_json(400, {"ok": False, "error": str(exc)}) + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8780 + print(f"Bingo overlay server running at http://localhost:{port}/") + print(f"Editor: http://localhost:{port}/editor.html") + print(f"Overlay: http://localhost:{port}/overlay.html") + ThreadingHTTPServer(("", port), Handler).serve_forever() diff --git a/blackout-bingo/start-bingo.bat b/blackout-bingo/start-bingo.bat new file mode 100644 index 0000000..88324ac --- /dev/null +++ b/blackout-bingo/start-bingo.bat @@ -0,0 +1,16 @@ +@echo off +cd /d "%~dp0" +echo Starting Blackout Bingo overlay server... +echo. +echo Editor: http://localhost:8780/editor.html +echo Overlay: http://localhost:8780/overlay.html +echo. +echo Keep this window open while streaming. Closing it stops the overlay. +echo. +py server.py 8780 +if errorlevel 1 ( + echo. + echo Python launcher failed. Trying python instead... + python server.py 8780 +) +pause diff --git a/blackout-bingo/tests/test_server.py b/blackout-bingo/tests/test_server.py new file mode 100644 index 0000000..4a492df --- /dev/null +++ b/blackout-bingo/tests/test_server.py @@ -0,0 +1,91 @@ +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + + +def start_server(tmp_path): + src = Path(__file__).resolve().parents[1] + work = tmp_path / "app" + work.mkdir() + for name in ["server.py", "overlay.html", "editor.html"]: + (work / name).write_text((src / name).read_text(), encoding="utf-8") + (work / "bingo.json").write_text((src / "defaults" / "bingo.json").read_text(), encoding="utf-8") + proc = subprocess.Popen( + [sys.executable, "server.py", "8780"], + cwd=work, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + deadline = time.time() + 5 + while time.time() < deadline: + try: + urllib.request.urlopen("http://127.0.0.1:8780/api/bingo", timeout=0.25).read() + return proc, work + except Exception: + time.sleep(0.1) + output = proc.stdout.read() if proc.stdout else "" + proc.kill() + raise AssertionError(f"server did not start: {output}") + + +def request_json(url, method="GET", payload=None): + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=2) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def test_save_rejects_more_than_100_tasks(tmp_path): + proc, work = start_server(tmp_path) + try: + payload = {"tasks": [f"Task {i}" for i in range(101)], "board": [], "players": ["P1", "P2"]} + try: + request_json("http://127.0.0.1:8780/api/bingo", "POST", payload) + except urllib.error.HTTPError as exc: + assert exc.code == 400 + else: + raise AssertionError("expected 400 for too many tasks") + finally: + proc.kill() + + +def test_save_and_generate_25_unique_board_cells(tmp_path): + proc, work = start_server(tmp_path) + try: + tasks = [f"Challenge {i:02d}" for i in range(30)] + saved = request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Ash", "Misty"]}) + assert saved["ok"] is True + generated = request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "demo"}) + board = generated["data"]["board"] + assert len(board) == 25 + assert len({cell["task"] for cell in board}) == 25 + assert all(cell["completedBy"] == [] for cell in board) + assert (work / "bingo.json").exists() + finally: + proc.kill() + + +def test_mark_cell_toggles_two_players_independently(tmp_path): + proc, work = start_server(tmp_path) + try: + tasks = [f"Task {i}" for i in range(25)] + request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Blue", "Red"]}) + request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "fixed"}) + one = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"}) + assert one["data"]["board"][0]["completedBy"] == ["Blue"] + two = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Red"}) + assert set(two["data"]["board"][0]["completedBy"]) == {"Blue", "Red"} + off = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"}) + assert off["data"]["board"][0]["completedBy"] == ["Red"] + finally: + proc.kill() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..689faab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + pokemon-party-overlay: + build: + context: ./pokemon-party + container_name: pokemon-party-overlay + restart: unless-stopped + ports: + - "8765:8765" + volumes: + - ./data/pokemon-party:/data + + gauntlet-overlay: + build: + context: ./gauntlet + container_name: gauntlet-overlay + restart: unless-stopped + ports: + - "8770:8770" + volumes: + - ./data/gauntlet:/data + + blackout-bingo-overlay: + build: + context: ./blackout-bingo + container_name: blackout-bingo-overlay + restart: unless-stopped + ports: + - "8780:8780" + volumes: + - ./data/blackout-bingo:/data diff --git a/gauntlet/.dockerignore b/gauntlet/.dockerignore new file mode 100644 index 0000000..11ad65d --- /dev/null +++ b/gauntlet/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.zip +data/ diff --git a/gauntlet/Dockerfile b/gauntlet/Dockerfile new file mode 100644 index 0000000..764abfb --- /dev/null +++ b/gauntlet/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY . /app + +EXPOSE 8770 + +CMD ["sh", "-c", "mkdir -p /data && [ -f /data/gauntlet.json ] || cp /app/defaults/gauntlet.json /data/gauntlet.json && ln -sf /data/gauntlet.json /app/gauntlet.json && exec python /app/server.py --host 0.0.0.0 --port 8770 --directory /app"] diff --git a/gauntlet/README.txt b/gauntlet/README.txt new file mode 100644 index 0000000..ea70cbf --- /dev/null +++ b/gauntlet/README.txt @@ -0,0 +1,33 @@ +Gauntlet OBS Overlay +==================== + +This is a local browser-source overlay for OBS. + +Files: + overlay.html The OBS overlay page + editor.html The editing page for up to 10 games + gauntlet.json The saved game list and statuses + server.py Local server with save/advance/reset endpoints + start-gauntlet.bat Double-click this to start the server on Windows + advance-game.bat Clears the active game and moves to the next game + reset-gauntlet.bat Resets the run to the first game + +Fast start on your streaming PC: + 1. Extract the zip. + 2. Double-click start-gauntlet.bat. + 3. Open the editor: http://localhost:8770/editor.html + 4. Add your games and click Save gauntlet.json directly. + 5. Add an OBS Browser Source pointed to: http://localhost:8770/overlay.html + +Recommended OBS Browser Source size: + Width: 1120 + Height: 350 + +How the gauntlet works: + - You can add up to 10 games. + - Blank title rows do not show on the overlay. + - Exactly one game should be Active. The active game is highlighted. + - advance-game.bat marks the active game Cleared and activates the next non-cleared game. + - reset-gauntlet.bat sets the first game Active and all other games Queued. + +Keep the start-gauntlet.bat window open while streaming. Closing it stops the local server. diff --git a/gauntlet/advance-game.bat b/gauntlet/advance-game.bat new file mode 100644 index 0000000..7b6f1c4 --- /dev/null +++ b/gauntlet/advance-game.bat @@ -0,0 +1,8 @@ +@echo off +setlocal + +REM Clears the active Gauntlet game and moves the highlight to the next game. + +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/advance' | Out-Null; Write-Host 'Gauntlet advanced: current game cleared, next game active.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }" + +endlocal diff --git a/gauntlet/defaults/gauntlet.json b/gauntlet/defaults/gauntlet.json new file mode 100644 index 0000000..57358ea --- /dev/null +++ b/gauntlet/defaults/gauntlet.json @@ -0,0 +1,40 @@ +{ + "games": [ + { + "title": "Super Mario World", + "subtitle": "96 exits optional", + "icon": "✅", + "status": "cleared" + }, + { + "title": "Metroid Fusion", + "subtitle": "Any% clear", + "icon": "✅", + "status": "cleared" + }, + { + "title": "Pokémon SoulSilver", + "subtitle": "Nuzlocke ruleset", + "icon": "⚡", + "status": "active" + }, + { + "title": "Mega Man X", + "subtitle": "Boss order TBD", + "icon": "🌙", + "status": "queued" + }, + { + "title": "Zelda: Minish Cap", + "subtitle": "Casual clear", + "icon": "🗡️", + "status": "queued" + }, + { + "title": "Castlevania", + "subtitle": "Finale candidate", + "icon": "👑", + "status": "queued" + } + ] +} diff --git a/gauntlet/editor.html b/gauntlet/editor.html new file mode 100644 index 0000000..8c0962f --- /dev/null +++ b/gauntlet/editor.html @@ -0,0 +1,37 @@ + + + + + +Gauntlet Overlay Editor + + + +
+
OBS Browser Source Tool

The Gauntlet Editor

Add up to 10 games. Blank title rows are hidden from the overlay. Set one game as Active so the overlay highlights your current run.

+
Overlay: open overlay.html. Batch controls use the same advance/reset actions as this editor.
+
+ + + diff --git a/gauntlet/overlay.html b/gauntlet/overlay.html new file mode 100644 index 0000000..3f5e434 --- /dev/null +++ b/gauntlet/overlay.html @@ -0,0 +1,48 @@ + + + + + +The Gauntlet Overlay + + + +

The Gauntlet Ledger

Loading gauntlet...
+ + + diff --git a/gauntlet/reset-gauntlet.bat b/gauntlet/reset-gauntlet.bat new file mode 100644 index 0000000..31dd283 --- /dev/null +++ b/gauntlet/reset-gauntlet.bat @@ -0,0 +1,8 @@ +@echo off +setlocal + +REM Resets the Gauntlet: first game active, all other games queued. + +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/reset' | Out-Null; Write-Host 'Gauntlet reset to the beginning.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }" + +endlocal diff --git a/gauntlet/server.py b/gauntlet/server.py new file mode 100644 index 0000000..74c52d9 --- /dev/null +++ b/gauntlet/server.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Tiny local server for the Gauntlet OBS overlay. + +Serves overlay.html/editor.html and adds local control endpoints: +POST /api/gauntlet -> validates/writes gauntlet.json +POST /api/advance -> mark active game cleared, activate next queued game +POST /api/reset -> first game active, remaining games queued +""" +from __future__ import annotations + +import argparse +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +VALID_STATUSES = {"active", "queued", "cleared", "paused", "failed"} +MAX_GAMES = 10 + + +def clean_text(value: Any, max_len: int = 120) -> str: + return str(value or "").strip()[:max_len] + + +def normalize_game(game: Any, fallback_status: str = "queued") -> dict[str, str] | None: + if not isinstance(game, dict): + return None + title = clean_text(game.get("title"), 80) + subtitle = clean_text(game.get("subtitle"), 120) + icon = clean_text(game.get("icon"), 8) + status = clean_text(game.get("status"), 20).lower() + if status not in VALID_STATUSES: + status = fallback_status + if not title: + return None + return {"title": title, "subtitle": subtitle, "icon": icon or "◆", "status": status} + + +def normalize_gauntlet(data: Any) -> dict[str, list[dict[str, str]]]: + games = data.get("games") if isinstance(data, dict) else data + if not isinstance(games, list): + raise ValueError("Expected JSON object with a games array, or a games array.") + + normalized: list[dict[str, str]] = [] + for item in games[:MAX_GAMES]: + game = normalize_game(item) + if game: + normalized.append(game) + + active_seen = False + for game in normalized: + if game["status"] == "active": + if active_seen: + game["status"] = "queued" + else: + active_seen = True + + if normalized and not active_seen: + first_not_cleared = next((g for g in normalized if g["status"] != "cleared"), normalized[0]) + first_not_cleared["status"] = "active" + + return {"games": normalized} + + +def load_gauntlet(directory: str) -> dict[str, list[dict[str, str]]]: + path = Path(directory).resolve() / "gauntlet.json" + if not path.exists(): + return {"games": []} + return normalize_gauntlet(json.loads(path.read_text(encoding="utf-8"))) + + +def save_gauntlet(directory: str, data: dict[str, list[dict[str, str]]]) -> None: + path = Path(directory).resolve() / "gauntlet.json" + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def advance_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]: + games = data.get("games", []) + active_index = next((i for i, g in enumerate(games) if g.get("status") == "active"), None) + if active_index is None: + if games: + games[0]["status"] = "active" + return {"games": games} + + games[active_index]["status"] = "cleared" + for i in range(active_index + 1, len(games)): + if games[i].get("status") != "cleared": + games[i]["status"] = "active" + break + return {"games": games} + + +def reset_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]: + games = data.get("games", []) + for index, game in enumerate(games): + game["status"] = "active" if index == 0 else "queued" + return {"games": games} + + +class GauntletHandler(SimpleHTTPRequestHandler): + server_version = "GauntletOverlay/1.0" + + def do_POST(self) -> None: # noqa: N802 + route = self.path.split("?", 1)[0] + try: + if route == "/api/gauntlet": + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + data = normalize_gauntlet(json.loads(raw)) + save_gauntlet(self.directory, data) + self.send_json({"ok": True, "message": "gauntlet.json saved", **data}) + return + if route == "/api/advance": + data = advance_gauntlet(load_gauntlet(self.directory)) + save_gauntlet(self.directory, data) + self.send_json({"ok": True, "message": "advanced to next game", **data}) + return + if route == "/api/reset": + data = reset_gauntlet(load_gauntlet(self.directory)) + save_gauntlet(self.directory, data) + self.send_json({"ok": True, "message": "gauntlet reset", **data}) + return + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, status=400) + return + self.send_json({"ok": False, "error": "Not found"}, status=404) + + def send_json(self, payload: dict[str, Any], status: int = 200) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Gauntlet OBS overlay server.") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1") + parser.add_argument("--port", type=int, default=8770, help="Port to bind. Default: 8770") + parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.") + args = parser.parse_args() + handler = lambda *handler_args, **kwargs: GauntletHandler(*handler_args, directory=args.directory, **kwargs) # noqa: E731 + with ThreadingHTTPServer((args.host, args.port), handler) as httpd: + print(f"Gauntlet Overlay server running at http://{args.host}:{args.port}/") + print(f"Editor: http://{args.host}:{args.port}/editor.html") + print(f"Overlay: http://{args.host}:{args.port}/overlay.html") + print("Controls: run advance-game.bat to clear the current game and activate the next one.") + print("Keep this window open while streaming. Press Ctrl+C to stop.") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/gauntlet/sketches/001-arcade-marquee/README.md b/gauntlet/sketches/001-arcade-marquee/README.md new file mode 100644 index 0000000..efa89a0 --- /dev/null +++ b/gauntlet/sketches/001-arcade-marquee/README.md @@ -0,0 +1,17 @@ +## Variant: Arcade Marquee + +### Design stance +High-energy stream overlay that treats the Gauntlet like an arcade event marquee. + +### Key choices +- Layout: big active-game hero on the left, queue cards across the right. +- Typography: bold uppercase, compact metadata chips. +- Color: neon magenta/cyan on dark glass. +- Interaction: hover lift on game cards for review. + +### Trade-offs +- Strong at: stream energy, quick active-state readability, sponsor/OBS-style polish. +- Weak at: quieter streams, long game titles, large queue lists. + +### Best for +A main overlay shown between segments or in a webcam/sidebar area where the active game needs to pop. diff --git a/gauntlet/sketches/001-arcade-marquee/index.html b/gauntlet/sketches/001-arcade-marquee/index.html new file mode 100644 index 0000000..3279221 --- /dev/null +++ b/gauntlet/sketches/001-arcade-marquee/index.html @@ -0,0 +1,33 @@ + + + + + +Gauntlet Overlay Sketch — Arcade Marquee + + + +
+
+
Now Running The Gauntlet

Pokémon SoulSilver

Active GameNuzlockeRun 01
+
Gauntlet Progress
+
+
+
Game Queue
3 cleared · 4 waiting
+
+
ACTIVE
#04
⚡
Pokémon SoulSilver
Johto badge chase
+
NEXT
#05
🌙
Mega Man X
Queued
+
#06
🗡️
Zelda: Minish Cap
Queued
+
#07
🏎️
Mario Kart DS
Challenge run
+
#08
👑
Castlevania
Final slot
+
+
+
+ + \ No newline at end of file diff --git a/gauntlet/sketches/002-rpg-quest-ledger/README.md b/gauntlet/sketches/002-rpg-quest-ledger/README.md new file mode 100644 index 0000000..2d6b034 --- /dev/null +++ b/gauntlet/sketches/002-rpg-quest-ledger/README.md @@ -0,0 +1,17 @@ +## Variant: RPG Quest Ledger + +### Design stance +Frames the Gauntlet as a compact quest log where the active game is highlighted directly in the ledger list. + +### Key choices +- Layout: two-column ledger rows without the separate current-trial panel. +- Typography: fantasy serif headings with readable UI-body details. +- Color: parchment, leather, gold, and dark red. +- Interaction: active row status styling shows state clearly. + +### Trade-offs +- Strong at: theme, storytelling, cleared/queued status clarity. +- Weak at: less modern/techy, may need simplification for tiny OBS placement. + +### Best for +A Gauntlet brand identity where the list feels like a challenge board or campaign log. diff --git a/gauntlet/sketches/002-rpg-quest-ledger/index.html b/gauntlet/sketches/002-rpg-quest-ledger/index.html new file mode 100644 index 0000000..2d4cab0 --- /dev/null +++ b/gauntlet/sketches/002-rpg-quest-ledger/index.html @@ -0,0 +1,25 @@ + + + + + +Gauntlet Overlay Sketch — RPG Quest Ledger + + + +
+

The Gauntlet Ledger

Set one quest as ACTIVE · track cleared and queued games
+
✅
Super Mario World
Cleared · 96 exits optional
Cleared
+
✅
Metroid Fusion
Cleared · any%
Cleared
+
⚡
Pokémon SoulSilver
Active · Nuzlocke ruleset
Active
+
🌙
Mega Man X
Queued · boss order TBD
Next
+
🗡️
Zelda: Minish Cap
Queued · casual clear
Queued
+
👑
Castlevania
Queued · finale candidate
Queued
+
+
+ + \ No newline at end of file diff --git a/gauntlet/sketches/index.html b/gauntlet/sketches/index.html new file mode 100644 index 0000000..ac41ea7 --- /dev/null +++ b/gauntlet/sketches/index.html @@ -0,0 +1 @@ +Gauntlet Overlay Sketches

Gauntlet Overlay Design Sketches

Two visual directions for the stream overlay. These are design-only mockups with sample games and an active state.

🕹️

Arcade Marquee

Neon, energetic, stream-forward. Active game gets a big hero panel with a horizontal queue.

Open prototype →
📜

RPG Quest Ledger

Gauntlet as a quest book. Strong active game spotlight with cleared/queued quest rows.

Open prototype →
\ No newline at end of file diff --git a/gauntlet/start-gauntlet.bat b/gauntlet/start-gauntlet.bat new file mode 100644 index 0000000..7823c46 --- /dev/null +++ b/gauntlet/start-gauntlet.bat @@ -0,0 +1,37 @@ +@echo off +setlocal + +REM Gauntlet Overlay launcher +REM This starts the local server used by OBS, the editor, and batch controls. + +cd /d "%~dp0" + +echo Starting Gauntlet Overlay server... +echo. +echo Editor: http://localhost:8770/editor.html +echo Overlay: http://localhost:8770/overlay.html +echo. +echo Keep this window open while streaming. +echo Press Ctrl+C to stop the server. +echo. + +where py >nul 2>nul +if %errorlevel%==0 ( + py server.py + goto :end +) + +where python >nul 2>nul +if %errorlevel%==0 ( + python server.py + goto :end +) + +echo ERROR: Python was not found. +echo Install Python from https://www.python.org/downloads/ +echo During install, check "Add Python to PATH". +echo. +pause + +:end +endlocal diff --git a/pokemon-party/.dockerignore b/pokemon-party/.dockerignore new file mode 100644 index 0000000..11ad65d --- /dev/null +++ b/pokemon-party/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.zip +data/ diff --git a/pokemon-party/Dockerfile b/pokemon-party/Dockerfile new file mode 100644 index 0000000..4a62569 --- /dev/null +++ b/pokemon-party/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY . /app + +EXPOSE 8765 + +CMD ["sh", "-c", "mkdir -p /data && [ -f /data/party.json ] || cp /app/defaults/party.json /data/party.json && ln -sf /data/party.json /app/party.json && exec python /app/server.py --host 0.0.0.0 --port 8765 --directory /app"] diff --git a/pokemon-party/README.txt b/pokemon-party/README.txt new file mode 100644 index 0000000..d99dc10 --- /dev/null +++ b/pokemon-party/README.txt @@ -0,0 +1,91 @@ +Pokémon Party OBS Overlay + +This is the simple Option 2 version: a static overlay page plus a party.json file. + +Files: +overlay.html - the OBS/browser overlay +editor.html - editor for the six party slots +guide.html - visual setup guide you can open in your browser +server.py - local server that lets editor.html save party.json directly +party.json - your six party slots +sprites/ - put your Pokémon sprite PNG/GIF files here + +How to run locally: +1. Open a terminal in this folder. +2. Run: + py server.py + + On Mac/Linux, use: + python3 server.py +3. In OBS, add a Browser Source with: + http://localhost:8765/overlay.html +4. Open the visual guide if you want setup help: + http://localhost:8765/guide.html +5. Recommended OBS browser size: + Width: 1400 + Height: 230 +6. Keep the server terminal open while streaming. + +Important: +Use server.py if you want the editor to save party.json directly. +Plain python -m http.server still works for viewing the overlay, but it cannot save files. + +Changing your party: +Option A, recommended direct-save workflow: +1. Start the server with: + py server.py +2. Open this in your normal browser: + http://localhost:8765/editor.html +3. Fill in the six slots. +4. Click Save party.json directly. +5. OBS should update within 2 seconds. + +Option B, backup static workflow: +1. Open this in your normal browser: + http://localhost:8765/editor.html +2. Fill in the six slots. +3. Click Copy JSON and paste it over the contents of party.json, or click Download party.json and replace the old file. +4. Save party.json. The overlay checks for changes every 2 seconds. + +Option B, browser-storage preview workflow: +1. Open editor.html in the same browser/profile that will view the overlay. +2. Click Save to browser preview. +3. Use this overlay URL: + http://localhost:8765/overlay.html?source=browser + +For OBS, Option A is more reliable because OBS may not share browser storage with your regular browser. + +Each slot looks like this: +{ + "species": "cyndaquil", + "nickname": "Toast", + "sprite": "sprites/cyndaquil.png", + "status": "alive" +} + +Status options: +alive - normal +boxed - yellow tag +dead - grayscale sprite and crossed text +empty - empty slot + +Sprite naming: +You can name files however you want, as long as party.json points to the right path. +Examples: +sprites/cyndaquil.png +sprites/155.png +sprites/toast.gif + +Layout options: +Horizontal party bar: +http://localhost:8765/overlay.html + +Vertical sidebar: +http://localhost:8765/overlay.html?layout=vertical + +2x3 grid: +http://localhost:8765/overlay.html?layout=grid + +Notes: +The page has a transparent background for OBS. +If a sprite file is missing, the overlay shows a Poké Ball placeholder instead of breaking. diff --git a/pokemon-party/defaults/party.json b/pokemon-party/defaults/party.json new file mode 100644 index 0000000..23e6e69 --- /dev/null +++ b/pokemon-party/defaults/party.json @@ -0,0 +1,40 @@ +{ + "party": [ + { + "species": "cyndaquil", + "nickname": "Toast", + "sprite": "sprites/cyndaquil.png", + "status": "alive" + }, + { + "species": "pidgey", + "nickname": "Gale", + "sprite": "sprites/pidgey.png", + "status": "alive" + }, + { + "species": "mareep", + "nickname": "Static", + "sprite": "sprites/mareep.png", + "status": "alive" + }, + { + "species": "", + "nickname": "", + "sprite": "", + "status": "empty" + }, + { + "species": "", + "nickname": "", + "sprite": "", + "status": "empty" + }, + { + "species": "", + "nickname": "", + "sprite": "", + "status": "empty" + } + ] +} diff --git a/pokemon-party/editor.html b/pokemon-party/editor.html new file mode 100644 index 0000000..04828c4 --- /dev/null +++ b/pokemon-party/editor.html @@ -0,0 +1,401 @@ + + + + + + Pokémon Party Editor + + + +
+

Pokémon Party Editor

+

Use this page to build your six-slot party and save it directly to party.json for the OBS overlay.

+ +
+ + + + + + +
Direct save requires running this folder with server.py, not plain python -m http.server. If direct save fails, Copy JSON and Download party.json still work as backups.
+
+ +

Sprite path examples: sprites/cyndaquil.png, sprites/hgss/155.png, or a full PokeAPI URL. If you enter a National Dex number and leave Sprite blank, the helper buttons can fill it for you.

+ +
+ +
+

Generated party.json

+ +
+
+ + + + diff --git a/pokemon-party/guide.html b/pokemon-party/guide.html new file mode 100644 index 0000000..3ddb81e --- /dev/null +++ b/pokemon-party/guide.html @@ -0,0 +1,365 @@ + + + + + + Pokémon Party OBS Overlay Guide + + + +
+
+
SoulSilver Nuzlocke stream tool
+

OBS party overlay setup guide

+

This guide walks you through running the Pokémon party overlay locally, editing your six active slots, adding HeartGold/SoulSilver sprites, and connecting everything to OBS.

+ +
+ +
+
+

Fast start

+
+

Extract the folder

Unzip pokemon-party-overlay.zip somewhere easy, like C:\StreamingTools\pokemon-party-overlay.

+

Start the local server

Double-click start-overlay.bat. Keep that window open while streaming.

+

Open the editor

Go to http://localhost:8765/editor.html, set your Pokémon, nicknames, sprite paths, and statuses, then click Save party.json directly.

+

Add the OBS source

In OBS, add a Browser Source pointing to http://localhost:8765/overlay.html. Recommended size: 1400 x 230.

+
+
+ +
+

What each file does

+
    +
  • start-overlay.bat starts everything on Windows.
  • +
  • server.py serves the pages and lets the editor save directly.
  • +
  • editor.html is your control page for changing the party.
  • +
  • overlay.html is the OBS Browser Source page.
  • +
  • party.json stores the six slots.
  • +
  • sprites/ stores your local PNG or GIF sprites.
  • +
+
+ +
+

OBS settings

+

Add a new Browser Source and use:

+
URL: http://localhost:8765/overlay.html
+Width: 1400
+Height: 230
+

The overlay background is transparent, so you can place it above your game capture. If you use a different layout, resize the Browser Source to fit.

+
+ +
+

Adding sprites

+

The easiest local setup is to put HeartGold/SoulSilver sprites in sprites/hgss/ and use National Dex numbers as filenames.

+
pokemon-party-overlay/
+  sprites/
+    hgss/
+      152.png
+      155.png
+      158.png
+      25.png
+

Then use paths like this in the editor:

+
sprites/hgss/155.png
+

Good source: https://github.com/PokeAPI/sprites. The HGSS folder is:

+
sprites/pokemon/versions/generation-iv/heartgold-soulsilver/
+
+ +
+

Layouts

+

Horizontal party bar:

+
overlay.html
+

Vertical sidebar:

+
overlay.html?layout=vertical
+

2x3 grid:

+
overlay.html?layout=grid
+
+ +
+

Status tags

+

Use these status values in the editor:

+
+ alive + boxed + dead + empty +
+

Dead Pokémon show with grayscale styling, which is useful for Nuzlocke tracking.

+
+ +
+

If something breaks

+
    +
  • If OBS is blank, confirm start-overlay.bat is still open.
  • +
  • If saving fails, make sure you started with server.py, not python -m http.server.
  • +
  • If a sprite is missing, check the path and filename spelling.
  • +
  • If port 8765 is busy, close the old server window and start again.
  • +
+
+ +
+

Example party.json slot

+

You normally edit this through the editor, but this is what one Pokémon slot looks like under the hood:

+
{
+  "species": "cyndaquil",
+  "nickname": "Toast",
+  "sprite": "sprites/hgss/155.png",
+  "status": "alive"
+}
+
+
+ +
+ Keep the server window open during stream. Update the party in the editor; OBS refreshes automatically within a couple seconds. +
+
+ + diff --git a/pokemon-party/overlay.html b/pokemon-party/overlay.html new file mode 100644 index 0000000..c926151 --- /dev/null +++ b/pokemon-party/overlay.html @@ -0,0 +1,325 @@ + + + + + + Pokémon Party Overlay + + + +
+ + + + diff --git a/pokemon-party/server.py b/pokemon-party/server.py new file mode 100644 index 0000000..0083b59 --- /dev/null +++ b/pokemon-party/server.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Tiny local server for the Pokémon Party OBS overlay. + +It serves overlay.html/editor.html like `python -m http.server`, plus adds: +POST /api/party -> validates JSON and writes party.json in this folder. +""" + +from __future__ import annotations + +import argparse +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +EMPTY_SLOT = {"species": "", "nickname": "", "sprite": "", "status": "empty"} +VALID_STATUSES = {"alive", "dead", "boxed", "empty"} + + +def normalize_slot(slot: Any) -> dict[str, str]: + if not isinstance(slot, dict): + return dict(EMPTY_SLOT) + + species = str(slot.get("species", "") or "").strip().lower().replace(" ", "-") + nickname = str(slot.get("nickname", "") or "").strip() + sprite = str(slot.get("sprite", "") or "").strip() + status = str(slot.get("status", "") or "").strip().lower() + + if not status or status not in VALID_STATUSES: + status = "alive" if (species or nickname or sprite) else "empty" + + if not (species or nickname or sprite): + return dict(EMPTY_SLOT) + + return { + "species": species, + "nickname": nickname, + "sprite": sprite, + "status": status, + } + + +def normalize_party(data: Any) -> dict[str, list[dict[str, str]]]: + party = data.get("party") if isinstance(data, dict) else data + if not isinstance(party, list): + raise ValueError("Expected JSON object with a party array, or a party array.") + + slots = [normalize_slot(slot) for slot in party[:6]] + while len(slots) < 6: + slots.append(dict(EMPTY_SLOT)) + return {"party": slots} + + +class PartyOverlayHandler(SimpleHTTPRequestHandler): + server_version = "PokemonPartyOverlay/1.0" + + def do_POST(self) -> None: # noqa: N802 - http.server method name + if self.path.split("?", 1)[0] != "/api/party": + self.send_json({"ok": False, "error": "Not found"}, status=404) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + data = json.loads(raw) + normalized = normalize_party(data) + party_path = Path(self.directory).resolve() / "party.json" + party_path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8") + except Exception as exc: # Keep this friendly for local streamer use. + self.send_json({"ok": False, "error": str(exc)}, status=400) + return + + self.send_json({"ok": True, "message": "party.json saved"}) + + def send_json(self, payload: dict[str, Any], status: int = 200) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Pokémon Party OBS overlay server.") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1") + parser.add_argument("--port", type=int, default=8765, help="Port to bind. Default: 8765") + parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.") + args = parser.parse_args() + + handler = lambda *handler_args, **kwargs: PartyOverlayHandler( # noqa: E731 + *handler_args, + directory=args.directory, + **kwargs, + ) + with ThreadingHTTPServer((args.host, args.port), handler) as httpd: + print(f"Pokémon Party Overlay server running at http://{args.host}:{args.port}/") + print(f"Editor: http://{args.host}:{args.port}/editor.html") + print(f"Overlay: http://{args.host}:{args.port}/overlay.html") + print("Keep this window open while streaming. Press Ctrl+C to stop.") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/pokemon-party/sprites/README.txt b/pokemon-party/sprites/README.txt new file mode 100644 index 0000000..419d384 --- /dev/null +++ b/pokemon-party/sprites/README.txt @@ -0,0 +1,8 @@ +Drop sprite files in this folder. + +Recommended formats: +PNG for still sprites +GIF for animated sprites + +Then reference them from party.json like: +"sprite": "sprites/cyndaquil.png" diff --git a/pokemon-party/start-overlay.bat b/pokemon-party/start-overlay.bat new file mode 100644 index 0000000..ece1ffe --- /dev/null +++ b/pokemon-party/start-overlay.bat @@ -0,0 +1,37 @@ +@echo off +setlocal + +REM Pokemon Party Overlay launcher +REM This starts the local server used by OBS and the editor. + +cd /d "%~dp0" + +echo Starting Pokemon Party Overlay server... +echo. +echo Editor: http://localhost:8765/editor.html +echo Overlay: http://localhost:8765/overlay.html +echo. +echo Keep this window open while streaming. +echo Press Ctrl+C to stop the server. +echo. + +where py >nul 2>nul +if %errorlevel%==0 ( + py server.py + goto :end +) + +where python >nul 2>nul +if %errorlevel%==0 ( + python server.py + goto :end +) + +echo ERROR: Python was not found. +echo Install Python from https://www.python.org/downloads/ +echo During install, check "Add Python to PATH". +echo. +pause + +:end +endlocal diff --git a/pokemon-party/tests/test_server.py b/pokemon-party/tests/test_server.py new file mode 100644 index 0000000..a74fd0d --- /dev/null +++ b/pokemon-party/tests/test_server.py @@ -0,0 +1,75 @@ +import json +import sys +import tempfile +import threading +import unittest +from http.client import HTTPConnection +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +class PartyServerTests(unittest.TestCase): + def setUp(self): + from server import PartyOverlayHandler, ThreadingHTTPServer + + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / "party.json").write_text('{"party": []}\n', encoding="utf-8") + (self.root / "editor.html").write_text("editor", encoding="utf-8") + + handler = lambda *args, **kwargs: PartyOverlayHandler(*args, directory=str(self.root), **kwargs) + self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + self.host, self.port = self.httpd.server_address + + def tearDown(self): + self.httpd.shutdown() + self.thread.join(timeout=2) + self.tmp.cleanup() + + def request(self, method, path, body=None, headers=None): + conn = HTTPConnection(self.host, self.port, timeout=5) + conn.request(method, path, body=body, headers=headers or {}) + response = conn.getresponse() + data = response.read().decode("utf-8") + conn.close() + return response.status, data, dict(response.getheaders()) + + def test_post_api_party_writes_normalized_party_json(self): + payload = { + "party": [ + {"species": "cyndaquil", "nickname": "Toast", "sprite": "sprites/hgss/155.png", "status": "alive"} + ] + } + status, body, _headers = self.request( + "POST", + "/api/party", + body=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(status, 200) + self.assertEqual(json.loads(body)["ok"], True) + saved = json.loads((self.root / "party.json").read_text(encoding="utf-8")) + self.assertEqual(len(saved["party"]), 6) + self.assertEqual(saved["party"][0]["species"], "cyndaquil") + self.assertEqual(saved["party"][1]["status"], "empty") + + def test_post_api_party_rejects_invalid_json_without_overwriting_file(self): + before = (self.root / "party.json").read_text(encoding="utf-8") + status, body, _headers = self.request( + "POST", + "/api/party", + body="not json", + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(status, 400) + self.assertIn("error", json.loads(body)) + self.assertEqual((self.root / "party.json").read_text(encoding="utf-8"), before) + + +if __name__ == "__main__": + unittest.main()