new RPG system
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from services.rpg_plot import (
|
||||
prune_beats_for_done_quests,
|
||||
@@ -6,6 +7,10 @@ from services.rpg_plot import (
|
||||
should_advance_arc_keywords,
|
||||
pop_matching_beats,
|
||||
dice_outcome_to_beat_trigger,
|
||||
active_quest_titles_to_close,
|
||||
can_fire_beat,
|
||||
record_beat_fired,
|
||||
complete_quest_for_fired_beat,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,21 +26,60 @@ def test_prune_removes_beat_when_quest_done():
|
||||
assert arc["beats"] == []
|
||||
|
||||
|
||||
def test_stuck_recovery_fires_when_no_active_quests():
|
||||
def test_stuck_recovery_fires_when_no_active_quests_and_cooldown_ok():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
|
||||
]
|
||||
],
|
||||
"meta": {"last_beat_fired_at_user_turn": 1},
|
||||
}
|
||||
quests = [{"title": "Old", "status": "done"}]
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(arc, quests, "hello")
|
||||
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
||||
|
||||
arc2, fired, pruned, mode = asyncio.run(run())
|
||||
arc2, fired, pruned, mode, extras = asyncio.run(run())
|
||||
assert mode == "stuck_recovery"
|
||||
assert fired[0]["title"] == "New Beat"
|
||||
assert arc2["beats"] == []
|
||||
assert extras.get("cooldown_skipped") is False
|
||||
|
||||
|
||||
def test_stuck_recovery_skipped_when_cooldown_active():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
||||
],
|
||||
"meta": {"last_beat_fired_at_user_turn": 4},
|
||||
}
|
||||
quests = []
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
||||
|
||||
arc2, fired, _, mode, extras = asyncio.run(run())
|
||||
assert mode == ""
|
||||
assert fired == []
|
||||
assert extras.get("cooldown_skipped") is True
|
||||
assert len(arc2["beats"]) == 1
|
||||
|
||||
|
||||
def test_stuck_recovery_skipped_after_reconcile_closed_quests():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
||||
],
|
||||
}
|
||||
quests = []
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(
|
||||
arc, quests, "hello", user_turn=10, reconcile_closed_count=2
|
||||
)
|
||||
|
||||
_, fired, _, mode, _ = asyncio.run(run())
|
||||
assert mode == ""
|
||||
assert fired == []
|
||||
|
||||
|
||||
def test_dice_outcome_maps_to_after_fail():
|
||||
@@ -44,7 +88,7 @@ def test_dice_outcome_maps_to_after_fail():
|
||||
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
|
||||
|
||||
|
||||
def test_after_fail_beat_fires_on_dice_failure():
|
||||
def test_after_fail_beat_fires_on_dice_failure_only_with_needs_check():
|
||||
arc = {
|
||||
"beats": [
|
||||
{
|
||||
@@ -54,25 +98,94 @@ def test_after_fail_beat_fires_on_dice_failure():
|
||||
"injection": "The stumble leaves you both shaken.",
|
||||
"choices": [{"id": "a", "label": "Try again"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def run(needs_check: bool):
|
||||
return await process_arc_beats(
|
||||
arc, [], "продолжаем", last_dice_outcome="failure", needs_check=needs_check, user_turn=3
|
||||
)
|
||||
|
||||
arc_ok, fired, _, mode, _ = asyncio.run(run(True))
|
||||
assert mode == "after_dice"
|
||||
assert fired[0]["id"] == "b_fail"
|
||||
assert arc_ok["beats"] == []
|
||||
|
||||
arc2 = {
|
||||
"beats": [
|
||||
{
|
||||
"id": "b_ok",
|
||||
"title": "Victory Lap",
|
||||
"trigger": "event_driven:after_success",
|
||||
"id": "b_fail",
|
||||
"title": "Dust Yourself Off",
|
||||
"trigger": "event_driven:after_fail",
|
||||
"choices": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(
|
||||
arc, [], "продолжаем разговор", last_dice_outcome="failure"
|
||||
)
|
||||
async def run_no_check():
|
||||
with patch(
|
||||
"services.rpg_plot.classify_plot_beat",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
return await process_arc_beats(
|
||||
arc2,
|
||||
[],
|
||||
"продолжаем",
|
||||
last_dice_outcome="failure",
|
||||
needs_check=False,
|
||||
user_turn=3,
|
||||
allow_stuck_recovery=False,
|
||||
)
|
||||
|
||||
arc2, fired, _, mode = asyncio.run(run())
|
||||
assert mode == "after_dice"
|
||||
assert fired[0]["id"] == "b_fail"
|
||||
assert len(arc2["beats"]) == 1
|
||||
assert arc2["beats"][0]["id"] == "b_ok"
|
||||
_, fired2, _, mode2, _ = asyncio.run(run_no_check())
|
||||
assert mode2 == ""
|
||||
assert fired2 == []
|
||||
|
||||
|
||||
def test_orphan_active_quests_closed_when_not_in_arc():
|
||||
arc = {
|
||||
"beats": [
|
||||
{
|
||||
"id": "b_new_3",
|
||||
"title": "Defensive Magic",
|
||||
"trigger": "event_driven:after_fail",
|
||||
"choices": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
quests = [
|
||||
{"title": "Floral Whispers", "status": "active"},
|
||||
{"title": "Gift from the Glade", "status": "active"},
|
||||
{"title": "Defensive Magic", "status": "active"},
|
||||
{"title": "Old Done", "status": "done"},
|
||||
]
|
||||
closed = active_quest_titles_to_close(arc, quests)
|
||||
assert set(closed) == {"Floral Whispers", "Gift from the Glade"}
|
||||
|
||||
|
||||
def test_can_fire_beat_respects_min_gap():
|
||||
arc = {"meta": {"last_beat_fired_at_user_turn": 5}}
|
||||
assert can_fire_beat(arc, 6, min_gap=2) is False
|
||||
assert can_fire_beat(arc, 7, min_gap=2) is True
|
||||
|
||||
|
||||
def test_record_beat_fired_updates_meta():
|
||||
arc = {}
|
||||
record_beat_fired(arc, {"id": "b1"}, 8)
|
||||
assert arc["meta"]["last_beat_fired_at_user_turn"] == 8
|
||||
assert arc["meta"]["last_beat_id"] == "b1"
|
||||
|
||||
|
||||
def test_complete_quest_for_fired_beat():
|
||||
beat = {"id": "b1", "title": "Pancake Talk", "injection": "..."}
|
||||
|
||||
async def run():
|
||||
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as mock:
|
||||
await complete_quest_for_fired_beat("sess-1", beat)
|
||||
mock.assert_called_once_with("sess-1", "Pancake Talk", "done")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_keyword_fallback_travel():
|
||||
|
||||
Reference in New Issue
Block a user