"""Character condition generation system.
This module implements a structured, rule-based system for generating coherent
character state descriptions across multiple axes (physique, wealth, health,
facial signals, etc.).
Unlike simple text file lookups, this system uses:
- Weighted probability distributions for realistic populations
- Semantic exclusion rules to prevent illogical combinations
- Mandatory and optional axis policies to control complexity
- Reproducible generation via random seeds
The system is designed for procedural character generation in both visual
(image generation prompts) and narrative (MUD/game) contexts.
Example usage:
>>> from pipeworks.core.condition_axis import generate_condition, condition_to_prompt
>>> condition = generate_condition(seed=42)
>>> prompt_fragment = condition_to_prompt(condition)
>>> print(prompt_fragment)
'skinny, poor, weary, alert'
Note: The condition dict may also include 'facial_signal' as an optional axis.
Architecture:
1. CONDITION_AXES: Define all possible values for each axis (including facial_signal)
2. AXIS_POLICY: Rules for mandatory vs optional axes
3. WEIGHTS: Statistical distribution for realistic populations
4. EXCLUSIONS: Semantic constraints to prevent nonsense (including cross-system rules)
5. Generator: Produces constrained random combinations
6. Converter: Transforms structured data into prompt text
"""
import logging
import random
from typing import Any
from ._base import apply_exclusion_rules, values_to_prompt, weighted_choice
logger = logging.getLogger(__name__)
# ============================================================================
# AXIS DEFINITIONS - Single Source of Truth
# ============================================================================
CONDITION_AXES: dict[str, list[str]] = {
# Physical build and body structure
"physique": ["skinny", "wiry", "stocky", "hunched", "frail", "broad"],
# Economic/social status indicators
"wealth": ["poor", "modest", "well-kept", "wealthy", "decadent"],
# Physical health and condition
"health": ["sickly", "scarred", "weary", "hale", "limping"],
# Behavioral presentation and attitude
"demeanor": ["timid", "suspicious", "resentful", "alert", "proud"],
# Life stage
"age": ["young", "middle-aged", "old", "ancient"],
# Facial perception modifiers (merged from facial_conditions.py)
"facial_signal": [
"understated",
"pronounced",
"exaggerated",
"asymmetrical",
"weathered",
"soft-featured",
"sharp-featured",
],
}
# ============================================================================
# AXIS POLICY - Controls Complexity and Prompt Clarity
# ============================================================================
AXIS_POLICY: dict[str, Any] = {
# Always include these axes (establish baseline character state)
"mandatory": ["physique", "wealth"],
# May include 0-N of these axes (add narrative detail)
"optional": ["health", "demeanor", "age", "facial_signal"],
# Maximum number of optional axes to include
# (prevents prompt dilution and maintains diffusion model clarity)
"max_optional": 2,
}
# ============================================================================
# WEIGHTS - Statistical Population Distribution
# ============================================================================
WEIGHTS: dict[str, dict[str, float]] = {
# Wealth distribution: skewed toward lower classes (realistic population)
"wealth": {
"poor": 4.0, # Most common
"modest": 3.0,
"well-kept": 2.0,
"wealthy": 1.0,
"decadent": 0.5, # Rare
},
# Physique distribution: skewed toward survival builds
"physique": {
"skinny": 3.0,
"wiry": 2.0,
"hunched": 2.0,
"frail": 1.0,
"stocky": 1.0,
"broad": 0.5, # Rare
},
# Facial signal distribution: skewed toward subtle/neutral signals
"facial_signal": {
"understated": 3.0, # Most common - most faces aren't remarkable
"soft-featured": 2.5, # Fairly common
"pronounced": 2.0, # Moderate
"sharp-featured": 2.0, # Moderate
"weathered": 1.5, # Less common (requires age/experience)
"asymmetrical": 1.0, # Uncommon
"exaggerated": 0.5, # Rare - extreme features
},
# Other axes use uniform distribution (no weights defined)
}
# ============================================================================
# EXCLUSIONS - Semantic Coherence Rules
# ============================================================================
EXCLUSIONS: dict[tuple[str, str], dict[str, list[str]]] = {
# Decadent characters are unlikely to be frail or sickly
# (wealth enables health care and nutrition, preserves appearance)
("wealth", "decadent"): {
"physique": ["frail"],
"health": ["sickly"],
"facial_signal": ["weathered"], # Wealth preserves appearance
},
# Ancient characters aren't timid and rarely have subtle features
# (age brings confidence and pronounced characteristics)
("age", "ancient"): {
"demeanor": ["timid"],
"facial_signal": ["understated"], # Ancient faces are rarely subtle
},
# Broad, strong physiques don't pair with sickness
("physique", "broad"): {
"health": ["sickly"],
},
# Hale (healthy) characters shouldn't have frail physiques or weathered faces
# (health affects both body and appearance)
("health", "hale"): {
"physique": ["frail"],
"facial_signal": ["weathered"], # Healthy people look healthy
},
# Young characters shouldn't look weathered
# (youth contradicts wear and age texture)
("age", "young"): {
"facial_signal": ["weathered"],
},
# Sickly characters already imply soft features
# (redundant signal - sickness softens appearance)
("health", "sickly"): {
"facial_signal": ["soft-featured"],
},
}
# ============================================================================
# GENERATOR FUNCTIONS
# ============================================================================
[docs]
def generate_condition(seed: int | None = None) -> dict[str, str]:
"""Generate a coherent character condition using weighted random selection.
This function applies the full rule system:
1. Select mandatory axes (always included)
2. Select 0-N optional axes (controlled by policy)
3. Apply weighted probability distributions
4. Apply semantic exclusion rules
5. Return structured condition data
Args:
seed: Optional random seed for reproducible generation.
If None, uses system entropy (non-reproducible).
Returns:
Dictionary mapping axis names to selected values.
Example: {"physique": "wiry", "wealth": "poor", "demeanor": "alert"}
Examples:
>>> # Reproducible generation
>>> cond1 = generate_condition(seed=42)
>>> cond2 = generate_condition(seed=42)
>>> cond1 == cond2
True
>>> # Non-reproducible (different each call)
>>> generate_condition()
{'physique': 'stocky', 'wealth': 'modest', 'health': 'weary'}
"""
# Create isolated RNG instance to avoid polluting global random state
rng = random.Random(seed)
chosen: dict[str, str] = {}
# ========================================================================
# PHASE 1: Select mandatory axes
# These establish the baseline character state
# ========================================================================
for axis in AXIS_POLICY["mandatory"]:
if axis not in CONDITION_AXES:
logger.warning(f"Mandatory axis '{axis}' not defined in CONDITION_AXES")
continue
chosen[axis] = weighted_choice(CONDITION_AXES[axis], WEIGHTS.get(axis), rng=rng)
logger.debug(f"Mandatory axis selected: {axis} = {chosen[axis]}")
# ========================================================================
# PHASE 2: Select optional axes
# Randomly pick 0 to max_optional axes to add narrative detail
# ========================================================================
max_optional = AXIS_POLICY.get("max_optional", 2)
num_optional = rng.randint(0, min(max_optional, len(AXIS_POLICY["optional"])))
# Randomly sample without replacement
optional_axes = rng.sample(AXIS_POLICY["optional"], num_optional)
logger.debug(f"Selected {num_optional} optional axes: {optional_axes}")
for axis in optional_axes:
if axis not in CONDITION_AXES:
logger.warning(f"Optional axis '{axis}' not defined in CONDITION_AXES")
continue
chosen[axis] = weighted_choice(CONDITION_AXES[axis], WEIGHTS.get(axis), rng=rng)
logger.debug(f"Optional axis selected: {axis} = {chosen[axis]}")
# ========================================================================
# PHASE 3: Apply semantic exclusion rules
# Remove illogical combinations (e.g., decadent + frail)
# ========================================================================
apply_exclusion_rules(chosen, EXCLUSIONS)
return chosen
[docs]
def condition_to_prompt(condition_dict: dict[str, str]) -> str:
"""Convert structured condition data to a comma-separated prompt fragment.
This is the only place structured data becomes prose text.
The output is designed to be clean and diffusion-friendly.
Args:
condition_dict: Dictionary mapping axis names to values
(output from generate_condition)
Returns:
Comma-separated string of condition values
Examples:
>>> condition_to_prompt({"physique": "wiry", "wealth": "poor"})
'wiry, poor'
>>> condition_to_prompt({"physique": "stocky", "wealth": "modest", "age": "old"})
'stocky, modest, old'
Notes:
- Order is determined by dict iteration (Python 3.7+ preserves insertion order)
- If you need deterministic ordering, consider sorting by axis name
- Empty dict returns empty string
"""
return values_to_prompt(condition_dict)
[docs]
def get_available_axes() -> list[str]:
"""Get list of all defined condition axes.
Returns:
List of axis names (e.g., ['physique', 'wealth', 'health', 'facial_signal', ...])
Example:
>>> get_available_axes()
['physique', 'wealth', 'health', 'demeanor', 'age', 'facial_signal']
"""
return list(CONDITION_AXES.keys())
[docs]
def get_axis_values(axis: str) -> list[str]:
"""Get all possible values for a specific axis.
Args:
axis: Name of the axis (e.g., 'physique', 'wealth')
Returns:
List of possible values for that axis
Raises:
KeyError: If axis is not defined in CONDITION_AXES
Example:
>>> get_axis_values('wealth')
['poor', 'modest', 'well-kept', 'wealthy', 'decadent']
"""
return CONDITION_AXES[axis]
# ============================================================================
# MODULE METADATA
# ============================================================================
__all__ = [
"AXIS_POLICY",
"CONDITION_AXES",
"EXCLUSIONS",
"WEIGHTS",
"condition_to_prompt",
"generate_condition",
"get_available_axes",
"get_axis_values",
]