Spaces:
Runtime error
Runtime error
| """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 | |
| 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 | |