new RPG system

This commit is contained in:
2026-06-05 14:57:15 +03:00
parent 6189a5fb74
commit 01b16dbeaa
29 changed files with 2395 additions and 311 deletions
+195
View File
@@ -0,0 +1,195 @@
import asyncio
import json
from unittest.mock import AsyncMock, patch
from services.rpg_story import (
migrate_beats_to_steps,
normalize_story_arc,
get_current_step,
step_progress,
is_arc_completed,
sync_quest_to_current_step,
apply_step_advance,
apply_story_post,
is_new_arc_request,
normalize_new_arc_first,
new_arc_roll_choice,
append_new_arc_roll_choice,
format_step_guidance_for_character,
format_new_arc_opening,
normalize_step,
resolve_step_injection,
roll_next_arc,
)
def test_migrate_beats_to_steps():
arc = {
"beats": [
{"id": "b1", "title": "Meet Yuki", "injection": "Door opens.", "choices": []},
]
}
out = migrate_beats_to_steps(arc)
assert len(out["steps"]) == 1
assert "beats" not in out
assert out["steps"][0]["title"] == "Meet Yuki"
def test_step_progress():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}, {"id": "s2", "title": "B"}],
"current_step_index": 1,
})
assert step_progress(arc) == (2, 2)
def test_is_arc_completed():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}],
"current_step_index": 1,
"status": "active",
})
assert is_arc_completed(arc)
def test_new_arc_request_detected():
assert is_new_arc_request("Начать новую арку")
assert is_new_arc_request("Начать новый квест: 'Тайна плазменных шаров'")
assert is_new_arc_request("Start new quest")
assert not is_new_arc_request("привет")
def test_normalize_new_arc_first():
assert normalize_new_arc_first("user") == "user"
assert normalize_new_arc_first("Character") == "character"
assert normalize_new_arc_first("other") is None
def test_new_arc_roll_choice_and_filter():
base = new_arc_roll_choice()
assert base["type"] == "new_arc_roll"
merged = append_new_arc_roll_choice([
{"id": "x", "label": "Начать новый квест: Foo", "source": "narrator"},
{"id": "y", "label": "Пойти спать", "source": "narrator"},
])
assert len(merged) == 2
assert merged[-1]["type"] == "new_arc_roll"
assert not any("новый квест" in (c.get("label") or "").lower() for c in merged[:-1])
def test_normalize_step_repairs_legacy_beat_shape():
step = normalize_step({
"status_quo": "Сара и Григо прибывают к лаборатории.",
"choices": [{"label": "Войти", "injection": "*Дверь скрипит*"}],
}, 0)
assert step["title"]
assert step["completion_criteria"]
assert resolve_step_injection(step) == "*Дверь скрипит*"
def test_format_new_arc_opening():
arc = {"title": "Тайна", "global_story": "Расследование шаров."}
step = {"status_quo": "У ворот лаборатории тихо.", "choices": []}
text = format_new_arc_opening(arc, step, lang="ru")
assert "Тайна" in text
assert "лаборатории" in text or "Расследование" in text
def test_guidance_block_ru():
step = {"title": "Приютить", "goal": "Впустить внутрь", "character_guidance": "Робость"}
block = format_step_guidance_for_character(step, {"global_story": "x", "steps": [step]}, lang="ru")
assert "Приютить" in block
assert "ОБЯЗАТЕЛЬНОЕ" in block
def test_apply_step_advance():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "injection": "Next scene.", "choices": []},
],
"current_step_index": 0,
})
async def run():
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as uq:
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await apply_step_advance("sess", arc), uq
result, mock_uq = asyncio.run(run())
assert result["advanced"] is True
assert result["injection"] == "Next scene."
mock_uq.assert_called()
def test_apply_story_post_step_complete_fallback_via_note():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "choices": []},
],
"current_step_index": 0,
})
post = {
"step_complete": False,
"step_completion_note": "The scene clearly satisfies the completion criteria.",
}
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(arc, ensure_ascii=False)}
with patch(
"services.rpg_story.apply_step_advance",
new_callable=AsyncMock,
) as asa:
asa.return_value = {"advanced": True, "arc_completed": False, "new_step": arc["steps"][1], "injection": ""}
r = await apply_story_post("sess", post, arc, {"quests": True})
return r, asa
r, asa = asyncio.run(run())
assert r["step_advanced"] is True
assert asa.await_count == 1
def test_roll_next_arc_combines_context_for_generate_next_arc():
old_arc = normalize_story_arc({
"title": "Arc One",
"steps": [{"id": "s1", "title": "Done"}],
"current_step_index": 1,
"status": "completed",
})
new_arc = normalize_story_arc({
"title": "Arc Two",
"steps": [{"id": "s1", "title": "Start"}],
"current_step_index": 0,
})
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(old_arc, ensure_ascii=False)}
with patch("services.rpg_plot.generate_next_arc", new_callable=AsyncMock) as gna:
gna.return_value = new_arc
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await roll_next_arc(
"sess",
{"name": "Sarah", "description": "", "scenario": ""},
"Начать новую арку",
"adventure",
recent_context="assistant: goodbye",
), gna
result, gna = asyncio.run(run())
assert result["title"] == "Arc Two"
gna.assert_awaited_once()
assert gna.await_args.args[3] == "assistant: goodbye\nНачать новую арку"