diff --git a/README.md b/README.md index 008a6a2..44ab62c 100644 --- a/README.md +++ b/README.md @@ -735,6 +735,13 @@ and larger groups. cast, such as who penetrates, who receives oral, and who joins from the side. It is currently most useful for `Hardcore sexual poses`. +For `Hardcore sexual poses`, `SxCP Hardcore Position Pool` also includes an +`outercourse` family and position checkboxes for `boobjob`, `testicle_sucking`, +`penis_licking`, and `footjob`. `SxCP Hardcore Action Filter` exposes +`outercourse_only` and `allow_outercourse` so these can be selected separately +from oral or penetration. If a man slot is set to `presence_mode=pov`, these +positions emit first-person wording from that participant's viewpoint. + Subcategories, templates, and axis values can declare cast constraints: ```json diff --git a/__init__.py b/__init__.py index 1dd33c8..9c8a94c 100644 --- a/__init__.py +++ b/__init__.py @@ -253,6 +253,7 @@ NODE_INPUT_TOOLTIPS = { "allow_double": "Allow double-penetration or second-contact wording.", "allow_penetration": "Allow vaginal/penetrative sex subcategories.", "allow_oral": "Allow oral sex subcategories.", + "allow_outercourse": "Allow non-penetrative penis-contact acts such as boobjob/titjob, footjob, penis licking, and testicle sucking.", "allow_anal": "Allow anal subcategories.", "allow_climax": "Allow cumshot/climax aftermath subcategories.", }, @@ -1784,6 +1785,7 @@ class SxCPHardcoreActionFilter: "allow_double": ("BOOLEAN", {"default": False}), "allow_penetration": ("BOOLEAN", {"default": True}), "allow_oral": ("BOOLEAN", {"default": True}), + "allow_outercourse": ("BOOLEAN", {"default": True}), "allow_anal": ("BOOLEAN", {"default": True}), "allow_climax": ("BOOLEAN", {"default": True}), }, @@ -1804,6 +1806,7 @@ class SxCPHardcoreActionFilter: allow_double, allow_penetration, allow_oral, + allow_outercourse, allow_anal, allow_climax, hardcore_position_config="", @@ -1815,6 +1818,7 @@ class SxCPHardcoreActionFilter: allow_double=allow_double, allow_penetration=allow_penetration, allow_oral=allow_oral, + allow_outercourse=allow_outercourse, allow_anal=allow_anal, allow_climax=allow_climax, ) diff --git a/categories/sexual_poses.json b/categories/sexual_poses.json index d035594..e436a15 100644 --- a/categories/sexual_poses.json +++ b/categories/sexual_poses.json @@ -390,6 +390,142 @@ ] } }, + { + "name": "Outercourse and genital teasing", + "slug": "outercourse_sex", + "min_people": 2, + "min_women": 1, + "min_men": 1, + "inherit_expressions": false, + "inherit_compositions": false, + "weight": 1.0, + "scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], + "expression_pools": ["hardcore_oral_expressions", "hardcore_messy_expressions"], + "compositions": [ + {"text": "tight contact close-up with penis, hands, and face readable", "min_people": 2, "max_people": 3}, + {"text": "side-profile non-penetrative contact frame with body geometry clear", "min_people": 2, "max_people": 3}, + {"text": "POV-aligned close-up from the man's hips with the visible partner centered", "min_people": 2, "max_people": 2}, + {"text": "low-angle close crop on breasts or feet around the penis", "min_people": 2, "max_people": 3}, + {"text": "front-facing kneeling contact composition with eyes, mouth, and hands visible", "min_people": 2, "max_people": 3}, + {"text": "mirror-reflected non-penetrative sex composition with contact point readable", "min_people": 2, "max_people": 3}, + {"text": "waist-level explicit contact frame with natural skin compression visible", "min_people": 2, "max_people": 3}, + {"text": "close candid creator-shot frame centered on non-penetrative genital contact", "min_people": 2, "max_people": 3} + ], + "item_templates": [ + "{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}", + "{position} featuring {outer_act}, {body_contact}, {texture_detail}, and {angle}", + "{angle} view of {outer_act}, with {visibility}, {contact_detail}, and {expression_detail}", + "explicit non-penetrative sex pose: {outer_act}, {position}, {contact_detail}, and {visibility}", + "{outer_act} on {surface}, with {hand_detail}, {body_contact}, and {texture_detail}", + "{position} while {outer_act}, with {texture_detail}, {hand_detail}, and {visibility}" + ], + "item_axes": { + "angle": [ + "tight close-up", + "side-profile", + "front-facing", + "low-angle", + "overhead", + "POV-aligned", + "mirror-reflected", + "waist-level" + ], + "body_contact": [ + "hips held steady at contact height", + "knees braced close to the body", + "torso leaning forward into the contact", + "legs spread enough to keep the contact point visible", + "body angled so the penis, hands, and face stay readable", + "shoulders relaxed while the contact stays centered", + "lower body held still while the upper body works", + "close body alignment around the penis" + ], + "contact_detail": [ + "compressed soft tissue around the shaft", + "glans kept visible at the mouth or between lips", + "tongue and lips making clear contact", + "soles pressing from both sides of the shaft", + "toes curled around the shaft", + "balls held gently against the mouth", + "skin visibly compressed under fingers", + "wet contact visible on the penis" + ], + "expression_detail": [ + "focused downward gaze", + "eyes looking up from the contact point", + "mouth open in concentration", + "messy lips and steady eye contact", + "controlled hungry expression", + "soft flushed concentration", + "tongue visible during the tease", + "intense close-camera stare" + ], + "hand_detail": [ + "hands pressing breasts firmly together", + "fingers digging into soft breast flesh", + "one hand guiding the penis", + "both hands bracing the thighs", + "hands holding the ankles in place", + "one hand holding the base of the penis", + "hands cupping balls close to the mouth", + "fingers spreading the breasts around the shaft" + ], + "outer_act": [ + "boobjob with the penis squeezed between both breasts", + "titjob with the shaft compressed between breasts and the glans near the mouth", + "breast sex with hands pressing the breasts tightly around the penis", + "testicle sucking with lips around the balls", + "balls licking with tongue contact under the shaft", + "penis licking with tongue along the shaft and glans", + "slow tongue licking on the underside of the penis", + "footjob with both soles wrapped around the penis", + "footjob with toes curled around the shaft", + "feet stroking the penis while the legs frame the contact" + ], + "position": [ + "kneeling boobjob position", + "seated titjob position", + "tight close-up breast-sex position", + "kneeling testicle-sucking position", + "low-angle balls-licking position", + "kneeling penis-licking position", + "side-profile penis-licking position", + "reclining footjob position", + "seated footjob position", + "POV footjob position" + ], + "surface": [ + "rumpled bed sheets", + "a wide couch", + "a velvet chair", + "floor cushions", + "a hotel bed", + "a soft rug", + "a low mattress", + "a private shower bench" + ], + "texture_detail": [ + "realistic skin compression", + "soft flesh squeezed around the shaft", + "matte skin texture visible in the close-up", + "wet lips and tongue contact", + "slight saliva shine on skin", + "toes pressing into the shaft", + "natural asymmetry and soft tissue movement", + "visible pressure marks from fingers or soles" + ], + "visibility": [ + "penis, breasts, and mouth clearly visible", + "shaft compressed between breasts", + "glans and lips visible at the same contact point", + "balls and mouth contact visible", + "tongue contact on the penis clearly visible", + "feet and penis centered in frame", + "soles and shaft contact clearly visible", + "explicit non-penetrative genital contact visible" + ] + } + }, { "name": "Anal and double penetration", "slug": "anal_double_penetration", diff --git a/prompt_builder.py b/prompt_builder.py index 5036fdc..b6a2cf0 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -299,6 +299,7 @@ HARDCORE_POSITION_FAMILY_CHOICES = [ "any", "penetrative", "oral", + "outercourse", "anal", "climax", "threesome", @@ -308,6 +309,7 @@ HARDCORE_POSITION_FOCUS_CHOICES = [ "keep_pool", "penetration_only", "oral_only", + "outercourse_only", "anal_only", "climax_only", "threesome_only", @@ -331,6 +333,10 @@ HARDCORE_POSITION_KEY_CHOICES = [ "straddled_oral", "spread_leg_oral", "chair_oral", + "boobjob", + "testicle_sucking", + "penis_licking", + "footjob", "open_thighs", "front_back", ] @@ -338,6 +344,7 @@ HARDCORE_POSITION_FAMILY_SUBCATEGORIES = { "any": [ "penetrative_sex", "oral_sex", + "outercourse_sex", "anal_double_penetration", "threesomes", "group_sex_orgy", @@ -345,6 +352,7 @@ HARDCORE_POSITION_FAMILY_SUBCATEGORIES = { ], "penetrative": ["penetrative_sex"], "oral": ["oral_sex"], + "outercourse": ["outercourse_sex"], "anal": ["anal_double_penetration"], "climax": ["cumshot_climax"], "threesome": ["threesomes"], @@ -368,6 +376,10 @@ HARDCORE_POSITION_KEY_MATCHES = { "straddled_oral": ("straddled oral",), "spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"), "chair_oral": ("chair oral",), + "boobjob": ("boobjob", "titjob", "breast-sex", "breast sex"), + "testicle_sucking": ("testicle", "balls-licking", "balls licking", "balls and mouth"), + "penis_licking": ("penis-licking", "penis licking", "tongue along", "tongue licking"), + "footjob": ("footjob", "soles", "toes curled", "feet stroking"), "open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"), "front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"), } @@ -993,6 +1005,83 @@ def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]: return values +def _outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]: + position_text = str(position or "").lower() + if not position_text: + return values + + def act_text(value: Any) -> str: + return _entry_text(value).lower() + + def filtered(predicate: Callable[[str], bool]) -> list[Any]: + matches = [value for value in values if predicate(act_text(value))] + return matches or values + + if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + return filtered(lambda text: any(term in text for term in ("boobjob", "titjob", "breast sex", "breasts"))) + if any(term in position_text for term in ("testicle", "balls")): + return filtered(lambda text: any(term in text for term in ("testicle", "balls"))) + if "penis-licking" in position_text or "penis licking" in position_text: + return filtered(lambda text: "licking" in text or "tongue" in text) + if "footjob" in position_text: + return filtered(lambda text: any(term in text for term in ("footjob", "feet", "soles", "toes"))) + return values + + +def _outercourse_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]: + position_text = str(position or "").lower() + if not position_text: + return values + axis_name = str(axis_name or "").lower() + if axis_name not in {"contact_detail", "hand_detail", "texture_detail", "visibility", "body_contact"}: + return values + + def value_text(value: Any) -> str: + return _entry_text(value).lower() + + def filtered(terms: tuple[str, ...]) -> list[Any]: + matches = [value for value in values if any(term in value_text(value) for term in terms)] + return matches or values + + if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + by_axis = { + "contact_detail": ("compressed", "glans", "shaft", "skin", "fingers"), + "hand_detail": ("breast", "breasts", "fingers"), + "texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"), + "visibility": ("breast", "breasts", "glans", "shaft"), + "body_contact": ("torso", "body angled", "shoulders", "hips"), + } + return filtered(by_axis.get(axis_name, ("breast", "breasts", "shaft"))) + if any(term in position_text for term in ("testicle", "balls")): + by_axis = { + "contact_detail": ("balls", "lips", "tongue", "wet"), + "hand_detail": ("balls", "base", "thigh"), + "texture_detail": ("wet", "saliva", "skin"), + "visibility": ("balls", "mouth"), + "body_contact": ("hips", "knees", "thigh", "lower body"), + } + return filtered(by_axis.get(axis_name, ("balls", "mouth", "tongue"))) + if "penis-licking" in position_text or "penis licking" in position_text: + by_axis = { + "contact_detail": ("tongue", "lips", "glans", "shaft", "wet"), + "hand_detail": ("base", "penis", "thigh"), + "texture_detail": ("wet", "saliva", "skin"), + "visibility": ("tongue", "penis"), + "body_contact": ("hips", "body angled", "lower body"), + } + return filtered(by_axis.get(axis_name, ("tongue", "glans", "shaft"))) + if "footjob" in position_text: + by_axis = { + "contact_detail": ("soles", "toes", "shaft"), + "hand_detail": ("ankles", "thighs"), + "texture_detail": ("toes", "soles", "pressure"), + "visibility": ("feet", "soles"), + "body_contact": ("legs", "knees", "body angled"), + } + return filtered(by_axis.get(axis_name, ("feet", "soles", "toes"))) + return values + + def _compose_item( rng: random.Random, category: dict[str, Any], @@ -1008,15 +1097,20 @@ def _compose_item( fields = [key for _, key, _, _ in Formatter().parse(template) if key] unique_fields = list(dict.fromkeys(fields)) axis_values: dict[str, str] = {} - if str(subcategory.get("slug") or "").lower() == "oral_sex" and "position" in unique_fields and axes.get("position"): + subcategory_slug = str(subcategory.get("slug") or "").lower() + if subcategory_slug in ("oral_sex", "outercourse_sex") and "position" in unique_fields and axes.get("position"): position_values = _compatible_entries(axes["position"], women_count, men_count) axis_values["position"] = _entry_text(_weighted_choice(rng, position_values)) for name in unique_fields: if name in axis_values or name not in axes or not axes[name]: continue values = _compatible_entries(axes[name], women_count, men_count) - if str(subcategory.get("slug") or "").lower() == "oral_sex" and name == "oral_act": + if subcategory_slug == "oral_sex" and name == "oral_act": values = _oral_acts_for_position(values, axis_values.get("position", "")) + if subcategory_slug == "outercourse_sex" and name == "outer_act": + values = _outercourse_acts_for_position(values, axis_values.get("position", "")) + if subcategory_slug == "outercourse_sex": + values = _outercourse_axis_values_for_position(values, axis_values.get("position", ""), name) axis_values[name] = _entry_text(_weighted_choice(rng, values)) item_text = _format(template, axis_values).strip() item_name = _item_name(item) or subcategory["name"] @@ -1664,6 +1758,7 @@ def _empty_hardcore_position_config() -> dict[str, Any]: "allow_double": True, "allow_penetration": True, "allow_oral": True, + "allow_outercourse": True, "allow_anal": True, "allow_climax": True, } @@ -1686,7 +1781,7 @@ def _parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[ parsed["family"] = _normalize_hardcore_position_family(parsed.get("family")) parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions")) parsed["require_position"] = not _is_false(parsed.get("require_position", False)) - for key in ("allow_toys", "allow_double", "allow_penetration", "allow_oral", "allow_anal", "allow_climax"): + for key in ("allow_toys", "allow_double", "allow_penetration", "allow_oral", "allow_outercourse", "allow_anal", "allow_climax"): parsed[key] = not _is_false(parsed.get(key, True)) return parsed @@ -1707,6 +1802,7 @@ def _hardcore_position_summary(config: dict[str, Any]) -> str: ("allow_double", "double"), ("allow_penetration", "penetration"), ("allow_oral", "oral"), + ("allow_outercourse", "outercourse"), ("allow_anal", "anal"), ("allow_climax", "climax"), ) @@ -1750,6 +1846,7 @@ def build_hardcore_action_filter_json( allow_double: bool = False, allow_penetration: bool = True, allow_oral: bool = True, + allow_outercourse: bool = True, allow_anal: bool = True, allow_climax: bool = True, ) -> str: @@ -1759,6 +1856,7 @@ def build_hardcore_action_filter_json( focus_family = { "penetration_only": "penetrative", "oral_only": "oral", + "outercourse_only": "outercourse", "anal_only": "anal", "climax_only": "climax", "threesome_only": "threesome", @@ -1770,6 +1868,7 @@ def build_hardcore_action_filter_json( config["allow_double"] = bool(allow_double) config["allow_penetration"] = bool(allow_penetration) config["allow_oral"] = bool(allow_oral) + config["allow_outercourse"] = bool(allow_outercourse) config["allow_anal"] = bool(allow_anal) config["allow_climax"] = bool(allow_climax) @@ -1779,6 +1878,7 @@ def build_hardcore_action_filter_json( for enabled, family in ( (config["allow_penetration"], "penetrative"), (config["allow_oral"], "oral"), + (config["allow_outercourse"], "outercourse"), (config["allow_anal"], "anal"), (config["allow_climax"], "climax"), ) @@ -1790,6 +1890,10 @@ def build_hardcore_action_filter_json( if focus == "oral_only": config["allow_oral"] = True config["allow_penetration"] = False + elif focus == "outercourse_only": + config["allow_outercourse"] = True + config["allow_oral"] = False + config["allow_penetration"] = False elif focus == "anal_only": config["allow_anal"] = True config["allow_penetration"] = True @@ -1820,6 +1924,8 @@ def _hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"}) if not config.get("allow_oral", True): allowed.discard("oral_sex") + if not config.get("allow_outercourse", True): + allowed.discard("outercourse_sex") if not config.get("allow_anal", True): allowed.discard("anal_double_penetration") if not config.get("allow_climax", True): @@ -1876,6 +1982,11 @@ def _hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio")) ): return True + if not config.get("allow_outercourse", True) and ( + axis_name in ("outer_act", "contact_detail", "texture_detail") + or any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex", "testicle", "balls", "penis licking", "penis-licking", "footjob", "soles", "toes")) + ): + return True if not config.get("allow_penetration", True) and ( axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail") or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex")) @@ -4825,6 +4936,7 @@ def _role_graph( subcategory: dict[str, Any], context: dict[str, str], item_axis_values: dict[str, str] | None = None, + pov_labels: list[str] | None = None, ) -> str: if context.get("subject_type") != "configured_cast": return "" @@ -4840,6 +4952,7 @@ def _role_graph( people = participants["people"] slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower() item_text = " ".join((item_axis_values or {}).values()).lower() + pov_set = set(pov_labels or []) def any_person(exclude: set[str] | None = None) -> str: exclude = exclude or set() @@ -4974,6 +5087,66 @@ def _role_graph( return f"{woman} kneels forward with hips raised while {man} kneels behind her and thrusts his penis into her ass." return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass." + def outercourse_position_graph(woman: str, man: str) -> str: + position_text = str((item_axis_values or {}).get("position") or "").lower() + text = " ".join( + str(part or "").lower() + for part in ( + item_text, + *((item_axis_values or {}).values()), + ) + ) + man_is_pov = man in pov_set + if any(term in text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): + if man_is_pov: + return ( + f"{woman} kneels between the POV viewer's thighs with her breasts squeezed around the POV viewer's penis, " + "hands pressing both breasts together around the shaft while the glans stays near her mouth." + ) + return ( + f"{man} sits or reclines with legs apart while {woman} kneels between his thighs, squeezing her breasts " + f"around {man}'s penis with both hands while the glans stays near her mouth." + ) + if any(term in text for term in ("testicle", "balls-licking", "balls licking", "balls and mouth", "balls held")): + if man_is_pov: + return ( + f"{woman} kneels between the POV viewer's thighs with her mouth at the POV viewer's balls, " + "lips and tongue making visible contact while the POV viewer's penis remains above her face." + ) + return ( + f"{man} sits with legs apart while {woman} kneels low between his thighs, sucking and licking his balls " + f"with {man}'s penis visible above her face." + ) + if "penis-licking" in position_text or "penis licking" in text or "tongue along" in text or "tongue licking" in text: + if man_is_pov: + return ( + f"{woman} kneels at the POV viewer's hips with her tongue along the POV viewer's penis, " + "face close to the shaft and glans while her hands steady the base." + ) + return ( + f"{woman} kneels at {man}'s hips with her tongue along {man}'s penis, face close to the shaft and glans " + "while her hands steady the base." + ) + if "footjob" in text or "soles" in text or "toes curled" in text or "feet stroking" in text: + if man_is_pov: + return ( + f"{woman} faces the POV viewer while the POV viewer reclines, wrapping both soles around the POV viewer's penis " + "and stroking the shaft with her feet from a clear first-person view." + ) + return ( + f"{man} reclines with hips forward while {woman} faces him and wraps both soles around {man}'s penis, " + "stroking the shaft with her feet while the contact stays centered." + ) + if man_is_pov: + return ( + f"{woman} kneels close to the POV viewer's hips and keeps the POV viewer's penis centered in clear non-penetrative contact, " + "with her mouth, hands, breasts, or feet visibly working around the shaft." + ) + return ( + f"{woman} kneels close to {man}'s hips and keeps {man}'s penis centered in clear non-penetrative contact, " + "with her mouth, hands, breasts, or feet visibly working around the shaft." + ) + def oral_position_graph(woman: str, man: str) -> str: position_text = str((item_axis_values or {}).get("position") or "").lower() text = " ".join( @@ -5073,7 +5246,9 @@ def _role_graph( a, b = _pick_distinct(rng, women, 2) c = any_woman({a, b}) if len(women) >= 3 else "" used = {a, b} - if "oral" in slug: + if "outercourse" in slug: + graph = f"{a} kneels close to {b}'s body and uses mouth, hands, breasts, or feet for explicit non-penetrative contact." + elif "oral" in slug: graph = f"{a} kneels between {b}'s spread thighs and uses tongue and fingers on her pussy." elif "anal" in slug or "double" in slug: graph = f"{a} uses a strap-on on {b} while keeping her hips held open." @@ -5091,7 +5266,9 @@ def _role_graph( a, b = _pick_distinct(rng, men, 2) c = any_man({a, b}) if len(men) >= 3 else "" used = {a, b} - if "oral" in slug: + if "outercourse" in slug: + graph = f"{a} and {b} keep explicit non-penetrative penis contact visible with hands, mouth, or feet." + elif "oral" in slug: graph = f"{a} kneels and takes {b}'s penis in his mouth while holding his hips." elif "anal" in slug or "double" in slug or "penetrative" in slug: graph = f"{a} penetrates {b} anally while {b}'s hips are held open." @@ -5109,7 +5286,9 @@ def _role_graph( woman = any_woman() man = any_man() third = any_person({woman, man}) if people_count >= 3 else "" - if "oral" in slug: + if "outercourse" in slug: + graph = outercourse_position_graph(woman, man) + elif "oral" in slug: graph = oral_position_graph(woman, man) elif "anal" in slug or "double" in slug: if "double" in item_text or "toy" in item_text: @@ -5534,7 +5713,7 @@ def _build_custom_row( if subject_type == "configured_cast" else [] ) - source_role_graph = _role_graph(role_rng, subcategory, context, item_axis_values) + source_role_graph = _role_graph(role_rng, subcategory, context, item_axis_values, pov_character_labels) if is_pose_category: source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph) role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels)