Files
AetherTable/python/tests/test_starter_adventure_depth.py
John SmithandClaude 47ca2bd3c2 feat(content): Sunken Crypt of Karas deepened to playable campaign shape (iteration 52)
8-chamber 44x30 map with per-chamber engine-WFC stitching, 7-node
encounter tree with save-DC hazards, DMG XP-threshold balance audit at
import (old opener was deadly+; now 5 fights EASY/MEDIUM, wight climax),
real compendium item ids in milestone loot, 9 tiered paradox-checked
lore seeds, full export→import round-trip verified. 22 tests red-first;
pytest 860 passed / 28 skipped.

Co-Authored-By: Claude <[email protected]>
2026-08-25 14:02:19 -04:00

503 lines
21 KiB
Python

"""Iteration 52 — Pillar-2 depth: the flagship starter adventure must be a
*complete playable campaign package*, not a monster list.
TDD red-first module. Covers:
1. Multi-area map layout (>= 6 chambers, WFC-style corridors, dressing tiles)
with per-area metadata the board can hydrate from.
2. An explicit encounter tree: entry fight -> exploration hazards -> mini-boss,
each node carrying a difficulty verdict computed with the standard DMG
XP-threshold model over real compendium XP values.
3. Balance sanity for a level-1 party of four: no encounter is DEADLY, and the
total adventure budget stays within the party's daily XP threshold.
4. Loot placements referencing real SRD 5.2 magic items by id.
5. Lore-graph seeds expressed as tiered assertions consistent with the Pillar-7
epistemic tiers and paradox-checked against canon before shipping.
6. Full export -> import round-trip through the existing packager + gateway,
including offline hydration validation of every token against the map.
"""
import base64
import json
import os
import pytest
from fastapi.testclient import TestClient
from vtt_orchestrator.compendium import starter_adventures
from vtt_orchestrator.compendium.bundle_packager import global_bundle_packager
from vtt_orchestrator.server import app as server_app
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
MONSTERS_FILE = os.path.join(PROJECT_ROOT, "compendium", "srd_5_2_monsters.json")
ITEMS_FILE = os.path.join(PROJECT_ROOT, "compendium", "srd_5_2_magic_items.json")
client = TestClient(server_app)
def _load_compendium(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return {e["id"]: e for e in json.load(f)}
def _monsters() -> dict:
return _load_compendium(MONSTERS_FILE)
def _signup(name: str) -> dict:
email = f"{name}_{abs(hash(name)) % 10**9}@example.com"
resp = client.post(
"/api/v1/auth/signup",
json={"email": email, "username": name, "display_name": name.title(),
"password": "dice-dice", "role": "player"},
)
assert resp.status_code == 200, resp.text
return resp.json()
# ===========================================================================
# 1. Multi-area map layout depth
# ===========================================================================
def test_layout_has_multiple_meaningful_chambers():
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
rooms = layout["rooms"]
# A complete campaign package is a multi-area dungeon, not one open hall.
assert len(rooms) >= 6, f"expected >= 6 chambers, got {len(rooms)}"
ids = [r["id"] for r in rooms]
assert len(set(ids)) == len(ids), "chamber ids must be unique"
for room in rooms:
x, y, w, h = room["rect"]
assert w >= 4 and h >= 3, f"chamber {room['id']} too small to fight in"
assert room["name"] and room["purpose"], "chambers need names + purpose"
def test_layout_rooms_are_non_overlapping_and_fully_carved():
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
grid = layout["tiles"]
rects = [tuple(r["rect"]) for r in layout["rooms"]]
for i, (ax, ay, aw, ah) in enumerate(rects):
for j, (bx, by, bw, bh) in enumerate(rects):
if i == j:
continue
assert ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay, (
f"chambers {i} and {j} overlap"
)
for rect in rects:
x, y, w, h = rect
for gy in range(y, y + h):
for gx in range(x, x + w):
assert grid[gy][gx] != 1, f"wall inside chamber at {(gx, gy)}"
def test_layout_is_fully_connected_with_corridor_doors():
"""Every chamber must be reachable from the entrance via floor/door tiles."""
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
grid = layout["tiles"]
height, width = len(grid), len(grid[0])
walkable = lambda x, y: grid[y][x] != 1 # everything but solid wall
start = (layout["entrance"]["x"], layout["entrance"]["y"])
assert walkable(*start), "entrance must be on a walkable tile"
seen = {start}
frontier = [start]
while frontier:
x, y = frontier.pop()
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = x + dx, y + dy
if 0 <= nx < width and 0 <= ny < height and (nx, ny) not in seen \
and walkable(nx, ny):
seen.add((nx, ny))
frontier.append((nx, ny))
def center(rect):
x, y, w, h = rect
return (x + w // 2, y + h // 2)
entrance_room = min(
layout["rooms"],
key=lambda r: abs(center(r["rect"])[0] - start[0]) + abs(center(r["rect"])[1] - start[1]),
)
for room in layout["rooms"]:
cx, cy = center(room["rect"])
assert (cx, cy) in seen, f"chamber '{room['name']}' unreachable from entrance"
# Corridors between chambers are doored, not just holes in the wall.
assert len(layout["doors"]) >= len(layout["rooms"]) - 1
def test_layout_dressing_tiles_present():
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
flat = [t for row in layout["tiles"] for t in row]
assert flat.count(3) >= 1, "no altars (shrine / effigy) carved"
assert flat.count(4) >= len(layout["rooms"]), "expected a chest per chamber area"
def test_dressing_tiles_are_walkable_not_walls():
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
for y, row in enumerate(layout["tiles"]):
for x, t in enumerate(row):
if t in (3, 4): # altar / chest
assert t != 1, "dressing must never be carved as wall"
def test_hazards_declared_in_layout():
layout = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
hazards = layout.get("hazards", [])
assert isinstance(hazards, list) and len(hazards) >= 1
for hazard in hazards:
assert {"kind", "x", "y"} <= set(hazard)
assert hazard.get("dc", 0) > 0, "exploration hazards need a save DC"
grid = layout["tiles"]
assert grid[hazard["y"]][hazard["x"]] in (0, 2, 4), (
"hazard must sit on traversable ground"
)
# ===========================================================================
# 2. Encounter tree shape
# ===========================================================================
@pytest.fixture(scope="module")
def karas() -> dict:
return starter_adventures.get_starter_adventure(starter_adventures.KARAS_KEY)
def test_encounter_tree_is_a_progression(karas):
tree = karas["encounter_tree"]
assert len(tree) >= 5, "entry fight + hazards + miniboss expected"
kinds = [n["kind"] for n in tree]
assert kinds[0] == "combat", "the tree opens with an entry fight"
assert "hazard" in kinds, "exploration hazard stage missing"
assert kinds[-1] == "combat" and tree[-1]["is_mini_boss"] is True
parents = [node.get("leads_to") for node in tree[:-1]]
node_ids = {n["id"] for n in tree}
for parent, nxt in zip(tree[:-1], tree[1:]):
assert parent["leads_to"] == nxt["id"], "tree must be a single chain"
assert parent["leads_to"] in node_ids
del parents
def test_encounter_tree_backed_by_legacy_encounters(karas):
tree_ids = {n["encounter_id"] for n in karas["encounter_tree"]
if n.get("encounter_id")}
legacy_ids = {e["id"] for e in karas["encounters"]}
assert tree_ids == legacy_ids, "tree nodes must map onto concrete encounters"
# ===========================================================================
# 3. Encounter balance (DMG XP-threshold model)
# ===========================================================================
LEVEL_1_THRESHOLDS = {"easy": 50, "medium": 100, "hard": 200, "deadly": 400}
PARTY_SIZE = 4
DAILY_BUDGET_LEVEL_1 = 300 # per PC; a party of four gets 1200 XP/day
def _encounter_xp(node: dict, compendium: dict) -> int:
raw = sum(
int(compendium[m["monster_id"]]["xp"]) * int(m["quantity"])
for m in node["monsters"]
)
count = sum(int(m["quantity"]) for m in node["monsters"])
mult = 0.5 if count <= 1 else (1.0 if count <= 2 else (1.5 if count <= 6 else (2.0 if count <= 10 else 2.5)))
return int(raw * mult)
def _verdict(xp: int):
# Thresholds are per character; scale them to the full party.
scaled = {band: value * PARTY_SIZE for band, value in LEVEL_1_THRESHOLDS.items()}
if xp >= scaled["deadly"]:
return "DEADLY"
if xp >= scaled["hard"]:
return "HARD"
if xp >= scaled["medium"]:
return "MEDIUM"
if xp >= scaled["easy"]:
return "EASY"
return "TRIVIAL"
def _encounter_by_id(karas: dict, encounter_id: str) -> dict:
return next(e for e in karas["encounters"] if e["id"] == encounter_id)
def test_every_combat_node_carries_difficulty_verdict(karas):
compendium = _monsters()
for node in karas["encounter_tree"]:
if node["kind"] != "combat":
continue
balance = node.get("balance")
assert balance, f"{node['id']} missing balance data"
expected_xp = _encounter_xp(
_encounter_by_id(karas, node["encounter_id"]), compendium)
assert balance["adjusted_xp"] == expected_xp, (
f"{node['id']}: shipped {balance['adjusted_xp']} vs recomputed "
f"{expected_xp} — stale numbers ship broken difficulty"
)
assert balance["difficulty"] == _verdict(expected_xp)
assert balance["party_level"] == 1 and balance["party_size"] == 4
def test_no_tpk_in_round_one_level_1_party(karas):
"""Nothing in the flagship may be DEADLY for the advertised level-1 party,
and hazard stages must never field monsters above the party's tier."""
for node in karas["encounter_tree"]:
if node["kind"] == "combat":
assert node["balance"]["difficulty"] != "DEADLY", (
f"{node['id']} can TPK a level-1 party ({node['balance']['adjusted_xp']} adjusted XP)"
)
encounter = _encounter_by_id(karas, node["encounter_id"])
for ref in encounter["monsters"]:
entry = _monsters()[ref["monster_id"]]
cr = entry["challenge_rating"]
num, _, den = cr.partition("/")
cr_val = float(num) / float(den or 1)
assert cr_val <= 3, (
f"{ref['monster_id']} CR {cr} exceeds level-1-appropriate ceiling"
)
else:
# Hazard nodes reference declared exploration hazards, not fights.
assert node.get("hazard_id") in {
h["id"] for h in starter_adventures.KARAS_HAZARD_PLAN
}, f"{node['id']} references an undeclared hazard"
def test_total_adventure_within_two_adventuring_days(karas):
"""The delve is a two-day delve: the Choir Landing waystation is the
documented long-rest site between the ossuary and the vault."""
total = sum(n["balance"]["adjusted_xp"] for n in karas["encounter_tree"]
if n["kind"] == "combat")
two_day_budget = DAILY_BUDGET_LEVEL_1 * PARTY_SIZE * 2
assert 0 < total <= two_day_budget, (
f"{total} adjusted XP exceeds two adventuring days for the level-1 party"
)
assert total >= LEVEL_1_THRESHOLDS["medium"], "adventure must not be empty content"
# The mini-boss must be the single hardest fight of the delve.
verdicts = [n["balance"]["adjusted_xp"] for n in karas["encounter_tree"]
if n["kind"] == "combat"]
assert verdicts[-1] == max(verdicts), "mini-boss must be the climax fight"
def test_spawn_tokens_carry_compendium_stats_and_balance(karas):
data = starter_adventures.starter_campaign_data(prefer_engine=False)
tokens = data["tokens"]
assert tokens, "bundle must spawn pre-seeded enemy tokens"
compendium = _monsters()
for tok in tokens:
assert tok["compendium_id"] in compendium
entry = compendium[tok["compendium_id"]]
assert tok["max_hp"] == entry["hp"] and tok["ac"] == entry["ac"]
assert "xp" in tok and tok["xp"] == entry["xp"]
assert tok["challenge_rating"] == entry["challenge_rating"]
assert tok["area_id"] in {r["id"] for r in data["adventure"]["layout"]["rooms"]}
# Tokens must be placed on walkable tiles (floor/door/chest/altar).
tiles = data["adventure"]["layout"]["tiles"]
assert tiles[int(tok["y"])][int(tok["x"])] != 1, (
f"{tok['name']} spawned inside a wall at {(tok['x'], tok['y'])}"
)
def test_loot_placements_reference_real_items():
data = starter_adventures.starter_campaign_data(prefer_engine=False)
items = _load_compendium(ITEMS_FILE)
placements = data["loot_tables"]["placements"]
assert len(placements) >= 3, "loot must be placed in the world, not just listed"
for placement in placements:
assert placement["container"] in ("chest", "altar", "boss_hoard")
assert placement["area_id"] in {r["id"] for r in data["adventure"]["layout"]["rooms"]}
for item_ref in placement["items"]:
if item_ref.get("item_id"):
assert item_ref["item_id"] in items, (
f"invented item {item_ref['item_id']!r}"
)
assert item_ref["name"] == items[item_ref["item_id"]]["name"]
else:
assert item_ref["name"] in set(starter_adventures.CRYPT_LOOT_TABLE_NAMES)
# ===========================================================================
# 5. Lore seeds as valid tiered assertions
# ===========================================================================
def test_lore_seeds_parse_as_tiered_assertions(karas):
from vtt_orchestrator.schemas.models import EpistemicTier, LoreAssertionPayload
from vtt_orchestrator.lore.epistemic_graph import EpistemicLoreGraphManager
manager = EpistemicLoreGraphManager()
canon_edges = {(e["from"], e["rel"], e["to"]) for e in manager.edges}
payloads = []
for assertion in karas["lore_assertions"]:
payload = LoreAssertionPayload(**assertion) # pydantic validation
payloads.append(payload)
assert len(payloads) >= 8, "a campaign package needs a seeded lore web"
tiers = {p.epistemic_tier for p in payloads}
assert EpistemicTier.SUBJECTIVE_RUMOR in tiers, "rumor tier must be exercised"
assert EpistemicTier.VALIDATED_CANON in tiers, "canon tier must be exercised"
for p in payloads:
assert p.context_sentence.strip(), "every seed needs grounding prose"
assert 0.0 <= p.confidence_score <= 1.0
# Canon-consistency: nothing may contradict the canon graph.
manager.nodes.update({
n["id"]: dict(n) for n in karas["lore_seeds"]["nodes"] if n["id"] not in manager.nodes
})
for p in payloads:
verdict, reason, _ms = manager.query_paradox(
p.subject_node_id, p.predicate_relation, p.object_node_id)
assert verdict, f"paradox seed shipped: {reason}"
def test_canon_nodes_never_regressed(karas):
vane = next(n for n in karas["lore_seeds"]["nodes"] if n["id"] == "NPC_Baron_Vane")
keep = next(n for n in karas["lore_seeds"]["nodes"] if n["id"] == "Location_Keep")
assert vane["life_stage"] == "DECEASED" and keep["status"] == "DESTROYED"
# ===========================================================================
# 6. Bundle export -> import round trip (+ offline hydration)
# ===========================================================================
@pytest.fixture(scope="module")
def imported_bundle() -> dict:
path = starter_adventures.build_starter_bundle(prefer_engine=False)
with open(path, "rb") as f:
return global_bundle_packager.import_bundle(f.read())
def test_bundle_member_inventory(imported_bundle):
manifest = imported_bundle["manifest"]
assert manifest["adventure_key"] == starter_adventures.KARAS_KEY
artifact = imported_bundle["adventure"]
assert len(artifact["encounter_tree"]) >= 5
assert len(artifact["lore_assertions"]) >= 8
assert len(imported_bundle["map_layout"]["walls"]) > 0
assert imported_bundle["manifest"]["token_count"] == len(imported_bundle["tokens"])
assert imported_bundle["manifest"]["lore_propositions_count"] == len(
imported_bundle["lore_graph"]["edges"])
assert len(imported_bundle["loot_tables"]["placements"]) >= 3
assert artifact["layout_source"] in ("engine_wfc", "fallback_procedural")
def test_roundtrip_preserves_layout_and_tokens(imported_bundle):
original = starter_adventures.starter_campaign_data(prefer_engine=False)
assert imported_bundle["map_layout"]["grid_width"] == original["grid_dimensions"]["width"]
assert imported_bundle["map_layout"]["walls"] == original["walls"]
assert imported_bundle["tokens"] == original["tokens"]
def test_offline_hydration_validation_of_imported_bundle(imported_bundle):
"""The gateway's import route validates walls/tokens offline-first; mirror
that contract here so bundle defects fail in CI even without an engine."""
layout = imported_bundle["map_layout"]
width, height = layout["grid_width"], layout["grid_height"]
solid = {(w["x"], w["y"]) for w in layout["walls"]}
assert 0 < len(solid) < width * height
for tok in imported_bundle["tokens"]:
assert 0 <= int(tok["x"]) < width and 0 <= int(tok["y"]) < height
entity_id = str(tok["id"])
assert len(entity_id) > 0
assert int(tok["hp"]) > 0 and int(tok["ac"]) > 0
def test_gateway_export_route_round_trip():
user = _signup("depth_bot")
resp = client.post(
f"/api/v1/adventures/starter/{starter_adventures.KARAS_KEY}/export",
headers={"Authorization": f"Bearer {user['token']}"},
)
assert resp.status_code == 200, resp.text
imported = global_bundle_packager.import_bundle(resp.content)
artifact = imported["adventure"]
assert artifact["key"] == starter_adventures.KARAS_KEY
assert len(artifact["encounters"]) >= 5
assert len(artifact["encounter_tree"]) >= 5
assert imported["manifest"]["token_count"] == len(imported["tokens"]) >= 10
# ===========================================================================
# 7. Engine-WFC path keeps the full campaign structure
# ===========================================================================
def _fake_engine_chambers(method: str, path: str, payload: dict) -> dict:
import random as _random
desc = payload["room_desc"]
width, height = desc["width"], desc["height"]
rng = _random.Random(payload["seed"])
tiles = [[1] * width for _ in range(height)]
for y in range(1, height - 1):
for x in range(1, width - 1):
tiles[y][x] = 0 if rng.random() < 0.9 else 4
return {"tiles": tiles}
def test_engine_wfc_path_preserves_campaign_structure(monkeypatch):
from vtt_orchestrator.routing import engine_client
monkeypatch.setattr(engine_client, "engine_request_sync",
_fake_engine_chambers)
layout = starter_adventures.resolve_crypt_layout(
starter_adventures.KARAS_SEED, prefer_engine=True)
assert layout["source"] == "engine_wfc"
fallback = starter_adventures.generate_crypt_layout(starter_adventures.KARAS_SEED)
assert len(layout["rooms"]) == len(fallback["rooms"]) >= 6
# Corridors/doors/entrance survive the stitch; hazards stay on open ground.
assert len(layout["doors"]) >= len(layout["rooms"]) - 1
for hazard in layout["hazards"]:
assert layout["tiles"][hazard["y"]][hazard["x"]] != 1
for x, y in [(w["x"], w["y"]) for w in layout["doors"]]:
assert layout["tiles"][y][x] == 2
data = starter_adventures.starter_campaign_data(prefer_engine=True)
room_ids = {r["id"] for r in data["adventure"]["layout"]["rooms"]}
assert all(tok["area_id"] in room_ids for tok in data["tokens"])
assert len(data["loot_tables"]["placements"]) >= 3
def test_engine_rejection_falls_back_to_procedural(monkeypatch):
from vtt_orchestrator.routing import engine_client
def broken_engine(method, path, payload):
raise RuntimeError("engine down")
monkeypatch.setattr(engine_client, "engine_request_sync", broken_engine)
layout = starter_adventures.resolve_crypt_layout(
starter_adventures.KARAS_SEED, prefer_engine=True)
assert layout["source"] == "fallback_procedural"
procedural = starter_adventures.generate_crypt_layout(
starter_adventures.KARAS_SEED)
assert layout["walls"] == procedural["walls"]
def garbage_engine(method, path, payload):
return {"tiles": [[9, 9], [9, 9]]}
monkeypatch.setattr(engine_client, "engine_request_sync", garbage_engine)
layout = starter_adventures.resolve_crypt_layout(
starter_adventures.KARAS_SEED, prefer_engine=True)
assert layout["source"] == "fallback_procedural"
def test_gateway_import_route_accepts_flagship_bundle():
user = _signup("depth_importer")
path = starter_adventures.build_starter_bundle(prefer_engine=False)
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
resp = client.post(
"/api/v1/campaign/import-bundle",
headers={"Authorization": f"Bearer {user['token']}"},
json={"bundle_b64": b64, "session_name": "Karas Depth Run"},
)
if resp.status_code == 502:
pytest.skip("engine not running")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "IMPORTED"
assert body["map_walls_applied"] > 0
assert body["tokens_spawned"] >= 10