reikhelm/combat/tools/portraits.py
Parley Hatch 902a233229 feat(combat): playable browser front-end + AI monster portraits
A real-time consumer of combat-core (the front-end the spec designs for, §14):
pick a foe, manage your single Vigor pool, try not to spend your survival.

- combat-core: a HumanController (input-driven, spec §5) behind a shared intent
  cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can
  drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g.
  auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green.
- combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the
  player's ability list, and a flavorful MonsterAI that uses each monster's
  signature kit (the wraith curses your ceiling) on a coin-flip so big hits space
  into a readable duel. Own workspace member, kept out of the default host build.
- combat-web: an atmospheric battler. The signature dual Vigor bar shows fill,
  the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue
  zone in one bar — the squeeze made legible. Floating damage, cast telegraphs,
  combat log, result overlay. Verified end-to-end in a real browser.
- tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image
  stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed.
- Added skeleton / troll / wraith monster stat blocks; bumped the duelist's
  durability so fights are readable (even-con ~30s) and active play beats every
  monster while passive auto-attacking loses the hard ones. Re-validated the sim:
  the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34%
  at even con). README findings updated to match.

Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a
3s active-play victory over the Skeleton Knight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:46:47 -06:00

151 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Generate monster portraits for the combat front-end via the LAN Z-Image stack.
Reuses the proven Z-Image-Turbo text-to-image path from
``../../tools/comfy-spike/comfy.py`` (same unet/clip/vae + ModelSamplingAuraFlow
shift + KSampler), minus the depth-ControlNet nodes — these are free text→image
portraits, not depth-conditioned renders.
Pure stdlib (no pip). Writes PNGs to ``combat-web/portraits/<id>.png``.
python3 tools/portraits.py [--url http://192.168.1.26:8188] [--size 768]
[--only wraith,golem] [--seed 7]
"""
import argparse
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
REPO_COMBAT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(REPO_COMBAT, "combat-web", "portraits")
# Z-Image-Turbo model names (from the proven comfy.py zrender defaults).
UNET = "z_image_turbo_bf16.safetensors"
CLIP = "qwen_3_4b.safetensors"
CLIP_TYPE = "qwen_image"
VAE = "ae.safetensors"
# A byte-identical style anchor across every portrait keeps lighting + palette
# coherent so the roster reads as one bestiary, not seven unrelated images.
STYLE = (
"dark fantasy character portrait, head and shoulders bust, centered, facing the viewer, "
"dramatic warm torchlight, deep black background, volumetric haze, painterly digital art, "
"ominous, highly detailed, sharp focus, "
)
NEGATIVE = (
"text, watermark, signature, letters, words, ui, hud, frame, border, blurry, "
"deformed, extra limbs, duplicate, low quality, modern, cartoon, cute, bright, washed out"
)
# id → the creature description. ids match the config stat_blocks so the front-end
# can map a chosen monster to its portrait.
ROSTER = {
"duelist": "a grizzled human duelist, worn leather armor and a steel pauldron, scarred jaw, "
"short beard, steady determined eyes, a sheathed blade, a hardened adventurer",
"rat": "an enormous dire rat, matted greasy brown fur, gleaming red eyes, bared yellow fangs, "
"twitching whiskers, ragged ears, a diseased sewer beast",
"skeleton": "an undead skeleton knight, pitted rusted plate armor, cold blue flames in hollow "
"eye sockets, cracked yellowed bone, a notched broadsword, grave-rot and cobwebs",
"golem": "a massive ancient stone golem, cracked granite and basalt body, molten orange runes "
"glowing deep in the fissures, moss and lichen, blunt boulder fists, a slow construct",
"troll": "a hulking cave troll, warty grey-green hide, hunched massive shoulders, a tusked "
"underbite, small cruel eyes, knotted muscle, club-like fists, brutish and dripping",
"wraith": "a spectral wraith, tattered black hooded burial shroud billowing, a hollow dark void "
"where a face should be, two faint points of cold blue light, wisps of freezing mist, "
"skeletal grasping hands reaching forward, incorporeal and dreadful",
"battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet "
"arcane lightning coiling around raised bony hands, glowing runic sigils, malice",
}
def post(base, path, payload):
req = urllib.request.Request(
base + path, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"}, method="POST")
return json.loads(urllib.request.urlopen(req, timeout=30).read())
def get(base, path, timeout=120):
with urllib.request.urlopen(base + path, timeout=timeout) as r:
return r.read()
def t2i_graph(prompt, negative, size, seed, prefix):
"""Z-Image-Turbo text-to-image graph (no ControlNet)."""
return {
"unet": {"class_type": "UNETLoader", "inputs": {"unet_name": UNET, "weight_dtype": "default"}},
"clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": CLIP, "type": CLIP_TYPE}},
"vae": {"class_type": "VAELoader", "inputs": {"vae_name": VAE}},
"shift": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["unet", 0], "shift": 3.0}},
"pos": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["clip", 0]}},
"neg": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["clip", 0]}},
"latent": {"class_type": "EmptySD3LatentImage", "inputs": {"width": size, "height": size, "batch_size": 1}},
"ks": {"class_type": "KSampler", "inputs": {
"seed": seed, "steps": 8, "cfg": 1.0, "sampler_name": "res_multistep",
"scheduler": "simple", "denoise": 1.0, "model": ["shift", 0],
"positive": ["pos", 0], "negative": ["neg", 0], "latent_image": ["latent", 0]}},
"dec": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["vae", 0]}},
"save": {"class_type": "SaveImage", "inputs": {"images": ["dec", 0], "filename_prefix": prefix}},
}
def render_one(base, mid, prompt, size, seed, wait):
graph = t2i_graph(STYLE + prompt, NEGATIVE, size, seed, f"portrait_{mid}")
try:
pid = post(base, "/prompt", {"prompt": graph})["prompt_id"]
except urllib.error.HTTPError as e:
print(f" {mid}: /prompt rejected:\n{e.read().decode()[:1500]}")
return False
deadline = time.time() + wait
while time.time() < deadline:
hist = json.loads(get(base, "/history/" + pid))
if pid in hist:
entry = hist[pid]
if entry.get("status", {}).get("status_str") == "error":
print(f" {mid}: execution error:\n{json.dumps(entry['status'].get('messages'), indent=1)[:1500]}")
return False
for node_out in entry.get("outputs", {}).values():
for im in node_out.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"],
"subfolder": im.get("subfolder", ""),
"type": im.get("type", "output")})
blob = get(base, "/view?" + q)
out = os.path.join(OUT_DIR, f"{mid}.png")
with open(out, "wb") as f:
f.write(blob)
print(f" {mid}: -> {os.path.relpath(out, REPO_COMBAT)} ({len(blob)//1024} KB)")
return True
print(f" {mid}: finished but no image")
return False
time.sleep(1.5)
print(f" {mid}: timed out after {wait}s")
return False
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--url", default="http://192.168.1.26:8188")
ap.add_argument("--size", type=int, default=768)
ap.add_argument("--seed", type=int, default=7, help="base seed; each monster offsets from it")
ap.add_argument("--only", default="", help="comma-separated ids to (re)render")
ap.add_argument("--wait", type=int, default=180)
a = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
base = a.url.rstrip("/")
only = {x.strip() for x in a.only.split(",") if x.strip()} or None
roster = {k: v for k, v in ROSTER.items() if only is None or k in only}
print(f"rendering {len(roster)} portraits at {a.size}px -> {os.path.relpath(OUT_DIR, REPO_COMBAT)}")
ok = 0
for i, (mid, prompt) in enumerate(roster.items()):
if render_one(base, mid, prompt, a.size, a.seed + i * 1000, a.wait):
ok += 1
print(f"done: {ok}/{len(roster)} portraits")
if __name__ == "__main__":
main()