From 2b41a82869374145a2da63b94f414aa9b1217dfe Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 19:19:37 +0200 Subject: [PATCH] Promote multi-person hardcore action routing --- caption_policy.py | 2 + hardcore_action_metadata.py | 16 ++++++++ hardcore_position_config.py | 9 ++++- sdxl_presets.py | 2 + tools/prompt_route_simulation.py | 59 +++++++++++++++++++++++++-- tools/prompt_smoke.py | 69 +++++++++++++++++++++++++++++++- 6 files changed, 150 insertions(+), 7 deletions(-) diff --git a/caption_policy.py b/caption_policy.py index d066eba..9d3c1ea 100644 --- a/caption_policy.py +++ b/caption_policy.py @@ -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", } diff --git a/hardcore_action_metadata.py b/hardcore_action_metadata.py index 778ad39..9f4a2b7 100644 --- a/hardcore_action_metadata.py +++ b/hardcore_action_metadata.py @@ -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) diff --git a/hardcore_position_config.py b/hardcore_position_config.py index 4ba3848..c0d6656 100644 --- a/hardcore_position_config.py +++ b/hardcore_position_config.py @@ -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: diff --git a/sdxl_presets.py b/sdxl_presets.py index 56c1ce5..5419bda 100644 --- a/sdxl_presets.py +++ b/sdxl_presets.py @@ -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"), } diff --git a/tools/prompt_route_simulation.py b/tools/prompt_route_simulation.py index 038f7d6..d3f186c 100644 --- a/tools/prompt_route_simulation.py +++ b/tools/prompt_route_simulation.py @@ -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), diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 68fa449..58b149e 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -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 []}