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:
parent
9718153709
commit
80e14163f9
7 changed files with 2471 additions and 314 deletions
329
tests/unit/shade_sprite_factions_test.py
Normal file
329
tests/unit/shade_sprite_factions_test.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue