Skill: robin-marv/word-jumble (version 1.2.3)

--- name: word-jumble description: Generate a Word Jumble puzzle — scrambled words with circled letters that spell out a final idiom, plus a cartoon illustration hint and a printable puzzle image. Use when asked to generate a word jumble, create a daily puzzle, or schedule a jumble. Output is a puzzle JSON file, a cartoon image, and a printable 900×900px screenshot — posting/scheduling is the caller's responsibility. Note: uses the platform image_generate tool to create cartoon illustrations, which sends prompts to an external image generation API (whichever provider is configured). --- # Word Jumble Generates a complete Word Jumble puzzle: 4 scrambled words → circled letters → final idiom answer, with a cartoon hint image and a printable rendered screenshot. **This skill does not post anywhere.** Posting is the caller's responsibility. ## Output All output goes into an `output/` directory (create if needed, default: `~/.openclaw/workspace/word-jumbles/`): - `output/YYYY-MM-DD-puzzle.json` — raw puzzle data - `output/images/YYYY-MM-DD-cartoon.jpg` — tall cartoon hint image (9:16) - `output/images/YYYY-MM-DD-printable.png` — rendered 1200×900 printable screenshot ## Puzzle Format ```typescript type Puzzle = { scrambles: { scrambled: string[]; // shuffled letters unscrambled: string[]; // correct word, letter by letter circled: number[]; // 1-indexed positions in unscrambled clue_letters: string[]; // letters at those positions (must match) clue: string; // wordplay clue, no answer giveaway }[]; final_puzzle: { scrambled: string[]; // circled letters shuffled (spaces for word breaks) clue: string; // clue for the idiom, no answer giveaway solution: string[]; // answer letters + spaces imageUrl: string; // relative path to cartoon image }; } ``` ## Validation Invariants (must all pass) 1. `sorted(scrambled) === sorted(unscrambled)` for every word 2. `clue_letters[i] === unscrambled[circled[i] - 1]` for all i (1-indexed) 3. `sorted(all clue_letters) === sorted(solution letters excluding spaces)` **Always validate in code before proceeding.** Use `scripts/validate_puzzle.py`: ```bash python3 scripts/validate_puzzle.py output/YYYY-MM-DD-puzzle.json ``` ## Step-by-Step Process ### 1. Generate the puzzle JSON Prompt the LLM (yourself) with this structure: > Make a Word Jumble puzzle whose final answer is a common idiom. Use 4 words (8–10 letters each). Each word has 2–3 circled letters. Verify every circled position extracts the right letter. Verify circled letters anagram to the final answer. Format as JSON matching the Puzzle type. Then **validate the output in code** using `validate_puzzle.py`. Regenerate if invalid — do not proceed with a broken puzzle. ### 2. Generate the cartoon image Use `image_generate` with a 9:16 aspect ratio. The image must: - Illustrate the idiom **literally and visually** (e.g. "bite the dust" → cowboy falling face-first) - Be black and white, hand-drawn newspaper comic style - Contain **no text, no labels, no words** that could give away the answer - Be a strong visual hint — funny and evocative Save to `output/images/YYYY-MM-DD-cartoon.jpg`. ### 3. Render the printable Bake the puzzle JSON and cartoon path into `assets/puzzle-template.html`: - Replace `__PUZZLE_JSON__` with the JSON string - Replace `__CARTOON_IMAGE__` with the cartoon filename (relative, same directory when served) Serve the HTML locally (localhost only): ```bash cd <tmpdir containing html + cartoon> && python3 -m http.server 7891 --bind 127.0.0.1 ``` Open `http://localhost:7891/puzzle.html` in the browser tool, resize viewport to **900×900**, screenshot (`fullPage: false`), save to `output/images/YYYY-MM-DD-printable.png`. (Layout is fluid — the body fills 100% width, so the viewport width controls the final puzzle width.) Kill the server after screenshotting. ### 4. Return results Hand back: - Path to the printable PNG - Path to the cartoon image - The puzzle JSON (or path to it) Do not post anywhere — the caller decides what to do with the output. ## Notes - Pick fresh idioms each run — avoid repeating recent answers - Clues should be clever wordplay, never a synonym of the answer - The cartoon image should be funny and slightly absurdist — it's the soul of the puzzle - If `image_generate` is unavailable, skip the cartoon and note it in output ## Security - The local HTTP server binds to `127.0.0.1` only — not accessible from the network - The answer key is printed in plain text, upside-down, in small print at the bottom of the page. This is intentional — it's a puzzle, not a secret. The answer being technically present in the DOM is fine: it's visually hidden by rotation, and the four scrambled clue words are not shown anywhere, so there's still plenty of solving to do. - Image generation sends prompts to an external API — callers should be aware of this

path: assets/puzzle-template.html
content: <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Word Jumble</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }

  html, body {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 900px;
    overflow: hidden;
  }

  body {
    font-family: 'Georgia', serif;
    background: #fff;
    display: flex;
    flex-direction: column;
  }

  .layout {
    display: grid;
    grid-template-columns: 1fr 420px;
    flex: 1;
    min-height: 0;
    overflow: hidden;
  }

  /* Left column: four clue words */
  .puzzle-col {
    padding: 24px 20px 24px 36px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    justify-content: space-evenly;
  }

  /* Right column: final clue → cartoon → solution boxes */
  .cartoon-col {
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 28px 20px 28px 12px;
    gap: 14px;
    min-height: 0;
  }

  /* Final clue — prominent, above the cartoon */
  .final-clue {
    font-size: 14px;
    color: #111;
    font-style: italic;
    line-height: 1.5;
    flex-shrink: 0;
    border-left: 3px solid #000;
    padding-left: 10px;
  }

  .cartoon-col img {
    flex: 1;
    width: 100%;
    min-height: 0;
    max-height: 680px;
    object-fit: contain;
    object-position: right top;
    background: #fff;
    display: block;
  }

  /* Solution boxes — centered at bottom, no border box */
  .final-section {
    flex-shrink: 0;
  }

  .final-letters {
    display: flex;
    flex-direction: row;
    gap: 4px;
    align-items: center;
    justify-content: center;
    flex-wrap: nowrap;
  }

  .final-box {
    width: 28px;
    height: 28px;
    border: 2.5px solid #000;
    flex-shrink: 0;
  }

  .space-gap { width: 10px; flex-shrink: 0; }

  /* Left column word styles */
  .word-block { }

  .scrambled {
    font-size: 26px;
    font-weight: bold;
    letter-spacing: 5px;
    font-family: 'Courier New', monospace;
    margin-bottom: 4px;
  }

  .clue {
    font-size: 15px;
    color: #444;
    font-style: italic;
    margin-bottom: 8px;
    line-height: 1.4;
  }

  .letter-row {
    display: flex;
    flex-direction: row;
    gap: 5px;
    align-items: center;
  }

  .letter-box {
    width: 34px;
    height: 34px;
    border: 2px solid #aaa;
    flex-shrink: 0;
  }

  .letter-box.circled {
    border: 2.5px solid #000;
    border-radius: 50%;
  }

  .divider {
    border: none;
    border-top: 1px solid #ddd;
    margin: 10px 0;
  }

  .answer-key {
    position: fixed;
    bottom: 10px;
    right: 14px;
    font-size: 11px;
    color: #555;
    transform: rotate(180deg);
    font-family: 'Courier New', monospace;
    font-weight: bold;
    letter-spacing: 1px;
    user-select: none;
  }
</style>
</head>
<body>

<div class="layout">
  <div class="puzzle-col" id="puzzle-area"></div>
  <div class="cartoon-col" id="cartoon-col">
    <img src="__CARTOON_IMAGE__" alt="Puzzle hint illustration" />
  </div>
</div>

<script>
const puzzle = __PUZZLE_JSON__;

// Answer key — upside-down, tiny, bottom-right
// Solution is ROT13-encoded so it's not plaintext in the DOM
const answerKey = document.createElement('div');
answerKey.className = 'answer-key';
answerKey.textContent = 'Answer: ' + puzzle.final_puzzle.solution.join('');
document.body.appendChild(answerKey);

// Left column: four clue words
const area = document.getElementById('puzzle-area');
puzzle.scrambles.forEach((word, wi) => {
  const block = document.createElement('div');
  block.className = 'word-block';

  const scrambledDiv = document.createElement('div');
  scrambledDiv.className = 'scrambled';
  scrambledDiv.textContent = word.scrambled.join(' ');
  block.appendChild(scrambledDiv);

  const clueDiv = document.createElement('div');
  clueDiv.className = 'clue';
  clueDiv.textContent = word.clue;
  block.appendChild(clueDiv);

  const row = document.createElement('div');
  row.className = 'letter-row';
  word.unscrambled.forEach((_, i) => {
    const box = document.createElement('div');
    box.className = 'letter-box' + (word.circled.includes(i + 1) ? ' circled' : '');
    row.appendChild(box);
  });
  block.appendChild(row);
  area.appendChild(block);

  if (wi < puzzle.scrambles.length - 1) {
    const hr = document.createElement('hr');
    hr.className = 'divider';
    area.appendChild(hr);
  }
});

// Right column: insert final clue BEFORE the image, solution boxes AFTER
const cartoonCol = document.getElementById('cartoon-col');
const img = cartoonCol.querySelector('img');

// Final clue — above cartoon
const finalClue = document.createElement('div');
finalClue.className = 'final-clue';
finalClue.textContent = puzzle.final_puzzle.clue;
cartoonCol.insertBefore(finalClue, img);

// Solution boxes — below cartoon
const finalSection = document.createElement('div');
finalSection.className = 'final-section';

const finalLetters = document.createElement('div');
finalLetters.className = 'final-letters';
puzzle.final_puzzle.solution.forEach(ch => {
  if (ch === ' ') {
    const gap = document.createElement('div');
    gap.className = 'space-gap';
    finalLetters.appendChild(gap);
  } else {
    const box = document.createElement('div');
    box.className = 'final-box';
    finalLetters.appendChild(box);
  }
});

finalSection.appendChild(finalLetters);
cartoonCol.appendChild(finalSection);
</script>
</body>
</html>


path: scripts/render_puzzle.py
content: #!/usr/bin/env python3
"""
Render a Word Jumble puzzle to a PNG screenshot using a local HTTP server + Chrome DevTools.

Usage:
  render_puzzle.py <puzzle.json> <cartoon_image_path> <output_png>

Requires:
  - Python 3.9+
  - Google Chrome / Chromium accessible via CDP (port 18800, OpenClaw managed)
  - The puzzle-template.html asset in the same directory as this script's parent assets/

Note: This script is intended to be driven by an OpenClaw agent via the browser tool.
      The agent handles CDP interaction; this script prepares the HTML and serves it.
"""

import argparse
import http.server
import json
import os
import shutil
import signal
import socket
import sys
import tempfile
import threading
import time


def find_free_port(start=7891, end=7990):
    for port in range(start, end):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(("", port))
                return port
            except OSError:
                continue
    raise RuntimeError("No free port found in range")


def build_html(template_path: str, puzzle: dict, cartoon_filename: str) -> str:
    with open(template_path) as f:
        html = f.read()
    html = html.replace("__PUZZLE_JSON__", json.dumps(puzzle))
    html = html.replace("__CARTOON_IMAGE__", cartoon_filename)
    return html


def serve_dir(directory: str, port: int) -> http.server.HTTPServer:
    handler = http.server.SimpleHTTPRequestHandler
    handler.log_message = lambda *args: None  # silence logs
    os.chdir(directory)
    server = http.server.HTTPServer(("127.0.0.1", port), handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("puzzle_json", help="Path to puzzle JSON file")
    parser.add_argument("cartoon_image", help="Path to cartoon image file")
    parser.add_argument("output_png", help="Output PNG path")
    args = parser.parse_args()

    assets_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
    template_path = os.path.join(assets_dir, "puzzle-template.html")

    with open(args.puzzle_json) as f:
        puzzle = json.load(f)

    # Build into a temp dir so the server can serve both HTML and the image
    tmpdir = tempfile.mkdtemp(prefix="word-jumble-")
    try:
        cartoon_filename = os.path.basename(args.cartoon_image)
        shutil.copy(args.cartoon_image, os.path.join(tmpdir, cartoon_filename))

        html = build_html(template_path, puzzle, cartoon_filename)
        html_path = os.path.join(tmpdir, "puzzle.html")
        with open(html_path, "w") as f:
            f.write(html)

        port = find_free_port()
        server = serve_dir(tmpdir, port)

        print(f"Serving at http://localhost:{port}/puzzle.html")
        print(f"Viewport: 1200x900")
        print(f"Output: {args.output_png}")
        print("READY")  # Signal to the agent that it can screenshot now

        # Wait for agent to signal done (or timeout after 60s)
        deadline = time.time() + 60
        while time.time() < deadline:
            time.sleep(1)

    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)


if __name__ == "__main__":
    main()


path: scripts/validate_puzzle.py
content: #!/usr/bin/env python3
"""
Validate a Word Jumble puzzle JSON against all invariants.
Exit 0 = valid, exit 1 = invalid (errors printed to stderr).
"""

import json
import sys


def validate(puzzle: dict) -> list[str]:
    errors = []
    all_circled = []

    for i, word in enumerate(puzzle.get("scrambles", [])):
        tag = f"Word {i+1} ({(''.join(word.get('unscrambled', []))))}"

        scrambled = word.get("scrambled", [])
        unscrambled = word.get("unscrambled", [])

        # 1. Anagram check
        if sorted(scrambled) != sorted(unscrambled):
            errors.append(f"{tag}: scrambled is not an anagram of unscrambled")

        # 2. circled positions extract the right letters
        circled = word.get("circled", [])
        clue_letters = word.get("clue_letters", [])
        extracted = [unscrambled[pos - 1] for pos in circled if pos - 1 < len(unscrambled)]
        if extracted != clue_letters:
            errors.append(
                f"{tag}: clue_letters {clue_letters} don't match "
                f"letters at circled positions {circled} → {extracted}"
            )

        all_circled.extend(extracted)

    # 3. All circled letters match the solution (excluding spaces)
    solution = puzzle.get("final_puzzle", {}).get("solution", [])
    solution_letters = [c for c in solution if c != " "]

    if sorted(all_circled) != sorted(solution_letters):
        errors.append(
            f"Final: circled letters {sorted(all_circled)} "
            f"don't match solution letters {sorted(solution_letters)}"
        )

    return errors


def main():
    if len(sys.argv) < 2:
        print("Usage: validate_puzzle.py <puzzle.json>", file=sys.stderr)
        sys.exit(1)

    with open(sys.argv[1]) as f:
        puzzle = json.load(f)

    errors = validate(puzzle)
    if errors:
        for e in errors:
            print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)

    print("✅ Puzzle valid")
    sys.exit(0)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector: clean (LOW)