546 lines
17 KiB
Python
546 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import re
|
|
from typing import Any, Callable
|
|
|
|
try:
|
|
from . import item_axis_policy
|
|
except ImportError: # Allows local smoke tests with top-level imports.
|
|
import item_axis_policy
|
|
|
|
|
|
WOMAN_LOWER_ACCESS_TERMS = (
|
|
"penetrat",
|
|
"thrust",
|
|
"vaginal",
|
|
"anal",
|
|
"rear-entry",
|
|
"rear entry",
|
|
"front-and-back",
|
|
"front and back",
|
|
"double",
|
|
"doggy",
|
|
"missionary",
|
|
"cowgirl",
|
|
"straddles",
|
|
"hips aligned",
|
|
"penis into",
|
|
"penis inside",
|
|
"penis entering",
|
|
"mouth on her pussy",
|
|
"mouth pressed to her pussy",
|
|
"pussy licking",
|
|
"cunnilingus",
|
|
"thighs spread",
|
|
"thighs open",
|
|
"legs spread",
|
|
"legs open",
|
|
"cum on pussy",
|
|
"cum across her pussy",
|
|
"cum dripping from pussy",
|
|
"cum dripping from ass",
|
|
"cum on belly",
|
|
"cum on thighs",
|
|
"cum across her ass",
|
|
"cum across her lower back",
|
|
"toy aligned",
|
|
"second penetration point",
|
|
)
|
|
|
|
WOMAN_UPPER_ACCESS_TERMS = (
|
|
"boobjob",
|
|
"titjob",
|
|
"breast sex",
|
|
"breasts around",
|
|
"breasts tightly",
|
|
"hands pressing both breasts",
|
|
"breasts together",
|
|
"cum on breasts",
|
|
"cum across her breasts",
|
|
"cum on chest",
|
|
)
|
|
|
|
MAN_LOWER_ACCESS_TERMS = (
|
|
"penis",
|
|
"glans",
|
|
"testicle",
|
|
"balls",
|
|
"cumshot",
|
|
"ejaculat",
|
|
"semen",
|
|
"boobjob",
|
|
"titjob",
|
|
"breast sex",
|
|
"footjob",
|
|
"handjob",
|
|
"hand job",
|
|
"hand wrapped",
|
|
"hand stroking",
|
|
"blowjob",
|
|
"fellatio",
|
|
"penis sucking",
|
|
"penis in mouth",
|
|
"mouth on penis",
|
|
"penis licking",
|
|
)
|
|
|
|
LOWER_BODY_CLOTHING_TERMS = (
|
|
"panty",
|
|
"panties",
|
|
"brief",
|
|
"briefs",
|
|
"thong",
|
|
"bottom",
|
|
"bottoms",
|
|
"bodysuit",
|
|
"teddy",
|
|
"dress",
|
|
"skirt",
|
|
"shorts",
|
|
"jeans",
|
|
"trousers",
|
|
"pants",
|
|
"bikini",
|
|
"towel",
|
|
"sheet",
|
|
"blanket",
|
|
)
|
|
|
|
UPPER_BODY_CLOTHING_TERMS = (
|
|
"bra",
|
|
"cup",
|
|
"cups",
|
|
"corset",
|
|
"bodysuit",
|
|
"bustier",
|
|
"top",
|
|
"camisole",
|
|
"shirt",
|
|
"blouse",
|
|
"bodice",
|
|
"dress",
|
|
"robe",
|
|
"jacket",
|
|
"sweater",
|
|
"harness",
|
|
"chest",
|
|
"cleavage",
|
|
"panel",
|
|
"panels",
|
|
)
|
|
|
|
INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS = [
|
|
"wears an open button shirt with jeans lowered below the hips for genital access",
|
|
"wears a fitted tee pushed up with trousers lowered below the hips",
|
|
"keeps a dark shirt on while pants and underwear are pulled down below the hips",
|
|
"wears an open overshirt with jeans pushed down at the thighs",
|
|
"wears a hoodie lifted at the waist with sweatpants lowered below the hips",
|
|
"wears gym shorts pulled down below the hips with his shirt still on",
|
|
"keeps a casual shirt on with belt open and pants lowered below the hips",
|
|
"wears a half-open shirt with lower garments pushed down below the hips",
|
|
]
|
|
|
|
INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE = [
|
|
"wears an open button shirt with jeans unfastened",
|
|
"wears a fitted tee with pants opened at the waist",
|
|
"keeps a dark shirt on with trousers loosened",
|
|
"wears an open overshirt with jeans partly lowered",
|
|
"wears gym shorts loose at the waist with a towel nearby",
|
|
"wears a hoodie lifted at the waist with sweatpants loosened",
|
|
"wears a casual shirt with belt open and pants partly lowered",
|
|
"wears a half-open shirt with dark trousers",
|
|
]
|
|
|
|
|
|
def _clean_pair_punctuation(text: Any) -> str:
|
|
text = re.sub(r"\s+", " ", str(text or "")).strip()
|
|
text = re.sub(r"\s+([,.;:])", r"\1", text)
|
|
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
|
text = re.sub(r"\.\s*\.", ".", text)
|
|
text = re.sub(r":\s*\.", ".", text)
|
|
return text.strip()
|
|
|
|
|
|
def body_exposure_scene_text(scene: Any) -> str:
|
|
text = str(scene or "").strip()
|
|
if not text:
|
|
return ""
|
|
replacements = (
|
|
(r",?\s*\bscattered (?:clothes|clothing)\b", ""),
|
|
(r",?\s*\bfloor clothes\b", ""),
|
|
(r"\bclothes scattered\b", "soft floor shadows"),
|
|
(r",?\s*\bscattered lingerie\b", ""),
|
|
(r",?\s*\blingerie visible nearby\b", ""),
|
|
(r"\boutfit racks\b", "mirror shelves"),
|
|
(r"\bcostume racks\b", "mirror shelves"),
|
|
(r"\bshoe shelves\b", "side shelves"),
|
|
(r"\bshoes visible\b", "body placement visible"),
|
|
(r"\bbag and shoes visible\b", "nearby floor edge visible"),
|
|
(r"\bshoes and bag visible\b", "nearby floor edge visible"),
|
|
(r"\bhanging outfits\b", "hanging fabric"),
|
|
(r"\bclothing hooks\b", "wall hooks"),
|
|
(r"\boutfit-check\b", "creator-shot"),
|
|
(r"\boutfit framing\b", "body framing"),
|
|
(r"\bfull outfits\b", "full bodies"),
|
|
(r"\bcoordinated outfits\b", "coordinated posing"),
|
|
)
|
|
for pattern, replacement in replacements:
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bwith,\s*", "with ", text, flags=re.IGNORECASE)
|
|
text = re.sub(r",\s*,", ",", text)
|
|
return _clean_pair_punctuation(text)
|
|
|
|
|
|
def softcore_outfit_sentence(label: str, outfit: str) -> str:
|
|
outfit = str(outfit or "").strip()
|
|
if not outfit:
|
|
return ""
|
|
lower = outfit.lower()
|
|
if lower.startswith(("wears ", "wearing ", "in ")):
|
|
return f"{label} {outfit}"
|
|
return f"{label} wears {outfit}"
|
|
|
|
|
|
def hardcore_clothing_sentence(label: str, clothing: str) -> str:
|
|
clothing = str(clothing or "").strip().rstrip(".")
|
|
if not clothing:
|
|
return ""
|
|
lower = clothing.lower()
|
|
if lower.startswith(("fully nude", "nude")):
|
|
return f"{label}'s body is fully exposed, bare skin unobstructed"
|
|
if lower.startswith("partly nude"):
|
|
return f"{label}'s body is partly exposed"
|
|
if lower.startswith(("is ", "wears ", "wearing ", "keeps ", "has ", "with ")):
|
|
return f"{label} {clothing}"
|
|
return f"{label}'s clothing: {clothing}"
|
|
|
|
|
|
def character_hardcore_clothing_entries(
|
|
label_map: dict[str, dict[str, Any]],
|
|
women_count: int,
|
|
men_count: int,
|
|
pov_labels: list[str] | None,
|
|
rng: Any,
|
|
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str],
|
|
) -> list[str]:
|
|
pov_set = set(pov_labels or [])
|
|
labels = [
|
|
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
|
|
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
|
|
]
|
|
entries: list[str] = []
|
|
for label in labels:
|
|
if label in pov_set:
|
|
continue
|
|
clothing = slot_hardcore_clothing(label_map.get(label), rng)
|
|
sentence = hardcore_clothing_sentence(label, clothing)
|
|
if sentence:
|
|
entries.append(sentence)
|
|
return entries
|
|
|
|
|
|
def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]:
|
|
axis_values = row.get("item_axis_values")
|
|
axis_text = item_axis_policy.context_text(axis_values=axis_values)
|
|
role_text = " ".join(
|
|
str(part or "")
|
|
for part in (
|
|
row.get("source_role_graph"),
|
|
row.get("role_graph"),
|
|
)
|
|
).lower()
|
|
detail_text = " ".join(
|
|
str(part or "")
|
|
for part in (
|
|
row.get("item"),
|
|
row.get("source_composition"),
|
|
row.get("composition"),
|
|
axis_text,
|
|
)
|
|
).lower()
|
|
full_text = f"{role_text} {detail_text}"
|
|
lower_access_text = f"{role_text} {axis_text}"
|
|
return {
|
|
"woman_lower": any(term in lower_access_text for term in WOMAN_LOWER_ACCESS_TERMS),
|
|
"woman_upper": any(term in full_text for term in WOMAN_UPPER_ACCESS_TERMS),
|
|
"man_lower": any(term in lower_access_text for term in MAN_LOWER_ACCESS_TERMS),
|
|
}
|
|
|
|
|
|
def _outfit_without_lower_body_blockers(outfit: str) -> str:
|
|
_removed, remaining = _outfit_split_by_terms(
|
|
outfit,
|
|
LOWER_BODY_CLOTHING_TERMS,
|
|
replacements=(
|
|
(r"\blingerie set\b", "lingerie top details"),
|
|
(r"\bbrief set\b", "bra set"),
|
|
(r"\bbodysuit with\b", "upper bodysuit detail with"),
|
|
),
|
|
)
|
|
return remaining
|
|
|
|
|
|
def _outfit_without_upper_body_blockers(outfit: str) -> str:
|
|
_removed, remaining = _outfit_split_by_terms(
|
|
outfit,
|
|
UPPER_BODY_CLOTHING_TERMS,
|
|
replacements=(
|
|
(r"\blingerie set\b", "lingerie styling"),
|
|
(r"\bbalconette bra and brief set\b", "briefs and garter styling"),
|
|
),
|
|
)
|
|
return remaining
|
|
|
|
|
|
def _split_outfit_fragments(outfit: str, replacements: tuple[tuple[str, str], ...] = ()) -> list[str]:
|
|
text = str(outfit or "").strip()
|
|
if not text:
|
|
return []
|
|
for pattern, replacement in replacements:
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
fragments = re.split(r"\s*,\s*|\s+\band\b\s+|\s+\bwith\b\s+|\s+\bunder\b\s+|\s+\bover\b\s+", text)
|
|
kept = []
|
|
for fragment in fragments:
|
|
fragment = fragment.strip(" ,.;")
|
|
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
|
|
if not fragment:
|
|
continue
|
|
kept.append(fragment)
|
|
deduped = []
|
|
seen = set()
|
|
for fragment in kept:
|
|
key = re.sub(r"\W+", " ", fragment.lower()).strip()
|
|
if key and key not in seen:
|
|
deduped.append(fragment)
|
|
seen.add(key)
|
|
return deduped
|
|
|
|
|
|
def _join_fragments(fragments: list[str]) -> str:
|
|
return ", ".join(fragment for fragment in fragments if fragment)
|
|
|
|
|
|
def _outfit_split_by_terms(
|
|
outfit: str,
|
|
terms: tuple[str, ...],
|
|
replacements: tuple[tuple[str, str], ...] = (),
|
|
) -> tuple[str, str]:
|
|
removed: list[str] = []
|
|
remaining: list[str] = []
|
|
for fragment in _split_outfit_fragments(outfit, replacements):
|
|
lower = fragment.lower()
|
|
if any(term in lower for term in terms):
|
|
removed.append(fragment)
|
|
else:
|
|
remaining.append(fragment)
|
|
return _join_fragments(removed), _join_fragments(remaining)
|
|
|
|
|
|
def _is_plural_clothing_phrase(text: str) -> bool:
|
|
lower = text.lower()
|
|
if "," in text or " and " in lower:
|
|
return True
|
|
return any(term in lower for term in ("briefs", "panties", "shorts", "jeans", "trousers", "pants", "stockings"))
|
|
|
|
|
|
def _partially_removed_outfit_state(outfit: str, woman_access: str, implied: bool = False) -> str:
|
|
outfit = str(outfit or "").strip()
|
|
if not outfit:
|
|
return "Woman A's body is partly exposed" if implied else "Woman A's outfit is pushed aside where needed"
|
|
if woman_access == "lower":
|
|
removed, remaining = _outfit_split_by_terms(
|
|
outfit,
|
|
LOWER_BODY_CLOTHING_TERMS,
|
|
replacements=(
|
|
(r"\blingerie set\b", "lingerie top details"),
|
|
(r"\bbrief set\b", "bra set"),
|
|
(r"\bbodysuit with\b", "upper bodysuit detail with"),
|
|
),
|
|
)
|
|
verb = "are" if _is_plural_clothing_phrase(removed) else "is"
|
|
lead = (
|
|
f"Woman A's {removed} {verb} pulled aside or removed below the hips"
|
|
if removed
|
|
else "Woman A's lower body is clear, with the outfit pulled aside below the hips"
|
|
)
|
|
if remaining:
|
|
remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains"
|
|
return f"{lead}; {remaining} {remain_verb} visible from the same outfit"
|
|
return lead
|
|
if woman_access == "upper":
|
|
removed, remaining = _outfit_split_by_terms(
|
|
outfit,
|
|
UPPER_BODY_CLOTHING_TERMS,
|
|
replacements=(
|
|
(r"\blingerie set\b", "lingerie styling"),
|
|
(r"\bbalconette bra and brief set\b", "briefs and garter styling"),
|
|
),
|
|
)
|
|
verb = "are" if _is_plural_clothing_phrase(removed) else "is"
|
|
lead = (
|
|
f"Woman A's {removed} {verb} pulled open or pushed aside from her breasts and chest"
|
|
if removed
|
|
else "Woman A's upper body is clear, with the outfit pulled open at the chest"
|
|
)
|
|
if remaining:
|
|
remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains"
|
|
return f"{lead}; {remaining} {remain_verb} visible from the same outfit"
|
|
return lead
|
|
if implied:
|
|
return f"Woman A's {outfit} is loosened and partly slipping off, leaving her body partly exposed"
|
|
return f"Woman A's {outfit} is pushed aside and partly removed where needed"
|
|
|
|
|
|
def hardcore_clothing_state(
|
|
mode: str,
|
|
softcore_outfit: str,
|
|
continuity_map: dict[str, str],
|
|
woman_access: str = "",
|
|
) -> str:
|
|
mode = mode if mode in continuity_map else "none"
|
|
outfit = str(softcore_outfit or "").strip()
|
|
if mode == "none" or not outfit:
|
|
return ""
|
|
base = continuity_map[mode]
|
|
if mode == "explicit_nude":
|
|
return f"Body exposure: {base}."
|
|
if mode == "partially_removed":
|
|
return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access)}."
|
|
if mode == "implied_nude":
|
|
return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access, implied=True)}."
|
|
return f"Clothing state: {base}; teaser outfit detail: {outfit}."
|
|
|
|
|
|
def default_man_hardcore_clothing_entries(
|
|
men_count: int,
|
|
pov_labels: list[str] | None,
|
|
configured_entries: list[str],
|
|
rng: Any,
|
|
needs_lower_access: bool,
|
|
choose: Callable[[Any, list[str]], str],
|
|
) -> list[str]:
|
|
pov_set = set(pov_labels or [])
|
|
configured_labels = {
|
|
match.group(1)
|
|
for entry in configured_entries
|
|
for match in [re.match(r"^\s*(Man [A-Z])\b", str(entry or ""))]
|
|
if match
|
|
}
|
|
pool = INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS if needs_lower_access else INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE
|
|
entries = []
|
|
for index in range(max(0, int(men_count))):
|
|
label = f"Man {chr(ord('A') + index)}"
|
|
if label in pov_set or label in configured_labels:
|
|
continue
|
|
entries.append(hardcore_clothing_sentence(label, choose(rng, pool)))
|
|
return entries
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HardcorePairClothingRoute:
|
|
access_flags: dict[str, bool]
|
|
woman_access: str
|
|
default_man_hardcore_clothing: list[str]
|
|
hardcore_clothing_state: str
|
|
hardcore_clothing_sentence: str
|
|
requires_body_exposure_scene: bool
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"access_flags": dict(self.access_flags),
|
|
"woman_access": self.woman_access,
|
|
"default_man_hardcore_clothing": list(self.default_man_hardcore_clothing),
|
|
"hardcore_clothing_state": self.hardcore_clothing_state,
|
|
"hardcore_clothing_sentence": self.hardcore_clothing_sentence,
|
|
"requires_body_exposure_scene": self.requires_body_exposure_scene,
|
|
}
|
|
|
|
|
|
def resolve_hardcore_pair_clothing_result(
|
|
*,
|
|
hard_row: dict[str, Any],
|
|
mode: str,
|
|
softcore_outfit: str,
|
|
character_hardcore_clothing_entries: list[str],
|
|
men_count: int,
|
|
pov_labels: list[str] | None,
|
|
rng: Any,
|
|
continuity_map: dict[str, str],
|
|
choose: Callable[[Any, list[str]], str],
|
|
) -> HardcorePairClothingRoute:
|
|
access_flags = hardcore_row_access_flags(hard_row)
|
|
woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else ""
|
|
default_man_entries = default_man_hardcore_clothing_entries(
|
|
men_count,
|
|
pov_labels,
|
|
character_hardcore_clothing_entries,
|
|
rng,
|
|
access_flags["man_lower"],
|
|
choose,
|
|
)
|
|
has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries)
|
|
fallback_state = "" if has_primary_hardcore_clothing else hardcore_clothing_state(
|
|
mode,
|
|
softcore_outfit,
|
|
continuity_map,
|
|
woman_access=woman_access,
|
|
)
|
|
hard_clothing_parts = [
|
|
part.strip().rstrip(".")
|
|
for part in (
|
|
fallback_state,
|
|
*character_hardcore_clothing_entries,
|
|
*default_man_entries,
|
|
)
|
|
if str(part or "").strip()
|
|
]
|
|
hard_clothing_state = "; ".join(hard_clothing_parts)
|
|
scene_cleanup_terms = (
|
|
"body is fully exposed",
|
|
"bare skin unobstructed",
|
|
"body is partly exposed",
|
|
"lower body is clear",
|
|
"upper body are clear",
|
|
"pulled aside",
|
|
"removed below the hips",
|
|
"pants and underwear are pulled down",
|
|
)
|
|
hard_clothing_lower = hard_clothing_state.lower()
|
|
return HardcorePairClothingRoute(
|
|
access_flags=access_flags,
|
|
woman_access=woman_access,
|
|
default_man_hardcore_clothing=default_man_entries,
|
|
hardcore_clothing_state=hard_clothing_state,
|
|
hardcore_clothing_sentence=f"{hard_clothing_state}. " if hard_clothing_state else "",
|
|
requires_body_exposure_scene=(
|
|
any(access_flags.values())
|
|
or any(term in hard_clothing_lower for term in scene_cleanup_terms)
|
|
),
|
|
)
|
|
|
|
|
|
def resolve_hardcore_pair_clothing(
|
|
*,
|
|
hard_row: dict[str, Any],
|
|
mode: str,
|
|
softcore_outfit: str,
|
|
character_hardcore_clothing_entries: list[str],
|
|
men_count: int,
|
|
pov_labels: list[str] | None,
|
|
rng: Any,
|
|
continuity_map: dict[str, str],
|
|
choose: Callable[[Any, list[str]], str],
|
|
) -> dict[str, Any]:
|
|
return resolve_hardcore_pair_clothing_result(
|
|
hard_row=hard_row,
|
|
mode=mode,
|
|
softcore_outfit=softcore_outfit,
|
|
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
|
|
men_count=men_count,
|
|
pov_labels=pov_labels,
|
|
rng=rng,
|
|
continuity_map=continuity_map,
|
|
choose=choose,
|
|
).as_dict()
|