From 7afc38232bf21529e1bcc287a95d90decf495f08 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 7 Jul 2026 09:51:14 -0700 Subject: [PATCH] feat(cardex): population-curve rarity + interactive flip gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rarity was inflating to 65% Legendary: the loop mass-produces ~70 near-clone VMs that look near-identical on every axis, so no absolute PowerScore threshold can create scarcity within that cluster. Fix (option C, hybrid): - grade_curve() is now the primary rarity mechanism — a population quota (Leg 8 / Epic 15 / Rare 25 / Uncommon 27 / Common 25%) ranked by PowerScore with a deterministic label-seed tiebreak. Yields a clean 8/15/26/27/24% pyramid. Rebuilds each card's rarity-dependent name + flavor via _apply_rarity. - Absolute RARITY_TIERS kept as a single-card (--label) fallback, recalibrated. - Dead axes repaired: _pedigree now derives from author lineage (human/hh-loop/ pulled/smoke) instead of a flat 2.0; _heft rescaled to a 512MB log reference so it spreads instead of pinning mid-size VMs at the cap. cardimg: apply grade_curve to the gallery so rendered cards match hh-cardex, plus the interactive 3D flip-card gallery (--flip: front dossier / back stats). Co-Authored-By: Claude Opus 4.6 --- cmd_chat/cardex.py | 107 +++++++++++--- cmd_chat/cardimg.py | 331 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 350 insertions(+), 88 deletions(-) diff --git a/cmd_chat/cardex.py b/cmd_chat/cardex.py index 8458ccb..1370774 100644 --- a/cmd_chat/cardex.py +++ b/cmd_chat/cardex.py @@ -60,13 +60,26 @@ CAPABILITY_WEIGHT = { "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 as a population QUOTA (a curve). PowerScore still ranks the cards, but +# the quota fixes the *shape* of the pyramid so a tight cluster of loop-built +# near-clones can't all land Legendary — scarcity is guaranteed regardless of how +# the raw scores inflate. This is the primary rarity mechanism (see grade_curve). +RARITY_QUOTA = [ + ("Legendary", 0.08), + ("Epic", 0.15), + ("Rare", 0.25), + ("Uncommon", 0.27), + ("Common", 0.25), +] + +# Absolute PowerScore cutoffs — a *fallback* used only when a single card is +# scored in isolation (no population to curve against). Calibrated to the live +# fixed-axis distribution so a lone card still grades sanely. RARITY_TIERS = [ - ("Legendary", 720), - ("Epic", 660), - ("Rare", 540), - ("Uncommon", 380), + ("Legendary", 830), + ("Epic", 800), + ("Rare", 720), + ("Uncommon", 560), ("Common", 0), ] @@ -142,16 +155,32 @@ def _richness(purpose: str, objective: str, tags: list, has_usage: bool) -> floa def _pedigree(provenance: list, created_by: str) -> float: - """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) + """How hand-built its lineage is — registry-derivable from the author, then + deepened by manifest provenance when a deep manifest is supplied. + + The author tier is the load-bearing signal the CLI actually has: a VM built by + a named human outranks a loop-mass-produced near-clone, which outranks a + pulled/smoke throwaway. (Provenance lives in the deep .hh-agent manifest and + is absent for registry-only scoring, so it can only *add*, never gate.)""" + cb = (created_by or "").strip().lower() + if cb in ("", "smoke"): + base = 0.0 + elif cb == "pulled": + base = 1.0 + elif cb == "hh-loop": + base = 4.0 # a real, reproducible automated build + else: + base = 8.0 # hand-authored by a named human + base += min(len(provenance) * 2.0, PED_MAX - base) # manifest deepens lineage + return _clamp(base, 0, PED_MAX) def _heft(size_bytes: int) -> float: - """Installed payload over the empty base — log-scaled so giants don't run away.""" + """Installed payload over the empty base — log-scaled against a 512MB + reference so heft spreads across the payload range instead of pinning every + mid-size VM at the cap (the old /6.0 scale saturated by ~350MB).""" payload_mb = max(0.0, (size_bytes or 0) / 1e6 - BASE_MB) - return _clamp(5.0 * math.log2(1 + payload_mb / 6.0), 0, HEFT_MAX) + return _clamp(HEFT_MAX * math.log2(1 + payload_mb) / math.log2(1 + 512), 0, HEFT_MAX) def _rarity_of(power: int) -> str: @@ -161,6 +190,12 @@ def _rarity_of(power: int) -> str: return "Common" +def _flavor(rarity: str, types: list, purpose: str, bst: int) -> str: + ty = "/".join(types) + return (f"A {rarity.lower()} {ty}-type born from {purpose.rstrip('.')}. " + f"Base stat total {bst}.") + + # ── stats: the same sub-scores, re-projected onto the 6 classic stats ───────── def _stat(frac: float) -> int: """Map a 0..1 fraction onto the Pokemon-ish 5..255 stat range.""" @@ -222,6 +257,7 @@ class Card: subscores: dict = field(default_factory=dict) shiny: bool = False # deterministic "holo pull" status: str = "" # VM build status (done / in_progress / …) + purpose: str = "" # the VM's human description (card front text) flavor: str = "" def to_dict(self) -> dict: @@ -277,14 +313,46 @@ def card_from_entry(entry: dict, manifest: dict | None = None) -> Card: shiny = (_seed(label) >> 16) % 16 == 0 # ~1/16, the classic shiny odds name = _name(label, types[0], rarity) purpose = entry.get("purpose") or objective or "an unknown sandbox" - ty = "/".join(types) - flavor = (f"A {rarity.lower()} {ty}-type born from {purpose.rstrip('.')}. " - f"Base stat total {sum(stats.values())}.") + flavor = _flavor(rarity, types, purpose, sum(stats.values())) status = (entry.get("status") or "unknown").strip().lower() return Card(label=label, name=name, dex=_dex(label), rarity=rarity, power=power, types=types, stats=stats, subscores=subs, shiny=shiny, - status=status, flavor=flavor) + status=status, purpose=purpose, flavor=flavor) + + +# ── population rarity curve ─────────────────────────────────────────────────── +def _apply_rarity(card: Card, rarity: str) -> None: + """Reassign a card's rarity (e.g. after curve grading) and rebuild the + rarity-dependent fields — the procedural name suffix and the flavor line.""" + card.rarity = rarity + card.name = _name(card.label, card.types[0], rarity) + card.flavor = _flavor(rarity, card.types, card.purpose, sum(card.stats.values())) + + +def grade_curve(cards: list[Card]) -> list[Card]: + """Re-grade a whole library's rarity on the population curve (in place). + + PowerScore is the ranking key; RARITY_QUOTA fixes the shape of the pyramid so + a tight cluster of loop-built near-clones can't all land Legendary. Rank ties + break deterministically on the VM label, so a fixed library always grades the + same way — and each card's name/flavor is rebuilt to match its new tier. + + Use this whenever scoring a *library*; a single card scored alone keeps its + absolute-threshold rarity (there is no population to rank it against).""" + if not cards: + return cards + order = sorted(cards, key=lambda c: (-c.power, _seed(c.label))) + n = len(order) + cuts, acc = [], 0.0 + for name, q in RARITY_QUOTA: + acc += q + cuts.append((name, acc)) + for i, c in enumerate(order): + f = (i + 0.5) / n + rarity = next((name for name, cum in cuts if f <= cum), "Common") + _apply_rarity(c, rarity) + return cards # ── registry I/O + CLI ──────────────────────────────────────────────────────── @@ -327,12 +395,17 @@ def main(argv=None) -> int: args = p.parse_args(argv) entries = load_entries(Path(args.registry)) - if args.label: + single = bool(args.label) + if single: entries = [e for e in entries if e.get("label") == args.label] if not entries: print(f"no VM labelled '{args.label}' in {args.registry}") return 1 cards = [card_from_entry(e) for e in entries] + # rarity is a population quota: grade the whole library on the curve. A lone + # --label card keeps its absolute-threshold rarity (nothing to rank against). + if not single: + grade_curve(cards) if args.json: out = [c.to_dict() for c in cards] diff --git a/cmd_chat/cardimg.py b/cmd_chat/cardimg.py index cdd7976..33976ed 100644 --- a/cmd_chat/cardimg.py +++ b/cmd_chat/cardimg.py @@ -38,7 +38,8 @@ import urllib.parse import urllib.request from pathlib import Path -from .cardex import Card, card_from_entry, load_entries, _registry_path, _seed +from .cardex import Card, card_from_entry, load_entries, grade_curve, _registry_path, _seed +from . import chardex # ── card geometry ───────────────────────────────────────────────────────────── W, H = 500, 700 @@ -210,8 +211,21 @@ def _art_runway(card: Card) -> bytes: raise RuntimeError("runway task timed out") +def _art_chardex(card: Card) -> bytes: + """Use a harvested real character portrait matched to this VM (free, offline).""" + lib = chardex.load_library() + ch = chardex.match(card, lib) + if ch is None: + raise RuntimeError("empty/unmatched character library") + path = ch.path(chardex.chardex_dir()) + if not path.exists(): + raise RuntimeError(f"portrait missing: {path}") + return path.read_bytes() + + ART_BACKENDS = { "sigil": None, # handled inline (no fetch) + "chardex": _art_chardex, "pollinations": _art_pollinations, "stability": _art_stability, "openai": _art_openai, @@ -236,7 +250,7 @@ def _esc(s: str) -> str: return (s.replace("&", "&").replace("<", "<").replace(">", ">")) -def _wrap(s: str, n: int) -> list[str]: +def _wrap(s: str, n: int, maxlines: int = 3) -> list[str]: out, line = [], "" for w in s.split(): if len(line) + len(w) + 1 > n: @@ -246,55 +260,135 @@ def _wrap(s: str, n: int) -> list[str]: line = (line + " " + w).strip() if line: out.append(line) - return out[:3] + if len(out) > maxlines: + out = out[:maxlines] + out[-1] = out[-1][:n - 1] + "…" + return out -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)}') +# premium-card visual constants +FOIL_RARITIES = {"Rare", "Epic", "Legendary"} +RARITY_GLINT = { # lighter metallic highlight per rarity for the beveled border + "Common": "#cfd4da", "Uncommon": "#c8f4d6", "Rare": "#cfe2ff", + "Epic": "#ecc9ff", "Legendary": "#fff0b0", +} + + +def _card_defs(card: Card, border, glow, accent) -> str: + """All gradients/filters/foil for one premium card (ids are card-local).""" + tc = TYPE_COLOR.get(card.types[0], "#a0a4ab") + glint = RARITY_GLINT.get(card.rarity, "#cfd4da") + d = [''] + # backdrop: deep vertical wash, type-tinted toward the foot + d.append(f'' + f'' + f'' + f'') + # type spotlight behind the art + d.append(f'' + f'' + f'' + f'') + # brushed-metal border: light glint → rarity color → shadow, for a bevel + d.append(f'' + f'' + f'' + f'' + f'' + f'') + # title-banner sheen + d.append(f'') + # diagonal holographic foil (rainbow shimmer) + d.append('' + '' + '' + '' + '' + '' + '' + '') + d.append(f'' + f'' + f'' + f'') + d.append(f'' + f'') + d.append('') + return "".join(d) + + +def _corner_gem(p: list, cx, cy, accent, glint): + """A faceted diamond gem ornament at a frame corner.""" + p.append(f'' + f'' + f'') + + +def _frame_and_header(p: list, card: Card, border, glow, accent) -> tuple: + """Shared premium frame + title banner + type gem + header. Returns status pill.""" + tc = TYPE_COLOR.get(card.types[0], "#a0a4ab") + glint = RARITY_GLINT.get(card.rarity, "#cfd4da") + p.append(_card_defs(card, border, glow, accent)) + # backdrop + spotlight + vignette + p.append(f'') + p.append(f'') + # holo foil sheen for Rare+ / shiny + if card.shiny or card.rarity in FOIL_RARITIES: + p.append(f'') + if card.shiny or card.rarity in ("Epic", "Legendary"): + h = _seed(card.label) + for k in range(7): + sx = 30 + (h >> (k * 3)) % (W - 60) + sy = 90 + (h >> (k * 3 + 1)) % (H - 180) + r = 1.5 + (h >> (k * 2)) % 3 + p.append(f'') + # multi-layer beveled border + if card.rarity == "Legendary": # outer glow ring for legendaries + p.append(f'') + p.append(f'') + p.append(f'') + p.append(f'') + for (gx, gy) in ((22, 22), (W - 22, 22), (22, H - 22), (W - 22, H - 22)): + _corner_gem(p, gx, gy, accent, glint) + # title banner + type energy gem + name + p.append(f'') + p.append(f'') + p.append(f'') + p.append(f'{_esc(card.name)}') holo = " · ★HOLO" if card.shiny else "" - p.append(f'' + p.append(f'' f'No.{card.dex:03d} · {_esc(card.label)}{holo}') - p.append(f'PWR {card.power}') - # status pill on the rarity ribbon's left (done = green check, else amber) - st_done = card.status in ("done", "complete", "completed") - st_col, st_txt = (("#3fae5a", f"✓ {card.status}") if st_done - else ("#d8a23a", f"● {card.status or 'unknown'}")) - # type badges (top-right under PWR) - bx = W - PAD - 6 + p.append(f'PWR {card.power}') + bx = W - PAD - 8 for ty in reversed(card.types): - tc = TYPE_COLOR.get(ty, "#a0a4ab") + tcc = TYPE_COLOR.get(ty, "#a0a4ab") w = 16 + len(ty) * 8 - p.append(f'') - p.append(f'') + p.append(f'{_esc(ty)}') bx -= w + 6 - # art window + st_done = card.status in ("done", "complete", "completed") + return (("#3fae5a", f"✓ {card.status}") if st_done + else ("#d8a23a", f"● {card.status or 'unknown'}")) + + +def _art_window(p: list, card: Card, art_data_uri, border, st_col, st_txt): p.append(f'') if art_data_uri: @@ -305,22 +399,21 @@ def render_card_svg(card: Card, art_data_uri: str | None = None) -> str: f'xlink:href="{art_data_uri}"/>') else: p.append(_sigil_svg(card)) - # rarity ribbon p.append(f'') p.append(f'' f'{card.rarity.upper()}') - # status pill (top-left corner of the art window) sw = 22 + len(st_txt) * 7 p.append(f'') p.append(f'{_esc(st_txt)}') - # stat bars - y0 = ART_Y + ART_H + 26 + + +def _stat_bars(p: list, card: Card, y0: int, border, accent): + s = card.stats bw = W - 2 * (PAD + 8) - maxstat = 255 for i, (key, lbl) in enumerate(STAT_ORDER): y = y0 + i * 30 val = s[key] @@ -328,18 +421,72 @@ def render_card_svg(card: Card, art_data_uri: str | None = None) -> str: f'font-weight="bold">{lbl}') p.append(f'') - fillw = max(6, (bw - 90) * val / maxstat) + fillw = max(6, (bw - 90) * val / 255) 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)}') + + +def render_card_svg(card: Card, art_data_uri: str | None = None, + face: str = "full") -> str: + """Render a card SVG. face: 'full' (art+stats), 'front' (art+description), + 'back' (stats+subscores) — front/back drive the flippable hub cards.""" + border, glow, accent = RARITY_THEME.get(card.rarity, RARITY_THEME["Common"]) + bst = sum(card.stats.values()) + p = [f''] + st_col, st_txt = _frame_and_header(p, card, border, glow, accent) + + if face in ("full", "front"): + _art_window(p, card, art_data_uri, border, st_col, st_txt) + + if face == "front": + # DOSSIER panel where stats live on the back — the VM's description. + y0 = ART_Y + ART_H + 24 + p.append(f'VM DOSSIER') + p.append(f'') + desc = card.purpose or card.flavor or "an unknown sandbox" + for j, ln in enumerate(_wrap(desc, 46, maxlines=8)): + p.append(f'{_esc(ln)}') + p.append(f'PWR {card.power} · BST {bst} · ' + f'{card.rarity}') + p.append(f'' + f'tap to flip → stats') + elif face == "back": + _stat_bars(p, card, ART_Y + 6, border, accent) + # subscore breakdown + flavor under the bars + fy = ART_Y + 6 + 6 * 30 + 14 + p.append(f'VALUE BREAKDOWN') + order = ["completeness", "reusability", "richness", "pedigree", "heft"] + for k, sk in enumerate(order): + v = card.subscores.get(sk, 0) + p.append(f'{sk.capitalize():<14}') + p.append(f'{v}') + gy = fy + 24 + 5 * 20 + 18 + p.append(f'BST {bst}') + for j, ln in enumerate(_wrap(card.flavor, 52, maxlines=3)): + p.append(f'{_esc(ln)}') + p.append(f'← tap to flip') + else: # full + _stat_bars(p, card, ART_Y + ART_H + 26, border, accent) + fy = ART_Y + ART_H + 26 + 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) @@ -382,8 +529,13 @@ def render_card(card: Card, out_dir: Path, backend: str, fmt: str) -> Path: RARITY_ORDER = {"Legendary": 0, "Epic": 1, "Rare": 2, "Uncommon": 3, "Common": 4} -def build_gallery(cards: list[Card], out_dir: Path, backend: str) -> Path: - """Render every card as an inline SVG and lay them out in one index.html.""" +def build_gallery(cards: list[Card], out_dir: Path, backend: str, + flip: bool = False) -> Path: + """Render every card as an inline SVG and lay them out in one index.html. + + flip=False → a static grid of 'full' cards (art + stats), good for PNG export. + flip=True → an interactive hub: each card shows its VM dossier on the front + and flips on hover/click to reveal the stat block on the back.""" out_dir.mkdir(parents=True, exist_ok=True) cards = sorted(cards, key=lambda c: (RARITY_ORDER.get(c.rarity, 9), -c.power)) tally: dict[str, int] = {} @@ -396,28 +548,59 @@ def build_gallery(cards: list[Card], out_dir: Path, backend: str) -> Path: except Exception as e: print(f" ! {c.label}: {backend} art failed ({e}); sigil", file=sys.stderr) tally[c.rarity] = tally.get(c.rarity, 0) + 1 - cells.append(f'
{render_card_svg(c, art_uri)}
') + if flip: + front = render_card_svg(c, art_uri, face="front") + back = render_card_svg(c, art_uri, face="back") + cells.append( + f'
' + f'
{front}
' + f'
{back}
' + f'
') + else: + cells.append(f'
{render_card_svg(c, art_uri)}
') print(f" rendered {c.rarity:<10} {c.label}") spread = " · ".join(f"{n}× {r}" for r, n in sorted(tally.items(), key=lambda kv: RARITY_ORDER.get(kv[0], 9))) - html = ( - '' - '' f'

hack-house VM Cardex

' - f'

{len(cards)} saved VMs · {spread}

' + f'

{len(cards)} saved VMs · {spread}{" — " + sub if flip else ""}

' f'
{"".join(cells)}
' - '') + f'{script}') idx = out_dir / "index.html" idx.write_text(html) return idx @@ -438,6 +621,8 @@ def main(argv=None) -> int: help="just print the AI-art prompt(s) and exit") p.add_argument("--gallery", action="store_true", help="emit a single browser index.html grid of all cards") + p.add_argument("--flip", action="store_true", + help="with --gallery: interactive flip cards (front=dossier, back=stats)") args = p.parse_args(argv) entries = load_entries(Path(args.registry)) @@ -451,6 +636,10 @@ def main(argv=None) -> int: return 2 cards = [card_from_entry(e) for e in entries] + # rarity is a population quota — grade the full library on the curve so the + # gallery matches `hh-cardex`. A lone --label card keeps its absolute rarity. + if not args.label: + grade_curve(cards) if args.prompt: for c in cards: print(f"# {c.label} [{c.rarity} {('/'.join(c.types))}]\n{build_prompt(c)}\n") @@ -458,7 +647,7 @@ def main(argv=None) -> int: out = Path(args.out) if args.gallery: - idx = build_gallery(cards, out, args.art) + idx = build_gallery(cards, out, args.art, flip=args.flip) print(f"gallery → {idx}") return 0 for c in cards: