mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-10 04:31:26 -10:00
- Gate reliability: autouse conftest fixture clears the shared auth rate-limit windows around every test; full suite now 3x-consecutive green (was nondeterministically red via cross-module bucket pressure). - Test quality over claim-laundering: computedHP gains the real SRD hit-die table (Wizard/Sorcerer d6, Barbarian d12, per-die average growth) — the old flat d8 fallback had been pinned by a test falsely citing "hit-die max"; tests now cite SRD values explicitly. - Scripted-mode social phase force-pins llm_gateway=None so an LLM-gatewayed persona can't leak upstream calls the report claims never happened; no-network test extended with a recording gateway. - Dynasty test tautology replaced with real lineage assertions. - Ambience-honesty test derives expected ids from AudioManager recipes (source of truth) with a drift guard, not the copied list. - Tracker honesty note appended to iteration 79: default quest output changed materially; only quest_id and summary preserved. Gates: pytest 547x3 green, vitest 69 green, build 13.3s. Co-Authored-By: Claude <[email protected]>
347 lines
14 KiB
Python
347 lines
14 KiB
Python
"""
|
|
Pillar 8 iteration: dynasty depth tests.
|
|
|
|
Covers multi-generation lineage compounding + mutation, cross-house
|
|
alliance inheritance weighted by trait dominance/recessiveness, house
|
|
prestige scoring/ordering, generation-appropriate lore-graph injection,
|
|
and backwards compatibility of the legacy single-generation API.
|
|
"""
|
|
|
|
import random
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from vtt_orchestrator.simulation.dynasty_engine import DynastyEngine, DynastyMember, NobleHouse
|
|
from vtt_orchestrator.lore.epistemic_graph import EpistemicLoreGraphManager
|
|
from vtt_orchestrator.schemas.models import EpistemicTier
|
|
|
|
|
|
IRON_WILL = "Iron Will (+2 Wisdom Saves)"
|
|
ARCANE_SPARK = "Arcane Spark (Innate Cantrip)"
|
|
SILVER_TONGUE = "Silver Tongue (+3 Persuasion)"
|
|
CURSED_BLOOD = "Cursed Bloodline (Disadvantage vs Necromancy)"
|
|
GRIFFIN_RIDER = "Griffin Rider (Aerial Mastery)"
|
|
|
|
|
|
class ScriptedRandom:
|
|
"""
|
|
Deterministic RNG stand-in. ``random()`` always returns the scripted
|
|
value, so every probabilistic gate with p > value passes and every
|
|
gate with p <= value fails. choice/sample/choices resolve
|
|
deterministically (argmax weight / head of sequence).
|
|
"""
|
|
|
|
def __init__(self, value: float):
|
|
self.value = value
|
|
|
|
def random(self) -> float:
|
|
return self.value
|
|
|
|
def choice(self, seq):
|
|
return seq[0]
|
|
|
|
def sample(self, seq, k):
|
|
return list(seq)[:k]
|
|
|
|
def choices(self, population, weights=None, k=1):
|
|
if weights:
|
|
idx = max(range(len(population)), key=lambda i: weights[i])
|
|
else:
|
|
idx = 0
|
|
return [population[idx]] * k
|
|
|
|
|
|
LEGACY_MEMBER_KEYS = {
|
|
"id", "name", "title", "generation", "is_alive", "traits",
|
|
"personality", "parent_ids", "spouse_id", "historical_event",
|
|
}
|
|
|
|
|
|
def _craft_house(engine: DynastyEngine, house_id: str, gen3_traits, gen2_traits=None, gen1_traits=None) -> NobleHouse:
|
|
"""Insert a minimal three-generation house with controlled trait pools."""
|
|
house = NobleHouse(
|
|
id=house_id,
|
|
name=f"House {house_id.split('_', 1)[1].title()}",
|
|
motto="Tested in Fire",
|
|
crest_icon="tower",
|
|
theme_color="#ffffff",
|
|
seat_of_power="Test Bastion",
|
|
primary_virtue="Verification",
|
|
)
|
|
founder = DynastyMember(
|
|
id=f"{house_id}_gen1_founder", name="Founder Test", title="Founder",
|
|
generation=1, is_alive=False,
|
|
traits=list(gen1_traits if gen1_traits is not None else gen3_traits[:1]),
|
|
personality="stub",
|
|
)
|
|
ruler = DynastyMember(
|
|
id=f"{house_id}_gen2_ruler", name="Ruler Test", title="Ruler",
|
|
generation=2, is_alive=True,
|
|
traits=list(gen2_traits if gen2_traits is not None else gen3_traits[:1]),
|
|
personality="stub", parent_ids=[founder.id],
|
|
)
|
|
spouse = DynastyMember(
|
|
id=f"{house_id}_gen2_spouse", name="Spouse Test", title="Consort",
|
|
generation=2, is_alive=True, traits=["Shadow Affinity (Darkvision 60ft)"],
|
|
personality="stub",
|
|
)
|
|
ruler.spouse_id = spouse.id
|
|
spouse.spouse_id = ruler.id
|
|
heir = DynastyMember(
|
|
id=f"{house_id}_gen3_heir", name="Heir Test", title="Heir",
|
|
generation=3, is_alive=True, traits=list(gen3_traits),
|
|
personality="stub", parent_ids=[ruler.id, spouse.id],
|
|
)
|
|
house.members = [founder, ruler, spouse, heir]
|
|
engine.houses[house_id] = house
|
|
return house
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Multi-generation lineages: compounding + mutation, deterministic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_three_generation_lineage_compounds_across_extended_generations():
|
|
engine = DynastyEngine(seed=101)
|
|
engine.rng = ScriptedRandom(0.0) # every inheritance gate succeeds
|
|
house = _craft_house(
|
|
engine, "house_compound",
|
|
gen3_traits=[IRON_WILL],
|
|
gen2_traits=[IRON_WILL, SILVER_TONGUE],
|
|
gen1_traits=[IRON_WILL],
|
|
)
|
|
|
|
created = engine.extend_lineage("house_compound", additional_generations=2, mutation_rate=0.0)
|
|
|
|
assert len(created) == 4 # heir + spouse per generation
|
|
generations = {m.generation for m in created}
|
|
assert generations == {4, 5}
|
|
|
|
by_gen = {m.generation: m for m in house.members}
|
|
gen4_heir = by_gen[4]
|
|
gen5_heir = by_gen[5]
|
|
|
|
# Compounding: the trait carried by gens 1-3 survives into gens 4 AND 5.
|
|
assert IRON_WILL in gen4_heir.traits
|
|
assert IRON_WILL in gen5_heir.traits
|
|
# Parent-pool traits flow down (spouse traits included in the pool).
|
|
assert SILVER_TONGUE in gen4_heir.traits
|
|
|
|
# Lineage linkage: every extended generation descends from exactly ONE
|
|
# parent — the previous generation's blooded heir, never a married-in
|
|
# consort — and each heir is wed to its own generation's consort. (This
|
|
# replaced an earlier `... or True` tautology that asserted nothing.)
|
|
assert len(created) == 4
|
|
assert gen4_heir.parent_ids == ["house_compound_gen3_heir"]
|
|
assert gen5_heir.parent_ids == [gen4_heir.id]
|
|
assert gen4_heir.spouse_id == "house_compound_gen4_consort"
|
|
assert gen5_heir.spouse_id == "house_compound_gen5_consort"
|
|
|
|
|
|
def test_mutation_introduces_new_trait_at_configured_rate():
|
|
engine = DynastyEngine(seed=202)
|
|
engine.rng = ScriptedRandom(0.0)
|
|
_craft_house(engine, "house_mutant", gen3_traits=[IRON_WILL])
|
|
|
|
created = engine.extend_lineage("house_mutant", additional_generations=1, mutation_rate=1.0)
|
|
|
|
heir = next(m for m in created if m.parent_ids)
|
|
assert IRON_WILL in heir.traits # inherited
|
|
assert len(heir.traits) > 1 # mutation added something new
|
|
mutated = [t for t in heir.traits if t != IRON_WILL and "Darkvision" not in t]
|
|
assert mutated, "expected a mutated-in trait beyond the parental pool"
|
|
|
|
|
|
def test_lineage_extension_is_fully_deterministic_for_a_seed():
|
|
def run():
|
|
engine = DynastyEngine(seed=303)
|
|
_craft_house(engine, "house_det", gen3_traits=[ARCANE_SPARK, IRON_WILL])
|
|
engine.extend_lineage("house_det", additional_generations=3)
|
|
return json.dumps(
|
|
[[m.id, m.generation, sorted(m.traits)] for m in engine.houses["house_det"].members],
|
|
sort_keys=True,
|
|
)
|
|
|
|
assert run() == run()
|
|
|
|
|
|
def test_compounding_raises_inheritance_probability_monotonically():
|
|
engine = DynastyEngine(seed=404)
|
|
p_one_gen = engine.inheritance_probability(IRON_WILL, prior_generation_count=1)
|
|
p_three_gens = engine.inheritance_probability(IRON_WILL, prior_generation_count=3)
|
|
assert p_three_gens > p_one_gen
|
|
assert p_three_gens <= 0.95 # never guaranteed, even for deep bloodlines
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Marriage / alliance between houses
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_alliance_children_inherit_from_both_pools_per_dominance_weights():
|
|
engine = DynastyEngine(seed=505)
|
|
engine.rng = ScriptedRandom(0.6) # expresses only traits with high dominance
|
|
_craft_house(engine, "house_sun", gen3_traits=[IRON_WILL]) # dominance 0.70 -> p=0.65 > 0.6
|
|
_craft_house(engine, "house_shade", gen3_traits=[CURSED_BLOOD]) # dominance 0.15 -> p=0.375 < 0.6
|
|
|
|
pact = engine.form_alliance("house_sun", "house_shade", mutation_rate=0.0)
|
|
|
|
assert pact is not None
|
|
sun_child = engine.houses["house_sun"].members[-1]
|
|
shade_child = engine.houses["house_shade"].members[-1]
|
|
|
|
# Dominant trait from EITHER parent pool expresses in BOTH children...
|
|
assert IRON_WILL in sun_child.traits
|
|
assert IRON_WILL in shade_child.traits
|
|
# ...while the recessive curse is suppressed in both.
|
|
assert CURSED_BLOOD not in sun_child.traits
|
|
assert CURSED_BLOOD not in shade_child.traits
|
|
|
|
# Children are recorded as children of the cross-house marriage.
|
|
for child in (sun_child, shade_child):
|
|
assert set(child.parent_ids) == {pact["marriage"]["partners"][0], pact["marriage"]["partners"][1]}
|
|
assert sun_child.id in [c for c in pact["children"]]
|
|
|
|
# Marriage partners are cross-linked and both houses record the alliance.
|
|
assert pact["marriage"]["partners"][0] != pact["marriage"]["partners"][1]
|
|
assert engine.houses["house_sun"].feuds["house_shade"].startswith("Allied")
|
|
assert engine.houses["house_shade"].feuds["house_sun"].startswith("Allied")
|
|
|
|
|
|
def test_alliance_recessive_expression_when_rng_is_lenient():
|
|
engine = DynastyEngine(seed=606)
|
|
engine.rng = ScriptedRandom(0.1) # nearly everything expresses
|
|
_craft_house(engine, "house_a", gen3_traits=[CURSED_BLOOD])
|
|
_craft_house(engine, "house_b", gen3_traits=[GRIFFIN_RIDER])
|
|
|
|
engine.form_alliance("house_a", "house_b", mutation_rate=0.0)
|
|
child = engine.houses["house_a"].members[-1]
|
|
# Lenient gate: both pools contribute, including the recessive curse.
|
|
assert CURSED_BLOOD in child.traits
|
|
assert GRIFFIN_RIDER in child.traits
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. House prestige scoring and ordering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_prestige_ordering_matches_crafted_inputs():
|
|
engine = DynastyEngine(seed=707)
|
|
golden = _craft_house(engine, "house_golden", gen3_traits=[GRIFFIN_RIDER, ARCANE_SPARK])
|
|
cursed = _craft_house(engine, "house_cursed", gen3_traits=[CURSED_BLOOD])
|
|
|
|
golden.feuds["house_x"] = "Allied (Mutual Defense Treaty)"
|
|
cursed.feuds["house_y"] = "Blood Feud (Borderlands War)"
|
|
|
|
p_golden = engine.house_prestige("house_golden")
|
|
p_cursed = engine.house_prestige("house_cursed")
|
|
|
|
assert p_golden > p_cursed
|
|
ranked = engine.rank_houses()
|
|
# The crafted pair must appear in the global ranking in score order,
|
|
# regardless of where the seeded template houses land.
|
|
positions = {hid: i for i, (hid, _) in enumerate(ranked)}
|
|
assert positions["house_golden"] < positions["house_cursed"]
|
|
scores = [score for _, score in ranked]
|
|
assert scores == sorted(scores, reverse=True)
|
|
|
|
|
|
def test_alliance_and_depth_raise_prestige():
|
|
engine = DynastyEngine(seed=808)
|
|
_craft_house(engine, "house_rise", gen3_traits=[SILVER_TONGUE])
|
|
_craft_house(engine, "house_other", gen3_traits=[ARCANE_SPARK])
|
|
|
|
before = engine.house_prestige("house_rise")
|
|
engine.form_alliance("house_rise", "house_other", mutation_rate=0.0)
|
|
after_alliance = engine.house_prestige("house_rise")
|
|
assert after_alliance > before
|
|
|
|
before_depth = engine.house_prestige("house_rise")
|
|
engine.extend_lineage("house_rise", additional_generations=2, mutation_rate=0.0)
|
|
after_depth = engine.house_prestige("house_rise")
|
|
assert after_depth > before_depth
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Lore-graph injection reflects the final dynastic state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_lore_injection_reflects_deep_lineage_and_alliances():
|
|
engine = DynastyEngine(seed=909)
|
|
_craft_house(engine, "house_lore", gen3_traits=[GRIFFIN_RIDER])
|
|
_craft_house(engine, "house_ally", gen3_traits=[SILVER_TONGUE])
|
|
engine.extend_lineage("house_lore", additional_generations=2, mutation_rate=0.0)
|
|
engine.form_alliance("house_lore", "house_ally", mutation_rate=0.0)
|
|
|
|
graph = EpistemicLoreGraphManager()
|
|
injected = engine.inject_lore_into_graph("house_lore", graph)
|
|
assert injected >= 5
|
|
|
|
rels = {(e["rel"], e["to"]) for e in graph.edges if e["from"] == "House Lore"}
|
|
deep = [edge for edge in rels if edge[0] == "HOLDS_UNBROKEN_BLOODLINE"]
|
|
assert deep and "5" in deep[0][1]
|
|
assert any(rel == "COMMANDS_PRESTIGE" for rel, _ in rels)
|
|
assert any(rel == "HOLDS_ALLIANCE_WITH" for rel, _ in rels)
|
|
|
|
# Deep-lineage canon is committed as validated canon, not rumor.
|
|
canon_edge = next(e for e in graph.edges if e["rel"] == "HOLDS_UNBROKEN_BLOODLINE")
|
|
assert canon_edge["tier"] == EpistemicTier.VALIDATED_CANON
|
|
|
|
|
|
def test_lore_injection_is_generation_appropriate_for_young_houses():
|
|
engine = DynastyEngine(seed=111)
|
|
graph = EpistemicLoreGraphManager()
|
|
|
|
engine.inject_lore_into_graph("house_vane", graph)
|
|
|
|
rels = {e["rel"] for e in graph.edges if e["from"] == "House Vane of Black Iron"}
|
|
assert "HOLDS_YOUNG_BLOODLINE" in rels # 3 recorded generations only
|
|
assert "HOLDS_UNBROKEN_BLOODLINE" not in rels # no deep-lineage claim
|
|
# Founding canon is validated; young-bloodline status stays proposed.
|
|
young_edge = next(e for e in graph.edges if e["rel"] == "HOLDS_YOUNG_BLOODLINE")
|
|
assert young_edge["tier"] == EpistemicTier.PROPOSED_FACT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Legacy single-generation API unchanged for existing callers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_legacy_single_generation_api_unchanged():
|
|
engine = DynastyEngine(seed=123)
|
|
payload = engine.get_dynasty_payload()
|
|
|
|
assert set(payload.keys()) == {"houses"}
|
|
assert len(payload["houses"]) == 3
|
|
vane = next(h for h in payload["houses"] if h["id"] == "house_vane")
|
|
assert len(vane["members"]) == 5
|
|
assert {m["generation"] for m in vane["members"]} == {1, 2, 3}
|
|
for m in vane["members"]:
|
|
assert set(m.keys()) == LEGACY_MEMBER_KEYS
|
|
assert len(m["traits"]) > 0
|
|
assert set(vane.keys()) == {
|
|
"id", "name", "motto", "crest_icon", "theme_color",
|
|
"seat_of_power", "primary_virtue", "members", "feuds",
|
|
}
|
|
|
|
# Unknown house id still yields zero injections.
|
|
assert engine.inject_lore_into_graph("house_missing", EpistemicLoreGraphManager()) == 0
|
|
|
|
|
|
def test_legacy_seed_output_stable_against_new_features():
|
|
"""Extending one house must not disturb the untouched template houses."""
|
|
engine_a = DynastyEngine(seed=77)
|
|
engine_b = DynastyEngine(seed=77)
|
|
_craft_house(engine_b, "house_extra", gen3_traits=[ARCANE_SPARK])
|
|
|
|
engine_b.extend_lineage("house_vane", additional_generations=1)
|
|
|
|
a_vane = engine_a.get_dynasty_payload()["houses"]
|
|
b_all = engine_b.get_dynasty_payload()["houses"]
|
|
assert len(a_vane) == 3
|
|
assert len(b_all) == 4
|
|
b_vane = next(h for h in b_all if h["id"] == "house_vane")
|
|
b_silverthorn = next(h for h in b_all if h["id"] == "house_silverthorn")
|
|
a_silverthorn = next(h for h in a_vane if h["id"] == "house_silverthorn")
|
|
assert b_silverthorn == a_silverthorn
|
|
assert len(next(m for m in b_vane["members"] if m["generation"] == 4)["traits"]) > 0
|