feat(cardex): population-curve rarity + interactive flip gallery
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 <noreply@anthropic.com>
This commit is contained in:
+90
-17
@@ -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]
|
||||
|
||||
+260
-71
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'xmlns:xlink="http://www.w3.org/1999/xlink" width="{W}" height="{H}" '
|
||||
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Arial, sans-serif">']
|
||||
# defs: backdrop + holo
|
||||
p.append(f'<defs>'
|
||||
f'<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">'
|
||||
f'<stop offset="0%" stop-color="#1a1d24"/>'
|
||||
f'<stop offset="100%" stop-color="{glow}"/></linearGradient>'
|
||||
f'<linearGradient id="holo" x1="0" y1="0" x2="1" y2="1">'
|
||||
f'<stop offset="0%" stop-color="{accent}" stop-opacity="0.0"/>'
|
||||
f'<stop offset="50%" stop-color="{accent}" stop-opacity="0.18"/>'
|
||||
f'<stop offset="100%" stop-color="{accent}" stop-opacity="0.0"/>'
|
||||
f'</linearGradient></defs>')
|
||||
# frame
|
||||
p.append(f'<rect x="0" y="0" width="{W}" height="{H}" rx="22" fill="url(#bg)"/>')
|
||||
p.append(f'<rect x="6" y="6" width="{W-12}" height="{H-12}" rx="18" '
|
||||
f'fill="none" stroke="{border}" stroke-width="5"/>')
|
||||
if card.shiny:
|
||||
p.append(f'<rect x="6" y="6" width="{W-12}" height="{H-12}" rx="18" '
|
||||
f'fill="url(#holo)"/>')
|
||||
# header: name + dex (holo marker folded into the dex line to avoid PWR collision)
|
||||
p.append(f'<text x="{PAD+6}" y="40" font-size="26" font-weight="bold" '
|
||||
f'fill="#f4f6f8">{_esc(card.name)}</text>')
|
||||
# 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 = ['<defs>']
|
||||
# backdrop: deep vertical wash, type-tinted toward the foot
|
||||
d.append(f'<linearGradient id="bg" x1="0" y1="0" x2="0.2" y2="1">'
|
||||
f'<stop offset="0%" stop-color="#0c0e13"/>'
|
||||
f'<stop offset="55%" stop-color="#13161d"/>'
|
||||
f'<stop offset="100%" stop-color="{glow}"/></linearGradient>')
|
||||
# type spotlight behind the art
|
||||
d.append(f'<radialGradient id="spot" cx="50%" cy="34%" r="62%">'
|
||||
f'<stop offset="0%" stop-color="{tc}" stop-opacity="0.32"/>'
|
||||
f'<stop offset="100%" stop-color="{tc}" stop-opacity="0"/>'
|
||||
f'</radialGradient>')
|
||||
# brushed-metal border: light glint → rarity color → shadow, for a bevel
|
||||
d.append(f'<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1">'
|
||||
f'<stop offset="0%" stop-color="{glint}"/>'
|
||||
f'<stop offset="28%" stop-color="{border}"/>'
|
||||
f'<stop offset="60%" stop-color="{border}"/>'
|
||||
f'<stop offset="78%" stop-color="#0c0e13"/>'
|
||||
f'<stop offset="100%" stop-color="{border}"/></linearGradient>')
|
||||
# title-banner sheen
|
||||
d.append(f'<linearGradient id="banner" x1="0" y1="0" x2="0" y2="1">'
|
||||
f'<stop offset="0%" stop-color="{border}" stop-opacity="0.95"/>'
|
||||
f'<stop offset="100%" stop-color="#0c0e13" stop-opacity="0.85"/>'
|
||||
f'</linearGradient>')
|
||||
# diagonal holographic foil (rainbow shimmer)
|
||||
d.append('<linearGradient id="foil" x1="0" y1="0" x2="1" y2="1">'
|
||||
'<stop offset="0%" stop-color="#ff5d8f" stop-opacity="0.0"/>'
|
||||
'<stop offset="20%" stop-color="#ffd166" stop-opacity="0.16"/>'
|
||||
'<stop offset="40%" stop-color="#06d6a0" stop-opacity="0.10"/>'
|
||||
'<stop offset="60%" stop-color="#4cc9f0" stop-opacity="0.18"/>'
|
||||
'<stop offset="80%" stop-color="#b8a6ff" stop-opacity="0.12"/>'
|
||||
'<stop offset="100%" stop-color="#ff5d8f" stop-opacity="0.0"/>'
|
||||
'</linearGradient>')
|
||||
d.append(f'<radialGradient id="vig" cx="50%" cy="50%" r="75%">'
|
||||
f'<stop offset="60%" stop-color="#000000" stop-opacity="0"/>'
|
||||
f'<stop offset="100%" stop-color="#000000" stop-opacity="0.45"/>'
|
||||
f'</radialGradient>')
|
||||
d.append(f'<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">'
|
||||
f'<feGaussianBlur stdDeviation="6"/></filter>')
|
||||
d.append('</defs>')
|
||||
return "".join(d)
|
||||
|
||||
|
||||
def _corner_gem(p: list, cx, cy, accent, glint):
|
||||
"""A faceted diamond gem ornament at a frame corner."""
|
||||
p.append(f'<g transform="translate({cx},{cy}) rotate(45)">'
|
||||
f'<rect x="-9" y="-9" width="18" height="18" rx="3" fill="{accent}" '
|
||||
f'stroke="{glint}" stroke-width="1.5"/>'
|
||||
f'<rect x="-9" y="-9" width="18" height="9" rx="3" fill="{glint}" '
|
||||
f'opacity="0.55"/></g>')
|
||||
|
||||
|
||||
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'<rect x="0" y="0" width="{W}" height="{H}" rx="24" fill="url(#bg)"/>')
|
||||
p.append(f'<rect x="14" y="80" width="{W-28}" height="330" rx="16" fill="url(#spot)"/>')
|
||||
# holo foil sheen for Rare+ / shiny
|
||||
if card.shiny or card.rarity in FOIL_RARITIES:
|
||||
p.append(f'<rect x="8" y="8" width="{W-16}" height="{H-16}" rx="20" '
|
||||
f'fill="url(#foil)"/>')
|
||||
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'<circle cx="{sx}" cy="{sy}" r="{r}" fill="#ffffff" '
|
||||
f'opacity="0.7"/>')
|
||||
# multi-layer beveled border
|
||||
if card.rarity == "Legendary": # outer glow ring for legendaries
|
||||
p.append(f'<rect x="9" y="9" width="{W-18}" height="{H-18}" rx="20" '
|
||||
f'fill="none" stroke="{border}" stroke-width="10" '
|
||||
f'filter="url(#glow)" opacity="0.7"/>')
|
||||
p.append(f'<rect x="7" y="7" width="{W-14}" height="{H-14}" rx="19" '
|
||||
f'fill="none" stroke="url(#metal)" stroke-width="8"/>')
|
||||
p.append(f'<rect x="13" y="13" width="{W-26}" height="{H-26}" rx="14" '
|
||||
f'fill="none" stroke="{accent}" stroke-width="1.5" opacity="0.7"/>')
|
||||
p.append(f'<rect x="0" y="0" width="{W}" height="{H}" rx="24" fill="url(#vig)"/>')
|
||||
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'<rect x="{PAD}" y="20" width="{W-2*PAD}" height="50" rx="12" '
|
||||
f'fill="url(#banner)" stroke="{accent}" stroke-width="1" opacity="0.96"/>')
|
||||
p.append(f'<circle cx="{PAD+26}" cy="45" r="15" fill="{tc}" '
|
||||
f'stroke="{glint}" stroke-width="2"/>')
|
||||
p.append(f'<circle cx="{PAD+21}" cy="40" r="5" fill="#ffffff" opacity="0.7"/>')
|
||||
p.append(f'<text x="{PAD+50}" y="44" font-size="25" font-weight="bold" '
|
||||
f'fill="#f7f9fb">{_esc(card.name)}</text>')
|
||||
holo = " · ★HOLO" if card.shiny else ""
|
||||
p.append(f'<text x="{PAD+6}" y="62" font-size="12" fill="{accent}">'
|
||||
p.append(f'<text x="{PAD+50}" y="62" font-size="11" fill="{glint}">'
|
||||
f'No.{card.dex:03d} · {_esc(card.label)}{holo}</text>')
|
||||
p.append(f'<text x="{W-PAD-6}" y="40" text-anchor="end" font-size="15" '
|
||||
f'fill="#f4f6f8" font-weight="bold">PWR {card.power}</text>')
|
||||
# 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'<text x="{W-PAD-8}" y="40" text-anchor="end" font-size="15" '
|
||||
f'fill="#ffffff" font-weight="bold">PWR {card.power}</text>')
|
||||
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'<rect x="{bx-w}" y="50" width="{w}" height="20" rx="10" fill="{tc}"/>')
|
||||
p.append(f'<text x="{bx-w/2}" y="64" text-anchor="middle" font-size="12" '
|
||||
p.append(f'<rect x="{bx-w}" y="50" width="{w}" height="18" rx="9" fill="{tcc}" '
|
||||
f'stroke="{glint}" stroke-width="0.75"/>')
|
||||
p.append(f'<text x="{bx-w/2}" y="63" text-anchor="middle" font-size="11" '
|
||||
f'fill="#0c0f14" font-weight="bold">{_esc(ty)}</text>')
|
||||
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'<rect x="{ART_X}" y="{ART_Y}" width="{ART_W}" height="{ART_H}" rx="12" '
|
||||
f'fill="#0c0f14" stroke="{border}" stroke-width="3"/>')
|
||||
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'<rect x="{ART_X}" y="{ART_Y+ART_H-26}" width="{ART_W}" height="26" '
|
||||
f'fill="{border}" opacity="0.85"/>')
|
||||
p.append(f'<text x="{W/2}" y="{ART_Y+ART_H-8}" text-anchor="middle" font-size="14" '
|
||||
f'fill="#0c0f14" font-weight="bold" letter-spacing="2">'
|
||||
f'{card.rarity.upper()}</text>')
|
||||
# status pill (top-left corner of the art window)
|
||||
sw = 22 + len(st_txt) * 7
|
||||
p.append(f'<rect x="{ART_X+8}" y="{ART_Y+8}" width="{sw}" height="20" rx="10" '
|
||||
f'fill="{st_col}" opacity="0.92"/>')
|
||||
p.append(f'<text x="{ART_X+8+sw/2}" y="{ART_Y+22}" text-anchor="middle" '
|
||||
f'font-size="11" fill="#0c0f14" font-weight="bold">{_esc(st_txt)}</text>')
|
||||
# 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}</text>')
|
||||
p.append(f'<rect x="{PAD+50}" y="{y}" width="{bw-90}" height="16" rx="8" '
|
||||
f'fill="#2a2e36"/>')
|
||||
fillw = max(6, (bw - 90) * val / maxstat)
|
||||
fillw = max(6, (bw - 90) * val / 255)
|
||||
p.append(f'<rect x="{PAD+50}" y="{y}" width="{fillw:.0f}" height="16" rx="8" '
|
||||
f'fill="{border}"/>')
|
||||
p.append(f'<text x="{W-PAD-8}" y="{y+13}" text-anchor="end" font-size="13" '
|
||||
f'fill="#f4f6f8">{val}</text>')
|
||||
# footer: BST + flavor
|
||||
fy = y0 + 6 * 30 + 6
|
||||
p.append(f'<text x="{PAD+8}" y="{fy+8}" font-size="12" fill="{accent}" '
|
||||
f'font-weight="bold">BST {bst}</text>')
|
||||
for j, ln in enumerate(_wrap(card.flavor, 58)):
|
||||
p.append(f'<text x="{PAD+8}" y="{fy+26+j*15}" font-size="11" '
|
||||
f'fill="#9aa0a8"><tspan>{_esc(ln)}</tspan></text>')
|
||||
|
||||
|
||||
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'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'xmlns:xlink="http://www.w3.org/1999/xlink" width="{W}" height="{H}" '
|
||||
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Arial, sans-serif">']
|
||||
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'<text x="{PAD+8}" y="{y0}" font-size="13" fill="{accent}" '
|
||||
f'font-weight="bold" letter-spacing="2">VM DOSSIER</text>')
|
||||
p.append(f'<line x1="{PAD+8}" y1="{y0+8}" x2="{W-PAD-8}" y2="{y0+8}" '
|
||||
f'stroke="{border}" stroke-width="1.5" opacity="0.6"/>')
|
||||
desc = card.purpose or card.flavor or "an unknown sandbox"
|
||||
for j, ln in enumerate(_wrap(desc, 46, maxlines=8)):
|
||||
p.append(f'<text x="{PAD+8}" y="{y0+30+j*22}" font-size="15" '
|
||||
f'fill="#d6dae0">{_esc(ln)}</text>')
|
||||
p.append(f'<text x="{PAD+8}" y="{H-46}" font-size="12" fill="{accent}" '
|
||||
f'font-weight="bold">PWR {card.power} · BST {bst} · '
|
||||
f'{card.rarity}</text>')
|
||||
p.append(f'<text x="{PAD+26}" y="{H-30}" font-size="11" fill="#7f868f">'
|
||||
f'tap to flip → stats</text>')
|
||||
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'<text x="{PAD+8}" y="{fy}" font-size="13" fill="{accent}" '
|
||||
f'font-weight="bold" letter-spacing="2">VALUE BREAKDOWN</text>')
|
||||
order = ["completeness", "reusability", "richness", "pedigree", "heft"]
|
||||
for k, sk in enumerate(order):
|
||||
v = card.subscores.get(sk, 0)
|
||||
p.append(f'<text x="{PAD+8}" y="{fy+24+k*20}" font-size="12" '
|
||||
f'fill="#b8bdc4">{sk.capitalize():<14}</text>')
|
||||
p.append(f'<text x="{W-PAD-8}" y="{fy+24+k*20}" text-anchor="end" '
|
||||
f'font-size="12" fill="#f4f6f8">{v}</text>')
|
||||
gy = fy + 24 + 5 * 20 + 18
|
||||
p.append(f'<text x="{PAD+8}" y="{gy}" font-size="13" fill="{accent}" '
|
||||
f'font-weight="bold">BST {bst}</text>')
|
||||
for j, ln in enumerate(_wrap(card.flavor, 52, maxlines=3)):
|
||||
p.append(f'<text x="{PAD+8}" y="{gy+22+j*16}" font-size="11" '
|
||||
f'fill="#9aa0a8">{_esc(ln)}</text>')
|
||||
p.append(f'<text x="{W-PAD-26}" y="{H-30}" text-anchor="end" font-size="11" '
|
||||
f'fill="#7f868f">← tap to flip</text>')
|
||||
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'<text x="{PAD+8}" y="{fy+8}" font-size="12" fill="{accent}" '
|
||||
f'font-weight="bold">BST {bst}</text>')
|
||||
for j, ln in enumerate(_wrap(card.flavor, 58)):
|
||||
p.append(f'<text x="{PAD+8}" y="{fy+26+j*15}" font-size="11" '
|
||||
f'fill="#9aa0a8">{_esc(ln)}</text>')
|
||||
p.append("</svg>")
|
||||
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'<div class="card">{render_card_svg(c, art_uri)}</div>')
|
||||
if flip:
|
||||
front = render_card_svg(c, art_uri, face="front")
|
||||
back = render_card_svg(c, art_uri, face="back")
|
||||
cells.append(
|
||||
f'<div class="flip-card" tabindex="0"><div class="flip-inner">'
|
||||
f'<div class="flip-face flip-front">{front}</div>'
|
||||
f'<div class="flip-face flip-back">{back}</div>'
|
||||
f'</div></div>')
|
||||
else:
|
||||
cells.append(f'<div class="card">{render_card_svg(c, art_uri)}</div>')
|
||||
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 = (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<style>'
|
||||
'body{margin:0;background:#0a0c10;color:#e8eaed;'
|
||||
'font-family:DejaVu Sans,Arial,sans-serif}'
|
||||
'.hd{padding:28px 36px 8px}'
|
||||
'.hd h1{margin:0;font-size:30px}'
|
||||
'.hd p{margin:6px 0 0;color:#9aa0a8;font-size:15px}'
|
||||
flip_css = (
|
||||
'.grid{display:grid;grid-template-columns:repeat(4,500px);gap:30px;'
|
||||
'padding:24px 36px 60px;justify-content:center}'
|
||||
'.flip-card{width:500px;height:700px;perspective:1600px;cursor:pointer;'
|
||||
'outline:none}'
|
||||
'.flip-inner{position:relative;width:100%;height:100%;'
|
||||
'transition:transform .7s cubic-bezier(.2,.7,.2,1);transform-style:preserve-3d}'
|
||||
'.flip-card:hover .flip-inner,.flip-card:focus .flip-inner,'
|
||||
'.flip-card.flipped .flip-inner{transform:rotateY(180deg)}'
|
||||
'.flip-face{position:absolute;inset:0;backface-visibility:hidden;'
|
||||
'border-radius:22px;box-shadow:0 10px 36px rgba(0,0,0,.65);overflow:hidden}'
|
||||
'.flip-face svg{width:500px;height:700px;display:block}'
|
||||
'.flip-back{transform:rotateY(180deg)}'
|
||||
'@media(max-width:2100px){.grid{grid-template-columns:repeat(3,500px)}}')
|
||||
static_css = (
|
||||
'.grid{display:grid;grid-template-columns:repeat(4,500px);gap:26px;'
|
||||
'padding:24px 36px 40px;justify-content:center}'
|
||||
'.card svg{width:500px;height:700px;display:block;'
|
||||
'border-radius:22px;box-shadow:0 8px 30px rgba(0,0,0,.6)}'
|
||||
'@media(max-width:2100px){.grid{grid-template-columns:repeat(3,500px)}}'
|
||||
'@media(max-width:2100px){.grid{grid-template-columns:repeat(3,500px)}}')
|
||||
sub = ('hover or tap a card to flip → stats' if flip
|
||||
else f'{len(cards)} saved VMs · {spread}')
|
||||
script = ('<script>document.querySelectorAll(".flip-card").forEach(c=>'
|
||||
'c.addEventListener("click",()=>c.classList.toggle("flipped")));'
|
||||
'</script>') if flip else ''
|
||||
html = (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<style>'
|
||||
'body{margin:0;background:radial-gradient(1200px 700px at 50% -10%,'
|
||||
'#161b24,#0a0c10 60%);color:#e8eaed;'
|
||||
'font-family:DejaVu Sans,Arial,sans-serif}'
|
||||
'.hd{padding:28px 36px 8px}'
|
||||
'.hd h1{margin:0;font-size:30px;letter-spacing:1px}'
|
||||
'.hd p{margin:6px 0 0;color:#9aa0a8;font-size:15px}'
|
||||
f'{flip_css if flip else static_css}'
|
||||
'</style></head><body>'
|
||||
f'<div class="hd"><h1>hack-house VM Cardex</h1>'
|
||||
f'<p>{len(cards)} saved VMs · {spread}</p></div>'
|
||||
f'<p>{len(cards)} saved VMs · {spread}{" — " + sub if flip else ""}</p></div>'
|
||||
f'<div class="grid">{"".join(cells)}</div>'
|
||||
'</body></html>')
|
||||
f'{script}</body></html>')
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user