Promote multi-person hardcore action routing

This commit is contained in:
2026-06-27 19:19:37 +02:00
parent 658743d876
commit 2b41a82869
6 changed files with 150 additions and 7 deletions
+2
View File
@@ -55,6 +55,8 @@ ACTION_FAMILY_CAPTION_LABELS = {
"outercourse": "non-penetrative action",
"oral": "oral action",
"penetration": "penetrative action",
"threesome": "three-person action",
"group": "group action",
"toy_double": "toy-assisted double-contact action",
"climax": "climax action",
}
+16
View File
@@ -32,6 +32,8 @@ ACTION_MANUAL = "manual"
ACTION_OUTERCOURSE = "outercourse"
ACTION_ORAL = "oral"
ACTION_PENETRATION = "penetration"
ACTION_THREESOME = "threesome"
ACTION_GROUP = "group"
ACTION_TOY_DOUBLE = "toy_double"
ACTION_DEFAULT = "default"
@@ -43,6 +45,8 @@ HARDCORE_ACTION_FAMILY_CHOICES = {
ACTION_OUTERCOURSE,
ACTION_ORAL,
ACTION_PENETRATION,
ACTION_THREESOME,
ACTION_GROUP,
ACTION_TOY_DOUBLE,
ACTION_DEFAULT,
}
@@ -66,6 +70,16 @@ def normalize_hardcore_action_family(value: Any, default: str = "") -> str:
"anal_penetration": ACTION_ANAL,
"outer_course": ACTION_OUTERCOURSE,
"outercourse_sex": ACTION_OUTERCOURSE,
"three_person": ACTION_THREESOME,
"three_person_action": ACTION_THREESOME,
"threesome": ACTION_THREESOME,
"threesomes": ACTION_THREESOME,
"threeway": ACTION_THREESOME,
"three_way": ACTION_THREESOME,
"group": ACTION_GROUP,
"group_sex": ACTION_GROUP,
"group_sex_orgy": ACTION_GROUP,
"orgy": ACTION_GROUP,
"manual": ACTION_MANUAL,
"manual_stimulation": ACTION_MANUAL,
"interaction": ACTION_FOREPLAY,
@@ -143,6 +157,8 @@ def source_hardcore_action_family(
"manual": ACTION_MANUAL,
"oral": ACTION_ORAL,
"outercourse": ACTION_OUTERCOURSE,
"threesome": ACTION_THREESOME,
"group": ACTION_GROUP,
"climax": ACTION_CLIMAX,
}
return source_mapping.get(family, inferred)
+7 -2
View File
@@ -525,7 +525,8 @@ def hardcore_position_template_required(config: dict[str, Any]) -> bool:
def hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]:
family = normalize_hardcore_position_family(config.get("family"))
allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]))
base_allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]))
allowed = set(base_allowed)
if not config.get("allow_penetration", True):
allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"})
if not config.get("allow_foreplay", True):
@@ -554,7 +555,11 @@ def hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]:
allowed.discard("cumshot_climax")
if not config.get("allow_double", True) and family == "anal":
allowed.add("anal_double_penetration")
return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])
if allowed:
return allowed
if family != "any":
return base_allowed
return set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])
def is_hardcore_sexual_category(category: dict[str, Any]) -> bool:
+2
View File
@@ -53,6 +53,8 @@ SDXL_ACTION_FAMILY_TAGS = {
"outercourse": ("outercourse", "non-penetrative sex"),
"oral": ("oral sex",),
"penetration": ("penetrative sex", "penetration"),
"threesome": ("threesome",),
"group": ("group sex",),
"toy_double": ("double penetration", "toy-assisted sex"),
"climax": ("climax", "semen"),
}
+55 -4
View File
@@ -113,6 +113,29 @@ def _character_cast(*, pov_man: bool = False) -> str:
)["character_cast"]
def _character_cast_subjects(subjects: list[str] | tuple[str, ...]) -> str:
cast = ""
counts = {"woman": 0, "man": 0}
for subject in subjects:
subject = str(subject)
counts[subject] += 1
label = chr(ord("A") + counts[subject] - 1)
cast = pb.build_character_slot_json(
subject_type=subject,
label=label,
age="25-year-old adult" if subject == "woman" else "40-year-old adult",
ethnicity="western_european",
figure="balanced",
body="slim busty" if subject == "woman" else "average",
descriptor_detail="compact",
expression_intensity=0.55,
softcore_expression_intensity=0.35,
hardcore_expression_intensity=0.75,
character_cast=cast,
)["character_cast"]
return cast
def _random_character_cast() -> str:
cast = pb.build_character_slot_json(
subject_type="woman",
@@ -302,6 +325,30 @@ HARDCORE_ROUTE_CASES = (
"caption": ("anal action",),
},
},
{
"name": "hardcore.single.threesome",
"subcategory": "Threesomes",
"focus": "threesome_only",
"family": "threesome",
"expected_route": {"action_family": "threesome", "position_family": "threesome"},
"expected_terms": {
"krea": ("three",),
"sdxl": ("threesome",),
"caption": ("three-person action",),
},
},
{
"name": "hardcore.single.group",
"subcategory": "Group sex and orgy",
"focus": "group_only",
"family": "group",
"expected_route": {"action_family": "group", "position_family": "group"},
"expected_terms": {
"krea": ("group",),
"sdxl": ("group sex",),
"caption": ("group action",),
},
},
{
"name": "hardcore.single.climax",
"subcategory": "Cumshot and climax",
@@ -309,7 +356,7 @@ HARDCORE_ROUTE_CASES = (
"family": "climax",
"expected_route": {"action_family": "climax", "position_family": "climax"},
"expected_terms": {
"krea": ("ejaculation",),
"krea": ("semen",),
"sdxl": ("climax", "semen"),
"caption": ("climax action",),
},
@@ -734,6 +781,10 @@ def _regular_single_case(seed: int) -> dict[str, Any]:
def _hardcore_single_case(seed: int, subcategory: str, focus: str, family: str) -> dict[str, Any]:
women_count, men_count, character_cast = {
"threesome": (1, 2, _character_cast_subjects(("woman", "man", "man"))),
"group": (2, 2, _character_cast_subjects(("woman", "woman", "man", "man"))),
}.get(family, (1, 1, _character_cast()))
return pb.build_prompt(
category="Hardcore sexual poses",
subcategory=subcategory,
@@ -754,9 +805,9 @@ def _hardcore_single_case(seed: int, subcategory: str, focus: str, family: str)
extra_positive="",
extra_negative="",
seed_config=pb.build_seed_lock_config_json(base_seed=seed),
women_count=1,
men_count=1,
character_cast=_character_cast(),
women_count=women_count,
men_count=men_count,
character_cast=character_cast,
hardcore_position_config=_position_filter(focus, family, []),
location_config=_coworking_location_config(),
camera_config=_orbit_camera(horizontal_angle=35, vertical_angle=0, zoom=6.5),
+68 -1
View File
@@ -4015,6 +4015,10 @@ def smoke_caption_policy() -> None:
)
row = {"action_family": "oral", "position_family": ""}
_expect(caption_policy.metadata_action_label(row) == "oral action", "Caption action-family label changed")
row = {"action_family": "threesome", "position_family": ""}
_expect(caption_policy.metadata_action_label(row) == "three-person action", "Caption threesome action-family label changed")
row = {"action_family": "group", "position_family": ""}
_expect(caption_policy.metadata_action_label(row) == "group action", "Caption group action-family label changed")
row = {"action_family": "oral", "position_family": "Anal"}
_expect(caption_naturalizer._metadata_action_label(row) == "anal action", "Caption position-family label priority changed")
browsing_caption, browsing_method = caption_naturalizer.naturalize_caption(
@@ -4913,6 +4917,14 @@ def smoke_hardcore_position_config_policy() -> None:
category_template_metadata.template_action_family({"action_family": "anal sex"}) == "anal",
"Template action-family normalizer should accept anal aliases",
)
_expect(
category_template_metadata.template_action_family({"action_family": "three way"}) == "threesome",
"Template action-family normalizer should accept threesome aliases",
)
_expect(
category_template_metadata.template_action_family({"action_family": "group sex"}) == "group",
"Template action-family normalizer should accept group aliases",
)
_expect(
category_template_metadata.template_position_family({"position_family": "penetration"}) == "penetrative",
"Template position-family normalizer should accept action-style aliases",
@@ -4965,6 +4977,26 @@ def smoke_hardcore_position_config_policy() -> None:
_expect(filtered.get("allow_penetration") is False, "Hardcore outercourse focus should disable penetration")
_expect("outercourse_sex" in hardcore_position_config.hardcore_allowed_subcategory_slugs(filtered), "Allowed subcategories lost outercourse")
_expect("oral_sex" not in hardcore_position_config.hardcore_allowed_subcategory_slugs(filtered), "Allowed subcategories should exclude oral")
strict_threesome = json.loads(
pb.build_hardcore_action_filter_json(
hardcore_position_config=pb.build_hardcore_position_pool_json(family="threesome"),
focus="threesome_only",
allow_toys=False,
allow_double=False,
allow_penetration=False,
allow_foreplay=False,
allow_interaction=False,
allow_manual=False,
allow_oral=False,
allow_outercourse=False,
allow_anal=False,
allow_climax=False,
)
)
_expect(
hardcore_position_config.hardcore_allowed_subcategory_slugs(strict_threesome) == {"threesomes"},
"Specific hardcore family filter should not widen to the full pool when boolean filters empty it",
)
action_only = json.loads(
hardcore_position_config.build_hardcore_action_filter_json(
focus="outercourse_only",
@@ -5062,6 +5094,14 @@ def smoke_hardcore_position_config_policy() -> None:
"generic contact",
)
_expect(source_action_family == "outercourse", "Source action-family fallback should accept hyphenated source aliases")
_expect(
hardcore_action_metadata.source_hardcore_action_family("threesome", "", "three-body contact") == "threesome",
"Source action-family fallback should accept threesome source family",
)
_expect(
hardcore_action_metadata.source_hardcore_action_family("group", "", "group sex contact") == "group",
"Source action-family fallback should accept group source family",
)
default_action_route = row_route_metadata.resolve_action_position_route(
is_pose_category=True,
subcategory={"slug": "anal_double_penetration"},
@@ -5550,6 +5590,31 @@ def smoke_hardcore_category_routes() -> None:
_expect(sdxl_tag in (sdxl.get("sdxl_prompt") or "").lower(), f"{name} SDXL prompt did not include family tag {sdxl_tag!r}")
caption, _method = caption_naturalizer.naturalize_caption("", metadata_json=_json(row), trigger=Trigger, include_trigger=True)
_expect(caption_label in caption.lower(), f"{name} caption did not include family label {caption_label!r}")
multi_cases = [
("hardcore_threesome", "Threesomes", "threesome_only", "threesome", {"threesome", "toy_double"}, "threesome", "three-person action", 1, 2),
("hardcore_group", "Group sex and orgy", "group_only", "group", {"group", "toy_double"}, "group sex", "group action", 2, 2),
]
for index, (name, subcategory, focus, position_family, action_families, sdxl_tag, caption_label, women_count, men_count) in enumerate(multi_cases, start=1151):
subjects = ["woman"] * women_count + ["man"] * men_count
row = _prompt_row(
name=name,
category="Hardcore sexual poses",
subcategory=subcategory,
seed=index,
character_cast=_character_cast_subjects(subjects),
women_count=women_count,
men_count=men_count,
hardcore_position_config=_action_filter(focus),
)
_expect_custom_row(row, name)
_expect(row.get("subject_type") == "configured_cast", f"{name} should use configured cast")
_expect(row.get("position_family") == position_family, f"{name} position_family mismatch: {row.get('position_family')}")
_expect(row.get("action_family") in action_families, f"{name} action_family mismatch: {row.get('action_family')}")
_expect_formatter_outputs(row, name, target="single")
sdxl = sdxl_formatter.format_sdxl_prompt("", metadata_json=_json(row), target="single", trigger=SdxlTrigger, prepend_trigger=True)
_expect(sdxl_tag in (sdxl.get("sdxl_prompt") or "").lower(), f"{name} SDXL prompt did not include family tag {sdxl_tag!r}")
caption, _method = caption_naturalizer.naturalize_caption("", metadata_json=_json(row), trigger=Trigger, include_trigger=True)
_expect(caption_label in caption.lower(), f"{name} caption did not include family label {caption_label!r}")
annotated_row = None
for seed in range(1801, 1841):
row = _prompt_row(
@@ -7071,6 +7136,8 @@ def smoke_fallback_role_graph_routes() -> None:
)
_expect_custom_row(row, name)
_expect(row.get("position_family") == family, f"{name} position_family mismatch: {row.get('position_family')}")
if family == "threesome":
_expect(row.get("action_family") == "threesome", f"{name} action_family should be threesome")
_expect(position_key in (row.get("position_keys") or []), f"{name} lost position key {position_key!r}")
role_graph = _expect_text(f"{name}.source_role_graph", row.get("source_role_graph"), 30).lower()
for term in role_terms:
@@ -7844,7 +7911,7 @@ def smoke_seed_config_policy() -> None:
def smoke_prompt_route_simulation_policy() -> None:
report = prompt_route_simulation.run_simulation(seed=3901, include_prompts=False)
summary = report.get("summary") or {}
_expect(summary.get("cases") == 11, "Prompt route simulation case count changed unexpectedly")
_expect(summary.get("cases") == 13, "Prompt route simulation case count changed unexpectedly")
_expect(summary.get("axis_checks") == 6, "Prompt route simulation lost axis check coverage")
_expect(summary.get("issues") == 0, f"Prompt route simulation reported issues: {report.get('issues')}")
cases = {case.get("name"): case for case in report.get("cases") or []}