Automated Quest Generation¶
Overview¶
The Automated Quest Generation system creates dynamic, contextual quests from world events and NPC states. Rather than relying solely on hand-crafted content, the system observes what's happening in your game world and generates quests that feel organic and responsive to player actions.
Key capabilities:
- Event-driven: Quests emerge from NPC needs, goals, threats, and player actions
- LLM-enhanced narratives: AI generates quest text with template fallbacks
- Quality controlled: Multi-stage validation prevents broken or repetitive quests
- Chain-aware: Completed quests can trigger follow-up story arcs
- Personalized delivery: Quests are matched to player play styles
- Consequence propagation: Quest outcomes ripple through the world
Pipeline Overview¶
Quest generation follows a seven-stage pipeline:
StorySignal → SeedEvaluator → ArchetypeRegistry → QuestBuilder → NarrativeGenerator → QuestValidator → Delivery
1. Story Signals¶
A StorySignal (or QuestSignal) is a lightweight event emitted by world systems:
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@dataclass(slots=True)
class QuestSignal:
signal_type: str # e.g., "npc_need", "npc_goal", "quest_chain"
source_entity: UUID # The NPC or entity that triggered the signal
importance: float # 0.0–1.0, how urgent/important this signal is
context: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
Signals are generated by:
- NPC autonomy systems — when NPCs have unmet needs or blocked goals
- World event systems — threats, resource crises, environmental changes
- Quest completion — chain follow-ups from prior quest outcomes
- Admin commands — manual
@questgen generatetriggers
Note: Signals flow into the system primarily via
StorySignalEvent(subscribed during startup). TheCompoundSeedComposerclass exists in the codebase but is not called byQuestGenerationSystemduring normal operation — it is available infrastructure for custom content packs that wish to batch multiple lightweight string signals into composite seeds externally.
2. Seed Evaluator¶
The SeedEvaluator converts raw signals into QuestSeed bundles using registered strategies:
- NpcNeedEvaluator — selects the NPC's highest need via
NeedsComponent.highest_need()(emits seeds withseed_type="npc_need") - NpcGoalEvaluator — detects NPC goals that require player assistance (emits seeds with
seed_type="npc_goal") - ChainFollowupEvaluator — creates follow-up seeds from completed quests (emits seeds with
seed_type="consequence_chain")
The evaluator applies shared filtering rules:
| Filter | Default | Description |
|---|---|---|
min_importance |
0.4 | Seeds below this threshold are discarded |
min_involved_npcs |
1 | At least one NPC must be involved |
cooldown_per_npc |
3600s | Minimum time between quests from same NPC |
variety_window |
5 | Recent quest type diversity check window |
max_active_seeds |
10 | Maximum seeds in the pipeline at once |
The CompoundSeedComposer can also merge multiple related lightweight signals into composite seeds (e.g., two NPC need signals in the same region become a "regional crisis"). However, it is not invoked automatically by QuestGenerationSystem — it must be called explicitly by content pack code that collects and groups signals before injection.
3. Archetype Registry¶
The ArchetypeRegistry class provides a standalone registry for quest archetypes, but QuestGenerationSystem uses its own internal _archetype_list combined with the select_archetype() function. Use the public register_archetype() / register_archetypes() methods on the system to add archetypes at runtime.
@dataclass(slots=True)
class QuestArchetype:
archetype_id: str
name: str
applicable_seed_types: list[str] # Which seed types this archetype handles
objective_patterns: list[str] # e.g., ["fetch", "clear", "escort"]
narrative_arcs: list[str] # e.g., ["three_act", "mystery"]
default_difficulty: float
reward_scale: float
priority: int = 0
requires_combat: bool = False
requires_social: bool = False
requires_exploration: bool = False
The select_archetype() function picks the best archetype by matching seed_type against applicable_seed_types, then selecting the highest-priority candidate. When multiple candidates share the same top priority, one is chosen at random.
4. Quest Builder¶
The QuestBuilder and ObjectiveBuilder construct a GeneratedQuest from the seed and archetype. The ObjectiveBuilder supports these built-in patterns:
| Pattern | Description |
|---|---|
fetch |
Retrieve an item and bring it back |
clear |
Eliminate threats from an area |
escort |
Protect an NPC during travel |
investigate |
Gather clues and uncover information |
deliver |
Transport items to a destination |
negotiate |
Resolve a conflict through dialogue |
craft_and_deliver |
Craft an item then deliver it |
multi_stage |
Multiple sequential objectives |
choice |
Branching objectives with player choice |
5. Narrative Generator¶
The QuestNarrativeGenerator uses an LLM to produce:
- Description and backstory — thematic quest text
- Dialogue — NPC offer, progress, and completion lines
Note: The LLM narrative generation produces a description, backstory, and stakes (via
QuestNarrativeModel). The quest title is set by theQuestBuilderduring stage 4, not by the narrative generator.
When LLM is unavailable or the token budget is exhausted, the TemplateFallbackGenerator provides deterministic text using archetype-specific templates (threat_elimination, diplomatic_resolution, supply_chain, investigation, rescue_mission, faction_choice).
Token budget controls:
- Token budget window: 3600 seconds (1 hour)
- Token budget limit: 15,000 tokens per window
- Max tokens per narrative: 500
- Max tokens per dialogue: 400
6. Quest Validator¶
Two validation stages ensure quality:
QuestValidator — structural checks: - Quest giver entity exists in world - Objective target entities and rooms exist - Minimum NPC involvement met
QuestQualityFilter — anti-pattern detection:
- duplicate_archetype — same archetype used too recently
- npc_overuse — same NPC giving too many quests
- region_saturation — too many active quests in one area
- impossible_objective — objectives referencing invalid targets
- trivial_reward — rewards too low for difficulty
Quality score thresholds (all 0.0–1.0):
| Score | Default Minimum | Description |
|---|---|---|
| Grounding | 0.7 | References to real world entities |
| Coherence | 0.5 | Objective composition makes sense |
| Novelty | 0.35 | Different from recent quests |
7. Delivery¶
The DeliveryPlanner selects how the quest reaches the player based on their PlayerProfile:
| Play Style | Delivery Method | Description |
|---|---|---|
| Explorer | DISCOVERY |
Player finds clues at a location |
| Social | RUMOR |
Heard through NPC gossip |
| Combat/Balanced | DIRECT_OFFER |
NPC approaches player directly |
Other delivery methods include LETTER and NOTICE_BOARD.
Configuration¶
Quest generation settings are defined as module-level constants in maid_stdlib.systems.quests.generation.constants and as a QuestGenerationSettings Pydantic model in the same module. These are not wired into the main MAID_ environment variable settings hierarchy — they are plain MAIDBaseModel defaults used at code level.
Limitations: QuestGenerationSystem(world) accepts only a World argument — there is no settings override parameter. The QuestGenerationSettings Pydantic model exists for schema documentation but is not consumed by the system at runtime. All tuning values are imported as module-level constants before construction, so the system uses the values that were set at import time. To customize, mutate the constants before the system is instantiated (i.e., before the content pack calls get_systems()):
# Must run before QuestGenerationSystem is constructed
import maid_stdlib.systems.quests.generation.constants as qg_constants
qg_constants.MIN_IMPORTANCE = 0.3
qg_constants.MAX_ACTIVE_SEEDS = 20
qg_constants.TOKEN_BUDGET_LIMIT = 30_000
Reference for all available constants:
from maid_stdlib.systems.quests.generation.constants import (
MIN_IMPORTANCE, # 0.4 — minimum signal importance to generate
MIN_INVOLVED_NPCS, # 1 — minimum NPCs involved in a seed
MAX_ACTIVE_SEEDS, # 10 — max seeds in pipeline
COOLDOWN_PER_NPC, # 3600 — seconds between quests from same NPC
VARIETY_WINDOW, # 5 — type diversity check window
MAX_PENDING_QUESTS, # 10 — max quests awaiting delivery
MAX_SEED_ATTEMPTS, # 3 — retries before discarding a seed
MAX_CONCURRENT_BUILDS, # 3 — parallel generation tasks
TOKEN_BUDGET_LIMIT, # 15000 — LLM tokens per hour
TOKEN_BUDGET_WINDOW, # 3600.0 — budget window in seconds
MAX_TOKENS_PER_NARRATIVE,# 500
MAX_TOKENS_PER_DIALOGUE, # 400
GOSSIP_CONFIDENCE_CAP, # 0.9 — max confidence for gossip-sourced seeds
MAX_ACTIVE_QUESTS_PER_REGION, # 3
MAX_PENDING_OFFERS_PER_PLAYER, # 2
MAX_CHAIN_DEPTH, # 5 — maximum chain follow-up depth
CHAIN_DAMPENING, # 0.7 — importance decay per chain level
CONSEQUENCE_SIGNAL_BUDGET, # 5 — max consequence signals per outcome
GENERATION_INTERVAL_TICKS, # 20 — ticks between generation cycles
MAX_QUESTS_PER_CYCLE, # 2 — max quests generated per cycle
SIGNAL_DEDUP_WINDOW, # 60.0 — seconds to dedup same signal
GLOBAL_SIGNAL_MAX, # 30 — max signals in 5-minute window
GLOBAL_SIGNAL_WINDOW, # 300.0 — 5-minute window for GLOBAL_SIGNAL_MAX
QUEUE_SPIKE_THRESHOLD, # 8 — spike detection threshold for queued seeds
BUILD_TIMEOUT_SECONDS, # 30.0 — timeout for a single quest build task
)
Note: Some constants (e.g.,
MIN_IMPORTANCE,MIN_INVOLVED_NPCS) are copied into class attributes onSeedEvaluatorat import time. Mutating the module constant after the class has been imported will not affect the evaluator unless you also updateSeedEvaluator.MIN_IMPORTANCEetc. directly.
The system uses the configured AI dialogue provider for LLM narrative generation:
# The narrative generator resolves the provider via settings.ai_dialogue.default_provider
# Override explicitly if you want a provider other than the configured default (chatjimmy):
MAID_AI_DIALOGUE_DEFAULT_PROVIDER=anthropic
MAID_AI_ANTHROPIC_API_KEY=sk-...
At startup, the system resolves a single provider name using this logic:
- If
settings.ai_dialogueexists, usesettings.ai_dialogue.default_provider. - Otherwise, if
settings.aiexists, usesettings.ai.default_provider. - Otherwise, default to
"anthropic".
This is a one-time lookup — there is no retry or fallback between providers. If the resolved provider name is not found in the provider_registry, or no provider registry exists, the system silently runs without an LLM and uses template-based narrative generation for all quests.
Built-in Quest Seed Types¶
| Seed Type | Trigger | Required Context Key |
|---|---|---|
npc_need |
NPC's highest need value is elevated | need |
npc_goal |
NPC has a blocked goal requiring help | goal |
consequence_chain |
Previous quest outcome creates new situation | parent_quest_id |
The QuestSeedTypes class also defines threat_response, resource_crisis, mystery, goal_assistance, and consequence_chain as registered seed types in the SeedTypeRegistry, but the built-in evaluators emit only npc_need, npc_goal, and consequence_chain. Custom evaluators can emit any registered seed type.
Classic RPG Extensions: The
maid-classic-rpgpack ships a parallel quest generation pipeline undermaid_classic_rpg.systems.quests.generationthat diverges from the stdlib implementation. Key differences:
NpcNeedEvaluatoremitsresource_crisisseeds (notnpc_need) based on the NPC's highest need.NpcGoalEvaluatoremitsgoal_assistanceseeds (notnpc_goal) based on the NPC's current goal.QuestSeedTypesdefines 12 additional extension types:rivalry_intervention,faction_conflict,trade_mission,alliance_quest,discovery_expedition,justice,power_struggle,rescue,player_reputation,crafting_request,social_manipulation, andcold_case.- The Classic RPG
constants.pymirrors the stdlib constants module but is a separate copy; changes to one do not affect the other.If you are building a content pack that depends on
maid-classic-rpg, use the Classic RPG evaluators and seed types. If you depend only onmaid-stdlib, use the stdlib pipeline.
Compound seed types (created by CompoundSeedComposer when called manually):
| Seed Type | Trigger |
|---|---|
regional_crisis |
2+ NPC need signals in the same region |
opportunity |
NPC goal aligns with a concurrent world event |
player_driven |
2+ player actions targeting the same entity |
Admin Commands¶
The @questgen command provides administrative control:
@questgen status¶
Displays current pipeline state:
Quest Generation Status
pending=0.30 in_flight=0.10 token_pressure=0.45
tokens_used=3200 tokens_remaining=11800 builds_completed=7 builds_failed=1
@questgen generate <seed_type>¶
Force-generates a quest with maximum importance:
This bypasses the SeedEvaluator entirely and injects a seed directly into the pending queue via enqueue_seed(). The forced seed has primary_npc=None and empty involved_npcs, so it skips evaluator filters like min_involved_npcs. However, it can still fail at later pipeline stages: select_archetype() may find no matching archetype for the seed type, QuestBuilder may produce incomplete objectives without a quest giver, and QuestValidator may reject the result for missing entity references. Prefer providing NPC context via a StorySignalEvent for realistic test generation.
@questgen history¶
Shows recently generated quests:
Recent generated quests:
- quest_abc123 [threat_elimination] outcome=completed
- quest_def456 [diplomatic_resolution] outcome=None
- quest_ghi789 [supply_chain] outcome=abandoned
Events¶
The system emits events at each pipeline stage for observability and integration:
| Event | When |
|---|---|
QuestSeedCreatedEvent |
Signal accepted as a valid seed |
QuestGeneratedEvent |
Quest fully built and validated |
QuestOfferedEvent |
Quest offered to a specific player |
QuestDeliveredEvent |
Quest delivered via chosen method |
QuestChainEvent |
Follow-up chain triggered |
QuestExpiredInternalEvent |
Unaccepted quest expired |
Subscribe to these in your content pack for custom behavior:
from maid_stdlib.events.quest_generation import QuestGeneratedEvent
from maid_engine.core.events import EventBus
async def on_quest_generated(event: QuestGeneratedEvent) -> None:
logger.info(f"New quest: {event.quest_id} from {event.archetype_id}")
# In a System's startup() or ContentPack's on_load():
engine.world.events.subscribe(QuestGeneratedEvent, on_quest_generated)
Consequences¶
When quests complete (or fail), the ConsequenceSystem applies world mutations:
| Consequence Type | Effect |
|---|---|
npc_goal_advance |
Progresses an NPC's goal state |
npc_goal_complete |
Marks an NPC goal as achieved |
npc_need_satisfy |
Satisfies an NPC need |
relationship_change |
Alters NPC-player relationships |
faction_standing |
Changes faction reputation |
economic_change |
Modifies economy state |
social_influence |
Shifts social dynamics |
gossip_injection |
Adds knowledge to gossip network |
memory_creation |
Creates NPC memories of events |
world_state |
Modifies arbitrary world data |
Mutations are applied atomically with rollback support via WorldMutationBatch.
Customization¶
Adding Custom Archetypes¶
Register archetypes in your content pack's on_load:
from maid_stdlib.systems.quests.generation.models import QuestArchetype
from maid_stdlib.systems.quests.generation.system import QuestGenerationSystem
async def on_load(self, engine: GameEngine) -> None:
# Get the quest generation system
quest_system = engine.world.systems.get(QuestGenerationSystem)
if quest_system is None:
return
custom_archetype = QuestArchetype(
archetype_id="bounty_hunt",
name="Bounty Hunt",
applicable_seed_types=["threat_response", "player_driven"],
objective_patterns=["clear", "fetch"],
narrative_arcs=["three_act", "escalation"],
default_difficulty=0.7,
reward_scale=1.5,
priority=5,
requires_combat=True,
)
quest_system.register_archetype(custom_archetype)
Adding Custom Seed Evaluators¶
Implement the SeedEvaluationStrategy protocol:
from datetime import UTC, datetime, timedelta
from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.systems.quests.generation.models import QuestSeed
from maid_stdlib.systems.quests.generation.seeds import SeedEvaluationStrategy
from maid_stdlib.systems.quests.generation.stubs import StorySignal
from maid_stdlib.systems.quests.generation.history import QuestHistory
class WeatherCrisisEvaluator:
"""Generates quests when severe weather threatens settlements."""
async def evaluate(
self,
signal: StorySignal,
world: World,
quest_history: QuestHistory,
) -> QuestSeed | None:
if signal.signal_type != "weather_crisis":
return None
now = datetime.now(UTC)
return QuestSeed(
source_signals=[signal.signal_type],
seed_type="resource_crisis",
importance=0.8,
urgency=0.9,
primary_npc=signal.source_entity,
primary_npc_motivation="Protect the village from the storm",
backstory="A terrible storm approaches the settlement.",
stakes="Without preparation, lives will be lost.",
location=None,
threat_source="weather",
evidence_location=None,
involved_npcs=[signal.source_entity],
context_keys={"need": "shelter"},
created_at=now,
expires_at=now + timedelta(hours=6),
)
Register it during startup:
Adding Custom Seed Types¶
The SeedTypeRegistry tracks known seed type identifiers for internal bookkeeping. However, the pipeline does not consult the registry during seed ingestion, and @questgen status does not display registered types. Registering a custom type has no observable effect on pipeline behavior or admin output — the pipeline will process any seed regardless of registration. To actually handle a custom seed type, register a SeedEvaluationStrategy with the SeedEvaluator (see above) and ensure at least one archetype matches the seed type.
# Optional: register the type for internal tracking (no user-visible effect)
quest_system._seed_registry.register(
"weather_crisis",
"Triggered by severe weather events threatening settlements"
)
Custom Objective Builders¶
Register pattern-specific objective builders:
from maid_engine.core.world import World
from maid_stdlib.systems.quests.generation.builder import ObjectiveBuilder
from maid_stdlib.systems.quests.generation.models import QuestSeed
from maid_stdlib.systems.quests.generation.system import QuestGenerationSystem
from maid_stdlib.models.quest import QuestObjective
def build_patrol_objectives(seed: QuestSeed, world: World) -> list[QuestObjective]:
"""Build patrol route objectives."""
# Create objectives for visiting multiple locations
...
quest_system._objective_builder.register("patrol", build_patrol_objectives)
Custom Narrative Templates¶
Extend the fallback generator with new archetype templates:
from string import Template
quest_system._fallback_generator._narrative_templates["bounty_hunt"] = [
Template("A bounty has been placed: $stakes. $motivation drives the hunt."),
Template("$backstory. The target must be found before $stakes escalates."),
]
Example: Quest Generation Flow¶
Here's how a quest emerges from world state:
-
World event: The NPC autonomy system detects that blacksmith Gren has an unmet "materials" need at urgency 0.85.
-
Signal emission: A
StorySignalEventfires withsignal_type="npc_need",involved_npc_ids=[gren_id],importance=0.85. -
Seed evaluation: The
NpcNeedEvaluatorconverts this to aQuestSeedwithseed_type="npc_need",context_keys={"need": "economic"}. -
Archetype selection: The registry matches a "supply_chain" archetype with
objective_patterns=["craft_and_deliver"]. -
Quest building: The
QuestBuildercreates objectives — fetch iron ore from the abandoned mine, deliver it to Gren. -
Narrative generation: The LLM (or fallback template) produces: "Critical supplies are missing: Gren's forge has gone cold. Recover iron from the old mine and restore the village's lifeline."
-
Validation: The validator confirms the mine room exists, Gren's entity is valid, and no duplicate supply quests are active.
-
Delivery: A combat-focused player receives a
DIRECT_OFFERfrom Gren. An explorer might instead discover ore samples asDISCOVERYclues. -
Completion: When the player delivers the ore, the
ConsequenceSystemsatisfies Gren's need, improves the player's relationship with Gren, and potentially triggers a chain quest if Gren's next goal requires the forge.
Architecture Notes¶
- ECS System:
QuestGenerationSystemruns at priority 230;ConsequenceSystemat 240 - Async builds: Quest generation runs as background tasks with configurable timeout (30s default)
- Back-pressure: The system pauses signal intake when the build pipeline stalls
- Spike detection: Throttles to 1 quest/cycle when 5+ signals arrive in 10 seconds
- Deduplication: Same signal type + entity pair ignored within 60-second window
- Token tracking: LLM usage tracked per-window with automatic fallback to templates when exhausted