diff --git a/cmd_chat/cardex.py b/cmd_chat/cardex.py index 863d239..d8b5ae6 100644 --- a/cmd_chat/cardex.py +++ b/cmd_chat/cardex.py @@ -32,12 +32,41 @@ BASE_MB = 128.0 # ~size of the empty hh-dev base image; payload = size SUBSCORE_MAX = 100 # completeness+reusability+richness+pedigree+heft caps sum POWER_MAX = 1000 # PowerScore = subscore_sum * 10 +# Sub-score caps (sum == SUBSCORE_MAX). completeness/reusability are near-binary +# "is it a real, shipped VM?" gates, so they're kept small; the discriminating +# signal lives in richness (how self-describing + how rare its capabilities), +# heft (installed payload), and pedigree — those carry most of the spread. +COMP_MAX = 20.0 +REUSE_MAX = 15.0 +RICH_MAX = 30.0 +PED_MAX = 10.0 +HEFT_MAX = 25.0 + +# Per-tag capability weight — rare/high-skill security capabilities are worth +# more than generic ones, so a VM's "type rarity" pushes its richness (and thus +# its rarity tier). Unlisted tags default to 1.5. +CAPABILITY_WEIGHT = { + # marquee / rare offensive + IR capabilities + "exploit": 3.0, "pwn": 3.0, "c2": 3.0, "payload": 3.0, "malware": 3.0, + "crack": 3.0, "redteam": 3.0, "dfir": 3.0, "forensics": 3.0, "reverse": 3.0, + "ransomware": 3.0, + # solid mid-tier capabilities + "recon": 2.0, "osint": 2.0, "fuzz": 2.0, "brute": 2.0, "crypto": 2.0, + "tls": 2.0, "dns": 2.0, "yara": 2.0, "pcap": 2.0, "vuln": 2.0, "cve": 2.0, + "ids": 2.0, "threat": 2.0, "nmap": 2.0, + # generic / scaffolding tags + "dev": 1.0, "build": 1.0, "lib": 1.0, "http": 1.0, "web": 1.0, "log": 1.0, + "network": 1.0, "audit": 1.0, "blue": 1.0, "training": 1.0, "lab": 1.0, + "security": 1.0, "smoke": 0.0, "slice": 0.0, +} + # Rarity by absolute PowerScore cutoff (user choice: stable & reproducible). +# Calibrated against the live library distribution so tiers actually spread. RARITY_TIERS = [ - ("Legendary", 825), - ("Epic", 650), - ("Rare", 450), - ("Uncommon", 250), + ("Legendary", 720), + ("Epic", 660), + ("Rare", 540), + ("Uncommon", 380), ("Common", 0), ] @@ -82,37 +111,47 @@ def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: # ── the five value sub-scores ──────────────────────────────────────────────── def _completeness(status: str, todo: list, blockers: list) -> float: + """Is it a real, finished VM? Near-binary gate; small cap by design.""" s = (status or "").strip().lower() - base = 25.0 if s in ("done", "complete", "completed") else (12.0 if s == "in_progress" else 0.0) + base = COMP_MAX if s in ("done", "complete", "completed") else (10.0 if s == "in_progress" else 0.0) base -= min(len(todo) * 3, 12) base -= len(blockers) * 5 - return _clamp(base, 0, 25) + return _clamp(base, 0, COMP_MAX) def _reusability(shareable: bool, share_path: str, artifact_kind: str) -> float: - v = 14.0 if (shareable and share_path) else 0.0 - v += {"file": 6.0, "image": 2.0, "snapshot": 2.0}.get(artifact_kind, 0.0) - return _clamp(v, 0, 20) + """Can another agent actually pull and reuse it?""" + v = 10.0 if (shareable and share_path) else 0.0 + v += {"file": 5.0, "image": 3.0, "snapshot": 2.0}.get(artifact_kind, 0.0) + return _clamp(v, 0, REUSE_MAX) def _richness(purpose: str, objective: str, tags: list, has_usage: bool) -> float: + """The discriminating axis: how self-describing + how rare its capabilities. + + Rewards prose depth (purpose/objective length), tag breadth, *and* the + capability-rarity of those tags (a c2/exploit VM out-scores a dev-lib one).""" v = 0.0 - v += 5 if purpose.strip() else 0 - v += 5 if objective.strip() else 0 - v += min(len(tags) * 2, 6) - v += 4 if has_usage else 0 - return _clamp(v, 0, 20) + v += min(len(purpose.split()) * 0.8, 8) # purpose prose depth + v += 5 if objective.strip() else 0 # has a stated objective + v += min(len(tags) * 1.5, 9) # tag breadth + cap = sum(CAPABILITY_WEIGHT.get(t.strip().lower(), 1.5) for t in tags) + v += min(cap, 8) # capability rarity + v += 4 if has_usage else 0 # documents how to run + return _clamp(v, 0, RICH_MAX) def _pedigree(provenance: list, created_by: str) -> float: - v = min(len(provenance) * 3, 12) - v += 3 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0 - return _clamp(v, 0, 15) + """How battle-tested / hand-built its lineage is.""" + v = min(len(provenance) * 3, 8) + v += 2 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0 + return _clamp(v, 0, PED_MAX) def _heft(size_bytes: int) -> float: + """Installed payload over the empty base — log-scaled so giants don't run away.""" payload_mb = max(0.0, (size_bytes or 0) / 1e6 - BASE_MB) - return _clamp(4.0 * math.log2(1 + payload_mb / 8.0), 0, 20) + return _clamp(5.0 * math.log2(1 + payload_mb / 6.0), 0, HEFT_MAX) def _rarity_of(power: int) -> str: @@ -226,12 +265,12 @@ def card_from_entry(entry: dict, manifest: dict | None = None) -> Card: payload_mb = max(0.0, (entry.get("size_bytes") or 0) / 1e6 - BASE_MB) speed_raw = _clamp(1 - payload_mb / 256) stats = { - "HP": _stat(floor + 0.70 * (0.6 * heft / 20 + 0.4 * comp / 25)), + "HP": _stat(floor + 0.70 * (0.6 * heft / HEFT_MAX + 0.4 * comp / COMP_MAX)), "Attack": _stat(floor + 0.70 * off), - "Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / 25)), - "SpAtk": _stat(floor + 0.70 * (rich / 20)), + "Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / COMP_MAX)), + "SpAtk": _stat(floor + 0.70 * (rich / RICH_MAX)), "Speed": _stat(floor + 0.70 * speed_raw), - "SpDef": _stat(floor + 0.70 * (0.6 * reuse / 20 + 0.4 * ped / 15)), + "SpDef": _stat(floor + 0.70 * (0.6 * reuse / REUSE_MAX + 0.4 * ped / PED_MAX)), } shiny = (_seed(label) >> 16) % 16 == 0 # ~1/16, the classic shiny odds diff --git a/cmd_chat/cardimg.py b/cmd_chat/cardimg.py new file mode 100644 index 0000000..33a8be3 --- /dev/null +++ b/cmd_chat/cardimg.py @@ -0,0 +1,410 @@ +"""hh-cardimg — render a cardex Card as a collectible trading-card image. + +Free + local-first by design, with paid art backends wired in as opt-in options: + + art backend cost needs what it draws in the art window + ─────────── ────── ─────────────── ─────────────────────────────── + sigil free nothing a deterministic procedural creature + (offline) glyph rendered in pure SVG (default) + pollinations free network Pollinations flux text-to-image (no key) + stability paid STABILITY_API_KEY Stability "stable-image core" + openai paid OPENAI_API_KEY OpenAI gpt-image-1 + runway paid RUNWAYML_API_SECRET RunwayML gen4_image (async task) + +The card *frame* (border, name, type badges, 6 stat bars, dex#, rarity holo, +flavor) is always hand-built SVG — no backend touches it, so a card always +renders fully offline; the chosen backend only fills the portrait window. The +SVG is self-contained (any fetched art is inlined as a base64 data URI) and is +rasterized to PNG locally via `cairosvg` or ImageMagick `convert` when present. + +Everything is deterministic and seeded by the VM label: the same VM mints the +same card art (same Pollinations/paid seed, same sigil geometry). + + python -m cmd_chat.cardimg --label net-mapper-kit --art sigil --format png + python -m cmd_chat.cardimg --all --art pollinations --out cards/ +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import math +import os +import shutil +import subprocess +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +from .cardex import Card, card_from_entry, load_entries, _registry_path, _seed + +# ── card geometry ───────────────────────────────────────────────────────────── +W, H = 500, 700 +PAD = 18 +ART_X, ART_Y, ART_W, ART_H = PAD + 8, 96, W - 2 * (PAD + 8), 300 + +# rarity → frame palette (border, inner glow, accent text) +RARITY_THEME = { + "Common": ("#8a8f98", "#3a3f47", "#c8ccd2"), + "Uncommon": ("#3fae5a", "#13351f", "#9be8ae"), + "Rare": ("#3f7fd6", "#102742", "#9cc3f4"), + "Epic": ("#9a4fd0", "#2a1140", "#d6a8f4"), + "Legendary": ("#e0a020", "#3a2705", "#ffd86b"), +} +# elemental type → badge color +TYPE_COLOR = { + "Psychic": "#d44d8a", "Dark": "#5a5366", "Fire": "#e0622a", "Electric": "#e0b62a", + "Ghost": "#7766b8", "Steel": "#8a96a6", "Flying": "#6fa8dc", "Dragon": "#5a4fd0", + "Normal": "#a0a4ab", "Ground": "#c8a05a", "Water": "#3f8fd6", +} +# type → a couple of visual descriptors for the AI-art prompt +TYPE_VISUAL = { + "Psychic": "luminous psionic aura, third eye, violet energy", + "Dark": "shadowy, obsidian carapace, smoke wreathed", + "Fire": "molten cracks, ember plumes, scorched scales", + "Electric": "crackling arcs, neon circuitry, charged spines", + "Ghost": "spectral, translucent, drifting wisps", + "Steel": "armored plating, riveted chrome, bladed edges", + "Flying": "feathered wings, wind currents, aerodynamic", + "Dragon": "ancient draconic, horned, scaled wings", + "Normal": "earthen, sturdy, understated", + "Ground": "rocky hide, sediment, tectonic", + "Water": "fluid, iridescent fins, deep currents", +} +STAT_ORDER = [("HP", "HP"), ("Attack", "ATK"), ("Defense", "DEF"), + ("SpAtk", "SpA"), ("Speed", "SPE"), ("SpDef", "SpD")] + + +# ── procedural creature sigil (free, offline, deterministic) ────────────────── +def _sigil_svg(card: Card) -> str: + """A deterministic radial creature glyph drawn from the label seed. + + No model, no network: a symmetric burst of limbs + an eye, colored by type. + Distinct per VM (geometry, limb count, eye) yet stable across runs.""" + h = _seed(card.label) + cx, cy = ART_X + ART_W / 2, ART_Y + ART_H / 2 + R = min(ART_W, ART_H) * 0.34 + col = TYPE_COLOR.get(card.types[0], "#a0a4ab") + col2 = TYPE_COLOR.get(card.types[-1], col) + limbs = 5 + (h % 6) # 5..10 radial limbs + twist = (h >> 4) % 360 + parts = [f'' + f'' + f'' + f''] + # radial limbs + for i in range(limbs): + ang = math.radians(twist + i * 360.0 / limbs) + seg = 3 + ((h >> (i + 2)) % 3) + pts = [] + for s in range(seg + 1): + rr = R * (0.35 + 0.75 * s / seg) + wob = math.radians(((h >> (i + s)) % 40) - 20) + x = cx + rr * math.cos(ang + wob) + y = cy + rr * math.sin(ang + wob) + pts.append(f"{x:.1f},{y:.1f}") + parts.append(f'') + # body + parts.append(f'') + # eye(s) + eyes = 1 + (h % 3) + for e in range(eyes): + ex = cx + (e - (eyes - 1) / 2) * R * 0.36 + ey = cy - R * 0.06 + parts.append(f'') + parts.append(f'') + return "".join(parts) + + +# ── AI-art prompt (deterministic text from the card) ────────────────────────── +def build_prompt(card: Card) -> str: + ty = card.types[0] + vis = ", ".join(TYPE_VISUAL.get(t, "") for t in card.types if TYPE_VISUAL.get(t)) + grandeur = { + "Common": "humble small creature", + "Uncommon": "spirited creature", + "Rare": "powerful beast", + "Epic": "fearsome legendary-class monster", + "Legendary": "god-tier mythical titan, epic scale, awe-inspiring", + }[card.rarity] + return (f"a {grandeur} named {card.name}, {ty}-type mythical creature, {vis}, " + f"centered character portrait, fantasy trading-card creature art, " + f"dramatic rim lighting, clean dark background, highly detailed, " + f"digital painting, no text, no border") + + +# ── art backends: return raw image bytes (PNG/JPEG) or raise ────────────────── +def _http(req: urllib.request.Request, timeout=120) -> bytes: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read() + + +def _art_pollinations(card: Card) -> bytes: + """Free hosted flux text-to-image — no API key.""" + prompt = urllib.parse.quote(build_prompt(card)) + seed = _seed(card.label) % 1_000_000 + url = (f"https://image.pollinations.ai/prompt/{prompt}" + f"?width={ART_W*2}&height={ART_H*2}&seed={seed}&nologo=true&model=flux") + return _http(urllib.request.Request(url, headers={"User-Agent": "hh-cardimg"})) + + +def _art_stability(card: Card) -> bytes: + key = os.environ.get("STABILITY_API_KEY") + if not key: + raise RuntimeError("STABILITY_API_KEY not set") + boundary = "----hhcardimg" + parts, body = [], b"" + fields = {"prompt": build_prompt(card), "output_format": "png", + "aspect_ratio": "1:1", "seed": str(_seed(card.label) % 4_000_000_000)} + for k, v in fields.items(): + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n') + body = ("".join(parts) + f"--{boundary}--\r\n").encode() + req = urllib.request.Request( + "https://api.stability.ai/v2beta/stable-image/generate/core", data=body, + headers={"Authorization": f"Bearer {key}", "Accept": "image/*", + "Content-Type": f"multipart/form-data; boundary={boundary}"}) + return _http(req) + + +def _art_openai(card: Card) -> bytes: + key = os.environ.get("OPENAI_API_KEY") + if not key: + raise RuntimeError("OPENAI_API_KEY not set") + payload = json.dumps({"model": "gpt-image-1", "prompt": build_prompt(card), + "size": "1024x1024", "n": 1}).encode() + req = urllib.request.Request( + "https://api.openai.com/v1/images/generations", data=payload, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) + out = json.loads(_http(req)) + return base64.b64decode(out["data"][0]["b64_json"]) + + +def _art_runway(card: Card) -> bytes: + """RunwayML gen4_image — async task: create, poll, download.""" + key = os.environ.get("RUNWAYML_API_SECRET") + if not key: + raise RuntimeError("RUNWAYML_API_SECRET not set") + hdr = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", + "X-Runway-Version": "2024-11-06"} + payload = json.dumps({"model": "gen4_image", "ratio": "1024:1024", + "promptText": build_prompt(card)}).encode() + task = json.loads(_http(urllib.request.Request( + "https://api.dev.runwayml.com/v1/text_to_image", data=payload, headers=hdr))) + tid = task["id"] + import time + for _ in range(60): + time.sleep(3) + st = json.loads(_http(urllib.request.Request( + f"https://api.dev.runwayml.com/v1/tasks/{tid}", headers=hdr))) + if st.get("status") == "SUCCEEDED": + return _http(urllib.request.Request(st["output"][0])) + if st.get("status") in ("FAILED", "CANCELLED"): + raise RuntimeError(f"runway task {st.get('status')}: {st.get('failure','')}") + raise RuntimeError("runway task timed out") + + +ART_BACKENDS = { + "sigil": None, # handled inline (no fetch) + "pollinations": _art_pollinations, + "stability": _art_stability, + "openai": _art_openai, + "runway": _art_runway, +} + + +def fetch_art_data_uri(card: Card, backend: str) -> str | None: + """Return a base64 data-URI for the portrait, or None to use the sigil.""" + fn = ART_BACKENDS.get(backend) + if fn is None: + return None + raw = fn(card) + mime = "image/png" + if raw[:3] == b"\xff\xd8\xff": + mime = "image/jpeg" + return f"data:{mime};base64," + base64.b64encode(raw).decode() + + +# ── SVG card frame ──────────────────────────────────────────────────────────── +def _esc(s: str) -> str: + return (s.replace("&", "&").replace("<", "<").replace(">", ">")) + + +def _wrap(s: str, n: int) -> list[str]: + out, line = [], "" + for w in s.split(): + if len(line) + len(w) + 1 > n: + out.append(line) + line = w + else: + line = (line + " " + w).strip() + if line: + out.append(line) + return out[:3] + + +def render_card_svg(card: Card, art_data_uri: str | None = None) -> str: + border, glow, accent = RARITY_THEME.get(card.rarity, RARITY_THEME["Common"]) + s = card.stats + bst = sum(s.values()) + p = [f''] + # defs: backdrop + holo + p.append(f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'') + # frame + p.append(f'') + p.append(f'') + if card.shiny: + p.append(f'') + # header: name + dex (holo marker folded into the dex line to avoid PWR collision) + p.append(f'{_esc(card.name)}') + holo = " · ★HOLO" if card.shiny else "" + p.append(f'' + f'No.{card.dex:03d} · {_esc(card.label)}{holo}') + p.append(f'PWR {card.power}') + # type badges (top-right under PWR) + bx = W - PAD - 6 + for ty in reversed(card.types): + tc = TYPE_COLOR.get(ty, "#a0a4ab") + w = 16 + len(ty) * 8 + p.append(f'') + p.append(f'{_esc(ty)}') + bx -= w + 6 + # art window + p.append(f'') + if art_data_uri: + p.append(f'') + p.append(f'') + else: + p.append(_sigil_svg(card)) + # rarity ribbon + p.append(f'') + p.append(f'' + f'{card.rarity.upper()}') + # stat bars + y0 = ART_Y + ART_H + 26 + bw = W - 2 * (PAD + 8) + maxstat = 255 + for i, (key, lbl) in enumerate(STAT_ORDER): + y = y0 + i * 30 + val = s[key] + p.append(f'{lbl}') + p.append(f'') + fillw = max(6, (bw - 90) * val / maxstat) + p.append(f'') + p.append(f'{val}') + # footer: BST + flavor + fy = y0 + 6 * 30 + 6 + p.append(f'BST {bst}') + for j, ln in enumerate(_wrap(card.flavor, 58)): + p.append(f'{_esc(ln)}') + p.append("") + return "".join(p) + + +# ── rasterize SVG → PNG locally ─────────────────────────────────────────────── +def svg_to_png(svg_path: Path, png_path: Path) -> bool: + if shutil.which("cairosvg"): + subprocess.run(["cairosvg", str(svg_path), "-o", str(png_path)], check=True) + return True + conv = shutil.which("convert") or shutil.which("magick") + if conv: + subprocess.run([conv, "-density", "192", "-background", "none", + str(svg_path), str(png_path)], check=True) + return True + return False + + +def render_card(card: Card, out_dir: Path, backend: str, fmt: str) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + art_uri = None + if backend != "sigil": + try: + art_uri = fetch_art_data_uri(card, backend) + except Exception as e: # graceful: fall back to sigil + print(f" ! {card.label}: {backend} art failed ({e}); using sigil", + file=sys.stderr) + svg = render_card_svg(card, art_uri) + svg_path = out_dir / f"{card.label}.svg" + svg_path.write_text(svg) + if fmt == "svg": + return svg_path + png_path = out_dir / f"{card.label}.png" + if svg_to_png(svg_path, png_path): + return png_path + print(" ! no local SVG rasterizer (cairosvg/convert); kept SVG", file=sys.stderr) + return svg_path + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(argv=None) -> int: + p = argparse.ArgumentParser(prog="hh-cardimg", + description="Render cardex VM cards as images.") + p.add_argument("--registry", default=str(_registry_path())) + p.add_argument("--label", help="one VM (default: all)") + p.add_argument("--all", action="store_true", help="render every VM in the registry") + p.add_argument("--art", default="sigil", choices=list(ART_BACKENDS), + help="portrait backend (default: sigil = free + offline)") + p.add_argument("--format", default="png", choices=["png", "svg"]) + p.add_argument("--out", default="cards", help="output directory") + p.add_argument("--prompt", action="store_true", + help="just print the AI-art prompt(s) and exit") + args = p.parse_args(argv) + + entries = load_entries(Path(args.registry)) + if args.label: + entries = [e for e in entries if e.get("label") == args.label] + if not entries: + print(f"no VM labelled '{args.label}'") + return 1 + elif not args.all: + print("specify --label or --all") + return 2 + + cards = [card_from_entry(e) for e in entries] + if args.prompt: + for c in cards: + print(f"# {c.label} [{c.rarity} {('/'.join(c.types))}]\n{build_prompt(c)}\n") + return 0 + + out = Path(args.out) + for c in cards: + path = render_card(c, out, args.art, args.format) + print(f"{c.rarity:<10} {c.label:<22} → {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())