Add hardcore outercourse actions

This commit is contained in:
2026-06-25 14:38:32 +02:00
parent 46ccbd3eac
commit 9c86151b89
4 changed files with 333 additions and 7 deletions
+7
View File
@@ -735,6 +735,13 @@ and larger groups.
cast, such as who penetrates, who receives oral, and who joins from the side. cast, such as who penetrates, who receives oral, and who joins from the side.
It is currently most useful for `Hardcore sexual poses`. 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: Subcategories, templates, and axis values can declare cast constraints:
```json ```json
+4
View File
@@ -253,6 +253,7 @@ NODE_INPUT_TOOLTIPS = {
"allow_double": "Allow double-penetration or second-contact wording.", "allow_double": "Allow double-penetration or second-contact wording.",
"allow_penetration": "Allow vaginal/penetrative sex subcategories.", "allow_penetration": "Allow vaginal/penetrative sex subcategories.",
"allow_oral": "Allow oral 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_anal": "Allow anal subcategories.",
"allow_climax": "Allow cumshot/climax aftermath subcategories.", "allow_climax": "Allow cumshot/climax aftermath subcategories.",
}, },
@@ -1784,6 +1785,7 @@ class SxCPHardcoreActionFilter:
"allow_double": ("BOOLEAN", {"default": False}), "allow_double": ("BOOLEAN", {"default": False}),
"allow_penetration": ("BOOLEAN", {"default": True}), "allow_penetration": ("BOOLEAN", {"default": True}),
"allow_oral": ("BOOLEAN", {"default": True}), "allow_oral": ("BOOLEAN", {"default": True}),
"allow_outercourse": ("BOOLEAN", {"default": True}),
"allow_anal": ("BOOLEAN", {"default": True}), "allow_anal": ("BOOLEAN", {"default": True}),
"allow_climax": ("BOOLEAN", {"default": True}), "allow_climax": ("BOOLEAN", {"default": True}),
}, },
@@ -1804,6 +1806,7 @@ class SxCPHardcoreActionFilter:
allow_double, allow_double,
allow_penetration, allow_penetration,
allow_oral, allow_oral,
allow_outercourse,
allow_anal, allow_anal,
allow_climax, allow_climax,
hardcore_position_config="", hardcore_position_config="",
@@ -1815,6 +1818,7 @@ class SxCPHardcoreActionFilter:
allow_double=allow_double, allow_double=allow_double,
allow_penetration=allow_penetration, allow_penetration=allow_penetration,
allow_oral=allow_oral, allow_oral=allow_oral,
allow_outercourse=allow_outercourse,
allow_anal=allow_anal, allow_anal=allow_anal,
allow_climax=allow_climax, allow_climax=allow_climax,
) )
+136
View File
@@ -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", "name": "Anal and double penetration",
"slug": "anal_double_penetration", "slug": "anal_double_penetration",
+186 -7
View File
@@ -299,6 +299,7 @@ HARDCORE_POSITION_FAMILY_CHOICES = [
"any", "any",
"penetrative", "penetrative",
"oral", "oral",
"outercourse",
"anal", "anal",
"climax", "climax",
"threesome", "threesome",
@@ -308,6 +309,7 @@ HARDCORE_POSITION_FOCUS_CHOICES = [
"keep_pool", "keep_pool",
"penetration_only", "penetration_only",
"oral_only", "oral_only",
"outercourse_only",
"anal_only", "anal_only",
"climax_only", "climax_only",
"threesome_only", "threesome_only",
@@ -331,6 +333,10 @@ HARDCORE_POSITION_KEY_CHOICES = [
"straddled_oral", "straddled_oral",
"spread_leg_oral", "spread_leg_oral",
"chair_oral", "chair_oral",
"boobjob",
"testicle_sucking",
"penis_licking",
"footjob",
"open_thighs", "open_thighs",
"front_back", "front_back",
] ]
@@ -338,6 +344,7 @@ HARDCORE_POSITION_FAMILY_SUBCATEGORIES = {
"any": [ "any": [
"penetrative_sex", "penetrative_sex",
"oral_sex", "oral_sex",
"outercourse_sex",
"anal_double_penetration", "anal_double_penetration",
"threesomes", "threesomes",
"group_sex_orgy", "group_sex_orgy",
@@ -345,6 +352,7 @@ HARDCORE_POSITION_FAMILY_SUBCATEGORIES = {
], ],
"penetrative": ["penetrative_sex"], "penetrative": ["penetrative_sex"],
"oral": ["oral_sex"], "oral": ["oral_sex"],
"outercourse": ["outercourse_sex"],
"anal": ["anal_double_penetration"], "anal": ["anal_double_penetration"],
"climax": ["cumshot_climax"], "climax": ["cumshot_climax"],
"threesome": ["threesomes"], "threesome": ["threesomes"],
@@ -368,6 +376,10 @@ HARDCORE_POSITION_KEY_MATCHES = {
"straddled_oral": ("straddled oral",), "straddled_oral": ("straddled oral",),
"spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"), "spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"),
"chair_oral": ("chair oral",), "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"), "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"), "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 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( def _compose_item(
rng: random.Random, rng: random.Random,
category: dict[str, Any], category: dict[str, Any],
@@ -1008,15 +1097,20 @@ def _compose_item(
fields = [key for _, key, _, _ in Formatter().parse(template) if key] fields = [key for _, key, _, _ in Formatter().parse(template) if key]
unique_fields = list(dict.fromkeys(fields)) unique_fields = list(dict.fromkeys(fields))
axis_values: dict[str, str] = {} 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) position_values = _compatible_entries(axes["position"], women_count, men_count)
axis_values["position"] = _entry_text(_weighted_choice(rng, position_values)) axis_values["position"] = _entry_text(_weighted_choice(rng, position_values))
for name in unique_fields: for name in unique_fields:
if name in axis_values or name not in axes or not axes[name]: if name in axis_values or name not in axes or not axes[name]:
continue continue
values = _compatible_entries(axes[name], women_count, men_count) 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", "")) 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)) axis_values[name] = _entry_text(_weighted_choice(rng, values))
item_text = _format(template, axis_values).strip() item_text = _format(template, axis_values).strip()
item_name = _item_name(item) or subcategory["name"] item_name = _item_name(item) or subcategory["name"]
@@ -1664,6 +1758,7 @@ def _empty_hardcore_position_config() -> dict[str, Any]:
"allow_double": True, "allow_double": True,
"allow_penetration": True, "allow_penetration": True,
"allow_oral": True, "allow_oral": True,
"allow_outercourse": True,
"allow_anal": True, "allow_anal": True,
"allow_climax": 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["family"] = _normalize_hardcore_position_family(parsed.get("family"))
parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions")) parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions"))
parsed["require_position"] = not _is_false(parsed.get("require_position", False)) 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)) parsed[key] = not _is_false(parsed.get(key, True))
return parsed return parsed
@@ -1707,6 +1802,7 @@ def _hardcore_position_summary(config: dict[str, Any]) -> str:
("allow_double", "double"), ("allow_double", "double"),
("allow_penetration", "penetration"), ("allow_penetration", "penetration"),
("allow_oral", "oral"), ("allow_oral", "oral"),
("allow_outercourse", "outercourse"),
("allow_anal", "anal"), ("allow_anal", "anal"),
("allow_climax", "climax"), ("allow_climax", "climax"),
) )
@@ -1750,6 +1846,7 @@ def build_hardcore_action_filter_json(
allow_double: bool = False, allow_double: bool = False,
allow_penetration: bool = True, allow_penetration: bool = True,
allow_oral: bool = True, allow_oral: bool = True,
allow_outercourse: bool = True,
allow_anal: bool = True, allow_anal: bool = True,
allow_climax: bool = True, allow_climax: bool = True,
) -> str: ) -> str:
@@ -1759,6 +1856,7 @@ def build_hardcore_action_filter_json(
focus_family = { focus_family = {
"penetration_only": "penetrative", "penetration_only": "penetrative",
"oral_only": "oral", "oral_only": "oral",
"outercourse_only": "outercourse",
"anal_only": "anal", "anal_only": "anal",
"climax_only": "climax", "climax_only": "climax",
"threesome_only": "threesome", "threesome_only": "threesome",
@@ -1770,6 +1868,7 @@ def build_hardcore_action_filter_json(
config["allow_double"] = bool(allow_double) config["allow_double"] = bool(allow_double)
config["allow_penetration"] = bool(allow_penetration) config["allow_penetration"] = bool(allow_penetration)
config["allow_oral"] = bool(allow_oral) config["allow_oral"] = bool(allow_oral)
config["allow_outercourse"] = bool(allow_outercourse)
config["allow_anal"] = bool(allow_anal) config["allow_anal"] = bool(allow_anal)
config["allow_climax"] = bool(allow_climax) config["allow_climax"] = bool(allow_climax)
@@ -1779,6 +1878,7 @@ def build_hardcore_action_filter_json(
for enabled, family in ( for enabled, family in (
(config["allow_penetration"], "penetrative"), (config["allow_penetration"], "penetrative"),
(config["allow_oral"], "oral"), (config["allow_oral"], "oral"),
(config["allow_outercourse"], "outercourse"),
(config["allow_anal"], "anal"), (config["allow_anal"], "anal"),
(config["allow_climax"], "climax"), (config["allow_climax"], "climax"),
) )
@@ -1790,6 +1890,10 @@ def build_hardcore_action_filter_json(
if focus == "oral_only": if focus == "oral_only":
config["allow_oral"] = True config["allow_oral"] = True
config["allow_penetration"] = False config["allow_penetration"] = False
elif focus == "outercourse_only":
config["allow_outercourse"] = True
config["allow_oral"] = False
config["allow_penetration"] = False
elif focus == "anal_only": elif focus == "anal_only":
config["allow_anal"] = True config["allow_anal"] = True
config["allow_penetration"] = 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"}) allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"})
if not config.get("allow_oral", True): if not config.get("allow_oral", True):
allowed.discard("oral_sex") allowed.discard("oral_sex")
if not config.get("allow_outercourse", True):
allowed.discard("outercourse_sex")
if not config.get("allow_anal", True): if not config.get("allow_anal", True):
allowed.discard("anal_double_penetration") allowed.discard("anal_double_penetration")
if not config.get("allow_climax", True): 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")) 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 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 ( if not config.get("allow_penetration", True) and (
axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail") 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")) 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], subcategory: dict[str, Any],
context: dict[str, str], context: dict[str, str],
item_axis_values: dict[str, str] | None = None, item_axis_values: dict[str, str] | None = None,
pov_labels: list[str] | None = None,
) -> str: ) -> str:
if context.get("subject_type") != "configured_cast": if context.get("subject_type") != "configured_cast":
return "" return ""
@@ -4840,6 +4952,7 @@ def _role_graph(
people = participants["people"] people = participants["people"]
slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower() slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower()
item_text = " ".join((item_axis_values or {}).values()).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: def any_person(exclude: set[str] | None = None) -> str:
exclude = exclude or set() 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} 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." 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: def oral_position_graph(woman: str, man: str) -> str:
position_text = str((item_axis_values or {}).get("position") or "").lower() position_text = str((item_axis_values or {}).get("position") or "").lower()
text = " ".join( text = " ".join(
@@ -5073,7 +5246,9 @@ def _role_graph(
a, b = _pick_distinct(rng, women, 2) a, b = _pick_distinct(rng, women, 2)
c = any_woman({a, b}) if len(women) >= 3 else "" c = any_woman({a, b}) if len(women) >= 3 else ""
used = {a, b} 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." 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: elif "anal" in slug or "double" in slug:
graph = f"{a} uses a strap-on on {b} while keeping her hips held open." 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) a, b = _pick_distinct(rng, men, 2)
c = any_man({a, b}) if len(men) >= 3 else "" c = any_man({a, b}) if len(men) >= 3 else ""
used = {a, b} 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." 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: 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." graph = f"{a} penetrates {b} anally while {b}'s hips are held open."
@@ -5109,7 +5286,9 @@ def _role_graph(
woman = any_woman() woman = any_woman()
man = any_man() man = any_man()
third = any_person({woman, man}) if people_count >= 3 else "" 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) graph = oral_position_graph(woman, man)
elif "anal" in slug or "double" in slug: elif "anal" in slug or "double" in slug:
if "double" in item_text or "toy" in item_text: if "double" in item_text or "toy" in item_text:
@@ -5534,7 +5713,7 @@ def _build_custom_row(
if subject_type == "configured_cast" if subject_type == "configured_cast"
else [] 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: if is_pose_category:
source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph) source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph)
role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels) role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels)