test-goblin-space / tests /test_beats.py
ASHu2's picture
feat: enhance NPC dialogue and weapon tempering mechanics
fe9c864 unverified
Raw
History Blame Contribute Delete
12.9 kB
"""Story-beat registry + endpoint payload sanity (game_plans/story_plan.md)."""
import pytest
from rune_goblin import beats, dialogue, story
from rune_goblin.world import AREAS
@pytest.fixture(autouse=True)
def _no_dialogue_api(monkeypatch):
"""Keep tests offline: every LLM path falls back deterministically."""
monkeypatch.setenv("RG_USE_DIALOGUE_API", "0")
def test_beat_ids_unique():
ids = [b.id for b in beats.BEATS]
assert len(ids) == len(set(ids))
def test_beat_areas_exist():
for b in beats.BEATS:
assert b.area in AREAS, f"beat {b.id} points at unknown area {b.area}"
def test_first_meet_beats_reference_real_entities():
for b in beats.BEATS:
if b.trigger != "first_meet":
continue
ids = {e.id for e in AREAS[b.area].entities}
assert b.npc in ids, f"beat {b.id}: no entity {b.npc} in {b.area}"
assert b.bark, f"beat {b.id} needs a deterministic bark fallback"
assert b.speaker, f"beat {b.id} needs a speaker name"
def test_area_enter_beats_have_fallback_text():
for b in beats.BEATS:
if b.trigger == "area_enter":
assert b.toast, f"beat {b.id} needs a fallback toast"
assert b.journal, f"beat {b.id} needs a fallback journal line"
def test_beat_conditions_use_allowlisted_flags():
for b in beats.BEATS:
for f in (*b.requires, *b.requires_any, *b.forbids):
assert story.is_allowed_flag(f), f"beat {b.id}: unknown flag {f}"
def test_every_area_has_an_entry_beat():
entry_areas = {b.area for b in beats.BEATS if b.trigger == "area_enter"}
assert entry_areas == set(AREAS), "every map needs at least one entry beat"
def test_gate_routing():
helpers = set(story.HELPER_FLAGS)
devour = set(story.DEVOUR_FLAGS)
allies = beats.get_beat("gate_allies")
debts = beats.get_beat("gate_debts")
mixed = beats.get_beat("gate_mixed")
clean = beats.get_beat("gate_clean")
# pure helper run -> allies only
flags = ["tourist_helped"]
assert beats.beat_eligible(allies, flags)
assert not beats.beat_eligible(debts, flags)
assert not beats.beat_eligible(mixed, flags)
assert not beats.beat_eligible(clean, flags)
# pure debt run -> debts only
flags = ["debt_accepted"]
assert not beats.beat_eligible(allies, flags)
assert beats.beat_eligible(debts, flags)
assert not beats.beat_eligible(mixed, flags)
# both -> mixed only
flags = ["tourist_helped", "debt_accepted"]
assert not beats.beat_eligible(allies, flags)
assert not beats.beat_eligible(debts, flags)
assert beats.beat_eligible(mixed, flags)
# neither -> clean only
assert beats.beat_eligible(clean, [])
assert helpers and devour # guards against table renames emptying the sets
def test_client_manifest_shape():
manifest = beats.client_manifest()
assert len(manifest) == len(beats.BEATS)
for entry in manifest:
assert entry["trigger"] in {"area_enter", "first_meet"}
if entry["trigger"] == "first_meet":
assert entry["npc"] and entry["radius"] >= 1
# text never ships in the manifest; the endpoint owns it
assert "toast" not in entry and "bark" not in entry
def test_generate_beat_fallback(monkeypatch):
monkeypatch.setenv("RG_USE_DIALOGUE_API", "0")
out = dialogue.generate_beat(beat_id="opening_bell", area="Goblin Toll Road",
player={"story_flags": []})
assert out["source"] == "fallback"
assert out["story_toast"].startswith("The Calendar Bell rings thirteen")
assert out["journal_entry"]
assert out["suggested_story_flag"] == ""
def test_generate_beat_rechecks_conditions(monkeypatch):
monkeypatch.setenv("RG_USE_DIALOGUE_API", "0")
# gate ally bark without the earning flag -> skipped, not narrated
out = dialogue.generate_beat(beat_id="meet_gate_tourist",
area="Calendar Gate Approach",
player={"story_flags": []})
assert out.get("skip") is True
# unknown beat -> skipped
out = dialogue.generate_beat(beat_id="nope", area="x", player={})
assert out.get("skip") is True
def test_sanitize_beat_clamps_and_routes_fields():
bark_beat = beats.get_beat("meet_tourist")
raw = {"story_toast": "should be dropped for barks",
"npc_line": "Lost Tourist: " + "y" * 400,
"journal_entry": "j" * 999, "suggested_story_flag": "tourist_helped"}
out = dialogue.sanitize_beat(raw, bark_beat)
assert out["story_toast"] == ""
assert len(out["npc_line"]) <= dialogue.MAX_BARK
assert len(out["journal_entry"]) <= dialogue.MAX_JOURNAL
assert out["suggested_story_flag"] == ""
area_beat = beats.get_beat("library_enter")
out = dialogue.sanitize_beat({"npc_line": "drop me", "story_toast": "t" * 400}, area_beat)
assert out["npc_line"] == ""
assert len(out["story_toast"]) <= dialogue.MAX_TOAST
def test_world_payload_ships_beats():
from rune_goblin.world import build_world
w = build_world(seed=1, admin=False)
assert w["story_beats"] == beats.client_manifest()
assert w["player"]["beats_seen"] == []
def test_story_brief_is_plain_english():
from rune_goblin.dialogue import _story_brief
player = {"goblin_class": "rogue", "level": 2, "weapon": "coin_sling",
"inventory": ["Calendar Shard"],
"story_flags": ["tourist_helped", "queue_goblin_paid",
"calendar_shard_1_taken"]}
brief = _story_brief(player, "The Wet Library")
assert "Goblin Rogue" in brief and "Coin Sling" in brief
assert "Calendar Key" in brief # current objective named plainly
assert "calmed the Lost Tourist" in brief
# no raw internal keys leak to the model
assert "tourist_helped" not in brief and "{" not in brief
def test_main_objective_progression():
assert "Calendar Shard" in story.main_objective({})
assert "Calendar Key" in story.main_objective(
{"inventory": ["Calendar Shard"]})
assert "Calendar Gate" in story.main_objective(
{"inventory": ["Calendar Shard", "Calendar Key"]})
assert "arena" in story.main_objective(
{"inventory": ["Calendar Shard", "Calendar Key"],
"story_flags": ["arena_approach_reached"]})
assert "Defeat or repair" in story.main_objective(
{"story_flags": ["calendar_beast_phase_2"]})
assert "ending" in story.main_objective(
{"story_flags": ["calendar_repaired"]})
def test_flag_gloss_covers_story_critical_flags():
for f in (*story.HELPER_FLAGS, *story.DEVOUR_FLAGS):
assert f in story.FLAG_GLOSS, f"missing gloss for {f}"
for f, text in story.FLAG_GLOSS.items():
assert story.is_allowed_flag(f)
assert text.endswith(".") and "_" not in text
def test_shop_list_and_buy():
from rune_goblin.world import resolve_shop
player = {"gold": 5, "weapon_inventory": ["clerk_wand"], "story_flags": []}
listing = resolve_shop(player, "market_merchant")
ids = [o["id"] for o in listing["offers"]]
assert ids == ["mirror_shield", "bell_staff", "river_thread", "bone_blade"]
assert listing["world_actions"] == [] and listing["line"]
buy = resolve_shop(player, "market_merchant", "mirror_shield")
kinds = [a["type"] for a in buy["world_actions"]]
assert {"add_gold", "add_weapon", "add_inventory"} <= set(kinds)
gold_delta = next(a["amount"] for a in buy["world_actions"] if a["type"] == "add_gold")
assert gold_delta == -4
flags = {a["flag"] for a in buy["world_actions"] if a["type"] == "set_story_flag"}
assert "weapon_bought" in flags and "calendar_repair_possible" in flags
def test_shop_denies_when_broke():
from rune_goblin.world import resolve_shop
player = {"gold": 1, "weapon_inventory": ["clerk_wand"], "story_flags": []}
buy = resolve_shop(player, "market_merchant", "bell_staff")
assert buy.get("denied") is True and buy["world_actions"] == []
# cursed blade needs no gold but takes the debt instead
cursed = resolve_shop(player, "market_merchant", "bone_blade")
flags = {a["flag"] for a in cursed["world_actions"] if a["type"] == "set_story_flag"}
assert "debt_accepted" in flags and "calendar_devour_pressure" in flags
assert not any(a["type"] == "add_gold" for a in cursed["world_actions"])
def test_shop_secret_merchant_and_guards():
from rune_goblin.world import resolve_shop
player = {"gold": 9, "weapon_inventory": ["clerk_wand"], "story_flags": []}
buy = resolve_shop(player, "secret_merchant", "coin_sling")
flags = {a["flag"] for a in buy["world_actions"] if a["type"] == "set_story_flag"}
assert {"secret_merchant_met", "tollmaster_route_open"} <= flags
assert resolve_shop(player, "toll_goblin").get("error") == "not_a_merchant"
owned = resolve_shop({"gold": 9, "weapon_inventory": ["coin_sling"]},
"secret_merchant", "coin_sling")
assert owned["world_actions"][0]["type"] == "upgrade_weapon"
def test_rune_cast_purchase_charges_gold():
from rune_goblin.world import resolve_world_cast
target = {"id": "market_merchant", "type": "npc", "name": "Bone Market Merchant"}
rich = {"gold": 5, "weapon_inventory": ["clerk_wand"], "inventory": [],
"hp": 10, "max_hp": 10, "courage": 5, "max_courage": 9, "level": 2}
out = resolve_world_cast(["coin"], rich, target, seed=7)
kinds = [a["type"] for a in out["world_actions"]]
assert "add_weapon" in kinds
assert any(a["type"] == "add_gold" and a["amount"] == -4 for a in out["world_actions"])
broke = dict(rich, gold=0)
out = resolve_world_cast(["coin"], broke, target, seed=7)
kinds = [a["type"] for a in out["world_actions"]]
assert "add_weapon" not in kinds # denied, no free weapon
def test_price_band_scales_with_stage_and_reputation():
early = {"story_flags": [], "inventory": []}
late = {"story_flags": ["arena_approach_reached", "calendar_shard_1_taken",
"calendar_key_found"],
"inventory": ["Calendar Shard", "Calendar Key"]}
lo_e, hi_e, anchor_e = story.price_band("mirror_shield", early)
lo_l, hi_l, anchor_l = story.price_band("mirror_shield", late)
assert anchor_l > anchor_e # endgame markets gouge
assert lo_e <= anchor_e <= hi_e and lo_l <= anchor_l <= hi_l
# repaying debt earns a kinder price; carrying curses costs extra
kind = story.price_band("mirror_shield", {"story_flags": ["debt_repaid"]})
cursed = story.price_band("mirror_shield", {"story_flags": ["debt_accepted"]})
assert kind[2] < cursed[2]
# the Bone Blade is never priced in gold
assert story.price_band("bone_blade", early) == (0, 0, 0)
def test_shop_prices_clamped_to_band(monkeypatch):
from rune_goblin import dialogue as dlg
offers = [{"id": "mirror_shield", "label": "Mirror Shield", "band": (3, 5, 4)}]
monkeypatch.setenv("RG_USE_DIALOGUE_API", "1")
monkeypatch.setattr(dlg, "_remote_chat", lambda messages: (
'{"prices": [{"id": "mirror_shield", "price": 99, "reason": "Endgame tax."}]}'))
out = dlg.generate_shop_prices(player={"story_flags": []},
area="The Bone Market", offers=offers)
assert out["mirror_shield"]["price"] == 5 # clamped to band hi
assert out["mirror_shield"]["source"] == "model"
# model off -> anchor fallback
monkeypatch.setenv("RG_USE_DIALOGUE_API", "0")
out = dlg.generate_shop_prices(player={}, area="x", offers=offers)
assert out["mirror_shield"] == {"price": 4, "reason": "", "source": "fallback"}
def test_shop_buy_honors_quote_only_inside_band(monkeypatch):
from rune_goblin.world import resolve_shop
monkeypatch.setenv("RG_USE_DIALOGUE_API", "0")
player = {"gold": 9, "weapon_inventory": ["clerk_wand"], "story_flags": []}
lo, hi, anchor = story.price_band("mirror_shield", player)
# in-band quote honored
buy = resolve_shop(player, "market_merchant", "mirror_shield", quoted_price=hi)
paid = next(a["amount"] for a in buy["world_actions"] if a["type"] == "add_gold")
assert paid == -hi
# forged out-of-band quote falls back to the anchor
buy = resolve_shop(player, "market_merchant", "mirror_shield", quoted_price=1)
paid = next(a["amount"] for a in buy["world_actions"] if a["type"] == "add_gold")
assert paid == -anchor
def test_client_manifest_marks_major_beats():
entries = {e["id"]: e for e in beats.client_manifest()}
assert entries["opening_bell"]["major"] is True
assert entries["arena_enter"]["major"] is True
assert entries["gate_allies"]["major"] is True
assert entries["meet_collector"]["major"] is True
# ambient mood beats stay in the toast channel
assert entries["caverns_enter"]["major"] is False
assert entries["meet_tourist"]["major"] is False