Shade sprite module: faction generation, asset scanning, TextureCache

Extends the shade_sprite module (for merchant-shade.itch.io character
sprite sheets) with procedural faction generation and asset management:

- FactionGenerator: seed-based faction recipes with Biome, Element,
  Aesthetic, and RoleType enums for thematic variety
- AssetLibrary: filesystem scanner that discovers and categorizes
  layer PNGs by type (skins, clothes, hair, etc.)
- TextureCache: avoids redundant disk I/O when building many variants
- CharacterAssembler: HSL shift documentation, method improvements
- Demo expanded to 6 interactive scenes (animation viewer, HSL recolor,
  character gallery, faction generator, layer compositing, equipment)
- EVALUATION.md: 7DRL readiness assessment of the full module
- 329-line faction generation test suite

Assets themselves are not included -- sprite sheets are external
dependencies, some under commercial license.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-20 23:17:24 -05:00
commit 80e14163f9
7 changed files with 2471 additions and 314 deletions

264
shade_sprite/EVALUATION.md Normal file
View file

@ -0,0 +1,264 @@
# shade_sprite Evaluation Report
**Date:** 2026-02-17
**Purpose:** 7DRL readiness assessment of the character assembly and animation system
---
## Module Structure
```
shade_sprite/
__init__.py - Clean public API, __all__ exports
formats.py - SheetFormat definitions, Direction enum, AnimFrame/AnimDef dataclasses
animation.py - AnimatedSprite state machine
assembler.py - CharacterAssembler (layer compositing + HSL recoloring)
demo.py - 6-scene interactive demo
```
---
## Component Assessment
### 1. CharacterAssembler
**Status: FUNCTIONAL, with caveats**
**What it does:**
- Accepts N layer PNGs added bottom-to-top via `add_layer(path, hue_shift, sat_shift, lit_shift)`
- Loads each as an `mcrfpy.Texture`, applies HSL shift if any non-zero values
- Composites all layers via `mcrfpy.Texture.composite()` (alpha blending)
- Returns a single `mcrfpy.Texture` ready for use with `mcrfpy.Sprite`
- Method chaining supported (`asm.add_layer(...).add_layer(...)`)
- `clear()` resets layers for reuse
**What it supports:**
- Loading separate layer PNGs (body, armor, weapon, etc.): **YES**
- Compositing them back-to-front: **YES** (via Texture.composite)
- Recoloring via HSL shift: **YES** (per-layer hue/sat/lit adjustments)
- Palette swap: **NO** (only continuous HSL rotation, not indexed palette remapping)
**Limitations:**
- No layer visibility toggle (must rebuild without the layer)
- No per-layer offset/transform (all layers must be pixel-aligned same-size sheets)
- No caching — every `build()` call reloads textures from disk
- No export/save — composite exists only as an in-memory mcrfpy.Texture
- No layer ordering control beyond insertion order
### 2. AnimatedSprite
**Status: COMPLETE AND WELL-TESTED**
**What it supports:**
- 8-directional facing (N/S/E/W/NE/NW/SE/SW): **YES** via `Direction` IntEnum
- 4-directional with diagonal rounding: **YES** (SW->S, NE->N, etc.)
- 1-directional (for slimes etc.): **YES**
- Programmatic direction setting: **YES** (`anim.direction = Direction.E`)
- Animation states: **YES** — any named animation from the format's dict
**Available animations (PUNY_29 format, 10 total):**
| Animation | Type | Frames | Behavior |
|-----------|------|--------|----------|
| idle | loop | 2 | Default start state |
| walk | loop | 4 | Movement |
| slash | one-shot | 4 | Melee attack, chains to idle |
| bow | one-shot | 4 | Ranged attack, chains to idle |
| thrust | one-shot | 4 | Spear/polearm, chains to idle |
| spellcast | one-shot | 4 | Magic attack, chains to idle |
| hurt | one-shot | 3 | Damage taken, chains to idle |
| death | one-shot | 3 | Death, no chain (stays on last frame) |
| dodge | one-shot | 4 | Evasion, chains to idle |
| item_use | one-shot | 1 | Item activation, chains to idle |
**PUNY_24 format (free pack, 8 animations):** Same minus dodge and item_use.
**Programmatic control:**
- `play("walk")` — start named animation, resets frame counter
- `tick(dt_ms)` — advance clock, auto-advances frames
- `set_direction(Direction.E)` — change facing, updates sprite immediately
- `finished` property — True when one-shot completes without chain
- Animation chaining — one-shot animations auto-transition to `chain_to`
**Architecture:** Wraps an `mcrfpy.Sprite` and updates its `sprite_index` property. Requires external `tick()` calls (typically from an `mcrfpy.Timer`).
### 3. Sprite Sheet Layout
**Status: WELL-DEFINED**
**Standard layout:** Rows = directions, Columns = animation frames.
| Format | Tile Size | Columns | Rows | Directions | Sheet Pixels |
|--------|-----------|---------|------|------------|--------------|
| PUNY_29 | 32x32 | 29 | 8 | 8 | 928x256 |
| PUNY_24 | 32x32 | 24 | 8 | 8 | 768x256 |
| CREATURE_RPGMAKER | 24x24 | 3 | 4 | 4 | 72x96 |
| SLIME | 32x32 | 15 | 1 | 1 | 480x32 |
**Auto-detection:** `detect_format(width, height)` maps pixel dimensions to format. Works for all 4 formats.
**Consistency:** All formats share the same `SheetFormat` abstraction. The `sprite_index(col, direction)` method computes flat tile indices consistently: `row * columns + col`.
### 4. Demo (demo.py)
**Status: FUNCTIONAL (6 scenes)**
**Runs without errors:** YES. Tested both headless (`--headless --exec`) and confirmed no Python exceptions. The KeyError from session 38f29994 has been resolved — the current code uses `mcrfpy.Key.NUM_1` etc. (not string-based lookups).
**Scene inventory:**
| Scene | Key | Content | Status |
|-------|-----|---------|--------|
| Animation Viewer | 1 | Cycle sheets/anims/directions, compass layout, slime | Complete |
| HSL Recolor | 2 | Live hue/sat/lit adjustment, 6-step hue wheel | Complete |
| Character Gallery | 3 | 5-column grid of all sheets, shared anim/dir control | Complete |
| Faction Generator | 4 | 4 random factions, 5 hue-shifted characters each | Complete |
| Layer Compositing | 5 | Base + overlay + composite side-by-side, hue row | Complete |
| Equipment Customizer | 6 | 3-slot system, procedural variant generation | Complete |
**Asset requirement:** Demo looks for PNGs in three search paths. The `~/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/Puny-Characters/` path resolves on this machine. Without assets, all scenes show a "no assets found" fallback message.
**Keyboard controls verified:** All 22 Key enum references (NUM_1-6, Q/E, A/D, W/S, LEFT/RIGHT, UP/DOWN, Z/X, TAB, T, R, SPACE) confirmed valid against current mcrfpy.Key enum.
### 5. Tests
**Status: ALL 25 PASS**
```
=== Format Definitions === 8 tests (dimensions, columns, rows, directions, animation counts, chaining)
=== Format Detection === 5 tests (all 4 formats + unknown)
=== Direction === 8 tests (enum values, 8-dir mapping, 4-dir mapping with diagonal rounding)
=== Sprite Index === 5 tests (flat index computation for PUNY_29 and SLIME)
=== AnimatedSprite === 14 tests (creation, play, tick timing, direction change, one-shot chaining, death, error handling)
=== CharacterAssembler === 2 tests (creation, empty build error)
```
**Test coverage gaps:**
- CharacterAssembler `build()` with actual layers is NOT tested (only error case tested)
- HSL shift integration is NOT tested (requires real texture data)
- No test for Texture.composite() through the assembler
- No visual regression tests (screenshots)
- No performance/memory tests for bulk texture generation
### 6. Assets
**Status: EXTERNAL DEPENDENCY, NOT IN REPO**
**Available on this machine (not in McRogueFace repo):**
*Free pack* (`Puny-Characters/`, 768x256 PUNY_24):
- 19 pre-composed character sheets (Warrior, Soldier, Archer, Mage, Human-Soldier, Human-Worker, Orc variants)
- 1 Character-Base.png (body-only layer)
- 1 Slime.png (480x32 SLIME format)
- 4 Environment tiles
*Paid pack* (`PUNY_CHARACTERS_v2.1/`, 928x256 PUNY_29):
- 8 individual layer categories: Skins (14 variants), Shoes, Clothes (7 body types x colors), Gloves, Hairstyle, Eyes, Headgears, Add-ons
- Pre-made composite sheets organized by race (Humans, Elves, Dwarves, Orcs, etc.)
- Photoshop/GIMP source files
- Tools (deleter overlays, weapon overlayer)
**The free pack has ONE layer file** (Character-Base.png) suitable for compositing. True multi-layer assembly requires the paid PUNY_CHARACTERS_v2.1 pack.
---
## 7DRL Gap Analysis
### Gap 1: Procedural Faction Generation
**Current capability:** Scene 4 (Faction Generator) demonstrates hue-shifting pre-composed sheets to create "factions." Scene 6 (Equipment Customizer) shows multi-layer compositing with per-slot HSL control.
**What works for 7DRL:**
- Applying a faction hue to existing character sheets creates visually distinct groups
- HSL shift covers hue (color identity), saturation (vibrancy), and lightness (dark/light variants)
- Random hue selection per faction produces reasonable visual variety
**What's missing:**
- **Species variation via layer swap:** The assembler supports this IF you have separate layer PNGs (the paid pack has them, the free pack does not). No code exists to enumerate available layers by category (skins, clothes, etc.) or randomly select from each category.
- **No "faction recipe" data structure:** There's no serializable faction definition that says "skin=Orc2, clothes=VikingBody-Red+hue180, hair=none." The demo builds composites imperatively.
- **No palette-indexed recoloring:** HSL shift rotates all hues uniformly. A red-and-blue character shifted +120 degrees becomes green-and-purple. True faction coloring would need selective recoloring (e.g., only shift the clothing layer, not the skin).
**Verdict:** Functional for simple hue-based faction differentiation. For species + equipment variety, you need the paid layer PNGs and a layer-category enumeration helper.
### Gap 2: Bulk Generation
**Can you generate 2-4 character variants per faction on a virtual tile sheet?**
**Current capability:** Each `assembler.build()` call produces a separate `mcrfpy.Texture`. There is no API to pack multiple characters onto a single tile sheet.
**What works:**
- Generate N separate textures (one per character variant), each assigned to a separate `mcrfpy.Sprite`
- The demo already does this: Scene 4 creates 4 factions x 5 characters = 20 separate textures
- Each texture is runtime-only (not saved to disk)
**What's missing:**
- **No tile-sheet packer:** Cannot combine 4 character textures into a single 4-wide sprite sheet for use with a single Entity on a Grid
- **Texture.from_bytes could theoretically be used** to manually blit multiple characters into one sheet, but this would require reading back pixel data (not currently exposed)
- **No Texture.read_pixels() or similar** to extract raw bytes from an existing texture
**Verdict:** For 7DRL, the simplest approach is one Texture per character variant (each gets its own Sprite/Entity). This works but means more GPU texture objects. A tile-sheet packer would be a nice-to-have but is not blocking.
### Gap 3: Runtime Integration
**Can McRogueFace entities use assembled sprites at runtime?**
**Status: YES, fully runtime**
- `CharacterAssembler.build()` returns an `mcrfpy.Texture` immediately usable with `mcrfpy.Sprite`
- `AnimatedSprite` wraps any `mcrfpy.Sprite` and drives its `sprite_index`
- Timer-based `tick()` integrates with the game loop
- The entire pipeline (load layers -> HSL shift -> composite -> animate) runs at runtime
- No build-time step required
**Integration pattern (from demo.py):**
```python
# Create composite texture at runtime
asm = CharacterAssembler(PUNY_24)
asm.add_layer("Character-Base.png")
asm.add_layer("Warrior-Red.png", hue_shift=120.0)
tex = asm.build("faction_warrior")
# Use with sprite
sprite = mcrfpy.Sprite(texture=tex, pos=(x, y), scale=2.0)
scene.children.append(sprite)
# Animate
anim = AnimatedSprite(sprite, PUNY_24, Direction.S)
anim.play("walk")
# Drive from timer
def tick(timer, runtime):
anim.tick(timer.interval)
mcrfpy.Timer("anim", tick, 50)
```
---
## Summary Scorecard
| Component | Status | 7DRL Ready? |
|-----------|--------|-------------|
| AnimatedSprite | Complete, well-tested | YES |
| Direction system (8-dir) | Complete | YES |
| Animation definitions (10 states) | Complete | YES |
| Format auto-detection | Complete | YES |
| CharacterAssembler (compositing) | Functional | YES (with paid pack layers) |
| HSL recoloring | Functional | YES |
| Demo | 6 scenes, no errors | YES |
| Unit tests | 25/25 pass | YES (coverage gaps in assembler) |
| Faction generation | Proof-of-concept in demo | PARTIAL — needs recipe/category system |
| Bulk sheet packing | Not implemented | NO — use 1 texture per character |
| Assets in repo | Not present | NO — external dependency |
| Layer category enumeration | Not implemented | NO — would need helper for paid pack |
## Recommendations for 7DRL
1. **Copy needed assets into the game project's assets directory** (or symlink). Don't rely on hardcoded paths to the 7DRL2026 project.
2. **For faction generation with the free pack:** Hue-shift pre-composed sheets. This gives color variety but not equipment/species variety. Sufficient for a jam game.
3. **For faction generation with the paid pack:** Build a small helper that scans the layer directories by category (Skins/, Clothes/, etc.) and randomly picks one from each. The CharacterAssembler already handles the compositing — you just need the selection logic.
4. **Don't build a tile-sheet packer.** One texture per character is fine for 7DRL scope. The engine handles many textures without issue.
5. **Add a texture cache in CharacterAssembler** if generating many variants. Currently every `build()` reloads PNGs from disk. A simple dict cache of `path -> Texture` would avoid redundant I/O.
6. **The demo is ready as a showcase/testing tool.** All 6 scenes work with keyboard navigation. It demonstrates every capability the module offers.

View file

@ -29,6 +29,14 @@ For layered characters:
assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0) assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0)
assembler.add_layer("hair/M-Hairstyle1-Black.png") assembler.add_layer("hair/M-Hairstyle1-Black.png")
texture = assembler.build("my_character") texture = assembler.build("my_character")
For procedural factions:
from shade_sprite import FactionGenerator, AssetLibrary
lib = AssetLibrary()
gen = FactionGenerator(seed=42, library=lib)
recipe = gen.generate()
textures = recipe.build_role_textures(assembler)
""" """
from .formats import ( from .formats import (
@ -44,12 +52,23 @@ from .formats import (
detect_format, detect_format,
) )
from .animation import AnimatedSprite from .animation import AnimatedSprite
from .assembler import CharacterAssembler from .assembler import CharacterAssembler, TextureCache
from .assets import AssetLibrary, LayerFile
from .factions import (
FactionRecipe,
FactionGenerator,
RoleDefinition,
Biome,
Element,
Aesthetic,
RoleType,
)
__all__ = [ __all__ = [
# Core classes # Core classes
"AnimatedSprite", "AnimatedSprite",
"CharacterAssembler", "CharacterAssembler",
"TextureCache",
# Format definitions # Format definitions
"Direction", "Direction",
"AnimFrame", "AnimFrame",
@ -63,4 +82,15 @@ __all__ = [
"ALL_FORMATS", "ALL_FORMATS",
# Utilities # Utilities
"detect_format", "detect_format",
# Asset scanning
"AssetLibrary",
"LayerFile",
# Faction generation
"FactionRecipe",
"FactionGenerator",
"RoleDefinition",
"Biome",
"Element",
"Aesthetic",
"RoleType",
] ]

View file

@ -3,11 +3,63 @@
Uses the engine's Texture.composite() and texture.hsl_shift() methods to Uses the engine's Texture.composite() and texture.hsl_shift() methods to
build composite character textures from multiple layer PNG files, without build composite character textures from multiple layer PNG files, without
requiring PIL or any external Python packages. requiring PIL or any external Python packages.
HSL notes (from C++ investigation):
- tex.hsl_shift(h, s, l) always creates a NEW texture by copying all
pixels, converting RGB->HSL, applying shifts, converting back.
- Works on any texture: file-loaded, from_bytes, composite, or
previously shifted. Alpha is preserved; transparent pixels skipped.
- No engine-level caching exists -- repeated identical calls produce
separate texture objects. The TextureCache below avoids redundant
loads and shifts at the Python level.
""" """
import mcrfpy import mcrfpy
from .formats import PUNY_29, SheetFormat from .formats import PUNY_29, SheetFormat
class TextureCache:
"""Cache for loaded and HSL-shifted textures to avoid redundant disk I/O.
Keys are (path, hue_shift, sat_shift, lit_shift) tuples.
Call clear() to free all cached textures.
"""
def __init__(self):
self._cache = {}
def get(self, path, tile_w, tile_h, hue=0.0, sat=0.0, lit=0.0):
"""Load a texture, using cached version if available.
Args:
path: File path to the PNG
tile_w: Sprite tile width
tile_h: Sprite tile height
hue: Hue rotation in degrees
sat: Saturation adjustment
lit: Lightness adjustment
Returns:
mcrfpy.Texture
"""
key = (path, hue, sat, lit)
if key not in self._cache:
tex = mcrfpy.Texture(path, tile_w, tile_h)
if hue != 0.0 or sat != 0.0 or lit != 0.0:
tex = tex.hsl_shift(hue, sat, lit)
self._cache[key] = tex
return self._cache[key]
def clear(self):
"""Drop all cached textures."""
self._cache.clear()
def __len__(self):
return len(self._cache)
def __contains__(self, key):
return key in self._cache
class CharacterAssembler: class CharacterAssembler:
"""Build composite character sheets from layer files. """Build composite character sheets from layer files.
@ -16,13 +68,16 @@ class CharacterAssembler:
Args: Args:
fmt: SheetFormat describing the sprite dimensions (default: PUNY_29) fmt: SheetFormat describing the sprite dimensions (default: PUNY_29)
cache: Optional TextureCache for reusing loaded textures across
multiple build() calls. If None, a private cache is created.
""" """
def __init__(self, fmt=None): def __init__(self, fmt=None, cache=None):
if fmt is None: if fmt is None:
fmt = PUNY_29 fmt = PUNY_29
self.fmt = fmt self.fmt = fmt
self.layers = [] self.layers = []
self.cache = cache if cache is not None else TextureCache()
def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0): def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0):
"""Queue a layer PNG with optional HSL recoloring. """Queue a layer PNG with optional HSL recoloring.
@ -44,8 +99,8 @@ class CharacterAssembler:
def build(self, name="<composed>"): def build(self, name="<composed>"):
"""Composite all queued layers into a single Texture. """Composite all queued layers into a single Texture.
Loads each layer file, applies HSL shifts if any, then composites Loads each layer file (using the cache to avoid redundant disk reads
all layers bottom-to-top using alpha blending. and HSL computations), then composites all layers bottom-to-top.
Args: Args:
name: Optional name for the resulting texture name: Optional name for the resulting texture
@ -62,9 +117,7 @@ class CharacterAssembler:
textures = [] textures = []
for path, h, s, l in self.layers: for path, h, s, l in self.layers:
tex = mcrfpy.Texture(path, self.fmt.tile_w, self.fmt.tile_h) tex = self.cache.get(path, self.fmt.tile_w, self.fmt.tile_h, h, s, l)
if h != 0.0 or s != 0.0 or l != 0.0:
tex = tex.hsl_shift(h, s, l)
textures.append(tex) textures.append(tex)
if len(textures) == 1: if len(textures) == 1:

295
shade_sprite/assets.py Normal file
View file

@ -0,0 +1,295 @@
"""AssetLibrary - scan and enumerate Puny Characters layer assets by category.
Scans the paid Puny Characters v2.1 "Individual Spritesheets" directory tree
and builds an inventory of available layers organized by category. The
FactionGenerator uses this to know what's actually on disk rather than
hardcoding filenames.
Directory structure (paid pack):
PUNY CHARACTERS/Individual Spritesheets/
Layer 0 - Skins/ -> species skins (Human1, Orc1, etc.)
Layer 1 - Shoes/ -> shoe layers
Layer 2 - Clothes/ -> clothing by style subfolder
Layer 3 - Gloves/ -> glove layers
Layer 4 - Hairstyle/ -> hair by gender + facial hair
Layer 5 - Eyes/ -> eye color + eyelashes
Layer 6 - Headgears/ -> helmets/hats by class/culture
Layer 7 - Add-ons/ -> species-specific add-ons (ears, horns, etc.)
Tools/ -> deleter/overlay tools (not used for characters)
"""
import os
import re
from dataclasses import dataclass, field
# Layer directory names inside "Individual Spritesheets"
_LAYER_DIRS = {
"skins": "Layer 0 - Skins",
"shoes": "Layer 1 - Shoes",
"clothes": "Layer 2 - Clothes",
"gloves": "Layer 3 - Gloves",
"hairstyle": "Layer 4 - Hairstyle",
"eyes": "Layer 5 - Eyes",
"headgears": "Layer 6 - Headgears",
"addons": "Layer 7 - Add-ons",
}
# Known search paths for the paid pack's Individual Spritesheets directory
_PAID_PACK_SEARCH_PATHS = [
os.path.expanduser(
"~/Development/7DRL2026_Liber_Noster_jmccardle/"
"assets_sources/PUNY_CHARACTERS_v2.1/"
"PUNY CHARACTERS/Individual Spritesheets"
),
"assets/PUNY_CHARACTERS/Individual Spritesheets",
"../assets/PUNY_CHARACTERS/Individual Spritesheets",
]
@dataclass
class LayerFile:
"""A single layer PNG file with parsed metadata."""
path: str # Full path to the PNG
filename: str # Just the filename (e.g. "Human1.png")
name: str # Name without extension (e.g. "Human1")
category: str # Category key (e.g. "skins", "clothes")
subcategory: str # Subfolder within category (e.g. "Armour Body", "")
def _parse_species_from_skin(name):
"""Extract species name from a skin filename like 'Human1' -> 'Human'."""
match = re.match(r'^([A-Za-z]+?)(\d*)$', name)
if match:
return match.group(1)
return name
class AssetLibrary:
"""Scans and indexes Puny Characters layer assets by category.
Args:
base_path: Path to the "Individual Spritesheets" directory.
If None, searches known locations automatically.
"""
def __init__(self, base_path=None):
if base_path is None:
base_path = self._find_base_path()
self.base_path = base_path
self._layers = {} # category -> list[LayerFile]
self._species_cache = None
if self.base_path:
self._scan()
@staticmethod
def _find_base_path():
for p in _PAID_PACK_SEARCH_PATHS:
if os.path.isdir(p):
return p
return None
@property
def available(self):
"""True if the asset directory was found and scanned."""
return self.base_path is not None and len(self._layers) > 0
def _scan(self):
"""Walk the layer directories and build the inventory."""
for cat_key, dir_name in _LAYER_DIRS.items():
cat_dir = os.path.join(self.base_path, dir_name)
if not os.path.isdir(cat_dir):
continue
files = []
for root, _dirs, filenames in os.walk(cat_dir):
for fn in sorted(filenames):
if not fn.lower().endswith(".png"):
continue
full_path = os.path.join(root, fn)
# Subcategory = relative dir from the category root
rel = os.path.relpath(root, cat_dir)
subcat = "" if rel == "." else rel
name = fn[:-4] # strip .png
files.append(LayerFile(
path=full_path,
filename=fn,
name=name,
category=cat_key,
subcategory=subcat,
))
self._layers[cat_key] = files
# ---- Species (Skins) ----
@property
def species(self):
"""List of distinct species names derived from Skins/ filenames."""
if self._species_cache is None:
seen = {}
for lf in self._layers.get("skins", []):
sp = _parse_species_from_skin(lf.name)
if sp not in seen:
seen[sp] = True
self._species_cache = list(seen.keys())
return list(self._species_cache)
def skins_for(self, species):
"""Return LayerFile list for skins matching a species name.
Args:
species: Species name (e.g. "Human", "Orc", "Demon")
Returns:
list[LayerFile]: Matching skin layers
"""
return [lf for lf in self._layers.get("skins", [])
if _parse_species_from_skin(lf.name) == species]
# ---- Generic category access ----
def layers(self, category):
"""Return all LayerFiles for a category.
Args:
category: One of "skins", "shoes", "clothes", "gloves",
"hairstyle", "eyes", "headgears", "addons"
Returns:
list[LayerFile]
"""
return list(self._layers.get(category, []))
def subcategories(self, category):
"""Return distinct subcategory names within a category.
Args:
category: Category key
Returns:
list[str]: Sorted subcategory names (empty string for root files)
"""
subs = set()
for lf in self._layers.get(category, []):
subs.add(lf.subcategory)
return sorted(subs)
def layers_in(self, category, subcategory):
"""Return LayerFiles within a specific subcategory.
Args:
category: Category key
subcategory: Subcategory name (e.g. "Armour Body")
Returns:
list[LayerFile]
"""
return [lf for lf in self._layers.get(category, [])
if lf.subcategory == subcategory]
# ---- Convenience shortcuts ----
@property
def clothes(self):
"""All clothing layer files."""
return self.layers("clothes")
@property
def shoes(self):
"""All shoe layer files."""
return self.layers("shoes")
@property
def gloves(self):
"""All glove layer files."""
return self.layers("gloves")
@property
def hairstyles(self):
"""All hairstyle layer files (head hair + facial hair)."""
return self.layers("hairstyle")
@property
def eyes(self):
"""All eye layer files (eye color + eyelashes)."""
return self.layers("eyes")
@property
def headgears(self):
"""All headgear layer files."""
return self.layers("headgears")
@property
def addons(self):
"""All add-on layer files (species-specific ears, horns, etc.)."""
return self.layers("addons")
def addons_for(self, species):
"""Return add-ons compatible with a species.
Matches based on subcategory containing the species name
(e.g. "Orc Add-ons" for species "Orc").
Args:
species: Species name
Returns:
list[LayerFile]
"""
result = []
for lf in self._layers.get("addons", []):
# Match "Orc Add-ons" for "Orc", "Elf Add-ons" for "Elf", etc.
if species.lower() in lf.subcategory.lower():
result.append(lf)
return result
# ---- Headgear by class ----
def headgears_for_class(self, class_name):
"""Return headgears matching a combat class.
Args:
class_name: One of "melee", "range", "mage", "assassin"
or a culture like "japanese", "viking", "mongol", "french"
Returns:
list[LayerFile]
"""
# Map common names to subcategory prefixes
lookup = class_name.lower()
result = []
for lf in self._layers.get("headgears", []):
if lookup in lf.subcategory.lower():
result.append(lf)
return result
# ---- Clothes by style ----
def clothes_by_style(self, style):
"""Return clothing matching a style keyword.
Args:
style: Style keyword (e.g. "armour", "basic", "tunic", "viking")
Returns:
list[LayerFile]
"""
lookup = style.lower()
return [lf for lf in self._layers.get("clothes", [])
if lookup in lf.subcategory.lower()]
# ---- Summary ----
@property
def categories(self):
"""List of category keys that have at least one file."""
return [k for k in _LAYER_DIRS if self._layers.get(k)]
def summary(self):
"""Return a dict of category -> file count."""
return {k: len(v) for k, v in self._layers.items() if v}
def __repr__(self):
if not self.available:
return "AssetLibrary(unavailable)"
total = sum(len(v) for v in self._layers.values())
cats = len(self.categories)
return f"AssetLibrary({total} files in {cats} categories)"

File diff suppressed because it is too large Load diff

494
shade_sprite/factions.py Normal file
View file

@ -0,0 +1,494 @@
"""Faction generation system for procedural army/group creation.
A Faction is a top-level group with a species, biome, element, and aesthetic.
Each faction contains several Roles -- visually and mechanically distinct unit
types with unique appearances built from the faction's species layers.
Key design: clothes, hair, and skin hues are per-ROLE, not per-faction.
The faction defines species and aesthetic; roles define visual specifics.
Usage:
from shade_sprite.factions import FactionGenerator
from shade_sprite.assets import AssetLibrary
from shade_sprite.assembler import CharacterAssembler
lib = AssetLibrary()
gen = FactionGenerator(seed=42, library=lib)
recipe = gen.generate()
assembler = CharacterAssembler()
textures = recipe.build_role_textures(assembler)
# textures["melee_fighter"] -> mcrfpy.Texture
"""
import random
from dataclasses import dataclass, field
from enum import Enum
from .assets import AssetLibrary
# ---------------------------------------------------------------------------
# Domain enums
# ---------------------------------------------------------------------------
class Biome(Enum):
ICE = "ice"
SWAMP = "swamp"
GRASSLAND = "grassland"
SCRUBLAND = "scrubland"
FOREST = "forest"
class Element(Enum):
FIRE = "fire"
WATER = "water"
STONE = "stone"
AIR = "air"
class Aesthetic(Enum):
SLAVERY = "slavery"
MILITARISTIC = "militaristic"
COWARDLY = "cowardly"
FANATICAL = "fanatical"
class RoleType(Enum):
MELEE_FIGHTER = "melee_fighter"
RANGED_FIGHTER = "ranged_fighter"
SPELLCASTER = "spellcaster"
HEALER = "healer"
PET_RUSHER = "pet_rusher"
PET_FLANKER = "pet_flanker"
# ---------------------------------------------------------------------------
# Aesthetic -> role generation templates
# ---------------------------------------------------------------------------
# Each aesthetic defines which roles are generated and their relative counts.
# The generator picks from these to produce 3-5 roles per faction.
_AESTHETIC_ROLE_POOLS = {
Aesthetic.MILITARISTIC: [
RoleType.MELEE_FIGHTER,
RoleType.MELEE_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.HEALER,
],
Aesthetic.FANATICAL: [
RoleType.SPELLCASTER,
RoleType.SPELLCASTER,
RoleType.MELEE_FIGHTER,
RoleType.HEALER,
RoleType.PET_RUSHER,
],
Aesthetic.COWARDLY: [
RoleType.RANGED_FIGHTER,
RoleType.PET_RUSHER,
RoleType.PET_FLANKER,
RoleType.PET_FLANKER,
RoleType.HEALER,
],
Aesthetic.SLAVERY: [
RoleType.MELEE_FIGHTER,
RoleType.RANGED_FIGHTER,
RoleType.SPELLCASTER,
RoleType.PET_RUSHER,
RoleType.PET_FLANKER,
],
}
# Headgear class mapping for roles
_ROLE_HEADGEAR_CLASS = {
RoleType.MELEE_FIGHTER: "melee",
RoleType.RANGED_FIGHTER: "range",
RoleType.SPELLCASTER: "mage",
RoleType.HEALER: "mage",
RoleType.PET_RUSHER: None, # pets/allies don't get headgear
RoleType.PET_FLANKER: None,
}
# Clothing style preferences per role
_ROLE_CLOTHING_STYLES = {
RoleType.MELEE_FIGHTER: ["armour", "viking", "mongol"],
RoleType.RANGED_FIGHTER: ["basic", "french", "japanese"],
RoleType.SPELLCASTER: ["tunic", "basic"],
RoleType.HEALER: ["tunic", "basic"],
RoleType.PET_RUSHER: [], # naked or minimal
RoleType.PET_FLANKER: [],
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class RoleDefinition:
"""A visually and mechanically distinct unit type within a faction.
Attributes:
role_type: The combat/function role
species: Species name for this role (main or ally species)
skin_hue: Hue shift for the skin layer (degrees)
skin_sat: Saturation shift for the skin layer
skin_lit: Lightness shift for the skin layer
clothing_layers: List of (path, hue, sat, lit) tuples for clothing
headgear_layer: Optional (path, hue, sat, lit) for headgear
addon_layer: Optional (path, hue, sat, lit) for species add-ons
hair_layer: Optional (path, hue, sat, lit) for hairstyle
eye_layer: Optional (path, hue, sat, lit) for eyes
shoe_layer: Optional (path, hue, sat, lit) for shoes
glove_layer: Optional (path, hue, sat, lit) for gloves
is_ally: True if this role uses an ally species (not the main species)
"""
role_type: RoleType
species: str
skin_hue: float = 0.0
skin_sat: float = 0.0
skin_lit: float = 0.0
clothing_layers: list = field(default_factory=list)
headgear_layer: tuple = None
addon_layer: tuple = None
hair_layer: tuple = None
eye_layer: tuple = None
shoe_layer: tuple = None
glove_layer: tuple = None
is_ally: bool = False
@property
def label(self):
"""Human-readable label like 'melee_fighter (Human)'."""
ally_tag = " [ally]" if self.is_ally else ""
return f"{self.role_type.value} ({self.species}{ally_tag})"
@dataclass
class FactionRecipe:
"""A complete faction definition with species, aesthetic, and roles.
Attributes:
name: Display name for the faction
biome: The biome this faction inhabits
species: Main species name
element: Elemental affinity
aesthetic: Behavioral aesthetic
ally_species: List of ally species names
roles: List of RoleDefinitions
seed: The seed used to generate this faction
"""
name: str
biome: Biome
species: str
element: Element
aesthetic: Aesthetic
ally_species: list = field(default_factory=list)
roles: list = field(default_factory=list)
seed: int = 0
def build_role_textures(self, assembler):
"""Build one composite texture per role using CharacterAssembler.
Args:
assembler: A CharacterAssembler instance (format will be reused)
library: An AssetLibrary to resolve species -> skin paths.
If None, skin layers must already be absolute paths in
the role's clothing_layers.
Returns:
dict[str, mcrfpy.Texture]: role label -> texture
"""
import mcrfpy # deferred import for headless testing without engine
textures = {}
for role in self.roles:
assembler.clear()
# Skin layer -- look up from library by species
skin_path = role._skin_path
if skin_path:
assembler.add_layer(
skin_path, role.skin_hue, role.skin_sat, role.skin_lit)
# Shoe layer
if role.shoe_layer:
path, h, s, l = role.shoe_layer
assembler.add_layer(path, h, s, l)
# Clothing layers
for path, h, s, l in role.clothing_layers:
assembler.add_layer(path, h, s, l)
# Glove layer
if role.glove_layer:
path, h, s, l = role.glove_layer
assembler.add_layer(path, h, s, l)
# Add-on layer (species ears, horns, etc.)
if role.addon_layer:
path, h, s, l = role.addon_layer
assembler.add_layer(path, h, s, l)
# Hair layer
if role.hair_layer:
path, h, s, l = role.hair_layer
assembler.add_layer(path, h, s, l)
# Eye layer
if role.eye_layer:
path, h, s, l = role.eye_layer
assembler.add_layer(path, h, s, l)
# Headgear layer (on top)
if role.headgear_layer:
path, h, s, l = role.headgear_layer
assembler.add_layer(path, h, s, l)
tex_name = f"{self.name}_{role.label}".replace(" ", "_")
textures[role.label] = assembler.build(tex_name)
return textures
# ---------------------------------------------------------------------------
# Faction name generation
# ---------------------------------------------------------------------------
_FACTION_PREFIXES = [
"Iron", "Shadow", "Dawn", "Ember", "Frost",
"Vine", "Storm", "Ash", "Gold", "Crimson",
"Azure", "Jade", "Silver", "Night", "Sun",
"Bone", "Blood", "Thorn", "Dusk", "Star",
"Stone", "Flame", "Void", "Moon", "Rust",
]
_FACTION_SUFFIXES = [
"Guard", "Pact", "Order", "Clan", "Legion",
"Court", "Band", "Wardens", "Company", "Oath",
"Fleet", "Circle", "Hand", "Watch", "Speakers",
"Reavers", "Chosen", "Vanguard", "Covenant", "Fang",
"Spire", "Horde", "Shield", "Tide", "Crown",
]
# ---------------------------------------------------------------------------
# Generator
# ---------------------------------------------------------------------------
class FactionGenerator:
"""Deterministic faction generator driven by a seed and asset library.
Args:
seed: Integer seed for reproducible generation
library: AssetLibrary instance (if None, creates one with auto-detection)
"""
def __init__(self, seed, library=None):
self.seed = seed
self.library = library if library is not None else AssetLibrary()
self._rng = random.Random(seed)
def generate(self):
"""Produce a complete FactionRecipe with 3-5 roles.
Returns:
FactionRecipe with fully specified roles
"""
rng = self._rng
# Pick faction attributes
biome = rng.choice(list(Biome))
element = rng.choice(list(Element))
aesthetic = rng.choice(list(Aesthetic))
# Pick species
available_species = self.library.species if self.library.available else [
"Human", "Orc", "Demon", "Skeleton", "NightElf", "Cyclops"]
species = rng.choice(available_species)
# Pick 0-2 ally species (different from main)
other_species = [s for s in available_species if s != species]
n_allies = rng.randint(0, min(2, len(other_species)))
ally_species = rng.sample(other_species, n_allies) if n_allies > 0 else []
# Generate name
name = rng.choice(_FACTION_PREFIXES) + " " + rng.choice(_FACTION_SUFFIXES)
# Generate roles
role_pool = list(_AESTHETIC_ROLE_POOLS[aesthetic])
n_roles = rng.randint(3, min(5, len(role_pool)))
chosen_role_types = rng.sample(role_pool, n_roles)
# Base skin hue for the main species (random starting point)
base_skin_hue = rng.uniform(0, 360)
roles = []
for i, role_type in enumerate(chosen_role_types):
is_pet = role_type in (RoleType.PET_RUSHER, RoleType.PET_FLANKER)
# Determine species for this role
if is_pet and ally_species:
role_species = rng.choice(ally_species)
is_ally = True
else:
role_species = species
is_ally = is_pet and not ally_species
# Skin hue: small variation from base for same species
if role_species == species:
skin_hue = (base_skin_hue + rng.uniform(-15, 15)) % 360
else:
skin_hue = rng.uniform(0, 360)
skin_sat = rng.uniform(-0.15, 0.15)
skin_lit = rng.uniform(-0.1, 0.1)
# For slavery aesthetic, allies get no clothes and dimmer skin
naked = (aesthetic == Aesthetic.SLAVERY and is_ally)
if naked:
skin_lit = rng.uniform(-0.3, -0.1)
role = RoleDefinition(
role_type=role_type,
species=role_species,
skin_hue=skin_hue,
skin_sat=skin_sat,
skin_lit=skin_lit,
is_ally=is_ally,
)
# Resolve actual layer files from the library
self._assign_skin(role, role_species)
if not naked:
self._assign_clothing(role, role_type, rng)
self._assign_headgear(role, role_type, rng)
self._assign_hair(role, rng)
self._assign_shoes(role, rng)
self._assign_gloves(role, role_type, rng)
self._assign_eyes(role, rng)
self._assign_addons(role, role_species, rng)
roles.append(role)
return FactionRecipe(
name=name,
biome=biome,
species=species,
element=element,
aesthetic=aesthetic,
ally_species=ally_species,
roles=roles,
seed=self.seed,
)
# ---- Layer assignment helpers ----
def _assign_skin(self, role, species):
"""Pick a skin layer file for the species."""
skins = self.library.skins_for(species) if self.library.available else []
if skins:
chosen = self._rng.choice(skins)
role._skin_path = chosen.path
else:
role._skin_path = None
def _assign_clothing(self, role, role_type, rng):
"""Pick clothing appropriate to the role type."""
if not self.library.available:
return
preferred_styles = _ROLE_CLOTHING_STYLES.get(role_type, [])
candidates = []
for style in preferred_styles:
candidates.extend(self.library.clothes_by_style(style))
if not candidates:
candidates = self.library.clothes
if not candidates:
return
chosen = rng.choice(candidates)
clothing_hue = rng.uniform(0, 360)
role.clothing_layers.append(
(chosen.path, clothing_hue, 0.0, rng.uniform(-0.1, 0.05)))
def _assign_headgear(self, role, role_type, rng):
"""Pick a headgear matching the role's combat class."""
if not self.library.available:
return
hg_class = _ROLE_HEADGEAR_CLASS.get(role_type)
if hg_class is None:
return
candidates = self.library.headgears_for_class(hg_class)
if not candidates:
candidates = self.library.headgears
if not candidates:
return
chosen = rng.choice(candidates)
role.headgear_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_hair(self, role, rng):
"""Pick a hairstyle (50% chance for humanoid roles)."""
if not self.library.available:
return
if rng.random() < 0.5:
return # no hair / covered by headgear
hairs = self.library.hairstyles
if not hairs:
return
# Filter to just actual hairstyles (not facial), pick one
head_hairs = [h for h in hairs if "Facial" not in h.subcategory]
if not head_hairs:
head_hairs = hairs
chosen = rng.choice(head_hairs)
role.hair_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_eyes(self, role, rng):
"""Pick an eye color (80% chance)."""
if not self.library.available:
return
if rng.random() < 0.2:
return
eye_colors = self.library.layers_in("eyes", "Eye Color")
if not eye_colors:
return
chosen = rng.choice(eye_colors)
role.eye_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_shoes(self, role, rng):
"""Pick shoes (70% chance)."""
if not self.library.available:
return
if rng.random() < 0.3:
return
shoes = self.library.shoes
if not shoes:
return
chosen = rng.choice(shoes)
role.shoe_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_gloves(self, role, role_type, rng):
"""Pick gloves for melee/ranged roles (40% chance)."""
if not self.library.available:
return
if role_type not in (RoleType.MELEE_FIGHTER, RoleType.RANGED_FIGHTER):
return
if rng.random() < 0.6:
return
gloves = self.library.gloves
if not gloves:
return
chosen = rng.choice(gloves)
role.glove_layer = (chosen.path, 0.0, 0.0, 0.0)
def _assign_addons(self, role, species, rng):
"""Pick species-specific add-ons if available."""
if not self.library.available:
return
addons = self.library.addons_for(species)
if not addons:
return
# 60% chance to add a species add-on
if rng.random() < 0.4:
return
chosen = rng.choice(addons)
role.addon_layer = (chosen.path, 0.0, 0.0, 0.0)

View file

@ -0,0 +1,329 @@
"""Unit tests for shade_sprite.factions and shade_sprite.assets modules."""
import mcrfpy
import sys
import os
# Add project root to path
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(script_dir))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from shade_sprite.assets import AssetLibrary, LayerFile, _parse_species_from_skin
from shade_sprite.factions import (
FactionRecipe, FactionGenerator, RoleDefinition,
Biome, Element, Aesthetic, RoleType,
_AESTHETIC_ROLE_POOLS,
)
from shade_sprite.assembler import CharacterAssembler, TextureCache
from shade_sprite.formats import PUNY_29
errors = []
def test(name, condition, msg=""):
if not condition:
errors.append(f"FAIL: {name} - {msg}")
print(f" FAIL: {name} {msg}")
else:
print(f" PASS: {name}")
# ===================================================================
# AssetLibrary tests
# ===================================================================
print("=== AssetLibrary ===")
lib = AssetLibrary()
has_assets = lib.available
test("AssetLibrary instantiates", lib is not None)
test("AssetLibrary repr", "AssetLibrary" in repr(lib))
if has_assets:
print(" (Paid asset pack detected - running full asset tests)")
test("has species", len(lib.species) > 0, f"got {lib.species}")
test("Human in species", "Human" in lib.species,
f"species: {lib.species}")
test("Orc in species", "Orc" in lib.species)
# Skins
human_skins = lib.skins_for("Human")
test("skins_for Human returns files", len(human_skins) > 0,
f"got {len(human_skins)}")
test("skin is LayerFile", isinstance(human_skins[0], LayerFile))
test("skin has path", os.path.isfile(human_skins[0].path))
test("skin category is skins", human_skins[0].category == "skins")
# No skins for nonexistent species
test("no skins for Alien", len(lib.skins_for("Alien")) == 0)
# Categories
cats = lib.categories
test("has categories", len(cats) >= 5,
f"got {len(cats)}: {cats}")
test("skins in categories", "skins" in cats)
test("clothes in categories", "clothes" in cats)
# Clothes
clothes = lib.clothes
test("has clothes", len(clothes) > 0, f"got {len(clothes)}")
# Subcategories
clothes_subs = lib.subcategories("clothes")
test("clothes has subcategories", len(clothes_subs) > 0,
f"got {clothes_subs}")
# clothes_by_style
armour = lib.clothes_by_style("armour")
test("armour clothes found", len(armour) > 0, f"got {len(armour)}")
test("armour subcategory", "Armour" in armour[0].subcategory)
# Headgears
melee_hg = lib.headgears_for_class("melee")
test("melee headgears found", len(melee_hg) > 0)
mage_hg = lib.headgears_for_class("mage")
test("mage headgears found", len(mage_hg) > 0)
# Add-ons
orc_addons = lib.addons_for("Orc")
test("Orc add-ons found", len(orc_addons) > 0)
elf_addons = lib.addons_for("Elf")
test("Elf add-ons found", len(elf_addons) > 0)
# Summary
summary = lib.summary()
test("summary has entries", len(summary) > 0)
test("summary values are ints", all(isinstance(v, int) for v in summary.values()))
# Shoes, gloves, etc.
test("has shoes", len(lib.shoes) > 0)
test("has gloves", len(lib.gloves) > 0)
test("has hairstyles", len(lib.hairstyles) > 0)
test("has eyes", len(lib.eyes) > 0)
test("has headgears", len(lib.headgears) > 0)
test("has addons", len(lib.addons) > 0)
else:
print(" (No paid asset pack - running minimal tests)")
test("unavailable lib has no species", len(lib.species) == 0)
test("unavailable lib layers empty", len(lib.layers("skins")) == 0)
test("unavailable lib summary empty", len(lib.summary()) == 0)
# ---- Species parsing ----
print("\n=== Species Parsing ===")
test("parse Human1 -> Human", _parse_species_from_skin("Human1") == "Human")
test("parse Human10 -> Human", _parse_species_from_skin("Human10") == "Human")
test("parse Orc2 -> Orc", _parse_species_from_skin("Orc2") == "Orc")
test("parse NightElf1 -> NightElf", _parse_species_from_skin("NightElf1") == "NightElf")
test("parse Cyclops1 -> Cyclops", _parse_species_from_skin("Cyclops1") == "Cyclops")
test("parse bare name -> itself", _parse_species_from_skin("Demon") == "Demon")
# ===================================================================
# FactionGenerator determinism tests
# ===================================================================
print("\n=== FactionGenerator Determinism ===")
gen1 = FactionGenerator(seed=42, library=lib)
recipe1 = gen1.generate()
gen2 = FactionGenerator(seed=42, library=lib)
recipe2 = gen2.generate()
test("same seed -> same name", recipe1.name == recipe2.name,
f"'{recipe1.name}' vs '{recipe2.name}'")
test("same seed -> same biome", recipe1.biome == recipe2.biome)
test("same seed -> same species", recipe1.species == recipe2.species)
test("same seed -> same element", recipe1.element == recipe2.element)
test("same seed -> same aesthetic", recipe1.aesthetic == recipe2.aesthetic)
test("same seed -> same ally count", len(recipe1.ally_species) == len(recipe2.ally_species))
test("same seed -> same role count", len(recipe1.roles) == len(recipe2.roles))
# Check each role matches
for i, (r1, r2) in enumerate(zip(recipe1.roles, recipe2.roles)):
test(f"role {i} same type", r1.role_type == r2.role_type)
test(f"role {i} same species", r1.species == r2.species)
test(f"role {i} same skin hue", abs(r1.skin_hue - r2.skin_hue) < 0.001,
f"{r1.skin_hue} vs {r2.skin_hue}")
# Different seeds -> different results
gen3 = FactionGenerator(seed=99, library=lib)
recipe3 = gen3.generate()
# Not guaranteed to be different in every field, but extremely likely
# Check at least one attribute differs
differs = (recipe1.name != recipe3.name or
recipe1.biome != recipe3.biome or
recipe1.species != recipe3.species or
recipe1.element != recipe3.element or
recipe1.aesthetic != recipe3.aesthetic)
test("different seed -> likely different recipe", differs)
# ===================================================================
# FactionRecipe structure tests
# ===================================================================
print("\n=== FactionRecipe Structure ===")
test("recipe has name", isinstance(recipe1.name, str) and len(recipe1.name) > 0)
test("recipe has biome", isinstance(recipe1.biome, Biome))
test("recipe has element", isinstance(recipe1.element, Element))
test("recipe has aesthetic", isinstance(recipe1.aesthetic, Aesthetic))
test("recipe has species", isinstance(recipe1.species, str))
test("recipe has seed", recipe1.seed == 42)
test("recipe has 3-5 roles", 3 <= len(recipe1.roles) <= 5,
f"got {len(recipe1.roles)}")
# ===================================================================
# Role generation counts by aesthetic
# ===================================================================
print("\n=== Role Counts by Aesthetic ===")
# Generate many factions to verify aesthetic influence on role pools
role_type_counts = {aesthetic: {} for aesthetic in Aesthetic}
for seed in range(100):
gen = FactionGenerator(seed=seed, library=lib)
recipe = gen.generate()
aes = recipe.aesthetic
for role in recipe.roles:
rt = role.role_type
role_type_counts[aes][rt] = role_type_counts[aes].get(rt, 0) + 1
# Militaristic should have more melee/ranged
mil = role_type_counts[Aesthetic.MILITARISTIC]
test("militaristic has melee fighters",
mil.get(RoleType.MELEE_FIGHTER, 0) > 0)
test("militaristic has ranged fighters",
mil.get(RoleType.RANGED_FIGHTER, 0) > 0)
# Fanatical should have spellcasters
fan = role_type_counts[Aesthetic.FANATICAL]
test("fanatical has spellcasters",
fan.get(RoleType.SPELLCASTER, 0) > 0)
# Cowardly should have pets/flankers
cow = role_type_counts[Aesthetic.COWARDLY]
test("cowardly has pet flankers",
cow.get(RoleType.PET_FLANKER, 0) > 0)
# Slavery should have pets (as enslaved allies)
slv = role_type_counts[Aesthetic.SLAVERY]
test("slavery has pet roles",
slv.get(RoleType.PET_RUSHER, 0) + slv.get(RoleType.PET_FLANKER, 0) > 0)
# ===================================================================
# RoleDefinition skin hue variation
# ===================================================================
print("\n=== Skin Hue Variation ===")
# Within a single faction, main-species roles should have close but distinct hues
gen_hue = FactionGenerator(seed=7, library=lib)
recipe_hue = gen_hue.generate()
main_roles = [r for r in recipe_hue.roles if r.species == recipe_hue.species]
if len(main_roles) >= 2:
hues = [r.skin_hue for r in main_roles]
# Check that hues are distinct (not identical)
unique_hues = len(set(round(h, 2) for h in hues))
test("main species roles have distinct hues",
unique_hues == len(hues),
f"hues: {[f'{h:.1f}' for h in hues]}")
# Check that hues are within reasonable range of each other (within 30 degrees)
# The generator uses +/-15 degree variation from a base
base_hue = sum(hues) / len(hues)
all_close = all(
min(abs(h - base_hue), 360 - abs(h - base_hue)) < 30
for h in hues
)
test("main species hues are close (within 30 degrees of mean)",
all_close, f"hues: {[f'{h:.1f}' for h in hues]}, mean: {base_hue:.1f}")
else:
print(f" SKIP: only {len(main_roles)} main-species roles (need >= 2 for hue test)")
# RoleDefinition label
test("role has label",
"(" in recipe1.roles[0].label and ")" in recipe1.roles[0].label)
# ===================================================================
# RoleDefinition layer assignments (if assets available)
# ===================================================================
if has_assets:
print("\n=== Layer Assignments ===")
# Generate a faction and check that roles have actual file paths
gen_layers = FactionGenerator(seed=55, library=lib)
recipe_layers = gen_layers.generate()
for i, role in enumerate(recipe_layers.roles):
has_skin = role._skin_path is not None
test(f"role {i} ({role.role_type.value}) has skin path", has_skin)
if has_skin:
test(f"role {i} skin file exists", os.path.isfile(role._skin_path))
# Non-pet roles should generally have clothing
if role.role_type not in (RoleType.PET_RUSHER, RoleType.PET_FLANKER):
# Check at least one of clothing/headgear/shoes is assigned
has_any = (len(role.clothing_layers) > 0 or
role.headgear_layer is not None or
role.shoe_layer is not None)
# Not guaranteed for slavery aesthetic allies, but main species should have gear
if not role.is_ally:
test(f"role {i} ({role.role_type.value}) has equipment",
has_any)
# ===================================================================
# TextureCache tests
# ===================================================================
print("\n=== TextureCache ===")
cache = TextureCache()
test("cache starts empty", len(cache) == 0)
# Create a test texture via from_bytes
tex_data = bytes([100, 150, 200, 255] * (928 * 256))
test_tex_path = None # Can't test file loading without real files
# Test cache contains
test("cache doesn't contain missing key",
("nonexistent.png", 0.0, 0.0, 0.0) not in cache)
cache.clear()
test("cache clear works", len(cache) == 0)
# ===================================================================
# Enum coverage
# ===================================================================
print("\n=== Enum Coverage ===")
test("Biome has 5 values", len(Biome) == 5)
test("Element has 4 values", len(Element) == 4)
test("Aesthetic has 4 values", len(Aesthetic) == 4)
test("RoleType has 6 values", len(RoleType) == 6)
# All aesthetics have role pools
for aes in Aesthetic:
pool = _AESTHETIC_ROLE_POOLS[aes]
test(f"{aes.value} has role pool", len(pool) >= 3,
f"got {len(pool)}")
# ===================================================================
# Summary
# ===================================================================
print()
if errors:
print(f"FAILED: {len(errors)} tests failed")
for e in errors:
print(f" {e}")
sys.exit(1)
else:
print("All tests passed!")
sys.exit(0)