Fixed SD RPG
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import asyncio
|
||||
|
||||
from services.rpg_plot import (
|
||||
prune_beats_for_done_quests,
|
||||
process_arc_beats,
|
||||
should_advance_arc_keywords,
|
||||
pop_matching_beats,
|
||||
dice_outcome_to_beat_trigger,
|
||||
)
|
||||
|
||||
|
||||
def test_prune_removes_beat_when_quest_done():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
|
||||
]
|
||||
}
|
||||
quests = [{"title": "Highway Howl", "status": "done"}]
|
||||
arc, removed = prune_beats_for_done_quests(arc, quests)
|
||||
assert removed[0]["title"] == "Highway Howl"
|
||||
assert arc["beats"] == []
|
||||
|
||||
|
||||
def test_stuck_recovery_fires_when_no_active_quests():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
|
||||
]
|
||||
}
|
||||
quests = [{"title": "Old", "status": "done"}]
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(arc, quests, "hello")
|
||||
|
||||
arc2, fired, pruned, mode = asyncio.run(run())
|
||||
assert mode == "stuck_recovery"
|
||||
assert fired[0]["title"] == "New Beat"
|
||||
assert arc2["beats"] == []
|
||||
|
||||
|
||||
def test_dice_outcome_maps_to_after_fail():
|
||||
assert dice_outcome_to_beat_trigger("failure") == "event_driven:after_fail"
|
||||
assert dice_outcome_to_beat_trigger("critical failure") == "event_driven:after_fail"
|
||||
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
|
||||
|
||||
|
||||
def test_after_fail_beat_fires_on_dice_failure():
|
||||
arc = {
|
||||
"beats": [
|
||||
{
|
||||
"id": "b_fail",
|
||||
"title": "Dust Yourself Off",
|
||||
"trigger": "event_driven:after_fail",
|
||||
"injection": "The stumble leaves you both shaken.",
|
||||
"choices": [{"id": "a", "label": "Try again"}],
|
||||
},
|
||||
{
|
||||
"id": "b_ok",
|
||||
"title": "Victory Lap",
|
||||
"trigger": "event_driven:after_success",
|
||||
"choices": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(
|
||||
arc, [], "продолжаем разговор", last_dice_outcome="failure"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_keyword_fallback_travel():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b2", "title": "Highway Howl", "trigger": "event_driven:travel", "choices": []},
|
||||
]
|
||||
}
|
||||
trig = should_advance_arc_keywords("едем на стадион")
|
||||
assert trig == "event_driven:travel"
|
||||
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
|
||||
assert fired[0]["title"] == "Highway Howl"
|
||||
@@ -0,0 +1,54 @@
|
||||
from services.memory import narrator_message_content, parse_narrator_message
|
||||
from routers.chat import messages_for_llm
|
||||
from services.rpg_state import format_narrator_outcome_for_llm
|
||||
|
||||
|
||||
def test_messages_for_llm_includes_narrator_ruling_not_original_only():
|
||||
resolution = (
|
||||
"Luna's attempt backfires; people roll their eyes and an elderly man pats her head."
|
||||
)
|
||||
narrator_json = narrator_message_content(
|
||||
{
|
||||
"roll": 5,
|
||||
"outcome": "failure",
|
||||
"text": resolution,
|
||||
"original_intent": "bulldoze through the line",
|
||||
}
|
||||
)
|
||||
history = [
|
||||
{"role": "system", "content": "static"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Луна влезла без очереди, распугивая всех",
|
||||
"action_resolution": {
|
||||
"intent_text": "Луна влезла без очереди",
|
||||
"roll": 5,
|
||||
"outcome": "failure",
|
||||
"resolution_text": resolution,
|
||||
},
|
||||
},
|
||||
{"role": "narrator", "content": narrator_json},
|
||||
]
|
||||
llm = messages_for_llm(history, "system+runtime")
|
||||
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()
|
||||
assert "Луна влезла" in user_msgs[0]["content"]
|
||||
assert "MANDATORY" in user_msgs[1]["content"]
|
||||
assert "backfires" in user_msgs[1]["content"]
|
||||
assert "Do NOT write a success version" in user_msgs[1]["content"]
|
||||
|
||||
|
||||
def test_format_narrator_failure_wording():
|
||||
text = format_narrator_outcome_for_llm(
|
||||
{"roll": 5, "outcome": "failure", "text": "It failed."}
|
||||
)
|
||||
assert "FAILED" in text
|
||||
assert "Do NOT write a success version" in text
|
||||
|
||||
|
||||
def test_parse_narrator_message_roundtrip():
|
||||
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
|
||||
data = parse_narrator_message(raw)
|
||||
assert data["roll"] == 12
|
||||
assert data["text"] == "OK"
|
||||
@@ -0,0 +1,18 @@
|
||||
from services.outfit_tags import enrich_outfit_tag, normalize_outfit_list, parse_and_normalize_outfit_json
|
||||
|
||||
|
||||
def test_enrich_shorts_and_tank():
|
||||
out = normalize_outfit_list(["sports_shorts", "torn_tank_top", "championship_belt_collar"])
|
||||
assert out == ["black_sports_shorts", "white_torn_tank_top", "gold_championship_belt_collar"]
|
||||
|
||||
|
||||
def test_keeps_existing_color():
|
||||
assert enrich_outfit_tag("red_dress") == "red_dress"
|
||||
assert "red" in normalize_outfit_list(["red_dress"])[0]
|
||||
|
||||
|
||||
def test_parse_json_string():
|
||||
raw = '["sports_shorts", "torn_tank_top"]'
|
||||
parsed = parse_and_normalize_outfit_json(raw)
|
||||
assert "black_sports_shorts" in parsed
|
||||
assert "white_torn_tank_top" in parsed
|
||||
@@ -0,0 +1,29 @@
|
||||
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice
|
||||
|
||||
|
||||
def test_choices_from_beat_tags_source():
|
||||
beat = {
|
||||
"id": "b_new_1",
|
||||
"title": "Rest Stop Confession",
|
||||
"injection": "GPS alerts you to a rest stop.",
|
||||
"choices": [
|
||||
{"id": "a", "label": "Tease her about snores"},
|
||||
{"id": "b", "label": "Ask about snacks"},
|
||||
],
|
||||
}
|
||||
out = choices_from_beat(beat)
|
||||
assert len(out) == 2
|
||||
assert out[0]["source"] == "plot_beat"
|
||||
assert out[0]["beat_title"] == "Rest Stop Confession"
|
||||
assert out[0]["beat_id"] == "b_new_1"
|
||||
assert "beat_injection" in out[0]
|
||||
|
||||
|
||||
def test_choices_from_narrator_tags_source():
|
||||
out = choices_from_narrator([{"id": "a", "label": "Look around"}])
|
||||
assert out[0]["source"] == "narrator"
|
||||
assert "beat_title" not in out[0]
|
||||
|
||||
|
||||
def test_normalize_choice_skips_empty_label():
|
||||
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
|
||||
@@ -0,0 +1,18 @@
|
||||
from services.rp_sanitize import strip_ooc_from_reply
|
||||
|
||||
|
||||
def test_strip_ps_block():
|
||||
text = (
|
||||
"Луна зевает и прижимается к тебе.\n\n"
|
||||
"Статус кво? Она никогда не признает слабость.\n\n"
|
||||
"P.S. Когда вы выйдете из сауны, она будет бурчать."
|
||||
)
|
||||
out = strip_ooc_from_reply(text)
|
||||
assert "P.S." not in out
|
||||
assert "Статус кво" not in out
|
||||
assert "Луна зевает" in out
|
||||
|
||||
|
||||
def test_strip_keeps_in_character_body():
|
||||
text = "— «Щенок…» — она бурчит, не открывая глаз."
|
||||
assert strip_ooc_from_reply(text) == text
|
||||
@@ -0,0 +1,42 @@
|
||||
from services.rpg_facts import (
|
||||
merge_facts,
|
||||
parse_facts_list,
|
||||
facts_to_prompt,
|
||||
facts_list_to_json,
|
||||
dedupe_facts_fuzzy,
|
||||
facts_are_similar,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_string_facts():
|
||||
raw = '["Old fact", "Another"]'
|
||||
facts = parse_facts_list(raw)
|
||||
assert len(facts) == 2
|
||||
assert facts[0]["text"] == "Old fact"
|
||||
|
||||
|
||||
def test_fuzzy_similar_near_duplicate():
|
||||
assert facts_are_similar(
|
||||
"Rin and Grigo found a magical glade with glowing flowers",
|
||||
"Rin and Grigo found magical glade glowing flowers",
|
||||
)
|
||||
|
||||
|
||||
def test_dedupe_collapses_duplicates():
|
||||
raw = facts_list_to_json(
|
||||
[
|
||||
{"text": "Rin found a magical glade with glowing flowers", "rp_day": "day 1"},
|
||||
{"text": "Rin found magical glade glowing flowers", "rp_day": "day 1"},
|
||||
{"text": "Player name is Grigo", "rp_day": "day 1"},
|
||||
]
|
||||
)
|
||||
out = dedupe_facts_fuzzy(parse_facts_list(raw))
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_merge_with_rp_day():
|
||||
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
|
||||
merged = parse_facts_list(
|
||||
merge_facts(existing, [{"text": "B", "rp_day": ""}], rp_day_default="день 2")
|
||||
)
|
||||
assert merged[1]["rp_day"] == "день 2"
|
||||
@@ -0,0 +1,17 @@
|
||||
from services.rpg_state import affinity_prompt_block, stats_prompt_block
|
||||
|
||||
|
||||
def test_affinity_mandatory_wording():
|
||||
block = affinity_prompt_block(5)
|
||||
assert "MANDATORY" in block
|
||||
assert "current player" in block
|
||||
|
||||
|
||||
def test_stamina_2_exhausted_instruction():
|
||||
block = stats_prompt_block({"lust": 0, "stamina": 2, "tension": 0})
|
||||
assert "barely moves" in block.lower() or "exhausted" in block.lower()
|
||||
|
||||
|
||||
def test_stamina_1_collapse_instruction():
|
||||
block = stats_prompt_block({"lust": 0, "stamina": 1, "tension": 0})
|
||||
assert "collapse" in block.lower() or "pass out" in block.lower()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Unit tests for layered Anima prompt assembly (no LLM)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure project root on path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from services import sd_prompt as sp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anima():
|
||||
with patch.object(sp, "_is_anima", return_value=True), patch.object(sp, "_is_pony", return_value=False):
|
||||
yield
|
||||
|
||||
|
||||
PERSONA_WOLF = {
|
||||
"appearance_tags": "wolfgirl, white_hair, golden_eyes, wolf_ears, tail, big_breast",
|
||||
"appearance_prose": "",
|
||||
"lora_name": "",
|
||||
}
|
||||
|
||||
PERSONA_CARRIE = {
|
||||
"appearance_tags": "short_hair, brown_hair, blue_eyes, skinny",
|
||||
"appearance_prose": "",
|
||||
"lora_name": "",
|
||||
}
|
||||
|
||||
|
||||
def test_walking_scene_includes_action_tags_and_contextual_pov(anima):
|
||||
scene = sp._sanitize_scene_fields({
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "walking_together",
|
||||
"viewer_body_visible": False,
|
||||
"action_tags": "holding_hands, walking, smiling, looking_at_each_other",
|
||||
"environment_tags": "outdoors, sunlight, golden_hour",
|
||||
"scene_description": "She walks beside you, laughter in the warm afternoon light.",
|
||||
})
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
|
||||
assert "walking" in hybrid
|
||||
assert "smiling" in hybrid
|
||||
assert "holding_hands" not in hybrid
|
||||
assert "looking_at_each_other" not in hybrid
|
||||
assert "outdoors" in hybrid
|
||||
assert "threshold" not in hybrid.lower()
|
||||
assert "POV: walking beside you" in hybrid
|
||||
assert "someone" not in hybrid.lower()
|
||||
assert "both " not in hybrid.lower()
|
||||
|
||||
|
||||
def test_hybrid_differs_from_tags_only_when_prose_present(anima):
|
||||
scene = {
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "walking_together",
|
||||
"viewer_body_visible": False,
|
||||
"action_tags": "holding_hands, walking",
|
||||
"environment_tags": "outdoors, sunlight",
|
||||
"scene_description": "Shared laughter drifts through the golden afternoon.",
|
||||
}
|
||||
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
|
||||
assert tags_only != hybrid
|
||||
assert "Shared laughter" in hybrid
|
||||
assert "Shared laughter" not in tags_only
|
||||
|
||||
|
||||
def test_carrie_doorway_scene(anima):
|
||||
scene = {
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "doorway_invite",
|
||||
"viewer_body_visible": False,
|
||||
"action_tags": "arms_out, inviting_hug, smirk, looking_at_viewer",
|
||||
"environment_tags": "doorway, apartment, night, indoors",
|
||||
"scene_description": "She waits in the doorway with playful hunger in half-lidded eyes.",
|
||||
}
|
||||
outfit = "crop_top, ripped_jeans, black_jeans"
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_CARRIE, outfit)
|
||||
assert "arms_out" in hybrid
|
||||
assert "doorway" in hybrid
|
||||
assert "crop_top" in hybrid
|
||||
assert "threshold" not in hybrid.lower()
|
||||
assert "POV: she blocks the doorway" in hybrid
|
||||
|
||||
|
||||
def test_pov_inferred_from_action_when_cue_missing(anima):
|
||||
scene = {
|
||||
"shot_type": "first_person_pov",
|
||||
"action_tags": "holding_hands, walking, smiling",
|
||||
"environment_tags": "outdoors, park",
|
||||
"scene_description": "",
|
||||
}
|
||||
tags = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
|
||||
assert "POV: walking beside you" in tags
|
||||
|
||||
|
||||
def test_negative_includes_interaction_block_for_pov_contact(anima):
|
||||
scene = {
|
||||
"shot_type": "first_person_pov",
|
||||
"viewer_body_visible": False,
|
||||
"action_tags": "arms_out, hug, inviting_hug",
|
||||
"environment_tags": "doorway",
|
||||
}
|
||||
neg = sp._negative_for_scene(scene)
|
||||
assert "duplicate" in neg
|
||||
assert "extra_person" in neg
|
||||
assert "third person" in neg
|
||||
|
||||
|
||||
def test_scene_should_generate_false():
|
||||
assert sp._scene_should_generate({"should_generate": False}) is False
|
||||
assert sp._scene_should_generate({"should_generate": True}) is True
|
||||
assert sp._scene_should_generate({}) is True
|
||||
|
||||
|
||||
def test_format_builder_user_block_illustrate_vs_context(anima):
|
||||
messages = [
|
||||
{"role": "assistant", "content": "Long old first_mes " + ("x" * 900)},
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "*walks holding your hand*"},
|
||||
]
|
||||
block = sp._format_builder_user_block(PERSONA_WOLF, messages, "[]")
|
||||
assert "=== ILLUSTRATE" in block
|
||||
assert "=== Context" in block
|
||||
assert "*walks holding your hand*" in block
|
||||
assert "Long old first_mes" in block
|
||||
assert len(block.split("Long old first_mes")[1].split("assistant:")[0]) < 900
|
||||
|
||||
|
||||
def test_bundle_from_scene_anima_uses_hybrid_as_tag_full(anima):
|
||||
scene = {
|
||||
"should_generate": True,
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "face_to_face",
|
||||
"action_tags": "smiling",
|
||||
"environment_tags": "indoors",
|
||||
"scene_description": "A warm smile greets you.",
|
||||
}
|
||||
with patch.object(sp, "anima_dual_enabled", return_value=False):
|
||||
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
|
||||
assert "A warm smile" in bundle.tag_full
|
||||
assert bundle.desc_full is None
|
||||
|
||||
|
||||
def test_user_example_walking_llm_output_cleaned(anima):
|
||||
"""Regression: LLM prose/sentence leakage and second-person refs."""
|
||||
scene = sp._sanitize_scene_fields({
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "walking_together",
|
||||
"action_tags": (
|
||||
"holding_hands, walking, smiling, looking_at_each_other, "
|
||||
"A wolfgirl walks hand in hand with someone, both smiling and chatting"
|
||||
),
|
||||
"environment_tags": "outdoor, daylight, path",
|
||||
"scene_description": (
|
||||
"A wolfgirl walks hand in hand with someone, both smiling and chatting under the daylight."
|
||||
),
|
||||
})
|
||||
persona = {**PERSONA_WOLF, "appearance_tags": PERSONA_WOLF["appearance_tags"] + ", pumped_up"}
|
||||
tags_only = sp.build_positive_prompt_tags_only(scene, persona, "")
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, persona, "")
|
||||
assert "pumped_up" not in tags_only
|
||||
assert "someone" not in hybrid.lower()
|
||||
assert "both " not in hybrid.lower()
|
||||
assert ". A wolfgirl walks" not in tags_only
|
||||
assert tags_only != hybrid or not scene.get("scene_description")
|
||||
|
||||
|
||||
def test_user_example_carrie_env_reconciled(anima):
|
||||
scene = sp._sanitize_scene_fields({
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "doorway_invite",
|
||||
"action_tags": "arms_out, inviting_hug, smirk, half-lidded_eyes",
|
||||
"environment_tags": "doorway, nighttime, outdoor",
|
||||
"scene_description": (
|
||||
"Carrie stands in her doorway at night, arms outstretched toward you with a mischievous smirk."
|
||||
),
|
||||
})
|
||||
hybrid = sp.build_positive_prompt_hybrid(
|
||||
scene, PERSONA_CARRIE, "crop_top, ripped_jeans, black_jeans, jeans"
|
||||
)
|
||||
assert "outdoor" not in hybrid.lower() or "doorway" in hybrid
|
||||
assert ", jeans," not in f", {hybrid},"
|
||||
assert "someone" not in hybrid.lower()
|
||||
|
||||
|
||||
def test_long_first_mes_uses_final_beat(anima):
|
||||
carrie_tail = (
|
||||
"About an hour later...\n\n"
|
||||
"Carrie stood at her front door, arms out, smirking. "
|
||||
'"Come on, hug me. Now." It\'s getting cold out.'
|
||||
)
|
||||
long = ("She shops for clothes.\n\n" * 5) + carrie_tail
|
||||
excerpt = sp._extract_illustrate_content(long)
|
||||
assert "front door" in excerpt or "hug me" in excerpt
|
||||
assert "shops for clothes" not in excerpt
|
||||
|
||||
|
||||
def test_hybrid_gets_fallback_when_no_scene_description(anima):
|
||||
scene = sp._sanitize_scene_fields({
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "walking_together",
|
||||
"action_tags": "walking, smiling",
|
||||
"environment_tags": "outdoor, daylight",
|
||||
"scene_description": "",
|
||||
})
|
||||
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
|
||||
assert hybrid != tags_only
|
||||
assert "afternoon" in hybrid.lower() or "laughter" in hybrid.lower()
|
||||
|
||||
|
||||
def test_yuki_pov_drops_lifting_and_nose_rub(anima):
|
||||
scene = sp._sanitize_scene_fields({
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "face_to_face",
|
||||
"action_tags": "arms_out, lifting, nose_rub, smiling",
|
||||
"environment_tags": "indoors, warm_lighting",
|
||||
"scene_description": "Her golden eyes soften with warmth toward the camera.",
|
||||
})
|
||||
hybrid = sp.build_positive_prompt_hybrid(scene, {**PERSONA_WOLF, "appearance_tags": "fox_girl, golden_eyes"}, "pink_sweater")
|
||||
assert "lifting" not in hybrid
|
||||
assert "nose_rub" not in hybrid
|
||||
assert "golden" in hybrid.lower()
|
||||
|
||||
|
||||
def test_bundle_tags_only_alt_when_dual_compare(anima):
|
||||
scene = {
|
||||
"shot_type": "first_person_pov",
|
||||
"pov_cue": "dialogue_close",
|
||||
"action_tags": "smiling",
|
||||
"environment_tags": "indoors",
|
||||
"scene_description": "Soft light on her face.",
|
||||
}
|
||||
with patch.object(sp, "anima_dual_enabled", return_value=True):
|
||||
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
|
||||
assert bundle.desc_full is not None
|
||||
assert bundle.desc_full != bundle.tag_full
|
||||
assert "Soft light" in bundle.tag_full
|
||||
assert "Soft light" not in bundle.desc_full.split(sp.NEGATIVE_PROMPT_SEPARATOR)[0]
|
||||
Reference in New Issue
Block a user