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
+130 -17
View File
@@ -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():
+13 -3
View File
@@ -29,7 +29,7 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
},
{"role": "narrator", "content": narrator_json},
]
llm = messages_for_llm(history, "system+runtime")
llm = messages_for_llm(history, "system+runtime", rp_lang="en")
user_msgs = [m for m in llm if m["role"] == "user"]
assert len(user_msgs) == 2
assert "canonical outcome" in user_msgs[0]["content"].lower()
@@ -39,14 +39,24 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
assert "Do NOT write a success version" in user_msgs[1]["content"]
def test_format_narrator_failure_wording():
def test_format_narrator_failure_wording_en():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "It failed."}
{"roll": 5, "outcome": "failure", "text": "It failed."},
lang="en",
)
assert "FAILED" in text
assert "Do NOT write a success version" in text
def test_format_narrator_failure_wording_ru():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "Не вышло."},
lang="ru",
)
assert "ОБЯЗАТЕЛЬНО" in text
assert "Не удалось" in text or "НЕ удалось" in text
def test_parse_narrator_message_roundtrip():
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
data = parse_narrator_message(raw)
+26 -2
View File
@@ -1,4 +1,9 @@
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice
from services.rpg_plot import (
choices_from_beat,
choices_from_narrator,
normalize_choice,
format_beat_injection_for_character,
)
def test_choices_from_beat_tags_source():
@@ -13,7 +18,7 @@ def test_choices_from_beat_tags_source():
}
out = choices_from_beat(beat)
assert len(out) == 2
assert out[0]["source"] == "plot_beat"
assert out[0]["source"] == "plot_step"
assert out[0]["beat_title"] == "Rest Stop Confession"
assert out[0]["beat_id"] == "b_new_1"
assert "beat_injection" in out[0]
@@ -25,5 +30,24 @@ def test_choices_from_narrator_tags_source():
assert "beat_title" not in out[0]
def test_beat_injection_block_for_character_prompt():
block = format_beat_injection_for_character(
"Over pancakes, Luna steals glances at you.",
lang="en",
)
assert "Plot hint" in block
assert "Over pancakes" in block
assert format_beat_injection_for_character("") == ""
def test_beat_injection_block_ru():
block = format_beat_injection_for_character(
"Луна крадёт взгляды через блины.",
lang="ru",
)
assert "Сюжетная подсказка" in block
assert "по-русски" in block
def test_normalize_choice_skips_empty_label():
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
+31
View File
@@ -1,10 +1,14 @@
from services.rpg_facts import (
FACTS_COMPRESS_SYSTEM,
merge_facts,
parse_facts_list,
facts_to_prompt,
facts_list_to_json,
dedupe_facts_fuzzy,
facts_are_similar,
is_likely_narrative_event,
filter_durable_facts,
validate_compressed_against_source,
)
@@ -34,6 +38,33 @@ def test_dedupe_collapses_duplicates():
assert len(out) == 2
def test_facts_compress_system_format_escapes_json_braces():
system = FACTS_COMPRESS_SYSTEM.format(target=22)
assert "Target at most 22 facts" in system
assert '{"text":' in system
def test_narrative_event_facts_filtered():
assert is_likely_narrative_event("Сара и Григо отправились через портал")
assert not is_likely_narrative_event("Сара носит красный спортивный костюм")
facts = filter_durable_facts([
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара и Григо решили вызвать СОДП", "rp_day": "день 2"},
])
assert len(facts) == 1
def test_validate_compressed_rejects_hallucinations():
original = [{"text": "Сара любит шоколад", "rp_day": "день 1"}]
compressed = [
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара — королева галактики", "rp_day": "день 9"},
]
out = validate_compressed_against_source(original, compressed)
assert len(out) == 1
assert "шоколад" in out[0]["text"]
def test_merge_with_rp_day():
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
merged = parse_facts_list(
+22
View File
@@ -0,0 +1,22 @@
from services.rpg_locale import infer_rp_language, locale_instruction
def test_infer_ru_from_cyrillic_chat():
msgs = [
{"role": "user", "content": "Привет, как дела?"},
{"role": "assistant", "content": "Нормально, идём дальше."},
]
assert infer_rp_language(msgs) == "ru"
def test_infer_en_from_latin_chat():
msgs = [
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "Fine, let's continue."},
]
assert infer_rp_language(msgs) == "en"
def test_locale_instruction_ru():
assert "Russian" in locale_instruction("ru")
assert "MUST be in Russian" in locale_instruction("ru")
+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Начать новую арку"
+80
View File
@@ -0,0 +1,80 @@
import asyncio
import json
import pytest
import database.db as dbmod
import services.memory as memory
@pytest.fixture
def snapshot_db(tmp_path, monkeypatch):
db_file = tmp_path / "snapshots.db"
monkeypatch.setenv("DB_PATH", str(db_file))
monkeypatch.setattr(dbmod, "DB_PATH", str(db_file))
monkeypatch.setattr(memory, "DB_PATH", str(db_file))
asyncio.run(dbmod.init_db())
return str(db_file)
def test_snapshot_save_restore_and_delete_rollback(snapshot_db):
asyncio.run(_test_snapshot_save_restore_and_delete_rollback())
async def _test_snapshot_save_restore_and_delete_rollback():
sid = "sess_snap_test"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact A", "rp_day": "день 1"}]))
await memory.update_session_plot_arc(
sid, json.dumps({"title": "Arc", "steps": [{"id": "s1", "title": "One"}], "current_step_index": 0})
)
await memory.set_session_affinity(sid, 3)
await memory.upsert_quest(sid, "Quest one", "active")
u1 = await memory.add_message(sid, "user", "hello")
await memory.save_state_snapshot(sid, u1)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact B", "rp_day": "день 2"}]))
await memory.set_session_affinity(sid, 9)
a1 = await memory.add_message(sid, "assistant", "hi")
await memory.save_state_snapshot(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact B"
assert session["affinity"] == 9
await memory.delete_message_and_following(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact A"
assert session["affinity"] == 3
quests = await memory.get_quests(sid)
assert len(quests) == 1
def test_fork_uses_snapshot_not_current_state(snapshot_db):
asyncio.run(_test_fork_uses_snapshot_not_current_state())
async def _test_fork_uses_snapshot_not_current_state():
sid = "sess_fork_src"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
u1 = await memory.add_message(sid, "user", "start")
await memory.update_session_facts(sid, json.dumps([{"text": "Early", "rp_day": "1"}]))
await memory.save_state_snapshot(sid, u1)
a1 = await memory.add_message(sid, "assistant", "reply")
await memory.update_session_facts(sid, json.dumps([{"text": "Late", "rp_day": "2"}]))
await memory.save_state_snapshot(sid, a1)
new_id = await memory.fork_session(sid, u1)
assert new_id
forked = await memory.get_session(new_id)
assert json.loads(forked["facts_json"])[0]["text"] == "Early"
hist = await memory.get_history(new_id)
roles = [m["role"] for m in hist if m["role"] != "system"]
assert roles == ["user"]