Files
AetherTable/python/tests/test_quest_parametrization.py
John SmithandClaude e51bb2b8c0 feat(simulation): parametrized quest generation from curated theme tables (iteration 79)
The quest engine built one hardcoded intrigue DAG regardless of theme
args — rng instantiated but never used. Now: three curated theme tables
(court/crypt/wilderness with a new TRAVEL_HAZARD node type) drive node
types, titles, prompts, skills, and factions; party level scales DCs
and rewards; length plans 8/10/12-node DAGs; seed drives all selection
deterministically. Unknown themes raise instead of silently
substituting; thin tables truncate with an explicit coverage_note
rather than padding with invented filler. Legacy call signature and
default output preserved. 20 red-first tests; suite at 527.

Co-Authored-By: Claude <[email protected]>
2026-08-24 13:59:24 -04:00

286 lines
10 KiB
Python

"""
Parametrization contract tests for QuestGraphGenerator.
These pin down the fix for the audit finding "Quest generator ignoring its
parameters (fixed narrative)": theme, party level, desired length, and the
seed must all materially change the generated DAG, deterministically.
Honesty contract under test: theme content comes only from curated theme
tables. When a table cannot fill a requested structure the generator emits
fewer nodes and discloses it via `QuestGraph.coverage_note` -- it never pads
with invented filler.
"""
from collections import Counter
import pytest
from vtt_orchestrator.simulation.quest_engine import (
ConcordiaPactEngine,
QuestGraph,
QuestGraphGenerator,
QuestNodeType,
)
THEMES = ("crypt", "court", "wilderness")
def _make(theme, seed=7, level=5, length="medium", **kwargs):
return QuestGraphGenerator(seed=seed).generate_campaign_quest(
theme=theme, party_level=level, length=length, **kwargs
)
def _edges(quest):
"""Flatten (node_id, choice_id, edge) triples over the whole DAG."""
out = []
for node_id, node in quest.nodes.items():
for edge in node.choices:
out.append((node_id, edge.choice_id, edge))
return out
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
def test_same_seed_yields_identical_dag():
a = QuestGraphGenerator(seed=1234).generate_campaign_quest(
theme="crypt", party_level=4, length="long"
)
b = QuestGraphGenerator(seed=1234).generate_campaign_quest(
theme="crypt", party_level=4, length="long"
)
assert a.model_dump() == b.model_dump()
def test_different_seeds_vary_content_within_a_theme():
graphs = [
QuestGraphGenerator(seed=s).generate_campaign_quest(theme="wilderness")
for s in (1, 2, 3)
]
contents = {
tuple(n.title for n in g.nodes.values()) for g in graphs
}
assert len(contents) > 1, "different seeds produced byte-identical quests"
# ---------------------------------------------------------------------------
# Theme changes structure and content (specific, not vibes)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("theme", THEMES)
def test_each_theme_produces_its_own_quest_identity(theme):
quest = _make(theme)
assert isinstance(quest, QuestGraph)
assert f"{theme}" in quest.quest_id
assert quest.quest_id != "quest_iron_succession"
def test_node_type_profiles_differ_pairwise_between_themes():
profiles = {}
for theme in THEMES:
quest = _make(theme)
profiles[theme] = Counter(n.node_type for n in quest.nodes.values())
assert profiles["court"] != profiles["crypt"]
assert profiles["crypt"] != profiles["wilderness"]
assert profiles["court"] != profiles["wilderness"]
def test_crypt_is_tactical_and_court_is_intrigue():
crypt = _make("crypt")
court = _make("court")
crypt_types = {n.node_type for n in crypt.nodes.values()}
court_types = {n.node_type for n in court.nodes.values()}
assert QuestNodeType.TACTICAL_ENCOUNTER in crypt_types
assert QuestNodeType.SOCIAL_NEGOTIATION not in crypt_types
assert QuestNodeType.SOCIAL_NEGOTIATION in court_types
assert QuestNodeType.TACTICAL_ENCOUNTER not in court_types
def test_wilderness_introduces_travel_hazards_absent_elsewhere():
wild = _make("wilderness")
court = _make("court")
assert any(
n.node_type == QuestNodeType.TRAVEL_HAZARD for n in wild.nodes.values()
)
assert all(
n.node_type != QuestNodeType.TRAVEL_HAZARD for n in court.nodes.values()
)
def test_theme_title_sets_are_pairwise_disjoint():
title_sets = {}
summaries = {}
for theme in THEMES:
quest = _make(theme)
title_sets[theme] = {n.title for n in quest.nodes.values()}
summaries[theme] = quest.summary
for i, a in enumerate(THEMES):
for b in THEMES[i + 1 :]:
assert title_sets[a].isdisjoint(title_sets[b]), f"{a} vs {b}"
assert summaries[a] != summaries[b], f"{a} vs {b}"
def test_skill_checks_use_theme_appropriate_skills():
court_social_skills = {"Persuasion", "Deception", "Intimidation"}
for theme in ("crypt", "wilderness"):
quest = _make(theme)
used = {
e.skill_check_required[0]
for _, _, e in _edges(quest)
if e.skill_check_required
}
assert used, f"{theme} quest has no skill checks at all"
assert not (used & court_social_skills), (
f"{theme} quest leaked court social skills: {used & court_social_skills}"
)
def test_objectives_differ_across_themes_via_prompts():
prompts = {}
for theme in THEMES:
quest = _make(theme)
prompts[theme] = " ".join(n.narrative_prompt for n in quest.nodes.values())
assert prompts["crypt"] != prompts["court"] != prompts["wilderness"]
# Theme-specific vocabulary must actually appear.
assert "crypt" in prompts["crypt"].lower() or "catacomb" in prompts["crypt"].lower()
assert (
"ridge" in prompts["wilderness"].lower()
or "trail" in prompts["wilderness"].lower()
)
# ---------------------------------------------------------------------------
# Party level scales difficulty numbers, not structure
# ---------------------------------------------------------------------------
def test_party_level_scales_difficulty_and_rewards_with_fixed_structure():
low = _make("crypt", seed=11, level=1)
high = _make("crypt", seed=11, level=16)
# Structure identical: same node ids, same choice ids per node.
assert set(low.nodes) == set(high.nodes)
for nid in low.nodes:
assert [c.choice_id for c in low.nodes[nid].choices] == [
c.choice_id for c in high.nodes[nid].choices
]
low_edges = {(n, c): e for n, c, e in _edges(low)}
high_edges = {(n, c): e for n, c, e in _edges(high)}
checked = [
k
for k in low_edges
if low_edges[k].skill_check_required and high_edges[k].skill_check_required
]
assert checked, "expected at least one skill-checked edge to compare"
for k in checked:
assert (
high_edges[k].skill_check_required[1]
> low_edges[k].skill_check_required[1]
), f"DC did not scale up at {k}"
total_gold_low = sum(e.rewards_gold for e in low_edges.values())
total_gold_high = sum(e.rewards_gold for e in high_edges.values())
total_xp_low = sum(e.rewards_xp for e in low_edges.values())
total_xp_high = sum(e.rewards_xp for e in high_edges.values())
assert total_gold_high > total_gold_low
assert total_xp_high > total_xp_low
# ---------------------------------------------------------------------------
# Desired length changes DAG size; thin tables truncate honestly
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("theme", ("court", "crypt"))
def test_length_grows_the_dag(theme):
sizes = {
length: len(_make(theme, length=length).nodes)
for length in ("short", "medium", "long")
}
assert sizes["short"] < sizes["medium"] < sizes["long"]
def test_thin_table_truncates_instead_of_padding():
# The wilderness investigation table has fewer entries than a full long
# quest needs; the generator must emit fewer nodes and disclose it.
medium = _make("wilderness", length="medium")
long_wild = _make("wilderness", length="long")
long_court = _make("court", length="long")
assert long_court.coverage_note is None
assert long_wild.coverage_note is not None
assert "truncat" in long_wild.coverage_note.lower()
# Still a valid, connected DAG despite the truncation.
assert long_wild.initial_node_id in long_wild.nodes
for node in long_wild.nodes.values():
for edge in node.choices:
assert edge.target_node_id in long_wild.nodes
def test_every_generated_graph_is_a_wellformed_reachable_dag():
for theme in THEMES:
quest = _make(theme, length="long")
seen = {quest.initial_node_id}
frontier = [quest.initial_node_id]
while frontier:
current = frontier.pop()
for edge in quest.nodes[current].choices:
assert edge.target_node_id in quest.nodes
if edge.target_node_id not in seen:
seen.add(edge.target_node_id)
frontier.append(edge.target_node_id)
assert seen == set(quest.nodes), f"{theme}: unreachable nodes present"
# Resolutions terminate.
for node in quest.nodes.values():
if node.node_type == QuestNodeType.RESOLUTION:
assert node.choices == []
# ---------------------------------------------------------------------------
# Backward compatibility with existing callers (server.py + old test)
# ---------------------------------------------------------------------------
def test_legacy_call_signature_still_works():
gen = QuestGraphGenerator(seed=42)
quest = gen.generate_campaign_quest(
campaign_theme="The Iron Succession",
primary_house="house_vane",
rival_house="house_silverpeak",
)
assert isinstance(quest, QuestGraph)
assert quest.quest_id == "quest_iron_succession"
assert quest.initial_node_id in quest.nodes
hook = quest.nodes[quest.initial_node_id]
assert hook.node_type == QuestNodeType.HOOK
assert len(hook.choices) >= 2
for edge in hook.choices:
assert edge.target_node_id in quest.nodes
def test_no_argument_call_matches_server_default_path():
quest = QuestGraphGenerator().generate_campaign_quest()
assert quest.initial_node_id in quest.nodes
assert quest.quest_id == "quest_iron_succession"
def test_explicit_unknown_theme_is_rejected_not_silently_substituted():
with pytest.raises(ValueError):
QuestGraphGenerator(seed=1).generate_campaign_quest(theme="space_opera")
def test_concordia_engine_untouched_by_parametrization():
res = ConcordiaPactEngine().negotiate_treaty(
house_a_name="House Vane",
house_b_name="House Silverpeak",
player_diplomacy_roll=18,
concessions_offered="mining rights",
)
assert res.pact_agreed is True